@hachej/boring-ui-kit 0.3.2 → 0.4.1

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 (57) hide show
  1. package/README.md +46 -18
  2. package/THIRD_PARTY_NOTICES.md +35 -0
  3. package/bin/boring-ui-blocks.mjs +286 -0
  4. package/dist/canvas-editor-native.d.ts +4 -1
  5. package/dist/canvas-editor-native.js +8 -6
  6. package/dist/canvas-editor.js +1 -1
  7. package/dist/canvas-proposals.d.ts +3 -1
  8. package/dist/canvas-proposals.js +4 -3
  9. package/dist/experience-document-viewer.d.ts +3 -0
  10. package/dist/experience-document-viewer.js +6 -5
  11. package/dist/experience.d.ts +3 -0
  12. package/dist/experience.js +2 -1
  13. package/dist/file-tree-view.d.ts +76 -3
  14. package/dist/file-tree-view.js +172 -39
  15. package/dist/file-tree.d.ts +3 -0
  16. package/dist/file-tree.js +17 -2
  17. package/dist/html-viewer.d.ts +4 -0
  18. package/dist/html-viewer.js +5 -3
  19. package/dist/markdown-autosave.d.ts +13 -0
  20. package/dist/markdown-autosave.js +94 -0
  21. package/dist/markdown-editor.d.ts +16 -0
  22. package/dist/markdown-editor.js +81 -17
  23. package/dist/markdown-extensions.js +9 -1
  24. package/dist/markdown-proposal-diff.d.ts +2 -1
  25. package/dist/markdown-proposal-diff.js +3 -2
  26. package/dist/markdown-safety.js +3 -1
  27. package/dist/native-chat.js +1 -1
  28. package/dist/remote-chat.d.ts +18 -0
  29. package/dist/remote-chat.js +149 -7
  30. package/dist/text-buffer.js +3 -2
  31. package/dist/text-draft-controls.d.ts +3 -1
  32. package/dist/text-draft-controls.js +3 -2
  33. package/dist/viewer-components.d.ts +14 -0
  34. package/dist/viewer-components.js +1 -0
  35. package/package.json +8 -4
  36. package/r/app-shell.json +39 -20
  37. package/r/button.json +19 -11
  38. package/r/dock.json +2 -2
  39. package/r/documents.json +17 -14
  40. package/r/feedback-preview.json +8 -2
  41. package/r/feedback.json +46 -325
  42. package/r/file-tree.json +14 -6
  43. package/r/html-viewer.json +8 -42
  44. package/r/markdown-editor.json +38 -87
  45. package/r/pi-ambient.json +15 -8
  46. package/r/pi-app.json +30 -24
  47. package/r/pi-chat.json +101 -70
  48. package/r/pi-workspace.json +7 -5
  49. package/r/provider-setup.json +9 -4
  50. package/r/registry.json +471 -893
  51. package/r/task-list-viewer.json +11 -21
  52. package/r/theme.json +23 -4
  53. package/r/version-history.json +24 -0
  54. package/r/viewers.json +139 -320
  55. package/styles/file-tree.css +41 -42
  56. package/dist/file-tree-shadcn.d.ts +0 -8
  57. package/dist/file-tree-shadcn.js +0 -14
@@ -37,8 +37,31 @@ export async function createRemoteChat(options) {
37
37
  target.searchParams.set(key, value);
38
38
  return target;
39
39
  };
40
+ // The transport in use: `websocket` until its first socket fails to open, then `http` for good.
41
+ let mode = options.transport ?? 'http', current, seq = 0;
42
+ /** Calls sent over a socket and not answered yet, by call ID: sent again unchanged on the next socket. */
43
+ const outbox = new Map();
44
+ function exchange(op, init, extra) {
45
+ const http = () => new Request(url(op, extra), init);
46
+ if (mode !== 'websocket')
47
+ return send(http());
48
+ const id = `c${++seq}`;
49
+ const text = JSON.stringify({ kind: 'call', id, op, params: extra, ...(typeof init.body === 'string' ? { body: JSON.parse(init.body) } : {}) });
50
+ return new Promise((resolve, reject) => { outbox.set(id, { text, http, resolve, reject }); try {
51
+ current?.send(text);
52
+ }
53
+ catch { /* sent again on the next socket */ } });
54
+ }
55
+ /** Leave the socket for HTTP: calls still waiting for a socket go over HTTP, unchanged (same request ID, so the host deduplicates). */
56
+ function fallBack() {
57
+ mode = 'http';
58
+ const pending = [...outbox.values()];
59
+ outbox.clear();
60
+ for (const call of pending)
61
+ send(call.http()).then(call.resolve, call.reject);
62
+ }
40
63
  async function call(op, init = {}, extra = {}) {
41
- const response = await send(new Request(url(op, extra), init));
64
+ const response = await exchange(op, init, extra);
42
65
  if (response.status === 402 && op === 'submit') {
43
66
  const refused = await response.json().catch(() => ({}));
44
67
  if (refused.reason === 'submission-refused')
@@ -50,13 +73,107 @@ export async function createRemoteChat(options) {
50
73
  }
51
74
  const post = (op, value) => call(op, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(value) });
52
75
  const lookup = async (requestId) => (await call('submission', {}, { requestId })).record ?? undefined;
53
- async function watch() {
76
+ const ping = options.webSocket?.pingMs ?? 15_000;
77
+ const openSocket = options.webSocket?.open ?? ((target) => new WebSocket(target, 'boring.chat'));
78
+ function socketSource() {
79
+ return new Promise((ready, refuse) => {
80
+ const target = new URL(base);
81
+ target.protocol = target.protocol === 'https:' ? 'wss:' : 'ws:';
82
+ let socket;
83
+ try {
84
+ socket = openSocket(target);
85
+ }
86
+ catch (error) {
87
+ refuse(error);
88
+ return;
89
+ }
90
+ const frames = [];
91
+ let wake, closed = false, opened = false, heard = Date.now();
92
+ const notify = () => { const resume = wake; wake = undefined; resume?.(); };
93
+ const finish = () => {
94
+ if (closed)
95
+ return;
96
+ closed = true;
97
+ clearInterval(timer);
98
+ if (current === socket)
99
+ current = undefined;
100
+ notify();
101
+ if (!opened)
102
+ refuse(new Error('Remote chat socket closed before its first view'));
103
+ };
104
+ const timer = setInterval(() => {
105
+ if (Date.now() - heard > ping * 2.5) {
106
+ try {
107
+ socket.close();
108
+ }
109
+ catch { /* closing */ }
110
+ finish();
111
+ }
112
+ else if (socket.readyState === 1)
113
+ socket.send('ping');
114
+ }, ping);
115
+ const next = async () => { while (frames.length === 0 && !closed)
116
+ await new Promise(resolve => { wake = resolve; }); return frames.shift(); };
117
+ const cancel = () => { try {
118
+ socket.close(1000);
119
+ }
120
+ catch { /* closing */ } finish(); };
121
+ socket.onclose = finish;
122
+ socket.onerror = () => { try {
123
+ socket.close();
124
+ }
125
+ catch { /* closing */ } finish(); };
126
+ socket.onmessage = event => {
127
+ heard = Date.now();
128
+ if (typeof event.data !== 'string' || event.data === 'pong')
129
+ return;
130
+ let frame;
131
+ try {
132
+ frame = JSON.parse(event.data);
133
+ }
134
+ catch {
135
+ return;
136
+ }
137
+ if (frame.kind === 'reply') {
138
+ const pending = outbox.get(frame.id);
139
+ if (pending) {
140
+ outbox.delete(frame.id);
141
+ pending.resolve(new Response(JSON.stringify(frame.body), { status: frame.status, headers: { 'content-type': 'application/json' } }));
142
+ }
143
+ return;
144
+ }
145
+ if (frame.kind === 'event') {
146
+ try {
147
+ options.onEvent?.(frame.event);
148
+ }
149
+ catch { /* a host listener never breaks the chat */ }
150
+ return;
151
+ }
152
+ if (!opened) {
153
+ if (frame.kind !== 'view') {
154
+ cancel();
155
+ return;
156
+ }
157
+ opened = true;
158
+ current = socket;
159
+ // Unanswered calls go again, unchanged: a submit keeps its request ID, so the host runs it once.
160
+ for (const pending of outbox.values())
161
+ socket.send(pending.text);
162
+ ready({ first: frame.view, next, cancel });
163
+ return;
164
+ }
165
+ frames.push(frame);
166
+ notify();
167
+ };
168
+ });
169
+ }
170
+ async function httpSource() {
54
171
  const stopped = new AbortController();
55
172
  const response = await send(new Request(url('watch'), { signal: stopped.signal }));
56
173
  if (!response.ok || !response.body)
57
174
  throw new WatchRefused(`Remote chat watch failed: ${await reason(response)}`, response.status);
58
175
  const reader = response.body.pipeThrough(new TextDecoderStream()).getReader();
59
- let pending = '', staleMs = 0, lost = false;
176
+ let pending = '', staleMs = 0;
60
177
  // Once the server has announced its heartbeat interval, a read waiting 2.5 intervals means the stream is dead even if the
61
178
  // socket still looks open (a proxy that dropped it silently).
62
179
  async function read() {
@@ -65,7 +182,7 @@ export async function createRemoteChat(options) {
65
182
  let timer;
66
183
  try {
67
184
  return await Promise.race([reader.read(), new Promise((_, reject) => {
68
- timer = setTimeout(() => { lost = true; stopped.abort(); reject(new Error('Remote chat stream went silent')); }, staleMs);
185
+ timer = setTimeout(() => { stopped.abort(); reject(new Error('Remote chat stream went silent')); }, staleMs);
69
186
  })]);
70
187
  }
71
188
  finally {
@@ -97,11 +214,32 @@ export async function createRemoteChat(options) {
97
214
  stopped.abort();
98
215
  throw new Error('Remote chat watch ended before its first view');
99
216
  }
100
- let value = first.view, started = false, settle, ended;
217
+ return { first: first.view, next, cancel: () => stopped.abort() };
218
+ }
219
+ let socketWorked = false;
220
+ async function watch() {
221
+ let source;
222
+ if (mode === 'websocket') {
223
+ try {
224
+ source = await socketSource();
225
+ socketWorked = true;
226
+ }
227
+ catch (error) {
228
+ if (socketWorked)
229
+ throw error;
230
+ fallBack();
231
+ source = await httpSource();
232
+ }
233
+ }
234
+ else
235
+ source = await httpSource();
236
+ const { next } = source;
237
+ let lost = false;
238
+ let value = source.first, started = false, settle, ended;
101
239
  const closed = new Promise(resolve => { settle = resolve; });
102
240
  const finish = (end) => { if (!ended) {
103
241
  ended = end;
104
- stopped.abort();
242
+ source.cancel();
105
243
  settle(end);
106
244
  } };
107
245
  const handle = {
@@ -316,7 +454,11 @@ export async function createRemoteChat(options) {
316
454
  // The controller only reconciles a submission by request ID; no other transaction work crosses the wire.
317
455
  commit: async (change, _context) => change({ submissionByRequest: (_conversationId, requestId) => lookup(requestId) }),
318
456
  abort: async () => { await post('abort', {}); },
319
- entries: async (_query, limit, cursor) => (await call('entries', {}, { limit: String(limit), ...(cursor === undefined ? {} : { cursor: JSON.stringify(cursor) }) })).page,
457
+ entries: async (query, limit, cursor) => (await call('entries', {}, { limit: String(limit),
458
+ ...(query.maxEntryId === undefined ? {} : { maxEntryId: String(query.maxEntryId) }),
459
+ ...(query.minEntryId === undefined ? {} : { minEntryId: String(query.minEntryId) }),
460
+ ...(query.order === undefined ? {} : { order: query.order }),
461
+ ...(cursor === undefined ? {} : { cursor: JSON.stringify(cursor) }) })).page,
320
462
  };
321
463
  const context = Object.freeze({});
322
464
  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.1",
4
4
  "license": "MIT",
5
5
  "private": false,
6
6
  "type": "module",
@@ -12,12 +12,16 @@
12
12
  "dist/**/*.js",
13
13
  "dist/**/*.d.ts",
14
14
  "r/*.json",
15
+ "bin/*.mjs",
15
16
  "README.md",
16
17
  "LICENSE",
17
18
  "THIRD_PARTY_NOTICES.md",
18
19
  "styles"
19
20
  ],
20
21
  "types": "./dist/index.d.ts",
22
+ "bin": {
23
+ "boring-ui-blocks": "./bin/boring-ui-blocks.mjs"
24
+ },
21
25
  "exports": {
22
26
  ".": {
23
27
  "types": "./dist/index.d.ts",
@@ -110,7 +114,7 @@
110
114
  "./file-tree.css": "./styles/file-tree.css"
111
115
  },
112
116
  "peerDependencies": {
113
- "@hachej/boring-files": "^0.3.2",
117
+ "@hachej/boring-files": "^0.4.1",
114
118
  "@earendil-works/pi-durable": "~1.2.0",
115
119
  "react": "^19.0.0",
116
120
  "react-dom": "^19.0.0",
@@ -137,7 +141,7 @@
137
141
  "zod": "^4.6.5",
138
142
  "@tldraw/tlschema": "^5.5.2",
139
143
  "lucide-react": "^1.51.0",
140
- "radix-ui": "^1.8.0"
144
+ "@base-ui/react": "1.9.0"
141
145
  },
142
146
  "peerDependenciesMeta": {
143
147
  "@hachej/boring-files": {
@@ -221,7 +225,7 @@
221
225
  "lucide-react": {
222
226
  "optional": true
223
227
  },
224
- "radix-ui": {
228
+ "@base-ui/react": {
225
229
  "optional": true
226
230
  }
227
231
  },