handoff-app 2.0.0-8 → 2.0.0-9

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 (34) hide show
  1. package/README.md +19 -3
  2. package/dist/catalog/normalize.js +3 -0
  3. package/dist/catalog/previews.js +5 -4
  4. package/dist/catalog/types.d.ts +8 -0
  5. package/dist/config/loader.js +2 -0
  6. package/dist/config/runtime.js +8 -10
  7. package/dist/handlebars.d.ts +10 -2
  8. package/dist/transformers/plugins/ssr-render.js +40 -15
  9. package/dist/transformers/preview/component/css.d.ts +1 -0
  10. package/dist/transformers/preview/component/css.js +85 -47
  11. package/dist/transformers/preview/pattern/html.js +3 -1
  12. package/dist/transformers/preview/types.d.ts +13 -0
  13. package/dist/transformers/utils/build.d.ts +19 -0
  14. package/dist/transformers/utils/build.js +83 -1
  15. package/dist/transformers/utils/module.js +2 -1
  16. package/dist/types/config.d.ts +6 -0
  17. package/package.json +1 -1
  18. package/src/app/components/Component/PageSliceResolver.tsx +1 -0
  19. package/src/app/components/Component/Preview.tsx +380 -189
  20. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
  21. package/src/catalog/README.md +3 -0
  22. package/src/catalog/normalize.ts +2 -0
  23. package/src/catalog/previews.ts +6 -3
  24. package/src/catalog/types.ts +8 -0
  25. package/src/config/loader.ts +2 -0
  26. package/src/config/runtime.ts +9 -4
  27. package/src/handlebars.ts +8 -2
  28. package/src/transformers/plugins/ssr-render.ts +53 -16
  29. package/src/transformers/preview/component/css.ts +93 -50
  30. package/src/transformers/preview/pattern/html.ts +3 -1
  31. package/src/transformers/preview/types.ts +14 -0
  32. package/src/transformers/utils/build.ts +80 -0
  33. package/src/transformers/utils/module.ts +4 -2
  34. package/src/types/config.ts +6 -0
@@ -1,6 +1,6 @@
1
1
  import { buildArtifactUrl } from '@handoff/artifacts/url';
2
2
  import { SlotMetadata } from '@handoff/transformers/preview/component';
3
- import { PageSlice } from '@handoff/transformers/preview/types';
3
+ import { PageSlice, PreviewWidth } from '@handoff/transformers/preview/types';
4
4
  import { PreviewObject } from '@handoff/types/preview';
5
5
  import { Types as CoreTypes } from 'handoff-core';
6
6
  import type { TypeNode, TypeNodeProperty } from 'handoff-docgen';
@@ -12,6 +12,8 @@ import {
12
12
  CircleCheck,
13
13
  Component,
14
14
  File,
15
+ Maximize2,
16
+ Minimize2,
15
17
  Monitor,
16
18
  MousePointerClick,
17
19
  RefreshCcw,
@@ -39,6 +41,23 @@ const getRenderablePreviewKeys = (previews: PreviewObject['previews'] | undefine
39
41
  .filter(([, preview]) => preview.url)
40
42
  .map(([key]) => key);
41
43
 
44
+ /** A frame longer than the card's duration-300 class, so the card rejoins the flow once it lands. */
45
+ const COLLAPSE_MS = 340;
46
+
47
+ /**
48
+ * Where the card is between the flow and the floating view.
49
+ *
50
+ * `entering` and `leaving` hold it at the rect it occupies in the flow, and only `full` and
51
+ * `leaving` transition. The geometry must never animate across the change of positioning scheme:
52
+ * `w-full` and the inline width are both percentages, of the viewport while fixed and of the
53
+ * column while static. The browser interpolates between the two, and the card jumps to the wrong
54
+ * width before it grows back.
55
+ */
56
+ type ExpandPhase = 'collapsed' | 'entering' | 'full' | 'leaving';
57
+
58
+ /** defaultHeight is hand-authored, and the stage scales it with calc(), which rejects bare numbers. */
59
+ const toCssLength = (value: string): string => (/^\d+(\.\d+)?$/.test(value) ? `${value}px` : value);
60
+
42
61
  /** The inspect artifact sits beside the preview artifact under a fixed suffix. */
43
62
  const toArtifactPath = (url: string, inspect: boolean): string => (inspect ? `${url.split('.html')[0]}-inspect.html` : url);
44
63
 
@@ -64,21 +83,101 @@ export const getComponentPreviewTitle = (previewableComponent: ComponentPreview)
64
83
  export const ComponentDisplay: React.FC<{
65
84
  component: PreviewObject | undefined;
66
85
  defaultHeight?: string | undefined;
86
+ defaultWidth?: PreviewWidth | undefined;
67
87
  title?: string;
68
88
  onPreviewChange?: (previewUrl: string) => void;
69
- }> = ({ component, defaultHeight, title, onPreviewChange }) => {
89
+ }> = ({ component, defaultHeight, defaultWidth, title, onPreviewChange }) => {
70
90
  const context = usePreviewContext();
71
91
  const ref = React.useRef<HTMLIFrameElement>(null);
92
+ const cardRef = React.useRef<HTMLDivElement>(null);
93
+ const collapseTimer = React.useRef<number | undefined>(undefined);
94
+ const contentObserver = React.useRef<ResizeObserver | null>(null);
95
+ const stageObserver = React.useRef<ResizeObserver | null>(null);
72
96
  const [height, setHeight] = React.useState('100px');
73
97
  const [previewKey, setPreviewKey] = React.useState<string | null>(null);
74
- const [width, setWidth] = React.useState('1100px');
98
+ const [previewWidth, setPreviewWidth] = React.useState<PreviewWidth>(defaultWidth ?? 'fluid');
99
+ const [stageWidth, setStageWidth] = React.useState(0);
75
100
  const [inspect, setInspect] = React.useState(false);
76
- const [scale, setScale] = React.useState(0.8);
101
+ const [phase, setPhase] = React.useState<ExpandPhase>('collapsed');
102
+ // top/left/width are percentages of the viewport, the same units the expanded card uses. A
103
+ // transition between a pixel value and a vw has nothing reliable to interpolate.
104
+ const [originRect, setOriginRect] = React.useState<{ top: number; left: number; width: number; height: number } | null>(null);
105
+
106
+ const expanded = phase !== 'collapsed';
107
+ const animates = phase === 'full' || phase === 'leaving';
77
108
 
78
109
  const renderablePreviewKeys = getRenderablePreviewKeys(component?.previews);
79
110
  const selectedPreview = previewKey ? component?.previews?.[previewKey] : undefined;
80
111
  const artifactPath = selectedPreview?.url ? toArtifactPath(selectedPreview.url, inspect) : null;
81
112
 
113
+ // A fluid preview takes the width the stage has, so it renders at 100% and shows the layout the
114
+ // reader would really get at that width. A fixed width is only scaled when it does not fit, and
115
+ // never scaled up: a component shown larger than it renders misrepresents the design system.
116
+ const renderWidth = previewWidth === 'fluid' ? stageWidth : previewWidth;
117
+ const scale = renderWidth > stageWidth ? stageWidth / renderWidth : 1;
118
+
119
+ // Expanding lifts the card out of the flow rather than moving it into a dialog. Moving it would
120
+ // remount the iframe, and the preview would reload behind the animation instead of growing with
121
+ // it. The card starts at the rect it already occupies, so the first frame looks identical.
122
+ const expand = () => {
123
+ const rect = cardRef.current?.getBoundingClientRect();
124
+ if (!rect) return;
125
+ window.clearTimeout(collapseTimer.current);
126
+ setOriginRect({
127
+ top: (rect.top / window.innerHeight) * 100,
128
+ left: (rect.left / window.innerWidth) * 100,
129
+ width: (rect.width / window.innerWidth) * 100,
130
+ height: rect.height,
131
+ });
132
+ setPhase('entering');
133
+ requestAnimationFrame(() => setPhase('full'));
134
+ };
135
+
136
+ // A timer, not transitionend: reduced motion removes the transition, and then the event that
137
+ // would put the card back in the flow never fires.
138
+ const collapse = () => {
139
+ setPhase('leaving');
140
+ window.clearTimeout(collapseTimer.current);
141
+ collapseTimer.current = window.setTimeout(() => setPhase('collapsed'), COLLAPSE_MS);
142
+ };
143
+
144
+ React.useEffect(() => {
145
+ if (!expanded) return;
146
+
147
+ const onKeyDown = (event: KeyboardEvent) => {
148
+ if (event.key === 'Escape') collapse();
149
+ };
150
+ const pageOverflow = document.body.style.overflow;
151
+
152
+ document.addEventListener('keydown', onKeyDown);
153
+ document.body.style.overflow = 'hidden';
154
+
155
+ return () => {
156
+ document.removeEventListener('keydown', onKeyDown);
157
+ document.body.style.overflow = pageOverflow;
158
+ };
159
+ }, [expanded]);
160
+
161
+ // A fluid preview already follows the stage, so animating its width only lags behind a resize.
162
+ // No easing while the card moves: the target changes every frame, so each frame would restart a
163
+ // 0.2s ease and the stage would trail the preview inside it. The ease is for discrete changes -
164
+ // a new device width, a new variant.
165
+ const boxTransition = animates
166
+ ? 'none'
167
+ : previewWidth === 'fluid'
168
+ ? 'height 0.2s ease-in-out'
169
+ : 'width 0.2s ease-in-out, height 0.2s ease-in-out';
170
+
171
+ // A ref callback, not an effect: the stage only exists once a preview is available. A hidden tab
172
+ // never delivers a ResizeObserver entry, so the first width is read directly.
173
+ const observeStage = useCallback((stage: HTMLDivElement | null) => {
174
+ stageObserver.current?.disconnect();
175
+ if (!stage) return;
176
+ setStageWidth(stage.clientWidth);
177
+ stageObserver.current = new ResizeObserver(([entry]) => setStageWidth(entry.contentRect.width));
178
+ stageObserver.current.observe(stage);
179
+ }, []);
180
+
82
181
  // Generate variants from component previews only for Figma atomic components
83
182
  const localVariants = React.useMemo(() => {
84
183
  if (!component?.figmaComponentId || !component?.previews) return null;
@@ -107,25 +206,42 @@ export const ComponentDisplay: React.FC<{
107
206
  return context.variants;
108
207
  }, [component?.figmaComponentId, component?.previews, context.preview, context.variants]);
109
208
 
209
+ // The stage reserves the scaled height, so it needs the live content height. That height changes
210
+ // whenever the preview reflows - a new device width, a late font, an image - not only on load.
110
211
  const onLoad = useCallback(() => {
212
+ contentObserver.current?.disconnect();
213
+
111
214
  if (defaultHeight) {
112
- setHeight(defaultHeight);
113
- } else if (ref.current) {
114
- if (ref.current.contentWindow.document.body) {
115
- setHeight(ref.current.contentWindow.document.body.scrollHeight + 'px');
116
- }
215
+ setHeight(toCssLength(defaultHeight));
216
+ return;
117
217
  }
118
- // eslint-disable-next-line react-hooks/exhaustive-deps
119
- }, [defaultHeight, ref]);
218
+
219
+ const body = ref.current?.contentWindow?.document?.body;
220
+ if (!body) return;
221
+
222
+ const measure = () => setHeight(body.scrollHeight + 'px');
223
+ measure();
224
+ contentObserver.current = new ResizeObserver(measure);
225
+ contentObserver.current.observe(body);
226
+ }, [defaultHeight]);
120
227
 
121
228
  React.useEffect(() => {
122
229
  onLoad();
123
- window.addEventListener('resize', onLoad);
124
- return () => {
125
- window.removeEventListener('resize', onLoad);
126
- };
127
230
  }, [onLoad]);
128
231
 
232
+ React.useEffect(() => {
233
+ setPreviewWidth(defaultWidth ?? 'fluid');
234
+ }, [defaultWidth]);
235
+
236
+ React.useEffect(
237
+ () => () => {
238
+ contentObserver.current?.disconnect();
239
+ stageObserver.current?.disconnect();
240
+ window.clearTimeout(collapseTimer.current);
241
+ },
242
+ []
243
+ );
244
+
129
245
  // One effect owns the selection. Split in two, the filter effect clears the selection, then
130
246
  // returns early on the next render and never restores it.
131
247
  React.useEffect(() => {
@@ -165,186 +281,261 @@ export const ComponentDisplay: React.FC<{
165
281
  );
166
282
  };
167
283
 
168
- return (
169
- <div className="md:flex" id="preview">
170
- <div className="text-medium flex w-full flex-col items-center rounded-lg border border-gray-200 dark:border-gray-900">
171
- {component?.previews && (
172
- <>
173
- <div className="flex w-full items-center justify-between rounded-t-lg bg-gray-50 px-6 py-2 pr-3 align-middle @container dark:bg-gray-800">
174
- <div className="flex flex-1 items-start gap-2">
175
- {localVariants ? (
176
- <>
177
- {Object.keys(localVariants).length > 0 && (
178
- <>
179
- <div className="flex h-8 shrink-0 items-center gap-2">
180
- <p className="font-monospace text-[11px] text-accent-foreground">{title ?? 'Variant'}</p>
181
- <Separator orientation="vertical" className="h-3" />
182
- </div>
183
- <div className="flex flex-wrap gap-2">
184
- {Object.keys(localVariants)
185
- .filter((variantProperty) => localVariants[variantProperty].length > 1)
186
- .map((variantProperty) => (
187
- <Select
188
- key={variantProperty}
189
- value={context.variantFilter ? context.variantFilter[variantProperty] : undefined}
190
- onValueChange={(value) => context.updateVariantFilter(variantProperty, value)}
191
- >
192
- <SelectTrigger className="h-8 w-[140px] border-none text-xs shadow-none">
193
- <SelectValue placeholder={variantProperty} />
194
- </SelectTrigger>
195
- <SelectContent>
196
- {localVariants[variantProperty].map((variantPropertyValue) => {
197
- if (typeof variantPropertyValue !== 'string' || variantPropertyValue === '') return null;
198
- return (
199
- <SelectItem
200
- key={variantPropertyValue}
201
- value={variantPropertyValue}
202
- disabled={!isOptionValid(variantProperty, variantPropertyValue)}
203
- >
204
- {variantPropertyValue}
205
- </SelectItem>
206
- );
207
- })}
208
- </SelectContent>
209
- </Select>
210
- ))}
211
- </div>
212
- </>
213
- )}
214
- </>
215
- ) : (
216
- <>
217
- <Select value={previewKey ?? ''} onValueChange={setPreviewKey}>
218
- <SelectTrigger className="h-8 w-[180px] border-none border-gray-200 bg-white text-xs shadow-none dark:border-gray-900">
219
- <SelectValue placeholder="Preview" />
220
- </SelectTrigger>
221
- <SelectContent>
222
- {renderablePreviewKeys.map((key) => (
223
- <SelectItem key={key} value={key}>
224
- {component.previews[key].title}
225
- </SelectItem>
226
- ))}
227
- </SelectContent>
228
- </Select>
229
- </>
230
- )}
231
- </div>
232
- <div className=" hidden items-center gap-0 @2xl:flex">
233
- <TooltipProvider delayDuration={0}>
234
- <Tooltip>
235
- <TooltipTrigger asChild>
236
- <Button
237
- className="h-7 px-3 font-mono text-[11px] hover:bg-gray-300"
238
- onClick={() => setScale(scale === 1 ? 0.8 : 1)}
239
- variant="ghost"
240
- >
241
- {scale === 1 ? '100%' : '80%'}
242
- </Button>
243
- </TooltipTrigger>
244
- <TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Toggle Scale</TooltipContent>
245
- </Tooltip>
246
- </TooltipProvider>
247
- <Separator orientation="vertical" className="mx-3 h-6" />
248
- <RadioGroup className="flex items-center gap-0" defaultValue="1100" onValueChange={(value) => setWidth(`${value}px`)}>
249
- <label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl ring-inset transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-3">
250
- <RadioGroupItem value="1100" className="sr-only after:absolute after:inset-0" />
251
- <Monitor />
252
- </label>
253
- <label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-3">
254
- <RadioGroupItem value="800" className="sr-only after:absolute after:inset-0" />
255
- <Tablet />
256
- </label>
257
- <label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-2.5">
258
- <RadioGroupItem value="400" className="sr-only after:absolute after:inset-0" />
259
- <Smartphone />
260
- </label>
261
- </RadioGroup>
262
- <Separator orientation="vertical" className="mx-3 h-6" />
263
- <TooltipProvider delayDuration={0}>
264
- <Tooltip>
265
- <TooltipTrigger asChild>
266
- <Button
267
- className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-3"
268
- onClick={() => {
269
- if (ref.current) {
270
- ref.current.contentWindow.location.reload();
271
- }
272
- }}
273
- variant="ghost"
274
- >
275
- <RefreshCcw />
276
- </Button>
277
- </TooltipTrigger>
278
- <TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Refresh Preview</TooltipContent>
279
- </Tooltip>
280
- </TooltipProvider>
281
- <TooltipProvider delayDuration={0}>
282
- <Tooltip>
283
- <TooltipTrigger asChild>
284
- <Button
285
- className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-4"
286
- onClick={() => {
287
- setInspect(!inspect);
288
- }}
289
- variant={inspect ? 'default' : 'ghost'}
290
- >
291
- <MousePointerClick strokeWidth={1.5} />
292
- </Button>
293
- </TooltipTrigger>
294
- <TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Inspect Component</TooltipContent>
295
- </Tooltip>
296
- </TooltipProvider>
297
- <TooltipProvider delayDuration={0}>
298
- <Tooltip>
299
- <TooltipTrigger asChild>
300
- <Button
301
- className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-3"
302
- onClick={() => {
303
- // open in new tab
304
- window.open(buildArtifactUrl(`component/${artifactPath}`, process.env.HANDOFF_APP_BASE_PATH ?? ''), '_blank');
305
- }}
306
- variant="ghost"
307
- >
308
- <SquareArrowOutUpRight />
309
- </Button>
310
- </TooltipTrigger>
311
- <TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Open in New Tab</TooltipContent>
312
- </Tooltip>
313
- </TooltipProvider>
314
- </div>
315
- </div>
284
+ const collapsedStyle: React.CSSProperties = { position: 'fixed', zIndex: 50 };
285
+ const originStyle: React.CSSProperties = originRect
286
+ ? { top: `${originRect.top}%`, left: `${originRect.left}%`, width: `${originRect.width}%`, transform: 'translateY(0)' }
287
+ : {};
288
+ const fullSizeStyle: React.CSSProperties = {
289
+ ...collapsedStyle,
290
+ top: '50%',
291
+ left: '2%',
292
+ width: '96%',
293
+ maxHeight: '92vh',
294
+ transform: 'translateY(-50%)',
295
+ };
316
296
 
317
- <div className="dotted-bg w-full p-8">
318
- {artifactPath ? (
319
- <div>
320
- <iframe
321
- key={`${artifactPath}-${reloadCounter}`}
322
- onLoad={onLoad}
323
- ref={ref}
324
- height={height}
325
- style={{
326
- minWidth: width,
327
- height: height,
328
- transform: `scale(${scale})`,
329
- transformOrigin: 'left top',
330
- transition: 'all 0.2s ease-in-out',
331
- display: 'block',
332
- margin: '0 auto',
333
- }}
334
- src={buildArtifactUrl(`component/${artifactPath}`, process.env.HANDOFF_APP_BASE_PATH ?? '')}
335
- />
336
- </div>
297
+ const card = (
298
+ <div
299
+ ref={cardRef}
300
+ role={expanded ? 'dialog' : undefined}
301
+ aria-modal={expanded ? true : undefined}
302
+ aria-label={expanded ? `${title ?? 'Component'} preview` : undefined}
303
+ style={expanded ? (phase === 'full' ? fullSizeStyle : { ...collapsedStyle, ...originStyle }) : undefined}
304
+ className={`text-medium flex w-full flex-col items-center rounded-lg border border-gray-200 dark:border-gray-900 ${
305
+ expanded ? 'overflow-hidden bg-background shadow-xl' : ''
306
+ } ${animates ? 'transition-[top,left,width,transform] duration-300 ease-out motion-reduce:transition-none' : ''}`}
307
+ >
308
+ {component?.previews && (
309
+ <>
310
+ <div
311
+ className="flex w-full items-center justify-between rounded-t-lg bg-gray-50 px-6 py-2 pr-3 align-middle @container dark:bg-gray-800"
312
+ >
313
+ <div className="flex flex-1 items-start gap-2">
314
+ {localVariants ? (
315
+ <>
316
+ {Object.keys(localVariants).length > 0 && (
317
+ <>
318
+ <div className="flex h-8 shrink-0 items-center gap-2">
319
+ <p className="font-monospace text-[11px] text-accent-foreground">{title ?? 'Variant'}</p>
320
+ <Separator orientation="vertical" className="h-3" />
321
+ </div>
322
+ <div className="flex flex-wrap gap-2">
323
+ {Object.keys(localVariants)
324
+ .filter((variantProperty) => localVariants[variantProperty].length > 1)
325
+ .map((variantProperty) => (
326
+ <Select
327
+ key={variantProperty}
328
+ value={context.variantFilter ? context.variantFilter[variantProperty] : undefined}
329
+ onValueChange={(value) => context.updateVariantFilter(variantProperty, value)}
330
+ >
331
+ <SelectTrigger className="h-8 w-[140px] border-none text-xs shadow-none">
332
+ <SelectValue placeholder={variantProperty} />
333
+ </SelectTrigger>
334
+ <SelectContent>
335
+ {localVariants[variantProperty].map((variantPropertyValue) => {
336
+ if (typeof variantPropertyValue !== 'string' || variantPropertyValue === '') return null;
337
+ return (
338
+ <SelectItem
339
+ key={variantPropertyValue}
340
+ value={variantPropertyValue}
341
+ disabled={!isOptionValid(variantProperty, variantPropertyValue)}
342
+ >
343
+ {variantPropertyValue}
344
+ </SelectItem>
345
+ );
346
+ })}
347
+ </SelectContent>
348
+ </Select>
349
+ ))}
350
+ </div>
351
+ </>
352
+ )}
353
+ </>
337
354
  ) : (
338
- <div className="flex items-center justify-center p-8 text-sm text-gray-500">
339
- No preview available for this selection.
340
- </div>
355
+ <>
356
+ <Select value={previewKey ?? ''} onValueChange={setPreviewKey}>
357
+ <SelectTrigger className="h-8 w-[180px] border-none border-gray-200 bg-white text-xs shadow-none dark:border-gray-900">
358
+ <SelectValue placeholder="Preview" />
359
+ </SelectTrigger>
360
+ <SelectContent>
361
+ {renderablePreviewKeys.map((key) => (
362
+ <SelectItem key={key} value={key}>
363
+ {component.previews[key].title}
364
+ </SelectItem>
365
+ ))}
366
+ </SelectContent>
367
+ </Select>
368
+ </>
341
369
  )}
342
370
  </div>
343
- </>
344
- )}
345
- </div>
371
+ <div className="hidden items-center gap-0 @xl:flex">
372
+ {scale < 1 && (
373
+ <>
374
+ <TooltipProvider delayDuration={0}>
375
+ <Tooltip>
376
+ <TooltipTrigger asChild>
377
+ <span className="px-3 font-mono text-[11px] text-gray-500">{Math.round(scale * 100)}%</span>
378
+ </TooltipTrigger>
379
+ <TooltipContent className="rounded-sm px-2 py-1 text-[11px]">
380
+ Too wide for this column. Expand to see it at full size.
381
+ </TooltipContent>
382
+ </Tooltip>
383
+ </TooltipProvider>
384
+ <Separator orientation="vertical" className="mx-3 h-6" />
385
+ </>
386
+ )}
387
+ <RadioGroup
388
+ className="flex items-center gap-0"
389
+ value={String(previewWidth)}
390
+ onValueChange={(value) => setPreviewWidth(value === 'fluid' ? 'fluid' : Number(value))}
391
+ >
392
+ <label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl ring-inset transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-3">
393
+ <RadioGroupItem value="fluid" aria-label="Fill the available width" className="sr-only after:absolute after:inset-0" />
394
+ <Monitor />
395
+ </label>
396
+ <label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-3">
397
+ <RadioGroupItem value="800" aria-label="Tablet width, 800px" className="sr-only after:absolute after:inset-0" />
398
+ <Tablet />
399
+ </label>
400
+ <label className="relative flex h-7 cursor-pointer flex-col items-center justify-center rounded-md px-3 text-center text-xl transition-colors hover:bg-gray-300 has-data-disabled:cursor-not-allowed has-data-[state=checked]:bg-blue-50 has-data-disabled:opacity-50 has-data-[state=checked]:shadow-[inset_0_1px_1px_0_rgba(0,0,0,0.05)] has-focus-visible:outline-solid has-focus-visible:outline-2 has-focus-visible:outline-ring/70 has-data-[state=checked]:ring-1 has-data-[state=checked]:ring-blue-500/20 [&_svg]:size-2.5">
401
+ <RadioGroupItem value="400" aria-label="Mobile width, 400px" className="sr-only after:absolute after:inset-0" />
402
+ <Smartphone />
403
+ </label>
404
+ </RadioGroup>
405
+ <Separator orientation="vertical" className="mx-3 h-6" />
406
+ <TooltipProvider delayDuration={0}>
407
+ <Tooltip>
408
+ <TooltipTrigger asChild>
409
+ <Button
410
+ className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-3"
411
+ onClick={() => {
412
+ if (ref.current) {
413
+ ref.current.contentWindow.location.reload();
414
+ }
415
+ }}
416
+ variant="ghost"
417
+ >
418
+ <RefreshCcw />
419
+ </Button>
420
+ </TooltipTrigger>
421
+ <TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Refresh Preview</TooltipContent>
422
+ </Tooltip>
423
+ </TooltipProvider>
424
+ <TooltipProvider delayDuration={0}>
425
+ <Tooltip>
426
+ <TooltipTrigger asChild>
427
+ <Button
428
+ className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-4"
429
+ onClick={() => {
430
+ setInspect(!inspect);
431
+ }}
432
+ variant={inspect ? 'default' : 'ghost'}
433
+ >
434
+ <MousePointerClick strokeWidth={1.5} />
435
+ </Button>
436
+ </TooltipTrigger>
437
+ <TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Inspect Component</TooltipContent>
438
+ </Tooltip>
439
+ </TooltipProvider>
440
+ <TooltipProvider delayDuration={0}>
441
+ <Tooltip>
442
+ <TooltipTrigger asChild>
443
+ <Button
444
+ aria-label={expanded ? 'Collapse preview' : 'Expand preview'}
445
+ className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-3"
446
+ onClick={() => (expanded ? collapse() : expand())}
447
+ variant="ghost"
448
+ >
449
+ {expanded ? <Minimize2 /> : <Maximize2 />}
450
+ </Button>
451
+ </TooltipTrigger>
452
+ <TooltipContent className="rounded-sm px-2 py-1 text-[11px]">
453
+ {expanded ? 'Collapse Preview' : 'Expand Preview'}
454
+ </TooltipContent>
455
+ </Tooltip>
456
+ </TooltipProvider>
457
+ <TooltipProvider delayDuration={0}>
458
+ <Tooltip>
459
+ <TooltipTrigger asChild>
460
+ <Button
461
+ className="h-7 px-3 hover:bg-gray-300 [&_svg]:size-3"
462
+ onClick={() => {
463
+ // open in new tab
464
+ window.open(buildArtifactUrl(`component/${artifactPath}`, process.env.HANDOFF_APP_BASE_PATH ?? ''), '_blank');
465
+ }}
466
+ variant="ghost"
467
+ >
468
+ <SquareArrowOutUpRight />
469
+ </Button>
470
+ </TooltipTrigger>
471
+ <TooltipContent className="rounded-sm px-2 py-1 text-[11px]">Open in New Tab</TooltipContent>
472
+ </Tooltip>
473
+ </TooltipProvider>
474
+ </div>
475
+ </div>
476
+
477
+ <div className={`dotted-bg w-full p-4 ${expanded ? 'min-h-0' : ''}`}>
478
+ {artifactPath ? (
479
+ // The outer div measures the width available to the preview. The inner one carries
480
+ // the scaled size, because a transform does not shrink the layout box: without it
481
+ // the stage keeps the unscaled box and pads the preview with dead space on the
482
+ // right and below.
483
+ <div ref={observeStage}>
484
+ {renderWidth > 0 && (
485
+ <div
486
+ style={{
487
+ width: renderWidth * scale,
488
+ height: `calc(${height} * ${scale})`,
489
+ margin: '0 auto',
490
+ transition: boxTransition,
491
+ }}
492
+ >
493
+ <iframe
494
+ key={`${artifactPath}-${reloadCounter}`}
495
+ onLoad={onLoad}
496
+ ref={ref}
497
+ style={{
498
+ width: renderWidth,
499
+ height: height,
500
+ transform: `scale(${scale})`,
501
+ transformOrigin: 'left top',
502
+ transition: animates ? 'none' : 'transform 0.2s ease-in-out',
503
+ display: 'block',
504
+ }}
505
+ src={buildArtifactUrl(`component/${artifactPath}`, process.env.HANDOFF_APP_BASE_PATH ?? '')}
506
+ />
507
+ </div>
508
+ )}
509
+ </div>
510
+ ) : (
511
+ <div className="flex items-center justify-center p-8 text-sm text-gray-500">
512
+ No preview available for this selection.
513
+ </div>
514
+ )}
515
+ </div>
516
+ </>
517
+ )}
346
518
  </div>
347
519
  );
520
+
521
+ // The docs column is narrower than the widths most components are designed for. Expanding is
522
+ // how a reader gets the real layout at 100% instead of a scaled copy of it. The placeholder holds
523
+ // the page height while the card floats, so nothing behind the overlay jumps.
524
+ return (
525
+ <>
526
+ {expanded && (
527
+ <div
528
+ className={`backdrop-blur-xs fixed inset-0 z-40 bg-gray-200/50 transition-opacity duration-300 dark:bg-gray-950/60 ${
529
+ phase === 'full' ? 'opacity-100' : 'opacity-0'
530
+ }`}
531
+ onClick={collapse}
532
+ />
533
+ )}
534
+ <div className="md:flex" id="preview" style={expanded && originRect ? { minHeight: originRect.height } : undefined}>
535
+ {card}
536
+ </div>
537
+ </>
538
+ );
348
539
  };
349
540
 
350
541
  export const ComponentPreview: React.FC<{
@@ -1,3 +1,4 @@
1
+ import { PreviewWidth } from '@handoff/transformers/preview/types';
1
2
  import { PreviewObject } from '@handoff/types/preview';
2
3
  import { Filter, evaluateFilter } from '@handoff/utils/filter';
3
4
  import React from 'react';
@@ -13,6 +14,7 @@ export interface ComponentDisplaySliceProps {
13
14
  showPreview?: boolean;
14
15
  showCodeHighlight?: boolean;
15
16
  defaultHeight?: string;
17
+ defaultWidth?: PreviewWidth;
16
18
  filterBy?: Filter;
17
19
  codeHighlight?: boolean;
18
20
  }
@@ -38,6 +40,7 @@ const ComponentDisplaySlice: React.FC<ComponentDisplaySliceProps> = ({
38
40
  showPreview = true,
39
41
  showCodeHighlight,
40
42
  defaultHeight,
43
+ defaultWidth,
41
44
  filterBy,
42
45
  codeHighlight = true,
43
46
  }) => {
@@ -68,6 +71,7 @@ const ComponentDisplaySlice: React.FC<ComponentDisplaySliceProps> = ({
68
71
  title={title}
69
72
  component={filteredPreview}
70
73
  defaultHeight={finalDefaultHeight}
74
+ defaultWidth={defaultWidth}
71
75
  onPreviewChange={onPreviewChange}
72
76
  />
73
77
  )}