{"$schema":"https:\/\/ui.particle.academy\/schema\/registry-item.json","name":"slide","type":"registry:ui","title":"Slide","description":"Single-slide renderer \u2014 shared by viewer + editor + thumbnails.","package":"fancy-slides","dependencies":[],"registryDependencies":["types","theme","elements"],"files":[{"path":"components\/fancy\/slide\/Slide.tsx","content":"import { useEffect, useMemo, useRef, useState, type CSSProperties, type PointerEvent as ReactPointerEvent, type ReactNode } from \"react\";\r\nimport type { ElementAnimation, Slide as SlideData, SlideElement, Theme } from \"..\/..\/types\";\r\nimport { resolveTheme } from \"..\/..\/theme\/theme-utils\";\r\nimport { cn } from \"..\/..\/utils\/cn\";\r\nimport { buildSteps, stepDelays, paragraphReveals, type ParaReveal } from \"..\/..\/utils\/builds\";\r\nimport { TextElementRenderer } from \"..\/elements\/TextElement\";\r\nimport { ImageElementRenderer } from \"..\/elements\/ImageElement\";\r\nimport { ShapeElementRenderer } from \"..\/elements\/ShapeElement\";\r\nimport { SlideContext, isDarkColor, type SlideContextValue } from \".\/slide-context\";\r\nimport { BUILD_KEYFRAMES, buildEnterStyle } from \".\/builds-style\";\r\n\r\nexport interface SlideProps {\r\n    \/** The slide to render. *\/\r\n    slide: SlideData;\r\n    \/** Deck theme \u2014 controls fonts\/colors\/aspect-ratio when the slide doesn't override. *\/\r\n    theme?: Theme;\r\n    \/** Pin the slide to this width in px. When omitted, the slide fills its container with auto-resize. *\/\r\n    width?: number;\r\n    \/** Aspect ratio override \u2014 falls back to theme.aspectRatio. *\/\r\n    aspectRatio?: number;\r\n    \/** Edit mode flag \u2014 passed to element renderers + enables drag\/resize affordances. *\/\r\n    editing?: boolean;\r\n    \/**\r\n     * Current build step (0..totalSteps). `0` = nothing built (only non-animated\r\n     * elements visible); each step reveals more animated elements. Omit (or pass\r\n     * a number \u2265 total) to show the fully-built slide. Ignored when `editing` \u2014\r\n     * the editor always shows every element so authors can position them.\r\n     *\/\r\n    buildStep?: number;\r\n    \/** Called when a text element's content is edited (only in editing mode). *\/\r\n    onElementContentChange?: (elementId: string, content: string) => void;\r\n    \/** Called when an element is clicked \u2014 host-driven selection. *\/\r\n    onElementSelect?: (elementId: string | null) => void;\r\n    \/** Selected element id \u2014 adds a focus ring and shows resize handles when editing. *\/\r\n    selectedElementId?: string | null;\r\n    \/** Called when the user drags an element body to a new position (slide-relative 0..1). *\/\r\n    onElementMove?: (elementId: string, x: number, y: number) => void;\r\n    \/** Called when the user resizes via a corner \/ edge handle (slide-relative 0..1). *\/\r\n    onElementResize?: (elementId: string, patch: { x: number; y: number; w: number; h: number }) => void;\r\n    \/** Optional override renderer for a custom element type (or to replace one of the built-ins). *\/\r\n    renderElement?: (element: SlideElement, slideWidthPx: number) => ReactNode | undefined;\r\n    className?: string;\r\n    style?: CSSProperties;\r\n}\r\n\r\n\/**\r\n * Slide \u2014 the shared renderer. Used by SlideViewer, DeckEditor canvas,\r\n * SlideThumbnail, and the agent bridge's preview tools. All resolution-\r\n * independence lives here: child elements get a `slideWidthPx` so they can\r\n * scale font sizes \/ stroke widths to the rendered slide size.\r\n *\r\n * In `editing` mode this component grows two extra affordances:\r\n *   - pointer-drag on an element body moves it\r\n *   - selected elements show 8 resize handles (4 corners, 4 edges)\r\n *\r\n * Both fire through `onElementMove` \/ `onElementResize` so the host owns\r\n * the state \u2014 Slide stays a pure renderer.\r\n *\/\r\nexport function Slide({\r\n    slide,\r\n    theme,\r\n    width,\r\n    aspectRatio,\r\n    editing = false,\r\n    buildStep,\r\n    onElementContentChange,\r\n    onElementSelect,\r\n    selectedElementId,\r\n    onElementMove,\r\n    onElementResize,\r\n    renderElement,\r\n    className,\r\n    style,\r\n}: SlideProps) {\r\n    const t = resolveTheme(theme);\r\n    const ratio = aspectRatio ?? t.aspectRatio ?? 16 \/ 9;\r\n    const ref = useRef<HTMLDivElement>(null);\r\n    const [measured, setMeasured] = useState<number>(width ?? 0);\r\n\r\n    \/\/ Auto-measure when width isn't pinned.\r\n    useEffect(() => {\r\n        if (width !== undefined) {\r\n            setMeasured(width);\r\n            return;\r\n        }\r\n        const el = ref.current;\r\n        if (!el) return;\r\n        const ro = new ResizeObserver((entries) => {\r\n            for (const e of entries) {\r\n                setMeasured(e.contentRect.width);\r\n            }\r\n        });\r\n        ro.observe(el);\r\n        return () => ro.disconnect();\r\n    }, [width]);\r\n\r\n    const slideWidthPx = measured || 1;\r\n    const slideHeightPx = slideWidthPx \/ ratio;\r\n\r\n    const bg = slide.background;\r\n    \/\/ The effective background colour used both for the slide div's `background`\r\n    \/\/ and for the dark-theme heuristic exposed via SlideContext.\r\n    const effectiveBg =\r\n        bg?.color ?? t.colors?.background ?? \"#ffffff\";\r\n    const backgroundStyle: CSSProperties = {\r\n        background: bg?.gradient\r\n            ? bg.gradient\r\n            : bg?.image\r\n                ? `${bg.color ?? \"transparent\"} url(${bg.image}) center\/${bg.imageFit ?? \"cover\"} no-repeat`\r\n                : effectiveBg,\r\n    };\r\n\r\n    const slideContext = useMemo<SlideContextValue>(\r\n        () => ({\r\n            theme: t,\r\n            isDark: isDarkColor(effectiveBg),\r\n            slideWidthPx,\r\n        }),\r\n        [t, effectiveBg, slideWidthPx],\r\n    );\r\n\r\n    \/\/ \u2500\u2500\u2500 Build (entrance-animation) bookkeeping \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\r\n    \/\/ In editing mode builds never hide anything \u2014 authors must see\/position\r\n    \/\/ every element. Otherwise we resolve each animated element's reveal step\r\n    \/\/ and the per-element entrance delay for the step currently firing.\r\n    const buildInfo = useMemo(() => {\r\n        if (editing) return null;\r\n        const steps = buildSteps(slide);\r\n        if (steps.length === 0) return null;\r\n        const revealStep = new Map<string, number>(); \/\/ element id \u2192 FIRST 1-based step it reveals on\r\n        steps.forEach((step, i) => {\r\n            for (const b of step.builds) {\r\n                \/\/ By-paragraph elements appear across multiple steps; the\r\n                \/\/ element box becomes visible on its earliest paragraph's step.\r\n                if (!revealStep.has(b.element.id)) revealStep.set(b.element.id, i + 1);\r\n            }\r\n        });\r\n        \/\/ When `buildStep` is omitted (thumbnails, exports) the slide renders\r\n        \/\/ fully built with NO entrance animation. When provided, the firing\r\n        \/\/ step's elements animate in.\r\n        const driven = buildStep !== undefined;\r\n        const currentStep = driven ? buildStep : steps.length;\r\n        const firing = driven ? steps[currentStep - 1] : undefined;\r\n        const delays = firing ? stepDelays(firing.builds) : new Map<string, number>();\r\n        \/\/ Per-element paragraph reveal state (by-paragraph text builds). When\r\n        \/\/ undriven, every paragraph is revealed (fully-built, no animation).\r\n        const paraReveals = driven\r\n            ? paragraphReveals(slide, currentStep)\r\n            : new Map<string, ParaReveal>();\r\n        return { revealStep, currentStep, delays, paraReveals, driven };\r\n    }, [editing, slide, buildStep]);\r\n\r\n    return (\r\n        <SlideContext.Provider value={slideContext}>\r\n            <div\r\n                ref={ref}\r\n                className={cn(\"fs-slide\", className)}\r\n                style={{\r\n                    width: width ? `${width}px` : \"100%\",\r\n                    height: width ? `${width \/ ratio}px` : `${slideHeightPx}px`,\r\n                    position: \"relative\",\r\n                    overflow: \"hidden\",\r\n                    color: t.colors?.text,\r\n                    ...backgroundStyle,\r\n                    ...style,\r\n                }}\r\n                data-fancy-slides-slide={slide.id}\r\n                onClick={(e) => {\r\n                    if (e.target === e.currentTarget && onElementSelect) onElementSelect(null);\r\n                }}\r\n            >\r\n                {buildInfo && <style>{BUILD_KEYFRAMES}<\/style>}\r\n                {orderedElements(slide.elements).map((element) => {\r\n                    \/\/ Resolve build visibility \/ entrance animation for this element.\r\n                    let buildHidden = false;\r\n                    let buildAnimation: ElementAnimation | undefined;\r\n                    let buildDelay = 0;\r\n                    \/\/ By-paragraph reveal state for this element (text builds only).\r\n                    const paraReveal = buildInfo?.paraReveals.get(element.id);\r\n                    if (buildInfo) {\r\n                        const step = buildInfo.revealStep.get(element.id);\r\n                        if (step !== undefined) {\r\n                            if (buildInfo.currentStep < step) {\r\n                                buildHidden = true; \/\/ not yet built\r\n                            } else if (paraReveal) {\r\n                                \/\/ By-paragraph element: the box stays mounted from its\r\n                                \/\/ first paragraph onward; individual paragraphs hide \/\r\n                                \/\/ animate inside the text renderer. No whole-box effect.\r\n                            } else if (buildInfo.currentStep === step && element.animation) {\r\n                                \/\/ Revealing on the step that just fired \u2192 play the effect.\r\n                                buildAnimation = element.animation;\r\n                                buildDelay = buildInfo.delays.get(element.id) ?? 0;\r\n                            }\r\n                        }\r\n                    }\r\n                    if (buildHidden) return null;\r\n                    return (\r\n                        <SlideElementHost\r\n                            key={element.id}\r\n                            element={element}\r\n                            theme={t}\r\n                            slideWidthPx={slideWidthPx}\r\n                            slideHeightPx={slideHeightPx}\r\n                            editing={editing}\r\n                            selected={selectedElementId === element.id}\r\n                            onContentChange={onElementContentChange}\r\n                            onSelect={onElementSelect}\r\n                            onMove={onElementMove}\r\n                            onResize={onElementResize}\r\n                            renderElement={renderElement}\r\n                            buildAnimation={buildAnimation}\r\n                            buildDelay={buildDelay}\r\n                            paraReveal={paraReveal}\r\n                        \/>\r\n                    );\r\n                })}\r\n            <\/div>\r\n        <\/SlideContext.Provider>\r\n    );\r\n}\r\n\r\ninterface SlideElementHostProps {\r\n    element: SlideElement;\r\n    theme: Theme;\r\n    slideWidthPx: number;\r\n    slideHeightPx: number;\r\n    editing: boolean;\r\n    selected: boolean;\r\n    onContentChange?: (elementId: string, content: string) => void;\r\n    onSelect?: (elementId: string | null) => void;\r\n    onMove?: (elementId: string, x: number, y: number) => void;\r\n    onResize?: (elementId: string, patch: { x: number; y: number; w: number; h: number }) => void;\r\n    renderElement?: (element: SlideElement, slideWidthPx: number) => ReactNode | undefined;\r\n    \/** Entrance build animation to play (only set on the step the element reveals on). *\/\r\n    buildAnimation?: ElementAnimation;\r\n    \/** Effective entrance delay (ms) resolved for with-prev \/ after-prev chaining. *\/\r\n    buildDelay?: number;\r\n    \/** Per-paragraph reveal state for a by-paragraph text build (text elements only). *\/\r\n    paraReveal?: ParaReveal;\r\n}\r\n\r\n\/** Smallest allowed element size, as a fraction of the slide. *\/\r\nconst MIN_DIM = 0.02;\r\n\r\n\/** Drag distance below which we treat the gesture as a click. *\/\r\nconst CLICK_DRAG_THRESHOLD_PX = 3;\r\n\r\ntype ResizeAnchor = \"nw\" | \"n\" | \"ne\" | \"e\" | \"se\" | \"s\" | \"sw\" | \"w\";\r\n\r\ninterface DragState {\r\n    mode: \"move\" | ResizeAnchor;\r\n    startClientX: number;\r\n    startClientY: number;\r\n    startX: number;\r\n    startY: number;\r\n    startW: number;\r\n    startH: number;\r\n    didMove: boolean;\r\n}\r\n\r\nfunction SlideElementHost({\r\n    element,\r\n    theme,\r\n    slideWidthPx,\r\n    slideHeightPx,\r\n    editing,\r\n    selected,\r\n    onContentChange,\r\n    onSelect,\r\n    onMove,\r\n    onResize,\r\n    renderElement,\r\n    buildAnimation,\r\n    buildDelay = 0,\r\n    paraReveal,\r\n}: SlideElementHostProps) {\r\n    const dragRef = useRef<DragState | null>(null);\r\n\r\n    if (element.hidden) return null;\r\n\r\n    const left = element.x * slideWidthPx;\r\n    const top = element.y * slideHeightPx;\r\n    const width = element.w * slideWidthPx;\r\n    const height = element.h * slideHeightPx;\r\n\r\n    const interactive = editing && !element.locked;\r\n    const canMove = interactive && !!onMove;\r\n    const canResize = interactive && !!onResize && selected;\r\n\r\n    const startDrag = (mode: DragState[\"mode\"]) => (e: ReactPointerEvent<HTMLElement>) => {\r\n        e.stopPropagation();\r\n        (e.target as HTMLElement).setPointerCapture(e.pointerId);\r\n        dragRef.current = {\r\n            mode,\r\n            startClientX: e.clientX,\r\n            startClientY: e.clientY,\r\n            startX: element.x,\r\n            startY: element.y,\r\n            startW: element.w,\r\n            startH: element.h,\r\n            didMove: false,\r\n        };\r\n    };\r\n\r\n    const onPointerMove = (e: ReactPointerEvent<HTMLElement>) => {\r\n        const drag = dragRef.current;\r\n        if (!drag) return;\r\n        const dxPx = e.clientX - drag.startClientX;\r\n        const dyPx = e.clientY - drag.startClientY;\r\n        if (Math.hypot(dxPx, dyPx) >= CLICK_DRAG_THRESHOLD_PX) drag.didMove = true;\r\n        const dx = dxPx \/ slideWidthPx;\r\n        const dy = dyPx \/ slideHeightPx;\r\n        if (drag.mode === \"move\") {\r\n            if (!onMove) return;\r\n            const maxX = 1 - element.w;\r\n            const maxY = 1 - element.h;\r\n            const nextX = clamp(drag.startX + dx, 0, Math.max(0, maxX));\r\n            const nextY = clamp(drag.startY + dy, 0, Math.max(0, maxY));\r\n            onMove(element.id, nextX, nextY);\r\n            return;\r\n        }\r\n        if (!onResize) return;\r\n        const patch = computeResize(drag, dx, dy);\r\n        onResize(element.id, patch);\r\n    };\r\n\r\n    const endDrag = (e: ReactPointerEvent<HTMLElement>) => {\r\n        const drag = dragRef.current;\r\n        if (!drag) return;\r\n        try {\r\n            (e.target as HTMLElement).releasePointerCapture(e.pointerId);\r\n        } catch {\r\n            \/* element may have unmounted; ignore *\/\r\n        }\r\n        const wasMove = drag.didMove;\r\n        dragRef.current = null;\r\n        if (!wasMove && onSelect) onSelect(element.id);\r\n    };\r\n\r\n    const box: CSSProperties = {\r\n        position: \"absolute\",\r\n        left: `${left}px`,\r\n        top: `${top}px`,\r\n        width: `${width}px`,\r\n        height: `${height}px`,\r\n        transform: element.rotation ? `rotate(${element.rotation}deg)` : undefined,\r\n        transformOrigin: \"center center\",\r\n        zIndex: element.z ?? \"auto\",\r\n        outline: selected ? \"2px solid #8b5cf6\" : undefined,\r\n        outlineOffset: selected ? 2 : undefined,\r\n        cursor: canMove ? \"move\" : interactive ? \"pointer\" : \"default\",\r\n        touchAction: canMove ? \"none\" : undefined,\r\n        ...(buildAnimation ? buildEnterStyle(buildAnimation, buildDelay) : null),\r\n    };\r\n\r\n    const rendered =\r\n        renderInner({ element, theme, slideWidthPx, editing, selected, onContentChange, paraReveal })\r\n        ?? renderElement?.(element, slideWidthPx)\r\n        ?? elementPlaceholder(element);\r\n\r\n    \/\/ Whole-element hyperlink \u2014 a click target in the viewer (never while\r\n    \/\/ editing, so the link doesn't swallow selection). Fills the element box.\r\n    const inner =\r\n        element.href && !editing ? (\r\n            <a\r\n                href={element.href}\r\n                target=\"_blank\"\r\n                rel=\"noreferrer\"\r\n                style={{ display: \"block\", width: \"100%\", height: \"100%\", color: \"inherit\", textDecoration: \"inherit\" }}\r\n                data-fancy-slides-href=\"\"\r\n            >\r\n                {rendered}\r\n            <\/a>\r\n        ) : (\r\n            rendered\r\n        );\r\n\r\n    return (\r\n        <div\r\n            className={buildAnimation ? \"fs-build-enter\" : undefined}\r\n            style={box}\r\n            data-fancy-slides-element={element.id}\r\n            data-fancy-slides-element-type={element.type}\r\n            data-fancy-slides-build={buildAnimation ? \"\" : undefined}\r\n            onPointerDown={canMove ? startDrag(\"move\") : undefined}\r\n            onPointerMove={canMove ? onPointerMove : undefined}\r\n            onPointerUp={canMove ? endDrag : undefined}\r\n            onPointerCancel={canMove ? endDrag : undefined}\r\n            onClick={(e) => {\r\n                \/\/ Click fallback for non-pointer environments \/ non-movable elements.\r\n                if (!onSelect || canMove) return;\r\n                e.stopPropagation();\r\n                onSelect(element.id);\r\n            }}\r\n        >\r\n            {inner}\r\n            {canResize && (\r\n                <ResizeHandles\r\n                    onStart={(anchor) => startDrag(anchor)}\r\n                    onMove={onPointerMove}\r\n                    onEnd={endDrag}\r\n                \/>\r\n            )}\r\n        <\/div>\r\n    );\r\n}\r\n\r\ninterface ResizeHandlesProps {\r\n    onStart: (anchor: ResizeAnchor) => (e: ReactPointerEvent<HTMLElement>) => void;\r\n    onMove: (e: ReactPointerEvent<HTMLElement>) => void;\r\n    onEnd: (e: ReactPointerEvent<HTMLElement>) => void;\r\n}\r\n\r\nfunction ResizeHandles({ onStart, onMove, onEnd }: ResizeHandlesProps) {\r\n    const anchors: Array<{ anchor: ResizeAnchor; style: CSSProperties; cursor: string }> = [\r\n        { anchor: \"nw\", style: { left: -5, top: -5 }, cursor: \"nwse-resize\" },\r\n        { anchor: \"n\", style: { left: \"calc(50% - 5px)\", top: -5 }, cursor: \"ns-resize\" },\r\n        { anchor: \"ne\", style: { right: -5, top: -5 }, cursor: \"nesw-resize\" },\r\n        { anchor: \"e\", style: { right: -5, top: \"calc(50% - 5px)\" }, cursor: \"ew-resize\" },\r\n        { anchor: \"se\", style: { right: -5, bottom: -5 }, cursor: \"nwse-resize\" },\r\n        { anchor: \"s\", style: { left: \"calc(50% - 5px)\", bottom: -5 }, cursor: \"ns-resize\" },\r\n        { anchor: \"sw\", style: { left: -5, bottom: -5 }, cursor: \"nesw-resize\" },\r\n        { anchor: \"w\", style: { left: -5, top: \"calc(50% - 5px)\" }, cursor: \"ew-resize\" },\r\n    ];\r\n    return (\r\n        <>\r\n            {anchors.map(({ anchor, style, cursor }) => (\r\n                <div\r\n                    key={anchor}\r\n                    style={{\r\n                        position: \"absolute\",\r\n                        width: 10,\r\n                        height: 10,\r\n                        background: \"#ffffff\",\r\n                        border: \"1.5px solid #8b5cf6\",\r\n                        borderRadius: 2,\r\n                        cursor,\r\n                        touchAction: \"none\",\r\n                        boxShadow: \"0 1px 2px rgba(0,0,0,0.15)\",\r\n                        ...style,\r\n                    }}\r\n                    data-fancy-slides-resize-handle={anchor}\r\n                    onPointerDown={onStart(anchor)}\r\n                    onPointerMove={onMove}\r\n                    onPointerUp={onEnd}\r\n                    onPointerCancel={onEnd}\r\n                \/>\r\n            ))}\r\n        <\/>\r\n    );\r\n}\r\n\r\ninterface RenderInnerArgs {\r\n    element: SlideElement;\r\n    theme: Theme;\r\n    slideWidthPx: number;\r\n    editing: boolean;\r\n    selected: boolean;\r\n    onContentChange?: (elementId: string, content: string) => void;\r\n    paraReveal?: ParaReveal;\r\n}\r\n\r\nfunction renderInner({ element, theme, slideWidthPx, editing, selected, onContentChange, paraReveal }: RenderInnerArgs): ReactNode | undefined {\r\n    switch (element.type) {\r\n        case \"text\":\r\n            return (\r\n                <TextElementRenderer\r\n                    element={element}\r\n                    theme={theme}\r\n                    slideWidthPx={slideWidthPx}\r\n                    editing={editing}\r\n                    selected={selected}\r\n                    onContentChange={onContentChange ? (c) => onContentChange(element.id, c) : undefined}\r\n                    paraReveal={paraReveal}\r\n                \/>\r\n            );\r\n        case \"image\":\r\n            return <ImageElementRenderer element={element} \/>;\r\n        case \"shape\":\r\n            return <ShapeElementRenderer element={element} theme={theme} slideWidthPx={slideWidthPx} \/>;\r\n        case \"chart\":\r\n        case \"code\":\r\n        case \"table\":\r\n        case \"embed\":\r\n            \/\/ These render via consumer-provided `renderElement` so we don't\r\n            \/\/ pull fancy-echarts \/ fancy-code \/ etc. into the package's static\r\n            \/\/ graph. Hosts opt into the default registry from\r\n            \/\/ `@particle-academy\/fancy-slides\/registry`.\r\n            return undefined;\r\n        default:\r\n            return null;\r\n    }\r\n}\r\n\r\n\/**\r\n * Built-in fallback for the optional element types (chart \/ code \/ table \/\r\n * embed) when the host hasn't wired a `renderElement` (or it returned\r\n * undefined). Without this the element would render blank, making the\r\n * toolbar's Insert buttons look broken. The placeholder keeps every insert\r\n * visible and nudges the dev toward the default registry. Hosts that want the\r\n * real chart\/code\/table render pass `renderElement` from\r\n * `@particle-academy\/fancy-slides\/registry`.\r\n *\/\r\nfunction elementPlaceholder(element: SlideElement): ReactNode {\r\n    if (element.type !== \"chart\" && element.type !== \"code\" && element.type !== \"table\" && element.type !== \"embed\") {\r\n        return null;\r\n    }\r\n    const label = element.type.charAt(0).toUpperCase() + element.type.slice(1);\r\n    return (\r\n        <div\r\n            style={{\r\n                width: \"100%\",\r\n                height: \"100%\",\r\n                display: \"flex\",\r\n                flexDirection: \"column\",\r\n                alignItems: \"center\",\r\n                justifyContent: \"center\",\r\n                gap: \"0.35em\",\r\n                textAlign: \"center\",\r\n                padding: \"0.5em\",\r\n                boxSizing: \"border-box\",\r\n                border: \"1px dashed currentColor\",\r\n                borderRadius: 8,\r\n                opacity: 0.55,\r\n                overflow: \"hidden\",\r\n            }}\r\n        >\r\n            <span style={{ fontWeight: 600 }}>{label}<\/span>\r\n            <span style={{ fontSize: \"0.7em\", opacity: 0.8 }}>Pass renderElement to render<\/span>\r\n        <\/div>\r\n    );\r\n}\r\n\r\nfunction orderedElements(elements: SlideElement[]): SlideElement[] {\r\n    \/\/ Elements without a `z` keep their array order; explicit z overrides win.\r\n    return [...elements].sort((a, b) => {\r\n        const az = a.z ?? -1;\r\n        const bz = b.z ?? -1;\r\n        if (az === bz) return 0;\r\n        return az < bz ? -1 : 1;\r\n    });\r\n}\r\n\r\nfunction clamp(v: number, min: number, max: number): number {\r\n    return Math.max(min, Math.min(max, v));\r\n}\r\n\r\n\/**\r\n * Given a drag delta + which anchor is being dragged, compute the patch\r\n * `{ x, y, w, h }` to apply. Clamps so dimensions stay >= MIN_DIM and the\r\n * element stays within the slide. Opposite edges stay fixed (e.g. dragging\r\n * the W handle moves the left edge but not the right).\r\n *\/\r\nfunction computeResize(drag: DragState, dx: number, dy: number): { x: number; y: number; w: number; h: number } {\r\n    let { startX: x, startY: y, startW: w, startH: h } = drag;\r\n    const right = drag.startX + drag.startW;\r\n    const bottom = drag.startY + drag.startH;\r\n    const anchor = drag.mode as ResizeAnchor;\r\n\r\n    if (anchor.includes(\"w\")) {\r\n        const newX = clamp(drag.startX + dx, 0, right - MIN_DIM);\r\n        x = newX;\r\n        w = right - newX;\r\n    } else if (anchor.includes(\"e\")) {\r\n        w = clamp(drag.startW + dx, MIN_DIM, 1 - drag.startX);\r\n    }\r\n    if (anchor.includes(\"n\")) {\r\n        const newY = clamp(drag.startY + dy, 0, bottom - MIN_DIM);\r\n        y = newY;\r\n        h = bottom - newY;\r\n    } else if (anchor.includes(\"s\")) {\r\n        h = clamp(drag.startH + dy, MIN_DIM, 1 - drag.startY);\r\n    }\r\n    return { x, y, w, h };\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/slide\/Slide.tsx"},{"path":"components\/fancy\/slide\/builds-style.ts","content":"\/**\r\n * Pure-CSS entrance animations for element builds. Mirrors the slide-level\r\n * transition approach in `SlideViewer`: a set of keyframes wrapped in a\r\n * `prefers-reduced-motion: no-preference` guard, plus a helper that picks the\r\n * right `animation-name` + timing for a given element animation.\r\n *\r\n * No runtime deps \u2014 keyframes are injected via a `<style>` tag by `<Slide>`.\r\n *\/\r\n\r\nimport type { CSSProperties } from \"react\";\r\nimport type { ElementAnimation } from \"..\/..\/types\";\r\n\r\nconst DEFAULT_BUILD_DURATION = 500;\r\nconst EASE = \"cubic-bezier(0.16, 1, 0.3, 1)\"; \/\/ ease-out\r\n\r\n\/**\r\n * Inline style that plays an element's entrance build. `delay` is the\r\n * *effective* delay already resolved for with-prev \/ after-prev chaining.\r\n * Under reduced-motion the keyframes are disabled in CSS, so the element\r\n * simply appears at its final frame.\r\n *\/\r\nexport function buildEnterStyle(animation: ElementAnimation, effectiveDelay: number): CSSProperties {\r\n    const duration = animation.duration ?? DEFAULT_BUILD_DURATION;\r\n    const dir = animation.direction ?? \"left\";\r\n    let name: string;\r\n    switch (animation.effect) {\r\n        case \"fade\":\r\n            name = \"fs-build-fade\";\r\n            break;\r\n        case \"zoom\":\r\n            name = \"fs-build-zoom\";\r\n            break;\r\n        case \"fly-in\":\r\n            name = `fs-build-fly-${dir}`;\r\n            break;\r\n        case \"wipe\":\r\n            name = `fs-build-wipe-${dir}`;\r\n            break;\r\n        default:\r\n            name = \"fs-build-fade\";\r\n    }\r\n    return {\r\n        animationName: name,\r\n        animationDuration: `${duration}ms`,\r\n        animationDelay: `${effectiveDelay}ms`,\r\n        animationTimingFunction: EASE,\r\n        animationFillMode: \"both\",\r\n    };\r\n}\r\n\r\n\/**\r\n * Keyframes for every build effect. `fly-in` translates from the named\r\n * direction; `wipe` reveals via a `clip-path` inset growing from that edge.\r\n * Reduced-motion users get no animation \u2014 `animation-fill-mode: both` would\r\n * otherwise pin the `from` frame, so we disable the build animations entirely\r\n * (the elements are still gated on clicks, they just appear instantly).\r\n *\/\r\nexport const BUILD_KEYFRAMES = `\r\n@media (prefers-reduced-motion: reduce) {\r\n    .fs-build-enter { animation: none !important; }\r\n}\r\n@media (prefers-reduced-motion: no-preference) {\r\n    @keyframes fs-build-fade {\r\n        from { opacity: 0; }\r\n        to { opacity: 1; }\r\n    }\r\n    @keyframes fs-build-zoom {\r\n        from { opacity: 0; transform: scale(0.8); }\r\n        to { opacity: 1; transform: scale(1); }\r\n    }\r\n    @keyframes fs-build-fly-left {\r\n        from { opacity: 0; transform: translateX(-24%); }\r\n        to { opacity: 1; transform: translateX(0); }\r\n    }\r\n    @keyframes fs-build-fly-right {\r\n        from { opacity: 0; transform: translateX(24%); }\r\n        to { opacity: 1; transform: translateX(0); }\r\n    }\r\n    @keyframes fs-build-fly-up {\r\n        from { opacity: 0; transform: translateY(24%); }\r\n        to { opacity: 1; transform: translateY(0); }\r\n    }\r\n    @keyframes fs-build-fly-down {\r\n        from { opacity: 0; transform: translateY(-24%); }\r\n        to { opacity: 1; transform: translateY(0); }\r\n    }\r\n    \/* wipe: clip-path inset reveals from the named edge toward the opposite one.\r\n       inset(top right bottom left) \u2014 start fully clipped on the far side. *\/\r\n    @keyframes fs-build-wipe-left {\r\n        from { clip-path: inset(0 100% 0 0); }\r\n        to { clip-path: inset(0 0 0 0); }\r\n    }\r\n    @keyframes fs-build-wipe-right {\r\n        from { clip-path: inset(0 0 0 100%); }\r\n        to { clip-path: inset(0 0 0 0); }\r\n    }\r\n    @keyframes fs-build-wipe-up {\r\n        from { clip-path: inset(100% 0 0 0); }\r\n        to { clip-path: inset(0 0 0 0); }\r\n    }\r\n    @keyframes fs-build-wipe-down {\r\n        from { clip-path: inset(0 0 100% 0); }\r\n        to { clip-path: inset(0 0 0 0); }\r\n    }\r\n}\r\n`;\r\n","type":"registry:ui","target":"components\/fancy\/slide\/builds-style.ts"},{"path":"components\/fancy\/slide\/index.ts","content":"export { Slide } from \".\/Slide\";\r\nexport type { SlideProps } from \".\/Slide\";\r\n","type":"registry:ui","target":"components\/fancy\/slide\/index.ts"},{"path":"components\/fancy\/slide\/slide-context.ts","content":"import { createContext, useContext } from \"react\";\r\nimport type { Theme } from \"..\/..\/types\";\r\n\r\n\/**\r\n * What the surrounding <Slide> knows about itself. Exposed to children\r\n * (including `renderElement` callbacks and registry hosts like chart-host)\r\n * via React context so they can react to the deck's theme without the\r\n * renderElement signature having to carry it around.\r\n *\/\r\nexport interface SlideContextValue {\r\n    \/** Fully-resolved theme (merged with defaults). *\/\r\n    theme: Theme;\r\n    \/**\r\n     * Heuristic: true when the resolved background is dark enough that\r\n     * native-rendered widgets (charts, code blocks) should use a dark variant.\r\n     * Computed once from `theme.colors.background` luminance.\r\n     *\/\r\n    isDark: boolean;\r\n    \/** Slide width in CSS pixels at the current render size. *\/\r\n    slideWidthPx: number;\r\n}\r\n\r\nexport const SlideContext = createContext<SlideContextValue | null>(null);\r\n\r\n\/**\r\n * Read the surrounding <Slide>'s context. Returns null when called outside\r\n * a Slide \u2014 chart-host \/ code-host can use that signal to fall back to\r\n * a sensible default (light theme, no resize anchor).\r\n *\/\r\nexport function useSlideContext(): SlideContextValue | null {\r\n    return useContext(SlideContext);\r\n}\r\n\r\n\/**\r\n * Convenience: returns the resolved theme, or undefined when the caller\r\n * isn't inside a Slide.\r\n *\/\r\nexport function useSlideTheme(): Theme | undefined {\r\n    return useContext(SlideContext)?.theme;\r\n}\r\n\r\n\/**\r\n * Convenience: returns true when the surrounding slide's background reads\r\n * as \"dark enough\" \u2014 useful for charts\/code blocks that ship a dark variant.\r\n *\/\r\nexport function useIsDarkSlide(): boolean {\r\n    return useContext(SlideContext)?.isDark ?? false;\r\n}\r\n\r\n\/** Compute `isDark` from a hex\/rgb colour. Used by Slide when building the context. *\/\r\nexport function isDarkColor(color: string): boolean {\r\n    \/\/ Normalise #rgb \/ #rrggbb \/ rgb()\/rgba() to a triple.\r\n    const m = color.match(\/^#([0-9a-f]{3,8})$\/i);\r\n    if (m) {\r\n        let hex = m[1];\r\n        if (hex.length === 3) {\r\n            hex = hex.split(\"\").map((c) => c + c).join(\"\");\r\n        }\r\n        if (hex.length >= 6) {\r\n            const r = parseInt(hex.slice(0, 2), 16);\r\n            const g = parseInt(hex.slice(2, 4), 16);\r\n            const b = parseInt(hex.slice(4, 6), 16);\r\n            return relativeLuminance(r, g, b) < 0.5;\r\n        }\r\n    }\r\n    const rgb = color.match(\/rgba?\\(([^()]+)\\)\/i);\r\n    if (rgb) {\r\n        const [r, g, b] = rgb[1].split(\",\").map((s) => parseInt(s.trim(), 10));\r\n        if (!Number.isNaN(r) && !Number.isNaN(g) && !Number.isNaN(b)) {\r\n            return relativeLuminance(r, g, b) < 0.5;\r\n        }\r\n    }\r\n    return false;\r\n}\r\n\r\nfunction relativeLuminance(r: number, g: number, b: number): number {\r\n    \/\/ Standard sRGB \u2192 relative luminance.\r\n    const toLinear = (c: number) => {\r\n        const v = c \/ 255;\r\n        return v <= 0.03928 ? v \/ 12.92 : Math.pow((v + 0.055) \/ 1.055, 2.4);\r\n    };\r\n    return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);\r\n}\r\n","type":"registry:ui","target":"components\/fancy\/slide\/slide-context.ts"}]}