@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.
- package/AGENTS.md +25 -0
- package/DEVELOPMENT.md +522 -0
- package/LICENSE +21 -0
- package/Readme.md +49 -0
- package/SPEC.md +88 -0
- package/docs/components.md +63 -0
- package/docs/configuration.md +91 -0
- package/docs/getting-started.md +116 -0
- package/docs/kit-authoring.md +207 -0
- package/docs/kits.md +387 -0
- package/docs/local-development.md +95 -0
- package/docs/mermaid.md +198 -0
- package/docs/mobile.md +108 -0
- package/docs/navigation.md +93 -0
- package/docs/next-steps.md +377 -0
- package/docs/pdf-export.md +91 -0
- package/docs/presenter-mode.md +104 -0
- package/docs/slides.md +130 -0
- package/docs/slidev-migration.md +42 -0
- package/docs/steps-and-reveals.md +171 -0
- package/package.json +134 -0
- package/skills/SPEC.md +21 -0
- package/skills/honeydeck/SKILL.md +65 -0
- package/skills/presentation-writing/SKILL.md +75 -0
- package/skills/slidev-migration/SKILL.md +153 -0
- package/src/SPEC.md +89 -0
- package/src/assets.d.ts +30 -0
- package/src/cli/SPEC.md +230 -0
- package/src/cli/args.ts +3 -0
- package/src/cli/banner.ts +9 -0
- package/src/cli/bin.js +5 -0
- package/src/cli/build.ts +229 -0
- package/src/cli/deck-path.ts +32 -0
- package/src/cli/dev.ts +263 -0
- package/src/cli/index.ts +126 -0
- package/src/cli/init.ts +369 -0
- package/src/cli/pdf.ts +923 -0
- package/src/cli/skill.ts +75 -0
- package/src/cli/templates/SPEC.md +70 -0
- package/src/cli/templates/deck-mdx.ts +15 -0
- package/src/cli/templates/package-json.ts +36 -0
- package/src/cli/templates/sparkle-button.ts +15 -0
- package/src/cli/templates/starter/components/SparkleButton.tsx +84 -0
- package/src/cli/templates/starter/deck.mdx +153 -0
- package/src/cli/templates/starter/styles.css +14 -0
- package/src/cli/templates/styles-css.ts +14 -0
- package/src/defaults.ts +1 -0
- package/src/layouts/ColorModeImage.tsx +55 -0
- package/src/layouts/SPEC.md +393 -0
- package/src/layouts/SlideFrame.tsx +48 -0
- package/src/layouts/bee/Blank.tsx +12 -0
- package/src/layouts/bee/Cover.tsx +70 -0
- package/src/layouts/bee/Default.tsx +42 -0
- package/src/layouts/bee/Image/Image.tsx +151 -0
- package/src/layouts/bee/Image/placeholder-dark.webp +0 -0
- package/src/layouts/bee/Image/placeholder-vertical-dark.webp +0 -0
- package/src/layouts/bee/Image/placeholder-vertical.webp +0 -0
- package/src/layouts/bee/Image/placeholder.webp +0 -0
- package/src/layouts/bee/ImageLeft.tsx +27 -0
- package/src/layouts/bee/ImageRight.tsx +27 -0
- package/src/layouts/bee/ImageSide.tsx +107 -0
- package/src/layouts/bee/Section.tsx +40 -0
- package/src/layouts/bee/TwoCol.tsx +108 -0
- package/src/layouts/bee/index.ts +40 -0
- package/src/layouts/clean/Blank.tsx +12 -0
- package/src/layouts/clean/Cover.tsx +58 -0
- package/src/layouts/clean/Default.tsx +33 -0
- package/src/layouts/clean/Image/Image.tsx +103 -0
- package/src/layouts/clean/ImageLeft.tsx +27 -0
- package/src/layouts/clean/ImageRight.tsx +27 -0
- package/src/layouts/clean/ImageSide.tsx +113 -0
- package/src/layouts/clean/Section.tsx +35 -0
- package/src/layouts/clean/TwoCol.tsx +63 -0
- package/src/layouts/clean/index.ts +40 -0
- package/src/layouts/index.ts +60 -0
- package/src/layouts/placeholders.ts +9 -0
- package/src/layouts/utils.ts +13 -0
- package/src/remark/SPEC.md +49 -0
- package/src/remark/h1-extract.ts +124 -0
- package/src/remark/index.ts +4 -0
- package/src/remark/shiki-code-blocks.ts +325 -0
- package/src/remark/step-numbering.ts +412 -0
- package/src/runtime/Deck.tsx +533 -0
- package/src/runtime/SPEC.md +256 -0
- package/src/runtime/SlideCanvas.tsx +95 -0
- package/src/runtime/TimelineContext.tsx +122 -0
- package/src/runtime/app-shell/index.html +31 -0
- package/src/runtime/app-shell/main.tsx +42 -0
- package/src/runtime/aspectRatio.ts +34 -0
- package/src/runtime/colorMode.ts +23 -0
- package/src/runtime/components/BrowserFrame.tsx +233 -0
- package/src/runtime/components/Button.tsx +57 -0
- package/src/runtime/components/CodeBlock.tsx +210 -0
- package/src/runtime/components/ColorModeCycleButton.tsx +59 -0
- package/src/runtime/components/ErrorBoundary.tsx +125 -0
- package/src/runtime/components/Keyboard.tsx +87 -0
- package/src/runtime/components/ListStyle.tsx +203 -0
- package/src/runtime/components/NavBar.tsx +223 -0
- package/src/runtime/components/NavBarButton.tsx +47 -0
- package/src/runtime/components/NavBarDivider.tsx +3 -0
- package/src/runtime/components/Notes.tsx +171 -0
- package/src/runtime/components/Reveal.tsx +82 -0
- package/src/runtime/components/RevealGroup.tsx +193 -0
- package/src/runtime/components/SPEC.md +263 -0
- package/src/runtime/components/SlideNumberBadge.tsx +11 -0
- package/src/runtime/components/TimelineSteps.tsx +115 -0
- package/src/runtime/components/index.ts +55 -0
- package/src/runtime/index.ts +42 -0
- package/src/runtime/inputOwnership.ts +68 -0
- package/src/runtime/keyboardTarget.ts +7 -0
- package/src/runtime/lastSlideRoute.ts +56 -0
- package/src/runtime/navigation.ts +211 -0
- package/src/runtime/router.ts +157 -0
- package/src/runtime/slideData.ts +137 -0
- package/src/runtime/sync.ts +267 -0
- package/src/runtime/types.ts +182 -0
- package/src/runtime/useKeyboardNav.ts +138 -0
- package/src/runtime/useSwipeNav.ts +257 -0
- package/src/runtime/views/DocsView.tsx +74 -0
- package/src/runtime/views/OverviewView.tsx +386 -0
- package/src/runtime/views/PresenterNotesPanel.tsx +76 -0
- package/src/runtime/views/PresenterView.tsx +340 -0
- package/src/runtime/views/SPEC.md +152 -0
- package/src/runtime/views/docs/ComponentsTab.tsx +178 -0
- package/src/runtime/views/docs/DocsHeader.tsx +101 -0
- package/src/runtime/views/docs/Intro.tsx +20 -0
- package/src/runtime/views/docs/LayoutsTab.tsx +324 -0
- package/src/runtime/views/docs/ThemeTab.tsx +110 -0
- package/src/runtime/views/index.ts +7 -0
- package/src/runtime/views/overviewGrid.ts +106 -0
- package/src/runtime/views/presenterPreview.ts +27 -0
- package/src/runtime/virtual-modules.d.ts +98 -0
- package/src/theme/SPEC.md +179 -0
- package/src/theme/base.css +623 -0
- package/src/theme/bee.css +35 -0
- package/src/theme/clean.css +38 -0
- package/src/vite-plugin/SPEC.md +114 -0
- package/src/vite-plugin/component-doc-crawler.ts +350 -0
- package/src/vite-plugin/deck-loader.ts +148 -0
- package/src/vite-plugin/index.ts +373 -0
- package/src/vite-plugin/layout-demo-crawler.ts +802 -0
- package/src/vite-plugin/splitter.ts +353 -0
- package/src/vite-plugin/token-manifest.ts +163 -0
- 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
|
+
}
|