@hachej/boring-ui-kit 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +46 -18
  2. package/THIRD_PARTY_NOTICES.md +35 -0
  3. package/dist/canvas-editor-native.d.ts +4 -1
  4. package/dist/canvas-editor-native.js +8 -6
  5. package/dist/canvas-editor.js +1 -1
  6. package/dist/canvas-proposals.d.ts +3 -1
  7. package/dist/canvas-proposals.js +4 -3
  8. package/dist/experience-document-viewer.d.ts +3 -0
  9. package/dist/experience-document-viewer.js +6 -5
  10. package/dist/experience.d.ts +3 -0
  11. package/dist/experience.js +2 -1
  12. package/dist/file-tree-view.d.ts +76 -3
  13. package/dist/file-tree-view.js +172 -39
  14. package/dist/file-tree.d.ts +3 -0
  15. package/dist/file-tree.js +17 -2
  16. package/dist/html-viewer.d.ts +4 -0
  17. package/dist/html-viewer.js +5 -3
  18. package/dist/markdown-autosave.d.ts +13 -0
  19. package/dist/markdown-autosave.js +94 -0
  20. package/dist/markdown-editor.d.ts +16 -0
  21. package/dist/markdown-editor.js +81 -17
  22. package/dist/markdown-extensions.js +9 -1
  23. package/dist/markdown-proposal-diff.d.ts +2 -1
  24. package/dist/markdown-proposal-diff.js +3 -2
  25. package/dist/markdown-safety.js +3 -1
  26. package/dist/native-chat.js +1 -1
  27. package/dist/remote-chat.js +5 -1
  28. package/dist/text-buffer.js +3 -2
  29. package/dist/text-draft-controls.d.ts +3 -1
  30. package/dist/text-draft-controls.js +3 -2
  31. package/dist/viewer-components.d.ts +14 -0
  32. package/dist/viewer-components.js +1 -0
  33. package/package.json +4 -4
  34. package/r/app-shell.json +39 -20
  35. package/r/button.json +19 -11
  36. package/r/dock.json +2 -2
  37. package/r/documents.json +17 -14
  38. package/r/feedback-preview.json +8 -2
  39. package/r/feedback.json +46 -325
  40. package/r/file-tree.json +14 -6
  41. package/r/html-viewer.json +8 -42
  42. package/r/markdown-editor.json +38 -87
  43. package/r/pi-ambient.json +15 -8
  44. package/r/pi-app.json +29 -18
  45. package/r/pi-chat.json +88 -69
  46. package/r/pi-workspace.json +7 -5
  47. package/r/provider-setup.json +9 -4
  48. package/r/registry.json +460 -888
  49. package/r/task-list-viewer.json +11 -21
  50. package/r/theme.json +23 -4
  51. package/r/version-history.json +24 -0
  52. package/r/viewers.json +139 -320
  53. package/styles/file-tree.css +41 -42
  54. package/dist/file-tree-shadcn.d.ts +0 -8
  55. package/dist/file-tree-shadcn.js +0 -14
@@ -1,8 +1,11 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { TextDraftControls } from './text-draft-controls.js';
4
- import { useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
4
+ import { useMarkdownAutosave } from './markdown-autosave.js';
5
+ import { Fragment, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from 'react';
6
+ import { createPortal } from 'react-dom';
5
7
  import { EditorContent, useEditor, useEditorState } from '@tiptap/react';
8
+ import { BubbleMenu } from '@tiptap/react/menus';
6
9
  import { createMarkdownExtensions } from './markdown-extensions.js';
7
10
  import { checkMarkdownRichSafety, describeUnsafe, finishRichMarkdown } from './markdown-safety.js';
8
11
  import { MarkdownIcon } from './markdown-icons.js';
@@ -18,7 +21,55 @@ export function MarkdownEditor(props) {
18
21
  setMount({ controller: props.controller, sequence: mount.sequence + 1 });
19
22
  return _jsx(MarkdownEditorSession, { ...props, activeController: active }, mount.sequence);
20
23
  }
21
- function MarkdownEditorSession({ controller, title = 'Document', initialMode = 'rich', placeholder = 'Start writing', className, onMountedTools, header = true, mode: controlledMode, onModeChange, onRichSafety, resolveImage, activeController: active }) {
24
+ const PHONE_QUERY = '(max-width: 640px)';
25
+ const phoneSnapshot = () => globalThis.matchMedia?.(PHONE_QUERY).matches === true;
26
+ const desktopSnapshot = () => false;
27
+ const subscribePhone = (notify) => {
28
+ const media = globalThis.matchMedia?.(PHONE_QUERY);
29
+ media?.addEventListener('change', notify);
30
+ return () => media?.removeEventListener('change', notify);
31
+ };
32
+ const BUBBLE_OPTIONS = { strategy: 'fixed', placement: 'top', offset: 8, shift: { padding: 12 }, flip: true };
33
+ const bubbleContainer = () => document.body;
34
+ function useEditorFocus(editor) {
35
+ const [focused, setFocused] = useState(false);
36
+ useEffect(() => {
37
+ if (!editor || editor.isDestroyed)
38
+ return;
39
+ const update = () => setFocused(editor.isFocused);
40
+ update();
41
+ editor.on('focus', update);
42
+ editor.on('blur', update);
43
+ return () => { editor.off('focus', update); editor.off('blur', update); };
44
+ }, [editor]);
45
+ return focused;
46
+ }
47
+ /** Height the soft keyboard covers at the bottom of the layout viewport, so a fixed bar can sit directly above it. */
48
+ function useKeyboardInset(active) {
49
+ const [inset, setInset] = useState(0);
50
+ useEffect(() => {
51
+ const viewport = globalThis.visualViewport;
52
+ if (!active || !viewport) {
53
+ setInset(0);
54
+ return;
55
+ }
56
+ const update = () => setInset(Math.max(0, Math.round(globalThis.innerHeight - viewport.offsetTop - viewport.height)));
57
+ update();
58
+ viewport.addEventListener('resize', update);
59
+ viewport.addEventListener('scroll', update);
60
+ return () => { viewport.removeEventListener('resize', update); viewport.removeEventListener('scroll', update); };
61
+ }, [active]);
62
+ return inset;
63
+ }
64
+ function MarkdownEditorSession({ components, controller, title = 'Document', initialMode = 'rich', placeholder = 'Start writing', className, onMountedTools, header = true, showOutline = header, mode: controlledMode, onModeChange, onRichSafety, resolveImage, formattingToolbar = 'pinned', autosave = true, onAutosaveChange, onSaveResult, activeController: active }) {
65
+ const Control = components?.button ?? 'button';
66
+ const Bubble = components?.bubble ?? Fragment;
67
+ const Source = components?.textarea ?? 'textarea';
68
+ const LinkInput = components?.input ?? 'input';
69
+ const phone = useSyncExternalStore(subscribePhone, phoneSnapshot, desktopSnapshot);
70
+ const [pinned, setPinned] = useState(formattingToolbar === 'pinned');
71
+ useEffect(() => { setPinned(formattingToolbar === 'pinned'); }, [formattingToolbar]);
72
+ const showPinned = pinned && !phone;
22
73
  const state = useSyncExternalStore(controller.subscribe, controller.getSnapshot, controller.getSnapshot);
23
74
  const projected = useRef(null);
24
75
  const emitted = useRef(null);
@@ -160,20 +211,26 @@ function MarkdownEditorSession({ controller, title = 'Document', initialMode = '
160
211
  const outcome = state.save.kind === 'settled' ? state.save.result : null;
161
212
  const uncertain = outcome?.kind === 'unknown';
162
213
  const conflict = outcome?.kind === 'conflict' || state.remote !== null;
163
- const save = () => {
164
- const selected = controller.actions.selection();
165
- setError(null);
166
- void controller.flush(selected).then(report, () => { if (active.current === controller)
167
- setError('The save could not be completed. Your text is still available.'); });
168
- };
169
214
  /** Shows a refused save or accept; the buffer keeps the text. */
170
215
  const report = (result) => {
171
216
  if (active.current !== controller || !result || typeof result !== 'object')
172
217
  return;
173
218
  const { kind, reason } = result;
219
+ if (kind === 'saved')
220
+ setError(null);
174
221
  if (kind === 'denied' || kind === 'conflict' || kind === 'unavailable' || kind === 'file_too_large')
175
222
  setError(typeof reason === 'string' && reason ? reason : kind === 'conflict' ? 'The document changed elsewhere. Your text is still available.' : 'The document could not be saved. Your text is still available.');
176
223
  };
224
+ const saving = useMarkdownAutosave(controller, autosave, result => { report(result); onSaveResult?.(result); });
225
+ useEffect(() => { onAutosaveChange?.(saving.enabled ? saving.immediate : null); return () => onAutosaveChange?.(null); }, [onAutosaveChange, saving.enabled, saving.immediate]);
226
+ const save = () => {
227
+ setError(null);
228
+ if (saving.enabled)
229
+ void saving.manual();
230
+ else
231
+ void controller.flush(controller.actions.selection()).then(report, () => { if (active.current === controller)
232
+ setError('The save could not be completed. Your text is still available.'); });
233
+ };
177
234
  const run = (operation) => {
178
235
  setError(null);
179
236
  void operation().then(report, () => { if (active.current === controller)
@@ -181,14 +238,19 @@ function MarkdownEditorSession({ controller, title = 'Document', initialMode = '
181
238
  };
182
239
  const richOnly = unavailable || mode === 'source' || !editor;
183
240
  const [linkDraft, setLinkDraft] = useState(null);
241
+ // On a phone the full formatting row docks above the soft keyboard while the rich surface has focus.
242
+ const editing = useEditorFocus(editor);
243
+ const dockVisible = phone && !unavailable && !!editor && (editing || linkDraft !== null);
244
+ const keyboardInset = useKeyboardInset(dockVisible);
184
245
  const command = (operation) => { interaction.current = true; try {
185
246
  operation();
186
247
  }
187
248
  finally {
188
249
  interaction.current = false;
189
250
  } };
190
- const tool = (label, icon, pressed, operation, extra = {}) => (_jsx("button", { type: "button", className: "boring-markdown-tool", "aria-label": label, title: label, "aria-pressed": pressed === true, "data-active": pressed === true || undefined, disabled: richOnly || extra.disabled, onClick: () => command(operation), children: _jsx(MarkdownIcon, { name: icon }) }));
191
- const separator = _jsx("span", { role: "separator", "aria-orientation": "vertical", className: "boring-markdown-separator" });
251
+ const tool = (label, icon, pressed, operation, extra = {}) => (_jsx(Control, { type: "button", className: "boring-markdown-tool", "aria-label": label, title: label, "aria-pressed": pressed === true, "data-active": pressed === true || undefined, disabled: richOnly || extra.disabled, onClick: () => command(operation), children: _jsx(MarkdownIcon, { name: icon }) }));
252
+ const Rule = components?.separator;
253
+ const separator = Rule ? _jsx(Rule, { orientation: "vertical", className: "boring-markdown-separator" }) : _jsx("span", { role: "separator", "aria-orientation": "vertical", className: "boring-markdown-separator" });
192
254
  const chain = () => editor.chain().focus();
193
255
  const openLink = () => setLinkDraft(marks?.href ?? '');
194
256
  const applyLink = () => {
@@ -198,17 +260,19 @@ function MarkdownEditorSession({ controller, title = 'Document', initialMode = '
198
260
  command(() => href === '' ? chain().extendMarkRange('link').unsetLink().run() : chain().extendMarkRange('link').setLink({ href }).run());
199
261
  setLinkDraft(null);
200
262
  };
201
- return _jsxs("section", { className: className, "data-boring": "markdown-editor", "data-mode": mode, "data-dirty": state.dirty || undefined, onKeyDownCapture: event => {
263
+ const formatTools = _jsxs(_Fragment, { children: [tool('Bold', 'Bold', marks?.bold, () => chain().toggleBold().run()), tool('Italic', 'Italic', marks?.italic, () => chain().toggleItalic().run()), tool('Strikethrough', 'Strike', marks?.strike, () => chain().toggleStrike().run()), tool('Highlight', 'Highlight', marks?.highlight, () => chain().toggleHighlight().run()), tool('Code', 'Code', marks?.code, () => chain().toggleCode().run()), separator, tool('Heading 1', 'Heading1', marks?.h1, () => chain().toggleHeading({ level: 1 }).run()), tool('Heading 2', 'Heading2', marks?.h2, () => chain().toggleHeading({ level: 2 }).run()), tool('Heading 3', 'Heading3', marks?.h3, () => chain().toggleHeading({ level: 3 }).run()), separator, tool('Bullet list', 'BulletList', marks?.bullet, () => chain().toggleBulletList().run()), tool('Numbered list', 'OrderedList', marks?.ordered, () => chain().toggleOrderedList().run()), tool('Task list', 'TaskList', marks?.task, () => chain().toggleTaskList().run()), separator, tool('Quote', 'Quote', marks?.quote, () => chain().toggleBlockquote().run()), tool('Code block', 'CodeBlock', marks?.codeBlock, () => chain().toggleCodeBlock().run()), tool('Link', 'Link', marks?.link || linkDraft !== null, openLink), tool('Table', 'Table', marks?.table, () => marks?.table ? chain().deleteTable().run() : chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()), tool('Horizontal rule', 'Rule', false, () => chain().setHorizontalRule().run()), marks?.table && _jsxs(_Fragment, { children: [separator, tool('Add table row', 'Rows', false, () => chain().addRowAfter().run()), tool('Add table column', 'Columns', false, () => chain().addColumnAfter().run()), tool('Delete table', 'Trash', false, () => chain().deleteTable().run())] })] });
264
+ return _jsxs("section", { className: className, "data-boring": "markdown-editor", "data-mode": mode, "data-dock": (mode === 'rich' && dockVisible) || undefined, "data-dirty": state.dirty || undefined, onBlurCapture: event => { if (saving.enabled && !event.currentTarget.contains(event.relatedTarget))
265
+ void saving.immediate(); }, onKeyDownCapture: event => {
202
266
  if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 's') {
203
267
  event.preventDefault();
204
- if (!unavailable && !pending && !uncertain)
268
+ if (!unavailable && !uncertain)
205
269
  save();
206
270
  }
207
- }, children: [header && _jsxs("header", { children: [_jsx("strong", { children: title }), state.readOnly && _jsx("span", { children: "Read only" }), _jsx("span", { role: "status", children: state.lifecycle === 'disposed' ? 'Closed' : pending ? 'Saving' : uncertain ? 'Save unconfirmed' : conflict ? 'Changed elsewhere' : state.dirty ? 'Unsaved changes' : 'Saved' }), _jsx("button", { type: "button", "aria-pressed": mode === 'source', disabled: !richOffered, onClick: switchMode, children: "Markdown source" }), _jsx("button", { type: "button", disabled: unavailable || !state.dirty || pending || uncertain, onClick: save, children: "Save" })] }), _jsx(TextDraftControls, { recovery: state.recovery, actions: controller.actions, readOnly: state.readOnly, blocked: state.lifecycle !== 'active' || state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown') }), mode === 'rich' && _jsxs("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-markdown-toolbar", children: [tool('Bold', 'Bold', marks?.bold, () => chain().toggleBold().run()), tool('Italic', 'Italic', marks?.italic, () => chain().toggleItalic().run()), tool('Strikethrough', 'Strike', marks?.strike, () => chain().toggleStrike().run()), tool('Highlight', 'Highlight', marks?.highlight, () => chain().toggleHighlight().run()), tool('Code', 'Code', marks?.code, () => chain().toggleCode().run()), separator, tool('Heading 1', 'Heading1', marks?.h1, () => chain().toggleHeading({ level: 1 }).run()), tool('Heading 2', 'Heading2', marks?.h2, () => chain().toggleHeading({ level: 2 }).run()), tool('Heading 3', 'Heading3', marks?.h3, () => chain().toggleHeading({ level: 3 }).run()), separator, tool('Bullet list', 'BulletList', marks?.bullet, () => chain().toggleBulletList().run()), tool('Numbered list', 'OrderedList', marks?.ordered, () => chain().toggleOrderedList().run()), tool('Task list', 'TaskList', marks?.task, () => chain().toggleTaskList().run()), separator, tool('Quote', 'Quote', marks?.quote, () => chain().toggleBlockquote().run()), tool('Code block', 'CodeBlock', marks?.codeBlock, () => chain().toggleCodeBlock().run()), tool('Link', 'Link', marks?.link || linkDraft !== null, openLink), tool('Table', 'Table', marks?.table, () => marks?.table ? chain().deleteTable().run() : chain().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run()), tool('Horizontal rule', 'Rule', false, () => chain().setHorizontalRule().run()), marks?.table && _jsxs(_Fragment, { children: [separator, tool('Add table row', 'Rows', false, () => chain().addRowAfter().run()), tool('Add table column', 'Columns', false, () => chain().addColumnAfter().run()), tool('Delete table', 'Trash', false, () => chain().deleteTable().run())] })] }), mode === 'rich' && linkDraft !== null && _jsxs("form", { className: "boring-markdown-link", "aria-label": "Link address", onSubmit: event => { event.preventDefault(); applyLink(); }, children: [_jsx("input", { type: "text", inputMode: "url", autoFocus: true, "aria-label": "Link address", placeholder: "https://", value: linkDraft, onChange: event => setLinkDraft(event.currentTarget.value), onKeyDown: event => { if (event.key === 'Escape') {
271
+ }, children: [header && _jsxs("header", { children: [_jsx("strong", { children: title }), state.readOnly && _jsx("span", { children: "Read only" }), _jsx("span", { role: "status", children: state.lifecycle === 'disposed' ? 'Closed' : pending ? 'Saving…' : uncertain ? 'Save unconfirmed' : conflict ? 'Changed elsewhere' : state.dirty ? 'Unsaved changes' : 'Saved' }), mode === 'rich' && !phone && !unavailable && _jsx(Control, { type: "button", className: "boring-markdown-pin", "aria-label": "Pin formatting toolbar", title: "Pin formatting toolbar", "aria-pressed": pinned, onClick: () => setPinned(value => !value), children: "Aa" }), _jsx(Control, { type: "button", "aria-pressed": mode === 'source', disabled: !richOffered, onClick: switchMode, children: "Markdown source" }), !saving.enabled && _jsx(Control, { type: "button", disabled: unavailable || !state.dirty || pending || uncertain, onClick: save, children: "Save" })] }), _jsx(TextDraftControls, { components: components, recovery: state.recovery, actions: controller.actions, readOnly: state.readOnly, blocked: state.lifecycle !== 'active' || state.save.kind === 'pending' || (state.save.kind === 'settled' && state.save.result.kind === 'unknown') }), mode === 'rich' && showPinned && _jsx("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-markdown-toolbar", children: formatTools }), mode === 'rich' && dockVisible && createPortal(_jsxs("div", { role: "toolbar", "aria-label": "Text formatting", className: "boring-viewer boring-markdown-dock", "data-testid": "markdown-dock", "data-keyboard": keyboardInset > 0 || undefined, style: { bottom: keyboardInset }, onPointerDown: event => event.preventDefault(), children: [Rule && _jsx(Rule, { className: "boring-markdown-dock-rule" }), formatTools] }), document.body), mode === 'rich' && editor && !showPinned && !unavailable && _jsx(BubbleMenu, { editor: editor, updateDelay: 0, options: BUBBLE_OPTIONS, appendTo: bubbleContainer, role: "toolbar", "aria-label": "Text formatting", className: "boring-viewer boring-markdown-bubble", children: _jsxs(Bubble, { children: [tool('Bold', 'Bold', marks?.bold, () => chain().toggleBold().run()), tool('Italic', 'Italic', marks?.italic, () => chain().toggleItalic().run()), tool('Highlight', 'Highlight', marks?.highlight, () => chain().toggleHighlight().run()), tool('Code', 'Code', marks?.code, () => chain().toggleCode().run()), tool('Link', 'Link', marks?.link || linkDraft !== null, openLink)] }) }), mode === 'rich' && linkDraft !== null && _jsxs("form", { className: "boring-markdown-link", "aria-label": "Link address", onSubmit: event => { event.preventDefault(); applyLink(); }, children: [_jsx(LinkInput, { type: "text", inputMode: "url", autoFocus: true, "aria-label": "Link address", placeholder: "https://", value: linkDraft, onChange: event => setLinkDraft(event.currentTarget.value), onKeyDown: event => { if (event.key === 'Escape') {
208
272
  event.preventDefault();
209
273
  setLinkDraft(null);
210
274
  editor?.commands.focus();
211
- } } }), _jsx("button", { type: "submit", children: linkDraft.trim() === '' && marks?.link ? 'Remove link' : 'Apply link' }), _jsx("button", { type: "button", onClick: () => { setLinkDraft(null); editor?.commands.focus(); }, children: "Cancel" })] }), !richOffered && _jsx("p", { role: "note", className: "boring-markdown-notice", "data-testid": "markdown-source-only", children: describeUnsafe(safety) }), mode === 'rich' && outline && outline.headings.length > 0 && _jsx("nav", { "aria-label": "Document outline", children: _jsx("ol", { children: outline.headings.map((heading, index) => _jsx("li", { "data-heading-level": heading.level, children: _jsx("button", { type: "button", "aria-label": `Go to heading: ${heading.text || 'Untitled heading'}`, disabled: state.lifecycle !== 'active', onClick: () => {
275
+ } } }), _jsx(Control, { type: "submit", children: linkDraft.trim() === '' && marks?.link ? 'Remove link' : 'Apply link' }), _jsx(Control, { type: "button", onClick: () => { setLinkDraft(null); editor?.commands.focus(); }, children: "Cancel" })] }), !richOffered && _jsx("p", { role: "note", className: "boring-markdown-notice", "data-testid": "markdown-source-only", children: describeUnsafe(safety) }), showOutline && mode === 'rich' && outline && outline.headings.length > 0 && _jsx("nav", { "aria-label": "Document outline", children: _jsx("ol", { children: outline.headings.map((heading, index) => _jsx("li", { "data-heading-level": heading.level, children: _jsx(Control, { type: "button", "aria-label": `Go to heading: ${heading.text || 'Untitled heading'}`, disabled: state.lifecycle !== 'active', onClick: () => {
212
276
  if (!editor || editor.isDestroyed || active.current !== controller || controller.getSnapshot().lifecycle !== 'active'
213
277
  || controller.getSnapshot().bufferVersion !== state.bufferVersion || editor.state.doc !== outline.document)
214
278
  return;
@@ -222,8 +286,8 @@ function MarkdownEditorSession({ controller, title = 'Document', initialMode = '
222
286
  setError(result.reason);
223
287
  }).catch(() => { if (active.current === controller)
224
288
  setError('The heading could not be selected'); });
225
- }, children: heading.text || 'Untitled heading' }) }, heading.position)) }) }), uncertain && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["Save acknowledgement was lost. Keep editing while its outcome is checked.", _jsx("button", { type: "button", onClick: () => run(controller.actions.reconcile), children: "Check save outcome" }), _jsx("button", { type: "button", onClick: () => run(controller.actions.abandon), children: "Stop checking and refresh, keeping my draft" })] }), conflict && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["The saved document changed. Your local text has been kept.", _jsx("button", { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.refresh), children: "Check saved version" }), _jsx("button", { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.discardToRemote), children: "Discard local changes and reload" })] }), (outcome?.kind === 'denied' || outcome?.kind === 'unavailable' || outcome?.kind === 'file_too_large') && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: outcome.reason }), error && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: error }), state.proposals.map(proposal => _jsxs("article", { "data-boring": "proposal", className: "boring-markdown-proposal", children: [_jsx("h3", { children: proposal.summary || 'Proposed edit' }), _jsxs("details", { children: [_jsx("summary", { children: "Review text changes" }), _jsx(MarkdownProposalDiff, { proposal: proposal }), _jsxs("details", { children: [_jsx("summary", { children: "Exact before and after" }), _jsx("h4", { children: "Before" }), _jsx("pre", { children: proposal.before }), _jsx("h4", { children: "After" }), _jsx("pre", { children: proposal.after })] })] }), _jsx("button", { type: "button", disabled: unavailable || pending || uncertain || proposal.adopted, onClick: () => run(() => controller.actions.accept(proposal.id)), children: proposal.adopted ? 'Adopted' : 'Accept and save' }), _jsx("button", { type: "button", onClick: () => controller.actions.reject(proposal.id), children: "Dismiss proposal" })] }, proposal.id)), mode === 'source'
226
- ? _jsx("textarea", { ref: sourceInput, className: "boring-markdown-source", "aria-label": `${title} Markdown source`, value: state.text, readOnly: unavailable, spellCheck: false, onChange: event => controller.actions.edit(event.currentTarget.value) })
289
+ }, children: heading.text || 'Untitled heading' }) }, heading.position)) }) }), uncertain && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["Save acknowledgement was lost. Keep editing while its outcome is checked.", _jsx(Control, { type: "button", onClick: () => run(controller.actions.reconcile), children: "Check save outcome" }), _jsx(Control, { type: "button", onClick: () => run(controller.actions.abandon), children: "Stop checking and refresh, keeping my draft" })] }), conflict && _jsxs("div", { role: "alert", className: "boring-markdown-alert", children: ["The saved document changed. Your local text has been kept.", _jsx(Control, { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.refresh), children: "Check saved version" }), _jsx(Control, { type: "button", disabled: pending || uncertain, onClick: () => run(controller.actions.discardToRemote), children: "Discard local changes and reload" })] }), (outcome?.kind === 'denied' || outcome?.kind === 'unavailable' || outcome?.kind === 'file_too_large') && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: outcome.reason }), error && _jsx("p", { role: "alert", className: "boring-markdown-alert", children: error }), state.proposals.map(proposal => _jsxs("article", { "data-boring": "proposal", className: "boring-markdown-proposal", children: [_jsx("h3", { children: proposal.summary || 'Proposed edit' }), _jsxs("details", { children: [_jsx("summary", { children: "Review text changes" }), _jsx(MarkdownProposalDiff, { components: components, proposal: proposal }), _jsxs("details", { children: [_jsx("summary", { children: "Exact before and after" }), _jsx("h4", { children: "Before" }), _jsx("pre", { children: proposal.before }), _jsx("h4", { children: "After" }), _jsx("pre", { children: proposal.after })] })] }), _jsx(Control, { type: "button", disabled: unavailable || pending || uncertain || proposal.adopted, onClick: () => run(() => controller.actions.accept(proposal.id)), children: proposal.adopted ? 'Adopted' : 'Accept and save' }), _jsx(Control, { type: "button", onClick: () => controller.actions.reject(proposal.id), children: "Dismiss proposal" })] }, proposal.id)), mode === 'source'
290
+ ? _jsx(Source, { ref: sourceInput, className: "boring-markdown-source", "aria-label": `${title} Markdown source`, value: state.text, readOnly: unavailable, spellCheck: false, onChange: event => controller.actions.edit(event.currentTarget.value) })
227
291
  : _jsx("div", { className: "boring-markdown-surface", onKeyDownCapture: markInteraction, onBeforeInputCapture: markInteraction, onInputCapture: markInteraction, onPasteCapture: markInteraction, onDropCapture: markInteraction, onChangeCapture: markInteraction, onClickCapture: event => { markInteraction(); if (event.target instanceof Element && event.target.closest('a'))
228
292
  event.preventDefault(); }, onAuxClickCapture: event => { if (event.target instanceof Element && event.target.closest('a'))
229
293
  event.preventDefault(); }, children: _jsx(EditorContent, { editor: editor }) })] });
@@ -5,6 +5,7 @@ import { TaskItem, TaskList } from '@tiptap/extension-list';
5
5
  import { Table, TableCell, TableHeader, TableRow } from '@tiptap/extension-table';
6
6
  import Image from '@tiptap/extension-image';
7
7
  import Highlight from '@tiptap/extension-highlight';
8
+ import { Node as TiptapNode } from '@tiptap/core';
8
9
  import { createMarkdownParser } from './markdown-parser.js';
9
10
  /**
10
11
  * The serialiser HTML-encodes text (`&` becomes `&`), which rewrites ordinary prose such as "Notes & Highlights". The parser
@@ -24,6 +25,13 @@ const PlainTextMarkdown = Markdown.extend({
24
25
  };
25
26
  },
26
27
  });
28
+ const RichStarterKit = StarterKit.extend({
29
+ addExtensions() {
30
+ return (this.parent?.() ?? []).map(extension => extension instanceof TiptapNode && extension.name === 'paragraph'
31
+ ? extension.extend({ renderMarkdown: (node, helpers) => helpers.renderChildren(node.content ?? []) })
32
+ : extension);
33
+ },
34
+ });
27
35
  /**
28
36
  * A GFM table keeps the exact text it was written in (unpadded `|a|b|`, `:---:` alignment rows, `_emphasis_`, escaped pipes, empty
29
37
  * cells) while its content is unchanged: parsing records the source and the canonical JSON of the rows it produced, and rendering
@@ -85,7 +93,7 @@ export function createMarkdownExtensions({ placeholder, images } = {}) {
85
93
  onBeforeCreate() { this.parent?.(); schema = this.editor.schema; },
86
94
  });
87
95
  return [
88
- StarterKit.configure({ link: { openOnClick: false, autolink: false, linkOnPaste: false }, underline: false }),
96
+ RichStarterKit.configure({ link: { openOnClick: false, autolink: false, linkOnPaste: false }, underline: false }),
89
97
  TaskList, TaskItem.configure({ nested: true }),
90
98
  SourceTable.configure({ resizable: false }), TableRow, TableHeader, TableCell,
91
99
  InertImage.configure({ inline: false, allowBase64: true }),
@@ -1,5 +1,6 @@
1
1
  import type { MarkdownProposal } from './markdown.js';
2
- export declare function MarkdownProposalDiff({ proposal }: {
2
+ export declare function MarkdownProposalDiff({ components, proposal }: {
3
+ readonly components?: import('./viewer-components.js').ViewerComponents | undefined;
3
4
  readonly proposal: MarkdownProposal;
4
5
  }): import("react").JSX.Element;
5
6
  //# sourceMappingURL=markdown-proposal-diff.d.ts.map
@@ -107,11 +107,12 @@ function DiffRow({ line }) {
107
107
  const display = terminated ? line.value.slice(0, -1) : line.value;
108
108
  return _jsxs("div", { "data-diff": line.kind, "data-line-ending": terminated ? 'lf' : 'none', children: [_jsx("span", { children: line.kind === 'add' ? '+ ' : line.kind === 'remove' ? '- ' : ' ' }), _jsx("span", { "data-diff-text": "", children: display }), line.kind !== 'same' && line.value.endsWith('\r\n') && _jsx("span", { "data-diff-crlf": "", children: " CRLF line ending" }), !terminated && _jsx("span", { "data-diff-no-newline": "", children: " No newline at end" })] });
109
109
  }
110
- export function MarkdownProposalDiff({ proposal }) {
110
+ export function MarkdownProposalDiff({ components, proposal }) {
111
+ const Control = components?.button ?? 'button';
111
112
  const diff = useMemo(() => review(proposal.before, proposal.after), [proposal.before, proposal.after]);
112
113
  const shown = useMemo(() => entries(diff.lines), [diff]);
113
114
  const [expanded, setExpanded] = useState(() => new Set());
114
- return _jsxs("div", { "data-boring": "proposal-diff", role: "group", "aria-label": "Proposed line changes", children: [diff.fallback && _jsx("p", { role: "status", "data-diff-fallback": "", children: "Detailed line alignment unavailable; showing changed block." }), proposal.before === proposal.after && _jsx("p", { children: "No text changes" }), _jsx("div", { style: { overflowX: 'auto', whiteSpace: 'pre-wrap' }, children: shown.map((entry, index) => entry.kind === 'fold' ? _jsxs("div", { children: [_jsxs("button", { type: "button", "data-diff-fold": "", "aria-expanded": expanded.has(entry.index), onClick: () => setExpanded(current => {
115
+ return _jsxs("div", { "data-boring": "proposal-diff", role: "group", "aria-label": "Proposed line changes", children: [diff.fallback && _jsx("p", { role: "status", "data-diff-fallback": "", children: "Detailed line alignment unavailable; showing changed block." }), proposal.before === proposal.after && _jsx("p", { children: "No text changes" }), _jsx("div", { style: { overflowX: 'auto', whiteSpace: 'pre-wrap' }, children: shown.map((entry, index) => entry.kind === 'fold' ? _jsxs("div", { children: [_jsxs(Control, { type: "button", "data-diff-fold": "", "aria-expanded": expanded.has(entry.index), onClick: () => setExpanded(current => {
115
116
  const next = new Set(current);
116
117
  if (next.has(entry.index))
117
118
  next.delete(entry.index);
@@ -7,10 +7,12 @@ export function normalizeMarkdownWhitespace(text) {
7
7
  const lines = text.split('\n');
8
8
  const out = [];
9
9
  let fence;
10
- for (const line of lines) {
10
+ for (let line of lines) {
11
11
  const opened = FENCE.exec(line);
12
12
  if (opened && (!fence || opened[1] === fence))
13
13
  fence = fence ? undefined : opened[1];
14
+ if (!fence && !opened && !/^ {4}|^\t/.test(line) && /^(?:[ \t]* [ \t]*)+$/.test(line))
15
+ line = '';
14
16
  if (!fence && !opened && line.trim() === '' && out.length > 0 && out[out.length - 1].trim() === '')
15
17
  continue;
16
18
  out.push(line);
@@ -202,7 +202,7 @@ export function createNativeChatController(options) {
202
202
  }
203
203
  const cursor = page.next === undefined ? undefined : structuredClone(page.next);
204
204
  historyScan = { maxEntryId: scan.maxEntryId, ...(cursor === undefined ? {} : { cursor }), ...(previousId === undefined ? {} : { lastId: previousId }) };
205
- publish({ history: { kind: 'ready', entries: Object.freeze([...page.items].reverse()), hasMore: cursor !== undefined } });
205
+ publish({ history: { kind: 'ready', entries: Object.freeze([...page.items].reverse().concat(entries)), hasMore: cursor !== undefined } });
206
206
  }
207
207
  catch (error) {
208
208
  if (current())
@@ -316,7 +316,11 @@ export async function createRemoteChat(options) {
316
316
  // The controller only reconciles a submission by request ID; no other transaction work crosses the wire.
317
317
  commit: async (change, _context) => change({ submissionByRequest: (_conversationId, requestId) => lookup(requestId) }),
318
318
  abort: async () => { await post('abort', {}); },
319
- entries: async (_query, limit, cursor) => (await call('entries', {}, { limit: String(limit), ...(cursor === undefined ? {} : { cursor: JSON.stringify(cursor) }) })).page,
319
+ entries: async (query, limit, cursor) => (await call('entries', {}, { limit: String(limit),
320
+ ...(query.maxEntryId === undefined ? {} : { maxEntryId: String(query.maxEntryId) }),
321
+ ...(query.minEntryId === undefined ? {} : { minEntryId: String(query.minEntryId) }),
322
+ ...(query.order === undefined ? {} : { order: query.order }),
323
+ ...(cursor === undefined ? {} : { cursor: JSON.stringify(cursor) }) })).page,
320
324
  };
321
325
  const context = Object.freeze({});
322
326
  const close = async () => { const first = unclaimed; unclaimed = undefined; await first?.stop(); };
@@ -233,8 +233,9 @@ export function createTextBuffer(options) {
233
233
  return read;
234
234
  }
235
235
  if (sameBase(remote, state.base) && !discard) {
236
- if (state.remote !== null)
237
- update({ ...state, remote: null });
236
+ const resolved = state.save.kind === 'settled' && state.save.result.kind !== 'saved';
237
+ if (state.remote !== null || resolved)
238
+ update({ ...state, remote: null, save: resolved ? { kind: 'idle' } : state.save });
238
239
  return read;
239
240
  }
240
241
  if (state.dirty && (!discard || state.bufferVersion !== previousVersion)) {
@@ -1,9 +1,11 @@
1
1
  import type { TextDraftActions, TextDraftRecoveryState } from './text-draft-types.js';
2
+ import type { ViewerComponents } from './viewer-components.js';
2
3
  export interface TextDraftControlsProps {
4
+ readonly components?: ViewerComponents | undefined;
3
5
  readonly recovery: TextDraftRecoveryState;
4
6
  readonly actions: TextDraftActions;
5
7
  readonly readOnly?: boolean;
6
8
  readonly blocked?: boolean;
7
9
  }
8
- export declare function TextDraftControls({ recovery, actions, readOnly, blocked }: TextDraftControlsProps): import("react").JSX.Element | null;
10
+ export declare function TextDraftControls({ components, recovery, actions, readOnly, blocked }: TextDraftControlsProps): import("react").JSX.Element | null;
9
11
  //# sourceMappingURL=text-draft-controls.d.ts.map
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useLayoutEffect, useRef, useState } from 'react';
4
- export function TextDraftControls({ recovery, actions, readOnly = false, blocked = false }) {
4
+ export function TextDraftControls({ components, recovery, actions, readOnly = false, blocked = false }) {
5
+ const Control = components?.button ?? 'button';
5
6
  const owner = useRef({ actions, busy: false });
6
7
  const [pending, setPending] = useState(false);
7
8
  const [notice, setNotice] = useState();
@@ -29,5 +30,5 @@ export function TextDraftControls({ recovery, actions, readOnly = false, blocked
29
30
  const discovery = active ? recovery.discovery : undefined;
30
31
  const checkpoint = active ? recovery.checkpoint : undefined;
31
32
  const status = !active ? `Draft recovery ${recovery.kind}` : discovery?.kind === 'checking' ? 'Checking stored drafts' : checkpoint?.kind === 'pending' ? 'Storing draft' : checkpoint?.kind === 'stored' ? 'Draft stored for recovery. Storage is not a save receipt.' : checkpoint?.kind === 'failed' ? 'Draft recovery storage unavailable' : 'Draft recovery enabled';
32
- return _jsxs("section", { "data-boring": "text-draft-controls", "aria-label": "Draft recovery", children: [_jsx("p", { role: "status", children: status }), _jsx("button", { type: "button", disabled: !active || pending || blocked || discovery?.kind === 'checking', onClick: () => run(actions.checkDrafts), children: "Check stored drafts" }), _jsx("button", { type: "button", disabled: !active || pending || readOnly, onClick: () => run(actions.checkpointDraft), children: "Store draft for recovery" }), discovery?.kind === 'empty' && _jsx("p", { children: "No stored drafts" }), discovery && 'reason' in discovery && _jsx("p", { role: "alert", children: discovery.reason }), checkpoint?.kind === 'failed' && _jsx("p", { role: "alert", children: checkpoint.result.reason }), discovery?.kind === 'offered' && _jsxs(_Fragment, { children: [discovery.truncated && _jsx("p", { children: "More stored drafts exist than this listing shows." }), discovery.choices.map(choice => _jsxs("div", { role: "group", "aria-label": "Stored draft", children: [_jsx("p", { children: choice.compatibility === 'conflict' ? 'Saved revision changed. Review this draft separately.' : 'Stored draft matches the checked saved revision.' }), _jsxs("details", { children: [_jsx("summary", { children: "Review stored draft" }), _jsx("pre", { children: choice.text })] }), _jsx("button", { type: "button", disabled: pending || blocked || readOnly || choice.compatibility !== 'exact', onClick: () => run(() => actions.restoreDraft(choice.selection)), children: "Restore draft" }), _jsx("button", { type: "button", disabled: pending || blocked, onClick: () => run(() => actions.discardDraft(choice.selection)), children: "Discard stored draft" })] }, `${choice.selection.offerId}:${choice.selection.draft.writerId}:${choice.selection.draft.sequence}`))] }), notice && _jsx("p", { role: "alert", children: notice })] });
33
+ return _jsxs("section", { "data-boring": "text-draft-controls", "aria-label": "Draft recovery", children: [_jsx("p", { role: "status", children: status }), _jsx(Control, { type: "button", disabled: !active || pending || blocked || discovery?.kind === 'checking', onClick: () => run(actions.checkDrafts), children: "Check stored drafts" }), _jsx(Control, { type: "button", disabled: !active || pending || readOnly, onClick: () => run(actions.checkpointDraft), children: "Store draft for recovery" }), discovery?.kind === 'empty' && _jsx("p", { children: "No stored drafts" }), discovery && 'reason' in discovery && _jsx("p", { role: "alert", children: discovery.reason }), checkpoint?.kind === 'failed' && _jsx("p", { role: "alert", children: checkpoint.result.reason }), discovery?.kind === 'offered' && _jsxs(_Fragment, { children: [discovery.truncated && _jsx("p", { children: "More stored drafts exist than this listing shows." }), discovery.choices.map(choice => _jsxs("div", { role: "group", "aria-label": "Stored draft", children: [_jsx("p", { children: choice.compatibility === 'conflict' ? 'Saved revision changed. Review this draft separately.' : 'Stored draft matches the checked saved revision.' }), _jsxs("details", { children: [_jsx("summary", { children: "Review stored draft" }), _jsx("pre", { children: choice.text })] }), _jsx(Control, { type: "button", disabled: pending || blocked || readOnly || choice.compatibility !== 'exact', onClick: () => run(() => actions.restoreDraft(choice.selection)), children: "Restore draft" }), _jsx(Control, { type: "button", disabled: pending || blocked, onClick: () => run(() => actions.discardDraft(choice.selection)), children: "Discard stored draft" })] }, `${choice.selection.offerId}:${choice.selection.draft.writerId}:${choice.selection.draft.sequence}`))] }), notice && _jsx("p", { role: "alert", children: notice })] });
33
34
  }
@@ -0,0 +1,14 @@
1
+ import type { ComponentPropsWithRef, ComponentType, PropsWithChildren } from 'react';
2
+ /** Host presentation for the document renderers. Bindings must forward refs and native input events. */
3
+ export interface ViewerComponents {
4
+ readonly button: ComponentType<ComponentPropsWithRef<'button'>>;
5
+ readonly input: ComponentType<ComponentPropsWithRef<'input'>>;
6
+ readonly textarea: ComponentType<ComponentPropsWithRef<'textarea'>>;
7
+ readonly bubble?: ComponentType<PropsWithChildren>;
8
+ /** The host's separator: vertical between toolbar groups, horizontal above the phone formatting bar. */
9
+ readonly separator?: ComponentType<{
10
+ readonly orientation?: 'horizontal' | 'vertical';
11
+ readonly className?: string;
12
+ }>;
13
+ }
14
+ //# sourceMappingURL=viewer-components.d.ts.map
@@ -0,0 +1 @@
1
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hachej/boring-ui-kit",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -110,7 +110,7 @@
110
110
  "./file-tree.css": "./styles/file-tree.css"
111
111
  },
112
112
  "peerDependencies": {
113
- "@hachej/boring-files": "^0.3.2",
113
+ "@hachej/boring-files": "^0.4.0",
114
114
  "@earendil-works/pi-durable": "~1.2.0",
115
115
  "react": "^19.0.0",
116
116
  "react-dom": "^19.0.0",
@@ -137,7 +137,7 @@
137
137
  "zod": "^4.6.5",
138
138
  "@tldraw/tlschema": "^5.5.2",
139
139
  "lucide-react": "^1.51.0",
140
- "radix-ui": "^1.8.0"
140
+ "@base-ui/react": "1.9.0"
141
141
  },
142
142
  "peerDependenciesMeta": {
143
143
  "@hachej/boring-files": {
@@ -221,7 +221,7 @@
221
221
  "lucide-react": {
222
222
  "optional": true
223
223
  },
224
- "radix-ui": {
224
+ "@base-ui/react": {
225
225
  "optional": true
226
226
  }
227
227
  },