wiki-viewer 1.8.2 → 1.8.3

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 (98) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +5 -5
  3. package/.next/standalone/.next/server/app/_global-error/page/build-manifest.json +2 -2
  4. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_not-found/page/build-manifest.json +2 -2
  12. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  14. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  15. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  16. package/.next/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
  18. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/standalone/.next/server/app/api/app-proxy/[...path]/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/system/browse/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/wiki/app/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/wiki/upload/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/index.html +1 -1
  26. package/.next/standalone/.next/server/app/index.rsc +3 -3
  27. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  28. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  29. package/.next/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  30. package/.next/standalone/.next/server/app/index.segments/_index.segment.rsc +2 -2
  31. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  32. package/.next/standalone/.next/server/app/page/build-manifest.json +2 -2
  33. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  35. package/.next/standalone/.next/server/app/signin/page/build-manifest.json +2 -2
  36. package/.next/standalone/.next/server/app/signin/page_client-reference-manifest.js +1 -1
  37. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-inabx._.js +1 -1
  38. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00lep-u._.js +1 -1
  39. package/.next/standalone/.next/server/chunks/ssr/{0~w0_katex_dist_katex_mjs_04mhawr._.js → 0~w0_katex_dist_katex_mjs_0hyln.b._.js} +1 -1
  40. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__09~x2v~._.js → [root-of-the-server]__0du58sw._.js} +2 -2
  41. package/.next/standalone/.next/server/chunks/ssr/_0pqaawz._.js +19 -19
  42. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0shlang._.js → node_modules__pnpm_0.kyqfq._.js} +1 -1
  43. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_04n0h6.._.js → node_modules__pnpm_0.~goq~._.js} +1 -1
  44. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_011vy_q._.js → node_modules__pnpm_00y7foi._.js} +2 -2
  45. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0yiz.-t._.js → node_modules__pnpm_03.1jli._.js} +1 -1
  46. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_093dp2q._.js → node_modules__pnpm_03z3na-._.js} +1 -1
  47. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_01o_5qt._.js → node_modules__pnpm_05~w-ok._.js} +1 -1
  48. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0b8qx0g._.js → node_modules__pnpm_09d5yyu._.js} +1 -1
  49. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0wmx9kq._.js → node_modules__pnpm_09eo8jh._.js} +2 -2
  50. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0nw5ou2._.js → node_modules__pnpm_0adm8h6._.js} +1 -1
  51. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0bbc---._.js → node_modules__pnpm_0arbjru._.js} +1 -1
  52. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0t9jb7d._.js → node_modules__pnpm_0b5fla7._.js} +1 -1
  53. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_124u4o2._.js → node_modules__pnpm_0beormu._.js} +2 -2
  54. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_13tajgx._.js → node_modules__pnpm_0c7u76a._.js} +2 -2
  55. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ej62_._.js → node_modules__pnpm_0dbr7c2._.js} +1 -1
  56. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0s0ur~d._.js → node_modules__pnpm_0d~ida~._.js} +1 -1
  57. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09ljbdz._.js → node_modules__pnpm_0elpfsb._.js} +2 -2
  58. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hlzy8f._.js → node_modules__pnpm_0fh.6ir._.js} +1 -1
  59. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_09~_ob2._.js → node_modules__pnpm_0fm6wcx._.js} +1 -1
  60. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0o.dn8o._.js → node_modules__pnpm_0iikbcy._.js} +1 -1
  61. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0hk6xys._.js → node_modules__pnpm_0mvih7b._.js} +2 -2
  62. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0a01ng7._.js → node_modules__pnpm_0qd2ovj._.js} +2 -2
  63. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c.309.._.js → node_modules__pnpm_0s4yn43._.js} +1 -1
  64. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0sq3c2r._.js → node_modules__pnpm_0s8fvbn._.js} +1 -1
  65. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0xfx0sf._.js → node_modules__pnpm_0tq8zs6._.js} +1 -1
  66. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_11sut1u._.js → node_modules__pnpm_0vyv.lz._.js} +1 -1
  67. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0c8aunj._.js → node_modules__pnpm_0xa.ko1._.js} +1 -1
  68. package/.next/standalone/.next/server/chunks/ssr/{node_modules__pnpm_0.96tmo._.js → node_modules__pnpm_12.ymhl._.js} +1 -1
  69. package/.next/standalone/.next/server/middleware-build-manifest.js +5 -5
  70. package/.next/standalone/.next/server/middleware-manifest.json +1 -1
  71. package/.next/standalone/.next/server/pages/404.html +1 -1
  72. package/.next/standalone/.next/server/pages/500.html +1 -1
  73. package/.next/standalone/.next/static/chunks/{0vp-ahhagxptc.js → 06iw_iy5e90d4.js} +1 -1
  74. package/.next/standalone/.next/static/chunks/0d~o0h0ssgra7.js +129 -0
  75. package/.next/standalone/.next/static/chunks/167ph4xws_-61.css +2 -0
  76. package/.next/standalone/.next/static/chunks/{turbopack-12~z-mr7b9b50.js → turbopack-0lf4~b-ofzbm6.js} +1 -1
  77. package/.next/standalone/AGENTS.md +104 -0
  78. package/.next/standalone/package.json +1 -1
  79. package/.next/standalone/src/app/api/wiki/app/route.ts +7 -4
  80. package/.next/standalone/src/app/page.tsx +580 -192
  81. package/.next/standalone/src/components/editor/backlinks-panel.tsx +152 -0
  82. package/.next/standalone/src/components/editor/document-outline.tsx +221 -0
  83. package/.next/standalone/src/components/editor/editor.tsx +145 -48
  84. package/.next/standalone/src/components/editor/node-app-viewer.tsx +65 -7
  85. package/.next/standalone/src/components/editor/slash-commands.tsx +216 -41
  86. package/.next/standalone/src/components/editor/source-viewer.tsx +29 -2
  87. package/.next/standalone/src/components/search/search-command-dialog.tsx +183 -46
  88. package/.next/standalone/src/lib/app-runner.ts +57 -16
  89. package/.next/standalone/src/stores/pin-store.ts +55 -0
  90. package/.next/standalone/src/stores/recent-store.ts +50 -0
  91. package/.next/standalone/tsconfig.tsbuildinfo +1 -1
  92. package/package.json +1 -1
  93. package/.next/standalone/.next/static/chunks/0c_~zevf2n0r4.css +0 -2
  94. package/.next/standalone/.next/static/chunks/16r--8~rpehcz.js +0 -129
  95. package/.next/standalone/src/components/wiki/markdown-preview.tsx +0 -120
  96. /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_buildManifest.js +0 -0
  97. /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_clientMiddlewareManifest.js +0 -0
  98. /package/.next/standalone/.next/static/{t_k0ztuf0V-iA-kgeez18 → Pkj31aCfRVk0Zy2rKA1wf}/_ssgManifest.js +0 -0
@@ -0,0 +1,152 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { ChevronDown, ChevronRight, Link2 } from "lucide-react";
5
+ import { wsFetch } from "@/lib/workspace-client";
6
+ import { useEditorStore } from "@/stores/editor-store";
7
+
8
+ interface BacklinkEntry {
9
+ path: string;
10
+ snippet: string;
11
+ }
12
+
13
+ function slugFromPath(filePath: string): string {
14
+ const base = filePath.split("/").pop() ?? filePath;
15
+ return base.replace(/\.md$/i, "");
16
+ }
17
+
18
+ /**
19
+ * True if `content` contains an actual wiki-link to `slug`, i.e. `[[slug]]`,
20
+ * `[[slug|alias]]`, or `[[slug#anchor]]`. FTS matches tokenised prose, so we
21
+ * re-check the raw text to drop false positives (a page merely mentioning the
22
+ * word, not linking to it).
23
+ */
24
+ function hasWikiLinkTo(content: string, slug: string): boolean {
25
+ const esc = slug.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
26
+ const re = new RegExp(`\\[\\[${esc}(?:\\|[^\\]#|]+|#[a-z0-9-]+)?\\]\\]`, "i");
27
+ return re.test(content);
28
+ }
29
+
30
+ /** Display the last two path segments, e.g. "notes/my-page.md" → "notes / my-page" */
31
+ function displayPath(filePath: string): string {
32
+ const parts = filePath.replace(/\.md$/i, "").split("/");
33
+ return parts.length > 1 ? parts.slice(-2).join(" / ") : parts[0] ?? filePath;
34
+ }
35
+
36
+ interface BacklinksPanelProps {
37
+ currentPath: string;
38
+ }
39
+
40
+ export function BacklinksPanel({ currentPath }: BacklinksPanelProps) {
41
+ const [backlinks, setBacklinks] = useState<BacklinkEntry[]>([]);
42
+ const [loading, setLoading] = useState(false);
43
+ const [collapsed, setCollapsed] = useState(false);
44
+
45
+ const slug = slugFromPath(currentPath);
46
+
47
+ useEffect(() => {
48
+ if (!slug) return;
49
+ let cancelled = false;
50
+ setLoading(true);
51
+ setBacklinks([]);
52
+
53
+ // Search for the slug surrounded by [[ ]] — FTS may not match brackets,
54
+ // but we pass the full pattern; backend uses BM25 on tokenised text so
55
+ // results will include pages that mention the slug near [[ ]].
56
+ // FTS is a coarse candidate filter (it tokenises and strips brackets), so
57
+ // we fetch each candidate's raw content and confirm a literal [[slug]] link.
58
+ (async () => {
59
+ try {
60
+ const r = await wsFetch("/api/wiki/search", {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify({ query: slug, limit: 50 }),
64
+ });
65
+ const d: {
66
+ matches?: Array<{ path: string; score: number; snippet: string }>;
67
+ } = r.ok ? await r.json() : { matches: [] };
68
+
69
+ const candidates = (d.matches ?? []).filter(
70
+ (m) => m.path !== currentPath && /\.md$/i.test(m.path),
71
+ );
72
+
73
+ const confirmed = await Promise.all(
74
+ candidates.map(async (m) => {
75
+ try {
76
+ const cr = await wsFetch(
77
+ `/api/wiki/content?path=${encodeURIComponent(m.path)}`,
78
+ );
79
+ if (!cr.ok) return null;
80
+ const { content } = (await cr.json()) as { content: string };
81
+ if (!hasWikiLinkTo(content, slug)) return null;
82
+ return { path: m.path, snippet: m.snippet } as BacklinkEntry;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }),
87
+ );
88
+
89
+ if (cancelled) return;
90
+ setBacklinks(
91
+ confirmed.filter((b): b is BacklinkEntry => b !== null),
92
+ );
93
+ } catch {
94
+ if (!cancelled) setBacklinks([]);
95
+ } finally {
96
+ if (!cancelled) setLoading(false);
97
+ }
98
+ })();
99
+
100
+ return () => {
101
+ cancelled = true;
102
+ };
103
+ }, [currentPath, slug]);
104
+
105
+ // Hide entirely when loading with no prior results or genuinely empty
106
+ if (!loading && backlinks.length === 0) return null;
107
+
108
+ return (
109
+ <div className="max-w-[var(--editor-max-w,48rem)] mx-auto px-4 sm:px-8 pb-8 pt-1">
110
+ <div className="border-t border-border/60 pt-4">
111
+ <button
112
+ onClick={() => setCollapsed((c) => !c)}
113
+ className="flex items-center gap-1.5 text-[11px] text-muted-foreground/60 hover:text-muted-foreground transition-colors mb-2"
114
+ aria-expanded={!collapsed}
115
+ >
116
+ {collapsed ? (
117
+ <ChevronRight className="h-3 w-3 shrink-0" />
118
+ ) : (
119
+ <ChevronDown className="h-3 w-3 shrink-0" />
120
+ )}
121
+ <Link2 className="h-3 w-3 shrink-0" />
122
+ <span className="font-medium">Linked from</span>
123
+ {backlinks.length > 0 && (
124
+ <span className="opacity-50 tabular-nums">({backlinks.length})</span>
125
+ )}
126
+ {loading && <span className="opacity-40 ml-1">…</span>}
127
+ </button>
128
+
129
+ {!collapsed && backlinks.length > 0 && (
130
+ <ul className="flex flex-col gap-1.5">
131
+ {backlinks.map((bl) => (
132
+ <li key={bl.path} className="flex flex-col">
133
+ <button
134
+ onClick={() => useEditorStore.getState().loadPage(bl.path)}
135
+ className="text-left text-[12px] text-primary/70 hover:text-primary hover:underline underline-offset-2 truncate transition-colors"
136
+ title={bl.path}
137
+ >
138
+ {displayPath(bl.path)}
139
+ </button>
140
+ {bl.snippet && (
141
+ <p className="text-[10.5px] text-muted-foreground/50 line-clamp-1 mt-0.5">
142
+ {bl.snippet}
143
+ </p>
144
+ )}
145
+ </li>
146
+ ))}
147
+ </ul>
148
+ )}
149
+ </div>
150
+ </div>
151
+ );
152
+ }
@@ -0,0 +1,221 @@
1
+ "use client";
2
+
3
+ import { useCallback, useEffect, useRef, useState } from "react";
4
+ import type { Editor } from "@tiptap/react";
5
+ import { List } from "lucide-react";
6
+ import { cn } from "@/lib/utils";
7
+
8
+ interface Heading {
9
+ level: number;
10
+ text: string;
11
+ pos: number; // ProseMirror doc position (start of node)
12
+ uid: string; // unique id within the doc (text + index dedup)
13
+ }
14
+
15
+ function extractHeadings(editor: Editor): Heading[] {
16
+ const headings: Heading[] = [];
17
+ const seen = new Map<string, number>();
18
+ editor.state.doc.forEach((node, pos) => {
19
+ if (node.type.name === "heading") {
20
+ const text = node.textContent;
21
+ const level = node.attrs.level as number;
22
+ const base = text.toLowerCase().replace(/\s+/g, "-").replace(/[^\w-]/g, "") || "heading";
23
+ const count = (seen.get(base) ?? 0) + 1;
24
+ seen.set(base, count);
25
+ const uid = count === 1 ? base : `${base}-${count}`;
26
+ headings.push({ level, text, pos, uid });
27
+ }
28
+ });
29
+ return headings;
30
+ }
31
+
32
+ interface DocumentOutlineProps {
33
+ editor: Editor | null;
34
+ scrollContainerRef: React.RefObject<HTMLDivElement | null>;
35
+ }
36
+
37
+ export function DocumentOutline({ editor, scrollContainerRef }: DocumentOutlineProps) {
38
+ const [headings, setHeadings] = useState<Heading[]>([]);
39
+ const [activeUid, setActiveUid] = useState<string | null>(null);
40
+ const [collapsed, setCollapsed] = useState(false);
41
+ const [scrollProgress, setScrollProgress] = useState(0);
42
+ const observerRef = useRef<IntersectionObserver | null>(null);
43
+
44
+ // Extract headings on every doc update
45
+ useEffect(() => {
46
+ if (!editor) return;
47
+ const update = () => setHeadings(extractHeadings(editor));
48
+ editor.on("update", update);
49
+ update();
50
+ return () => { editor.off("update", update); };
51
+ }, [editor]);
52
+
53
+ // Reading-progress bar: track scroll on the scroll container
54
+ useEffect(() => {
55
+ const el = scrollContainerRef.current;
56
+ if (!el) return;
57
+ let rafId = 0;
58
+ const onScroll = () => {
59
+ cancelAnimationFrame(rafId);
60
+ rafId = requestAnimationFrame(() => {
61
+ const total = el.scrollHeight - el.clientHeight;
62
+ setScrollProgress(total > 0 ? Math.min(1, el.scrollTop / total) : 0);
63
+ });
64
+ };
65
+ el.addEventListener("scroll", onScroll, { passive: true });
66
+ return () => {
67
+ el.removeEventListener("scroll", onScroll);
68
+ cancelAnimationFrame(rafId);
69
+ };
70
+ }, [scrollContainerRef]);
71
+
72
+ // Scroll-spy: observe heading DOM nodes
73
+ useEffect(() => {
74
+ observerRef.current?.disconnect();
75
+ const container = scrollContainerRef.current;
76
+ if (!container || !editor || headings.length === 0) return;
77
+
78
+ const observer = new IntersectionObserver(
79
+ (entries) => {
80
+ for (const entry of entries) {
81
+ if (entry.isIntersecting) {
82
+ const uid = (entry.target as HTMLElement).dataset.outlineId;
83
+ if (uid) setActiveUid(uid);
84
+ }
85
+ }
86
+ },
87
+ { root: container, rootMargin: "-8% 0px -72% 0px", threshold: 0 },
88
+ );
89
+
90
+ // Tag and observe each heading node via ProseMirror pos→DOM
91
+ for (const h of headings) {
92
+ try {
93
+ const { node } = editor.view.domAtPos(h.pos + 1);
94
+ const el: HTMLElement | null =
95
+ node.nodeType === Node.ELEMENT_NODE
96
+ ? (node as HTMLElement).closest("h1,h2,h3,h4,h5,h6") ?? (node as HTMLElement)
97
+ : (node as Node).parentElement?.closest("h1,h2,h3,h4,h5,h6") ?? null;
98
+ if (el) {
99
+ el.dataset.outlineId = h.uid;
100
+ observer.observe(el);
101
+ }
102
+ } catch {
103
+ // pos might be out of range during transition — skip
104
+ }
105
+ }
106
+
107
+ observerRef.current = observer;
108
+ return () => { observer.disconnect(); };
109
+ }, [headings, editor, scrollContainerRef]);
110
+
111
+ const scrollToHeading = useCallback(
112
+ (h: Heading) => {
113
+ if (!editor) return;
114
+ try {
115
+ const { node } = editor.view.domAtPos(h.pos + 1);
116
+ const el: HTMLElement | null =
117
+ node.nodeType === Node.ELEMENT_NODE
118
+ ? (node as HTMLElement).closest("h1,h2,h3,h4,h5,h6") ?? (node as HTMLElement)
119
+ : (node as Node).parentElement?.closest("h1,h2,h3,h4,h5,h6") ?? null;
120
+ el?.scrollIntoView({ behavior: "smooth", block: "start" });
121
+ } catch {
122
+ // ignore
123
+ }
124
+ },
125
+ [editor],
126
+ );
127
+
128
+ const showToc = headings.length >= 2;
129
+ const [overlayOpen, setOverlayOpen] = useState(false);
130
+
131
+ const headingList = (
132
+ <>
133
+ {headings.map((h, i) => (
134
+ <button
135
+ key={`${h.uid}-${i}`}
136
+ onClick={() => {
137
+ scrollToHeading(h);
138
+ setOverlayOpen(false);
139
+ }}
140
+ title={h.text}
141
+ className={cn(
142
+ "text-left text-[10.5px] leading-snug py-0.5 rounded truncate transition-colors",
143
+ activeUid === h.uid
144
+ ? "text-primary bg-primary/10 font-medium"
145
+ : "text-muted-foreground/60 hover:text-foreground hover:bg-accent",
146
+ )}
147
+ style={{ paddingLeft: `${(h.level - 1) * 8 + 4}px` }}
148
+ >
149
+ {h.text}
150
+ </button>
151
+ ))}
152
+ </>
153
+ );
154
+
155
+ return (
156
+ <>
157
+ {/* Reading-progress bar — thin line at very top of editor area */}
158
+ <div
159
+ aria-hidden
160
+ className="absolute top-0 left-0 right-0 h-0.5 z-30 pointer-events-none"
161
+ >
162
+ <div
163
+ className="h-full bg-primary/35 transition-[width] duration-100 ease-out"
164
+ style={{ width: `${scrollProgress * 100}%` }}
165
+ />
166
+ </div>
167
+
168
+ {/* TOC rail — xl+ screens (enough side gutter to avoid content overlap) */}
169
+ {showToc && (
170
+ <div className="absolute right-1 top-4 z-20 hidden xl:block w-40">
171
+ <button
172
+ onClick={() => setCollapsed((c) => !c)}
173
+ className="flex items-center gap-1 mb-1.5 px-1 py-0.5 rounded text-muted-foreground/40 hover:text-muted-foreground hover:bg-accent transition-colors text-[10px]"
174
+ aria-label={collapsed ? "Expand outline" : "Collapse outline"}
175
+ >
176
+ <List className="h-3 w-3" />
177
+ {!collapsed && <span>Outline</span>}
178
+ </button>
179
+ {!collapsed && (
180
+ <nav
181
+ aria-label="Document outline"
182
+ className="flex flex-col gap-px max-h-[50vh] overflow-y-auto pr-1"
183
+ >
184
+ {headingList}
185
+ </nav>
186
+ )}
187
+ </div>
188
+ )}
189
+
190
+ {/* Floating toggle + overlay — below xl, where there's no room for a rail */}
191
+ {showToc && (
192
+ <div className="absolute right-2 top-2 z-30 xl:hidden">
193
+ <button
194
+ onClick={() => setOverlayOpen((o) => !o)}
195
+ className="flex items-center gap-1 px-1.5 py-1 rounded bg-background/80 backdrop-blur border border-border/60 text-muted-foreground/60 hover:text-foreground hover:bg-accent transition-colors text-[10px] shadow-sm"
196
+ aria-label="Document outline"
197
+ aria-expanded={overlayOpen}
198
+ >
199
+ <List className="h-3.5 w-3.5" />
200
+ </button>
201
+ {overlayOpen && (
202
+ <>
203
+ <button
204
+ aria-hidden
205
+ tabIndex={-1}
206
+ className="fixed inset-0 z-0 cursor-default"
207
+ onClick={() => setOverlayOpen(false)}
208
+ />
209
+ <nav
210
+ aria-label="Document outline"
211
+ className="absolute right-0 top-9 z-10 w-52 max-h-[60vh] overflow-y-auto flex flex-col gap-px rounded-lg border border-border bg-popover p-2 shadow-lg"
212
+ >
213
+ {headingList}
214
+ </nav>
215
+ </>
216
+ )}
217
+ </div>
218
+ )}
219
+ </>
220
+ );
221
+ }