@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
package/README.md ADDED
@@ -0,0 +1,24 @@
1
+ # @kolkrabbi/kol-component
2
+
3
+ The KOL design-system component library — atoms through organisms. Components emit canonical `kol-*` classes; their styling lives in [`@kolkrabbi/kol-theme`](https://www.npmjs.com/package/@kolkrabbi/kol-theme).
4
+
5
+ ## Install
6
+
7
+ ```sh
8
+ npm i @kolkrabbi/kol-component @kolkrabbi/kol-theme
9
+ # react, react-dom are peers; react-router-dom is an optional peer (some components)
10
+ ```
11
+
12
+ Requires a **Vite + Tailwind v4** app and the theme CSS imported (see the theme package).
13
+
14
+ ## Use
15
+
16
+ ```jsx
17
+ import { Button, Tag, Badge, Slider, Dropdown, Table } from '@kolkrabbi/kol-component'
18
+ import { Icon } from '@kolkrabbi/kol-loader'
19
+
20
+ <Button variant="primary" iconLeft="plus">New</Button>
21
+ <Badge variant="success">Active</Badge>
22
+ ```
23
+
24
+ Atoms (Button, Input, Slider, Toggle\*, …), molecules (Dropdown, Tag, Badge, Modal, Popover, …), primitives (Accordion, Carousel, CodeBlock, Image, …), an organism (Table), graphics, and hooks (`useReveal`, `useScrollSpy`). See the [usage reference](https://github.com/kolkrabbi/kol-design-system/tree/main/docs/usage) for real examples of each.
package/package.json ADDED
@@ -0,0 +1,46 @@
1
+ {
2
+ "name": "@kolkrabbi/kol-component",
3
+ "version": "0.1.0",
4
+ "description": "KOL design-system components — atoms through organisms, emitting canonical kol-* classes. Pairs with @kolkrabbi/kol-theme for styling.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "main": "./src/index.js",
8
+ "module": "./src/index.js",
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "files": [
13
+ "src",
14
+ "README.md"
15
+ ],
16
+ "keywords": [
17
+ "kol",
18
+ "kolkrabbi",
19
+ "design-system",
20
+ "react",
21
+ "components"
22
+ ],
23
+ "dependencies": {
24
+ "@floating-ui/react": "^0.27.19",
25
+ "embla-carousel-react": "^8.6.0",
26
+ "@kolkrabbi/kol-loader": "0.1.0"
27
+ },
28
+ "peerDependencies": {
29
+ "react": "^18.3.0 || ^19.0.0",
30
+ "react-dom": "^18.3.0 || ^19.0.0",
31
+ "react-router-dom": "^6.0.0 || ^7.0.0"
32
+ },
33
+ "peerDependenciesMeta": {
34
+ "react-router-dom": {
35
+ "optional": true
36
+ }
37
+ },
38
+ "repository": {
39
+ "type": "git",
40
+ "url": "git+https://github.com/kolkrabbi/kol-design-system.git",
41
+ "directory": "packages/component"
42
+ },
43
+ "publishConfig": {
44
+ "access": "public"
45
+ }
46
+ }
@@ -0,0 +1,17 @@
1
+ const SIZE_MAP = {
2
+ sm: 'w-8 h-8 text-xs',
3
+ md: 'w-10 h-10 text-sm',
4
+ lg: 'w-14 h-14 text-base',
5
+ xl: 'w-24 h-24 text-3xl',
6
+ }
7
+
8
+ export default function Avatar({ initial, size = 'sm', className = '' }) {
9
+ const sizeCls = SIZE_MAP[size] ?? SIZE_MAP.sm
10
+ return (
11
+ <span
12
+ className={`kol-avatar inline-flex items-center justify-center rounded-full bg-surface-secondary text-emphasis font-narrow font-semibold shrink-0 ${sizeCls} ${className}`}
13
+ >
14
+ {initial}
15
+ </span>
16
+ )
17
+ }
@@ -0,0 +1,172 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+
3
+ /**
4
+ * Button — canonical KOL button. Emits kol-btn* classes (CSS in @kol/theme).
5
+ *
6
+ * Supports link (href) and button (onClick / default) forms with optional
7
+ * icons (left / right / icon-only), per-icon hover swap, and a selected
8
+ * (toggle) state.
9
+ *
10
+ * @param {Object} props
11
+ * @param {ReactNode} props.children - Button content
12
+ * @param {'primary'|'secondary'|'accent'|'outline'|'ghost'|'control'} props.variant - Visual variant. `control` is an alias for `ghost` (legacy call-sites).
13
+ * @param {'sm'|'md'|'lg'} props.size - Button size (default: 'md')
14
+ * @param {string} props.iconLeft - Icon name to display on the left
15
+ * @param {string} props.iconRight - Icon name to display on the right
16
+ * @param {string} props.iconLeftHover - Icon to show on hover (left position)
17
+ * @param {string} props.iconRightHover - Icon to show on hover (right position)
18
+ * @param {string} props.iconOnly - Icon name for icon-only button
19
+ * @param {string} props.iconOnlyHover - Icon to show on hover (icon-only)
20
+ * @param {boolean} props.animateIcon - Disable default hover states to focus on icon animation
21
+ * @param {boolean} props.quiet - Dimmed at rest, brightens on hover; stays dimmed when disabled. For secondary icon-only chrome.
22
+ * @param {number} props.iconSize - Size of the icon in pixels (default: auto by size)
23
+ * @param {number} props.iconGap - Gap between icon and text (default: 8)
24
+ * @param {string} props.href - Link destination (makes it an <a>)
25
+ * @param {Function} props.onClick - Click handler (makes it a <button>)
26
+ * @param {string} props.className - Additional classes
27
+ * @param {Object} props.style - Inline styles
28
+ * @param {string} props.type - Button type attribute (default: 'button')
29
+ * @param {boolean} props.disabled - Disabled state
30
+ * @param {boolean} props.selected - Selected/active state (toggle highlight)
31
+ */
32
+ const Button = ({
33
+ children,
34
+ variant = 'primary',
35
+ size = 'md',
36
+ iconLeft,
37
+ iconRight,
38
+ iconLeftHover,
39
+ iconRightHover,
40
+ iconOnly,
41
+ iconOnlyHover,
42
+ animateIcon = false,
43
+ quiet = false,
44
+ iconSize,
45
+ iconGap,
46
+ href,
47
+ onClick,
48
+ className = '',
49
+ style = {},
50
+ type = 'button',
51
+ disabled = false,
52
+ selected = false,
53
+ ...props
54
+ }) => {
55
+ const resolvedIconSize = iconSize ?? (size === 'sm' ? 14 : size === 'lg' ? 18 : 16)
56
+
57
+ // `control` is a legacy alias for `ghost` (kept so web call-sites passing
58
+ // variant="control" keep working post-migration).
59
+ const resolvedVariant = variant === 'control' ? 'ghost' : variant
60
+
61
+ const variantClass = resolvedVariant === 'primary'
62
+ ? 'kol-btn-primary'
63
+ : resolvedVariant === 'accent'
64
+ ? 'kol-btn-accent'
65
+ : resolvedVariant === 'outline'
66
+ ? 'kol-btn-outline'
67
+ : resolvedVariant === 'ghost'
68
+ ? 'kol-btn-ghost'
69
+ : 'kol-btn-secondary'
70
+
71
+ // Add size class — pairs the padding rule with its mono type class.
72
+ const sizeClass = size === 'sm'
73
+ ? 'kol-btn-sm kol-mono-12'
74
+ : size === 'lg'
75
+ ? 'kol-btn-lg kol-mono-16'
76
+ : 'kol-btn-md kol-mono-14'
77
+
78
+ // Add kol-btn-animate class if animateIcon is true to disable default hover states
79
+ const animateClass = animateIcon ? 'kol-btn-animate' : ''
80
+ const quietClass = quiet ? 'kol-btn-quiet' : ''
81
+ const selectedClass = selected ? 'kol-btn-selected' : ''
82
+
83
+ const combinedClass = `kol-btn ${variantClass} ${sizeClass} ${animateClass} ${quietClass} ${selectedClass} ${className}`.trim().replace(/\s+/g, ' ')
84
+
85
+ // Render icon with optional hover state
86
+ const renderIcon = (iconName, iconHoverName) => {
87
+ if (!iconName && !iconHoverName) return null
88
+
89
+ // If no hover icon, render single icon
90
+ if (!iconHoverName) {
91
+ return <Icon name={iconName} size={resolvedIconSize} />
92
+ }
93
+
94
+ // Render both default and hover icons with positioning
95
+ return (
96
+ <span className="kol-icon-swap-container" style={{ position: 'relative', display: 'inline-flex', width: resolvedIconSize, height: resolvedIconSize, overflow: 'hidden' }}>
97
+ <Icon
98
+ name={iconName}
99
+ size={resolvedIconSize}
100
+ className="kol-icon-default"
101
+ style={{ position: 'absolute' }}
102
+ />
103
+ <Icon
104
+ name={iconHoverName}
105
+ size={resolvedIconSize}
106
+ className="kol-icon-hover"
107
+ style={{ position: 'absolute' }}
108
+ />
109
+ </span>
110
+ )
111
+ }
112
+
113
+ // Render content with icons
114
+ const renderContent = () => {
115
+ // Icon-only button
116
+ if (iconOnly) {
117
+ return renderIcon(iconOnly, iconOnlyHover)
118
+ }
119
+
120
+ // Button with icon(s) and text
121
+ if (iconLeft || iconRight || iconLeftHover || iconRightHover) {
122
+ return (
123
+ <span className="flex items-center" style={{ gap: iconGap ?? 8 }}>
124
+ {(iconLeft || iconLeftHover) && <span style={{ marginLeft: -2 }}>{renderIcon(iconLeft, iconLeftHover)}</span>}
125
+ {children}
126
+ {(iconRight || iconRightHover) && <span style={{ marginRight: -2 }}>{renderIcon(iconRight, iconRightHover)}</span>}
127
+ </span>
128
+ )
129
+ }
130
+
131
+ // Text-only button
132
+ return children
133
+ }
134
+
135
+ // Merge icon-only specific styles with user-provided styles
136
+ const mergedStyle = iconOnly
137
+ ? { lineHeight: 0, display: 'inline-flex', alignItems: 'center', justifyContent: 'center', ...style }
138
+ : style
139
+
140
+ // Render as button
141
+ if (onClick || !href) {
142
+ return (
143
+ <button
144
+ onClick={onClick}
145
+ type={type}
146
+ className={combinedClass}
147
+ style={mergedStyle}
148
+ disabled={disabled}
149
+ aria-pressed={selected ? true : undefined}
150
+ aria-label={iconOnly ? (props['aria-label'] || 'Button') : undefined}
151
+ {...props}
152
+ >
153
+ {renderContent()}
154
+ </button>
155
+ )
156
+ }
157
+
158
+ // Render as link
159
+ return (
160
+ <a
161
+ href={href}
162
+ className={combinedClass}
163
+ style={mergedStyle}
164
+ aria-label={iconOnly ? (props['aria-label'] || 'Link') : undefined}
165
+ {...props}
166
+ >
167
+ {renderContent()}
168
+ </a>
169
+ )
170
+ }
171
+
172
+ export default Button
@@ -0,0 +1,126 @@
1
+ /**
2
+ * ColorSwatch — fixed-size color chip atom.
3
+ *
4
+ * onClick provided → renders <button> with hover + selected states
5
+ * onClick omitted → renders <span aria-hidden> — non-interactive preview
6
+ *
7
+ * Pass `showTransparent` to render the TransparentX overlay (for null /
8
+ * unset color slots). When true, bg goes transparent regardless of `hex`.
9
+ *
10
+ * Props:
11
+ * hex — color string, e.g. '#FF6F00'. Ignored if showTransparent.
12
+ * selected — adds active border + ring (border-fg-64 ring-1).
13
+ * size — number (px) for fixed-size, 'fill' (w-full aspect-square,
14
+ * for grid cells that should stay square), or 'stretch'
15
+ * (w-full h-full, for grid cells that stretch to row height).
16
+ * Default 24.
17
+ * radius — 'sm' (4px, default) | 'tight' (2px) | 'none' | 'full' (circle).
18
+ * frame — boolean (default true). When false, no border drawn —
19
+ * used by tightly-packed grid layouts.
20
+ * variant — 'default' (border-based chrome) |
21
+ * 'halo' (macOS-port double box-shadow halo). Halo
22
+ * overrides `frame` since it provides its own ring.
23
+ * hoverable — boolean (default true). When false, no hover border
24
+ * state is applied even if `onClick` is set. For static
25
+ * chips that just open a popover (e.g. inspector fill /
26
+ * stroke swatches).
27
+ * showTransparent — universal "disabled / no value / unset" indicator.
28
+ * Renders white background + TransparentX diagonal
29
+ * stroke; rounded corners clip the line cleanly via
30
+ * `overflow-hidden` on the swatch root.
31
+ * transparentTone — tone of the TransparentX stroke when `showTransparent`
32
+ * is true: 'warning' (default) | 'error' | 'info' |
33
+ * 'success'. Maps through to `var(--ui-{tone})`.
34
+ * onClick — if provided, renders as <button>; else <span>.
35
+ * title — passes through.
36
+ */
37
+ import TransparentX from './TransparentX'
38
+
39
+ const SIZE_CLASSES = {
40
+ fill: 'w-full aspect-square',
41
+ stretch: 'w-full h-full',
42
+ }
43
+
44
+ const RADIUS_CLASSES = {
45
+ none: 'rounded-none',
46
+ tight: 'rounded-[2px]',
47
+ sm: 'rounded',
48
+ full: 'rounded-full',
49
+ }
50
+
51
+ const HALO_SHADOW = '0 0 0 1px #000, 0 0 0 2px #505050'
52
+
53
+ export default function ColorSwatch({
54
+ hex,
55
+ selected = false,
56
+ size = 24,
57
+ radius = 'tight',
58
+ frame = true,
59
+ variant = 'default',
60
+ hoverable = true,
61
+ showTransparent = false,
62
+ transparentTone = 'warning',
63
+ onClick,
64
+ title,
65
+ className = '',
66
+ ...rest
67
+ }) {
68
+ const interactive = typeof onClick === 'function'
69
+ const isNamed = typeof size === 'string'
70
+ const sizeCls = isNamed ? (SIZE_CLASSES[size] ?? '') : ''
71
+ const sizeStyle = isNamed ? null : { width: size, height: size }
72
+
73
+ const isHalo = variant === 'halo'
74
+ const radiusCls = RADIUS_CLASSES[radius] ?? RADIUS_CLASSES.sm
75
+
76
+ /* Halo provides its own ring via box-shadow → no border classes.
77
+ * frame=false → consumer wants a borderless chip (e.g. swatch grids
78
+ * that tile edge-to-edge). */
79
+ const showBorder = !isHalo && frame
80
+
81
+ /* Border policy:
82
+ * default state (not selected) — no border, clean chip
83
+ * selected — 2px border-fg-64 (the selection chrome)
84
+ * halo variant — handled separately via box-shadow
85
+ * `frame` and `hoverable` are kept on the API but no longer drive a
86
+ * default-state border; they're advisory for future variants. */
87
+ const cls = [
88
+ 'relative shrink-0 inline-flex overflow-hidden',
89
+ radiusCls,
90
+ !isHalo && selected ? 'border-2 border-fg-64' : '',
91
+ interactive ? 'cursor-pointer' : '',
92
+ sizeCls,
93
+ className,
94
+ ].filter(Boolean).join(' ')
95
+
96
+ const style = {
97
+ background: showTransparent ? '#FFFFFF' : (hex || 'transparent'),
98
+ ...(isHalo && { boxShadow: HALO_SHADOW }),
99
+ ...sizeStyle,
100
+ }
101
+
102
+ const inner = showTransparent ? <TransparentX tone={transparentTone} /> : null
103
+
104
+ if (interactive) {
105
+ return (
106
+ <button
107
+ {...rest}
108
+ type="button"
109
+ onClick={onClick}
110
+ title={title}
111
+ aria-label={rest['aria-label'] ?? hex ?? 'transparent'}
112
+ aria-pressed={selected}
113
+ className={cls}
114
+ style={style}
115
+ >
116
+ {inner}
117
+ </button>
118
+ )
119
+ }
120
+
121
+ return (
122
+ <span {...rest} aria-hidden="true" title={title} className={cls} style={style}>
123
+ {inner}
124
+ </span>
125
+ )
126
+ }
@@ -0,0 +1,34 @@
1
+ import React from 'react'
2
+
3
+ /**
4
+ * Divider - Horizontal or vertical divider line
5
+ *
6
+ * Simple atom for creating separator lines
7
+ * Uses bg-fg-08 for consistent 8% opacity across themes by default
8
+ * Vertical variant includes wrapper div for proper flex behavior
9
+ *
10
+ * @param {Object} props
11
+ * @param {string} props.variant - 'horizontal' or 'vertical' (default: 'horizontal')
12
+ * @param {string} props.className - Additional classes
13
+ * @param {string} props.opacity - Opacity level (01, 02, 04, 08, 12, 16, 24, 32, 48, 64, 80, 88, 96) (default: '08')
14
+ */
15
+ const Divider = ({ variant = 'horizontal', className = '', opacity = '08', inverse = false }) => {
16
+ const isVertical = variant === 'vertical'
17
+ const opacityClass = inverse ? `bg-fg-inverse-${opacity}` : `bg-fg-${opacity}`
18
+
19
+ if (isVertical) {
20
+ return (
21
+ <div className={`self-stretch flex justify-center items-center ${className}`.trim()}>
22
+ <div className={opacityClass} style={{ width: '1px', height: '100%' }} />
23
+ </div>
24
+ )
25
+ }
26
+
27
+ return (
28
+ <div className={className}>
29
+ <div className={`${opacityClass} h-px w-full`} />
30
+ </div>
31
+ )
32
+ }
33
+
34
+ export default Divider
@@ -0,0 +1,121 @@
1
+ import { Icon } from '@kolkrabbi/kol-loader'
2
+
3
+ /**
4
+ * Input — single-input atom built on the .kol-control shell.
5
+ *
6
+ * variant="filled" (default) — persistent solid bg
7
+ * variant="ghost" — borderless at rest, reveals on hover/focus
8
+ * variant="outline" — bordered, transparent bg (also: the
9
+ * forced-reveal / "is-edited" state of a
10
+ * ghost field — flip variant to outline)
11
+ *
12
+ * size="sm" / "md" (default) / "lg" — matched padding + type class
13
+ *
14
+ * Sizing:
15
+ * chars — HTML `size` attribute on the inner input. When set, the inner
16
+ * <input> sizes to N characters and the shell hugs (padding + prefix +
17
+ * N chars + suffix + padding). Use for known-format values (hex,
18
+ * percentage, integer counts). Drops `flex-1` so the input doesn't
19
+ * overflow into empty trailing space.
20
+ * width — explicit shell width override (e.g. "100%", "240px"). For
21
+ * stretch behavior, set the shell width and let the inner input fill.
22
+ *
23
+ * Props:
24
+ * prefix / suffix — small static text (e.g. "#", "%") rendered inside
25
+ * the shell at text-meta. aria-hidden — affordances, not labels.
26
+ * iconLeft — name of a leading icon rendered inside the shell (e.g.
27
+ * "search-16"). iconSize overrides the size-derived default.
28
+ * uppercase — adds Tailwind `uppercase` to the input element only.
29
+ *
30
+ * Chrome (bg/border/padding/transition/disabled) comes from .kol-control;
31
+ * Input owns prefix/suffix/icon layout + the inner <input> styling.
32
+ */
33
+
34
+ const SIZE_TYPE = { sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-16' }
35
+ const ICON_SIZE = { sm: 14, md: 14, lg: 18 }
36
+
37
+ export default function Input({
38
+ type = 'text',
39
+ value,
40
+ onChange,
41
+ variant = 'filled',
42
+ size = 'md',
43
+ chars,
44
+ prefix,
45
+ suffix,
46
+ iconLeft,
47
+ iconSize = null,
48
+ placeholder,
49
+ disabled = false,
50
+ uppercase = false,
51
+ width,
52
+ className = '',
53
+ inputClassName = '',
54
+ ...inputProps
55
+ }) {
56
+ const isNumber = type === 'number'
57
+ const fixedChars = typeof chars === 'number'
58
+ const resolvedIconSize = iconSize ?? ICON_SIZE[size] ?? 14
59
+
60
+ const shellCls = [
61
+ 'kol-control',
62
+ `kol-control--${variant}`,
63
+ `kol-control-${size}`,
64
+ SIZE_TYPE[size],
65
+ 'cursor-text',
66
+ className,
67
+ ].filter(Boolean).join(' ')
68
+
69
+ /* Pin inner input height to the typography token's line-height. Without
70
+ * this the `<input>` renders ~0.5px taller than the equivalent <button>
71
+ * or <label> at the same kol-mono-N — Chromium computes input height
72
+ * from the font's ascender+descender (font-metric), not strictly from
73
+ * CSS line-height. Result: kol-control-sm ends up 26.5px instead of 26.
74
+ * h-4 / h-[18px] / h-[22px] match the kol-mono-12 / -14 / -16 line-heights. */
75
+ const heightCls = size === 'sm' ? 'h-4' : size === 'md' ? 'h-[18px]' : 'h-[22px]'
76
+
77
+ const inputCls = [
78
+ 'min-w-0 bg-transparent border-none outline-none text-auto',
79
+ heightCls,
80
+ !fixedChars && 'flex-1',
81
+ /* Balance the dim prefix/suffix visual weight with extra inner padding
82
+ * on the opposite side. Without this the bright value sits closer to
83
+ * the affordance than to the empty edge, reads off-balance. */
84
+ prefix !== undefined && 'pr-1',
85
+ suffix !== undefined && 'pl-1',
86
+ uppercase && 'uppercase',
87
+ isNumber && 'hide-number-spinners',
88
+ inputClassName,
89
+ ].filter(Boolean).join(' ')
90
+
91
+ return (
92
+ <label
93
+ className={shellCls}
94
+ style={width ? { width: typeof width === 'number' ? `${width}px` : width } : undefined}
95
+ aria-disabled={disabled || undefined}
96
+ >
97
+ {iconLeft && (
98
+ <span aria-hidden="true" className="flex items-center text-auto opacity-50 shrink-0 pr-2">
99
+ <Icon name={iconLeft} size={resolvedIconSize} />
100
+ </span>
101
+ )}
102
+ {prefix !== undefined && (
103
+ <span aria-hidden="true" className="text-meta pr-1 shrink-0">{prefix}</span>
104
+ )}
105
+ <input
106
+ type={type}
107
+ value={value ?? ''}
108
+ onChange={onChange}
109
+ placeholder={placeholder}
110
+ disabled={disabled}
111
+ spellCheck={false}
112
+ size={fixedChars ? chars : undefined}
113
+ className={inputCls}
114
+ {...inputProps}
115
+ />
116
+ {suffix !== undefined && (
117
+ <span aria-hidden="true" className="text-meta pl-1 shrink-0">{suffix}</span>
118
+ )}
119
+ </label>
120
+ )
121
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Label — minimal form label. Inherits typography from parent (consumer
3
+ * supplies `kol-helper-N` or similar via className when needed). Defaults
4
+ * to `text-fg-48` for the muted-control look.
5
+ */
6
+ export default function Label({ children, htmlFor, className = '' }) {
7
+ return (
8
+ <label htmlFor={htmlFor} className={`text-fg-48 ${className}`}>
9
+ {children}
10
+ </label>
11
+ )
12
+ }
@@ -0,0 +1,129 @@
1
+ import { useEffect, useMemo, useState } from 'react'
2
+ import Input from './Input.jsx'
3
+
4
+ /**
5
+ * Slider — range slider with label and an editable value readout.
6
+ *
7
+ * Variants (track/shell styling):
8
+ * default — bordered track + boxed value.
9
+ * minimal — bare track + boxed value. 99% of real usage (foundry previews,
10
+ * inline controls). PRESERVED from web.
11
+ * subtle — filled rounded chip; for inspector-style controls.
12
+ *
13
+ * The value readout is an editable <Input> (type a value, commit on
14
+ * blur / Enter, revert on Escape). Track color is exposed as the
15
+ * `--kol-slider-track` CSS variable on `.slider-black`; override
16
+ * per-instance via style={{ '--kol-slider-track': '...' }}.
17
+ *
18
+ * @param {Object} props
19
+ * @param {string} props.label - Slider label text
20
+ * @param {number} props.min - Minimum value
21
+ * @param {number} props.max - Maximum value
22
+ * @param {number} props.value - Current value
23
+ * @param {Function} props.onChange - Change handler
24
+ * @param {'default'|'minimal'|'subtle'} props.variant - Visual variant (default: 'default')
25
+ * @param {string} props.className - Additional wrapper classes
26
+ * @param {number} props.displayWidth - Width of the value readout, in characters (default: 6)
27
+ * @param {string} props.fontSize - Font size for label/value (e.g., '11px')
28
+ * @param {number} props.step - Slider step increment (default: 1)
29
+ * @param {Function} props.formatValue - Optional formatter for displayed value
30
+ */
31
+ const Slider = ({
32
+ label,
33
+ min = 0,
34
+ max = 100,
35
+ value = 0,
36
+ onChange,
37
+ variant = 'default',
38
+ className = '',
39
+ displayWidth = 6,
40
+ fontSize,
41
+ step = 1,
42
+ formatValue
43
+ }) => {
44
+ const handleChange = (e) => {
45
+ if (onChange) {
46
+ onChange(Number(e.target.value))
47
+ }
48
+ }
49
+
50
+ const variantClass = variant === 'minimal'
51
+ ? 'control-slider-minimal'
52
+ : variant === 'subtle'
53
+ ? 'control-slider-subtle'
54
+ : 'control-slider'
55
+
56
+ const decimals = useMemo(() => {
57
+ if (formatValue) return null
58
+ if (!Number.isFinite(step)) return 0
59
+ if (step >= 1) return 0
60
+ const decimalPart = step.toString().split('.')[1]
61
+ return decimalPart ? decimalPart.length : 2
62
+ }, [formatValue, step])
63
+
64
+ const displayValue = useMemo(() => {
65
+ if (formatValue) return String(formatValue(value))
66
+ if (decimals && decimals > 0) {
67
+ return Number(value).toFixed(decimals)
68
+ }
69
+ return String(Math.round(value))
70
+ }, [decimals, formatValue, value])
71
+
72
+ /* Editable readout — local string state lets the user type intermediate
73
+ * values (e.g. "-" while entering a negative) without clamping mid-keystroke.
74
+ * Commits on blur / Enter; reverts to current value on Escape. */
75
+ const [draft, setDraft] = useState(displayValue)
76
+ const [editing, setEditing] = useState(false)
77
+ useEffect(() => { if (!editing) setDraft(displayValue) }, [displayValue, editing])
78
+
79
+ const commit = () => {
80
+ setEditing(false)
81
+ const parsed = Number(draft)
82
+ if (!Number.isFinite(parsed) || onChange == null) {
83
+ setDraft(displayValue)
84
+ return
85
+ }
86
+ const clamped = Math.max(min, Math.min(max, parsed))
87
+ onChange(clamped)
88
+ setDraft(String(clamped))
89
+ }
90
+
91
+ const onKeyDown = (e) => {
92
+ if (e.key === 'Enter') { e.currentTarget.blur() }
93
+ if (e.key === 'Escape') { setDraft(displayValue); setEditing(false); e.currentTarget.blur() }
94
+ }
95
+
96
+ return (
97
+ <div className={`${variantClass} gap-3 shadow-none ${className}`}>
98
+ {label && (
99
+ <label className="kol-helper-12 whitespace-nowrap shrink-0 w-fit" style={fontSize ? { fontSize } : undefined}>
100
+ {label}
101
+ </label>
102
+ )}
103
+ <input
104
+ type="range"
105
+ min={min}
106
+ max={max}
107
+ step={step}
108
+ value={value}
109
+ onChange={handleChange}
110
+ className="slider-black flex-1 w-full cursor-pointer"
111
+ />
112
+ <Input
113
+ type="text"
114
+ inputMode="decimal"
115
+ variant="filled"
116
+ size="sm"
117
+ chars={displayWidth}
118
+ value={draft}
119
+ onFocus={(e) => { setEditing(true); e.target.select() }}
120
+ onChange={(e) => setDraft(e.target.value)}
121
+ onBlur={commit}
122
+ onKeyDown={onKeyDown}
123
+ inputClassName="text-center"
124
+ />
125
+ </div>
126
+ )
127
+ }
128
+
129
+ export default Slider