@kolkrabbi/kol-component 0.34.0 → 0.36.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.
package/README.md CHANGED
@@ -30,3 +30,15 @@ import { Icon } from '@kolkrabbi/kol-icons'
30
30
  ```
31
31
 
32
32
  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/Tor-Grimsson/kol-ds/tree/main/docs/usage) for real examples of each.
33
+
34
+ ### Deep imports — skip the barrel, skip the peers
35
+
36
+ The barrel (`import { Button } from '@kolkrabbi/kol-component'`) statically imports the whole tree, so building against it requires every organism peer (`framer-motion`, `gsap`, `hls.js`) even if you render none of them — module resolution happens before tree-shaking. Consumers that want a slice import the file directly and only its own chain resolves:
37
+
38
+ ```jsx
39
+ import Button from '@kolkrabbi/kol-component/atoms/Button'
40
+ import Modal from '@kolkrabbi/kol-component/molecules/Modal'
41
+ import useReveal from '@kolkrabbi/kol-component/hooks/useReveal'
42
+ ```
43
+
44
+ Subpaths: `./atoms/*`, `./molecules/*`, `./organisms/*`, `./utilities/*` (`.jsx`) and `./hooks/*` (`.js`). Most files default-export their component; multi-part families (`Modal`, `MenuItem`, `Accordion`) export named — mirror whatever the barrel re-exports. The barrel stays for showcase-style consumers that want everything.
package/package.json CHANGED
@@ -1,13 +1,18 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.34.0",
3
+ "version": "0.36.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"
10
+ ".": "./src/index.js",
11
+ "./atoms/*": "./src/atoms/*.jsx",
12
+ "./molecules/*": "./src/molecules/*.jsx",
13
+ "./organisms/*": "./src/organisms/*.jsx",
14
+ "./utilities/*": "./src/utilities/*.jsx",
15
+ "./hooks/*": "./src/hooks/*.js"
11
16
  },
12
17
  "files": [
13
18
  "src",
@@ -11,10 +11,15 @@
11
11
  * hex — color string, e.g. '#FF6F00'. Ignored if showTransparent.
12
12
  * selected — adds active border + ring (border-fg-64 ring-1).
13
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.
14
+ * for grid cells that should stay square), 'stretch'
15
+ * (w-full h-full, for grid cells that stretch to row height),
16
+ * or 'control-sm' (26px — the kol-control-sm row height, so
17
+ * a swatch sits flush beside sm input chrome in a paint bar;
18
+ * ColorSwatchFieldSizing 2026-08-12). Default 24.
17
19
  * radius — 'sm' (4px, default) | 'tight' (2px) | 'none' | 'full' (circle).
20
+ * Default was 'tight' until 2026-08-12 — flipped to 'sm' per
21
+ * the system radius law (containers are 4px everywhere);
22
+ * square-corner contexts opt out via 'tight'/'none'.
18
23
  * frame — boolean (default true). When false, no border drawn —
19
24
  * used by tightly-packed grid layouts.
20
25
  * variant — 'default' (border-based chrome) |
@@ -41,6 +46,12 @@ const SIZE_CLASSES = {
41
46
  stretch: 'w-full h-full',
42
47
  }
43
48
 
49
+ /* Named px sizes that track the control ladder — inline style, not a
50
+ * utility class (package chrome never rides arbitrary utilities). */
51
+ const NAMED_PX = {
52
+ 'control-sm': 26, // kol-control-sm outer height — paint-bar swatch flush with sm input chrome
53
+ }
54
+
44
55
  const RADIUS_CLASSES = {
45
56
  none: 'rounded-none',
46
57
  tight: 'rounded-[var(--kol-radius-xs)]',
@@ -54,7 +65,7 @@ export default function ColorSwatch({
54
65
  hex,
55
66
  selected = false,
56
67
  size = 24,
57
- radius = 'tight',
68
+ radius = 'sm',
58
69
  frame = true,
59
70
  variant = 'default',
60
71
  hoverable = true,
@@ -66,9 +77,10 @@ export default function ColorSwatch({
66
77
  ...rest
67
78
  }) {
68
79
  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 }
80
+ const resolvedSize = NAMED_PX[size] ?? size
81
+ const isNamed = typeof resolvedSize === 'string'
82
+ const sizeCls = isNamed ? (SIZE_CLASSES[resolvedSize] ?? '') : ''
83
+ const sizeStyle = isNamed ? null : { width: resolvedSize, height: resolvedSize }
72
84
 
73
85
  const isHalo = variant === 'halo'
74
86
  const radiusCls = RADIUS_CLASSES[radius] ?? RADIUS_CLASSES.sm
@@ -9,6 +9,14 @@ import { glyphSize } from '../hooks/glyphLadders.js'
9
9
  * treatment (2026-07-08 chrome law: one
10
10
  * secondary, always subordinate to filled)
11
11
  * variant="ghost" — legacy alias, resolves to outline
12
+ * variant="property" — the Figma property field (PropertyField,
13
+ * 2026-08-12): filled chrome, dim `affordance`
14
+ * (letter or icon node) at a 6px gap, the value
15
+ * HUGS its own length (mono ch-width — number-
16
+ * safe, no `size` attr), and `unit` renders
17
+ * IMMEDIATELY after the value (`0°`, `100%`).
18
+ * Shell fills its cell, content left-packed.
19
+ * Width tracks `value` — controlled usage only.
12
20
  *
13
21
  * size="sm" / "md" (default) / "lg" — matched padding + type class
14
22
  *
@@ -26,6 +34,10 @@ import { glyphSize } from '../hooks/glyphLadders.js'
26
34
  * the shell at text-meta. aria-hidden — affordances, not labels.
27
35
  * iconLeft — name of a leading icon rendered inside the shell (e.g.
28
36
  * "search-16"). iconSize overrides the size-derived default.
37
+ * slotLeft — arbitrary leading node rendered inside the shell, before
38
+ * iconLeft/prefix — the paint-bar anatomy ([swatch] FFFFFF is ONE
39
+ * container, not two boxes; ColorSwatchFieldSizing 2026-08-12). The
40
+ * consumer owns the node's sizing; the shell's padding frames it.
29
41
  *
30
42
  * Chrome (bg/border/padding/transition/disabled) comes from .kol-control;
31
43
  * Input owns prefix/suffix/icon layout + the inner <input> styling.
@@ -42,6 +54,9 @@ export default function Input({
42
54
  chars,
43
55
  prefix,
44
56
  suffix,
57
+ slotLeft,
58
+ affordance,
59
+ unit,
45
60
  iconLeft,
46
61
  iconSize = null,
47
62
  placeholder,
@@ -61,7 +76,9 @@ export default function Input({
61
76
  const resolvedIconSize = iconSize ?? glyphSize(size)
62
77
 
63
78
  // ghost folds into outline (2026-07-08 chrome law): one secondary treatment.
64
- const resolvedVariant = variant === 'ghost' ? 'outline' : variant
79
+ // property rides the filled chrome it is a behaviour variant, not new paint.
80
+ const isProperty = variant === 'property'
81
+ const resolvedVariant = variant === 'ghost' ? 'outline' : isProperty ? 'filled' : variant
65
82
 
66
83
  const shellCls = [
67
84
  'kol-control',
@@ -69,9 +86,16 @@ export default function Input({
69
86
  `kol-control-${size}`,
70
87
  SIZE_TYPE[size],
71
88
  'cursor-text',
89
+ isProperty && 'w-full',
72
90
  className,
73
91
  ].filter(Boolean).join(' ')
74
92
 
93
+ /* Property width: the shell type is mono, so every glyph is exactly 1ch —
94
+ * `${len}ch` hugs the value with no probe element, and stays number-safe
95
+ * where the HTML `size` attr is ignored (<input type="number">). +2px keeps
96
+ * the caret from clipping at the end. Tracks `value` → controlled only. */
97
+ const propertyLen = Math.max(String(value ?? placeholder ?? '').length, 1)
98
+
75
99
  /* Pin inner input height to the typography token's line-height. Without
76
100
  * this the `<input>` renders ~0.5px taller than the equivalent <button>
77
101
  * or <label> at the same kol-mono-N — Chromium computes input height
@@ -83,7 +107,7 @@ export default function Input({
83
107
  const inputCls = [
84
108
  'min-w-0 bg-transparent border-none outline-none text-auto',
85
109
  heightCls,
86
- !fixedChars && 'flex-1',
110
+ !fixedChars && !isProperty && 'flex-1',
87
111
  /* Balance the dim prefix/suffix visual weight with extra inner padding
88
112
  * on the opposite side. Without this the bright value sits closer to
89
113
  * the affordance than to the empty edge, reads off-balance. */
@@ -99,6 +123,9 @@ export default function Input({
99
123
  style={width ? { width: typeof width === 'number' ? `${width}px` : width } : undefined}
100
124
  aria-disabled={disabled || undefined}
101
125
  >
126
+ {slotLeft && (
127
+ <span className="flex items-center shrink-0 pr-2">{slotLeft}</span>
128
+ )}
102
129
  {iconLeft && (
103
130
  <span aria-hidden="true" className="flex items-center text-auto opacity-50 shrink-0 pr-2">
104
131
  <Icon name={iconLeft} size={resolvedIconSize} />
@@ -107,6 +134,9 @@ export default function Input({
107
134
  {prefix !== undefined && (
108
135
  <span aria-hidden="true" className="text-meta pr-1 shrink-0">{prefix}</span>
109
136
  )}
137
+ {affordance !== undefined && (
138
+ <span aria-hidden="true" className="text-meta pr-1.5 shrink-0 inline-flex items-center">{affordance}</span>
139
+ )}
110
140
  {/* Controlled only when a `value` prop is passed — otherwise stay
111
141
  * uncontrolled so prop-less usages (search stubs, quick demos) type
112
142
  * normally instead of freezing on a value-without-onChange input.
@@ -121,8 +151,12 @@ export default function Input({
121
151
  spellCheck={false}
122
152
  size={fixedChars ? chars : undefined}
123
153
  className={inputCls}
154
+ style={isProperty ? { width: `calc(${propertyLen}ch + 2px)` } : undefined}
124
155
  {...inputProps}
125
156
  />
157
+ {unit !== undefined && (
158
+ <span aria-hidden="true" className="text-meta shrink-0">{unit}</span>
159
+ )}
126
160
  {suffix !== undefined && (
127
161
  <span aria-hidden="true" className="text-meta pl-1 shrink-0">{suffix}</span>
128
162
  )}
@@ -23,9 +23,17 @@
23
23
  * the group on the active cell, ←/→ (or ↑/↓) move selection + focus.
24
24
  *
25
25
  * Props:
26
- * value — current option value
26
+ * value — current option value. `null`/`undefined` = STATELESS mode
27
+ * (the segmented state law, 2026-08-12): role `group`, no
28
+ * aria-checked, no selected styling — a pure one-shot ACTION
29
+ * strip (canvas alignment, transform cluster); onChange is the
30
+ * action dispatch.
27
31
  * onChange — handler (newValue) => void
28
32
  * options — [{ value, label, ariaLabel? }]
33
+ * variant — 'default' (shipped chrome: shared outer stroke + dividers,
34
+ * filled active cell) | 'filled' (the state law's tiles: every
35
+ * cell a surface-secondary tile with 1px transparent gaps, NO
36
+ * outline shell; the inset ring marks ONLY the selected cell).
29
37
  * size — mirrors Button exactly: 'sm' (26px, mono-12, 4/12 pad) |
30
38
  * 'md' (default, 32px, mono-14, 6/16 pad) | 'lg' (40px,
31
39
  * mono-16, 8/20 pad). Same cell padding + mono type as the
@@ -34,11 +42,13 @@
34
42
  * ariaLabel — accessible name for the group
35
43
  * className — additional classes on the outer shell
36
44
  */
37
- export default function SegmentedToggle({ value, onChange, options = [], size = 'md', ariaLabel, className = '' }) {
45
+ export default function SegmentedToggle({ value, onChange, options = [], variant = 'default', size = 'md', ariaLabel, className = '' }) {
38
46
  const cellType = { sm: 'kol-mono-12', md: 'kol-mono-14', lg: 'kol-mono-16' }[size]
47
+ const stateless = value == null
39
48
  const focusIdx = Math.max(0, options.findIndex((opt) => opt.value === value))
40
49
 
41
50
  const handleKeyDown = (e) => {
51
+ if (stateless) return // plain button row — Tab moves focus, arrows do nothing
42
52
  const dir = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 }[e.key]
43
53
  if (!dir || !options.length) return
44
54
  e.preventDefault()
@@ -49,21 +59,21 @@ export default function SegmentedToggle({ value, onChange, options = [], size =
49
59
 
50
60
  return (
51
61
  <div
52
- role="radiogroup"
62
+ role={stateless ? 'group' : 'radiogroup'}
53
63
  aria-label={ariaLabel}
54
64
  onKeyDown={handleKeyDown}
55
- className={['kol-seg', size !== 'md' && `kol-seg--${size}`, className].filter(Boolean).join(' ')}
65
+ className={['kol-seg', variant === 'filled' && 'kol-seg--filled', size !== 'md' && `kol-seg--${size}`, className].filter(Boolean).join(' ')}
56
66
  >
57
67
  {options.map((opt, i) => {
58
- const isActive = opt.value === value
68
+ const isActive = !stateless && opt.value === value
59
69
  return (
60
70
  <button
61
71
  key={opt.value}
62
72
  type="button"
63
- role="radio"
64
- aria-checked={isActive}
73
+ role={stateless ? undefined : 'radio'}
74
+ aria-checked={stateless ? undefined : isActive}
65
75
  aria-label={opt.ariaLabel}
66
- tabIndex={i === focusIdx ? 0 : -1}
76
+ tabIndex={stateless ? 0 : (i === focusIdx ? 0 : -1)}
67
77
  onClick={() => onChange?.(opt.value)}
68
78
  className={['kol-seg-cell', cellType, isActive && 'is-active'].filter(Boolean).join(' ')}
69
79
  >
@@ -15,7 +15,14 @@ import { Icon } from '@kolkrabbi/kol-icons'
15
15
  * dominating the panel). Resize is real (2026-07-08): native resize is
16
16
  * OFF (Firefox's built-in grip cannot be hidden any other way) and the
17
17
  * kol-icon-set-v1 `resize-grip` icon IS the drag handle — corner drag,
18
- * both axes, min 120×40. One grip, every browser.
18
+ * min 120×40. One grip, every browser.
19
+ *
20
+ * The X-drag is container-clamped (2026-08-12, TextareaResizeClamp): the
21
+ * width write caps at the parent's content width, so a drag can never
22
+ * overflow the box the Textarea sits in. `axis` narrows the drag:
23
+ * axis="both" (default) — corner drag, clamped
24
+ * axis="y" — height only; rail-mounted textareas have
25
+ * nowhere meaningful to grow on X
19
26
  *
20
27
  * Controlled OR uncontrolled:
21
28
  * - pass `value` + `onChange` for controlled,
@@ -32,6 +39,7 @@ export default function Textarea({
32
39
  variant = 'filled',
33
40
  size = 'md',
34
41
  rows = 3,
42
+ axis = 'both',
35
43
  placeholder,
36
44
  disabled = false,
37
45
  className = '',
@@ -72,8 +80,11 @@ export default function Textarea({
72
80
  aria-hidden="true"
73
81
  className="kol-textarea-resize-icon"
74
82
  onPointerDown={(e) => {
75
- // The grip IS the resize handle — corner drag, both axes
76
- // (min 120×40). Width lands on the shell, height on the textarea.
83
+ // The grip IS the resize handle — corner drag (min 120×40).
84
+ // Width lands on the shell, height on the textarea. The width
85
+ // write is clamped to the parent's content width so an X-drag
86
+ // can never overflow the container; the 120 floor wins if the
87
+ // container is narrower than that.
77
88
  const shell = e.currentTarget.parentElement
78
89
  const ta = shell?.querySelector('textarea')
79
90
  if (!shell || !ta) return
@@ -82,8 +93,17 @@ export default function Textarea({
82
93
  const startY = e.clientY
83
94
  const startW = shell.offsetWidth
84
95
  const startH = ta.offsetHeight
96
+ const parent = shell.parentElement
97
+ let maxW = Infinity
98
+ if (parent) {
99
+ const pcs = getComputedStyle(parent)
100
+ maxW = parent.clientWidth
101
+ - parseFloat(pcs.paddingLeft) - parseFloat(pcs.paddingRight)
102
+ }
85
103
  const move = (ev) => {
86
- shell.style.width = `${Math.max(startW + ev.clientX - startX, 120)}px`
104
+ if (axis !== 'y') {
105
+ shell.style.width = `${Math.max(Math.min(startW + ev.clientX - startX, maxW), 120)}px`
106
+ }
87
107
  ta.style.height = `${Math.max(startH + ev.clientY - startY, 40)}px`
88
108
  }
89
109
  const up = () => {
@@ -127,7 +127,9 @@ export default function ColorInputRow({
127
127
  chip(24)
128
128
  )
129
129
 
130
- const labelCls = `kol-helper-12 truncate ${unused ? 'text-meta' : 'text-emphasis'}`
130
+ /* leading-normal: truncate's overflow clip cuts mono descenders on
131
+ * kol-helper's 1-em line box (MenuItemDescenderClip sweep, 2026-08-12). */
132
+ const labelCls = `kol-helper-12 truncate leading-normal ${unused ? 'text-meta' : 'text-emphasis'}`
131
133
 
132
134
  const hexInput = (
133
135
  <Input
@@ -156,7 +158,7 @@ export default function ColorInputRow({
156
158
  >
157
159
  {swatchCell}
158
160
  <span className={labelCls}>{labelVisible ? label : ''}</span>
159
- <span className={`kol-helper-10 truncate ${unused ? 'text-subtle' : 'text-meta'}`}>
161
+ <span className={`kol-helper-10 truncate leading-normal ${unused ? 'text-subtle' : 'text-meta'}`}>
160
162
  {tokenName}
161
163
  </span>
162
164
  {hexInput}
@@ -236,7 +236,8 @@ export default function FieldRow({
236
236
  <div className={`kol-mono-12 text-emphasis${type === 'media' ? ' self-start pt-1' : ''}`}>{label}</div>
237
237
  <div className="min-w-0">{control}</div>
238
238
  {hint != null && (
239
- <div className="col-start-2 kol-helper-12 text-meta pt-2 truncate">{hint}</div>
239
+ /* leading-normal: descender fix (MenuItemDescenderClip sweep, 2026-08-12) */
240
+ <div className="col-start-2 kol-helper-12 text-meta pt-2 truncate leading-normal">{hint}</div>
240
241
  )}
241
242
  </div>
242
243
  )
@@ -101,7 +101,11 @@ export function MenuDropdownItem({ onClick, disabled, prefix, iconLeft, shortcut
101
101
  >
102
102
  {prefix && <span className="shrink-0 inline-flex items-center">{prefix}</span>}
103
103
  {iconLeft && <span className="shrink-0 w-4 inline-flex items-center justify-center">{iconLeft}</span>}
104
- <span className="flex-1 truncate">{children}</span>
104
+ {/* leading-normal: kol-helper-12 is line-height 1, and truncate's
105
+ * overflow clip cuts mono descenders on a 1-em line box ("Show grid"
106
+ * loses its g). The h-8 centered row absorbs the taller line box —
107
+ * zero layout shift (MenuItemDescenderClip, 2026-08-12). */}
108
+ <span className="flex-1 truncate leading-normal">{children}</span>
105
109
  {shortcut && <span className="kol-helper-10 text-emphasis shrink-0 inline-flex items-center">{shortcut}</span>}
106
110
  </button>
107
111
  )
@@ -133,7 +137,8 @@ export function MenuDropdownNest({ prefix, iconLeft, label, children }) {
133
137
  >
134
138
  {prefix && <span className="shrink-0 inline-flex items-center">{prefix}</span>}
135
139
  {iconLeft && <span className="shrink-0 w-4 inline-flex items-center justify-center">{iconLeft}</span>}
136
- <span className="flex-1 truncate">{label}</span>
140
+ {/* leading-normal — same descender fix as MenuDropdownItem above. */}
141
+ <span className="flex-1 truncate leading-normal">{label}</span>
137
142
  <Icon
138
143
  name="chevron-down"
139
144
  size={10}
@@ -9,6 +9,13 @@ import Input from '../atoms/Input.jsx'
9
9
  * const { prompt, confirm } = useModal()
10
10
  * const name = await prompt('Name this frame:', 'Untitled')
11
11
  * const proceed = await confirm('Discard unsaved changes?')
12
+ * const restore = await confirm('Restore your last canvas?',
13
+ * { okLabel: 'Restore', cancelLabel: 'New file' })
14
+ *
15
+ * Both take an options object — `{ okLabel, cancelLabel }` (prompt: third
16
+ * arg, after defaultValue) — so the buttons can SAY the outcome; defaults
17
+ * stay OK / Cancel, existing callers untouched (ModalConfirmLabels,
18
+ * 2026-08-12). Enter/Escape keep their meanings regardless of labels.
12
19
  *
13
20
  * Returned promise resolves to:
14
21
  * - prompt → string (value) on submit, `null` on cancel
@@ -30,12 +37,12 @@ export function ModalProvider({ children }) {
30
37
  })
31
38
  }, [])
32
39
 
33
- const prompt = useCallback((title, defaultValue = '') =>
34
- new Promise((resolve) => setState({ kind: 'prompt', title, defaultValue, resolve })),
40
+ const prompt = useCallback((title, defaultValue = '', { okLabel, cancelLabel } = {}) =>
41
+ new Promise((resolve) => setState({ kind: 'prompt', title, defaultValue, okLabel, cancelLabel, resolve })),
35
42
  [])
36
43
 
37
- const confirm = useCallback((title) =>
38
- new Promise((resolve) => setState({ kind: 'confirm', title, resolve })),
44
+ const confirm = useCallback((title, { okLabel, cancelLabel } = {}) =>
45
+ new Promise((resolve) => setState({ kind: 'confirm', title, okLabel, cancelLabel, resolve })),
39
46
  [])
40
47
 
41
48
  return (
@@ -85,7 +92,9 @@ function ModalView({ state, closeWith }) {
85
92
  maxWidth: '90vw',
86
93
  }}
87
94
  >
88
- <p className="kol-helper-12 text-emphasis">{state.title}</p>
95
+ {/* kol-mono-12, not helper: dialog copy WRAPS, and helper's
96
+ * line-height 1 is single-line chrome only (type protocol). */}
97
+ <p className="kol-mono-12 text-emphasis">{state.title}</p>
89
98
  {state.kind === 'prompt' && (
90
99
  <Input
91
100
  ref={inputRef}
@@ -97,19 +106,28 @@ function ModalView({ state, closeWith }) {
97
106
  />
98
107
  )}
99
108
  <div className="flex gap-2 justify-end">
100
- <Button variant="secondary" size="sm" onClick={cancel}>Cancel</Button>
101
- <Button variant="primary" size="sm" onClick={submit}>OK</Button>
109
+ <Button variant="secondary" size="sm" onClick={cancel}>{state.cancelLabel ?? 'Cancel'}</Button>
110
+ <Button variant="primary" size="sm" onClick={submit}>{state.okLabel ?? 'OK'}</Button>
102
111
  </div>
103
112
  </div>
104
113
  </div>
105
114
  )
106
115
  }
107
116
 
117
+ /* Warn once, not per call — the fallback swallowing a missing ModalProvider
118
+ * silently is how kol-fxr ran months on native window.confirm without
119
+ * noticing (ModalConfirmLabels, 2026-08-12). */
120
+ let warnedNoProvider = false
121
+
108
122
  export function useModal() {
109
123
  const ctx = useContext(ModalCtx)
110
124
  if (ctx) return ctx
111
125
  /* No-context fallback — falls back to native prompt/confirm so callers
112
126
  * don't need to null-check. */
127
+ if (!warnedNoProvider && typeof console !== 'undefined') {
128
+ warnedNoProvider = true
129
+ console.warn('[kol] useModal(): no <ModalProvider> mounted — falling back to native window.prompt/confirm. Custom labels are ignored on the fallback.')
130
+ }
113
131
  return {
114
132
  prompt: async (title, def = '') => {
115
133
  if (typeof window === 'undefined') return null
@@ -353,7 +353,7 @@ export default function RecordManager({
353
353
  {onPreview && (
354
354
  <ToolbarIcon name="play" label="Preview" onClick={() => onPreview(record)} />
355
355
  )}
356
- {saveState != null && <span className="kol-helper-10 text-meta truncate">{saveState}</span>}
356
+ {saveState != null && <span className="kol-helper-10 text-meta truncate leading-normal">{saveState}</span>}
357
357
  {onPublish && (
358
358
  <Button variant="primary" size="sm" onClick={() => onPublish(record)}>
359
359
  {publishLabel}