@kolkrabbi/kol-component 0.223.0 → 0.225.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.
@@ -171,7 +171,11 @@ export function LabeledControlSection({ label, divided = false, rowGap = 2, chil
171
171
  * rebuilt this same Dropdown in a local `ChoiceRow`, which is the exact
172
172
  * duplication this component exists to prevent. A component that wraps another
173
173
  * has to forward the wrapped one's seams or it becomes a wall. */
174
- export function SettingsChoice({ options = [], value, onChange, ariaLabel, tone = 'default', size = 'sm', variant = 'primary', className = '' }) {
174
+ /* `variant` UNSET by default (the Hub review, 2026-09-26): a forced `primary` blocked the
175
+ * tone of a `kol-tone-*` wrapper, so a sunken settings page drew a light well in every row
176
+ * unless the row said `tone="sunken"` itself. Unset, Dropdown takes the wrapper's tone and
177
+ * falls back to primary — the same render wherever there is no wrapper. */
178
+ export function SettingsChoice({ options = [], value, onChange, ariaLabel, tone = 'default', size = 'sm', variant, className = '' }) {
175
179
  const opts = options.map((o) => (o != null && typeof o === 'object' ? o : { value: o, label: String(o) }))
176
180
  return <Dropdown size={size} variant={variant} tone={tone} value={value} onChange={onChange} options={opts} className={`w-full ${className}`.trim()} aria-label={ariaLabel} />
177
181
  }
@@ -35,7 +35,7 @@ import { LabeledControlSection, SettingsRow } from './SettingsPanel.jsx'
35
35
  * first included: the rule is `.kol-section--divided + .kol-section--divided`,
36
36
  * so the first draws nothing and skipping it just costs the next its line.
37
37
  *
38
- * @param {Array<{label?: string, rows: Array<{label: ReactNode, render?: Function, value?: ReactNode, align?: string, labelWidth?: number|'auto', id?: string}>, rowGap?: number}>} sections - The settings body, in order
38
+ * @param {Array<{label?: string, rows: Array<{label: ReactNode, render?: Function, value?: ReactNode, hint?: string, align?: string, labelWidth?: number|'auto', id?: string}>, rowGap?: number}>} sections - The settings body, in order (`hint` → SettingsRow's hint)
39
39
  * @param {boolean} [divided=false] - Hairline above each section
40
40
  * @param {number|'auto'} [labelWidth] - Default label column for every row — `'auto'` makes the label yield and the control hug, which is what a narrow column or a nowrap value needs
41
41
  * @param {string} [align] - Default row alignment passed to `SettingsRow`
@@ -50,6 +50,7 @@ export default function SettingsSections({ sections = [], divided = false, label
50
50
  <SettingsRow
51
51
  key={row.id ?? row.label ?? j}
52
52
  label={row.label}
53
+ hint={row.hint}
53
54
  align={row.align ?? align}
54
55
  labelWidth={row.labelWidth ?? labelWidth}
55
56
  >
@@ -1,6 +1,7 @@
1
1
  import { useEffect, useState } from 'react'
2
2
  import ColorSwatch from '../atoms/ColorSwatch.jsx'
3
3
  import { resolveCssVar, resolveCssColor, isLight } from '../hooks/cssVar.js'
4
+ import { Tooltip } from '../utilities/Popover.jsx'
4
5
 
5
6
  /**
6
7
  * SpectrumGrid — matrix view of the whole ramp system: rows = ramps, columns =
@@ -63,10 +64,9 @@ export default function SpectrumGrid({ ramps, stops = DEFAULT_STOPS, brandAnchor
63
64
  const textColor = isLight(color) ? '#000' : '#fff'
64
65
  const brand = isBrandAnchor(ramp, stop)
65
66
  return (
67
+ <Tooltip key={stop} label={`${ramp}-${stop} ${hex}${brand ? ' · brand anchor' : ''}`} asChild>
66
68
  <div
67
- key={stop}
68
69
  className="relative aspect-[5/3] rounded overflow-hidden transition-transform hover:scale-[1.04]"
69
- title={`${ramp}-${stop} ${hex}${brand ? ' · brand anchor' : ''}`}
70
70
  >
71
71
  <ColorSwatch hex={`var(--${ramp}-${stop})`} size="stretch" radius="none" frame={false} className="absolute inset-0" />
72
72
  <div className="pointer-events-none absolute inset-0 px-2 py-1.5" style={{ color: textColor }}>
@@ -81,6 +81,7 @@ export default function SpectrumGrid({ ramps, stops = DEFAULT_STOPS, brandAnchor
81
81
  )}
82
82
  </div>
83
83
  </div>
84
+ </Tooltip>
84
85
  )
85
86
  })}
86
87
  </div>
@@ -1,6 +1,7 @@
1
1
  import { useRef, useState } from 'react'
2
2
  import Input from '../atoms/Input.jsx'
3
3
  import Dropdown from '../molecules/Dropdown.jsx'
4
+ import { Tooltip } from '../utilities/Popover.jsx'
4
5
 
5
6
  /* The six easings the key editor offers. The CURVES stay the consumer's
6
7
  * (its interpolator resolves the name); this is the menu, not the math. */
@@ -123,9 +124,11 @@ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
123
124
 
124
125
  return (
125
126
  <div className="flex items-center gap-3">
126
- <span className="kol-helper-10 text-meta truncate shrink-0 text-right" style={{ width: 120 }} title={track.label}>
127
+ <Tooltip label={track.label} asChild>
128
+ <span className="kol-helper-10 text-meta truncate shrink-0 text-right" style={{ width: 120 }}>
127
129
  {track.label}
128
130
  </span>
131
+ </Tooltip>
129
132
  <div
130
133
  ref={laneRef}
131
134
  className="relative flex-1 h-5 rounded cursor-copy"
@@ -136,13 +139,12 @@ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
136
139
  {track.keys.map((k, i) => {
137
140
  const kt = drag.current?.index === i ? drag.current.t : k.t
138
141
  return (
142
+ <Tooltip key={i} label={`t=${kt.toFixed(2)} v=${typeof k.v === 'number' ? Math.round(k.v * 100) / 100 : k.v} (alt-click deletes)`} asChild>
139
143
  <span
140
- key={i}
141
144
  data-diamond=""
142
145
  onPointerDown={onDiamondPointerDown(i)}
143
146
  onPointerMove={onDiamondPointerMove(i)}
144
147
  onPointerUp={onDiamondPointerUp(i)}
145
- title={`t=${kt.toFixed(2)} v=${typeof k.v === 'number' ? Math.round(k.v * 100) / 100 : k.v} (alt-click deletes)`}
146
148
  className="absolute top-1/2 cursor-grab"
147
149
  style={{
148
150
  left: `${kt * 100}%`,
@@ -152,6 +154,7 @@ function TrackRow({ track, t, selected, setSelected, writeKeys }) {
152
154
  borderRadius: 1.5,
153
155
  }}
154
156
  />
157
+ </Tooltip>
155
158
  )
156
159
  })}
157
160
  </div>
@@ -2,6 +2,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
2
2
  import Button from '../atoms/Button.jsx'
3
3
  import Divider from '../atoms/Divider.jsx'
4
4
  import SplitToolButton from '../molecules/SplitToolButton.jsx'
5
+ import { Tooltip } from '../utilities/Popover.jsx'
5
6
 
6
7
  /**
7
8
  * ToolPalette — the editor's tool bar as one row (kol-fxr `shell/panels/ToolPalette.jsx`,
@@ -45,7 +46,6 @@ const ToolPalette = ({
45
46
  <div className={`flex items-center gap-1 min-w-0 overflow-x-auto ${className}`.trim()}>
46
47
  {items.map((it, i) => {
47
48
  if (it.kind === 'divider') return <Divider key={`d${i}`} variant="vertical" height={20} className="mx-1.5 shrink-0" />
48
- const tip = it.shortcut ? `${it.label} (${it.shortcut})` : it.label
49
49
  if (it.kind === 'split') {
50
50
  const variants = (it.variants ?? []).map((v) => (v.action && !it.action ? { ...v, onSelect: () => onAction?.(v.id) } : v))
51
51
  const run = (id) => (it.action ? onAction : onSelect)?.(id)
@@ -58,10 +58,13 @@ const ToolPalette = ({
58
58
  }
59
59
  const isTool = it.kind === 'tool'
60
60
  return (
61
- <Button key={it.id} variant="ghost" size={size} quiet iconOnly={it.icon} iconComponent={iconComponent}
61
+ /* the DS tooltip carries the key as its own chip (the native-title sweep, 2026-09-26) */
62
+ <Tooltip key={it.id} label={it.label} shortcut={it.shortcut}>
63
+ <Button variant="ghost" size={size} quiet iconOnly={it.icon} iconComponent={iconComponent}
62
64
  pressed={isTool ? activeId === it.id : undefined} disabled={it.disabled}
63
- aria-label={it.label} title={tip}
65
+ aria-label={it.label}
64
66
  onClick={(e) => { (isTool ? onSelect : onAction)?.(it.id); blur(e) }} />
67
+ </Tooltip>
65
68
  )
66
69
  })}
67
70
  </div>
@@ -1,5 +1,5 @@
1
- import { createContext, useContext, useState } from 'react'
2
- import { toneClass } from './tone.js'
1
+ import { cloneElement, createContext, isValidElement, useContext, useLayoutEffect, useState, version as reactVersion } from 'react'
2
+ import { toneClass, TONE_VARS } from './tone.js'
3
3
  import {
4
4
  useFloating,
5
5
  autoUpdate,
@@ -136,13 +136,29 @@ export function usePopover({
136
136
  * — the innermost wins, as the native `title` it replaced behaved. */
137
137
  const TooltipNest = createContext(null)
138
138
 
139
- export function Tooltip({
139
+ /* `asChild` (the native-title sweep, 2026-09-26): the tooltip attaches to its one child element
140
+ * instead of wrapping it in a span — for a trigger whose box IS the layout (a grid cell, a rail row,
141
+ * an absolutely placed handle), where an inline-flex wrapper would move it. The child must be an
142
+ * element that takes a ref: an intrinsic tag always does; a component only if it forwards one
143
+ * (React 18 needs forwardRef), so Button / IconFrame keep the wrapper. Its own ref and handlers are
144
+ * kept — the reference props merge over the child's. */
145
+ const childRefOf = (el) => ('ref' in (el.props ?? {}) ? el.props.ref : reactVersion.startsWith('18') ? el.ref : undefined)
146
+
147
+ export function Tooltip(props) {
148
+ /* no label → no tooltip: several call sites pass an optional hint, and an empty panel is worse
149
+ * than none. A separate component so the hooks below never run conditionally. */
150
+ if (props.label == null || props.label === '') return props.children
151
+ return <TooltipOn {...props} />
152
+ }
153
+
154
+ function TooltipOn({
140
155
  label,
141
156
  shortcut,
142
157
  placement = 'bottom',
143
158
  offset = 6,
144
159
  children,
145
160
  triggerClassName = 'inline-flex',
161
+ asChild = false,
146
162
  }) {
147
163
  const [open, setOpen] = useState(false)
148
164
  const [covered, setCovered] = useState(false)
@@ -158,22 +174,56 @@ export function Tooltip({
158
174
  focus: true,
159
175
  })
160
176
 
177
+ /* THE TONE OF WHERE IT IS (user, 2026-09-26: "tooltip should probably use same tone as dropdowns
178
+ * and buttons"). The panel is portalled to body, outside the `kol-tone-*` wrapper that tones the
179
+ * controls around the trigger — Dropdown's problem, solved the same way: on open, copy the
180
+ * trigger's RESOLVED `--kol-tone-*` onto the panel. No tone in effect = nothing copied, and the
181
+ * tooltip keeps its own surface. */
182
+ const [ambient, setAmbient] = useState(null)
183
+ const shown = open && !covered
184
+ useLayoutEffect(() => {
185
+ const ref = popover.refs.reference.current
186
+ /* the SURROUNDINGS' tone, not the trigger's own: an `asChild` trigger can be a variant button
187
+ * (`kol-btn-ghost` carries its own `--kol-tone-*`), so read its parent; the wrapper span has no
188
+ * variant and inherits the ambient values as it is */
189
+ const el = asChild ? ref?.parentElement : ref
190
+ if (!shown || !(el instanceof Element)) return
191
+ const cs = getComputedStyle(el)
192
+ const vars = {}
193
+ for (const v of TONE_VARS) { const val = cs.getPropertyValue(v).trim(); if (val) vars[v] = val }
194
+ setAmbient(vars)
195
+ }, [shown]) // eslint-disable-line react-hooks/exhaustive-deps
196
+
197
+ const child = asChild && isValidElement(children) ? children : null
198
+ const childRef = child ? childRefOf(child) : undefined
199
+ const setRef = (node) => {
200
+ popover.refs.setReference(node)
201
+ if (typeof childRef === 'function') childRef(node)
202
+ else if (childRef) childRef.current = node
203
+ }
161
204
  return (
162
205
  <>
163
- <span
164
- ref={popover.refs.setReference}
165
- {...popover.getReferenceProps()}
166
- className={triggerClassName}
167
- >
168
- <TooltipNest.Provider value={setCovered}>{children}</TooltipNest.Provider>
169
- </span>
206
+ {child ? (
207
+ <TooltipNest.Provider value={setCovered}>
208
+ {cloneElement(child, { ...popover.getReferenceProps(child.props), ref: setRef })}
209
+ </TooltipNest.Provider>
210
+ ) : (
211
+ <span
212
+ ref={popover.refs.setReference}
213
+ {...popover.getReferenceProps()}
214
+ className={triggerClassName}
215
+ >
216
+ <TooltipNest.Provider value={setCovered}>{children}</TooltipNest.Provider>
217
+ </span>
218
+ )}
170
219
  <PopoverPanel
171
220
  popover={popover}
172
221
  focus={false}
173
222
  panel={false}
174
223
  className="kol-tooltip"
224
+ style={ambient ?? undefined}
175
225
  >
176
- <span className="text-emphasis">{label}</span>
226
+ <span>{label}</span>
177
227
  {shortcut && <span className="kol-tooltip-key">{shortcut}</span>}
178
228
  </PopoverPanel>
179
229
  </>
@@ -38,6 +38,7 @@
38
38
  */
39
39
 
40
40
  import { useCallback, useEffect, useRef, useState } from 'react'
41
+ import { Tooltip } from './Popover.jsx'
41
42
 
42
43
  /* ── coordinates ─────────────────────────────────────────────────────────
43
44
  * Pure. No DOM writes. The invariant everything rests on: a cell is a
@@ -442,14 +443,15 @@ export default function QuadrantSync ({
442
443
  className="kol-mono-12 text-fg-96 bg-fg-04 border border-fg-16"
443
444
  style={{ width: 52, padding: '5px 6px', textAlign: 'center', borderRadius: 4 }}
444
445
  />
446
+ <Tooltip label="Set divisions so cells come out square at the current size" asChild>
445
447
  <button
446
448
  type="button" onClick={applySquare}
447
- title="Set divisions so cells come out square at the current size"
448
449
  className="kol-helper-10 text-fg-96 border border-fg-16"
449
450
  style={{ marginLeft: 'auto', padding: '5px 9px', borderRadius: 4, background: 'transparent', cursor: 'pointer' }}
450
451
  >
451
452
  square
452
453
  </button>
454
+ </Tooltip>
453
455
  </div>
454
456
  {aspect && (
455
457
  <div className="kol-mono-10 text-fg-48" style={{ marginTop: 6 }}>
@@ -27,3 +27,50 @@ export function parseFrontmatter(raw) {
27
27
  }
28
28
  return metadata
29
29
  }
30
+
31
+ /**
32
+ * splitFrontmatter / joinFrontmatter — the EDITOR's pair (DocumentEditor, 2026-09-26). Where
33
+ * `parseFrontmatter` reads for display (keys lowercased, lists flattened), these round-trip a file:
34
+ * keys keep their case and their order, a list stays a list, and the body is untouched — so opening
35
+ * and saving a document without touching the form changes nothing but what was edited.
36
+ *
37
+ * `fields` is `[[key, value]]`, value a string or an array of strings.
38
+ */
39
+ const unquote = (v) => {
40
+ if (v.length > 1 && v.startsWith('"') && v.endsWith('"')) { try { return JSON.parse(v) } catch { return v.slice(1, -1) } }
41
+ if (v.length > 1 && v.startsWith("'") && v.endsWith("'")) return v.slice(1, -1).replace(/''/g, "'")
42
+ return v
43
+ }
44
+
45
+ export function splitFrontmatter(raw = '') {
46
+ const match = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
47
+ if (!match) return { has: false, fields: [], body: raw }
48
+ const fields = []
49
+ const lines = match[1].split(/\r?\n/)
50
+ for (let i = 0; i < lines.length; i++) {
51
+ const kv = lines[i].match(/^([A-Za-z_][\w -]*):\s*(.*)$/)
52
+ if (!kv) continue
53
+ let value = kv[2].trim()
54
+ if (!value) {
55
+ const items = []
56
+ while (i + 1 < lines.length && /^\s+-\s+/.test(lines[i + 1])) { i++; items.push(lines[i].replace(/^\s+-\s+/, '').trim()) }
57
+ value = items.length ? items : ''
58
+ } else if (value.startsWith('[') && value.endsWith(']')) {
59
+ value = value.slice(1, -1).split(',').map((t) => unquote(t.trim())).filter(Boolean)
60
+ } else {
61
+ value = unquote(value)
62
+ }
63
+ fields.push([kv[1], value])
64
+ }
65
+ return { has: true, fields, body: raw.slice(match[0].length) }
66
+ }
67
+
68
+ /* bare when YAML reads it back as the same string; quoted otherwise (a colon, a leading #, quotes) */
69
+ const yamlScalar = (v) => (/^[\w./@+-][\w ./@+,-]*$/.test(v) || v === '' ? v : JSON.stringify(v))
70
+
71
+ export function joinFrontmatter(fields, body = '') {
72
+ const rows = fields.filter(([key]) => String(key).trim())
73
+ if (!rows.length) return body
74
+ const yaml = rows.map(([key, v]) => (Array.isArray(v) ? `${key}: [${v.map(yamlScalar).join(', ')}]` : `${key}: ${yamlScalar(String(v ?? ''))}`)).join('\n')
75
+ return `---\n${yaml}\n---\n${body.startsWith('\n') ? body.slice(1) : body}`
76
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * localDrafts — a text file's unsaved edit, in BROWSER memory (media D1 plan v2, user ruling
3
+ * 2026-09-26: "draft saves can rely on browser memory … only what's needed goes to D1"). A draft is
4
+ * work in progress on one device; the database holds what must outlive the device. So the DS keeps
5
+ * drafts itself, keyed by bucket + file key, and no client needs a draft verb.
6
+ *
7
+ * Every write announces itself on `window` (`kol-media-drafts`), so a list elsewhere on the page
8
+ * (a Home shelf, the "unsaved draft" mark) re-reads without prop drilling. Storage can be missing
9
+ * or full (private mode, a quota) — every call degrades to "no draft", never to a throw.
10
+ */
11
+ const PREFIX = 'kol-media-draft:'
12
+ const EVENT = 'kol-media-drafts'
13
+ const store = () => { try { return globalThis.localStorage ?? null } catch { return null } }
14
+ const k = (bucket, key) => `${PREFIX}${bucket ?? ''}:${key}`
15
+ const announce = () => { try { globalThis.dispatchEvent?.(new Event(EVENT)) } catch { /* no window */ } }
16
+
17
+ export const DRAFTS_EVENT = EVENT
18
+
19
+ /** `{ text, at }` (at = epoch ms) or null */
20
+ export function readDraft(bucket, key) {
21
+ try { const raw = store()?.getItem(k(bucket, key)); return raw ? JSON.parse(raw) : null } catch { return null }
22
+ }
23
+ export function writeDraft(bucket, key, text) {
24
+ try { store()?.setItem(k(bucket, key), JSON.stringify({ text, at: Date.now() })); announce() } catch { /* quota */ }
25
+ }
26
+ export function clearDraft(bucket, key) {
27
+ try { store()?.removeItem(k(bucket, key)); announce() } catch { /* nothing to clear */ }
28
+ }
29
+ /** every draft in a bucket, newest first — `[{ key, at, size }]` */
30
+ export function listDrafts(bucket) {
31
+ const s = store()
32
+ if (!s) return []
33
+ const head = k(bucket, '')
34
+ const out = []
35
+ for (let i = 0; i < s.length; i++) {
36
+ const name = s.key(i)
37
+ if (!name?.startsWith(head)) continue
38
+ try { const d = JSON.parse(s.getItem(name)); out.push({ key: name.slice(head.length), at: d.at, size: d.text?.length ?? 0 }) } catch { /* a foreign value */ }
39
+ }
40
+ return out.sort((a, b) => b.at - a.at)
41
+ }
42
+ /** a file or a folder moved: its drafts follow (a folder is a path ending in `/`) */
43
+ export function moveDrafts(bucket, from, to) {
44
+ const moved = listDrafts(bucket).filter((d) => d.key === from || (from.endsWith('/') && d.key.startsWith(from)))
45
+ for (const d of moved) {
46
+ const draft = readDraft(bucket, d.key)
47
+ try { store()?.removeItem(k(bucket, d.key)); store()?.setItem(k(bucket, to + d.key.slice(from.length)), JSON.stringify(draft)) } catch { /* quota */ }
48
+ }
49
+ if (moved.length) announce()
50
+ }