@kolkrabbi/kol-component 0.224.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.
- package/package.json +1 -1
- package/src/atoms/ColorSwatch.jsx +6 -2
- package/src/atoms/SelectionOverlay.jsx +3 -1
- package/src/index.js +1 -1
- package/src/molecules/ColorInputRow.jsx +3 -2
- package/src/molecules/Dropdown.jsx +10 -5
- package/src/molecules/SectionText.jsx +5 -1
- package/src/molecules/ShapeDropdown.jsx +3 -2
- package/src/molecules/SplitToolButton.jsx +3 -2
- package/src/molecules/SwatchControls.jsx +3 -1
- package/src/organisms/Canvas.jsx +5 -2
- package/src/organisms/DocumentEditor.jsx +8 -5
- package/src/organisms/KeyframeEditor.jsx +2 -1
- package/src/organisms/LayerStack.jsx +11 -6
- package/src/organisms/MediaLibrary.jsx +1 -1
- package/src/organisms/MediaLibraryExplorer.jsx +67 -3
- package/src/organisms/MediaLibraryPages.jsx +112 -61
- package/src/organisms/SettingsPanel.jsx +5 -1
- package/src/organisms/SettingsSections.jsx +2 -1
- package/src/organisms/SpectrumGrid.jsx +3 -2
- package/src/organisms/TimelineDock.jsx +6 -3
- package/src/organisms/ToolPalette.jsx +6 -3
- package/src/utilities/Popover.jsx +61 -11
- package/src/utilities/QuadrantSync.jsx +3 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kolkrabbi/kol-component",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.225.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",
|
|
@@ -40,6 +40,7 @@
|
|
|
40
40
|
* title — passes through.
|
|
41
41
|
*/
|
|
42
42
|
import TransparentX from '../utilities/TransparentX'
|
|
43
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
43
44
|
|
|
44
45
|
const SIZE_CLASSES = {
|
|
45
46
|
fill: 'w-full aspect-square',
|
|
@@ -122,11 +123,11 @@ export default function ColorSwatch({
|
|
|
122
123
|
|
|
123
124
|
if (interactive) {
|
|
124
125
|
return (
|
|
126
|
+
<Tooltip label={title} asChild>
|
|
125
127
|
<button
|
|
126
128
|
{...rest}
|
|
127
129
|
type="button"
|
|
128
130
|
onClick={onClick}
|
|
129
|
-
title={title}
|
|
130
131
|
aria-label={rest['aria-label'] ?? hex ?? 'transparent'}
|
|
131
132
|
aria-pressed={selected}
|
|
132
133
|
className={cls}
|
|
@@ -134,12 +135,15 @@ export default function ColorSwatch({
|
|
|
134
135
|
>
|
|
135
136
|
{inner}
|
|
136
137
|
</button>
|
|
138
|
+
</Tooltip>
|
|
137
139
|
)
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
return (
|
|
141
|
-
<
|
|
143
|
+
<Tooltip label={title} asChild>
|
|
144
|
+
<span {...rest} aria-hidden="true" className={cls} style={style}>
|
|
142
145
|
{inner}
|
|
143
146
|
</span>
|
|
147
|
+
</Tooltip>
|
|
144
148
|
)
|
|
145
149
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useContext } from 'react'
|
|
2
2
|
import { CanvasZoomContext } from '../hooks/canvasZoom.js'
|
|
3
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
3
4
|
|
|
4
5
|
/* taxonomy-ok: presentational transform-chrome overlay. It nests no KOL
|
|
5
6
|
* component (pure inline-styled squares + label), so by the letter of the
|
|
@@ -110,9 +111,9 @@ export default function SelectionOverlay({
|
|
|
110
111
|
* the box about its centre. Independent of the resize handles: a path
|
|
111
112
|
* hides those (node-edit owns their geometry) and still rotates. */}
|
|
112
113
|
{rotate && (
|
|
114
|
+
<Tooltip label={rotateTitle} asChild>
|
|
113
115
|
<div
|
|
114
116
|
data-handle="ROT"
|
|
115
|
-
title={rotateTitle}
|
|
116
117
|
style={{
|
|
117
118
|
position: 'absolute',
|
|
118
119
|
left: `calc(50% - ${size / 2}px)`,
|
|
@@ -126,6 +127,7 @@ export default function SelectionOverlay({
|
|
|
126
127
|
pointerEvents: 'auto',
|
|
127
128
|
}}
|
|
128
129
|
/>
|
|
130
|
+
</Tooltip>
|
|
129
131
|
)}
|
|
130
132
|
{showHandles && HANDLE_DIRS.map(({ dir, cursor, x: hx, y: hy }) => (
|
|
131
133
|
<div
|
package/src/index.js
CHANGED
|
@@ -166,7 +166,7 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
|
|
|
166
166
|
/* the full-screen file viewer — public 2026-09-04 (user ruling): it already
|
|
167
167
|
* rendered both mobile viewer views and was module-private, so every consumer
|
|
168
168
|
* needing a phone viewer hand-rolled one. */
|
|
169
|
-
export { MediaInspector } from './organisms/MediaLibraryPages.jsx'
|
|
169
|
+
export { MediaInspector, mediaSettingsSections, SETTINGS_BASE as MEDIA_SETTINGS_BASE } from './organisms/MediaLibraryPages.jsx'
|
|
170
170
|
export { default as MediaLibraryExplorer } from './organisms/MediaLibraryExplorer.jsx'
|
|
171
171
|
export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
|
|
172
172
|
export { default as MediaViewer } from './organisms/MediaViewer.jsx'
|
|
@@ -2,7 +2,7 @@ import { useState } from 'react'
|
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import ColorSwatch from '../atoms/ColorSwatch'
|
|
4
4
|
import Input from '../atoms/Input'
|
|
5
|
-
import { usePopover, PopoverPanel } from '../utilities/Popover'
|
|
5
|
+
import { usePopover, PopoverPanel, Tooltip } from '../utilities/Popover'
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* ColorInputRow — swatch chip + `#` hex input row. The single merged form of
|
|
@@ -150,11 +150,11 @@ export default function ColorInputRow({
|
|
|
150
150
|
{chip(24)}
|
|
151
151
|
</button>
|
|
152
152
|
) : isLockToggle ? (
|
|
153
|
+
<Tooltip label={locked ? 'Unlock' : 'Lock'} asChild>
|
|
153
154
|
<button
|
|
154
155
|
type="button"
|
|
155
156
|
onClick={onToggleLock}
|
|
156
157
|
aria-pressed={locked}
|
|
157
|
-
title={locked ? 'Unlock' : 'Lock'}
|
|
158
158
|
className="group relative inline-flex h-6 w-6 shrink-0"
|
|
159
159
|
>
|
|
160
160
|
{chip('stretch')}
|
|
@@ -167,6 +167,7 @@ export default function ColorInputRow({
|
|
|
167
167
|
<Icon name={locked ? 'lock' : 'unlock'} size={12} />
|
|
168
168
|
</span>
|
|
169
169
|
</button>
|
|
170
|
+
</Tooltip>
|
|
170
171
|
) : (
|
|
171
172
|
chip(24)
|
|
172
173
|
)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
import { toneClass, TONE_VARS } from '../utilities/tone.js'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
|
-
import { MenuDropdownItem } from './MenuItem.jsx'
|
|
4
|
+
import { MenuDropdownItem, MenuDropdownDivider } from './MenuItem.jsx'
|
|
5
5
|
import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
|
|
6
6
|
import { glyphSize, indicatorSize } from '../hooks/glyphLadders.js'
|
|
7
7
|
|
|
@@ -160,7 +160,11 @@ const Dropdown = ({
|
|
|
160
160
|
onOptionHover?.(null)
|
|
161
161
|
}, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
162
162
|
|
|
163
|
-
|
|
163
|
+
/* `{ divider: true }` in `options` draws a hairline between groups (FoundationsTones, 2026-09-26 —
|
|
164
|
+
* user: none on top, the filled tones in order, outline and ghost apart). It is never a choice:
|
|
165
|
+
* the lookup, the fallback and the trigger's width all read the choices only. */
|
|
166
|
+
const choices = options.filter((opt) => !opt?.divider)
|
|
167
|
+
const currentOption = choices.find((opt) => opt.value === value) || choices[0]
|
|
164
168
|
|
|
165
169
|
/* A clamped list (Popover caps the panel to the viewport) can open with the
|
|
166
170
|
* checked row past the fold — scroll it into reach. Keyboard focus after
|
|
@@ -168,7 +172,7 @@ const Dropdown = ({
|
|
|
168
172
|
const listRef = useRef(null)
|
|
169
173
|
useEffect(() => {
|
|
170
174
|
if (!isOpen) return
|
|
171
|
-
const idx = options.findIndex((opt) => opt.value === currentOption?.value)
|
|
175
|
+
const idx = options.findIndex((opt) => !opt?.divider && opt.value === currentOption?.value)
|
|
172
176
|
listRef.current?.children[idx]?.scrollIntoView({ block: 'nearest' })
|
|
173
177
|
}, [isOpen]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
174
178
|
|
|
@@ -215,7 +219,7 @@ const Dropdown = ({
|
|
|
215
219
|
* trigger and list stay one piece at every selection */}
|
|
216
220
|
<span className="kol-dd-label">
|
|
217
221
|
<span>{currentOption?.label}</span>
|
|
218
|
-
{
|
|
222
|
+
{choices.map((option) => (
|
|
219
223
|
<span key={option.value} className="kol-dd-ghost" aria-hidden="true">
|
|
220
224
|
{option.label}
|
|
221
225
|
</span>
|
|
@@ -247,7 +251,8 @@ const Dropdown = ({
|
|
|
247
251
|
{(resolvedVariant === 'primary' || resolvedVariant === 'grey') && <div className="kol-dd-div" />}
|
|
248
252
|
|
|
249
253
|
<div ref={listRef} className="kol-dd-list" role="listbox">
|
|
250
|
-
{options.map((option) => {
|
|
254
|
+
{options.map((option, i) => {
|
|
255
|
+
if (option?.divider) return <MenuDropdownDivider key={`divider-${i}`} />
|
|
251
256
|
const isActive = option.value === currentOption?.value
|
|
252
257
|
return (
|
|
253
258
|
<MenuDropdownItem
|
|
@@ -115,7 +115,11 @@ export default function SectionText({
|
|
|
115
115
|
{headline && <Headline className={cls(`kol-section-text-headline ${headlineClass ?? (HEADLINE_ROLE[headlineSize] ?? HEADLINE_ROLE['heading-02'])}${headlineCase === 'upper' ? ' kol-section-text-caps' : ''}`, 'headline')} style={slotStyle.headline}>{headline}</Headline>}
|
|
116
116
|
{bodyNode && (inlineActions
|
|
117
117
|
? (
|
|
118
|
-
|
|
118
|
+
/* SELF-STRETCH (the Hub review, 2026-09-26 — user, on fxr's /settings: "there should
|
|
119
|
+
* have been space between … on the right edge"). The column is `items-start`, so this
|
|
120
|
+
* row shrank to its content and `justify-between` had no room to act: the cluster sat
|
|
121
|
+
* against the subtitle on every PageHeader with actions. */
|
|
122
|
+
<div className="flex self-stretch items-baseline justify-between gap-6">
|
|
119
123
|
{bodyNode}
|
|
120
124
|
{cluster}
|
|
121
125
|
</div>
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import Button from '../atoms/Button.jsx'
|
|
4
|
-
import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
|
|
4
|
+
import { PopoverPanel, usePopover, Tooltip } from '../utilities/Popover.jsx'
|
|
5
5
|
import { MenuDropdownItem } from './MenuItem.jsx'
|
|
6
6
|
|
|
7
7
|
/**
|
|
@@ -44,15 +44,16 @@ const ShapeDropdown = ({ options = [], value, onChange, onAction, className = ''
|
|
|
44
44
|
return (
|
|
45
45
|
<div className={`inline-flex items-center ${className}`.trim()}>
|
|
46
46
|
{current?.icon ? (
|
|
47
|
+
<Tooltip label={current.label}>
|
|
47
48
|
<Button
|
|
48
49
|
variant="ghost"
|
|
49
50
|
size="sm"
|
|
50
51
|
quiet
|
|
51
52
|
iconOnly={current.icon}
|
|
52
53
|
aria-label={current.label}
|
|
53
|
-
title={current.label}
|
|
54
54
|
onClick={() => onAction?.(current.id)}
|
|
55
55
|
/>
|
|
56
|
+
</Tooltip>
|
|
56
57
|
) : (
|
|
57
58
|
<Button variant="ghost" size="sm" quiet onClick={() => onAction?.(current?.id)}>
|
|
58
59
|
{current?.label}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
|
-
import { PopoverPanel, usePopover } from '../utilities/Popover.jsx'
|
|
3
|
+
import { PopoverPanel, usePopover, Tooltip } from '../utilities/Popover.jsx'
|
|
4
4
|
import { glyphSize } from '../hooks/glyphLadders.js'
|
|
5
5
|
|
|
6
6
|
/**
|
|
@@ -121,6 +121,7 @@ const SplitToolButton = ({
|
|
|
121
121
|
|
|
122
122
|
return (
|
|
123
123
|
<>
|
|
124
|
+
<Tooltip label={title} asChild>
|
|
124
125
|
<button
|
|
125
126
|
ref={popover.refs.setReference}
|
|
126
127
|
{...popover.getReferenceProps({ onClick: handleTriggerClick })}
|
|
@@ -129,11 +130,11 @@ const SplitToolButton = ({
|
|
|
129
130
|
disabled={disabled}
|
|
130
131
|
aria-pressed={onTrigger ? undefined : active}
|
|
131
132
|
aria-label={label}
|
|
132
|
-
title={title}
|
|
133
133
|
>
|
|
134
134
|
{triggerVariant && <IconC name={triggerVariant.icon} size={glyphSize(size, true)} />}
|
|
135
135
|
<FoldIndicator />
|
|
136
136
|
</button>
|
|
137
|
+
</Tooltip>
|
|
137
138
|
{/* w-max — floats size to content, the menu-family law (2026-08-09). */}
|
|
138
139
|
<PopoverPanel
|
|
139
140
|
popover={popover}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useEffect, useState } from 'react'
|
|
2
2
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
3
3
|
import ColorSwatch from '../atoms/ColorSwatch.jsx'
|
|
4
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
4
5
|
|
|
5
6
|
/*
|
|
6
7
|
* SwatchControls — the Photoshop-style top row of the colour panel, in two
|
|
@@ -107,16 +108,17 @@ export function EyedropPick({ sampleColor, onPick, disabled = false }) {
|
|
|
107
108
|
return (
|
|
108
109
|
<div className="flex items-start gap-1 shrink-0">
|
|
109
110
|
{supported && (
|
|
111
|
+
<Tooltip label="Pick a color from the canvas" asChild>
|
|
110
112
|
<button
|
|
111
113
|
type="button"
|
|
112
114
|
onClick={onPick}
|
|
113
115
|
disabled={disabled}
|
|
114
116
|
aria-label="Eyedropper"
|
|
115
|
-
title="Pick a color from the canvas"
|
|
116
117
|
className="inline-flex cursor-pointer text-fg hover:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed"
|
|
117
118
|
>
|
|
118
119
|
<Icon name="eyedrop" size={24} />
|
|
119
120
|
</button>
|
|
121
|
+
</Tooltip>
|
|
120
122
|
)}
|
|
121
123
|
<ColorSwatch
|
|
122
124
|
hex={sampleColor}
|
package/src/organisms/Canvas.jsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { useCallback, useContext, useEffect, useLayoutEffect, useRef, useState } from 'react'
|
|
2
2
|
import { CanvasZoomContext } from '../hooks/canvasZoom.js'
|
|
3
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
3
4
|
|
|
4
5
|
/**
|
|
5
6
|
* Canvas — the editor's aspect-ratio stage.
|
|
@@ -628,22 +629,24 @@ export function PanZoomViewport({
|
|
|
628
629
|
{/* Zoom % + fps — matching chips. Zoom (click resets to 100% /
|
|
629
630
|
centered) first, fps to its right, shown while `f` toggles it. */}
|
|
630
631
|
<div className="absolute bottom-3 right-3 z-[3] flex items-center gap-2">
|
|
632
|
+
<Tooltip label="Reset zoom (⌘0)" asChild>
|
|
631
633
|
<button
|
|
632
634
|
type="button"
|
|
633
635
|
onClick={() => setView({ zoom: 1, x: 0, y: 0 })}
|
|
634
636
|
className="px-2 py-1 rounded border border-fg-08 bg-surface-secondary kol-mono-12 text-emphasis tabular-nums"
|
|
635
637
|
style={{ opacity: atRest && !showFps ? 0.55 : 1 }}
|
|
636
|
-
title="Reset zoom (⌘0)"
|
|
637
638
|
>
|
|
638
639
|
{Math.round(view.zoom * 100)}%
|
|
639
640
|
</button>
|
|
641
|
+
</Tooltip>
|
|
640
642
|
{showFps && (
|
|
643
|
+
<Tooltip label="Framerate — press F to hide" asChild>
|
|
641
644
|
<span
|
|
642
645
|
className="px-2 py-1 rounded border border-fg-08 bg-surface-secondary kol-mono-12 text-emphasis tabular-nums"
|
|
643
|
-
title="Framerate — press F to hide"
|
|
644
646
|
>
|
|
645
647
|
{fps} fps
|
|
646
648
|
</span>
|
|
649
|
+
</Tooltip>
|
|
647
650
|
)}
|
|
648
651
|
</div>
|
|
649
652
|
</div>
|
|
@@ -41,6 +41,9 @@ import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
|
|
|
41
41
|
* searchable list inside the editor inserts the pick at the caret
|
|
42
42
|
* @param {Function} onPickAsset `() => Promise<{ name, url, contentType } | null>` — a consumer's own picker instead
|
|
43
43
|
* @param {Function} onClose
|
|
44
|
+
* @param {boolean} inline render IN the page, filling its container, instead of over it (the notes
|
|
45
|
+
* page, media-shell 2026-09-26: the editor is the page's content there,
|
|
46
|
+
* not a window over a list). No scrim, no Escape-to-close; the X still closes
|
|
44
47
|
*/
|
|
45
48
|
const TYPES = [
|
|
46
49
|
{ value: 'md', label: 'MD', kind: 'markdown' },
|
|
@@ -64,6 +67,7 @@ const DRAFT_PAUSE = 400 // browser memory is free — a short pause, not D1's wr
|
|
|
64
67
|
export default function DocumentEditor({
|
|
65
68
|
mode = 'edit', name: nameProp = '', kind: kindProp, text, savedAt = 0, draft,
|
|
66
69
|
onSave, onCreate, folder = '', assets, onPickAsset, onClose,
|
|
70
|
+
inline = false,
|
|
67
71
|
}) {
|
|
68
72
|
const [picking, setPicking] = useState(false)
|
|
69
73
|
const [pickQuery, setPickQuery] = useState('')
|
|
@@ -186,9 +190,8 @@ export default function DocumentEditor({
|
|
|
186
190
|
? <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(value)}`} alt="" className="max-w-full max-h-full object-contain m-auto" />
|
|
187
191
|
: <KindPreview o={{ key: name || 'document' }} text={value} kind={kind} />
|
|
188
192
|
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<QuickLookFrame title={isNew ? `New document${folder ? ` in ${folder}` : ''}` : name} meta={status} onClose={close}
|
|
193
|
+
const frame = (
|
|
194
|
+
<QuickLookFrame className={inline ? 'kol-quicklook--inline' : ''} title={isNew ? `New document${folder ? ` in ${folder}` : ''}` : name} meta={status} onClose={close}
|
|
192
195
|
actions={(
|
|
193
196
|
<span className="flex items-center gap-2">
|
|
194
197
|
<ViewToggle variant="text" viewMode={view} onViewChange={setView} options={views} />
|
|
@@ -198,7 +201,7 @@ export default function DocumentEditor({
|
|
|
198
201
|
</span>
|
|
199
202
|
)}>
|
|
200
203
|
{/* the window's own caps, inline — Tailwind does not generate arbitrary values from package source */}
|
|
201
|
-
<div className="kol-doc-editor flex flex-col" style={{ width: 'min(1200px, var(--kol-ql-max-w))', height: 'min(760px, var(--kol-ql-media-h))' }}>
|
|
204
|
+
<div className="kol-doc-editor flex flex-col" style={inline ? { width: '100%', height: '100%' } : { width: 'min(1200px, var(--kol-ql-max-w))', height: 'min(760px, var(--kol-ql-media-h))' }}>
|
|
202
205
|
{isNew && (
|
|
203
206
|
<div className="flex flex-wrap items-center gap-3 p-4 border-b" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
204
207
|
<Input variant="filled" size="sm" value={name} aria-label="File name" placeholder="File name"
|
|
@@ -240,8 +243,8 @@ export default function DocumentEditor({
|
|
|
240
243
|
</div>
|
|
241
244
|
</div>
|
|
242
245
|
</QuickLookFrame>
|
|
243
|
-
</FullscreenOverlay>
|
|
244
246
|
)
|
|
247
|
+
return inline ? frame : <FullscreenOverlay open onClose={close} closeButton={false} scrim>{frame}</FullscreenOverlay>
|
|
245
248
|
}
|
|
246
249
|
|
|
247
250
|
/* THE FRONTMATTER AS FIELDS. One row per key; a list value (tags) is typed comma-separated. The
|
|
@@ -3,6 +3,7 @@ import Button from '../atoms/Button.jsx'
|
|
|
3
3
|
import Dropdown from '../molecules/Dropdown.jsx'
|
|
4
4
|
import LabeledControl from '../molecules/LabeledControl.jsx'
|
|
5
5
|
import Slider from '../molecules/Slider.jsx'
|
|
6
|
+
import { Tooltip } from '../utilities/Popover.jsx'
|
|
6
7
|
|
|
7
8
|
const deg = (r) => Math.round(((r || 0) * 180) / Math.PI)
|
|
8
9
|
const rad = (d) => (d * Math.PI) / 180
|
|
@@ -117,7 +118,7 @@ export default function KeyframeEditor({
|
|
|
117
118
|
</div>
|
|
118
119
|
<div className="flex gap-2">
|
|
119
120
|
<Button variant="primary" size="sm" className="flex-1" onClick={onAdd}>Add @ playhead</Button>
|
|
120
|
-
<Button variant="ghost" size="sm"
|
|
121
|
+
<Tooltip label="Delete keyframe"><Button variant="ghost" size="sm" onClick={onDelete} disabled={kfs.length <= 1}>Delete</Button></Tooltip>
|
|
121
122
|
</div>
|
|
122
123
|
|
|
123
124
|
<span className="kol-helper-10 text-meta">Pose</span>
|
|
@@ -3,7 +3,7 @@ import { Icon } from '@kolkrabbi/kol-icons'
|
|
|
3
3
|
import Button from '../atoms/Button.jsx'
|
|
4
4
|
import Input from '../atoms/Input.jsx'
|
|
5
5
|
import { MenuDropdownItem, MenuDropdownNest } from '../molecules/MenuItem.jsx'
|
|
6
|
-
import { usePopover, PopoverPanel } from '../utilities/Popover.jsx'
|
|
6
|
+
import { usePopover, PopoverPanel, Tooltip } from '../utilities/Popover.jsx'
|
|
7
7
|
import { rowLabelForLayer, findLayerDeep } from '../hooks/layerTree.js'
|
|
8
8
|
|
|
9
9
|
/* DS icon names for the engine's types — every one a shipped v1 glyph. A
|
|
@@ -51,11 +51,11 @@ function useShiftClickHandlers(onSelect, onShiftSelect) {
|
|
|
51
51
|
|
|
52
52
|
function Chevron({ IconC, collapsed, onToggle, title }) {
|
|
53
53
|
return (
|
|
54
|
+
<Tooltip label={title} asChild>
|
|
54
55
|
<button
|
|
55
56
|
type="button"
|
|
56
57
|
onClick={onToggle}
|
|
57
58
|
aria-expanded={!collapsed}
|
|
58
|
-
title={title}
|
|
59
59
|
className="kol-layer-stack-collapse"
|
|
60
60
|
>
|
|
61
61
|
<IconC
|
|
@@ -64,6 +64,7 @@ function Chevron({ IconC, collapsed, onToggle, title }) {
|
|
|
64
64
|
style={{ transform: collapsed ? 'rotate(-90deg)' : 'rotate(0deg)', transition: 'transform 150ms' }}
|
|
65
65
|
/>
|
|
66
66
|
</button>
|
|
67
|
+
</Tooltip>
|
|
67
68
|
)
|
|
68
69
|
}
|
|
69
70
|
|
|
@@ -160,24 +161,26 @@ function LayerRow({
|
|
|
160
161
|
</span>
|
|
161
162
|
</button>
|
|
162
163
|
)}
|
|
164
|
+
<Tooltip label={layer.visible ? 'Hide' : 'Show'} asChild>
|
|
163
165
|
<button
|
|
164
166
|
type="button"
|
|
165
167
|
onClick={onToggleVisibility}
|
|
166
|
-
title={layer.visible ? 'Hide' : 'Show'}
|
|
167
168
|
aria-pressed={!layer.visible}
|
|
168
169
|
className={`kol-layer-stack-toggle kol-layer-stack-toggle--eye${active || !layer.visible ? ' is-pinned' : ''}`}
|
|
169
170
|
>
|
|
170
171
|
<IconC name={layer.visible ? 'eye-on' : 'eye-off'} size={12} />
|
|
171
172
|
</button>
|
|
173
|
+
</Tooltip>
|
|
174
|
+
<Tooltip label={layer.locked ? 'Unlock' : 'Lock'} asChild>
|
|
172
175
|
<button
|
|
173
176
|
type="button"
|
|
174
177
|
onClick={onToggleLock}
|
|
175
|
-
title={layer.locked ? 'Unlock' : 'Lock'}
|
|
176
178
|
aria-pressed={!!layer.locked}
|
|
177
179
|
className={`kol-layer-stack-toggle kol-layer-stack-toggle--lock${active || layer.locked ? ' is-pinned' : ''}${layer.locked ? ' is-on' : ''}`}
|
|
178
180
|
>
|
|
179
181
|
<IconC name={layer.locked ? 'lock' : 'unlock'} size={12} />
|
|
180
182
|
</button>
|
|
183
|
+
</Tooltip>
|
|
181
184
|
</div>
|
|
182
185
|
</div>
|
|
183
186
|
)
|
|
@@ -419,16 +422,17 @@ export default function LayerStack({
|
|
|
419
422
|
* consumer's keymap. */}
|
|
420
423
|
{onGroup && layerSelectionCount >= 2 && (
|
|
421
424
|
<div className="mt-auto flex items-center gap-2 px-3 h-10 border-t border-fg-08">
|
|
425
|
+
<Tooltip label={`Group ${layerSelectionCount} selected layers`}>
|
|
422
426
|
<Button
|
|
423
427
|
iconComponent={IconC}
|
|
424
428
|
variant="primary"
|
|
425
429
|
size="sm"
|
|
426
430
|
iconLeft="layers"
|
|
427
431
|
onClick={() => onGroup(layerSelectedIds)}
|
|
428
|
-
title={`Group ${layerSelectionCount} selected layers`}
|
|
429
432
|
>
|
|
430
433
|
Group {layerSelectionCount}
|
|
431
434
|
</Button>
|
|
435
|
+
</Tooltip>
|
|
432
436
|
</div>
|
|
433
437
|
)}
|
|
434
438
|
</div>
|
|
@@ -469,6 +473,7 @@ export function AddLayerButton({
|
|
|
469
473
|
return (
|
|
470
474
|
<>
|
|
471
475
|
<span ref={popover.refs.setReference} {...popover.getReferenceProps()} className="inline-flex">
|
|
476
|
+
<Tooltip label="Add layer">
|
|
472
477
|
<Button
|
|
473
478
|
iconComponent={IconC}
|
|
474
479
|
variant="primary"
|
|
@@ -476,8 +481,8 @@ export function AddLayerButton({
|
|
|
476
481
|
quiet
|
|
477
482
|
iconOnly="plus"
|
|
478
483
|
aria-label="Add layer"
|
|
479
|
-
title="Add layer"
|
|
480
484
|
/>
|
|
485
|
+
</Tooltip>
|
|
481
486
|
</span>
|
|
482
487
|
<PopoverPanel popover={popover} className="py-1" style={{ width: menuWidth }}>
|
|
483
488
|
{types.map((t) => {
|
|
@@ -708,7 +708,7 @@ function FilesBody({ files, viewMode, onOpen, onPick }) {
|
|
|
708
708
|
)
|
|
709
709
|
/* the title voice is the family's ruled default (heading-04 card / heading-05
|
|
710
710
|
* row, truncated by ContentText); the full key rides as the tooltip, as before */
|
|
711
|
-
const nameFor = (row) => <
|
|
711
|
+
const nameFor = (row) => <Tooltip label={row.key} asChild><span>{row.displayKey}</span></Tooltip>
|
|
712
712
|
const date = (row) => (row.uploaded ? String(row.uploaded).slice(0, 10) : undefined)
|
|
713
713
|
const size = (row) => formatSize(row.size) || undefined
|
|
714
714
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
|
-
import { useState } from 'react'
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
2
|
import { MediaLibraryBrowse } from './MediaLibraryPages.jsx'
|
|
3
|
+
import { useModal } from '../molecules/Modal.jsx'
|
|
4
|
+
import useMediaQuery from '../hooks/useMediaQuery.js'
|
|
3
5
|
|
|
4
6
|
/**
|
|
5
7
|
* MediaLibraryExplorer — the media surface, and since 2026-09-22 a thin name for it.
|
|
@@ -17,15 +19,77 @@ import { MediaLibraryBrowse } from './MediaLibraryPages.jsx'
|
|
|
17
19
|
* @param {'columns'|'rows'|'grid'|'list'} view controlled view; omit and the page reads its own setting
|
|
18
20
|
* @param {Function} onViewChange (view) => void
|
|
19
21
|
* @param {'columns'|'rows'|'grid'|'list'} defaultView first view when uncontrolled and nothing is stored
|
|
22
|
+
*
|
|
23
|
+
* THE TOOL'S OWN BEHAVIOUR, opt-in (media parity, 2026-09-26 — user: "media in shell is just media in
|
|
24
|
+
* shell.. so there shouldnt really be a reason to diff"). apps/media carried these in its App.jsx, so
|
|
25
|
+
* the same tool one shell over had none of them. They are the product's, so they live with it:
|
|
26
|
+
*
|
|
27
|
+
* @param {boolean} keys the view keys — B browse · F files · R rows · C columns · G grid — and N
|
|
28
|
+
* (new folder, when `fileActions.createFolder` is given), plus K → `onKinds`.
|
|
29
|
+
* Ignored while typing and with ⌘ / Ctrl / ⌥ held
|
|
30
|
+
* @param {boolean} phoneTabs below 768 the surface is three tabs — Browse · Files · Kinds (Kinds only
|
|
31
|
+
* with `onKinds`); a tab picks the view, Kinds opens the overview and leaves
|
|
32
|
+
* the surface where it was
|
|
33
|
+
* @param {Function} onKinds opens the app's file-formats overview (K, the Kinds tab)
|
|
20
34
|
*/
|
|
21
|
-
|
|
35
|
+
const TABS = [
|
|
36
|
+
{ value: 'browse', label: 'Browse', icon: 'folder' },
|
|
37
|
+
{ value: 'files', label: 'Files', icon: 'view-list' },
|
|
38
|
+
{ value: 'kinds', label: 'Kinds', icon: 'grid' },
|
|
39
|
+
]
|
|
40
|
+
/* a key → the view it shows and the phone tab that view belongs to (apps/media's map, verbatim) */
|
|
41
|
+
const VIEW_KEYS = { b: ['columns', 'browse'], f: ['list', 'files'], r: ['rows', 'browse'], c: ['columns', 'browse'], g: ['grid', 'files'] }
|
|
42
|
+
|
|
43
|
+
export default function MediaLibraryExplorer({ view, onViewChange, defaultView, keys = false, phoneTabs = false, onKinds, ...pageProps }) {
|
|
22
44
|
const [ownView, setOwnView] = useState(view ?? defaultView)
|
|
23
45
|
const current = view ?? ownView
|
|
46
|
+
const setView = (v) => { setOwnView(v); onViewChange?.(v) }
|
|
47
|
+
const [tab, setTab] = useState('browse')
|
|
48
|
+
const phone = useMediaQuery('(max-width: 767px)')
|
|
49
|
+
const modal = useModal()
|
|
50
|
+
|
|
51
|
+
/* the latest props for the key handler, without re-binding it every render */
|
|
52
|
+
const live = useRef()
|
|
53
|
+
live.current = { onKinds, prefix: pageProps.prefix ?? '', createFolder: pageProps.fileActions?.createFolder, modal }
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
if (!keys) return undefined
|
|
56
|
+
const onKey = (e) => {
|
|
57
|
+
if (e.metaKey || e.ctrlKey || e.altKey) return
|
|
58
|
+
const el = e.target
|
|
59
|
+
if (el?.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(el?.tagName ?? '')) return
|
|
60
|
+
const { onKinds: kinds, prefix, createFolder, modal: m } = live.current
|
|
61
|
+
if (VIEW_KEYS[e.key]) {
|
|
62
|
+
e.preventDefault()
|
|
63
|
+
const [v, t] = VIEW_KEYS[e.key]
|
|
64
|
+
setOwnView(v); onViewChange?.(v); setTab(t)
|
|
65
|
+
} else if (e.key === 'k' && kinds) {
|
|
66
|
+
e.preventDefault(); kinds()
|
|
67
|
+
} else if (e.key === 'n' && createFolder) {
|
|
68
|
+
e.preventDefault()
|
|
69
|
+
m.prompt(`New folder in ${prefix || 'the bucket root'}:`, '', { okLabel: 'Create' }).then((name) => {
|
|
70
|
+
if (name?.trim()) createFolder(`${prefix}${name.trim().replace(/^\/+|\/+$/g, '')}/`)
|
|
71
|
+
})
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
window.addEventListener('keydown', onKey)
|
|
75
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
76
|
+
}, [keys]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
77
|
+
|
|
78
|
+
const onTabChange = (v) => {
|
|
79
|
+
if (v === 'kinds') { onKinds?.(); return }
|
|
80
|
+
setTab(v)
|
|
81
|
+
setView(v === 'files' ? 'list' : 'columns')
|
|
82
|
+
}
|
|
83
|
+
const tabProps = phoneTabs && phone
|
|
84
|
+
? { tabs: onKinds ? TABS : TABS.filter((t) => t.value !== 'kinds'), activeTab: tab, onTabChange }
|
|
85
|
+
: {}
|
|
86
|
+
|
|
24
87
|
return (
|
|
25
88
|
<MediaLibraryBrowse
|
|
26
89
|
{...pageProps}
|
|
90
|
+
{...tabProps}
|
|
27
91
|
view={current}
|
|
28
|
-
onViewChange={
|
|
92
|
+
onViewChange={setView}
|
|
29
93
|
/>
|
|
30
94
|
)
|
|
31
95
|
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Fragment, useEffect, useMemo, useRef, useState } from 'react'
|
|
1
|
+
import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
|
2
2
|
import formatSize from '../utilities/formatSize.js'
|
|
3
3
|
import { Icon } from '@kolkrabbi/kol-icons'
|
|
4
4
|
import Button from '../atoms/Button.jsx'
|
|
@@ -37,7 +37,8 @@ import { listDrafts, moveDrafts, DRAFTS_EVENT } from '../utilities/localDrafts.j
|
|
|
37
37
|
import { parseFrontmatter } from '../utilities/frontmatter.js'
|
|
38
38
|
import ShellSearchOverlay from './ShellSearchOverlay.jsx'
|
|
39
39
|
import ColumnBrowser, { isFileDrag, Preview as ColumnPreview, SelectionPreview } from './ColumnBrowser.jsx'
|
|
40
|
-
import SettingsPanel, {
|
|
40
|
+
import SettingsPanel, { SettingsSwitch, SettingsChoice, SettingsMulti, SettingsFooter } from './SettingsPanel.jsx'
|
|
41
|
+
import SettingsSections from './SettingsSections.jsx'
|
|
41
42
|
import { kindOf, extOf, KIND_LABEL, KINDS, DEFAULT_KINDS, isSystemFile, isSegment, groupSegments, groupVariants, posterFor, partition } from '../utilities/mediaKinds.js'
|
|
42
43
|
import { nearestRatio } from '../utilities/ratios.js'
|
|
43
44
|
|
|
@@ -108,7 +109,7 @@ export const SETTINGS_BASE = {
|
|
|
108
109
|
folderView: 'columns', // 'rows' | 'columns'
|
|
109
110
|
sortBy: 'name',
|
|
110
111
|
sortDir: 'asc',
|
|
111
|
-
columnHeight: 528, // px,
|
|
112
|
+
columnHeight: 528, // px, any CSS length (forwarded to ColumnBrowser `height` untouched), or 'fill' — what is left of the window
|
|
112
113
|
columnWidths: {},
|
|
113
114
|
rowPreview: true, // row view's preview pane beside the list (Finder's "Show preview")
|
|
114
115
|
view: 'columns', // 'columns' | 'rows' | 'grid' | 'list' — the ONE view state (2026-09-22)
|
|
@@ -683,61 +684,62 @@ export function MediaInspector({ files, index, onClose, onPrev, onNext, mediaUrl
|
|
|
683
684
|
)
|
|
684
685
|
}
|
|
685
686
|
|
|
686
|
-
/*
|
|
687
|
-
*
|
|
688
|
-
*
|
|
689
|
-
|
|
687
|
+
/* THE DISPLAY SETTINGS AS DATA (media-shell, user 2026-09-26: "missing settings sidebar? … or are
|
|
688
|
+
* we saying the shell has settings different from media?"). The rows were hard-wired JSX inside this
|
|
689
|
+
* drawer, so an app page could not show them and a second settings system grew beside it. Now ONE
|
|
690
|
+
* builder: this drawer renders it, and an app renders the same rows on its settings page and its own
|
|
691
|
+
* drawer (kol-shell `HubSettings sections`). `settings` is the page's per-bucket settings object,
|
|
692
|
+
* `onChange(next)` sets it, `profile` (what the bucket holds) drives the "nothing to fold" states —
|
|
693
|
+
* omit it where the listing is not at hand and those two switches stay enabled with plain hints.
|
|
694
|
+
* Every control sets a default, never a gate. */
|
|
695
|
+
export function mediaSettingsSections({ settings, onChange, profile }) {
|
|
690
696
|
const set = (patch) => onChange({ ...settings, ...patch })
|
|
691
|
-
const
|
|
692
|
-
const
|
|
693
|
-
const
|
|
697
|
+
const kinds = settings.kinds ?? []
|
|
698
|
+
const toggleKind = (k) => set({ kinds: kinds.includes(k) ? kinds.filter((x) => x !== k) : [...kinds, k] })
|
|
699
|
+
const noVariants = profile?.variantSets === 0
|
|
700
|
+
const noSegments = profile?.segments === 0
|
|
701
|
+
return [
|
|
702
|
+
{ label: 'Structure', rowGap: 1, rows: [
|
|
703
|
+
{ label: 'Columns', hint: 'Finder-style columns instead of folder rows',
|
|
704
|
+
render: () => <SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} /> },
|
|
705
|
+
{ label: 'Preview', hint: 'the picked file beside the rows',
|
|
706
|
+
render: () => <SettingsSwitch label="Preview" on={settings.rowPreview ?? true} onChange={(v) => set({ rowPreview: v })} /> },
|
|
707
|
+
/* NOT "Columns" — that switch above means Finder's column browser. These are the row view's
|
|
708
|
+
* date/size FIELDS, which is what the user calls them. */
|
|
709
|
+
{ label: 'Fields', hint: 'date and size beside each row, with a sort header',
|
|
710
|
+
render: () => <SettingsSwitch label="Fields" on={!!settings.rowColumns} onChange={(v) => set({ rowColumns: v })} /> },
|
|
711
|
+
{ label: 'Flat', hint: 'ignore folders, show the whole subtree',
|
|
712
|
+
render: () => <SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} /> },
|
|
713
|
+
{ label: 'Group resolutions', hint: noVariants ? 'no resolution sets in this bucket' : profile ? `${profile.variantSets} sets — previews the smallest file` : 'previews the smallest file of a set',
|
|
714
|
+
render: () => <SettingsSwitch label="Group resolutions" on={settings.groupVariants} onChange={(v) => set({ groupVariants: v })} disabled={noVariants} disabledHint="nothing to group here" /> },
|
|
715
|
+
{ label: 'Fold HLS segments', hint: noSegments ? 'no segments in this bucket' : profile ? `${profile.segments} segments into stream rows` : 'segments into stream rows',
|
|
716
|
+
render: () => <SettingsSwitch label="Fold HLS segments" on={settings.foldSegments} onChange={(v) => set({ foldSegments: v })} disabled={noSegments} disabledHint="nothing to fold here" /> },
|
|
717
|
+
] },
|
|
718
|
+
{ label: 'Loading', rows: [
|
|
719
|
+
{ label: 'Kinds', align: 'fill',
|
|
720
|
+
render: () => <SettingsMulti options={ALL_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] || k }))} selected={kinds} onToggle={toggleKind} noun="kinds" /> },
|
|
721
|
+
{ label: 'Page size', hint: 'entries mounted at once', align: 'fill',
|
|
722
|
+
render: () => <SettingsChoice options={[{ value: 100, label: '100' }, { value: 200, label: '200' }, { value: 500, label: '500' }, { value: 0, label: 'All' }]} value={settings.pageSize} onChange={(v) => set({ pageSize: v })} /> },
|
|
723
|
+
{ label: 'Video preview', hint: 'poster uses the sibling image; autoload fetches the file', align: 'fill',
|
|
724
|
+
render: () => <SettingsChoice options={[{ value: 'poster', label: 'Poster' }, { value: 'none', label: 'None' }, { value: 'autoload', label: 'Autoload' }]} value={settings.videoPreview} onChange={(v) => set({ videoPreview: v })} /> },
|
|
725
|
+
] },
|
|
726
|
+
{ label: 'Layout', rows: [
|
|
727
|
+
{ label: 'View', align: 'fill',
|
|
728
|
+
render: () => <SettingsChoice options={[{ value: 'off', label: 'Off' }, { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} value={settings.layout} onChange={(v) => set({ layout: v })} /> },
|
|
729
|
+
{ label: 'Sort', align: 'fill',
|
|
730
|
+
render: () => <SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} /> },
|
|
731
|
+
{ label: 'Direction', align: 'fill',
|
|
732
|
+
render: () => <SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} /> },
|
|
733
|
+
] },
|
|
734
|
+
]
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/* Display settings for the ACTIVE bucket — kol-r2b2's drawer wiring on the DS organism (the
|
|
738
|
+
* composition the user locked 2026-08-27), drawn from `mediaSettingsSections`. */
|
|
739
|
+
function MediaSettings({ settings, onChange, onReset, onClose, profile, settingsFooter }) {
|
|
694
740
|
return (
|
|
695
741
|
<SettingsPanel variant="drawer" title="Display settings" onClose={onClose} footer={<SettingsFooter onReset={onReset} resetLabel="Reset preferences">{settingsFooter}</SettingsFooter>}>
|
|
696
|
-
<
|
|
697
|
-
<SettingsRow label="Columns" hint="Finder-style columns instead of folder rows">
|
|
698
|
-
<SettingsSwitch label="Columns" on={(settings.folderView ?? 'rows') === 'columns'} onChange={(v) => set({ folderView: v ? 'columns' : 'rows' })} />
|
|
699
|
-
</SettingsRow>
|
|
700
|
-
<SettingsRow label="Preview" hint="the picked file beside the rows">
|
|
701
|
-
<SettingsSwitch label="Preview" on={settings.rowPreview ?? true} onChange={(v) => set({ rowPreview: v })} />
|
|
702
|
-
</SettingsRow>
|
|
703
|
-
{/* NOT "Columns" — that switch three rows up means Finder's column browser. These are the
|
|
704
|
-
row view's date/size FIELDS, which is what the user calls them. */}
|
|
705
|
-
<SettingsRow label="Fields" hint="date and size beside each row, with a sort header">
|
|
706
|
-
<SettingsSwitch label="Fields" on={!!settings.rowColumns} onChange={(v) => set({ rowColumns: v })} />
|
|
707
|
-
</SettingsRow>
|
|
708
|
-
<SettingsRow label="Flat" hint="ignore folders, show the whole subtree">
|
|
709
|
-
<SettingsSwitch label="Flat" on={settings.flat} onChange={(v) => set({ flat: v })} />
|
|
710
|
-
</SettingsRow>
|
|
711
|
-
<SettingsRow label="Group resolutions" hint={noVariants ? 'no resolution sets in this bucket' : `${profile.variantSets} sets — previews the smallest file`}>
|
|
712
|
-
<SettingsSwitch label="Group resolutions" on={settings.groupVariants} onChange={(v) => set({ groupVariants: v })} disabled={noVariants} disabledHint="nothing to group here" />
|
|
713
|
-
</SettingsRow>
|
|
714
|
-
<SettingsRow label="Fold HLS segments" hint={noSegments ? 'no segments in this bucket' : `${profile.segments} segments into stream rows`}>
|
|
715
|
-
<SettingsSwitch label="Fold HLS segments" on={settings.foldSegments} onChange={(v) => set({ foldSegments: v })} disabled={noSegments} disabledHint="nothing to fold here" />
|
|
716
|
-
</SettingsRow>
|
|
717
|
-
</LabeledControlSection>
|
|
718
|
-
<LabeledControlSection label="Loading" divided>
|
|
719
|
-
<SettingsRow label="Kinds" align="fill">
|
|
720
|
-
<SettingsMulti options={ALL_KINDS.map((k) => ({ value: k, label: KIND_LABEL[k] || k }))} selected={settings.kinds} onToggle={toggleKind} noun="kinds" />
|
|
721
|
-
</SettingsRow>
|
|
722
|
-
<SettingsRow label="Page size" hint="entries mounted at once" align="fill">
|
|
723
|
-
<SettingsChoice options={[{ value: 100, label: '100' }, { value: 200, label: '200' }, { value: 500, label: '500' }, { value: 0, label: 'All' }]} value={settings.pageSize} onChange={(v) => set({ pageSize: v })} />
|
|
724
|
-
</SettingsRow>
|
|
725
|
-
<SettingsRow label="Video preview" hint="poster uses the sibling image; autoload fetches the file" align="fill">
|
|
726
|
-
<SettingsChoice options={[{ value: 'poster', label: 'Poster' }, { value: 'none', label: 'None' }, { value: 'autoload', label: 'Autoload' }]} value={settings.videoPreview} onChange={(v) => set({ videoPreview: v })} />
|
|
727
|
-
</SettingsRow>
|
|
728
|
-
</LabeledControlSection>
|
|
729
|
-
<LabeledControlSection label="Layout" divided>
|
|
730
|
-
<SettingsRow label="View" align="fill">
|
|
731
|
-
<SettingsChoice options={[{ value: 'off', label: 'Off' }, { value: 'grid', label: 'Grid' }, { value: 'list', label: 'List' }]} value={settings.layout} onChange={(v) => set({ layout: v })} />
|
|
732
|
-
</SettingsRow>
|
|
733
|
-
<SettingsRow label="Sort" align="fill">
|
|
734
|
-
<SettingsChoice options={SORT_OPTIONS} value={settings.sortBy} onChange={(v) => set({ sortBy: v })} />
|
|
735
|
-
</SettingsRow>
|
|
736
|
-
<SettingsRow label="Direction" align="fill">
|
|
737
|
-
<SettingsChoice options={[{ value: 'asc', label: '↓ Asc' }, { value: 'desc', label: '↑ Desc' }]} value={settings.sortDir} onChange={(v) => set({ sortDir: v })} />
|
|
738
|
-
</SettingsRow>
|
|
739
|
-
</LabeledControlSection>
|
|
740
|
-
{void bucketMeta}
|
|
742
|
+
<SettingsSections sections={mediaSettingsSections({ settings, onChange, profile })} divided />
|
|
741
743
|
</SettingsPanel>
|
|
742
744
|
)
|
|
743
745
|
}
|
|
@@ -916,6 +918,44 @@ function LibraryHeader({ title, buckets, bucketId, appRoot, onBucket, bucketMeta
|
|
|
916
918
|
)
|
|
917
919
|
}
|
|
918
920
|
|
|
921
|
+
/* `columnHeight: 'fill'` — THE BROWSER TAKES WHAT IS LEFT OF THE WINDOW (media-shell, user 2026-09-26:
|
|
922
|
+
* "why isnt the browser in media shell using available height like media does?"). apps/media had it
|
|
923
|
+
* as a hand-measured `calc(100dvh - 212px)` — its header, gaps and padding, counted once — so the same
|
|
924
|
+
* tool one shell over got the 528 base. Measured instead: the window, less the view's own top, less
|
|
925
|
+
* what the page draws under it (the count line), less the bottom padding / border / margin of every
|
|
926
|
+
* box around it. Follows a resize and any change above it. Floor 240 so a short window still browses. */
|
|
927
|
+
/* `view` is the ELEMENT (state, via a callback ref), not a ref object: the view mounts after the data
|
|
928
|
+
* loads, and in a fixed-height page (kol-shell PageShell) nothing else resizes to wake the observer —
|
|
929
|
+
* a ref read once at mount stayed null and the browser sat at the 528 base. */
|
|
930
|
+
function useFillHeight(on, view, pageEl) {
|
|
931
|
+
const [h, setH] = useState(null)
|
|
932
|
+
useLayoutEffect(() => {
|
|
933
|
+
if (!on || !view) return undefined
|
|
934
|
+
const measure = () => {
|
|
935
|
+
const page = pageEl.current
|
|
936
|
+
if (!page) return
|
|
937
|
+
const r = view.getBoundingClientRect()
|
|
938
|
+
/* what the page DRAWS under the view — its content's bottom, not its box's: a page stretched to
|
|
939
|
+
* fill its container (flex-1) would count its own empty space as "below" and freeze the height */
|
|
940
|
+
const contentBottom = Math.max(r.bottom, ...[...page.children].map((c) => c.getBoundingClientRect().bottom))
|
|
941
|
+
let below = contentBottom - r.bottom
|
|
942
|
+
for (let el = page; el && el !== document.body; el = el.parentElement) {
|
|
943
|
+
const cs = getComputedStyle(el)
|
|
944
|
+
below += parseFloat(cs.paddingBottom) + parseFloat(cs.borderBottomWidth) + (el === page ? 0 : parseFloat(cs.marginBottom))
|
|
945
|
+
}
|
|
946
|
+
const next = Math.max(240, Math.floor(window.innerHeight - r.top - below))
|
|
947
|
+
setH((p) => (p === next ? p : next))
|
|
948
|
+
}
|
|
949
|
+
measure()
|
|
950
|
+
const ro = new ResizeObserver(measure)
|
|
951
|
+
ro.observe(document.body)
|
|
952
|
+
if (pageEl.current) ro.observe(pageEl.current)
|
|
953
|
+
window.addEventListener('resize', measure)
|
|
954
|
+
return () => { ro.disconnect(); window.removeEventListener('resize', measure) }
|
|
955
|
+
}, [on, view]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
956
|
+
return h
|
|
957
|
+
}
|
|
958
|
+
|
|
919
959
|
/* the profile — what the bucket holds; drives the chips and the settings' "nothing to fold" states */
|
|
920
960
|
const profileOf = (objects, rawFiles, systemCount) => ({
|
|
921
961
|
variantSets: groupVariants(objects.map((o) => ({ ...o, displayKey: o.key }))).filter((g) => g.variants).length,
|
|
@@ -1689,6 +1729,17 @@ export function MediaLibraryBrowse({
|
|
|
1689
1729
|
setQuickLook({ files, index: at === -1 ? 0 : at })
|
|
1690
1730
|
}, [rowSelection]) // eslint-disable-line react-hooks/exhaustive-deps
|
|
1691
1731
|
const columnsRef = useRef(null)
|
|
1732
|
+
/* the view box (whichever view is mounted) and the page root — what `fill` measures */
|
|
1733
|
+
const [viewEl, viewRef] = useState(null)
|
|
1734
|
+
/* stable, so the columns box is not re-attached (null, then itself) on every render */
|
|
1735
|
+
const columnsBox = useCallback((el) => { columnsRef.current = el; viewRef(el) }, [])
|
|
1736
|
+
const pageRef = useRef(null)
|
|
1737
|
+
const fill = settings.columnHeight === 'fill'
|
|
1738
|
+
const fillH = useFillHeight(fill, viewEl, pageRef)
|
|
1739
|
+
/* a drag in fill mode is the SESSION's, never saved — one height on every reload (apps/media's ruling) */
|
|
1740
|
+
const [dragH, setDragH] = useState(null)
|
|
1741
|
+
const colH = fill ? (dragH ?? fillH ?? SETTINGS_BASE.columnHeight) : (settings.columnHeight ?? SETTINGS_BASE.columnHeight)
|
|
1742
|
+
const onColH = (px) => (fill ? setDragH(px) : setSettings({ ...settings, columnHeight: px }))
|
|
1692
1743
|
/* `flat` IS THE WALL'S, NOT THIS PAGE'S (user 2026-09-22: "there is no option to switch to flat
|
|
1693
1744
|
* mode, bc its not in files view. this is row mode.. this makes no sense"). It reached here from
|
|
1694
1745
|
* kol-r2b2's single surface and did two things that cannot be defended in a TREE: it struck every
|
|
@@ -1904,12 +1955,12 @@ export function MediaLibraryBrowse({
|
|
|
1904
1955
|
const treeObjects = keep ? sortedObjects.filter((o) => keep.has(o.key)) : sortedObjects
|
|
1905
1956
|
return (
|
|
1906
1957
|
folderView === 'columns' || phone ? (
|
|
1907
|
-
<div ref={
|
|
1958
|
+
<div ref={columnsBox} className="relative">
|
|
1908
1959
|
<ColumnBrowser
|
|
1909
1960
|
autoFocus={autoFocus}
|
|
1910
1961
|
className={atTitleRoot ? 'is-root' : ''}
|
|
1911
|
-
height={
|
|
1912
|
-
onHeightChange={
|
|
1962
|
+
height={colH}
|
|
1963
|
+
onHeightChange={onColH}
|
|
1913
1964
|
columnWidths={settings.columnWidths}
|
|
1914
1965
|
onColumnResize={(i, px) => setSettings({ ...settings, columnWidths: { ...(settings.columnWidths ?? {}), [i]: px } })}
|
|
1915
1966
|
objects={treeObjects.filter((o) => !isSystemFile(o.key)).map((o) => ({ ...o, key: `${VROOT}${o.key}` }))}
|
|
@@ -1993,7 +2044,7 @@ export function MediaLibraryBrowse({
|
|
|
1993
2044
|
* the same line — the count line under them jumps otherwise, and a consumer's fill height
|
|
1994
2045
|
* is ONE value for both — so the header comes out of this view's budget rather than being
|
|
1995
2046
|
* added to it. The pane takes what is left. */
|
|
1996
|
-
<div className="flex flex-col gap-2" style={{ height:
|
|
2047
|
+
<div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
|
|
1997
2048
|
{/* the header labels the LIST, so it spans the list and stops where the preview starts */}
|
|
1998
2049
|
{rowCols && (
|
|
1999
2050
|
<RowHeader sortBy={settings.sortBy} sortDir={settings.sortDir} width={showRowPreview ? `calc(100% - ${previewWidth}px)` : '100%'}
|
|
@@ -2103,7 +2154,7 @@ export function MediaLibraryBrowse({
|
|
|
2103
2154
|
onChange={(e) => setSettings({ ...settings, tileSize: Number(e.target.value) })} aria-label="Tile size" />
|
|
2104
2155
|
)
|
|
2105
2156
|
const wallPane = (files) => (
|
|
2106
|
-
<div className="flex flex-col gap-2" style={{ height:
|
|
2157
|
+
<div ref={viewRef} className="flex flex-col gap-2" style={{ height: colH }}>
|
|
2107
2158
|
<div className="relative border rounded flex overflow-hidden flex-1 min-h-0" style={{ borderColor: 'var(--kol-oq-08)' }}>
|
|
2108
2159
|
{/* THE MARQUEE IS THE PANE'S, not the tile grid's (user 2026-09-23: a drag only started where
|
|
2109
2160
|
* there were tiles — the grid is as tall as its tiles, the pane is the whole frame). The rows'
|
|
@@ -2189,7 +2240,7 @@ export function MediaLibraryBrowse({
|
|
|
2189
2240
|
: treeBody(filtered ? new Set(filtered.map((f) => f.key)) : null))
|
|
2190
2241
|
|
|
2191
2242
|
return (
|
|
2192
|
-
<div {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
2243
|
+
<div ref={pageRef} {...press} className={`flex flex-col gap-6 ${className}`.trim()}>
|
|
2193
2244
|
<LibraryHeader title={title} buckets={buckets} bucketId={bucketMeta.id} appRoot={atTitleRoot} bucketMeta={bucketMeta} writable={writable} headerActions={headerActions} headerTrailing={headerTrailing} onTrash={trash ? () => setTrashOpen(true) : undefined}
|
|
2194
2245
|
onHome={goTitleRoot}
|
|
2195
2246
|
onBucket={(v) => { if (v === 'all') goTitleRoot(); else { setAppRoot(false); switchBucket(v) } }}
|
|
@@ -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
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
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}
|
|
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
|
-
|
|
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
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
<
|
|
169
|
-
|
|
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
|
|
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 }}>
|