@kolkrabbi/kol-component 0.1.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 (98) hide show
  1. package/README.md +24 -0
  2. package/package.json +46 -0
  3. package/src/atoms/Avatar.jsx +17 -0
  4. package/src/atoms/Button.jsx +172 -0
  5. package/src/atoms/ColorSwatch.jsx +126 -0
  6. package/src/atoms/Divider.jsx +34 -0
  7. package/src/atoms/Input.jsx +121 -0
  8. package/src/atoms/Label.jsx +12 -0
  9. package/src/atoms/Slider.jsx +129 -0
  10. package/src/atoms/Stepper.jsx +97 -0
  11. package/src/atoms/Textarea.jsx +70 -0
  12. package/src/atoms/ToggleCheckbox.jsx +40 -0
  13. package/src/atoms/ToggleSwitch.jsx +42 -0
  14. package/src/atoms/TransparentX.jsx +37 -0
  15. package/src/graphics/Graphic.jsx +53 -0
  16. package/src/graphics/svg/abstract/abstract-01.svg +3 -0
  17. package/src/graphics/svg/abstract/abstract-02.svg +3 -0
  18. package/src/graphics/svg/abstract/abstract-03.svg +3 -0
  19. package/src/graphics/svg/abstract/abstract-06.svg +4 -0
  20. package/src/graphics/svg/diagrams/.gitkeep +0 -0
  21. package/src/graphics/svg/diagrams/ac-structure.svg +90 -0
  22. package/src/graphics/svg/diagrams/rg-x-height.svg +3 -0
  23. package/src/graphics/svg/diagrams/structure-grid.svg +618 -0
  24. package/src/graphics/svg/diagrams/structure-logo.svg +23 -0
  25. package/src/graphics/svg/patterns/.gitkeep +0 -0
  26. package/src/graphics/svg/patterns/patt-01.svg +34 -0
  27. package/src/graphics/svg/patterns/patt-02.svg +34 -0
  28. package/src/graphics/svg/patterns/patt-03.svg +110 -0
  29. package/src/graphics/svg/patterns/patt-04.svg +10 -0
  30. package/src/graphics/svg/patterns/patt-05.svg +62 -0
  31. package/src/graphics/svg/patterns/patt-06.svg +66 -0
  32. package/src/graphics/svg/patterns/patt-07.svg +130 -0
  33. package/src/graphics/svg/patterns/patt-08.svg +434 -0
  34. package/src/graphics/svg/patterns/patt-09.svg +38 -0
  35. package/src/graphics/svg/patterns/patt-10.svg +20 -0
  36. package/src/graphics/svg/patterns/patt-11.svg +38 -0
  37. package/src/graphics/svg/patterns/patt-12.svg +58 -0
  38. package/src/graphics/svg/patterns/patt-13.svg +48 -0
  39. package/src/graphics/svg/patterns/patt-14.svg +90 -0
  40. package/src/graphics/svg/patterns/patt-15.svg +194 -0
  41. package/src/graphics/svg/patterns/patt-16.svg +12 -0
  42. package/src/graphics/svg/social/social-01.svg +18 -0
  43. package/src/graphics/svg/social/social-02.svg +18 -0
  44. package/src/graphics/svg/social/social-03.svg +18 -0
  45. package/src/graphics/svg/social/social-04.svg +18 -0
  46. package/src/graphics/svg/social/social-05.svg +18 -0
  47. package/src/graphics/svg/social/social-06.svg +18 -0
  48. package/src/graphics/svg/social/social-07.svg +22 -0
  49. package/src/graphics/svg/social/social-08.svg +22 -0
  50. package/src/graphics/svg/social/social-09.svg +22 -0
  51. package/src/graphics/svg/social/social-10.svg +6 -0
  52. package/src/graphics/svg/social/social-11.svg +6 -0
  53. package/src/graphics/svg/social/social-12.svg +6 -0
  54. package/src/graphics/svg/social/social-13.svg +9 -0
  55. package/src/graphics/svg/social/social-14.svg +9 -0
  56. package/src/graphics/svg/social/social-15.svg +9 -0
  57. package/src/graphics/svg/structure/diagram-grid-lockup-hori.svg +23 -0
  58. package/src/graphics/svg/structure/diagram-grid-lockup-vert.svg +25 -0
  59. package/src/graphics/svg/structure/diagram-grid-logomark.svg +20 -0
  60. package/src/graphics/svg/structure/diagram-grid-wordmark.svg +18 -0
  61. package/src/graphics/svg/structure/diagram-logo-lockup-hori.svg +9 -0
  62. package/src/graphics/svg/structure/diagram-logo-lockup-vert.svg +23 -0
  63. package/src/graphics/svg/structure/diagram-logo-logomark.svg +7 -0
  64. package/src/graphics/svg/structure/diagram-logo-wordmark.svg +3 -0
  65. package/src/graphics/svg/structure/diagram-x-lockup-hori.svg +5 -0
  66. package/src/graphics/svg/structure/diagram-x-lockup-vert.svg +10 -0
  67. package/src/graphics/svg/structure/diagram-x-logomark.svg +5 -0
  68. package/src/graphics/svg/structure/diagram-x-wordmark.svg +5 -0
  69. package/src/hooks/useReveal.js +28 -0
  70. package/src/hooks/useScrollSpy.js +56 -0
  71. package/src/index.js +59 -0
  72. package/src/molecules/Badge.jsx +51 -0
  73. package/src/molecules/ContentFilters.jsx +263 -0
  74. package/src/molecules/Dropdown.jsx +223 -0
  75. package/src/molecules/DropdownTagFilter.jsx +253 -0
  76. package/src/molecules/LabeledControl.jsx +66 -0
  77. package/src/molecules/MenuItem.jsx +148 -0
  78. package/src/molecules/MenuPopover.jsx +128 -0
  79. package/src/molecules/Modal.jsx +124 -0
  80. package/src/molecules/Pill.jsx +33 -0
  81. package/src/molecules/Popover.jsx +208 -0
  82. package/src/molecules/PropertyInput.jsx +32 -0
  83. package/src/molecules/QuantityInput.jsx +174 -0
  84. package/src/molecules/QuantityStepper.jsx +149 -0
  85. package/src/molecules/Section.jsx +16 -0
  86. package/src/molecules/SectionLabel.jsx +59 -0
  87. package/src/molecules/SegmentedToggle.jsx +56 -0
  88. package/src/molecules/Tag.jsx +79 -0
  89. package/src/molecules/ToggleBracket.jsx +45 -0
  90. package/src/molecules/ViewToggle.jsx +101 -0
  91. package/src/organisms/Table.jsx +46 -0
  92. package/src/primitives/Accordion.jsx +45 -0
  93. package/src/primitives/AssetPlaceholder.jsx +28 -0
  94. package/src/primitives/Carousel.jsx +50 -0
  95. package/src/primitives/CodeBlock.jsx +41 -0
  96. package/src/primitives/ExitPreview.jsx +12 -0
  97. package/src/primitives/FullscreenOverlay.jsx +39 -0
  98. package/src/primitives/Image.jsx +38 -0
@@ -0,0 +1,149 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ // Match Dropdown SIZE_MAP for consistent height
4
+ const SIZE_MAP = {
5
+ sm: { fontSize: 11, paddingY: 12, paddingX: 24, radius: 20 },
6
+ md: { fontSize: 12, paddingY: 14, paddingX: 24, radius: 22 },
7
+ lg: { fontSize: 14, paddingY: 16, paddingX: 24, radius: 24 }
8
+ }
9
+
10
+ const QuantityStepper = ({
11
+ value = 1,
12
+ onChange,
13
+ min = 1,
14
+ max = 10,
15
+ size,
16
+ className = ''
17
+ }) => {
18
+ const [resolvedSize, setResolvedSize] = useState('md')
19
+ const [componentWidth, setComponentWidth] = useState('180px')
20
+
21
+ // Responsive size
22
+ useEffect(() => {
23
+ const determineSize = () => {
24
+ if (size) {
25
+ setResolvedSize(size)
26
+ return
27
+ }
28
+
29
+ if (typeof window === 'undefined') {
30
+ setResolvedSize('md')
31
+ return
32
+ }
33
+
34
+ if (window.innerWidth >= 1024) {
35
+ setResolvedSize('lg')
36
+ } else if (window.innerWidth >= 768) {
37
+ setResolvedSize('md')
38
+ } else {
39
+ setResolvedSize('sm')
40
+ }
41
+ }
42
+
43
+ determineSize()
44
+ window.addEventListener('resize', determineSize)
45
+ return () => window.removeEventListener('resize', determineSize)
46
+ }, [size])
47
+
48
+ // Width management - match Dropdown
49
+ useEffect(() => {
50
+ const updateWidth = () => {
51
+ if (typeof window === 'undefined') return
52
+
53
+ if (window.innerWidth >= 1024) {
54
+ setComponentWidth('180px')
55
+ } else if (window.innerWidth >= 768) {
56
+ setComponentWidth('140px')
57
+ } else {
58
+ setComponentWidth('100px')
59
+ }
60
+ }
61
+ updateWidth()
62
+ window.addEventListener('resize', updateWidth)
63
+ return () => window.removeEventListener('resize', updateWidth)
64
+ }, [])
65
+
66
+ const metrics = SIZE_MAP[resolvedSize] || SIZE_MAP.md
67
+
68
+ const decrement = () => {
69
+ if (value > min) onChange?.(value - 1)
70
+ }
71
+
72
+ const increment = () => {
73
+ if (value < max) onChange?.(value + 1)
74
+ }
75
+
76
+ const buttonStyle = {
77
+ padding: `${metrics.paddingY}px 12px`,
78
+ display: 'flex',
79
+ alignItems: 'center',
80
+ justifyContent: 'center',
81
+ backgroundColor: 'transparent',
82
+ border: 'none',
83
+ color: 'var(--kol-surface-on-primary)',
84
+ cursor: 'pointer',
85
+ transition: 'opacity 0.15s',
86
+ fontFamily: 'var(--kol-font-family-mono)',
87
+ fontSize: `${metrics.fontSize}px`,
88
+ lineHeight: '120%'
89
+ }
90
+
91
+ const disabledStyle = {
92
+ opacity: 0.3,
93
+ cursor: 'not-allowed'
94
+ }
95
+
96
+ return (
97
+ <div
98
+ className={`flex items-center justify-center ${className}`}
99
+ style={{
100
+ width: componentWidth,
101
+ minWidth: componentWidth,
102
+ border: '1px solid var(--kol-border-default)',
103
+ borderRadius: `${metrics.radius}px`,
104
+ backgroundColor: 'var(--kol-surface-primary)'
105
+ }}
106
+ >
107
+ <button
108
+ type="button"
109
+ onClick={decrement}
110
+ disabled={value <= min}
111
+ style={{
112
+ ...buttonStyle,
113
+ ...(value <= min ? disabledStyle : {})
114
+ }}
115
+ aria-label="Decrease quantity"
116
+ >
117
+
118
+ </button>
119
+
120
+ <span
121
+ style={{
122
+ minWidth: '24px',
123
+ textAlign: 'center',
124
+ color: 'var(--kol-surface-on-primary)',
125
+ fontFamily: 'var(--kol-font-family-mono)',
126
+ fontSize: `${metrics.fontSize}px`,
127
+ lineHeight: '120%'
128
+ }}
129
+ >
130
+ {value}
131
+ </span>
132
+
133
+ <button
134
+ type="button"
135
+ onClick={increment}
136
+ disabled={value >= max}
137
+ style={{
138
+ ...buttonStyle,
139
+ ...(value >= max ? disabledStyle : {})
140
+ }}
141
+ aria-label="Increase quantity"
142
+ >
143
+ +
144
+ </button>
145
+ </div>
146
+ )
147
+ }
148
+
149
+ export default QuantityStepper
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Section — labeled control group for inspector/editor panels.
3
+ *
4
+ * A small-caps label above a vertical content stack. Used across the editor
5
+ * inspector panels (palette / pattern / type modes): `<Section label="Aspect">…</Section>`.
6
+ */
7
+ export default function Section({ label, children, className = '' }) {
8
+ return (
9
+ <div className={`flex flex-col gap-2 ${className}`}>
10
+ {label && (
11
+ <p className="kol-helper-10 uppercase tracking-widest text-meta">{label}</p>
12
+ )}
13
+ {children}
14
+ </div>
15
+ )
16
+ }
@@ -0,0 +1,59 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+
3
+ export default function SectionLabel({
4
+ text,
5
+ size = 'md',
6
+ className = ''
7
+ }) {
8
+ // Size variants: sm (16px), md (20px), lg (32px)
9
+ const sizeConfig = {
10
+ sm: {
11
+ height: 'h-4',
12
+ iconSize: 16,
13
+ textClass: 'kol-label-compact-md'
14
+ },
15
+ md: {
16
+ height: 'h-5',
17
+ iconSize: 24,
18
+ textClass: 'kol-label-compact-lg'
19
+ },
20
+ lg: {
21
+ height: 'h-8',
22
+ iconSize: 40,
23
+ textClass: 'kol-heading-md'
24
+ }
25
+ }
26
+
27
+ const config = sizeConfig[size] || sizeConfig.md
28
+
29
+ return (
30
+ <div className={`section-label-wrapper flex flex-row items-center gap-1 overflow-visible ${config.height} ${className}`}>
31
+ <p className={`${config.textClass} text-auto`}>{text}</p>
32
+ <span
33
+ className="icon-swap-container"
34
+ style={{
35
+ position: 'relative',
36
+ display: 'inline-flex',
37
+ alignItems: 'center',
38
+ justifyContent: 'center',
39
+ width: `${config.iconSize}px`,
40
+ height: `${config.iconSize}px`,
41
+ overflow: 'hidden'
42
+ }}
43
+ >
44
+ <Icon
45
+ name="arrow-downright"
46
+ size={config.iconSize}
47
+ className="icon-default"
48
+ style={{ position: 'absolute' }}
49
+ />
50
+ <Icon
51
+ name="arrow-downright"
52
+ size={config.iconSize}
53
+ className="icon-hover"
54
+ style={{ position: 'absolute' }}
55
+ />
56
+ </span>
57
+ </div>
58
+ )
59
+ }
@@ -0,0 +1,56 @@
1
+ /**
2
+ * SegmentedToggle — N-way segmented control. Joined buttons sharing one
3
+ * outer stroke; thin dividers between cells; no gap. Active cell uses
4
+ * bg-fg-04 + text-emphasis, inactive is text-meta with hover lifting.
5
+ *
6
+ * <SegmentedToggle
7
+ * value={current}
8
+ * onChange={setCurrent}
9
+ * options={[{ value, label }]}
10
+ * />
11
+ *
12
+ * Companion to `ViewToggle`:
13
+ * - ViewToggle (text) — bare segmented buttons separated by gap; no shared shell.
14
+ * - ViewToggle (icon) — inset-well row of square icon buttons.
15
+ * - SegmentedToggle — flat segmented strip with shared border + dividers.
16
+ *
17
+ * Labels accept any node — pass strings or inline SVG previews. Optional
18
+ * `ariaLabel` per option for non-text labels.
19
+ *
20
+ * Props:
21
+ * value — current option value
22
+ * onChange — handler (newValue) => void
23
+ * options — [{ value, label, ariaLabel? }]
24
+ * size — 'md' (default, 26px) | 'sm' (16px). `sm` is for icon/preview-
25
+ * only options (e.g. line-style previews) where text labels
26
+ * aren't used; the cells still center their contents but the
27
+ * outer height is tighter.
28
+ * className — additional classes on the outer shell
29
+ */
30
+ export default function SegmentedToggle({ value, onChange, options = [], size = 'md', className = '' }) {
31
+ const wrapHeight = size === 'sm' ? 'h-4' : 'h-[26px]' /* 16 vs 26 */
32
+ const cellType = size === 'sm' ? '' : 'kol-mono-12'
33
+ return (
34
+ <div className={`flex ${wrapHeight} border border-fg-04 rounded overflow-hidden ${className}`}>
35
+ {options.map((opt, i) => {
36
+ const isActive = opt.value === value
37
+ return (
38
+ <button
39
+ key={opt.value}
40
+ type="button"
41
+ onClick={() => onChange?.(opt.value)}
42
+ aria-pressed={isActive}
43
+ aria-label={opt.ariaLabel}
44
+ className={[
45
+ `flex-1 ${cellType} inline-flex items-center justify-center cursor-pointer`,
46
+ isActive ? 'bg-surface-secondary text-emphasis' : 'text-meta hover:text-emphasis',
47
+ i > 0 ? 'border-l border-fg-04' : '',
48
+ ].filter(Boolean).join(' ')}
49
+ >
50
+ {opt.label}
51
+ </button>
52
+ )
53
+ })}
54
+ </div>
55
+ )
56
+ }
@@ -0,0 +1,79 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+
3
+ const ICON_SIZES = { sm: 10, md: 12, lg: 14 }
4
+
5
+ /**
6
+ * Tag — canonical (merged web rich + brand compat).
7
+ *
8
+ * Web's rich API: variant (default/naked/inverse/solid), size, color, solid,
9
+ * active, icon, onRemove, onClick. Plus:
10
+ * - `hash` (default true) — prepend `#` (web's tag style). Pass hash={false}
11
+ * for plain labels (brand usage).
12
+ * - `text` — content fallback when no children (brand SwatchControls passes text=).
13
+ */
14
+ export default function Tag({
15
+ children,
16
+ text,
17
+ variant = 'default',
18
+ size = 'md',
19
+ color,
20
+ solid = false,
21
+ active = false,
22
+ hash = true,
23
+ icon,
24
+ onRemove,
25
+ onClick,
26
+ className = ''
27
+ }) {
28
+ const isInteractive = !!(onClick || onRemove)
29
+ const Element = isInteractive ? 'button' : 'span'
30
+ const iconSize = ICON_SIZES[size] || 12
31
+ const content = children ?? text
32
+
33
+ let baseClass
34
+ if (variant === 'naked') {
35
+ baseClass = color ? `tag-naked tag--${color}` : 'tag-naked'
36
+ } else if (color) {
37
+ baseClass = `tag tag--${color}`
38
+ } else {
39
+ baseClass = variant === 'inverse' ? 'tag-control-inverse' : 'tag-control'
40
+ }
41
+
42
+ const isSolid = solid || variant === 'solid'
43
+ const activeClass = active ? (color ? 'tag--active' : 'is-active') : ''
44
+
45
+ const classes = [
46
+ baseClass,
47
+ `tag-${size}`,
48
+ isSolid && variant !== 'naked' ? 'tag--solid' : '',
49
+ activeClass,
50
+ isInteractive ? 'cursor-pointer' : '',
51
+ className
52
+ ].filter(Boolean).join(' ')
53
+
54
+ const handleRemove = (e) => {
55
+ e.stopPropagation()
56
+ onRemove?.(e)
57
+ }
58
+
59
+ return (
60
+ <Element
61
+ type={isInteractive ? 'button' : undefined}
62
+ className={classes}
63
+ onClick={onClick}
64
+ >
65
+ {icon && <Icon name={icon} size={iconSize} />}
66
+ <span>{hash ? '#' : ''}{content}</span>
67
+ {onRemove && (
68
+ <span
69
+ role="button"
70
+ tabIndex={-1}
71
+ className="tag-dismiss"
72
+ onClick={handleRemove}
73
+ >
74
+ <Icon name="cross" size={iconSize} />
75
+ </span>
76
+ )}
77
+ </Element>
78
+ )
79
+ }
@@ -0,0 +1,45 @@
1
+ /**
2
+ * ToggleBracket — text-pair toggle showing `Label [STATE]`.
3
+ *
4
+ * variant="default" — Filled shell (bg-surface-secondary). When ON,
5
+ * overrides to accent-yellow with navy ink.
6
+ * variant="plain" — Bare text rows, no chrome. For inline contexts
7
+ * (sidenav child rows, dense panels).
8
+ */
9
+ const ToggleBracket = ({
10
+ label,
11
+ value = false,
12
+ onToggle,
13
+ onChange,
14
+ offLabel = 'OFF',
15
+ onLabel = 'ON',
16
+ variant = 'default',
17
+ className = '',
18
+ ...props
19
+ }) => {
20
+ const handleClick = () => {
21
+ if (onToggle) onToggle(!value)
22
+ if (onChange) onChange(!value)
23
+ }
24
+
25
+ const isPlain = variant === 'plain'
26
+
27
+ const cls = isPlain
28
+ ? `inline-flex items-center gap-3 kol-mono-12 uppercase text-meta hover:text-emphasis transition-colors bg-transparent border-0 p-0 cursor-pointer ${className}`
29
+ : `kol-control kol-control--filled kol-control-md kol-mono-12 uppercase justify-between gap-3 ${value ? 'toggle-bracket--active' : ''} ${className}`
30
+
31
+ return (
32
+ <button
33
+ type="button"
34
+ className={cls.trim()}
35
+ onClick={handleClick}
36
+ aria-pressed={value}
37
+ {...props}
38
+ >
39
+ <span>{label}</span>
40
+ <span>[{value ? onLabel : offLabel}]</span>
41
+ </button>
42
+ )
43
+ }
44
+
45
+ export default ToggleBracket
@@ -0,0 +1,101 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+
3
+ /**
4
+ * ViewToggle — control for switching between view modes.
5
+ *
6
+ * variant="text" — segmented bare buttons; active uses kol-control--filled,
7
+ * inactive is bare text-meta with text-emphasis on hover.
8
+ * variant="icon" — bordered chip-row container holding square icon
9
+ * buttons. Active uses bg-fg-absolute-24.
10
+ * variant="single" — single button binary toggle. Click flips between the
11
+ * two `options` values. The button shows the *current*
12
+ * option's label; active = on (kol-control--filled),
13
+ * inactive = off (bare text). Use for compact on/off
14
+ * where a segmented two-button toggle is overkill.
15
+ *
16
+ * Built on the .kol-control shell. Default options use grid-06 / list-01
17
+ * icons; consumers can pass `options` to override. For `variant="single"`,
18
+ * the FIRST option in `options` is the "off" value; the SECOND is "on".
19
+ */
20
+ const ViewToggle = ({
21
+ viewMode,
22
+ onViewChange,
23
+ variant = 'text',
24
+ options = [
25
+ { value: 'grid', label: 'Grid view', icon: 'grid-06' },
26
+ { value: 'list', label: 'List view', icon: 'list-01' }
27
+ ],
28
+ className = ''
29
+ }) => {
30
+ const isIconVariant = variant === 'icon'
31
+ const isSingleVariant = variant === 'single'
32
+
33
+ if (isSingleVariant) {
34
+ const [offOpt, onOpt] = options
35
+ const isOn = viewMode === onOpt.value
36
+ const next = isOn ? offOpt.value : onOpt.value
37
+ const cls = isOn
38
+ ? `kol-control kol-control--filled kol-control-sm kol-mono-12 ${className}`
39
+ : `kol-control kol-control-sm kol-mono-12 text-meta hover:text-emphasis ${className}`
40
+ /* Both labels stack in a single grid cell so the button width is fixed
41
+ * to the longer label — flipping state never reflows the row. */
42
+ return (
43
+ <button
44
+ type="button"
45
+ onClick={() => onViewChange(next)}
46
+ className={cls}
47
+ aria-pressed={isOn}
48
+ title={isOn ? onOpt.label : offOpt.label}
49
+ >
50
+ <span className="grid">
51
+ <span className={`col-start-1 row-start-1 ${isOn ? '' : 'invisible'}`}>{onOpt.label}</span>
52
+ <span className={`col-start-1 row-start-1 ${isOn ? 'invisible' : ''}`}>{offOpt.label}</span>
53
+ </span>
54
+ </button>
55
+ )
56
+ }
57
+
58
+ const containerClasses = isIconVariant
59
+ ? `inline-flex items-center gap-1 p-1 bg-surface-secondary rounded ${className}`
60
+ : `flex gap-2 ${className}`
61
+
62
+ const buttonClasses = (isActive) => {
63
+ if (isIconVariant) {
64
+ // Inset "well" pattern — container is bg-fg-04 (slight lift from page),
65
+ // active button uses bg-fg-absolute-24 (theme-invariant black, always
66
+ // darkens regardless of theme) so it reads as recessed/pressed.
67
+ return `inline-flex items-center justify-center p-1.5 rounded transition-colors text-emphasis cursor-pointer ${
68
+ isActive ? 'bg-fg-absolute-24' : 'hover:bg-fg-absolute-08'
69
+ }`
70
+ }
71
+ /* Active = filled chip. Inactive = bare-text on the shell base — no
72
+ * border-reveal hover. (Earlier ghost variant revealed an outline on
73
+ * hover; that's deliberately gone.) */
74
+ return isActive
75
+ ? 'kol-control kol-control--filled kol-control-sm kol-mono-12'
76
+ : 'kol-control kol-control-sm kol-mono-12 text-meta hover:text-emphasis'
77
+ }
78
+
79
+ return (
80
+ <div className={containerClasses}>
81
+ {options.map((option) => (
82
+ <button
83
+ key={option.value}
84
+ onClick={() => onViewChange(option.value)}
85
+ className={buttonClasses(viewMode === option.value)}
86
+ aria-label={option.label}
87
+ aria-pressed={viewMode === option.value}
88
+ title={option.label}
89
+ >
90
+ {isIconVariant && option.icon ? (
91
+ <Icon name={option.icon} size={14} />
92
+ ) : (
93
+ option.label
94
+ )}
95
+ </button>
96
+ ))}
97
+ </div>
98
+ )
99
+ }
100
+
101
+ export default ViewToggle
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Table — data table.
3
+ * Styles: src/styles/kol-components-organisms.css.
4
+ *
5
+ * Variants:
6
+ * default — bordered, column dividers, header bg
7
+ * simple — borderless, flush, no column dividers
8
+ */
9
+ const Table = ({ caption, columns, rows, variant = 'default', className = '' }) => {
10
+ const variantClass = variant === 'simple' ? 'kol-table--simple' : ''
11
+ const wrapperClass = ['kol-table-wrapper', variantClass, className].filter(Boolean).join(' ')
12
+ return (
13
+ <div className={wrapperClass}>
14
+ <table className="kol-table">
15
+ {caption ? <caption className="sr-only">{caption}</caption> : null}
16
+ <thead className="kol-table-thead">
17
+ <tr>
18
+ {columns.map((column) => (
19
+ <th
20
+ key={column.accessor}
21
+ scope="col"
22
+ className={column.headerClassName ?? 'kol-table-cell-title'}
23
+ style={column.style}
24
+ >
25
+ {column.header}
26
+ </th>
27
+ ))}
28
+ </tr>
29
+ </thead>
30
+ <tbody>
31
+ {rows.map((row, rowIndex) => (
32
+ <tr key={row.id ?? row.token ?? rowIndex} className="kol-table-row">
33
+ {columns.map((column) => (
34
+ <td key={column.accessor} className={(typeof column.className === 'function' ? column.className(row) : column.className) ?? 'kol-table-cell-text'} style={column.style}>
35
+ {column.render ? column.render(row) : row[column.accessor] ?? '—'}
36
+ </td>
37
+ ))}
38
+ </tr>
39
+ ))}
40
+ </tbody>
41
+ </table>
42
+ </div>
43
+ )
44
+ }
45
+
46
+ export default Table
@@ -0,0 +1,45 @@
1
+ /**
2
+ * Accordion — collapsible panel group.
3
+ *
4
+ * Composition-based: <Accordion> wraps <AccordionPanel> children. Each panel
5
+ * owns its open/closed state independently (additive — multiple can be open).
6
+ * For single-open behavior, manage state from the parent and pass controlled
7
+ * `open` + `onToggle` props to each panel.
8
+ */
9
+ import { useState } from 'react'
10
+
11
+ export function Accordion({ children, className = '' }) {
12
+ return <div className={`kol-accordion mt-2 mb-6 ${className}`.trim()}>{children}</div>
13
+ }
14
+
15
+ export function AccordionPanel({
16
+ title,
17
+ meta,
18
+ defaultOpen = false,
19
+ open: controlledOpen,
20
+ onToggle,
21
+ children,
22
+ }) {
23
+ const [internalOpen, setInternalOpen] = useState(defaultOpen)
24
+ const isControlled = controlledOpen !== undefined
25
+ const open = isControlled ? controlledOpen : internalOpen
26
+ const toggle = () => {
27
+ if (isControlled) onToggle?.(!open)
28
+ else setInternalOpen(!open)
29
+ }
30
+ return (
31
+ <div className={`kol-accordion-panel border-t border-[var(--kol-fg-08)] last:border-b${open ? ' is-open' : ''}`}>
32
+ <button
33
+ type="button"
34
+ className="kol-accordion-trigger flex items-center gap-4 w-full py-4 bg-transparent border-0 cursor-pointer text-left font-mono text-emphasis transition-colors duration-[120ms] hover:text-fg-88"
35
+ onClick={toggle}
36
+ aria-expanded={open}
37
+ >
38
+ <span className="kol-accordion-title kol-helper-12 uppercase tracking-widest">{title}</span>
39
+ {meta && <span className="kol-accordion-meta kol-helper-12 uppercase tracking-widest text-fg-48 ml-auto mr-3">{meta}</span>}
40
+ <span className="kol-accordion-chevron font-mono text-[18px] text-fg-48 min-w-3 transition-colors duration-[120ms] ml-auto" aria-hidden="true">{open ? '−' : '+'}</span>
41
+ </button>
42
+ {open && <div className="kol-accordion-body pt-2 pb-6">{children}</div>}
43
+ </div>
44
+ )
45
+ }
@@ -0,0 +1,28 @@
1
+ /**
2
+ * AssetPlaceholder — deliberate "missing asset" frame.
3
+ *
4
+ * Used by Image + Graphic as fallback when the resource can't be found.
5
+ * Renders an outlined tile with the asset's category/name so missing assets
6
+ * are visibly flagged rather than showing the browser's broken-image icon.
7
+ */
8
+
9
+ export default function AssetPlaceholder({
10
+ category,
11
+ name,
12
+ aspectRatio = '16 / 9',
13
+ note = 'missing',
14
+ className = '',
15
+ }) {
16
+ const label = [category, name].filter(Boolean).join(' · ')
17
+ return (
18
+ <div
19
+ className={`kol-asset-placeholder flex flex-col items-center justify-center gap-[6px] w-full p-6 border border-dashed border-[var(--kol-fg-24)] rounded-[4px] bg-[var(--kol-fg-02)] text-fg-48 font-mono text-center box-border ${className}`.trim()}
20
+ style={{ aspectRatio }}
21
+ role="img"
22
+ aria-label={`${label || 'asset'} — ${note}`}
23
+ >
24
+ <span className="kol-asset-placeholder-note text-[12px] uppercase [letter-spacing:0.12em] text-fg-48">{note}</span>
25
+ {label && <span className="kol-asset-placeholder-label text-[12px] text-fg-40 [letter-spacing:0.04em]">{label}</span>}
26
+ </div>
27
+ )
28
+ }
@@ -0,0 +1,50 @@
1
+ import { Children, useCallback, useEffect, useState } from 'react'
2
+ import useEmblaCarousel from 'embla-carousel-react'
3
+
4
+ export default function Carousel({ children, options = { align: 'start', loop: false, dragFree: true, containScroll: 'trimSnaps' }, className = '' }) {
5
+ const [emblaRef, emblaApi] = useEmblaCarousel(options)
6
+ const [canPrev, setCanPrev] = useState(false)
7
+ const [canNext, setCanNext] = useState(false)
8
+
9
+ const onSelect = useCallback((api) => {
10
+ setCanPrev(api.canScrollPrev())
11
+ setCanNext(api.canScrollNext())
12
+ }, [])
13
+
14
+ useEffect(() => {
15
+ if (!emblaApi) return
16
+ onSelect(emblaApi)
17
+ emblaApi.on('select', onSelect)
18
+ emblaApi.on('reInit', onSelect)
19
+ }, [emblaApi, onSelect])
20
+
21
+ const slides = Children.toArray(children)
22
+
23
+ return (
24
+ <div className={`kol-embla ${className}`.trim()}>
25
+ <div className="kol-embla-viewport" ref={emblaRef}>
26
+ <div className="kol-embla-container">
27
+ {slides.map((child, i) => (
28
+ <div key={i} className="kol-embla-slide">{child}</div>
29
+ ))}
30
+ </div>
31
+ </div>
32
+ <div className="kol-embla-controls">
33
+ <button
34
+ type="button"
35
+ className="kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto"
36
+ aria-label="Previous"
37
+ onClick={() => emblaApi?.scrollPrev()}
38
+ disabled={!canPrev}
39
+ >‹</button>
40
+ <button
41
+ type="button"
42
+ className="kol-embla-btn border border-fg-16 hover:border-fg-32 text-auto"
43
+ aria-label="Next"
44
+ onClick={() => emblaApi?.scrollNext()}
45
+ disabled={!canNext}
46
+ >›</button>
47
+ </div>
48
+ </div>
49
+ )
50
+ }