@karimsa/mdxserve 0.0.0-stage → 0.2.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 (94) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +233 -2
  3. package/client/App.tsx +7 -0
  4. package/client/CodeBlock.tsx +395 -0
  5. package/client/CrossFade.tsx +72 -0
  6. package/client/DocContext.ts +14 -0
  7. package/client/DocView.tsx +107 -0
  8. package/client/ErrorBox.tsx +23 -0
  9. package/client/Heading.tsx +31 -0
  10. package/client/HomeEmptyState.tsx +101 -0
  11. package/client/HomeView.tsx +78 -0
  12. package/client/ListingView.tsx +663 -0
  13. package/client/MdSection.tsx +234 -0
  14. package/client/MdSectionEditor.tsx +233 -0
  15. package/client/Mermaid.tsx +435 -0
  16. package/client/RenderErrorBoundary.tsx +40 -0
  17. package/client/Table.tsx +14 -0
  18. package/client/TaskCheckbox.tsx +38 -0
  19. package/client/api.ts +138 -0
  20. package/client/app.css +372 -0
  21. package/client/builtins/Badge.tsx +109 -0
  22. package/client/builtins/Button.tsx +111 -0
  23. package/client/builtins/Callout.tsx +97 -0
  24. package/client/builtins/Card.tsx +111 -0
  25. package/client/builtins/Chart.tsx +875 -0
  26. package/client/builtins/Diff.tsx +722 -0
  27. package/client/builtins/Dropdown.tsx +417 -0
  28. package/client/builtins/FileTree.tsx +87 -0
  29. package/client/builtins/Kbd.tsx +18 -0
  30. package/client/builtins/Screenshot.tsx +209 -0
  31. package/client/builtins/Sparkline.tsx +63 -0
  32. package/client/builtins/Tabs.tsx +169 -0
  33. package/client/builtins/Tooltip.tsx +52 -0
  34. package/client/builtins/chart-data.ts +133 -0
  35. package/client/builtins/index.ts +167 -0
  36. package/client/design/base/editor.css +151 -0
  37. package/client/design/base/prose.css +143 -0
  38. package/client/design/base/reset.css +79 -0
  39. package/client/design/tokens/colors.css +188 -0
  40. package/client/design/tokens/elevation.css +42 -0
  41. package/client/design/tokens/fonts.css +6 -0
  42. package/client/design/tokens/motion.css +76 -0
  43. package/client/design/tokens/spacing.css +34 -0
  44. package/client/design/tokens/typography.css +56 -0
  45. package/client/doc-module-cache.ts +17 -0
  46. package/client/editor-link.ts +27 -0
  47. package/client/entry.tsx +51 -0
  48. package/client/export-doc.ts +80 -0
  49. package/client/export-save.ts +96 -0
  50. package/client/favicon.svg +1 -0
  51. package/client/file-system-access.d.ts +29 -0
  52. package/client/format.ts +17 -0
  53. package/client/hooks.ts +34 -0
  54. package/client/lucide-icons.d.ts +9 -0
  55. package/client/mdx-components-base.ts +32 -0
  56. package/client/mdx-components.ts +18 -0
  57. package/client/mermaid-chart.ts +109 -0
  58. package/client/mermaid-direction.ts +73 -0
  59. package/client/motion.ts +104 -0
  60. package/client/platform.ts +16 -0
  61. package/client/route-path.ts +15 -0
  62. package/client/router.ts +452 -0
  63. package/client/shell/AppShell.tsx +401 -0
  64. package/client/shell/Footer.tsx +33 -0
  65. package/client/shell/NotFoundView.tsx +22 -0
  66. package/client/shell/Sidebar.tsx +169 -0
  67. package/client/shell/StandaloneShell.tsx +65 -0
  68. package/client/shell/TocRail.tsx +53 -0
  69. package/client/shell/TopBar.tsx +117 -0
  70. package/client/shell/use-doc-width.ts +61 -0
  71. package/client/shell/useToc.ts +77 -0
  72. package/client/ssr-entry.tsx +22 -0
  73. package/client/standalone-entry.tsx +51 -0
  74. package/client/state.ts +90 -0
  75. package/client/theme.ts +65 -0
  76. package/client/ui/Breadcrumb.tsx +49 -0
  77. package/client/ui/ConfirmDeleteDialog.tsx +113 -0
  78. package/client/ui/ExpandModal.tsx +342 -0
  79. package/client/ui/Icon.tsx +114 -0
  80. package/client/ui/IconButton.tsx +63 -0
  81. package/client/ui/Kbd.tsx +17 -0
  82. package/client/ui/PageNav.tsx +77 -0
  83. package/client/ui/ResizeHandle.tsx +201 -0
  84. package/client/ui/SearchDialog.tsx +187 -0
  85. package/client/ui/Tag.tsx +44 -0
  86. package/client/ui/Toast.tsx +189 -0
  87. package/client/ui/TocList.tsx +71 -0
  88. package/client/ui/icon-set.ts +102 -0
  89. package/client/ui/toast-count.ts +28 -0
  90. package/dist/cli.js +5091 -0
  91. package/dist/registry.json +703 -0
  92. package/dist/render-worker.js +145 -0
  93. package/package.json +113 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
@@ -0,0 +1,113 @@
1
+ import { useEffect } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { AnimatePresence, motion } from "framer-motion";
4
+ import { formatModified, formatSize } from "../format";
5
+ import Button from "../builtins/Button";
6
+ import type { ListingEntry } from "../router";
7
+ import { Icon } from "./Icon";
8
+ import { VARIANTS } from "../motion";
9
+
10
+ export function ConfirmDeleteDialog({
11
+ open,
12
+ files,
13
+ pending,
14
+ onCancel,
15
+ onConfirm,
16
+ }: {
17
+ open: boolean;
18
+ /** Selected file entries, in listing order. */
19
+ files: ListingEntry[];
20
+ pending: boolean;
21
+ onCancel: () => void;
22
+ onConfirm: () => void;
23
+ }) {
24
+ // Document-level, not panel-level: while a delete is pending both buttons
25
+ // are disabled and the browser drops focus to <body>, so a panel onKeyDown
26
+ // would stop hearing Escape (e.g. after a failed request leaves the dialog
27
+ // open).
28
+ useEffect(() => {
29
+ if (!open) return;
30
+ function onKeyDown(event: KeyboardEvent) {
31
+ if (event.key === "Escape" && !pending) {
32
+ event.preventDefault();
33
+ onCancel();
34
+ }
35
+ }
36
+ document.addEventListener("keydown", onKeyDown);
37
+ return () => document.removeEventListener("keydown", onKeyDown);
38
+ }, [open, pending, onCancel]);
39
+
40
+ // Portal to <body>: this renders inside the route wrapper, whose fadeRise
41
+ // transform would otherwise turn `fixed` into "fixed to the column" and let
42
+ // the scrim miss the top bar and sidebar.
43
+ return createPortal(
44
+ <AnimatePresence>
45
+ {open ? (
46
+ <motion.div
47
+ key="scrim"
48
+ onClick={pending ? undefined : onCancel}
49
+ {...VARIANTS.scrim}
50
+ className="fixed inset-0 z-[var(--z-modal)] flex justify-center bg-[var(--scrim)] pt-[10vh] backdrop-blur-sm"
51
+ >
52
+ <motion.div
53
+ onClick={(event) => event.stopPropagation()}
54
+ {...VARIANTS.pop}
55
+ role="alertdialog"
56
+ aria-modal="true"
57
+ aria-labelledby="confirm-delete-title"
58
+ className="w-[560px] max-w-[92vw] self-start overflow-hidden rounded-xl border border-border-default bg-surface-raised shadow-lg"
59
+ >
60
+ <div className="flex items-center gap-2 border-b border-border-subtle px-4 py-3">
61
+ <Icon name="trash-2" size="md" className="text-status-danger-fg" />
62
+ <div className="flex flex-col">
63
+ <span
64
+ id="confirm-delete-title"
65
+ className="font-sans font-semibold text-text-heading text-[length:var(--size-md)]"
66
+ >
67
+ Delete {files.length} {files.length === 1 ? "file" : "files"}?
68
+ </span>
69
+ <span className="font-sans text-[length:var(--size-sm)] text-text-muted">
70
+ They&rsquo;ll be moved to the system Trash — this can be undone from there.
71
+ </span>
72
+ </div>
73
+ </div>
74
+ <div className="max-h-72 overflow-y-auto p-2">
75
+ {files.map((file) => (
76
+ <div key={file.name} className="flex items-center gap-2 rounded-md px-2 py-1.5">
77
+ <Icon name="file-text" size="sm" className="shrink-0 text-text-subtle" />
78
+ <span className="truncate font-mono text-[length:var(--size-sm)]">
79
+ {file.name}
80
+ </span>
81
+ <span className="ml-auto flex shrink-0 gap-4 pl-4 font-mono text-[length:var(--size-xs)] text-text-subtle tabular-nums">
82
+ {typeof file.mtime === "number" ? (
83
+ <span>{formatModified(file.mtime)}</span>
84
+ ) : null}
85
+ {typeof file.size === "number" ? (
86
+ <span className="w-16 text-right">{formatSize(file.size)}</span>
87
+ ) : null}
88
+ </span>
89
+ </div>
90
+ ))}
91
+ </div>
92
+ <div className="flex justify-end gap-2 border-t border-border-subtle px-4 py-3">
93
+ {/* Start keyboard focus on the safe action. */}
94
+ <Button variant="secondary" size="sm" autoFocus disabled={pending} onClick={onCancel}>
95
+ Cancel
96
+ </Button>
97
+ <Button
98
+ variant="danger"
99
+ size="sm"
100
+ icon="trash-2"
101
+ disabled={pending}
102
+ onClick={onConfirm}
103
+ >
104
+ {pending ? "Moving…" : "Move to Trash"}
105
+ </Button>
106
+ </div>
107
+ </motion.div>
108
+ </motion.div>
109
+ ) : null}
110
+ </AnimatePresence>,
111
+ document.body,
112
+ );
113
+ }
@@ -0,0 +1,342 @@
1
+ import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
2
+ import { createPortal } from "react-dom";
3
+ import { AnimatePresence, motion, useSpring } from "framer-motion";
4
+ import { Icon } from "./Icon";
5
+ import { IconButton } from "./IconButton";
6
+ import { Kbd } from "./Kbd";
7
+ import { TRANSITIONS, VARIANTS } from "../motion";
8
+
9
+ export interface ExpandModalProps {
10
+ open: boolean;
11
+ onClose: () => void;
12
+ /** Lucide icon shown before the title. */
13
+ icon: string;
14
+ title: string;
15
+ /** Short interaction hint after the title, e.g. "Drag to pan · scroll to zoom". */
16
+ hint?: ReactNode;
17
+ /** Controls shown at the right of the header, before the close button. */
18
+ actions?: ReactNode;
19
+ /** Fills the panel below the header; render it `h-full` to take the whole area. */
20
+ children: ReactNode;
21
+ }
22
+
23
+ /** Explicit panel size once the reader has dragged an edge; null = the default inset. */
24
+ interface PanelSize {
25
+ width: number;
26
+ height: number;
27
+ }
28
+
29
+ type Edge = "n" | "s" | "e" | "w" | "ne" | "nw" | "se" | "sw";
30
+
31
+ const MIN_WIDTH = 480;
32
+ const MIN_HEIGHT = 320;
33
+ /** Smallest gap kept between the panel and the viewport while resizing. */
34
+ const MARGIN = 16;
35
+
36
+ // Grab strips are 10px wide, straddling the edge (5px out, 5px in); corners are 16px squares.
37
+ // Written as literal class strings so Tailwind's scanner can see them.
38
+ const EDGE_HANDLES: { edge: Edge; className: string; bar?: string }[] = [
39
+ {
40
+ edge: "n",
41
+ className: "top-[-5px] right-4 left-4 h-[10px] cursor-ns-resize",
42
+ bar: "top-[4px] right-0 left-0 h-[2px]",
43
+ },
44
+ {
45
+ edge: "s",
46
+ className: "bottom-[-5px] right-4 left-4 h-[10px] cursor-ns-resize",
47
+ bar: "bottom-[4px] right-0 left-0 h-[2px]",
48
+ },
49
+ {
50
+ edge: "e",
51
+ className: "top-4 right-[-5px] bottom-4 w-[10px] cursor-ew-resize",
52
+ bar: "top-0 right-[4px] bottom-0 w-[2px]",
53
+ },
54
+ {
55
+ edge: "w",
56
+ className: "top-4 bottom-4 left-[-5px] w-[10px] cursor-ew-resize",
57
+ bar: "top-0 bottom-0 left-[4px] w-[2px]",
58
+ },
59
+ { edge: "nw", className: "top-[-5px] left-[-5px] h-4 w-4 cursor-nwse-resize" },
60
+ { edge: "se", className: "right-[-5px] bottom-[-5px] h-4 w-4 cursor-nwse-resize" },
61
+ { edge: "ne", className: "top-[-5px] right-[-5px] h-4 w-4 cursor-nesw-resize" },
62
+ { edge: "sw", className: "bottom-[-5px] left-[-5px] h-4 w-4 cursor-nesw-resize" },
63
+ ];
64
+
65
+ function clampSize(size: PanelSize, viewport: { width: number; height: number }): PanelSize {
66
+ // The minimum is capped by what's available: on a window narrower or shorter
67
+ // than the minimum the panel has to fit the viewport rather than overflow it
68
+ // (which would push the close control off screen).
69
+ const availableWidth = Math.max(0, viewport.width - MARGIN * 2);
70
+ const availableHeight = Math.max(0, viewport.height - MARGIN * 2);
71
+ const width = Math.min(Math.max(size.width, Math.min(MIN_WIDTH, availableWidth)), availableWidth);
72
+ const height = Math.min(
73
+ Math.max(size.height, Math.min(MIN_HEIGHT, availableHeight)),
74
+ availableHeight,
75
+ );
76
+ return { width: Math.round(width), height: Math.round(height) };
77
+ }
78
+
79
+ /** A viewport coordinate, in CSS pixels — the same x/y the DOM's own rects use. */
80
+ interface Point {
81
+ x: number;
82
+ y: number;
83
+ }
84
+
85
+ /**
86
+ * The panel stays centred while it resizes, so moving one edge by `d` changes
87
+ * that dimension by `2d`. Each dimension is solved from the pointer's distance
88
+ * to the panel centre (minus where on the strip it was grabbed) rather than
89
+ * accumulated deltas, so a long drag can't drift.
90
+ */
91
+ function resizeFromCentre(
92
+ edge: Edge,
93
+ pointer: Point,
94
+ centre: Point,
95
+ grab: Point,
96
+ current: PanelSize,
97
+ viewport: { width: number; height: number },
98
+ ): PanelSize {
99
+ let { width, height } = current;
100
+ if (edge.includes("e")) width = 2 * (pointer.x - centre.x - grab.x);
101
+ if (edge.includes("w")) width = 2 * (centre.x - pointer.x - grab.x);
102
+ if (edge.includes("s")) height = 2 * (pointer.y - centre.y - grab.y);
103
+ if (edge.includes("n")) height = 2 * (centre.y - pointer.y - grab.y);
104
+ return clampSize({ width, height }, viewport);
105
+ }
106
+
107
+ /**
108
+ * Near-full-screen modal for exploring a diagram or chart at a larger scale.
109
+ * The panel takes the viewport minus a small inset by default; dragging any
110
+ * edge or corner resizes it symmetrically so it stays centred (double-click a
111
+ * handle, or reopen, to reset), and
112
+ * `children` get the whole area under a slim header (icon, title, hint, close).
113
+ */
114
+ export function ExpandModal({
115
+ open,
116
+ onClose,
117
+ icon,
118
+ title,
119
+ hint,
120
+ actions,
121
+ children,
122
+ }: ExpandModalProps) {
123
+ const panelRef = useRef<HTMLDivElement>(null);
124
+ // Whether the reader has taken over the size; the springs below hold it.
125
+ const [custom, setCustom] = useState(false);
126
+ const [dragging, setDragging] = useState<Edge | null>(null);
127
+ // glide, not snap (as for the sidebar/toc widths): the panel trails the
128
+ // cursor slightly so the spring is felt as it settles into shape.
129
+ const widthSpring = useSpring(0, TRANSITIONS.glide);
130
+ const heightSpring = useSpring(0, TRANSITIONS.glide);
131
+ /** Latest un-animated target, so clamping on window resize starts from the real size. */
132
+ const target = useRef<PanelSize | null>(null);
133
+ const drag = useRef<{
134
+ edge: Edge;
135
+ centre: Point;
136
+ /** Pointer distance past the grabbed edge at press time, so the edge doesn't snap under the cursor. */
137
+ grab: Point;
138
+ } | null>(null);
139
+
140
+ // Document-level so Escape works wherever focus landed (a pan surface or
141
+ // chart is a plain element and never holds focus itself). A control in the
142
+ // header that already consumed the key (the layout menu closing itself)
143
+ // marks the event default-prevented, and that Escape must not also close
144
+ // the modal.
145
+ useEffect(() => {
146
+ if (!open) return;
147
+ function onKeyDown(event: KeyboardEvent) {
148
+ if (event.key === "Escape" && !event.defaultPrevented) {
149
+ event.preventDefault();
150
+ onClose();
151
+ }
152
+ }
153
+ document.addEventListener("keydown", onKeyDown);
154
+ // Wheel-zooming the content must not also scroll the document behind the scrim.
155
+ const previousOverflow = document.body.style.overflow;
156
+ document.body.style.overflow = "hidden";
157
+ return () => {
158
+ document.removeEventListener("keydown", onKeyDown);
159
+ document.body.style.overflow = previousOverflow;
160
+ // A drag interrupted by closing (Escape, scrim click, unmount) never sees
161
+ // pointerup, so release its global state here: otherwise text selection
162
+ // stays disabled page-wide and the next open treats a hover over a handle
163
+ // as an in-progress drag.
164
+ drag.current = null;
165
+ setDragging(null);
166
+ document.body.style.userSelect = "";
167
+ };
168
+ }, [open, onClose]);
169
+
170
+ // A custom size lasts for one viewing: reopening starts from the default inset.
171
+ const resetSize = useCallback(() => {
172
+ target.current = null;
173
+ setCustom(false);
174
+ }, []);
175
+
176
+ useEffect(() => {
177
+ if (!open) resetSize();
178
+ }, [open, resetSize]);
179
+
180
+ // Keep a custom size inside the window if the window shrinks underneath it.
181
+ useEffect(() => {
182
+ if (!custom) return;
183
+ function onResize() {
184
+ if (!target.current) return;
185
+ const next = clampSize(target.current, {
186
+ width: window.innerWidth,
187
+ height: window.innerHeight,
188
+ });
189
+ target.current = next;
190
+ widthSpring.set(next.width);
191
+ heightSpring.set(next.height);
192
+ }
193
+ window.addEventListener("resize", onResize);
194
+ return () => window.removeEventListener("resize", onResize);
195
+ }, [custom, widthSpring, heightSpring]);
196
+
197
+ const onHandleDown = useCallback(
198
+ (edge: Edge, event: React.PointerEvent<HTMLDivElement>) => {
199
+ const panel = panelRef.current;
200
+ if (!panel) return;
201
+ event.preventDefault();
202
+ const bounds = panel.getBoundingClientRect();
203
+ const centre = { x: bounds.left + bounds.width / 2, y: bounds.top + bounds.height / 2 };
204
+ const grab = {
205
+ x: edge.includes("e")
206
+ ? event.clientX - bounds.right
207
+ : edge.includes("w")
208
+ ? bounds.left - event.clientX
209
+ : 0,
210
+ y: edge.includes("s")
211
+ ? event.clientY - bounds.bottom
212
+ : edge.includes("n")
213
+ ? bounds.top - event.clientY
214
+ : 0,
215
+ };
216
+ drag.current = { edge, centre, grab };
217
+ // Land the springs on what's on screen (no animating up from 0) before
218
+ // the panel switches to the explicit, spring-driven size.
219
+ target.current = { width: bounds.width, height: bounds.height };
220
+ widthSpring.jump(bounds.width);
221
+ heightSpring.jump(bounds.height);
222
+ setCustom(true);
223
+ setDragging(edge);
224
+ try {
225
+ event.currentTarget.setPointerCapture(event.pointerId);
226
+ } catch {
227
+ // Not a live pointer (synthetic event); the drag still works while the cursor stays on the handle.
228
+ }
229
+ document.body.style.userSelect = "none";
230
+ },
231
+ [widthSpring, heightSpring],
232
+ );
233
+
234
+ const onHandleMove = useCallback(
235
+ (event: React.PointerEvent<HTMLDivElement>) => {
236
+ const current = drag.current;
237
+ if (!current) return;
238
+ const next = resizeFromCentre(
239
+ current.edge,
240
+ { x: event.clientX, y: event.clientY },
241
+ current.centre,
242
+ current.grab,
243
+ target.current ?? { width: MIN_WIDTH, height: MIN_HEIGHT },
244
+ { width: window.innerWidth, height: window.innerHeight },
245
+ );
246
+ target.current = next;
247
+ widthSpring.set(next.width);
248
+ heightSpring.set(next.height);
249
+ },
250
+ [widthSpring, heightSpring],
251
+ );
252
+
253
+ const onHandleUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
254
+ if (!drag.current) return;
255
+ drag.current = null;
256
+ setDragging(null);
257
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
258
+ event.currentTarget.releasePointerCapture(event.pointerId);
259
+ }
260
+ document.body.style.userSelect = "";
261
+ }, []);
262
+
263
+ // The SSR entry renders the same component tree; never touch `document` at
264
+ // render time there (the modal is only ever opened client-side anyway).
265
+ if (typeof document === "undefined") return null;
266
+
267
+ // Portal to <body>: callers sit inside the route wrapper, whose fadeRise
268
+ // transform would otherwise turn `fixed` into "fixed to the column" and let
269
+ // the scrim miss the top bar and sidebar.
270
+ return createPortal(
271
+ <AnimatePresence>
272
+ {open ? (
273
+ <motion.div
274
+ key="scrim"
275
+ onClick={onClose}
276
+ {...VARIANTS.scrim}
277
+ className="fixed inset-0 z-[var(--z-modal)] flex items-center justify-center bg-[var(--scrim)] p-4 backdrop-blur-sm sm:p-8"
278
+ >
279
+ <motion.div
280
+ ref={panelRef}
281
+ onClick={(event) => event.stopPropagation()}
282
+ {...VARIANTS.pop}
283
+ role="dialog"
284
+ aria-modal="true"
285
+ aria-label={`Expanded ${title.toLowerCase()}`}
286
+ // Once an edge has been dragged the panel takes an explicit size and stays
287
+ // centred by the scrim's flex box; until then it fills the padded area
288
+ // (capped for very wide windows).
289
+ style={
290
+ custom
291
+ ? { width: widthSpring, height: heightSpring, maxWidth: "none", flex: "none" }
292
+ : undefined
293
+ }
294
+ className="relative flex h-full w-full max-w-[1600px] flex-col rounded-xl border border-border-default bg-surface-raised shadow-lg"
295
+ >
296
+ <div className="flex shrink-0 items-center gap-2 rounded-t-xl border-b border-border-subtle px-3 py-2">
297
+ <Icon name={icon} size="md" className="text-text-muted" />
298
+ <span className="font-sans font-semibold text-text-heading text-[length:var(--size-sm)]">
299
+ {title}
300
+ </span>
301
+ <span className="hidden items-center gap-1.5 font-sans text-[length:var(--size-xs)] text-text-subtle sm:flex">
302
+ {hint ? <>{hint} · </> : null}
303
+ drag an edge to resize · <Kbd>esc</Kbd> to close
304
+ </span>
305
+ <div className="ml-auto flex items-center gap-2">
306
+ {actions}
307
+ <IconButton icon="x" label="Close" size="md" autoFocus onClick={onClose} />
308
+ </div>
309
+ </div>
310
+ <div className="min-h-0 flex-1 overflow-hidden rounded-b-xl">{children}</div>
311
+ {EDGE_HANDLES.map(({ edge, className, bar }) => (
312
+ <div
313
+ key={edge}
314
+ role="separator"
315
+ aria-label={`Resize ${edge} edge`}
316
+ className={"group absolute z-10 " + className}
317
+ onPointerDown={(event) => onHandleDown(edge, event)}
318
+ onPointerMove={onHandleMove}
319
+ onPointerUp={onHandleUp}
320
+ onPointerCancel={onHandleUp}
321
+ onDoubleClick={resetSize}
322
+ >
323
+ {bar ? (
324
+ <span
325
+ className={
326
+ "absolute rounded-full transition-opacity " +
327
+ bar +
328
+ (dragging === edge
329
+ ? " bg-surface-accent opacity-100"
330
+ : " bg-border-default opacity-0 group-hover:opacity-100")
331
+ }
332
+ />
333
+ ) : null}
334
+ </div>
335
+ ))}
336
+ </motion.div>
337
+ </motion.div>
338
+ ) : null}
339
+ </AnimatePresence>,
340
+ document.body,
341
+ );
342
+ }
@@ -0,0 +1,114 @@
1
+ // The explicit .mjs extension matters: this module is also reachable from
2
+ // scripts/build-registry.ts under plain Node, whose ESM resolver (unlike
3
+ // Vite's) requires a real extension since lucide-react's package.json has no
4
+ // "exports" map for this subpath.
5
+ import dynamicIconImports from "lucide-react/dynamicIconImports.mjs";
6
+ import type { LucideIcon, LucideProps } from "lucide-react";
7
+ import { useEffect, useState, type CSSProperties } from "react";
8
+ import { staticIcons } from "./icon-set";
9
+
10
+ const SIZES = { sm: 14, md: 16, lg: 20, xl: 24 } as const;
11
+
12
+ export type IconSize = keyof typeof SIZES | number;
13
+
14
+ export interface IconProps extends Omit<LucideProps, "size" | "strokeWidth" | "ref"> {
15
+ /** Lucide icon name in kebab-case, e.g. "search", "chevron-right", "file-text". */
16
+ name: string;
17
+ /** 14 / 16 / 20 / 24 px, or an explicit pixel number. Default "md". */
18
+ size?: IconSize;
19
+ /** "light" dims the glyph to 75% for secondary chrome. */
20
+ strokeWidth?: "light";
21
+ style?: CSSProperties;
22
+ }
23
+
24
+ type DynamicIconName = keyof typeof dynamicIconImports;
25
+
26
+ function isDynamicIconName(name: string): name is DynamicIconName {
27
+ return Object.hasOwn(dynamicIconImports, name);
28
+ }
29
+
30
+ // Module-level so every Icon instance sharing a name shares one load: a
31
+ // resolved glyph (or `null` for a name that turned out to have no default
32
+ // export) sits in `resolved`, an in-flight import sits in `pending` until it
33
+ // settles.
34
+ const resolved = new Map<string, LucideIcon | null>();
35
+ const pending = new Map<string, Promise<LucideIcon | null>>();
36
+
37
+ function loadDynamicIcon(name: DynamicIconName): Promise<LucideIcon | null> {
38
+ const cached = resolved.get(name);
39
+ if (cached !== undefined) return Promise.resolve(cached);
40
+ const inFlight = pending.get(name);
41
+ if (inFlight) return inFlight;
42
+
43
+ const promise = dynamicIconImports[name]()
44
+ .then((mod) => mod.default ?? null)
45
+ .catch(() => null)
46
+ .then((glyph) => {
47
+ resolved.set(name, glyph);
48
+ pending.delete(name);
49
+ return glyph;
50
+ });
51
+ pending.set(name, promise);
52
+ return promise;
53
+ }
54
+
55
+ /**
56
+ * Lucide (stroke 1.75) is mdxserve's icon set. Components that show icons take a
57
+ * Lucide *name string* so MDX authors can write `icon="rocket"`; this is the one
58
+ * place that turns the name into a glyph. Unknown names render nothing rather
59
+ * than throwing, so a typo in a doc never breaks the page.
60
+ *
61
+ * Two tiers: `staticIcons` (client/ui/icon-set.ts) covers the ~30 names
62
+ * mdxserve's own chrome uses, resolved synchronously with no import at all.
63
+ * Any other name is looked up in lucide-react's `dynamicIconImports` map and
64
+ * loaded on demand — this is how MDX authors can write `icon="anything-in-
65
+ * lucide"` without every one of lucide's 2,022 icons shipping in the bundle.
66
+ * While a dynamic icon is loading, a same-size empty placeholder holds its
67
+ * layout so nothing shifts once the glyph lands.
68
+ */
69
+ export function Icon({ name, size = "md", strokeWidth, style, ...rest }: IconProps) {
70
+ const px = typeof size === "number" ? size : SIZES[size];
71
+ const opacity = strokeWidth === "light" ? 0.75 : undefined;
72
+
73
+ const StaticGlyph = staticIcons[name];
74
+ const dynamic = !StaticGlyph && isDynamicIconName(name);
75
+ const [DynamicGlyph, setDynamicGlyph] = useState<LucideIcon | null>(() =>
76
+ dynamic ? (resolved.get(name) ?? null) : null,
77
+ );
78
+
79
+ useEffect(() => {
80
+ if (!dynamic) return;
81
+ if (resolved.has(name)) {
82
+ setDynamicGlyph(resolved.get(name) ?? null);
83
+ return;
84
+ }
85
+ let cancelled = false;
86
+ loadDynamicIcon(name).then((glyph) => {
87
+ if (!cancelled) setDynamicGlyph(glyph);
88
+ });
89
+ return () => {
90
+ cancelled = true;
91
+ };
92
+ }, [dynamic, name]);
93
+
94
+ const Glyph = StaticGlyph ?? (dynamic ? DynamicGlyph : null);
95
+
96
+ if (!StaticGlyph && !dynamic) return null;
97
+
98
+ if (!Glyph) {
99
+ // Unresolved dynamic icon: still loading. Same footprint as the glyph
100
+ // it will become so nothing reflows once it lands.
101
+ return <svg aria-hidden="true" width={px} height={px} style={{ flex: "0 0 auto", ...style }} />;
102
+ }
103
+
104
+ return (
105
+ <Glyph
106
+ aria-hidden="true"
107
+ data-icon={name}
108
+ size={px}
109
+ strokeWidth={1.75}
110
+ style={{ flex: "0 0 auto", opacity, ...style }}
111
+ {...rest}
112
+ />
113
+ );
114
+ }
@@ -0,0 +1,63 @@
1
+ import { forwardRef, type ButtonHTMLAttributes } from "react";
2
+ import { motion, type MotionProps } from "framer-motion";
3
+ import { Icon } from "./Icon";
4
+ import { TRANSITIONS } from "../motion";
5
+
6
+ const BOX: Record<"sm" | "md" | "lg", string> = {
7
+ sm: "h-[26px] w-[26px]",
8
+ md: "h-8 w-8",
9
+ lg: "h-[38px] w-[38px]",
10
+ };
11
+
12
+ // `motion.button` redefines a handful of DOM event props (onAnimationStart,
13
+ // onDrag*…) with its own signatures; omitting their DOM versions here is what
14
+ // lets `{...rest}` below flow straight onto it without a type clash.
15
+ export interface IconButtonProps extends Omit<
16
+ ButtonHTMLAttributes<HTMLButtonElement>,
17
+ keyof MotionProps
18
+ > {
19
+ /** Lucide icon name. */
20
+ icon: string;
21
+ size?: "sm" | "md" | "lg";
22
+ variant?: "ghost" | "outline";
23
+ /** Sticky pressed state, e.g. an enabled sidebar toggle. */
24
+ active?: boolean;
25
+ /** Accessible name — also used as the native tooltip. */
26
+ label?: string;
27
+ }
28
+
29
+ /** Small square control for chrome: sidebar toggle, code copy, diagram zoom. */
30
+ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(function IconButton(
31
+ { icon, size = "md", variant = "ghost", active, label, disabled, className, ...rest },
32
+ ref,
33
+ ) {
34
+ const bordered = variant === "outline";
35
+ return (
36
+ <motion.button
37
+ ref={ref}
38
+ type="button"
39
+ aria-label={label}
40
+ title={label}
41
+ disabled={disabled}
42
+ whileTap={disabled ? undefined : { scale: 0.92 }}
43
+ transition={TRANSITIONS.snap}
44
+ className={
45
+ "inline-flex items-center justify-center rounded-md cursor-pointer transition-colors " +
46
+ "disabled:cursor-not-allowed disabled:opacity-45 " +
47
+ BOX[size] +
48
+ " " +
49
+ (bordered ? "border border-border-default" : "border border-transparent") +
50
+ " " +
51
+ (active
52
+ ? "bg-surface-active text-text-heading"
53
+ : bordered
54
+ ? "bg-surface-card text-text-muted hover:bg-surface-hover"
55
+ : "text-text-muted hover:bg-surface-hover") +
56
+ (className ? " " + className : "")
57
+ }
58
+ {...rest}
59
+ >
60
+ <Icon name={icon} size={size === "sm" ? "sm" : "md"} />
61
+ </motion.button>
62
+ );
63
+ });
@@ -0,0 +1,17 @@
1
+ import type { HTMLAttributes } from "react";
2
+
3
+ export interface KbdProps extends HTMLAttributes<HTMLElement> {}
4
+
5
+ /** A single keycap, e.g. inside SearchDialog's "esc" hint. */
6
+ export function Kbd({ className, ...rest }: KbdProps) {
7
+ return (
8
+ <kbd
9
+ className={
10
+ "inline-flex h-5 min-w-5 items-center justify-center rounded-sm border border-b-2 border-border-default " +
11
+ "bg-surface-card px-[5px] font-mono text-[length:var(--size-2xs)] font-medium text-text-muted" +
12
+ (className ? " " + className : "")
13
+ }
14
+ {...rest}
15
+ />
16
+ );
17
+ }