@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,77 @@
1
+ import { useState, type HTMLAttributes, type ReactNode } from "react";
2
+ import { motion } from "framer-motion";
3
+ import { Icon } from "./Icon";
4
+ import { TRANSITIONS } from "../motion";
5
+
6
+ export interface PageNavLink {
7
+ label: ReactNode;
8
+ href: string;
9
+ /** Overrides the default "Previous" / "Next" eyebrow. */
10
+ eyebrow?: ReactNode;
11
+ }
12
+
13
+ export interface PageNavProps extends HTMLAttributes<HTMLDivElement> {
14
+ prev?: PageNavLink;
15
+ next?: PageNavLink;
16
+ }
17
+
18
+ function Side({ item, dir }: { item: PageNavLink; dir: "prev" | "next" }) {
19
+ const [hover, setHover] = useState(false);
20
+ const prev = dir === "prev";
21
+ return (
22
+ <motion.a
23
+ href={item.href}
24
+ onMouseEnter={() => setHover(true)}
25
+ onMouseLeave={() => setHover(false)}
26
+ animate={{ y: hover ? -2 : 0 }}
27
+ whileTap={{ y: 0, scale: 0.995 }}
28
+ transition={TRANSITIONS.snap}
29
+ className={
30
+ "flex min-w-0 flex-1 flex-col gap-1 rounded-lg border bg-surface-card px-4 py-3 no-underline transition-shadow " +
31
+ (prev ? "items-start text-left" : "items-end text-right") +
32
+ " " +
33
+ (hover ? "border-border-accent shadow-sm" : "border-border-default")
34
+ }
35
+ >
36
+ <span className="flex items-center gap-1 font-mono text-[length:var(--size-2xs)] font-semibold uppercase tracking-[var(--tracking-caps)] text-text-subtle">
37
+ {prev ? (
38
+ <motion.span
39
+ animate={{ x: hover ? -2 : 0 }}
40
+ transition={TRANSITIONS.snap}
41
+ className="inline-flex"
42
+ >
43
+ <Icon name="arrow-left" size={12} />
44
+ </motion.span>
45
+ ) : null}
46
+ {item.eyebrow ?? (prev ? "Previous" : "Next")}
47
+ {prev ? null : (
48
+ <motion.span
49
+ animate={{ x: hover ? 2 : 0 }}
50
+ transition={TRANSITIONS.snap}
51
+ className="inline-flex"
52
+ >
53
+ <Icon name="arrow-right" size={12} />
54
+ </motion.span>
55
+ )}
56
+ </span>
57
+ <span
58
+ className={
59
+ "max-w-full break-words text-[15px] leading-normal font-semibold transition-colors " +
60
+ (hover ? "text-text-accent" : "text-text-heading")
61
+ }
62
+ >
63
+ {item.label}
64
+ </span>
65
+ </motion.a>
66
+ );
67
+ }
68
+
69
+ /** Prev/next page cards below the prose column. */
70
+ export function PageNav({ prev, next, className, ...rest }: PageNavProps) {
71
+ return (
72
+ <div className={"flex gap-3" + (className ? " " + className : "")} {...rest}>
73
+ {prev ? <Side item={prev} dir="prev" /> : <span className="flex-1" />}
74
+ {next ? <Side item={next} dir="next" /> : <span className="flex-1" />}
75
+ </div>
76
+ );
77
+ }
@@ -0,0 +1,201 @@
1
+ import { motion } from "framer-motion";
2
+ import { useCallback, useEffect, useRef, useState } from "react";
3
+ import { TRANSITIONS } from "../motion";
4
+
5
+ /** Gap between the content's edge and the resize handle's resting line. */
6
+ const HANDLE_INSET = 12;
7
+ /** Hover zone width: covers the inset gap plus room on either side of the line. */
8
+ const HANDLE_ZONE = 40;
9
+ /** Height of the resting pill. */
10
+ const PILL_HEIGHT = 40;
11
+
12
+ type HandleState = "idle" | "hover" | "drag";
13
+
14
+ const handleBar: Record<
15
+ HandleState,
16
+ { width: number; height: string; opacity: number; backgroundColor: string }
17
+ > = {
18
+ // A short muted pill at rest, so the affordance is discoverable.
19
+ idle: {
20
+ width: 3,
21
+ height: `${PILL_HEIGHT}px`,
22
+ opacity: 0.6,
23
+ backgroundColor: "var(--border-default)",
24
+ },
25
+ // Full-height line on hover.
26
+ hover: { width: 2, height: "100%", opacity: 1, backgroundColor: "var(--border-default)" },
27
+ // Thicker and teal while dragging.
28
+ drag: { width: 4, height: "100%", opacity: 1, backgroundColor: "var(--surface-accent)" },
29
+ };
30
+
31
+ /**
32
+ * Resize handle just outside one edge of a content block. The hover zone is
33
+ * wide (it spans the inset gap) so it's easy to find; the bar itself rests as
34
+ * a short pill, grows to a full-height line on hover, and thickens + turns
35
+ * teal mid-drag. For a page-height content block the pill sticks to the
36
+ * viewport's vertical centre (rather than the block's, which is usually off
37
+ * screen) so it's always in view; anchored panels are already viewport-bound,
38
+ * so theirs is simply centred.
39
+ *
40
+ * Two modes:
41
+ * - Default: the content is centred in the area marked with
42
+ * `data-content-area`, so moving one edge by `d` changes the width by `2d` —
43
+ * computed from the content's centre rather than accumulated deltas so the
44
+ * drag can't drift. Widths are clamped to [minWidth, maxWidth] and to the
45
+ * content area so the block never runs under the surrounding chrome.
46
+ * - `anchored`: the panel is flush against one edge of the layout (the docked
47
+ * sidebar on the left with `side="right"`, the toc rail on the right with
48
+ * `side="left"`), so the dragged edge maps 1:1 to the width with no doubling
49
+ * or centring, and the width is only clamped to [minWidth, maxWidth].
50
+ */
51
+ export function ResizeHandle({
52
+ side,
53
+ container,
54
+ onResize,
55
+ label,
56
+ minWidth,
57
+ maxWidth,
58
+ anchored = false,
59
+ }: {
60
+ side: "left" | "right";
61
+ container: React.RefObject<HTMLDivElement | null>;
62
+ onResize: (width: number) => void;
63
+ label: string;
64
+ minWidth: number;
65
+ maxWidth: number;
66
+ /** Panel flush against the layout edge opposite `side`, so the drag maps 1:1 to the width. */
67
+ anchored?: boolean;
68
+ }) {
69
+ const [hovered, setHovered] = useState(false);
70
+ const [dragging, setDragging] = useState(false);
71
+ const draggingRef = useRef(false);
72
+ /** Pointer distance from the resting bar at grab time, so a press anywhere
73
+ * in the zone drags relative to it instead of snapping the bar under the cursor. */
74
+ const grabOffset = useRef(0);
75
+
76
+ useEffect(() => {
77
+ return () => {
78
+ if (!draggingRef.current) return;
79
+ draggingRef.current = false;
80
+ document.body.style.cursor = "";
81
+ document.body.style.userSelect = "";
82
+ };
83
+ }, []);
84
+
85
+ const clamp = useCallback(
86
+ (px: number) => {
87
+ // Anchored panels aren't centred in a content area — clamp to the
88
+ // configured bounds only.
89
+ if (anchored) return Math.min(maxWidth, Math.max(minWidth, Math.round(px)));
90
+ const area = container.current?.closest("[data-content-area]");
91
+ const available = area instanceof HTMLElement ? area.clientWidth : maxWidth;
92
+ return Math.min(Math.min(maxWidth, available), Math.max(minWidth, Math.round(px)));
93
+ },
94
+ [container, minWidth, maxWidth, anchored],
95
+ );
96
+
97
+ const onPointerDown = useCallback(
98
+ (event: React.PointerEvent<HTMLDivElement>) => {
99
+ event.preventDefault();
100
+ if (anchored && container.current) {
101
+ // Pointer distance past the panel's dragged edge, so the drag stays
102
+ // relative to where it was grabbed instead of snapping the edge to the cursor.
103
+ const rect = container.current.getBoundingClientRect();
104
+ grabOffset.current =
105
+ side === "right" ? event.clientX - rect.right : rect.left - event.clientX;
106
+ } else if (container.current) {
107
+ const rect = container.current.getBoundingClientRect();
108
+ const centre = rect.left + rect.width / 2;
109
+ const half = side === "right" ? event.clientX - centre : centre - event.clientX;
110
+ grabOffset.current = half - (rect.width / 2 + HANDLE_INSET);
111
+ } else {
112
+ grabOffset.current = 0;
113
+ }
114
+ draggingRef.current = true;
115
+ setDragging(true);
116
+ try {
117
+ event.currentTarget.setPointerCapture(event.pointerId);
118
+ } catch {
119
+ // Not a live pointer (synthetic event); the drag still works while the cursor stays in the zone.
120
+ }
121
+ document.body.style.cursor = "col-resize";
122
+ document.body.style.userSelect = "none";
123
+ },
124
+ [container, side, anchored],
125
+ );
126
+
127
+ const onPointerMove = useCallback(
128
+ (event: React.PointerEvent<HTMLDivElement>) => {
129
+ if (!draggingRef.current || !container.current) return;
130
+ if (anchored) {
131
+ const rect = container.current.getBoundingClientRect();
132
+ const edge = side === "right" ? event.clientX - rect.left : rect.right - event.clientX;
133
+ onResize(clamp(edge - grabOffset.current));
134
+ return;
135
+ }
136
+ const rect = container.current.getBoundingClientRect();
137
+ const centre = rect.left + rect.width / 2;
138
+ const half = side === "right" ? event.clientX - centre : centre - event.clientX;
139
+ onResize(clamp((half - HANDLE_INSET - grabOffset.current) * 2));
140
+ },
141
+ [container, onResize, side, clamp, anchored],
142
+ );
143
+
144
+ const onPointerUp = useCallback((event: React.PointerEvent<HTMLDivElement>) => {
145
+ draggingRef.current = false;
146
+ setDragging(false);
147
+ if (event.currentTarget.hasPointerCapture(event.pointerId)) {
148
+ event.currentTarget.releasePointerCapture(event.pointerId);
149
+ }
150
+ document.body.style.cursor = "";
151
+ document.body.style.userSelect = "";
152
+ }, []);
153
+
154
+ const state: HandleState = dragging ? "drag" : hovered ? "hover" : "idle";
155
+ // Centre the bar on the inset line: the zone starts at the content edge and
156
+ // extends outward, so the line sits HANDLE_INSET in from the zone's inner edge.
157
+ // The left-anchored sidebar sits flush beside the main column, so rather than
158
+ // overhang into it the zone straddles the panel's border, centred on it. The
159
+ // right-anchored toc rail has a gutter on its left and its list draws its own
160
+ // left border, so its bar sits out in the gutter like the centred handles.
161
+ const zoneStyle =
162
+ anchored && side === "right"
163
+ ? { right: -HANDLE_ZONE / 2 }
164
+ : side === "right"
165
+ ? { right: -HANDLE_ZONE, paddingLeft: HANDLE_INSET }
166
+ : { left: -HANDLE_ZONE, paddingRight: HANDLE_INSET };
167
+
168
+ return (
169
+ <div
170
+ role="separator"
171
+ aria-orientation="vertical"
172
+ aria-label={label}
173
+ onPointerEnter={() => setHovered(true)}
174
+ onPointerLeave={() => setHovered(false)}
175
+ onPointerDown={onPointerDown}
176
+ onPointerMove={onPointerMove}
177
+ onPointerUp={onPointerUp}
178
+ onPointerCancel={onPointerUp}
179
+ style={{ width: HANDLE_ZONE, ...zoneStyle }}
180
+ className={
181
+ "absolute inset-y-0 z-10 flex cursor-col-resize " +
182
+ (anchored
183
+ ? "items-center " + (side === "right" ? "justify-center" : "justify-end")
184
+ : // A column so the sticky pill can slide along the zone's full height.
185
+ "flex-col " + (side === "right" ? "items-start" : "items-end"))
186
+ }
187
+ >
188
+ <motion.div
189
+ initial={false}
190
+ animate={handleBar[state]}
191
+ transition={{
192
+ ...TRANSITIONS.snap,
193
+ opacity: TRANSITIONS.fast,
194
+ backgroundColor: TRANSITIONS.fast,
195
+ }}
196
+ className={"shrink-0 rounded-full" + (anchored ? "" : " sticky")}
197
+ style={anchored ? undefined : { top: `calc(50vh - ${PILL_HEIGHT / 2}px)` }}
198
+ />
199
+ </div>
200
+ );
201
+ }
@@ -0,0 +1,187 @@
1
+ import {
2
+ useEffect,
3
+ useState,
4
+ type HTMLAttributes,
5
+ type KeyboardEvent,
6
+ type ReactNode,
7
+ } from "react";
8
+ import { AnimatePresence, motion, type MotionProps } from "framer-motion";
9
+ import { Icon } from "./Icon";
10
+ import { Kbd } from "./Kbd";
11
+ import { TRANSITIONS, VARIANTS } from "../motion";
12
+
13
+ export interface SearchResult {
14
+ title: ReactNode;
15
+ excerpt?: ReactNode;
16
+ path?: string;
17
+ /** Display path, e.g. "<rootName>/<relative path>"; shown above the title. */
18
+ label?: ReactNode;
19
+ /** Matched query terms; occurrences are highlighted in each field. */
20
+ terms?: string[];
21
+ }
22
+
23
+ function escapeRegExp(input: string): string {
24
+ return input.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
25
+ }
26
+
27
+ /** Wrap every case-insensitive occurrence of `terms` in `text` with `<mark>`. */
28
+ function highlight(text: ReactNode, terms: string[] | undefined): ReactNode {
29
+ if (typeof text !== "string" || !terms || terms.length === 0) return text;
30
+ const pattern = terms
31
+ .filter((term) => term.length > 0)
32
+ .sort((first, second) => second.length - first.length)
33
+ .map(escapeRegExp)
34
+ .join("|");
35
+ if (pattern === "") return text;
36
+ const parts = text.split(new RegExp(`(${pattern})`, "gi"));
37
+ return parts.map((part, index) =>
38
+ index % 2 === 1 ? (
39
+ <mark key={index} className="rounded-sm bg-surface-accent-soft text-text-accent">
40
+ {part}
41
+ </mark>
42
+ ) : (
43
+ part
44
+ ),
45
+ );
46
+ }
47
+
48
+ // `results` shadows a legacy global HTML attribute (a WebKit `<input>` prop
49
+ // React still types on `HTMLAttributes`); `Omit` it, along with the
50
+ // motion.div-owned event props, before extending.
51
+ export interface SearchDialogProps extends Omit<
52
+ HTMLAttributes<HTMLDivElement>,
53
+ "results" | "onSelect" | keyof MotionProps
54
+ > {
55
+ open?: boolean;
56
+ query?: string;
57
+ results?: SearchResult[];
58
+ onQueryChange?: (query: string) => void;
59
+ onSelect?: (result: SearchResult) => void;
60
+ onClose?: () => void;
61
+ }
62
+
63
+ const STAGGER_CAP = 5;
64
+
65
+ /** ⌘K command palette: search the served folder without leaving the page. */
66
+ export function SearchDialog({
67
+ open = true,
68
+ query = "",
69
+ results = [],
70
+ onQueryChange,
71
+ onSelect,
72
+ onClose,
73
+ className,
74
+ ...rest
75
+ }: SearchDialogProps) {
76
+ const [highlighted, setHighlighted] = useState(0);
77
+
78
+ useEffect(() => {
79
+ setHighlighted(0);
80
+ }, [query, results, open]);
81
+
82
+ function handleKeyDown(event: KeyboardEvent<HTMLDivElement>) {
83
+ if (event.key === "ArrowDown") {
84
+ event.preventDefault();
85
+ setHighlighted((current) => Math.min(current + 1, Math.max(results.length - 1, 0)));
86
+ } else if (event.key === "ArrowUp") {
87
+ event.preventDefault();
88
+ setHighlighted((current) => Math.max(current - 1, 0));
89
+ } else if (event.key === "Enter") {
90
+ const result = results[highlighted];
91
+ if (result) {
92
+ event.preventDefault();
93
+ onSelect?.(result);
94
+ }
95
+ } else if (event.key === "Escape") {
96
+ event.preventDefault();
97
+ onClose?.();
98
+ }
99
+ }
100
+
101
+ return (
102
+ <AnimatePresence>
103
+ {open ? (
104
+ <motion.div
105
+ key="scrim"
106
+ onClick={onClose}
107
+ {...VARIANTS.scrim}
108
+ className="fixed inset-0 z-[var(--z-modal)] flex justify-center bg-[var(--scrim)] pt-[10vh] backdrop-blur-sm"
109
+ >
110
+ <motion.div
111
+ onClick={(event) => event.stopPropagation()}
112
+ onKeyDown={handleKeyDown}
113
+ {...VARIANTS.pop}
114
+ className={
115
+ "w-[560px] max-w-[92vw] self-start overflow-hidden rounded-xl border border-border-default bg-surface-raised shadow-lg" +
116
+ (className ? " " + className : "")
117
+ }
118
+ {...rest}
119
+ >
120
+ <div className="flex h-12 items-center gap-3 border-b border-border-subtle px-4">
121
+ <Icon name="search" size="md" className="text-text-subtle" />
122
+ <input
123
+ autoFocus
124
+ data-bare-focus
125
+ value={query}
126
+ onChange={(event) => onQueryChange?.(event.target.value)}
127
+ placeholder="Search docs"
128
+ className="flex-1 border-0 bg-transparent text-[18px] font-normal text-text-body outline-none placeholder:text-text-subtle"
129
+ />
130
+ <Kbd>esc</Kbd>
131
+ </div>
132
+ <motion.div
133
+ layout
134
+ transition={TRANSITIONS.glide}
135
+ className="max-h-80 overflow-y-auto p-2"
136
+ >
137
+ {results.length === 0 ? (
138
+ <div className="p-6 text-center text-[13px] leading-normal font-medium text-text-subtle">
139
+ No matches
140
+ </div>
141
+ ) : (
142
+ results.map((result, index) => {
143
+ const active = index === highlighted;
144
+ return (
145
+ <motion.div
146
+ key={(result.path ?? "") + index}
147
+ layout
148
+ initial={{ opacity: 0, y: 4 }}
149
+ animate={{ opacity: 1, y: 0 }}
150
+ transition={{
151
+ ...TRANSITIONS.glide,
152
+ delay: Math.min(index, STAGGER_CAP - 1) * 0.02,
153
+ }}
154
+ onMouseEnter={() => setHighlighted(index)}
155
+ onClick={() => onSelect?.(result)}
156
+ className={
157
+ "flex cursor-pointer items-start gap-3 rounded-md p-2.5 " +
158
+ (active ? "bg-surface-hover" : "bg-transparent")
159
+ }
160
+ >
161
+ <Icon name="file-text" size="sm" className="mt-0.5 text-text-subtle" />
162
+ <div className="min-w-0 flex-1">
163
+ {result.label ? (
164
+ <div className="overflow-hidden text-ellipsis whitespace-nowrap font-mono text-[length:var(--size-2xs)] text-text-subtle">
165
+ {highlight(result.label, result.terms)}
166
+ </div>
167
+ ) : null}
168
+ <div className="text-[13px] leading-normal font-semibold text-text-heading">
169
+ {highlight(result.title, result.terms)}
170
+ </div>
171
+ {result.excerpt ? (
172
+ <div className="mt-px overflow-hidden text-ellipsis whitespace-nowrap text-[12px] font-normal text-text-muted">
173
+ {highlight(result.excerpt, result.terms)}
174
+ </div>
175
+ ) : null}
176
+ </div>
177
+ </motion.div>
178
+ );
179
+ })
180
+ )}
181
+ </motion.div>
182
+ </motion.div>
183
+ </motion.div>
184
+ ) : null}
185
+ </AnimatePresence>
186
+ );
187
+ }
@@ -0,0 +1,44 @@
1
+ import { useState, type HTMLAttributes } from "react";
2
+ import { Icon } from "./Icon";
3
+
4
+ export interface TagProps extends HTMLAttributes<HTMLSpanElement> {
5
+ /** Selected/filtering state. */
6
+ active?: boolean;
7
+ /** Renders a dismiss affordance. */
8
+ onRemove?: () => void;
9
+ }
10
+
11
+ /** Small pill used for filter chips and metadata labels. */
12
+ export function Tag({ children, onRemove, active, className, ...rest }: TagProps) {
13
+ const [hover, setHover] = useState(false);
14
+ return (
15
+ <span
16
+ onMouseEnter={() => setHover(true)}
17
+ onMouseLeave={() => setHover(false)}
18
+ className={
19
+ "inline-flex h-6 items-center gap-1.5 rounded-pill border font-sans font-medium leading-normal text-[length:var(--size-sm)] transition-colors " +
20
+ (onRemove ? "pr-1.5 pl-2.5" : "px-2.5") +
21
+ " " +
22
+ (active
23
+ ? "border-teal-200 bg-surface-accent-soft text-text-accent"
24
+ : hover
25
+ ? "border-border-default bg-surface-hover text-text-muted"
26
+ : "border-border-default bg-surface-sunken text-text-muted") +
27
+ (className ? " " + className : "")
28
+ }
29
+ {...rest}
30
+ >
31
+ {children}
32
+ {onRemove ? (
33
+ <button
34
+ type="button"
35
+ onClick={onRemove}
36
+ aria-label="Remove"
37
+ className="inline-flex cursor-pointer rounded-pill border-0 bg-transparent p-0.5 text-inherit"
38
+ >
39
+ <Icon name="x" size={12} />
40
+ </button>
41
+ ) : null}
42
+ </span>
43
+ );
44
+ }
@@ -0,0 +1,189 @@
1
+ import { useEffect, type CSSProperties, type ReactNode } from "react";
2
+ import {
3
+ CheckmarkIcon,
4
+ ErrorIcon,
5
+ ToastBar,
6
+ Toaster,
7
+ toast,
8
+ useToasterStore,
9
+ type Toast as HotToast,
10
+ } from "react-hot-toast";
11
+ import { Icon } from "./Icon";
12
+ import { nextCount } from "./toast-count";
13
+ import { docModuleCache } from "../doc-module-cache";
14
+
15
+ export type ToastTone = "info" | "ok" | "warn" | "danger";
16
+
17
+ export interface ToastInput {
18
+ tone: ToastTone;
19
+ /** The whole toast — one line, sentence case, human-written. */
20
+ text: string;
21
+ /** Lucide icon name overriding the tone's icon (e.g. "download", "trash-2"). */
22
+ icon?: string;
23
+ /** Pushes sharing a key collapse into one card: repeat push = same card, ×N counter, bounce, dismiss timer restarted. */
24
+ dedupeKey?: string;
25
+ }
26
+
27
+ const TONE_FG: Record<ToastTone, string> = {
28
+ info: "text-status-info-fg",
29
+ ok: "text-status-ok-fg",
30
+ warn: "text-status-warn-fg",
31
+ danger: "text-status-danger-fg",
32
+ };
33
+
34
+ // react-hot-toast only exposes live state through this hook, so the module
35
+ // scope below can't just read a store synchronously the way `pushToast` (a
36
+ // plain function, called from outside React) needs to. Instead a child
37
+ // component mirrors the hook's state into `liveIds` on every change; that
38
+ // mirror is what `nextCount` consults. Driving it off `visible` (not just
39
+ // presence in the list, which includes toasts mid fade-out) is what keeps
40
+ // counting correct under pause-on-hover: a hovered toast's dismiss timer is
41
+ // paused, not fired, so it stays "live" and a repeat push still increments
42
+ // its counter instead of resetting to 1.
43
+ let liveIds = new Set<string>();
44
+ const counts = new Map<string, number>();
45
+
46
+ function LiveIdMirror() {
47
+ const { toasts } = useToasterStore();
48
+ useEffect(() => {
49
+ liveIds = new Set(toasts.filter((item) => item.visible).map((item) => item.id));
50
+ }, [toasts]);
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * react-hot-toast sets each bar's enter/exit `animation` inline (see its
56
+ * source), so a plain class on `ToastBar` itself would lose to that inline
57
+ * style — or, with `!important`, would clobber the exit animation too. This
58
+ * wrapper carries the attention-bounce class instead, one layer out from
59
+ * where react-hot-toast writes its own animation.
60
+ */
61
+ function Bump({ pulse, children }: { pulse: number; children: ReactNode }) {
62
+ const bumpClass = pulse > 0 ? (pulse % 2 === 1 ? "toast-bump-a" : "toast-bump-b") : undefined;
63
+ return <div className={bumpClass}>{children}</div>;
64
+ }
65
+
66
+ function toneIcon(tone: ToastTone, iconName: string | undefined) {
67
+ if (iconName) return <Icon name={iconName} size="md" className={TONE_FG[tone]} />;
68
+ switch (tone) {
69
+ case "ok":
70
+ return <CheckmarkIcon primary="var(--status-ok-fg)" secondary="var(--surface-raised)" />;
71
+ case "danger":
72
+ return <ErrorIcon primary="var(--status-danger-fg)" secondary="var(--surface-raised)" />;
73
+ case "warn":
74
+ return <Icon name="triangle-alert" size="md" className="text-status-warn-fg" />;
75
+ case "info":
76
+ return null;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Queues a toast; repeat pushes sharing `dedupeKey` collapse into the same
82
+ * card (message updates, ×N counter, attention bounce, dismiss timer
83
+ * restarted) instead of stacking a second one. Returns the toast's id.
84
+ */
85
+ export function pushToast(input: ToastInput): string {
86
+ const id = input.dedupeKey;
87
+ const count = input.dedupeKey ? nextCount(liveIds, counts, input.dedupeKey) : 1;
88
+ const message =
89
+ count > 1 ? (
90
+ <>
91
+ {input.text}
92
+ <span className="ml-2 rounded-pill border border-border-default bg-surface-sunken px-1.5 text-[11px] tabular-nums text-text-muted">
93
+ ×{count}
94
+ </span>
95
+ </>
96
+ ) : (
97
+ input.text
98
+ );
99
+ const toastId = toast.custom(
100
+ (liveToast: HotToast) => (
101
+ <Bump pulse={count - 1}>
102
+ <ToastBar toast={{ ...liveToast, message }} />
103
+ </Bump>
104
+ ),
105
+ {
106
+ id,
107
+ duration: 4000,
108
+ // ToastBar picks its enter/exit direction from the toast, not the
109
+ // Toaster; without this it animates as if the stack were top-center.
110
+ position: "bottom-right",
111
+ icon: toneIcon(input.tone, input.icon),
112
+ },
113
+ );
114
+ // The mirror only catches up after React commits; mark the id live now so
115
+ // a second push in the same tick still counts as a repeat.
116
+ liveIds.add(toastId);
117
+ return toastId;
118
+ }
119
+
120
+ /** Re-exported for symmetry with `pushToast`. */
121
+ export const dismissToast = toast.dismiss;
122
+
123
+ const TOAST_STYLE: CSSProperties = {
124
+ background: "var(--surface-raised)",
125
+ color: "var(--text-heading)",
126
+ border: "1px solid var(--border-default)",
127
+ borderRadius: 8,
128
+ boxShadow: "var(--shadow-toast)",
129
+ font: "500 14px/1.4 var(--font-sans)",
130
+ maxWidth: 360,
131
+ };
132
+
133
+ /** Fixed bottom-right stack. Mount once near the app root. */
134
+ export function ToastStack() {
135
+ // The server names the doc that was edited (`mdxserve:doc-changed`, sent
136
+ // from its file watcher). Vite's own `vite:afterUpdate` can't be used for
137
+ // this: a doc module has no HMR boundary, so the update is attributed to
138
+ // the importer that accepted it (App.tsx), and the Tailwind rebuild the
139
+ // edit triggers shows up as a second update for app.css.
140
+ useEffect(() => {
141
+ if (!import.meta.hot) return;
142
+ // The event covers every doc under every mounted root, so keep only the
143
+ // docs this tab has actually loaded (the old vite:afterUpdate toast was
144
+ // scoped to loaded modules the same way) — an agent editing forty
145
+ // unrelated files shouldn't rain toasts on a reader. Keyed by path so a
146
+ // burst of saves to one doc refreshes a single card (counter + bounce,
147
+ // timer restarted) instead of stacking copies.
148
+ const handleDocChanged = (data: {
149
+ files?: Array<{ path: string; root: string; rel: string }>;
150
+ }) => {
151
+ const files = (data?.files ?? []).filter((file) => docModuleCache.has(file.path));
152
+ const [first] = files;
153
+ if (!first) return;
154
+ if (files.length === 1) {
155
+ pushToast({
156
+ tone: "info",
157
+ text: `Reloaded ${first.rel}`,
158
+ dedupeKey: `reload:${first.path}`,
159
+ });
160
+ } else {
161
+ pushToast({
162
+ tone: "info",
163
+ text: `Reloaded ${files.length} docs`,
164
+ dedupeKey: "reload:many",
165
+ });
166
+ }
167
+ };
168
+ import.meta.hot.on("mdxserve:doc-changed", handleDocChanged);
169
+ return () => {
170
+ import.meta.hot?.off?.("mdxserve:doc-changed", handleDocChanged);
171
+ };
172
+ }, []);
173
+
174
+ return (
175
+ <>
176
+ <LiveIdMirror />
177
+ {/* Inline style, not a class: goober (react-hot-toast's runtime CSS-in-JS)
178
+ injects the base toast class after this stylesheet loads, so only an
179
+ inline style is guaranteed to win. The custom properties still resolve
180
+ at computed-style time, so light/dark both come through correctly. */}
181
+ <Toaster
182
+ position="bottom-right"
183
+ gutter={8}
184
+ containerStyle={{ zIndex: "var(--z-toast)", inset: 16 }}
185
+ toastOptions={{ duration: 4000, style: TOAST_STYLE }}
186
+ />
187
+ </>
188
+ );
189
+ }