@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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kolkrabbi/kol-component",
3
- "version": "0.223.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
- <span {...rest} aria-hidden="true" title={title} className={cls} style={style}>
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
@@ -121,6 +121,7 @@ export { default as SpecList } from './molecules/SpecList.jsx'
121
121
  export { default as SpectrumControls, HueStrip, SBSquare, WheelTriangle } from './organisms/SpectrumControls.jsx'
122
122
  export { default as SplitToolButton } from './molecules/SplitToolButton.jsx'
123
123
  export { default as ToolPalette } from './organisms/ToolPalette.jsx'
124
+ export { default as DocumentEditor } from './organisms/DocumentEditor.jsx'
124
125
  export { default as SwatchControls, SwatchStack, EyedropPick } from './molecules/SwatchControls.jsx'
125
126
  export { default as TabsRow } from './molecules/TabsRow.jsx'
126
127
 
@@ -165,7 +166,7 @@ export { default as MediaLibrary, MediaLibraryProvider, useMediaLibrary, MediaPi
165
166
  /* the full-screen file viewer — public 2026-09-04 (user ruling): it already
166
167
  * rendered both mobile viewer views and was module-private, so every consumer
167
168
  * needing a phone viewer hand-rolled one. */
168
- export { MediaInspector } from './organisms/MediaLibraryPages.jsx'
169
+ export { MediaInspector, mediaSettingsSections, SETTINGS_BASE as MEDIA_SETTINGS_BASE } from './organisms/MediaLibraryPages.jsx'
169
170
  export { default as MediaLibraryExplorer } from './organisms/MediaLibraryExplorer.jsx'
170
171
  export { default as MediaTileGallery } from './organisms/MediaTileGallery.jsx'
171
172
  export { default as MediaViewer } from './organisms/MediaViewer.jsx'
@@ -187,7 +188,8 @@ export { default as RowMenuButton } from './molecules/RowMenuButton.jsx'
187
188
  export { default as DocPage } from './molecules/DocPage.jsx'
188
189
  export { default as DocFrontmatter } from './molecules/DocFrontmatter.jsx'
189
190
  export { default as ProfileCard } from './molecules/ProfileCard.jsx'
190
- export { parseFrontmatter } from './utilities/frontmatter.js'
191
+ export { parseFrontmatter, splitFrontmatter, joinFrontmatter } from './utilities/frontmatter.js'
192
+ export { readDraft, writeDraft, clearDraft, listDrafts, moveDrafts, DRAFTS_EVENT } from './utilities/localDrafts.js'
191
193
  export { readCover } from './utilities/id3.js'
192
194
  export { kindOf, extOf, isSystemFile, KINDS, KIND_LABEL, DEFAULT_KINDS, isSegment, groupSegments, groupVariants, posterFor, partition } from './utilities/mediaKinds.js'
193
195
  export { nearestRatio, RATIOS } from './utilities/ratios.js'
@@ -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
- const currentOption = options.find((opt) => opt.value === value) || options[0]
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
- {options.map((option) => (
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
- <div className="flex items-baseline justify-between gap-6">
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}
@@ -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>
@@ -0,0 +1,282 @@
1
+ /* taxonomy-ok: organism — composes QuickLookFrame, FullscreenOverlay, Input, Textarea, ViewToggle, Button and KindPreview into one editing surface. */
2
+ import { useEffect, useMemo, useRef, useState } from 'react'
3
+ import Button from '../atoms/Button.jsx'
4
+ import Input from '../atoms/Input.jsx'
5
+ import Textarea from '../atoms/Textarea.jsx'
6
+ import ViewToggle from '../atoms/ViewToggle.jsx'
7
+ import QuickLookFrame from '../molecules/QuickLookFrame.jsx'
8
+ import KindPreview from '../molecules/KindPreview.jsx'
9
+ import FullscreenOverlay from '../utilities/FullscreenOverlay.jsx'
10
+ import { splitFrontmatter, joinFrontmatter } from '../utilities/frontmatter.js'
11
+ import { readDraft, writeDraft, clearDraft } from '../utilities/localDrafts.js'
12
+
13
+ /**
14
+ * DocumentEditor — write a text file: open one, or make a new one (media D1 plan v2, 2026-09-26).
15
+ *
16
+ * The concept is kol-olina's brand notes page (NoteEdit): a title, a markdown body, Write / Preview,
17
+ * attachments from the bucket, a page for writing something NEW rather than a button hidden in a
18
+ * list. Here the document is a FILE in the bucket, so it takes any text kind, and a markdown file's
19
+ * frontmatter gets a form instead of a YAML fence to hand-edit.
20
+ *
21
+ * markdown a FIELDS form (title · description · date · tags suggested, any key allowed) over
22
+ * the body; Write · Split · Preview; Attach inserts a bucket file as a link / image
23
+ * svg text with a live picture beside it
24
+ * the rest text (json · yaml · csv · txt · code) with Split / Preview through KindPreview
25
+ *
26
+ * TWO TIERS, as NoteEdit had them: the DRAFT is browser memory (`localDrafts`, every pause, no
27
+ * network — the user's ruling), the SAVE is explicit (`onSave` / ⌘S). A draft is restored only if it
28
+ * is newer than the saved file (`savedAt`), so a file saved since is never overwritten by an old
29
+ * draft. Revert drops the draft.
30
+ *
31
+ * @param {'edit'|'new'} mode `new` shows the name + type row and calls `onCreate`
32
+ * @param {string} name the file's name (edit) or a starting name (new)
33
+ * @param {string} kind markdown · text · json · yaml · code · svg (edit); new picks it
34
+ * @param {string} text the saved text; `null` while it loads
35
+ * @param {number} savedAt epoch ms of the saved file — a draft older than this is ignored
36
+ * @param {Object} draft `{ bucket, key }` — where the draft lives; omit for no drafts
37
+ * @param {Function} onSave `(text) => Promise` — edit mode
38
+ * @param {Function} onCreate `({ name, text }) => Promise` — new mode; the name carries the extension
39
+ * @param {string} folder new mode: where the file will be made (shown, not edited)
40
+ * @param {Array} assets `[{ key, name, url, contentType }]` — the files markdown's Attach offers; a
41
+ * searchable list inside the editor inserts the pick at the caret
42
+ * @param {Function} onPickAsset `() => Promise<{ name, url, contentType } | null>` — a consumer's own picker instead
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
47
+ */
48
+ const TYPES = [
49
+ { value: 'md', label: 'MD', kind: 'markdown' },
50
+ { value: 'txt', label: 'TXT', kind: 'text' },
51
+ { value: 'json', label: 'JSON', kind: 'json' },
52
+ { value: 'yaml', label: 'YAML', kind: 'yaml' },
53
+ { value: 'csv', label: 'CSV', kind: 'text' },
54
+ ]
55
+ const today = () => new Date().toISOString().slice(0, 10)
56
+ const STARTER = {
57
+ md: (title) => joinFrontmatter([['title', title], ['date', today()], ['tags', []]], `\n# ${title}\n\n`),
58
+ txt: () => '',
59
+ json: () => '{\n \n}\n',
60
+ yaml: () => '',
61
+ csv: () => 'name,value\n',
62
+ }
63
+ /* the keys a document usually carries, offered as one-click rows — any other key is typed */
64
+ const SUGGESTED = ['title', 'description', 'date', 'tags']
65
+ const DRAFT_PAUSE = 400 // browser memory is free — a short pause, not D1's write budget
66
+
67
+ export default function DocumentEditor({
68
+ mode = 'edit', name: nameProp = '', kind: kindProp, text, savedAt = 0, draft,
69
+ onSave, onCreate, folder = '', assets, onPickAsset, onClose,
70
+ inline = false,
71
+ }) {
72
+ const [picking, setPicking] = useState(false)
73
+ const [pickQuery, setPickQuery] = useState('')
74
+ const isNew = mode === 'new'
75
+ const [name, setName] = useState(isNew ? (nameProp || 'untitled') : nameProp)
76
+ const [type, setType] = useState('md')
77
+ const kind = isNew ? TYPES.find((t) => t.value === type).kind : kindProp
78
+ const [value, setValue] = useState(null)
79
+ const [base, setBase] = useState(null)
80
+ const [status, setStatus] = useState(isNew ? 'Not created yet' : 'Loading…')
81
+ const [view, setView] = useState(kind === 'markdown' || kind === 'svg' ? 'split' : 'write')
82
+ const timer = useRef(null)
83
+ const bodyRef = useRef(null)
84
+ const latest = useRef('')
85
+
86
+ /* load: the saved text, or a newer draft; a new document starts from its type's starter */
87
+ useEffect(() => {
88
+ if (isNew) {
89
+ const d = draft ? readDraft(draft.bucket, draft.key) : null
90
+ const start = d?.text ?? STARTER.md(name)
91
+ setValue(start); latest.current = start; setBase('')
92
+ if (d) setStatus('Draft restored')
93
+ return
94
+ }
95
+ if (text == null) return
96
+ const d = draft ? readDraft(draft.bucket, draft.key) : null
97
+ const useDraft = d && d.at > savedAt && d.text !== text
98
+ setBase(text)
99
+ setValue(useDraft ? d.text : text); latest.current = useDraft ? d.text : text
100
+ setStatus(useDraft ? 'Draft restored — not saved to the file' : 'Saved')
101
+ if (d && !useDraft && draft) clearDraft(draft.bucket, draft.key)
102
+ }, [text]) // eslint-disable-line react-hooks/exhaustive-deps
103
+
104
+ const flush = () => {
105
+ if (timer.current == null || !draft) return
106
+ clearTimeout(timer.current); timer.current = null
107
+ if (latest.current === base && !isNew) clearDraft(draft.bucket, draft.key)
108
+ else writeDraft(draft.bucket, draft.key, latest.current)
109
+ }
110
+ useEffect(() => () => flush(), []) // eslint-disable-line react-hooks/exhaustive-deps
111
+ const change = (next) => {
112
+ setValue(next); latest.current = next
113
+ setStatus(isNew ? 'Not created yet' : next === base ? 'Saved' : 'Edited — not saved')
114
+ clearTimeout(timer.current)
115
+ timer.current = setTimeout(flush, DRAFT_PAUSE)
116
+ }
117
+
118
+ const dirty = value != null && (isNew || value !== base)
119
+ const save = async () => {
120
+ if (!dirty) return
121
+ clearTimeout(timer.current); timer.current = null
122
+ setStatus(isNew ? 'Creating…' : 'Saving…')
123
+ try {
124
+ if (isNew) {
125
+ const file = name.trim().replace(/\.[^./]+$/, '') || 'untitled'
126
+ await onCreate?.({ name: `${file}.${type}`, text: latest.current })
127
+ } else {
128
+ await onSave?.(latest.current)
129
+ setBase(latest.current)
130
+ }
131
+ if (draft) clearDraft(draft.bucket, draft.key)
132
+ setStatus('Saved')
133
+ } catch (e) { setStatus(`Not saved: ${e.message}`) }
134
+ }
135
+ const revert = () => {
136
+ clearTimeout(timer.current); timer.current = null
137
+ if (draft) clearDraft(draft.bucket, draft.key)
138
+ const back = isNew ? STARTER[type](name) : base
139
+ setValue(back); latest.current = back; setStatus(isNew ? 'Not created yet' : 'Saved')
140
+ }
141
+ /* ⌘S anywhere while the editor is open */
142
+ const saveRef = useRef(save)
143
+ saveRef.current = save
144
+ useEffect(() => {
145
+ const onKey = (e) => { if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 's') { e.preventDefault(); saveRef.current() } }
146
+ window.addEventListener('keydown', onKey)
147
+ return () => window.removeEventListener('keydown', onKey)
148
+ }, [])
149
+
150
+ /* markdown: the fields form and the body are one text, split for editing and joined on change */
151
+ const md = kind === 'markdown'
152
+ const parts = useMemo(() => (md && value != null ? splitFrontmatter(value) : null), [md, value])
153
+ const setFields = (fields) => change(joinFrontmatter(fields, parts.body))
154
+ const setBody = (body) => change(parts.has || parts.fields.length ? joinFrontmatter(parts.fields, body) : body)
155
+
156
+ const insertAsset = (a) => {
157
+ const link = (a.contentType ?? '').startsWith('image/') ? `![${a.name}](${a.url})` : `[${a.name}](${a.url})`
158
+ const el = bodyRef.current?.querySelector('textarea')
159
+ const body = parts.body
160
+ const at = el ? el.selectionStart : body.length
161
+ setBody(`${body.slice(0, at)}${link}${body.slice(at)}`)
162
+ setPicking(false)
163
+ }
164
+ const attach = async () => {
165
+ if (!onPickAsset) { setPicking((p) => !p); return }
166
+ const a = await onPickAsset()
167
+ if (a) insertAsset(a)
168
+ }
169
+ /* images first — Attach is mostly "put this picture in the document" */
170
+ const pickList = (assets ?? [])
171
+ .filter((a) => !pickQuery || a.key.toLowerCase().includes(pickQuery.toLowerCase()))
172
+ .sort((a, b) => Number(!(a.contentType ?? '').startsWith('image/')) - Number(!(b.contentType ?? '').startsWith('image/')) || a.key.localeCompare(b.key))
173
+ .slice(0, 60)
174
+
175
+ /* THE NAME CARRIES THE TITLE while the title is still the starter's — the first thing typed names
176
+ * both, as NoteEdit's title named the note; once the title is edited it is left alone */
177
+ const rename = (next) => {
178
+ const was = name
179
+ setName(next)
180
+ if (!md || !parts) return
181
+ const title = parts.fields.find(([k]) => k.toLowerCase() === 'title')
182
+ if (!title || title[1] !== was) return
183
+ const heading = `# ${was}`
184
+ const body = parts.body.startsWith(`\n${heading}`) || parts.body.startsWith(heading) ? parts.body.replace(heading, `# ${next}`) : parts.body
185
+ change(joinFrontmatter(parts.fields.map(([k, v]) => (k === title[0] ? [k, next] : [k, v])), body))
186
+ }
187
+ const close = () => { flush(); onClose?.() }
188
+ const views = [{ value: 'write', label: 'Write' }, { value: 'split', label: 'Split' }, { value: 'preview', label: 'Preview' }]
189
+ const preview = value == null ? null : kind === 'svg'
190
+ ? <img src={`data:image/svg+xml;charset=utf-8,${encodeURIComponent(value)}`} alt="" className="max-w-full max-h-full object-contain m-auto" />
191
+ : <KindPreview o={{ key: name || 'document' }} text={value} kind={kind} />
192
+
193
+ const frame = (
194
+ <QuickLookFrame className={inline ? 'kol-quicklook--inline' : ''} title={isNew ? `New document${folder ? ` in ${folder}` : ''}` : name} meta={status} onClose={close}
195
+ actions={(
196
+ <span className="flex items-center gap-2">
197
+ <ViewToggle variant="text" viewMode={view} onViewChange={setView} options={views} />
198
+ {md && (onPickAsset || assets?.length > 0) && <Button variant="nav" size="sm" iconLeft="image" onClick={attach} aria-pressed={picking}>Attach</Button>}
199
+ {!isNew && value !== base && value != null && <Button variant="nav" size="sm" onClick={revert}>Revert</Button>}
200
+ <Button variant="secondary" size="sm" onClick={save} disabled={!dirty}>{isNew ? 'Create' : 'Save'}</Button>
201
+ </span>
202
+ )}>
203
+ {/* the window's own caps, inline — Tailwind does not generate arbitrary values from package source */}
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))' }}>
205
+ {isNew && (
206
+ <div className="flex flex-wrap items-center gap-3 p-4 border-b" style={{ borderColor: 'var(--kol-oq-08)' }}>
207
+ <Input variant="filled" size="sm" value={name} aria-label="File name" placeholder="File name"
208
+ onChange={(e) => rename(e.target.value)} className="min-w-0 flex-1" />
209
+ <ViewToggle variant="text" viewMode={type} options={TYPES.map(({ value: v, label }) => ({ value: v, label }))}
210
+ onViewChange={(t) => { setType(t); const s = STARTER[t](name); setValue(s); latest.current = s; setView(t === 'md' ? 'split' : 'write') }} />
211
+ </div>
212
+ )}
213
+ <div className="flex flex-1 min-h-0">
214
+ {view !== 'preview' && (
215
+ <div ref={bodyRef} className="flex flex-col gap-4 p-4 min-w-0 overflow-y-auto" style={{ flex: '1 1 0' }}>
216
+ {md && parts && <FieldsForm fields={parts.fields} onChange={setFields} />}
217
+ {picking && (
218
+ <div className="flex flex-col gap-2 border rounded p-2" style={{ borderColor: 'var(--kol-oq-08)' }}>
219
+ <Input variant="filled" size="xs" value={pickQuery} placeholder="Find a file to attach" aria-label="Find a file to attach"
220
+ onChange={(e) => setPickQuery(e.target.value)} autoFocus />
221
+ <div className="flex flex-col overflow-y-auto" style={{ maxHeight: 200 }}>
222
+ {pickList.length === 0 && <span className="kol-mono-12 text-fg-48 p-2">No file matches.</span>}
223
+ {pickList.map((a) => (
224
+ <button key={a.key} type="button" onClick={() => insertAsset(a)}
225
+ className="kol-mono-12 text-left px-2 py-1 rounded text-fg-default hover:bg-oq-04 truncate">{a.key}</button>
226
+ ))}
227
+ </div>
228
+ </div>
229
+ )}
230
+ {value == null
231
+ ? <p className="kol-mono-12 text-fg-48">Loading…</p>
232
+ : <Textarea variant="filled" size="md" rows={18} axis="vertical" className="w-full flex-1 kol-mono-12"
233
+ value={md ? parts.body : value} onChange={(e) => (md ? setBody(e.target.value) : change(e.target.value))}
234
+ spellCheck={kind === 'markdown' || kind === 'text'} aria-label="Document text"
235
+ placeholder={md ? 'Write in markdown — # heading, **bold**, - list' : ''} />}
236
+ </div>
237
+ )}
238
+ {view !== 'write' && (
239
+ <div className="p-4 min-w-0 overflow-auto border-l" style={{ flex: '1 1 0', borderColor: 'var(--kol-oq-08)' }}>
240
+ {preview}
241
+ </div>
242
+ )}
243
+ </div>
244
+ </div>
245
+ </QuickLookFrame>
246
+ )
247
+ return inline ? frame : <FullscreenOverlay open onClose={close} closeButton={false} scrim>{frame}</FullscreenOverlay>
248
+ }
249
+
250
+ /* THE FRONTMATTER AS FIELDS. One row per key; a list value (tags) is typed comma-separated. The
251
+ * suggested keys a document has not got yet are one click away; any other key is typed. */
252
+ function FieldsForm({ fields, onChange }) {
253
+ const set = (i, key, v) => onChange(fields.map((f, j) => (j === i ? [key, v] : f)))
254
+ const missing = SUGGESTED.filter((s) => !fields.some(([k]) => k.toLowerCase() === s))
255
+ return (
256
+ <div className="flex flex-col gap-2">
257
+ <span className="kol-mono-12 text-fg-48">Fields</span>
258
+ {fields.map(([key, v], i) => (
259
+ <div key={i} className="flex items-center gap-2">
260
+ <Input variant="filled" size="xs" value={key} aria-label="Field name" className="w-28 shrink-0"
261
+ onChange={(e) => set(i, e.target.value, v)} />
262
+ {Array.isArray(v) || key.toLowerCase() === 'tags' ? (
263
+ /* a list commits on Enter / blur — re-deriving it per keystroke would eat the comma you just typed */
264
+ <Input variant="filled" size="xs" className="flex-1 min-w-0" aria-label={`${key || 'Field'} value`}
265
+ value={Array.isArray(v) ? v.join(', ') : v} placeholder="comma, separated"
266
+ onCommit={(raw) => set(i, key, raw.split(',').map((t) => t.trim()).filter(Boolean))} />
267
+ ) : (
268
+ <Input variant="filled" size="xs" className="flex-1 min-w-0" aria-label={`${key || 'Field'} value`}
269
+ value={v} onChange={(e) => set(i, key, e.target.value)} />
270
+ )}
271
+ <Button variant="nav" size="xs" iconOnly="x" aria-label={`Remove ${key}`} onClick={() => onChange(fields.filter((_, j) => j !== i))} />
272
+ </div>
273
+ ))}
274
+ <div className="flex flex-wrap items-center gap-1">
275
+ {missing.map((s) => (
276
+ <Button key={s} variant="nav" size="xs" iconLeft="plus" onClick={() => onChange([...fields, [s, s === 'tags' ? [] : s === 'date' ? today() : '']])}>{s}</Button>
277
+ ))}
278
+ <Button variant="nav" size="xs" iconLeft="plus" onClick={() => onChange([...fields, ['', '']])}>Field</Button>
279
+ </div>
280
+ </div>
281
+ )
282
+ }
@@ -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" title="Delete keyframe" onClick={onDelete} disabled={kfs.length <= 1}>Delete</Button>
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>