@honeydeck/honeydeck 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 (144) hide show
  1. package/AGENTS.md +25 -0
  2. package/DEVELOPMENT.md +522 -0
  3. package/LICENSE +21 -0
  4. package/Readme.md +49 -0
  5. package/SPEC.md +88 -0
  6. package/docs/components.md +63 -0
  7. package/docs/configuration.md +91 -0
  8. package/docs/getting-started.md +116 -0
  9. package/docs/kit-authoring.md +207 -0
  10. package/docs/kits.md +387 -0
  11. package/docs/local-development.md +95 -0
  12. package/docs/mermaid.md +198 -0
  13. package/docs/mobile.md +108 -0
  14. package/docs/navigation.md +93 -0
  15. package/docs/next-steps.md +377 -0
  16. package/docs/pdf-export.md +91 -0
  17. package/docs/presenter-mode.md +104 -0
  18. package/docs/slides.md +130 -0
  19. package/docs/slidev-migration.md +42 -0
  20. package/docs/steps-and-reveals.md +171 -0
  21. package/package.json +134 -0
  22. package/skills/SPEC.md +21 -0
  23. package/skills/honeydeck/SKILL.md +65 -0
  24. package/skills/presentation-writing/SKILL.md +75 -0
  25. package/skills/slidev-migration/SKILL.md +153 -0
  26. package/src/SPEC.md +89 -0
  27. package/src/assets.d.ts +30 -0
  28. package/src/cli/SPEC.md +230 -0
  29. package/src/cli/args.ts +3 -0
  30. package/src/cli/banner.ts +9 -0
  31. package/src/cli/bin.js +5 -0
  32. package/src/cli/build.ts +229 -0
  33. package/src/cli/deck-path.ts +32 -0
  34. package/src/cli/dev.ts +263 -0
  35. package/src/cli/index.ts +126 -0
  36. package/src/cli/init.ts +369 -0
  37. package/src/cli/pdf.ts +923 -0
  38. package/src/cli/skill.ts +75 -0
  39. package/src/cli/templates/SPEC.md +70 -0
  40. package/src/cli/templates/deck-mdx.ts +15 -0
  41. package/src/cli/templates/package-json.ts +36 -0
  42. package/src/cli/templates/sparkle-button.ts +15 -0
  43. package/src/cli/templates/starter/components/SparkleButton.tsx +84 -0
  44. package/src/cli/templates/starter/deck.mdx +153 -0
  45. package/src/cli/templates/starter/styles.css +14 -0
  46. package/src/cli/templates/styles-css.ts +14 -0
  47. package/src/defaults.ts +1 -0
  48. package/src/layouts/ColorModeImage.tsx +55 -0
  49. package/src/layouts/SPEC.md +393 -0
  50. package/src/layouts/SlideFrame.tsx +48 -0
  51. package/src/layouts/bee/Blank.tsx +12 -0
  52. package/src/layouts/bee/Cover.tsx +70 -0
  53. package/src/layouts/bee/Default.tsx +42 -0
  54. package/src/layouts/bee/Image/Image.tsx +151 -0
  55. package/src/layouts/bee/Image/placeholder-dark.webp +0 -0
  56. package/src/layouts/bee/Image/placeholder-vertical-dark.webp +0 -0
  57. package/src/layouts/bee/Image/placeholder-vertical.webp +0 -0
  58. package/src/layouts/bee/Image/placeholder.webp +0 -0
  59. package/src/layouts/bee/ImageLeft.tsx +27 -0
  60. package/src/layouts/bee/ImageRight.tsx +27 -0
  61. package/src/layouts/bee/ImageSide.tsx +107 -0
  62. package/src/layouts/bee/Section.tsx +40 -0
  63. package/src/layouts/bee/TwoCol.tsx +108 -0
  64. package/src/layouts/bee/index.ts +40 -0
  65. package/src/layouts/clean/Blank.tsx +12 -0
  66. package/src/layouts/clean/Cover.tsx +58 -0
  67. package/src/layouts/clean/Default.tsx +33 -0
  68. package/src/layouts/clean/Image/Image.tsx +103 -0
  69. package/src/layouts/clean/ImageLeft.tsx +27 -0
  70. package/src/layouts/clean/ImageRight.tsx +27 -0
  71. package/src/layouts/clean/ImageSide.tsx +113 -0
  72. package/src/layouts/clean/Section.tsx +35 -0
  73. package/src/layouts/clean/TwoCol.tsx +63 -0
  74. package/src/layouts/clean/index.ts +40 -0
  75. package/src/layouts/index.ts +60 -0
  76. package/src/layouts/placeholders.ts +9 -0
  77. package/src/layouts/utils.ts +13 -0
  78. package/src/remark/SPEC.md +49 -0
  79. package/src/remark/h1-extract.ts +124 -0
  80. package/src/remark/index.ts +4 -0
  81. package/src/remark/shiki-code-blocks.ts +325 -0
  82. package/src/remark/step-numbering.ts +412 -0
  83. package/src/runtime/Deck.tsx +533 -0
  84. package/src/runtime/SPEC.md +256 -0
  85. package/src/runtime/SlideCanvas.tsx +95 -0
  86. package/src/runtime/TimelineContext.tsx +122 -0
  87. package/src/runtime/app-shell/index.html +31 -0
  88. package/src/runtime/app-shell/main.tsx +42 -0
  89. package/src/runtime/aspectRatio.ts +34 -0
  90. package/src/runtime/colorMode.ts +23 -0
  91. package/src/runtime/components/BrowserFrame.tsx +233 -0
  92. package/src/runtime/components/Button.tsx +57 -0
  93. package/src/runtime/components/CodeBlock.tsx +210 -0
  94. package/src/runtime/components/ColorModeCycleButton.tsx +59 -0
  95. package/src/runtime/components/ErrorBoundary.tsx +125 -0
  96. package/src/runtime/components/Keyboard.tsx +87 -0
  97. package/src/runtime/components/ListStyle.tsx +203 -0
  98. package/src/runtime/components/NavBar.tsx +223 -0
  99. package/src/runtime/components/NavBarButton.tsx +47 -0
  100. package/src/runtime/components/NavBarDivider.tsx +3 -0
  101. package/src/runtime/components/Notes.tsx +171 -0
  102. package/src/runtime/components/Reveal.tsx +82 -0
  103. package/src/runtime/components/RevealGroup.tsx +193 -0
  104. package/src/runtime/components/SPEC.md +263 -0
  105. package/src/runtime/components/SlideNumberBadge.tsx +11 -0
  106. package/src/runtime/components/TimelineSteps.tsx +115 -0
  107. package/src/runtime/components/index.ts +55 -0
  108. package/src/runtime/index.ts +42 -0
  109. package/src/runtime/inputOwnership.ts +68 -0
  110. package/src/runtime/keyboardTarget.ts +7 -0
  111. package/src/runtime/lastSlideRoute.ts +56 -0
  112. package/src/runtime/navigation.ts +211 -0
  113. package/src/runtime/router.ts +157 -0
  114. package/src/runtime/slideData.ts +137 -0
  115. package/src/runtime/sync.ts +267 -0
  116. package/src/runtime/types.ts +182 -0
  117. package/src/runtime/useKeyboardNav.ts +138 -0
  118. package/src/runtime/useSwipeNav.ts +257 -0
  119. package/src/runtime/views/DocsView.tsx +74 -0
  120. package/src/runtime/views/OverviewView.tsx +386 -0
  121. package/src/runtime/views/PresenterNotesPanel.tsx +76 -0
  122. package/src/runtime/views/PresenterView.tsx +340 -0
  123. package/src/runtime/views/SPEC.md +152 -0
  124. package/src/runtime/views/docs/ComponentsTab.tsx +178 -0
  125. package/src/runtime/views/docs/DocsHeader.tsx +101 -0
  126. package/src/runtime/views/docs/Intro.tsx +20 -0
  127. package/src/runtime/views/docs/LayoutsTab.tsx +324 -0
  128. package/src/runtime/views/docs/ThemeTab.tsx +110 -0
  129. package/src/runtime/views/index.ts +7 -0
  130. package/src/runtime/views/overviewGrid.ts +106 -0
  131. package/src/runtime/views/presenterPreview.ts +27 -0
  132. package/src/runtime/virtual-modules.d.ts +98 -0
  133. package/src/theme/SPEC.md +179 -0
  134. package/src/theme/base.css +623 -0
  135. package/src/theme/bee.css +35 -0
  136. package/src/theme/clean.css +38 -0
  137. package/src/vite-plugin/SPEC.md +114 -0
  138. package/src/vite-plugin/component-doc-crawler.ts +350 -0
  139. package/src/vite-plugin/deck-loader.ts +148 -0
  140. package/src/vite-plugin/index.ts +373 -0
  141. package/src/vite-plugin/layout-demo-crawler.ts +802 -0
  142. package/src/vite-plugin/splitter.ts +353 -0
  143. package/src/vite-plugin/token-manifest.ts +163 -0
  144. package/src/vite-plugin/virtual-modules.ts +587 -0
@@ -0,0 +1,233 @@
1
+ import {
2
+ type CSSProperties,
3
+ type IframeHTMLAttributes,
4
+ type ReactNode,
5
+ useState,
6
+ } from "react";
7
+
8
+ type IframeProps = Omit<
9
+ IframeHTMLAttributes<HTMLIFrameElement>,
10
+ "className" | "height" | "src" | "style"
11
+ >;
12
+
13
+ export type BrowserFrameProps = IframeProps & {
14
+ /**
15
+ * URL loaded by the iframe.
16
+ *
17
+ * This should be a URL the browser can load from the presented deck, such as
18
+ * an external `https://` URL or a local Vite-served route like `/demo.html`.
19
+ */
20
+ src: string;
21
+ /**
22
+ * Optional content shown in the address-bar field.
23
+ *
24
+ * Pass a short string such as `example.com` for the common case, or pass any
25
+ * React node when a richer label is needed. Omit this prop to hide the
26
+ * address-bar field while keeping the browser chrome.
27
+ */
28
+ addressBar?: ReactNode;
29
+ /**
30
+ * Light/default screenshot shown when iframe loading fails or fallback mode is toggled on.
31
+ *
32
+ * Use this to keep a talk reliable when a live site is offline, blocked by
33
+ * iframe headers, or not available during PDF export.
34
+ */
35
+ fallbackImage?: string;
36
+ /**
37
+ * Dark-mode screenshot shown when fallback mode is active.
38
+ *
39
+ * When omitted, `fallbackImage` is used for every color mode.
40
+ */
41
+ fallbackDarkImage?: string;
42
+ /**
43
+ * Accessible alt text for the fallback image.
44
+ *
45
+ * Defaults to `Fallback preview`.
46
+ */
47
+ fallbackAlt?: string;
48
+ /**
49
+ * Start in fallback mode instead of loading the iframe.
50
+ *
51
+ * Useful for deterministic demos, final-state screenshots, and PDF-friendly
52
+ * decks where the static preview is preferred by default.
53
+ */
54
+ defaultFallback?: boolean;
55
+ /**
56
+ * Aspect ratio of the full browser window, including chrome. Defaults to `16 / 9`.
57
+ *
58
+ * Accepts the same values as React's `style.aspectRatio`, for example `16 / 9`,
59
+ * `"4 / 3"`, or `1.6`.
60
+ */
61
+ aspectRatio?: CSSProperties["aspectRatio"];
62
+ /** Additional CSS class for the outer browser frame. */
63
+ className?: string;
64
+ /**
65
+ * Additional CSS class for the iframe element.
66
+ *
67
+ * Only applied while live iframe content is rendered. It is not applied to the
68
+ * fallback image container.
69
+ */
70
+ iframeClassName?: string;
71
+ };
72
+
73
+ function cn(...classes: (string | undefined | false)[]): string | undefined {
74
+ const value = classes.filter(Boolean).join(" ");
75
+ return value || undefined;
76
+ }
77
+
78
+ function frameStyle(
79
+ aspectRatio: CSSProperties["aspectRatio"] | undefined,
80
+ ): CSSProperties {
81
+ return {
82
+ "--honeydeck-browser-frame-aspect-ratio": aspectRatio ?? "16 / 9",
83
+ } as CSSProperties;
84
+ }
85
+
86
+ /**
87
+ * Displays an iframe inside a macOS-style browser window frame.
88
+ *
89
+ * The frame uses CSS to size itself to the largest rectangle that fits its parent
90
+ * while preserving `aspectRatio`. It renders macOS-style traffic-light controls,
91
+ * optional address-bar content, and a live iframe. When `fallbackImage` is
92
+ * provided, iframe load errors switch to a static screenshot; presenters can
93
+ * also toggle that fallback with the extra chrome control that appears on hover
94
+ * or keyboard focus.
95
+ *
96
+ * Standard iframe attributes such as `allow`, `sandbox`, `loading`, and
97
+ * `referrerPolicy` are forwarded to the live iframe.
98
+ *
99
+ * ```mdx
100
+ * import { BrowserFrame } from '@honeydeck/honeydeck'
101
+ *
102
+ * <BrowserFrame
103
+ * src="https://example.com"
104
+ * addressBar="example.com"
105
+ * fallbackImage="/example-light.png"
106
+ * fallbackDarkImage="/example-dark.png"
107
+ * />
108
+ * ```
109
+ */
110
+ export function BrowserFrame({
111
+ src,
112
+ addressBar,
113
+ fallbackImage,
114
+ fallbackDarkImage,
115
+ fallbackAlt,
116
+ defaultFallback = false,
117
+ aspectRatio,
118
+ className,
119
+ iframeClassName,
120
+ onError,
121
+ ...iframeProps
122
+ }: BrowserFrameProps) {
123
+ const hasFallback = Boolean(fallbackImage);
124
+ const [showFallback, setShowFallback] = useState(
125
+ defaultFallback && hasFallback,
126
+ );
127
+ const fallbackActive = hasFallback && showFallback;
128
+ const fallbackLabel = "Fallback preview";
129
+ const iframeTitle =
130
+ iframeProps.title ??
131
+ (typeof addressBar === "string" ? addressBar : "Embedded page");
132
+ const alt = fallbackAlt ?? fallbackLabel;
133
+
134
+ return (
135
+ <div className="grid h-full min-h-0 w-full place-items-center [container-type:size]">
136
+ <div
137
+ className={cn(
138
+ "group mx-auto flex aspect-[var(--honeydeck-browser-frame-aspect-ratio)] w-[min(100cqw,calc(100cqh*var(--honeydeck-browser-frame-aspect-ratio)))] max-w-full flex-col self-center overflow-hidden rounded-[calc(var(--honeydeck-border-radius)*2)] border border-border bg-surface font-body text-surface-foreground shadow-[0_2px_6px_color-mix(in_srgb,var(--honeydeck-foreground)_10%,transparent)]",
139
+ className,
140
+ )}
141
+ data-honeydeck-browser-frame=""
142
+ data-fallback={fallbackActive ? "true" : undefined}
143
+ style={frameStyle(aspectRatio)}
144
+ >
145
+ <div
146
+ className="flex items-center gap-[0.55em] border-border border-b bg-[color-mix(in_srgb,var(--honeydeck-surface)_88%,var(--honeydeck-background))] px-[0.55em] py-[0.42em]"
147
+ data-honeydeck-browser-frame-chrome=""
148
+ >
149
+ <div className="flex items-center gap-[0.22em]">
150
+ <span
151
+ className="h-[0.36em] w-[0.36em] rounded-full border border-[color-mix(in_srgb,var(--honeydeck-foreground)_18%,transparent)] bg-[#ff5f57]"
152
+ aria-hidden="true"
153
+ />
154
+ <span
155
+ className="h-[0.36em] w-[0.36em] rounded-full border border-[color-mix(in_srgb,var(--honeydeck-foreground)_18%,transparent)] bg-[#febc2e]"
156
+ aria-hidden="true"
157
+ />
158
+ <span
159
+ className="h-[0.36em] w-[0.36em] rounded-full border border-[color-mix(in_srgb,var(--honeydeck-foreground)_18%,transparent)] bg-[#28c840]"
160
+ aria-hidden="true"
161
+ />
162
+ {hasFallback && (
163
+ <button
164
+ type="button"
165
+ className="h-[0.36em] w-[0.36em] cursor-pointer appearance-none rounded-full border border-[color-mix(in_srgb,var(--honeydeck-foreground)_18%,transparent)] bg-accent p-0 opacity-0 transition-opacity duration-150 hover:opacity-100 focus-visible:opacity-100 focus-visible:outline-2 focus-visible:outline-accent focus-visible:outline-offset-2"
166
+ data-honeydeck-browser-frame-toggle=""
167
+ aria-label={
168
+ fallbackActive
169
+ ? "Show live browser content"
170
+ : "Show fallback preview"
171
+ }
172
+ aria-pressed={fallbackActive}
173
+ onClick={() => setShowFallback((value) => !value)}
174
+ />
175
+ )}
176
+ </div>
177
+ {addressBar !== undefined && (
178
+ <div
179
+ className="min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap rounded-honeydeck border border-border bg-background px-[0.7em] py-[0.28em] text-center font-mono text-[0.42em] text-foreground leading-[1.4]"
180
+ data-honeydeck-browser-frame-address=""
181
+ >
182
+ {addressBar}
183
+ </div>
184
+ )}
185
+ {fallbackActive && (
186
+ <span
187
+ className="ml-auto inline-flex shrink-0 items-center rounded-honeydeck border border-border bg-background px-[0.7em] py-[0.28em] font-mono font-semibold text-[0.42em] text-foreground leading-[1.4]"
188
+ data-honeydeck-browser-frame-badge=""
189
+ >
190
+ {fallbackLabel}
191
+ </span>
192
+ )}
193
+ </div>
194
+ <div className="relative min-h-0 flex-1 overflow-hidden bg-background">
195
+ {fallbackActive ? (
196
+ <div className="block h-full w-full border-0 bg-background">
197
+ <img
198
+ className={cn(
199
+ "block h-full w-full object-cover",
200
+ fallbackDarkImage &&
201
+ "[[data-honeydeck-color-mode=dark]_&]:hidden",
202
+ )}
203
+ src={fallbackImage}
204
+ alt={alt}
205
+ />
206
+ {fallbackDarkImage && (
207
+ <img
208
+ className="hidden h-full w-full object-cover [[data-honeydeck-color-mode=dark]_&]:block"
209
+ src={fallbackDarkImage}
210
+ alt={alt}
211
+ />
212
+ )}
213
+ </div>
214
+ ) : (
215
+ <iframe
216
+ {...iframeProps}
217
+ className={cn(
218
+ "block h-full w-full border-0 bg-background",
219
+ iframeClassName,
220
+ )}
221
+ src={src}
222
+ title={iframeTitle}
223
+ onError={(event) => {
224
+ if (hasFallback) setShowFallback(true);
225
+ onError?.(event);
226
+ }}
227
+ />
228
+ )}
229
+ </div>
230
+ </div>
231
+ </div>
232
+ );
233
+ }
@@ -0,0 +1,57 @@
1
+ import type { ComponentPropsWithoutRef } from "react";
2
+
3
+ export const transitionClass =
4
+ "transition-[background-color,border-color,color,box-shadow] duration-150";
5
+ export const hoverBorderClass =
6
+ "hover:border-[color:color-mix(in_oklab,var(--honeydeck-primary)_48%,var(--honeydeck-border))]";
7
+ export const surfaceControlClass = `border border-[color:var(--honeydeck-border)] bg-[color-mix(in_oklab,var(--honeydeck-surface)_86%,transparent)] text-[color:var(--honeydeck-foreground)] ${hoverBorderClass}`;
8
+
9
+ const buttonBaseClass = `inline-flex items-center justify-center gap-2.5 rounded-lg px-4 py-3 font-black no-underline ${transitionClass}`;
10
+
11
+ export const buttonPrimaryClass = `${buttonBaseClass} border border-[color:color-mix(in_oklab,#000_10%,var(--honeydeck-primary))] bg-[color:var(--honeydeck-primary)] text-[color:var(--honeydeck-primary-foreground)] shadow-[0_14px_30px_color-mix(in_oklab,var(--honeydeck-primary)_26%,transparent)] ${hoverBorderClass}`;
12
+ export const buttonSecondaryClass = `${buttonBaseClass} ${surfaceControlClass}`;
13
+ export const iconButtonClass = `inline-flex min-h-[2.35rem] min-w-[2.35rem] items-center justify-center gap-1.5 rounded-lg px-3 py-2 no-underline ${surfaceControlClass} ${transitionClass}`;
14
+ export const smallButtonClass = `inline-flex min-h-[2.35rem] flex-1 basis-48 items-center justify-center gap-1.5 whitespace-nowrap rounded-lg px-3 py-2 no-underline md:flex-none ${surfaceControlClass} ${transitionClass}`;
15
+ export const quietLinkClass = `inline-flex items-center gap-1.5 rounded-lg border border-[color:var(--honeydeck-border)] px-3 py-2 text-[color:color-mix(in_oklab,var(--honeydeck-surface-foreground)_70%,var(--honeydeck-background))] no-underline hover:text-[color:var(--honeydeck-foreground)] ${hoverBorderClass} ${transitionClass}`;
16
+
17
+ export type ButtonVariant =
18
+ | "primary"
19
+ | "secondary"
20
+ | "icon"
21
+ | "small"
22
+ | "quiet";
23
+
24
+ const buttonClassByVariant: Record<ButtonVariant, string> = {
25
+ primary: buttonPrimaryClass,
26
+ secondary: buttonSecondaryClass,
27
+ icon: iconButtonClass,
28
+ small: smallButtonClass,
29
+ quiet: quietLinkClass,
30
+ };
31
+
32
+ export function buttonClass(
33
+ variant: ButtonVariant = "secondary",
34
+ className?: string,
35
+ ) {
36
+ const baseClass = buttonClassByVariant[variant];
37
+ return className ? `${baseClass} ${className}` : baseClass;
38
+ }
39
+
40
+ export type ButtonProps = ComponentPropsWithoutRef<"button"> & {
41
+ variant?: ButtonVariant;
42
+ };
43
+
44
+ export function Button({
45
+ variant = "secondary",
46
+ className,
47
+ type = "button",
48
+ ...buttonProps
49
+ }: ButtonProps) {
50
+ return (
51
+ <button
52
+ {...buttonProps}
53
+ type={type}
54
+ className={buttonClass(variant, className)}
55
+ />
56
+ );
57
+ }
@@ -0,0 +1,210 @@
1
+ /**
2
+ * HoneydeckCodeBlock — runtime code block component with syntax highlighting
3
+ * and timeline-driven step-through.
4
+ *
5
+ * ### Usage
6
+ * This component is **not imported directly by slide authors**. The
7
+ * `remarkShikiCodeBlocks` remark plugin automatically replaces fenced code
8
+ * blocks in MDX with `<HoneydeckCodeBlock>` elements at compile time.
9
+ *
10
+ * ### Props
11
+ * - `html` — Pre-highlighted HTML string from shiki (full `<pre>…</pre>`).
12
+ * - `stepsJson` — JSON-encoded `StepGroup[]` (e.g. `[[2],[4,5],"all"]`).
13
+ * Empty array = no step-through.
14
+ * - `startAt` — 1-based timeline step at which group 1 activates.
15
+ * Group 0 is the baseline active highlight; 0 for non-stepped blocks.
16
+ * - `source` — Original fenced code text for clipboard copying.
17
+ *
18
+ * ### Step-through mechanics
19
+ * The component reads `stepIndex` from `useTimeline()` and computes the active
20
+ * group index: group 0 before `startAt`, then later groups from `startAt`.
21
+ * It then adds `data-dim="1"` and an inline opacity style to non-highlighted
22
+ * `.line` spans in the rendered HTML.
23
+ * Base CSS also targets the attribute as a fallback.
24
+ *
25
+ * The highlighted HTML is injected via `dangerouslySetInnerHTML`, so the
26
+ * step-specific `data-dim` attributes are computed as part of the HTML string
27
+ * before React writes it to the DOM.
28
+ */
29
+
30
+ import { CheckIcon, CopyIcon } from "lucide-react";
31
+ import { useMemo, useState } from "react";
32
+ import { useTimeline } from "../TimelineContext.tsx";
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Types
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /** A single step-through group: line numbers (1-based) or 'all'. */
39
+ export type StepGroup = number[] | "all";
40
+
41
+ type HoneydeckCodeBlockProps = {
42
+ /** Full shiki HTML output (includes the `<pre>` element). */
43
+ html: string;
44
+ /** JSON-encoded StepGroup[] — empty array = no step-through. */
45
+ stepsJson: string;
46
+ /** 1-based timeline step where group 1 activates (0 = no step-through). */
47
+ startAt: number;
48
+ /** Original fenced code text copied by the hover/focus copy control. */
49
+ source?: string;
50
+ };
51
+
52
+ const DATA_DIM_ATTR = /\sdata-dim=(["'])1\1/g;
53
+ const LINE_SPAN = /<span\b([^>]*)>/g;
54
+ const LINE_CLASS = /\bclass=(["'])[^"']*\bline\b[^"']*\1/;
55
+ const DATA_LINE = /\bdata-line=(["'])(\d+)\1/;
56
+ const STYLE_ATTR = /\sstyle=(["'])(.*?)\1/g;
57
+ const DIM_STYLE_DECL = "opacity: var(--honeydeck-code-line-dim-opacity);";
58
+
59
+ async function writeClipboardText(text: string): Promise<boolean> {
60
+ try {
61
+ if (navigator.clipboard) {
62
+ await navigator.clipboard.writeText(text);
63
+ return true;
64
+ }
65
+ } catch {
66
+ // Fall back to the textarea path below.
67
+ }
68
+
69
+ try {
70
+ const textarea = document.createElement("textarea");
71
+ textarea.value = text;
72
+ textarea.setAttribute("readonly", "");
73
+ textarea.style.position = "fixed";
74
+ textarea.style.inset = "0";
75
+ textarea.style.opacity = "0";
76
+ document.body.appendChild(textarea);
77
+ textarea.select();
78
+ const didCopy = document.execCommand("copy");
79
+ document.body.removeChild(textarea);
80
+ return didCopy;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+
86
+ function removeDimStyle(html: string): string {
87
+ return html.replace(STYLE_ATTR, (match, quote: string, style: string) => {
88
+ if (!style.includes(DIM_STYLE_DECL)) return match;
89
+
90
+ const cleanedStyle = style
91
+ .replace(DIM_STYLE_DECL, "")
92
+ .replace(/\s{2,}/g, " ")
93
+ .trim();
94
+
95
+ return cleanedStyle ? ` style=${quote}${cleanedStyle}${quote}` : "";
96
+ });
97
+ }
98
+
99
+ function addDimAttributes(attrs: string): string {
100
+ if (attrs.includes(DIM_STYLE_DECL)) return `${attrs} data-dim="1"`;
101
+
102
+ const withStyle = attrs.replace(
103
+ STYLE_ATTR,
104
+ (_match, quote: string, style: string) =>
105
+ ` style=${quote}${style.trim()}${style.trim().endsWith(";") ? "" : ";"} ${DIM_STYLE_DECL}${quote}`,
106
+ );
107
+
108
+ if (withStyle !== attrs) return `${withStyle} data-dim="1"`;
109
+
110
+ return `${attrs} data-dim="1" style="${DIM_STYLE_DECL}"`;
111
+ }
112
+
113
+ export function applyCodeStepDimming(
114
+ html: string,
115
+ steps: StepGroup[],
116
+ stepIndex: number,
117
+ startAt: number,
118
+ ): string {
119
+ const cleanHtml = removeDimStyle(html.replace(DATA_DIM_ATTR, ""));
120
+
121
+ if (steps.length === 0) return cleanHtml;
122
+
123
+ let activeGroupIndex = 0;
124
+ if (startAt > 0 && stepIndex >= startAt) {
125
+ activeGroupIndex = Math.min(stepIndex - startAt + 1, steps.length - 1);
126
+ }
127
+
128
+ const activeGroup = steps[activeGroupIndex];
129
+ if (!activeGroup || activeGroup === "all") return cleanHtml;
130
+
131
+ return cleanHtml.replace(LINE_SPAN, (match, attrs: string) => {
132
+ if (!LINE_CLASS.test(attrs)) return match;
133
+
134
+ const dataLine = attrs.match(DATA_LINE);
135
+ if (!dataLine) return match;
136
+
137
+ const lineNumber = parseInt(dataLine[2] ?? "", 10);
138
+ if (activeGroup.includes(lineNumber)) return match;
139
+
140
+ return `<span${addDimAttributes(attrs)}>`;
141
+ });
142
+ }
143
+
144
+ // ---------------------------------------------------------------------------
145
+ // Component
146
+ // ---------------------------------------------------------------------------
147
+
148
+ /**
149
+ * Renders a pre-highlighted code block and applies timeline-driven line
150
+ * dimming for step-through walkthroughs.
151
+ *
152
+ * Export name matches the identifier injected by `remarkShikiCodeBlocks`:
153
+ * `import { HoneydeckCodeBlock } from '@honeydeck/honeydeck/components/code-block'`
154
+ */
155
+ export function HoneydeckCodeBlock({
156
+ html,
157
+ stepsJson,
158
+ startAt,
159
+ source,
160
+ }: HoneydeckCodeBlockProps) {
161
+ const { stepIndex } = useTimeline();
162
+ const [copied, setCopied] = useState(false);
163
+
164
+ // Parse step groups once (stepsJson is static — set at compile time)
165
+ const steps = useMemo((): StepGroup[] => {
166
+ try {
167
+ return JSON.parse(stepsJson) as StepGroup[];
168
+ } catch {
169
+ return [];
170
+ }
171
+ }, [stepsJson]);
172
+
173
+ const dimmedHtml = useMemo(
174
+ () => applyCodeStepDimming(html, steps, stepIndex, startAt),
175
+ [html, startAt, stepIndex, steps],
176
+ );
177
+
178
+ async function copySource() {
179
+ if (!source) return;
180
+
181
+ if (await writeClipboardText(source)) {
182
+ setCopied(true);
183
+ window.setTimeout(() => setCopied(false), 1600);
184
+ }
185
+ }
186
+
187
+ return (
188
+ <div className="honeydeck-code-block group relative mb-[0.75em] overflow-hidden rounded-honeydeck font-mono text-[length:var(--honeydeck-font-size-code)] [&_.line]:transition-opacity [&_.line]:duration-150 [&_.line]:ease-in">
189
+ {source ? (
190
+ <button
191
+ className="honeydeck-code-copy-button"
192
+ type="button"
193
+ aria-label={copied ? "Code copied" : "Copy code"}
194
+ title={copied ? "Copied" : "Copy code"}
195
+ onClick={copySource}
196
+ >
197
+ {copied ? (
198
+ <CheckIcon aria-hidden="true" />
199
+ ) : (
200
+ <CopyIcon aria-hidden="true" />
201
+ )}
202
+ </button>
203
+ ) : null}
204
+ <div
205
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: Shiki generates this highlighted HTML during MDX compilation.
206
+ dangerouslySetInnerHTML={{ __html: dimmedHtml }}
207
+ />
208
+ </div>
209
+ );
210
+ }
@@ -0,0 +1,59 @@
1
+ import { type LucideIcon, MonitorIcon, MoonIcon, SunIcon } from "lucide-react";
2
+ import type { ComponentPropsWithoutRef } from "react";
3
+
4
+ export const COLOR_MODES = ["system", "light", "dark"] as const;
5
+
6
+ export type ColorMode = (typeof COLOR_MODES)[number];
7
+
8
+ const COLOR_MODE_LABEL: Record<ColorMode, string> = {
9
+ system: "System",
10
+ light: "Light",
11
+ dark: "Dark",
12
+ };
13
+
14
+ const COLOR_MODE_ICON: Record<ColorMode, LucideIcon> = {
15
+ system: MonitorIcon,
16
+ light: SunIcon,
17
+ dark: MoonIcon,
18
+ };
19
+
20
+ export function getNextColorMode(mode: ColorMode): ColorMode {
21
+ if (mode === "system") return "light";
22
+ if (mode === "light") return "dark";
23
+ return "system";
24
+ }
25
+
26
+ export type ColorModeCycleButtonProps = Omit<
27
+ ComponentPropsWithoutRef<"button">,
28
+ "aria-label" | "children" | "onClick"
29
+ > & {
30
+ colorMode: ColorMode;
31
+ onSetColorMode: (mode: ColorMode) => void;
32
+ iconSize?: number;
33
+ ariaLabel?: string;
34
+ };
35
+
36
+ export function ColorModeCycleButton({
37
+ colorMode,
38
+ onSetColorMode,
39
+ iconSize = 16,
40
+ type = "button",
41
+ title,
42
+ ariaLabel,
43
+ ...buttonProps
44
+ }: ColorModeCycleButtonProps) {
45
+ const ColorModeIcon = COLOR_MODE_ICON[colorMode];
46
+ const label = COLOR_MODE_LABEL[colorMode];
47
+
48
+ return (
49
+ <button
50
+ {...buttonProps}
51
+ type={type}
52
+ onClick={() => onSetColorMode(getNextColorMode(colorMode))}
53
+ title={title ?? `Color mode: ${label} - click to cycle`}
54
+ aria-label={ariaLabel ?? `Color mode: ${label} - click to cycle`}
55
+ >
56
+ <ColorModeIcon aria-hidden="true" size={iconSize} />
57
+ </button>
58
+ );
59
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * ErrorBoundary — per-slide React error boundary.
3
+ *
4
+ * Wraps each slide in Deck.tsx so a runtime error in one slide doesn't crash
5
+ * the entire presentation. Other slides remain fully navigable.
6
+ *
7
+ * ### Dev mode
8
+ * Shows the error message and stack trace rendered inside the slide canvas
9
+ * area so it's visible without opening DevTools.
10
+ *
11
+ * ### Production mode
12
+ * Shows a minimal "Something went wrong" message with the slide number.
13
+ *
14
+ * ### Usage
15
+ * ```tsx
16
+ * <ErrorBoundary slideNumber={i + 1}>
17
+ * <SlideContents />
18
+ * </ErrorBoundary>
19
+ * ```
20
+ */
21
+
22
+ import { AlertTriangleIcon, BombIcon } from "lucide-react";
23
+ import { Component, type ErrorInfo, type ReactNode } from "react";
24
+
25
+ // ---------------------------------------------------------------------------
26
+ // Types
27
+ // ---------------------------------------------------------------------------
28
+
29
+ type Props = {
30
+ /** 1-based slide number — shown in the fallback UI. */
31
+ slideNumber: number;
32
+ children: ReactNode;
33
+ };
34
+
35
+ type State = {
36
+ hasError: boolean;
37
+ error: Error | null;
38
+ componentStack: string;
39
+ };
40
+
41
+ // ---------------------------------------------------------------------------
42
+ // Component
43
+ // ---------------------------------------------------------------------------
44
+
45
+ export class ErrorBoundary extends Component<Props, State> {
46
+ constructor(props: Props) {
47
+ super(props);
48
+ this.state = { hasError: false, error: null, componentStack: "" };
49
+ }
50
+
51
+ static getDerivedStateFromError(error: Error): State {
52
+ return { hasError: true, error, componentStack: "" };
53
+ }
54
+
55
+ override componentDidCatch(error: Error, info: ErrorInfo): void {
56
+ console.error(
57
+ `[honeydeck] ❌ Error on slide ${this.props.slideNumber}:`,
58
+ error,
59
+ info.componentStack,
60
+ );
61
+ this.setState({ componentStack: info.componentStack ?? "" });
62
+ }
63
+
64
+ override render(): ReactNode {
65
+ if (!this.state.hasError) {
66
+ return this.props.children;
67
+ }
68
+
69
+ const { slideNumber } = this.props;
70
+ const { error, componentStack } = this.state;
71
+
72
+ // Check if we're in dev mode via Vite's injected env
73
+ const isDev =
74
+ typeof import.meta !== "undefined" &&
75
+ (import.meta as { env?: { DEV?: boolean } }).env?.DEV === true;
76
+
77
+ if (isDev) {
78
+ return (
79
+ <div className="w-full h-full flex flex-col bg-error-surface text-error font-mono p-8 overflow-auto box-border">
80
+ {/* Header */}
81
+ <div className="flex items-center gap-3 mb-5 shrink-0">
82
+ <BombIcon aria-hidden="true" className="shrink-0" size={48} />
83
+ <div>
84
+ <div className="text-4xl font-bold text-error">
85
+ Error on slide {slideNumber}
86
+ </div>
87
+ <div className="text-md text-error/60 mt-1">
88
+ Other slides are still navigable (← →)
89
+ </div>
90
+ </div>
91
+ </div>
92
+
93
+ {/* Error message */}
94
+ <div className="bg-error/8 border border-error/30 rounded-md p-4 mb-4 shrink-0">
95
+ <div className="text-2xl font-semibold mb-2">
96
+ {error?.name ?? "Error"}: {error?.message}
97
+ </div>
98
+ </div>
99
+
100
+ {/* Stack trace */}
101
+ {(error?.stack || componentStack) && (
102
+ <pre className="bg-black/40 border border-white/8 rounded-md p-4 text-base leading-relaxed overflow-auto text-red-200/80 m-0 shrink-0">
103
+ {error?.stack ?? ""}
104
+ {componentStack && (
105
+ <>
106
+ {"\n\nComponent stack:"}
107
+ {componentStack}
108
+ </>
109
+ )}
110
+ </pre>
111
+ )}
112
+ </div>
113
+ );
114
+ }
115
+
116
+ // Production fallback
117
+ return (
118
+ <div className="w-full h-full flex flex-col items-center justify-center bg-void text-white/60 font-sans gap-3">
119
+ <AlertTriangleIcon aria-hidden="true" size={64} />
120
+ <div className="text-3xl font-semibold">Something went wrong</div>
121
+ <div className="text-md">Slide {slideNumber}</div>
122
+ </div>
123
+ );
124
+ }
125
+ }