@kolkrabbi/kol-component 0.7.0 → 0.8.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 (128) 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/index.js +6 -18
  5. package/src/molecules/ButtonGroup.jsx +45 -0
  6. package/src/molecules/ColorInputRow.jsx +146 -126
  7. package/src/molecules/Slider.jsx +29 -14
  8. package/src/molecules/SplitToolButton.jsx +133 -0
  9. package/src/organisms/ContentFilters.jsx +2 -2
  10. package/src/organisms/GalleryCarousel.jsx +2 -1
  11. package/src/organisms/LoaderOverlay.jsx +11 -14
  12. package/src/organisms/MediaTileGallery.jsx +57 -0
  13. package/src/organisms/MediaViewer.jsx +89 -64
  14. package/src/atoms/PriceDisplay.jsx +0 -34
  15. package/src/atoms/TextPressure.jsx +0 -331
  16. package/src/atoms/TypeSample.jsx +0 -50
  17. package/src/atoms/TypeSpecCard.jsx +0 -42
  18. package/src/chess/ChessPiece.jsx +0 -191
  19. package/src/chess/apparatus/AlternativeControlsMock.jsx +0 -460
  20. package/src/chess/apparatus/ChessAnalysisLayout.jsx +0 -24
  21. package/src/chess/apparatus/ChessBoard.jsx +0 -210
  22. package/src/chess/apparatus/ChessBoardFullscreen.jsx +0 -49
  23. package/src/chess/apparatus/ChessBoardWithControls.jsx +0 -41
  24. package/src/chess/apparatus/ChessBoardWithSidebar.jsx +0 -122
  25. package/src/chess/apparatus/ChessSidebar.jsx +0 -131
  26. package/src/chess/apparatus/GameArchiveTable.jsx +0 -493
  27. package/src/chess/apparatus/GameSelector.jsx +0 -47
  28. package/src/chess/apparatus/NotationPanel.jsx +0 -70
  29. package/src/chess/apparatus/PlaybackControls.jsx +0 -52
  30. package/src/chess/apparatus/VariationTree.jsx +0 -137
  31. package/src/chess/assets/chess-extra-set/set-1/bishop-1.svg +0 -6
  32. package/src/chess/assets/chess-extra-set/set-1/bishop.svg +0 -6
  33. package/src/chess/assets/chess-extra-set/set-1/king-1.svg +0 -9
  34. package/src/chess/assets/chess-extra-set/set-1/king.svg +0 -7
  35. package/src/chess/assets/chess-extra-set/set-1/knight-1.svg +0 -8
  36. package/src/chess/assets/chess-extra-set/set-1/knight.svg +0 -7
  37. package/src/chess/assets/chess-extra-set/set-1/pawn-1.svg +0 -3
  38. package/src/chess/assets/chess-extra-set/set-1/pawn.svg +0 -3
  39. package/src/chess/assets/chess-extra-set/set-1/queen-1.svg +0 -11
  40. package/src/chess/assets/chess-extra-set/set-1/queen.svg +0 -6
  41. package/src/chess/assets/chess-extra-set/set-1/rook-1.svg +0 -7
  42. package/src/chess/assets/chess-extra-set/set-1/rook.svg +0 -11
  43. package/src/chess/assets/chess-extra-set/set-2/bishop-1.svg +0 -4
  44. package/src/chess/assets/chess-extra-set/set-2/bishop.svg +0 -4
  45. package/src/chess/assets/chess-extra-set/set-2/king-1.svg +0 -4
  46. package/src/chess/assets/chess-extra-set/set-2/king.svg +0 -4
  47. package/src/chess/assets/chess-extra-set/set-2/knight-1.svg +0 -5
  48. package/src/chess/assets/chess-extra-set/set-2/knight.svg +0 -5
  49. package/src/chess/assets/chess-extra-set/set-2/pawn-1.svg +0 -4
  50. package/src/chess/assets/chess-extra-set/set-2/pawn.svg +0 -4
  51. package/src/chess/assets/chess-extra-set/set-2/queen-1.svg +0 -4
  52. package/src/chess/assets/chess-extra-set/set-2/queen.svg +0 -4
  53. package/src/chess/assets/chess-extra-set/set-2/rook-1.svg +0 -5
  54. package/src/chess/assets/chess-extra-set/set-2/rook.svg +0 -5
  55. package/src/chess/assets/chess-extra-set/set-3/bishop-1.svg +0 -10
  56. package/src/chess/assets/chess-extra-set/set-3/bishop.svg +0 -3
  57. package/src/chess/assets/chess-extra-set/set-3/king-1.svg +0 -12
  58. package/src/chess/assets/chess-extra-set/set-3/king.svg +0 -3
  59. package/src/chess/assets/chess-extra-set/set-3/knight-1.svg +0 -3
  60. package/src/chess/assets/chess-extra-set/set-3/knight.svg +0 -10
  61. package/src/chess/assets/chess-extra-set/set-3/pawn-1.svg +0 -10
  62. package/src/chess/assets/chess-extra-set/set-3/pawn.svg +0 -10
  63. package/src/chess/assets/chess-extra-set/set-3/queen-1.svg +0 -10
  64. package/src/chess/assets/chess-extra-set/set-3/queen.svg +0 -3
  65. package/src/chess/assets/chess-extra-set/set-3/rook-1.svg +0 -3
  66. package/src/chess/assets/chess-extra-set/set-3/rook.svg +0 -11
  67. package/src/chess/assets/chess-vector-set/ABoard.svg +0 -164
  68. package/src/chess/assets/chess-vector-set/BBiskup.svg +0 -12
  69. package/src/chess/assets/chess-vector-set/BKing.svg +0 -15
  70. package/src/chess/assets/chess-vector-set/BKnight.svg +0 -13
  71. package/src/chess/assets/chess-vector-set/BPawn.svg +0 -7
  72. package/src/chess/assets/chess-vector-set/BQueen.svg +0 -6
  73. package/src/chess/assets/chess-vector-set/BRook.svg +0 -12
  74. package/src/chess/assets/chess-vector-set/WBiskup.svg +0 -14
  75. package/src/chess/assets/chess-vector-set/WKing.svg +0 -13
  76. package/src/chess/assets/chess-vector-set/WKnight.svg +0 -10
  77. package/src/chess/assets/chess-vector-set/WPawn.svg +0 -8
  78. package/src/chess/assets/chess-vector-set/WQueen.svg +0 -17
  79. package/src/chess/assets/chess-vector-set/WRook.svg +0 -13
  80. package/src/chess/context/ChessControlsContext.jsx +0 -379
  81. package/src/chess/dashboards/ChessHero.jsx +0 -303
  82. package/src/chess/index.js +0 -32
  83. package/src/chess/utils/parsePgnTree.js +0 -97
  84. package/src/dashboards/cards/DashAlertCard.jsx +0 -44
  85. package/src/dashboards/cards/DashChartCard.jsx +0 -44
  86. package/src/dashboards/cards/DashFeaturedCard.jsx +0 -42
  87. package/src/dashboards/cards/DashListCard.jsx +0 -87
  88. package/src/dashboards/cards/DashMetricCard.jsx +0 -59
  89. package/src/dashboards/cards/DashSlotCard.jsx +0 -45
  90. package/src/dashboards/cards/DashStackedBarCard.jsx +0 -70
  91. package/src/dashboards/cards/DashTableCard.jsx +0 -31
  92. package/src/dashboards/cards/_shared/CardHeader.jsx +0 -15
  93. package/src/dashboards/cards/_shared/LegendRow.jsx +0 -25
  94. package/src/dashboards/charts/Candlestick.jsx +0 -79
  95. package/src/dashboards/charts/DonutChart.jsx +0 -87
  96. package/src/dashboards/charts/Heatmap.jsx +0 -79
  97. package/src/dashboards/charts/Histogram.jsx +0 -56
  98. package/src/dashboards/charts/LineChart.jsx +0 -157
  99. package/src/dashboards/charts/ScatterPlot.jsx +0 -93
  100. package/src/dashboards/charts/Sparkline.jsx +0 -63
  101. package/src/dashboards/index.js +0 -28
  102. package/src/dashboards/layout/DashboardGrid.jsx +0 -30
  103. package/src/dashboards/layout/GridCard.jsx +0 -44
  104. package/src/dashboards/shared/DashTooltip.jsx +0 -6
  105. package/src/dashboards/shared/formatMetric.js +0 -26
  106. package/src/dashboards/shared/useChartTooltip.js +0 -25
  107. package/src/dashboards/shared/useCountUp.js +0 -107
  108. package/src/molecules/ArticleCard.jsx +0 -178
  109. package/src/molecules/WorkListItem.jsx +0 -83
  110. package/src/molecules/foundry/SpecimenSectionHeader.jsx +0 -89
  111. package/src/organisms/ArticleHeader.jsx +0 -95
  112. package/src/organisms/ColorLoader.jsx +0 -155
  113. package/src/organisms/DiagonalMarqueeRiver.jsx +0 -138
  114. package/src/organisms/ParallaxShelf.jsx +0 -141
  115. package/src/organisms/PortableTextRenderer.jsx +0 -115
  116. package/src/organisms/ProductDetailLayout.jsx +0 -189
  117. package/src/organisms/ScrollDriftGallery.jsx +0 -214
  118. package/src/organisms/StackHero.jsx +0 -83
  119. package/src/organisms/WorkCard.jsx +0 -120
  120. package/src/organisms/WorkViewToggle.jsx +0 -170
  121. package/src/organisms/foundry/FontPreviewSection.jsx +0 -187
  122. package/src/organisms/foundry/FoundryCharacterSets.jsx +0 -113
  123. package/src/organisms/foundry/GlyphMetricsGrid.jsx +0 -335
  124. package/src/organisms/foundry/TypefaceHero.jsx +0 -107
  125. package/src/organisms/foundry/TypefaceStyleSection.jsx +0 -163
  126. package/src/organisms/foundry/VariableFontSection.jsx +0 -158
  127. package/src/organisms/foundry/glyphData.js +0 -30
  128. package/src/organisms/foundry/index.js +0 -21
@@ -2,28 +2,35 @@ import { useEffect, useMemo, useState } from 'react'
2
2
  import Input from '../atoms/Input.jsx'
3
3
 
4
4
  /**
5
- * Slider — range slider with label and an editable value readout.
5
+ * Slider — range slider with label and an editable value readout. The LINEAR
6
+ * VARIANT of RotaryDial (atoms/RotaryDial.jsx): both implement the shared
7
+ * value-control contract — `value` / `min` / `max` / `step` /
8
+ * `onChange(next: number)` / `label` / `size` / `disabled` / `formatValue`.
9
+ * Controlled; onChange always fires with the plain number.
6
10
  *
7
11
  * One bare inline row: label · track · editable readout. (Bordered
8
12
  * `default` and chip `subtle` variants retired 2026-07-08 — minimal is
9
13
  * the only slider.)
10
14
  *
11
15
  * The value readout is an editable <Input> (type a value, commit on
12
- * blur / Enter, revert on Escape). Track color is exposed as the
13
- * `--kol-slider-track` CSS variable on `.slider-black`; override
14
- * per-instance via style={{ '--kol-slider-track': '...' }}.
16
+ * blur / Enter, revert on Escape) a Slider-only extra; RotaryDial's
17
+ * readout is display-only (atoms nest no KOL component). Track color is
18
+ * exposed as the `--kol-slider-track` CSS variable on `.slider-black`;
19
+ * override per-instance via style={{ '--kol-slider-track': '...' }}.
15
20
  *
16
21
  * @param {Object} props
17
22
  * @param {string} props.label - Slider label text
18
- * @param {number} props.min - Minimum value
19
- * @param {number} props.max - Maximum value
20
- * @param {number} props.value - Current value
21
- * @param {Function} props.onChange - Change handler
23
+ * @param {number} props.min - Minimum value (default: 0)
24
+ * @param {number} props.max - Maximum value (default: 100)
25
+ * @param {number} props.value - Controlled value within min–max
26
+ * @param {Function} props.onChange - (next: number) => void change handler
27
+ * @param {number} props.step - Slider step increment (default: 1)
28
+ * @param {number} props.size - Track length in px (mirrors RotaryDial's dial px size); unset = fluid flex-1 track
29
+ * @param {boolean} props.disabled - Disables track + readout and dims the control (default: false)
30
+ * @param {Function} props.formatValue - Optional formatter for displayed value; RotaryDial's default readout is `${value}%`
22
31
  * @param {string} props.className - Additional wrapper classes
23
32
  * @param {number} props.displayWidth - Width of the value readout, in characters (default: 6)
24
33
  * @param {string} props.fontSize - Font size for label/value (e.g., '11px')
25
- * @param {number} props.step - Slider step increment (default: 1)
26
- * @param {Function} props.formatValue - Optional formatter for displayed value
27
34
  */
28
35
  const Slider = ({
29
36
  label,
@@ -31,11 +38,13 @@ const Slider = ({
31
38
  max = 100,
32
39
  value = 0,
33
40
  onChange,
41
+ step = 1,
42
+ size,
43
+ disabled = false,
44
+ formatValue,
34
45
  className = '',
35
46
  displayWidth = 6,
36
47
  fontSize,
37
- step = 1,
38
- formatValue
39
48
  }) => {
40
49
  const handleChange = (e) => {
41
50
  if (onChange) {
@@ -86,7 +95,10 @@ const Slider = ({
86
95
  return (
87
96
  <div className={`control-slider gap-3 shadow-none ${className}`}>
88
97
  {label && (
89
- <label className="kol-helper-12 whitespace-nowrap shrink-0 w-fit" style={fontSize ? { fontSize } : undefined}>
98
+ <label
99
+ className={`kol-helper-12 whitespace-nowrap shrink-0 w-fit ${disabled ? 'opacity-50' : ''}`}
100
+ style={fontSize ? { fontSize } : undefined}
101
+ >
90
102
  {label}
91
103
  </label>
92
104
  )}
@@ -97,7 +109,9 @@ const Slider = ({
97
109
  step={step}
98
110
  value={value}
99
111
  onChange={handleChange}
100
- className="slider-black flex-1 w-full cursor-pointer"
112
+ disabled={disabled}
113
+ className={`slider-black cursor-pointer disabled:cursor-default disabled:opacity-50 ${size ? 'flex-none' : 'flex-1 w-full'}`}
114
+ style={size ? { width: size } : undefined}
101
115
  />
102
116
  <Input
103
117
  type="text"
@@ -106,6 +120,7 @@ const Slider = ({
106
120
  size="sm"
107
121
  chars={displayWidth}
108
122
  value={draft}
123
+ disabled={disabled}
109
124
  onFocus={(e) => { setEditing(true); e.target.select() }}
110
125
  onChange={(e) => setDraft(e.target.value)}
111
126
  onBlur={commit}
@@ -0,0 +1,133 @@
1
+ import { useState } from 'react'
2
+ import { Icon } from '@kolkrabbi/kol-icons'
3
+ import { PopoverPanel, usePopover } from '../atoms/Popover.jsx'
4
+
5
+ /**
6
+ * SplitToolButton — single-trigger split tool button + variant menu (the
7
+ * tool-palette idiom: Select · Text · [Shape ◢] · Pattern). A 28×28
8
+ * quiet/pressed trigger shows the current variant while the group is `active`
9
+ * (else the `lastPicked` variant) plus a corner fold indicator; ONE click both
10
+ * arms that variant (`onChange`) and opens the variant menu (floating-ui's own
11
+ * click handling), so the user can immediately re-pick. Menu rows show ✓ on
12
+ * the active variant, else the variant's shortcut.
13
+ *
14
+ * The trigger is a plain `kol-btn` element, not DS Button: Button doesn't
15
+ * forward refs (the popover must anchor on the real button), can't host the
16
+ * corner fold inside its own box, and `blurOnClick` must blur the button
17
+ * itself. The class output — kol-btn kol-btn-ghost + kol-btn-quiet /
18
+ * kol-btn-pressed — is exactly what `<Button variant="ghost" quiet pressed>`
19
+ * emits, so the visual contract stays Button's.
20
+ *
21
+ * For a text-trigger single-select use `Dropdown`; for the two-button
22
+ * action-half + chevron-half split use `ShapeDropdown`.
23
+ *
24
+ * @param {Object} props
25
+ * @param {{id: string, label: string, icon: string, shortcut?: string}[]} props.variants - Variants: menu rows + trigger glyph
26
+ * @param {string} props.value - Active variant id (controlled)
27
+ * @param {Function} props.onChange - Fires with a variant id — on menu pick, and on trigger click while inactive (arming)
28
+ * @param {string} props.lastPicked - Variant id the trigger shows while the group is inactive (default: variants[0])
29
+ * @param {boolean} props.active - Whether this tool group is the active tool — lit trigger, `aria-pressed`
30
+ * @param {number} props.size - Trigger box in px (default: 28)
31
+ * @param {boolean} props.blurOnClick - Blur the trigger after click so a canvas can reclaim focus and refresh its cursor (default: false)
32
+ * @param {string} props.aria-label - Trigger label fallback when no variant resolves
33
+ * @param {string} props.className - Additional classes on the trigger
34
+ */
35
+
36
+ /* Corner fold marker. `tool-fold-indicator` doesn't exist in kol-icons yet —
37
+ * icon-set candidate; inline until promoted. Lives inside the button so it
38
+ * dims with kol-btn-quiet and inverts with kol-btn-pressed (currentColor). */
39
+ const FoldIndicator = () => (
40
+ <svg
41
+ aria-hidden="true"
42
+ width={4}
43
+ height={4}
44
+ viewBox="0 0 4 4"
45
+ className="pointer-events-none absolute right-0.5 bottom-0.5 opacity-70"
46
+ >
47
+ <path d="M4 0v4H0z" fill="currentColor" />
48
+ </svg>
49
+ )
50
+
51
+ const SplitToolButton = ({
52
+ variants = [],
53
+ value,
54
+ onChange,
55
+ lastPicked,
56
+ active = false,
57
+ size = 28,
58
+ blurOnClick = false,
59
+ className = '',
60
+ 'aria-label': ariaLabel,
61
+ }) => {
62
+ const [open, setOpen] = useState(false)
63
+ const popover = usePopover({
64
+ open,
65
+ onOpenChange: setOpen,
66
+ placement: 'bottom-start',
67
+ offset: 4,
68
+ role: 'menu',
69
+ })
70
+
71
+ const byId = (id) => variants.find((v) => v.id === id)
72
+ const triggerVariant = (active && byId(value)) || byId(lastPicked) || variants[0]
73
+ const label = triggerVariant?.label ?? ariaLabel
74
+ const title = triggerVariant?.shortcut ? `${label} (${triggerVariant.shortcut})` : label
75
+
76
+ /* Arm the last-picked variant on the same click that toggles the menu —
77
+ * floating-ui's useClick (on the reference) handles the open/close half. */
78
+ const handleTriggerClick = (event) => {
79
+ if (!active && triggerVariant) onChange?.(triggerVariant.id)
80
+ if (blurOnClick) event.currentTarget.blur()
81
+ }
82
+
83
+ const handleSelect = (variant) => {
84
+ onChange?.(variant.id)
85
+ setOpen(false)
86
+ }
87
+
88
+ return (
89
+ <>
90
+ <button
91
+ ref={popover.refs.setReference}
92
+ {...popover.getReferenceProps({ onClick: handleTriggerClick })}
93
+ type="button"
94
+ className={`relative kol-btn kol-btn-ghost ${active ? 'kol-btn-pressed' : 'kol-btn-quiet'} ${className}`.trim()}
95
+ style={{ width: size, height: size, padding: 6 }}
96
+ aria-pressed={active}
97
+ aria-label={label}
98
+ title={title}
99
+ >
100
+ {triggerVariant && <Icon name={triggerVariant.icon} size={14} />}
101
+ <FoldIndicator />
102
+ </button>
103
+ <PopoverPanel
104
+ popover={popover}
105
+ panel={false}
106
+ focus={false}
107
+ className="bg-surface-secondary border border-fg-08 rounded shadow-lg"
108
+ >
109
+ {variants.map((variant) => {
110
+ const isActive = active && variant.id === value
111
+ return (
112
+ <button
113
+ key={variant.id}
114
+ type="button"
115
+ onClick={() => handleSelect(variant)}
116
+ className="w-full kol-helper-12 px-3 h-8 inline-flex items-center gap-2 text-body hover:text-emphasis text-left"
117
+ >
118
+ <span className="shrink-0 w-4 inline-flex items-center justify-center">
119
+ <Icon name={variant.icon} size={14} />
120
+ </span>
121
+ <span className="flex-1 truncate">{variant.label}</span>
122
+ <span className="kol-helper-10 text-emphasis shrink-0">
123
+ {isActive ? '✓' : variant.shortcut}
124
+ </span>
125
+ </button>
126
+ )
127
+ })}
128
+ </PopoverPanel>
129
+ </>
130
+ )
131
+ }
132
+
133
+ export default SplitToolButton
@@ -210,7 +210,7 @@ const ContentFilters = ({
210
210
  key={opt.value}
211
211
  onClick={() => handleViewModeChange(opt.value)}
212
212
  className={`kol-helper-14 cursor-pointer select-none ${viewMode === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
213
- style={{ textTransform: 'uppercase', letterSpacing: 1 }}
213
+ style={{ letterSpacing: 1 }}
214
214
  >
215
215
  {opt.label}
216
216
  </span>
@@ -245,7 +245,7 @@ const ContentFilters = ({
245
245
  key={opt.value}
246
246
  onClick={() => setLayout(opt.value)}
247
247
  className={`kol-helper-12 cursor-pointer select-none ${layout === opt.value ? 'text-fg-96' : 'text-fg-32 hover:text-fg-48'}`}
248
- style={{ textTransform: 'uppercase', letterSpacing: 1 }}
248
+ style={{ letterSpacing: 1 }}
249
249
  >
250
250
  {opt.label}
251
251
  </span>
@@ -75,7 +75,8 @@ export default function GalleryCarousel({ media = [], title = '', defaultAspect
75
75
  <MediaViewer
76
76
  open={viewerIndex !== null}
77
77
  media={media}
78
- initialIndex={viewerIndex ?? 0}
78
+ index={viewerIndex ?? 0}
79
+ onIndexChange={setViewerIndex}
79
80
  onClose={() => setViewerIndex(null)}
80
81
  />
81
82
  </section>
@@ -1,30 +1,27 @@
1
1
  import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
2
- import ColorLoader from './ColorLoader.jsx'
3
2
 
4
3
  /**
5
4
  * LoaderOverlay — mounts a loading curtain over everything, dead center. A thin
6
5
  * composition: it wraps DS FullscreenOverlay for the fixed, top-`z`,
7
6
  * scroll-locking backdrop (replacing the app's hand-rolled `fixed inset-0 …
8
- * z-[100]` div and its `CursorProvider`), then renders `children` — or a
9
- * default ColorLoader — as the overlay content. Being mounted IS being visible;
7
+ * z-[100]` div and its `CursorProvider`), then renders `children` — or the
8
+ * `loader` slot — as the overlay content. Being mounted IS being visible;
10
9
  * the parent removes this element to dismiss.
11
10
  *
12
- * The default ColorLoader is a full-viewport `dismissOnClick` curtain whose
13
- * completion is wired to `onEnter`. A `fixed inset-0` box escapes
11
+ * The loader is a SLOT, not a built-in: inject a curtain (e.g. `<ColorLoader/>`
12
+ * from `@kolkrabbi/kol-foundry`) via `loader` and wire its completion callback
13
+ * yourself. The slot is mounted in a `fixed inset-0` box that escapes
14
14
  * FullscreenOverlay's centered, `--kol-container-max`-width sheet so the loader
15
- * fills the screen. Pass `children` to drop that and mount anything else.
15
+ * fills the screen. Pass `children` instead to mount anything else, centered.
16
+ * With neither, the loader slot renders nothing (the overlay still works).
16
17
  *
17
- * @param {ReactNode} children overlay content; defaults to a full-screen ColorLoader
18
- * @param {() => void} onEnter fired when the default ColorLoader completes/dismisses
18
+ * @param {ReactNode} children overlay content, centered (takes precedence)
19
+ * @param {ReactNode} loader full-screen loading curtain, e.g. foundry's ColorLoader
19
20
  */
20
- export default function LoaderOverlay({ children, onEnter }) {
21
+ export default function LoaderOverlay({ children, loader }) {
21
22
  return (
22
23
  <FullscreenOverlay open>
23
- {children || (
24
- <div className="fixed inset-0">
25
- <ColorLoader onComplete={onEnter} dismissOnClick />
26
- </div>
27
- )}
24
+ {children || (loader ? <div className="fixed inset-0">{loader}</div> : null)}
28
25
  </FullscreenOverlay>
29
26
  )
30
27
  }
@@ -0,0 +1,57 @@
1
+ import { useState } from 'react'
2
+ import MediaViewer from './MediaViewer.jsx'
3
+
4
+ /**
5
+ * MediaTileGallery — a stack or grid of framed media tiles that open THE
6
+ * shared fullscreen MediaViewer at the clicked tile, paged across all items
7
+ * (the monorepo's FullscreenGallery recreated on the shared viewer instead of
8
+ * its nav-less single image; the AssetFigure/AssetGrid deps are dropped —
9
+ * tile and grid are inlined).
10
+ *
11
+ * Tiles are real <button>s, so Space + Enter activation comes free. Captions
12
+ * render verbatim — authored case, no auto-casing.
13
+ *
14
+ * @param {Array} items [{ url, alt?, kind?: 'image' | 'video', caption? }] — the viewer's media shape
15
+ * @param {string} layout 'stack' (flow column, default) | 'grid'
16
+ * @param {number} cols grid column count (grid layout only)
17
+ * @param {string} tileClassName extra classes merged onto each tile button
18
+ */
19
+ export default function MediaTileGallery({ items = [], layout = 'stack', cols = 4, tileClassName = '' }) {
20
+ const [current, setCurrent] = useState(null)
21
+
22
+ if (!items.length) return null
23
+
24
+ const isGrid = layout === 'grid'
25
+
26
+ return (
27
+ <>
28
+ <div
29
+ className={isGrid ? 'grid gap-4' : 'flex flex-col gap-4'}
30
+ style={isGrid ? { gridTemplateColumns: `repeat(${cols}, minmax(0, 1fr))` } : undefined}
31
+ >
32
+ {items.map((item, i) => (
33
+ <figure key={i} className="min-w-0">
34
+ <button
35
+ type="button"
36
+ className={`block w-full cursor-zoom-in overflow-hidden rounded border border-fg-08 bg-fg-04 ${tileClassName}`.trim()}
37
+ onClick={() => setCurrent(i)}
38
+ >
39
+ <img src={item.url} alt={item.alt || ''} loading="lazy" className="block h-auto w-full" />
40
+ </button>
41
+ {item.caption && (
42
+ <figcaption className="kol-helper-12 text-meta mt-2">{item.caption}</figcaption>
43
+ )}
44
+ </figure>
45
+ ))}
46
+ </div>
47
+
48
+ <MediaViewer
49
+ open={current != null}
50
+ media={items}
51
+ index={current ?? 0}
52
+ onIndexChange={setCurrent}
53
+ onClose={() => setCurrent(null)}
54
+ />
55
+ </>
56
+ )
57
+ }
@@ -1,4 +1,5 @@
1
- import { useEffect, useRef, useState } from 'react'
1
+ import { useEffect, useState } from 'react'
2
+ import useEmblaCarousel from 'embla-carousel-react'
2
3
  import { Icon } from '@kolkrabbi/kol-icons'
3
4
  import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
4
5
 
@@ -7,89 +8,113 @@ import FullscreenOverlay from '../atoms/FullscreenOverlay.jsx'
7
8
  * lightbox/gallery opener (the monorepo's ImageLightbox, FullscreenGallery
8
9
  * and WorkDetail's inline gallery all collapse onto this).
9
10
  *
10
- * The shell (scrim, scroll-lock, Escape, backdrop-close, close button) is
11
- * FullscreenOverlay not reimplemented. This adds paging: prev/next chips,
12
- * arrow keys, touch swipe, wrap-around.
11
+ * Composed, not forked: the shell (scrim, scroll-lock, Escape, backdrop-close,
12
+ * close button, stacking) is FullscreenOverlay; paging (touch swipe, snap,
13
+ * wrap-around) is embla — the same core Carousel wraps, wired directly here
14
+ * because Carousel's API has no controlled index and its slide chrome is
15
+ * strip-width, not full-stage. ArrowLeft/ArrowRight bind to scrollPrev/Next.
13
16
  *
14
- * ponytail: paging is ~20 lines of state + swipe, so it's inline rather than
15
- * composing Carousel embla's drag physics buy nothing for a one-item stage;
16
- * revisit if momentum/preview-of-next is ever wanted.
17
+ * Controlled: the parent owns `index`. Embla's select event reports back
18
+ * through `onIndexChange`; an external `index` change scrolls the stage.
17
19
  *
18
20
  * @param {boolean} open viewer visible
19
- * @param {Array} media [{ url, alt?, kind: 'image' | 'video' }]
20
- * @param {number} initialIndex item shown on open
21
- * @param {Function} onClose close request
21
+ * @param {Array} media [{ url, alt?, kind: 'image' | 'video', caption? }]
22
+ * @param {number} index active item index (parent-owned)
22
23
  * @param {Function} onIndexChange fires with the new index on page
24
+ * @param {Function} onClose close request (Esc, backdrop, close button)
23
25
  */
24
- export default function MediaViewer({ open, media = [], initialIndex = 0, onClose, onIndexChange }) {
25
- const [index, setIndex] = useState(initialIndex)
26
- const touchStart = useRef(null)
27
26
 
28
- // re-anchor to initialIndex each time the viewer opens
27
+ /* Inverse-tier chip tokens: the overlay scrim is surface-inverse, and .kol-overlay
28
+ * carries no surface-context class, so the standard fg ramp doesn't flip there. */
29
+ const CHIP =
30
+ 'kol-embla-btn absolute top-1/2 z-10 hidden -translate-y-1/2 items-center justify-center border border-fg-inverse-16 text-inverse hover:border-fg-inverse-32 md:flex'
31
+
32
+ /* Mounted only while the overlay is open, so embla initializes fresh each open
33
+ * with `startIndex` frozen at mount — no reInit games on later index changes. */
34
+ function ViewerStage({ media, index, onIndexChange }) {
35
+ const [options] = useState(() => ({ loop: true, startIndex: index }))
36
+ const [emblaRef, emblaApi] = useEmblaCarousel(options)
37
+
38
+ // embla → parent: keep the parent authoritative over the active index
29
39
  useEffect(() => {
30
- if (open) setIndex(initialIndex)
31
- }, [open, initialIndex])
40
+ if (!emblaApi) return
41
+ const onSelect = () => onIndexChange?.(emblaApi.selectedScrollSnap())
42
+ emblaApi.on('select', onSelect)
43
+ return () => emblaApi.off('select', onSelect)
44
+ }, [emblaApi, onIndexChange])
32
45
 
33
- const page = (delta) => {
34
- if (media.length < 2) return
35
- setIndex((i) => {
36
- const next = (i + delta + media.length) % media.length
37
- onIndexChange?.(next)
38
- return next
39
- })
40
- }
46
+ // parent embla: external index changes scroll the stage
47
+ useEffect(() => {
48
+ if (emblaApi && emblaApi.selectedScrollSnap() !== index) emblaApi.scrollTo(index)
49
+ }, [emblaApi, index])
41
50
 
51
+ // Escape lives in FullscreenOverlay; the viewer only adds arrow paging
42
52
  useEffect(() => {
43
- if (!open) return
53
+ if (!emblaApi) return
44
54
  const onKey = (e) => {
45
- if (e.key === 'ArrowLeft') page(-1)
46
- if (e.key === 'ArrowRight') page(1)
55
+ if (e.key === 'ArrowLeft') emblaApi.scrollPrev()
56
+ if (e.key === 'ArrowRight') emblaApi.scrollNext()
47
57
  }
48
58
  document.addEventListener('keydown', onKey)
49
59
  return () => document.removeEventListener('keydown', onKey)
50
- }, [open, media.length]) // eslint-disable-line react-hooks/exhaustive-deps
51
-
52
- const item = media[index]
53
- if (!open || !item) return null
54
-
55
- const onTouchStart = (e) => { touchStart.current = e.touches[0].clientX }
56
- const onTouchEnd = (e) => {
57
- if (touchStart.current == null) return
58
- const delta = e.changedTouches[0].clientX - touchStart.current
59
- if (delta > 50) page(-1)
60
- if (delta < -50) page(1)
61
- touchStart.current = null
62
- }
63
-
64
- const chip = 'absolute top-1/2 -translate-y-1/2 z-10 hidden md:flex items-center justify-center w-10 h-10 rounded-full bg-fg-04 hover:bg-fg-08 transition-colors cursor-pointer border-0'
60
+ }, [emblaApi])
65
61
 
66
62
  return (
67
- <FullscreenOverlay open={open} onClose={onClose}>
68
- <div className="relative flex h-full w-full items-center justify-center" onTouchStart={onTouchStart} onTouchEnd={onTouchEnd}>
69
- {media.length > 1 && (
70
- <button type="button" className={`${chip} left-4`} aria-label="Previous" onClick={() => page(-1)}>
63
+ <div className="relative w-full">
64
+ <div className="overflow-hidden" ref={emblaRef}>
65
+ <div className="flex items-center">
66
+ {media.map((item, i) => (
67
+ <figure key={i} className="flex min-w-0 flex-[0_0_100%] flex-col items-center justify-center gap-3">
68
+ {item.kind === 'video' ? (
69
+ <video
70
+ src={item.url}
71
+ autoPlay
72
+ muted
73
+ playsInline
74
+ loop
75
+ className="max-h-[90vh] max-w-full object-contain"
76
+ />
77
+ ) : (
78
+ <img src={item.url} alt={item.alt || ''} className="max-h-[90vh] max-w-full object-contain" />
79
+ )}
80
+ {item.caption && (
81
+ <figcaption className="kol-helper-12 text-fg-inverse-64">{item.caption}</figcaption>
82
+ )}
83
+ </figure>
84
+ ))}
85
+ </div>
86
+ </div>
87
+
88
+ {media.length > 1 && (
89
+ <>
90
+ <button
91
+ type="button"
92
+ className={`${CHIP} left-4`}
93
+ aria-label="Previous"
94
+ onClick={(e) => { e.stopPropagation(); emblaApi?.scrollPrev() }}
95
+ >
71
96
  <Icon name="chevron-left" size={20} />
72
97
  </button>
73
- )}
74
- {item.kind === 'video' ? (
75
- <video
76
- key={item.url}
77
- src={item.url}
78
- autoPlay
79
- muted
80
- loop
81
- playsInline
82
- className="max-h-[85vh] max-w-full object-contain"
83
- />
84
- ) : (
85
- <img src={item.url} alt={item.alt || ''} className="max-h-[85vh] max-w-full object-contain" />
86
- )}
87
- {media.length > 1 && (
88
- <button type="button" className={`${chip} right-4`} aria-label="Next" onClick={() => page(1)}>
98
+ <button
99
+ type="button"
100
+ className={`${CHIP} right-4`}
101
+ aria-label="Next"
102
+ onClick={(e) => { e.stopPropagation(); emblaApi?.scrollNext() }}
103
+ >
89
104
  <Icon name="chevron-right" size={20} />
90
105
  </button>
91
- )}
92
- </div>
106
+ </>
107
+ )}
108
+ </div>
109
+ )
110
+ }
111
+
112
+ export default function MediaViewer({ open, media = [], index = 0, onIndexChange, onClose }) {
113
+ if (!open || !media[index]) return null
114
+
115
+ return (
116
+ <FullscreenOverlay open onClose={onClose}>
117
+ <ViewerStage media={media} index={index} onIndexChange={onIndexChange} />
93
118
  </FullscreenOverlay>
94
119
  )
95
120
  }
@@ -1,34 +0,0 @@
1
- /**
2
- * PriceDisplay — baseline-aligned price line: a large primary amount beside a
3
- * small muted secondary note (a second-currency echo or a cost breakdown).
4
- * Formats `amount` via Intl.NumberFormat in whole units (prints are priced in
5
- * round numbers); `secondary` is authored at the call site, parens and all —
6
- * omit it to render the primary alone.
7
- *
8
- * Presentational — pure scalars in, formatted currency out. Seed atom for the
9
- * price/sale/edition family.
10
- *
11
- * @param {number} amount primary price, run through the currency formatter
12
- * @param {string} currency ISO 4217 code for `amount` (e.g. 'EUR', 'ISK')
13
- * @param {string} locale number-format locale
14
- * @param {ReactNode} secondary muted trailing note (omit to hide)
15
- */
16
- export default function PriceDisplay({
17
- amount,
18
- currency = 'EUR',
19
- locale = 'de-DE',
20
- secondary,
21
- }) {
22
- const formatted = new Intl.NumberFormat(locale, {
23
- style: 'currency',
24
- currency,
25
- maximumFractionDigits: 0,
26
- }).format(amount)
27
-
28
- return (
29
- <div className="flex flex-wrap items-baseline gap-3">
30
- <span className="kol-sans-heading-03">{formatted}</span>
31
- {secondary != null && <span className="kol-mono-12 text-fg-48">{secondary}</span>}
32
- </div>
33
- )
34
- }