@karimsa/mdxserve 0.0.0-stage → 0.1.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 +115 -5
  94. package/skills/mdxserve/SKILL.md +178 -0
@@ -0,0 +1,29 @@
1
+ // TS 7.0.2's DOM lib doesn't (yet) declare the File System Access API this
2
+ // module uses (client/export-save.ts). Only what's actually used is declared
3
+ // here — trim or extend this file if `yarn typecheck` disagrees.
4
+
5
+ interface SaveFilePickerOptions {
6
+ suggestedName?: string;
7
+ types?: { description?: string; accept: Record<string, string[]> }[];
8
+ }
9
+
10
+ interface FileSystemWritableFileStream {
11
+ write(data: Blob | BufferSource | string): Promise<void>;
12
+ close(): Promise<void>;
13
+ }
14
+
15
+ interface FileSystemFileHandle {
16
+ createWritable(): Promise<FileSystemWritableFileStream>;
17
+ }
18
+
19
+ interface Window {
20
+ showSaveFilePicker?: (options?: SaveFilePickerOptions) => Promise<FileSystemFileHandle>;
21
+ }
22
+
23
+ interface UserActivation {
24
+ readonly isActive: boolean;
25
+ }
26
+
27
+ interface Navigator {
28
+ readonly userActivation?: UserActivation;
29
+ }
@@ -0,0 +1,17 @@
1
+ import { formatDistanceToNow } from "date-fns";
2
+
3
+ export function formatSize(bytes: number): string {
4
+ if (bytes < 1024) return `${bytes} B`;
5
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
6
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
7
+ }
8
+
9
+ export function formatModified(mtime: number): string {
10
+ return formatDistanceToNow(mtime, { addSuffix: true });
11
+ }
12
+
13
+ /** "/Users/karim/foo" -> "~/foo" (best-effort; the client has no direct os.homedir()). */
14
+ export function shortenHome(dir: string): string {
15
+ const match = dir.match(/^\/(?:Users|home)\/[^/]+/);
16
+ return match ? `~${dir.slice(match[0].length)}` : dir;
17
+ }
@@ -0,0 +1,34 @@
1
+ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
2
+
3
+ /** Returns `value`, updated only after it has stayed unchanged for `delayMs`. */
4
+ export function useDebounced<Value>(value: Value, delayMs: number): Value {
5
+ const [debouncedValue, setDebouncedValue] = useState(value);
6
+
7
+ useEffect(() => {
8
+ const handle = setTimeout(() => setDebouncedValue(value), delayMs);
9
+ return () => clearTimeout(handle);
10
+ }, [value, delayMs]);
11
+
12
+ return debouncedValue;
13
+ }
14
+
15
+ /**
16
+ * Whether the viewport matches a CSS media query, kept live as the window is
17
+ * resized. The SSR pass has no viewport, so it reports `serverDefault`; the
18
+ * store swaps in the real answer on hydration without a mismatch.
19
+ */
20
+ export function useMediaQuery(query: string, serverDefault = false): boolean {
21
+ const subscribe = useCallback(
22
+ (onChange: () => void) => {
23
+ const list = window.matchMedia(query);
24
+ list.addEventListener("change", onChange);
25
+ return () => list.removeEventListener("change", onChange);
26
+ },
27
+ [query],
28
+ );
29
+ return useSyncExternalStore(
30
+ subscribe,
31
+ () => window.matchMedia(query).matches,
32
+ () => serverDefault,
33
+ );
34
+ }
@@ -0,0 +1,9 @@
1
+ // lucide-react ships one .mjs file per icon under dist/esm/icons/ (no .d.ts
2
+ // alongside them) so a deep import like
3
+ // `import ArrowLeft from "lucide-react/dist/esm/icons/arrow-left.mjs"` has no
4
+ // type on its own. This ambient module covers every such deep import with the
5
+ // same shape lucide-react's own named exports use.
6
+ declare module "lucide-react/dist/esm/icons/*" {
7
+ const icon: import("lucide-react").LucideIcon;
8
+ export default icon;
9
+ }
@@ -0,0 +1,32 @@
1
+ import type { ComponentType } from "react";
2
+ // This module (and everything it pulls in — every builtin, transitively) is
3
+ // reachable from the SSR entry (client/ssr-entry.tsx) that validate_doc's
4
+ // render check loads via Vite's SSR module runner, and from
5
+ // client/standalone-entry.tsx's build output. Nothing in this file or its
6
+ // dependency graph may import client/router.ts (it touches `window` and the
7
+ // query cache at module scope). client/api.ts is the one exception:
8
+ // MdSection imports its `trpcClient`, and api.ts is written to evaluate
9
+ // without a `window` for exactly that reason — keep it that way, and keep
10
+ // every package it imports in src/rendering/vite.ts's `ssr.optimizeDeps.include`.
11
+ import { TaskCheckbox } from "./TaskCheckbox";
12
+ import { Figure, Pre } from "./CodeBlock";
13
+ import { H2, H3, H4 } from "./Heading";
14
+ import { Table } from "./Table";
15
+ import { builtinComponents } from "./builtins/index";
16
+
17
+ /**
18
+ * The `MDXProvider` component map shared by every render path that does not
19
+ * need `MdSection` — the browser entry, the SSR entry, and the standalone
20
+ * build entry. client/mdx-components.ts adds `MdSection` on top for the live
21
+ * server's browser entry.
22
+ */
23
+ export const mdxComponentsBase: Record<string, ComponentType<any>> = {
24
+ ...builtinComponents,
25
+ pre: Pre,
26
+ figure: Figure,
27
+ h2: H2,
28
+ h3: H3,
29
+ h4: H4,
30
+ input: TaskCheckbox,
31
+ table: Table,
32
+ };
@@ -0,0 +1,18 @@
1
+ import type { ComponentType } from "react";
2
+ import { MdSection } from "./MdSection";
3
+ import { mdxComponentsBase } from "./mdx-components-base";
4
+
5
+ /**
6
+ * The `MDXProvider` component map shared by both the browser entry
7
+ * (`client/entry.tsx`) and the SSR entry (`client/ssr-entry.tsx`), so the two
8
+ * render paths cannot drift apart.
9
+ */
10
+ export const mdxComponents: Record<string, ComponentType<any>> = {
11
+ ...mdxComponentsBase,
12
+ // The remark-sections compiler plugin (src/rendering/mdx/remark-sections.ts, server-owned)
13
+ // wraps every editable run of top-level markdown nodes in this synthetic
14
+ // element. It is NOT in the base compile options seen by validate_doc's
15
+ // static registry check (src/rendering/mdx/mdx-options.ts), only in the live Vite config
16
+ // — so it must be registered here (SSR-safe) but never listed as a builtin.
17
+ MdSection,
18
+ };
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Shared, framework-free mermaid header detection: locating the line that
3
+ * names a mermaid diagram's type (skipping blank lines, a leading `---`
4
+ * front-matter block, `%%` comments, and multi-line `%%{init: …}%%`
5
+ * directives), and recognising the handful of diagram types that draw
6
+ * charts of data — the kind mdxserve's builtin `<Chart>` component already
7
+ * covers, and renders far better (design tokens, hover, expand).
8
+ *
9
+ * Pure and import-free like `client/mermaid-direction.ts`, so it is safe to
10
+ * ship in the browser bundle. `src/` importing a pure module from `client/`
11
+ * is fine — the AGENTS.md rule against crossing the boundary is
12
+ * one-directional, and `scripts/build-registry.ts` already imports
13
+ * `client/builtins/index.ts` the same way — but this file itself must stay
14
+ * import-free and browser-safe: no `src/`, no Node builtins, no React.
15
+ */
16
+
17
+ export const CHART_DIAGRAM_KEYWORDS = [
18
+ "pie",
19
+ "xychart-beta",
20
+ "quadrantChart",
21
+ "sankey-beta",
22
+ ] as const;
23
+
24
+ export type ChartDiagramKeyword = (typeof CHART_DIAGRAM_KEYWORDS)[number];
25
+
26
+ const CHART_HEADER_PATTERNS: Record<ChartDiagramKeyword, RegExp> = {
27
+ pie: /^pie(?:\s|$)/,
28
+ "xychart-beta": /^xychart-beta(?:\s|$)/,
29
+ quadrantChart: /^quadrantChart(?:\s|$)/,
30
+ "sankey-beta": /^sankey-beta(?:\s|$)/,
31
+ };
32
+
33
+ const CHART_DIAGRAM_ALTERNATIVES: Record<ChartDiagramKeyword, string> = {
34
+ pie: "use a bar chart or a table for part-to-whole",
35
+ "xychart-beta": 'use type="bar" or type="line"',
36
+ quadrantChart: "use a table (one row per item, a column per axis)",
37
+ "sankey-beta": "use a table, or totals as bars",
38
+ };
39
+
40
+ /**
41
+ * Index of the line in `lines` that starts the diagram's content: blanks, a
42
+ * leading `---` front-matter block, `%%` comment lines, and a directive that
43
+ * spans several lines (`%%{init: {\n …\n }}%%`) are all skipped. -1 when the
44
+ * source has no such line (empty, or an unclosed front-matter block).
45
+ */
46
+ export function mermaidHeaderLineIndex(lines: string[]): number {
47
+ let index = 0;
48
+ while (index < lines.length && lines[index]!.trim() === "") index++;
49
+ if (lines[index]?.trim() === "---") {
50
+ const close = lines.findIndex((line, at) => at > index && line.trim() === "---");
51
+ if (close === -1) return -1;
52
+ index = close + 1;
53
+ }
54
+ let inDirective = false;
55
+ for (; index < lines.length; index++) {
56
+ const line = lines[index]!;
57
+ if (inDirective) {
58
+ if (line.includes("}%%")) inDirective = false;
59
+ continue;
60
+ }
61
+ const trimmed = line.trim();
62
+ if (trimmed === "") continue;
63
+ if (trimmed.startsWith("%%")) {
64
+ if (trimmed.startsWith("%%{") && !trimmed.includes("}%%")) inDirective = true;
65
+ continue;
66
+ }
67
+ return index;
68
+ }
69
+ return -1;
70
+ }
71
+
72
+ /**
73
+ * The trimmed header line of a mermaid fence's `source`, or `undefined` when
74
+ * there isn't one (an empty fence, or an unclosed front-matter block).
75
+ */
76
+ export function mermaidHeaderLine(source: string): string | undefined {
77
+ const lines = source.split(/\r?\n/);
78
+ const index = mermaidHeaderLineIndex(lines);
79
+ return index === -1 ? undefined : lines[index]!.trim();
80
+ }
81
+
82
+ /**
83
+ * The chart-diagram keyword a mermaid `source` opens with, or `null` for
84
+ * every other diagram type (flow, sequence, class, state, ER, gantt,
85
+ * timeline, gitGraph, mindmap, journey, C4, …). Matches on a word boundary,
86
+ * so `pieChart`, `xychart`, and `sankey` (near-misses, not the real
87
+ * keywords) never match, and the keyword must be the header line itself —
88
+ * one appearing later in the body does not count.
89
+ */
90
+ export function chartDiagramKeyword(source: string): ChartDiagramKeyword | null {
91
+ const header = mermaidHeaderLine(source);
92
+ if (header === undefined) return null;
93
+ for (const keyword of CHART_DIAGRAM_KEYWORDS) {
94
+ if (CHART_HEADER_PATTERNS[keyword].test(header)) return keyword;
95
+ }
96
+ return null;
97
+ }
98
+
99
+ /**
100
+ * The one steer shown to a reader (in the "don't render here" card) and an
101
+ * agent (in a `validate_doc` diagnostic) for a rejected chart diagram.
102
+ */
103
+ export function chartDiagramMessage(keyword: ChartDiagramKeyword): string {
104
+ return (
105
+ `Mermaid \`${keyword}\` charts do not render in mdxserve. Use the builtin <Chart> ` +
106
+ `component instead (type="bar" | "line" | "area" | "histogram", orientation="horizontal" ` +
107
+ `for horizontal bars) — ${CHART_DIAGRAM_ALTERNATIVES[keyword]}.`
108
+ );
109
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * Read and rewrite the layout direction of a mermaid flowchart without touching
3
+ * anything else in the source. The reader can flip a diagram between top-down
4
+ * and left-right for their own view; the markdown on disk (and the text the
5
+ * copy button hands out) is never changed, so these helpers are the only place
6
+ * that knows how a direction is spelled.
7
+ *
8
+ * Mermaid spells the header as `flowchart LR`, `graph TD`, `flowchart-elk BT`,
9
+ * optionally with a trailing `;`, and treats `TD` as an alias of `TB`. It may be
10
+ * preceded by a `---` front-matter block, `%%` comments, and `%%{init: …}%%`
11
+ * directives, which is why the header is located line by line instead of with
12
+ * a single anchored regex — `mermaidHeaderLineIndex` (`./mermaid-chart.js`)
13
+ * does that scan; this file only checks whether the line it finds is a
14
+ * flowchart header.
15
+ */
16
+
17
+ import { mermaidHeaderLineIndex } from "./mermaid-chart.js";
18
+
19
+ export const FLOW_DIRECTIONS = ["TB", "BT", "LR", "RL"] as const;
20
+
21
+ export type FlowDirection = (typeof FLOW_DIRECTIONS)[number];
22
+
23
+ /**
24
+ * Captures the keyword (1), the whitespace before the direction (2) and the
25
+ * direction (3); the lookahead keeps `graphs` or `flowchartX` from matching.
26
+ */
27
+ const HEADER = /^(\s*(?:flowchart|graph)(?:-elk)?)(?:(\s+)(TB|TD|BT|LR|RL))?(?=[\s;]|$)/;
28
+
29
+ function normalize(direction: string): FlowDirection {
30
+ return direction === "TD" ? "TB" : (direction as FlowDirection);
31
+ }
32
+
33
+ /**
34
+ * Index of the line holding the `flowchart`/`graph` header, or -1 when the
35
+ * source is not a flowchart. `mermaidHeaderLineIndex` does the scan past a
36
+ * leading front-matter block and any comment or directive lines; this only
37
+ * adds the flowchart-specific check on the line it finds.
38
+ */
39
+ function headerLineIndex(lines: string[]): number {
40
+ const index = mermaidHeaderLineIndex(lines);
41
+ return index !== -1 && HEADER.test(lines[index]!) ? index : -1;
42
+ }
43
+
44
+ /**
45
+ * The direction a flowchart lays out in, or null when `source` is some other
46
+ * kind of diagram. A bare `flowchart` header with no direction reads as `TB`,
47
+ * which is what mermaid falls back to.
48
+ */
49
+ export function readFlowchartDirection(source: string): FlowDirection | null {
50
+ const lines = source.split("\n");
51
+ const index = headerLineIndex(lines);
52
+ if (index === -1) return null;
53
+ const match = HEADER.exec(lines[index]!);
54
+ return normalize(match?.[3] ?? "TB");
55
+ }
56
+
57
+ /**
58
+ * `source` with its flowchart laid out in `direction`. Only the header line
59
+ * changes; a source that is not a flowchart, or one already in that direction
60
+ * (`TD` counts as `TB`), comes back unchanged.
61
+ */
62
+ export function setFlowchartDirection(source: string, direction: FlowDirection): string {
63
+ const lines = source.split("\n");
64
+ const index = headerLineIndex(lines);
65
+ if (index === -1) return source;
66
+ const header = lines[index]!;
67
+ const match = HEADER.exec(header);
68
+ if (!match) return source;
69
+ if (normalize(match[3] ?? "TB") === direction) return source;
70
+ const rest = header.slice(match[0].length);
71
+ lines[index] = `${match[1]}${match[2] ?? " "}${direction}${rest}`;
72
+ return lines.join("\n");
73
+ }
@@ -0,0 +1,104 @@
1
+ import type { Transition, Variants } from "framer-motion";
2
+
3
+ /** Shared ease for every subtle motion in the app: a gentle deceleration. */
4
+ export const subtleEase = [0.22, 1, 0.36, 1] as const;
5
+
6
+ export const enterTransition: Transition = { duration: 0.2, ease: subtleEase };
7
+ export const exitTransition: Transition = { duration: 0.15, ease: subtleEase };
8
+ export const fadeSwapTransition: Transition = { duration: 0.12, ease: subtleEase };
9
+
10
+ /**
11
+ * Fade + slight vertical rise. Used for page-level view transitions (App.tsx)
12
+ * and card-level entrances (CodeFrame, Callout).
13
+ */
14
+ export const fadeRise: Variants = {
15
+ initial: { opacity: 0, y: 8 },
16
+ enter: { opacity: 1, y: 0, transition: enterTransition },
17
+ exit: { opacity: 0, y: -6, transition: exitTransition },
18
+ };
19
+
20
+ /** Opacity-only cross-fade for swapping small bits of content in place (labels, toggled views). */
21
+ export const fadeSwap: Variants = {
22
+ initial: { opacity: 0 },
23
+ enter: { opacity: 1, transition: fadeSwapTransition },
24
+ exit: { opacity: 0, transition: fadeSwapTransition },
25
+ };
26
+
27
+ /** Stagger container for groups of items that should enter together (listing rows). */
28
+ export const stagger: Variants = {
29
+ initial: {},
30
+ enter: { transition: { staggerChildren: 0.02 } },
31
+ };
32
+
33
+ /* ── Design-system motion (mirrors client/design-ref/components/motion/MotionKit.jsx
34
+ and the --spring-* / --dur-* tokens in client/design/tokens/motion.css).
35
+ Springs for anything physical, tweens for colour and opacity only. Nothing
36
+ overshoots past 2%, nothing travels more than 10px. ── */
37
+
38
+ export const TRANSITIONS = {
39
+ /** Controls: press, toggle knobs, segmented pills. 1:1 with --spring-snap. */
40
+ snap: { type: "spring", stiffness: 620, damping: 34, mass: 0.7 } satisfies Transition,
41
+ /** Surfaces: dialogs, toasts, disclosure. 1:1 with --spring-glide. */
42
+ glide: { type: "spring", stiffness: 340, damping: 30, mass: 0.9 } satisfies Transition,
43
+ /** Colour and opacity only. */
44
+ fast: { duration: 0.12, ease: [0.2, 0, 0.2, 1] } satisfies Transition,
45
+ base: { duration: 0.18, ease: [0.2, 0, 0.2, 1] } satisfies Transition,
46
+ slow: { duration: 0.26, ease: [0, 0, 0.2, 1] } satisfies Transition,
47
+ };
48
+
49
+ type Entrance = {
50
+ initial: Record<string, number | string>;
51
+ animate: Record<string, number | string>;
52
+ exit?: Record<string, number | string | Transition>;
53
+ transition: Transition;
54
+ };
55
+
56
+ /** Named entrances, so every surface of the same class enters identically. Spread onto a `motion.*`. */
57
+ export const VARIANTS = {
58
+ /** Tooltips, inline swaps: opacity only. */
59
+ fade: {
60
+ initial: { opacity: 0 },
61
+ animate: { opacity: 1 },
62
+ exit: { opacity: 0 },
63
+ transition: TRANSITIONS.base,
64
+ },
65
+ /** Dialogs and the search palette. */
66
+ pop: {
67
+ initial: { opacity: 0, y: 6, scale: 0.985 },
68
+ animate: { opacity: 1, y: 0, scale: 1 },
69
+ exit: { opacity: 0, y: 4, scale: 0.99, transition: TRANSITIONS.fast },
70
+ transition: TRANSITIONS.glide,
71
+ },
72
+ /** Toasts, from the bottom-right stack. */
73
+ slideUp: {
74
+ initial: { opacity: 0, y: 10, scale: 0.98 },
75
+ animate: { opacity: 1, y: 0, scale: 1 },
76
+ exit: { opacity: 0, x: 16, transition: TRANSITIONS.fast },
77
+ transition: TRANSITIONS.glide,
78
+ },
79
+ /** Scrims behind a modal surface. */
80
+ scrim: {
81
+ initial: { opacity: 0 },
82
+ animate: { opacity: 1 },
83
+ exit: { opacity: 0 },
84
+ transition: TRANSITIONS.fast,
85
+ },
86
+ /** Collapsible nav groups and diagram/code swaps. */
87
+ collapse: {
88
+ initial: { opacity: 0, height: 0 },
89
+ animate: { opacity: 1, height: "auto" },
90
+ exit: { opacity: 0, height: 0 },
91
+ transition: TRANSITIONS.glide,
92
+ },
93
+ /** Page and section content, 6px up. */
94
+ rise: {
95
+ initial: { opacity: 0, y: 6 },
96
+ animate: { opacity: 1, y: 0 },
97
+ transition: TRANSITIONS.glide,
98
+ },
99
+ } satisfies Record<string, Entrance>;
100
+
101
+ /** Stagger a list of children by 40ms each (search results cap at 5). */
102
+ export function staggerBy(delay = 0.04): Variants {
103
+ return { animate: { transition: { staggerChildren: delay } } };
104
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Whether the user is on an Apple platform, for keyboard hints (⌘ vs Ctrl).
3
+ * Shared by TopBar's search hint and the section editor's save/cancel hints.
4
+ * `navigator.platform` is deprecated but still the one signal every browser
5
+ * exposes synchronously; guard it so the SSR render worker can import this.
6
+ */
7
+ export function isApplePlatform(): boolean {
8
+ return typeof navigator !== "undefined" && /Mac|iPhone|iPod|iPad/.test(navigator.platform);
9
+ }
10
+
11
+ /**
12
+ * The viewport width at which the shell docks the sidebar instead of using a
13
+ * drawer — Tailwind's `md`. Anything narrower is treated as a phone-sized
14
+ * screen by every component that adapts its layout.
15
+ */
16
+ export const DESKTOP_MEDIA = "(min-width: 768px)";
@@ -0,0 +1,15 @@
1
+ /**
2
+ * A pathname straight from `URL.pathname` / `location.pathname` is
3
+ * percent-encoded (`/docs/My%20Page.md`), while the server embeds the decoded
4
+ * form in the initial route and every API takes decoded filesystem paths.
5
+ * Routes created on the client go through this so `route.path` is always the
6
+ * decoded form. A stray `%` that is not a valid escape is kept as-is rather
7
+ * than thrown on.
8
+ */
9
+ export function decodeRoutePath(pathname: string): string {
10
+ try {
11
+ return decodeURIComponent(pathname);
12
+ } catch {
13
+ return pathname;
14
+ }
15
+ }