@honeydeck/honeydeck 0.4.0 → 0.6.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 (71) hide show
  1. package/AGENTS.md +4 -4
  2. package/DEVELOPMENT.md +6 -4
  3. package/Readme.md +15 -15
  4. package/SPEC.md +5 -4
  5. package/docs/browser-frame.md +38 -0
  6. package/docs/components.md +16 -57
  7. package/docs/configuration.md +13 -0
  8. package/docs/customization.md +2 -0
  9. package/docs/deeper-dive.md +32 -7
  10. package/docs/getting-started.md +4 -2
  11. package/docs/index.json +258 -0
  12. package/docs/keyboard.md +35 -0
  13. package/docs/list-style.md +53 -0
  14. package/docs/local-development.md +3 -1
  15. package/docs/mermaid.md +2 -0
  16. package/docs/mobile.md +2 -0
  17. package/docs/navigation.md +3 -1
  18. package/docs/notes.md +40 -0
  19. package/docs/pdf-export.md +6 -2
  20. package/docs/presenter-mode.md +8 -3
  21. package/docs/reveal-group.md +60 -0
  22. package/docs/reveal-with.md +39 -0
  23. package/docs/reveal.md +35 -0
  24. package/docs/skills.md +5 -3
  25. package/docs/slides.md +2 -0
  26. package/docs/slidev-migration.md +5 -0
  27. package/docs/steps-and-reveals.md +145 -8
  28. package/docs/timeline-steps.md +50 -0
  29. package/package.json +6 -2
  30. package/skills/SPEC.md +6 -6
  31. package/skills/honeydeck/SKILL.md +9 -9
  32. package/skills/slidev-migration/SKILL.md +7 -6
  33. package/src/SPEC.md +8 -3
  34. package/src/cli/SPEC.md +3 -2
  35. package/src/cli/pdf.ts +11 -4
  36. package/src/remark/SPEC.md +102 -2
  37. package/src/remark/code-utils.ts +151 -0
  38. package/src/remark/shiki-code-blocks.ts +329 -136
  39. package/src/remark/step-numbering.ts +408 -103
  40. package/src/runtime/Deck.tsx +133 -116
  41. package/src/runtime/EffectiveColorModeContext.tsx +37 -0
  42. package/src/runtime/SPEC.md +21 -8
  43. package/src/runtime/SlideCanvas.tsx +19 -16
  44. package/src/runtime/SlideScaleContext.tsx +23 -0
  45. package/src/runtime/components/CodeBlock.tsx +19 -202
  46. package/src/runtime/components/CodeBlockCopyButton.tsx +64 -0
  47. package/src/runtime/components/CodeBlockShared.ts +17 -0
  48. package/src/runtime/components/Fade.tsx +51 -0
  49. package/src/runtime/components/FadeGroup.tsx +175 -0
  50. package/src/runtime/components/FadeWith.tsx +54 -0
  51. package/src/runtime/components/MagicCodeBlock.tsx +223 -0
  52. package/src/runtime/components/NavBar.tsx +1 -1
  53. package/src/runtime/components/NormalCodeBlock.tsx +128 -0
  54. package/src/runtime/components/Reveal.tsx +27 -27
  55. package/src/runtime/components/RevealGroup.tsx +143 -41
  56. package/src/runtime/components/RevealWith.tsx +63 -0
  57. package/src/runtime/components/SPEC.md +115 -10
  58. package/src/runtime/components/TimelineReveal.tsx +81 -0
  59. package/src/runtime/components/index.ts +13 -5
  60. package/src/runtime/components/timelineVisibility.ts +45 -0
  61. package/src/runtime/index.ts +9 -1
  62. package/src/runtime/navigation.ts +6 -4
  63. package/src/runtime/presentationApi.ts +449 -0
  64. package/src/runtime/views/PresenterCastButton.tsx +39 -0
  65. package/src/runtime/views/PresenterView.tsx +21 -4
  66. package/src/runtime/views/SPEC.md +7 -5
  67. package/src/theme/base.css +67 -2
  68. package/src/vite-plugin/SPEC.md +20 -2
  69. package/src/vite-plugin/index.ts +16 -2
  70. package/src/vite-plugin/splitter.ts +1 -0
  71. package/src/vite-plugin/virtual-modules.ts +16 -6
@@ -0,0 +1,223 @@
1
+ import type { KeyedTokensInfo } from "@shikijs/magic-move/core";
2
+ import { ShikiMagicMovePrecompiled } from "@shikijs/magic-move/react";
3
+ import { useEffect, useMemo, useState } from "react";
4
+ import { useEffectiveColorMode } from "../EffectiveColorModeContext.tsx";
5
+ import { useSlideScale } from "../SlideScaleContext.tsx";
6
+ import { useTimeline } from "../TimelineContext.tsx";
7
+ import { CodeBlock } from "./CodeBlock.tsx";
8
+ import { parseJsonProp, type StepGroup } from "./CodeBlockShared.ts";
9
+
10
+ type HoneydeckMagicCodeBlockProps = {
11
+ /** JSON-encoded unique build-time Shiki Magic Move token states for light mode. */
12
+ lightTokenStatesJson: string;
13
+ /** JSON-encoded unique build-time Shiki Magic Move token states for dark mode. */
14
+ darkTokenStatesJson: string;
15
+ /** JSON-encoded indexes from Magic Code timeline states to unique token states. */
16
+ tokenStateIndexesJson: string;
17
+ /** JSON-encoded StepGroup[] aligned to the Magic Code timeline states. */
18
+ stepGroupsJson: string;
19
+ /** JSON-encoded source strings aligned to the unique token states. */
20
+ sourcesJson: string;
21
+ /** 1-based timeline step where Magic Code state 1 activates. */
22
+ startAt: number;
23
+ /** Magic Move animation duration in milliseconds. */
24
+ duration: number;
25
+ };
26
+
27
+ type MagicToken = KeyedTokensInfo["tokens"][number];
28
+
29
+ export function getActiveCodeStateIndex(
30
+ stateCount: number,
31
+ stepIndex: number,
32
+ startAt: number,
33
+ ): number {
34
+ if (stateCount <= 1) return 0;
35
+ if (startAt > 0 && stepIndex >= startAt) {
36
+ return Math.min(stepIndex - startAt + 1, stateCount - 1);
37
+ }
38
+ return 0;
39
+ }
40
+
41
+ const MAGIC_CODE_TOKEN_OPACITY_VAR = "--honeydeck-magic-code-token-opacity";
42
+
43
+ function withMagicCodeTokenOpacity(
44
+ token: MagicToken,
45
+ opacity: string,
46
+ ): MagicToken {
47
+ return {
48
+ ...token,
49
+ htmlStyle: {
50
+ ...token.htmlStyle,
51
+ [MAGIC_CODE_TOKEN_OPACITY_VAR]: opacity,
52
+ },
53
+ };
54
+ }
55
+
56
+ export function applyMagicCodeDimming(
57
+ step: KeyedTokensInfo,
58
+ group: StepGroup | undefined,
59
+ ): KeyedTokensInfo {
60
+ let lineNumber = 1;
61
+ return {
62
+ ...step,
63
+ tokens: step.tokens.map((token) => {
64
+ if (token.content === "\n") {
65
+ lineNumber++;
66
+ return token;
67
+ }
68
+
69
+ const opacity =
70
+ !group || group === "all" || group.includes(lineNumber)
71
+ ? "1"
72
+ : "var(--honeydeck-code-line-dim-opacity)";
73
+ return withMagicCodeTokenOpacity(token, opacity);
74
+ }),
75
+ };
76
+ }
77
+
78
+ function applyMagicCodeStepDimming(
79
+ steps: KeyedTokensInfo[],
80
+ groups: StepGroup[],
81
+ ): KeyedTokensInfo[] {
82
+ return steps.map((step, index) => applyMagicCodeDimming(step, groups[index]));
83
+ }
84
+
85
+ export function isPdfExportRender(): boolean {
86
+ if (typeof window === "undefined") return false;
87
+
88
+ const params = new URLSearchParams(window.location.search);
89
+ return params.has("honeydeckPdfRender");
90
+ }
91
+
92
+ export function getMagicCodeTransitionOptions(
93
+ duration: number,
94
+ slideScale: number,
95
+ animate = true,
96
+ ) {
97
+ return {
98
+ duration,
99
+ lineNumbers: false,
100
+ animateContainer: animate,
101
+ easing: "ease-in",
102
+ delayMove: 0,
103
+ delayEnter: 0,
104
+ delayLeave: 0,
105
+ delayContainer: 0,
106
+ // Honeydeck scales slides with CSS transforms; Shiki needs that scale so
107
+ // measured viewport pixels map back to slide-local CSS pixels.
108
+ globalScale: slideScale > 0 ? slideScale : 1,
109
+ };
110
+ }
111
+
112
+ /** Renders a build-time precompiled Shiki Magic Move code block. */
113
+ export function HoneydeckMagicCodeBlock({
114
+ lightTokenStatesJson,
115
+ darkTokenStatesJson,
116
+ tokenStateIndexesJson,
117
+ stepGroupsJson,
118
+ sourcesJson,
119
+ startAt,
120
+ duration,
121
+ }: HoneydeckMagicCodeBlockProps) {
122
+ const { stepIndex } = useTimeline();
123
+ const slideScale = useSlideScale();
124
+ const colorMode = useEffectiveColorMode();
125
+ const isPdfExport = isPdfExportRender();
126
+ const [isBaselineReady, setIsBaselineReady] = useState(isPdfExport);
127
+
128
+ useEffect(() => {
129
+ if (isPdfExport) {
130
+ setIsBaselineReady(true);
131
+ return;
132
+ }
133
+
134
+ let firstFrame = 0;
135
+ let secondFrame = 0;
136
+ firstFrame = window.requestAnimationFrame(() => {
137
+ secondFrame = window.requestAnimationFrame(() =>
138
+ setIsBaselineReady(true),
139
+ );
140
+ });
141
+
142
+ return () => {
143
+ window.cancelAnimationFrame(firstFrame);
144
+ window.cancelAnimationFrame(secondFrame);
145
+ };
146
+ }, [isPdfExport]);
147
+
148
+ const tokenStateIndexes = useMemo(
149
+ () =>
150
+ parseJsonProp<number[]>(
151
+ tokenStateIndexesJson,
152
+ [],
153
+ "tokenStateIndexesJson",
154
+ ),
155
+ [tokenStateIndexesJson],
156
+ );
157
+ const stepGroups = useMemo(
158
+ () => parseJsonProp<StepGroup[]>(stepGroupsJson, [], "stepGroupsJson"),
159
+ [stepGroupsJson],
160
+ );
161
+ const sources = useMemo(
162
+ () => parseJsonProp<string[]>(sourcesJson, [], "sourcesJson"),
163
+ [sourcesJson],
164
+ );
165
+ const tokenStates = useMemo(
166
+ () =>
167
+ parseJsonProp<KeyedTokensInfo[]>(
168
+ colorMode === "dark" ? darkTokenStatesJson : lightTokenStatesJson,
169
+ [],
170
+ colorMode === "dark" ? "darkTokenStatesJson" : "lightTokenStatesJson",
171
+ ),
172
+ [colorMode, darkTokenStatesJson, lightTokenStatesJson],
173
+ );
174
+
175
+ const rawSteps = useMemo(
176
+ () =>
177
+ tokenStateIndexes.flatMap((index) => {
178
+ const state = tokenStates[index];
179
+ return state ? [state] : [];
180
+ }),
181
+ [tokenStateIndexes, tokenStates],
182
+ );
183
+ const dimmedSteps = useMemo(
184
+ () => applyMagicCodeStepDimming(rawSteps, stepGroups),
185
+ [rawSteps, stepGroups],
186
+ );
187
+ const activeStateIndex = getActiveCodeStateIndex(
188
+ dimmedSteps.length,
189
+ stepIndex,
190
+ startAt,
191
+ );
192
+ const [renderedStateIndex, setRenderedStateIndex] =
193
+ useState(activeStateIndex);
194
+
195
+ useEffect(() => {
196
+ if (!isBaselineReady) return;
197
+ setRenderedStateIndex(activeStateIndex);
198
+ }, [activeStateIndex, isBaselineReady]);
199
+
200
+ const visibleStateIndex = Math.min(
201
+ renderedStateIndex,
202
+ Math.max(0, dimmedSteps.length - 1),
203
+ );
204
+ const source =
205
+ sources[tokenStateIndexes[visibleStateIndex] ?? visibleStateIndex];
206
+ const transitionOptions = useMemo(
207
+ () => getMagicCodeTransitionOptions(duration, slideScale, !isPdfExport),
208
+ [duration, isPdfExport, slideScale],
209
+ );
210
+
211
+ if (dimmedSteps.length === 0) return null;
212
+
213
+ return (
214
+ <CodeBlock source={source} className="honeydeck-magic-code-block">
215
+ <ShikiMagicMovePrecompiled
216
+ steps={dimmedSteps}
217
+ step={visibleStateIndex}
218
+ animate={!isPdfExport}
219
+ options={transitionOptions}
220
+ />
221
+ </CodeBlock>
222
+ );
223
+ }
@@ -13,7 +13,7 @@
13
13
  * - Overview grid toggle
14
14
  * - Layouts reference
15
15
  * - Docs website (opens new window)
16
- * - Presenter mode (opens new window)
16
+ * - Presenter mode (opens current tab)
17
17
  * - Fullscreen toggle
18
18
  * - Mobile slide text selection toggle
19
19
  * - Color mode cycle (system → light → dark → system)
@@ -0,0 +1,128 @@
1
+ import { useMemo } from "react";
2
+ import { useTimeline } from "../TimelineContext.tsx";
3
+ import { CodeBlock } from "./CodeBlock.tsx";
4
+ import { parseJsonProp, type StepGroup } from "./CodeBlockShared.ts";
5
+
6
+ type HoneydeckCodeBlockProps = {
7
+ /** Full shiki HTML output (includes the `<pre>` element). */
8
+ html: string;
9
+ /** JSON-encoded StepGroup[] — empty array = no step-through. */
10
+ stepsJson: string;
11
+ /** 1-based timeline step where group 1 activates (0 = no step-through). */
12
+ startAt: number;
13
+ /** Original fenced code text copied by the hover/focus copy control. */
14
+ source?: string;
15
+ };
16
+
17
+ const DATA_DIM_ATTR = /\sdata-dim=(["'])1\1/g;
18
+ const DATA_HIGHLIGHT_ATTR = /\sdata-highlight=(["'])1\1/g;
19
+ const LINE_SPAN = /<span\b([^>]*)>/g;
20
+ const LINE_CLASS = /\bclass=(["'])[^"']*\bline\b[^"']*\1/;
21
+ const DATA_LINE = /\bdata-line=(["'])(\d+)\1/;
22
+ const STYLE_ATTR = /\sstyle=(["'])(.*?)\1/g;
23
+ const DIM_STYLE_DECL = "opacity: var(--honeydeck-code-line-dim-opacity);";
24
+
25
+ function removeDimStyle(html: string): string {
26
+ return html.replace(STYLE_ATTR, (match, quote: string, style: string) => {
27
+ if (!style.includes(DIM_STYLE_DECL)) return match;
28
+
29
+ const cleanedStyle = style
30
+ .replace(DIM_STYLE_DECL, "")
31
+ .replace(/\s{2,}/g, " ")
32
+ .trim();
33
+
34
+ return cleanedStyle ? ` style=${quote}${cleanedStyle}${quote}` : "";
35
+ });
36
+ }
37
+
38
+ function addHighlightAttributes(attrs: string): string {
39
+ return `${attrs} data-highlight="1"`;
40
+ }
41
+
42
+ function addDimAttributes(attrs: string): string {
43
+ if (attrs.includes(DIM_STYLE_DECL)) return `${attrs} data-dim="1"`;
44
+
45
+ const withStyle = attrs.replace(
46
+ STYLE_ATTR,
47
+ (_match, quote: string, style: string) =>
48
+ ` style=${quote}${style.trim()}${style.trim().endsWith(";") ? "" : ";"} ${DIM_STYLE_DECL}${quote}`,
49
+ );
50
+
51
+ if (withStyle !== attrs) return `${withStyle} data-dim="1"`;
52
+
53
+ return `${attrs} data-dim="1" style="${DIM_STYLE_DECL}"`;
54
+ }
55
+
56
+ export function applyCodeStepDimming(
57
+ html: string,
58
+ steps: StepGroup[],
59
+ stepIndex: number,
60
+ startAt: number,
61
+ ): string {
62
+ const cleanHtml = removeDimStyle(
63
+ html.replace(DATA_DIM_ATTR, "").replace(DATA_HIGHLIGHT_ATTR, ""),
64
+ );
65
+
66
+ if (steps.length === 0) return cleanHtml;
67
+
68
+ let activeGroupIndex = 0;
69
+ if (startAt > 0 && stepIndex >= startAt) {
70
+ activeGroupIndex = Math.min(stepIndex - startAt + 1, steps.length - 1);
71
+ }
72
+
73
+ const activeGroup = steps[activeGroupIndex];
74
+ if (!activeGroup || activeGroup === "all") return cleanHtml;
75
+
76
+ return cleanHtml.replace(LINE_SPAN, (match, attrs: string) => {
77
+ if (!LINE_CLASS.test(attrs)) return match;
78
+
79
+ const dataLine = attrs.match(DATA_LINE);
80
+ if (!dataLine) return match;
81
+
82
+ const lineNumber = parseInt(dataLine[2] ?? "", 10);
83
+ if (activeGroup.includes(lineNumber)) {
84
+ return `<span${addHighlightAttributes(attrs)}>`;
85
+ }
86
+
87
+ return `<span${addDimAttributes(attrs)}>`;
88
+ });
89
+ }
90
+
91
+ /**
92
+ * Renders a pre-highlighted code block and applies timeline-driven line
93
+ * dimming for step-through walkthroughs.
94
+ *
95
+ * Export name matches the identifier injected by `remarkShikiCodeBlocks`:
96
+ * `import { HoneydeckCodeBlock } from '@honeydeck/honeydeck/components/code-block/normal'`
97
+ */
98
+ export function HoneydeckCodeBlock({
99
+ html,
100
+ stepsJson,
101
+ startAt,
102
+ source,
103
+ }: HoneydeckCodeBlockProps) {
104
+ const { stepIndex } = useTimeline();
105
+
106
+ // Parse step groups once (stepsJson is static — set at compile time)
107
+ const steps = useMemo(
108
+ () => parseJsonProp<StepGroup[]>(stepsJson, []),
109
+ [stepsJson],
110
+ );
111
+
112
+ const dimmedHtml = useMemo(
113
+ () => applyCodeStepDimming(html, steps, stepIndex, startAt),
114
+ [html, startAt, stepIndex, steps],
115
+ );
116
+
117
+ return (
118
+ <CodeBlock
119
+ source={source}
120
+ className="[&_.line]:transition-opacity [&_.line]:duration-150 [&_.line]:ease-in"
121
+ >
122
+ <div
123
+ // biome-ignore lint/security/noDangerouslySetInnerHtml: Shiki generates this highlighted HTML during MDX compilation.
124
+ dangerouslySetInnerHTML={{ __html: dimmedHtml }}
125
+ />
126
+ </CodeBlock>
127
+ );
128
+ }
@@ -1,5 +1,8 @@
1
- import type { CSSProperties, ReactNode } from "react";
2
- import { useTimeline } from "../TimelineContext.tsx";
1
+ import type { ReactNode } from "react";
2
+ import {
3
+ TimelineReveal,
4
+ type TimelineRevealElement,
5
+ } from "./TimelineReveal.tsx";
3
6
 
4
7
  // ---------------------------------------------------------------------------
5
8
  // Types
@@ -15,9 +18,13 @@ export type RevealProps = {
15
18
  * Wrapper element. Injected by the compiler from MDX context:
16
19
  * flow/block reveals use `div`, text/inline reveals use `span`.
17
20
  */
18
- as?: "div" | "span";
21
+ as?: TimelineRevealElement;
22
+ /** Slide-local target name for <RevealWith target="..."> synchronization. */
23
+ name?: string;
19
24
  /** Additional CSS class for custom transition overrides. */
20
25
  className?: string;
26
+ /** Remove hidden content from the DOM/layout instead of reserving space. */
27
+ ephemeral?: boolean;
21
28
  children?: ReactNode;
22
29
  };
23
30
 
@@ -30,7 +37,8 @@ export type RevealProps = {
30
37
  *
31
38
  * Content appears when the slide's current step reaches `at`. Before that it
32
39
  * is invisible while still occupying layout space, so reveals do not cause
33
- * nearby content to jump around.
40
+ * nearby content to jump around. With `ephemeral`, hidden content is not
41
+ * rendered and does not reserve layout space.
34
42
  *
35
43
  * Reveals are cumulative: once visible, they stay visible as the presenter
36
44
  * advances. The default transition is a simple opacity fade.
@@ -40,43 +48,35 @@ export type RevealProps = {
40
48
  *
41
49
  * Visible from the start.
42
50
  *
43
- * <Reveal>This appears at step 1.</Reveal>
51
+ * <Reveal name="intro">This appears at step 1.</Reveal>
44
52
  *
45
53
  * <Reveal>This appears at step 2.</Reveal>
46
54
  * ```
47
55
  *
48
56
  * Honeydeck normally injects `at` during MDX compilation. It also injects `as`
49
57
  * from the MDX context so block reveals render as `div` and inline reveals
50
- * render as `span`.
58
+ * render as `span`. Optional `name` values are slide-local targets for
59
+ * `<RevealWith target="...">`.
51
60
  */
52
61
  export function Reveal({
53
- as: Component = "div",
62
+ as = "div",
54
63
  at = 1,
64
+ name,
55
65
  className = "",
66
+ ephemeral = false,
56
67
  children,
57
68
  }: RevealProps) {
58
- const { stepIndex, showFutureSteps, futureStepOpacity } = useTimeline();
59
- const visible = stepIndex >= at;
60
- const previewFuture = !visible && showFutureSteps;
61
-
62
- const style: CSSProperties = {
63
- display: Component === "span" ? "inline" : "block",
64
- visibility: visible || previewFuture ? "visible" : "hidden",
65
- opacity: visible ? 1 : previewFuture ? futureStepOpacity : 0,
66
- transition: "opacity 300ms ease",
67
- };
68
-
69
69
  return (
70
- <Component
71
- className={[
72
- "honeydeck-reveal mb-[0.75em] text-[length:var(--honeydeck-font-size-body)] leading-[1.6] [&>:last-child]:mb-0",
73
- className,
74
- ]
75
- .filter(Boolean)
76
- .join(" ")}
77
- style={style}
70
+ <TimelineReveal
71
+ as={as}
72
+ at={at}
73
+ className={className}
74
+ ephemeral={ephemeral}
75
+ dataAttributes={{
76
+ "data-honeydeck-reveal-id": name,
77
+ }}
78
78
  >
79
79
  {children}
80
- </Component>
80
+ </TimelineReveal>
81
81
  );
82
82
  }