{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "realmorphism-rolling-pickers", "type": "registry:block", "title": "Realmorphism Rolling Pickers", "description": "Y-axis rolling pickers: compact doc-type icons, expand-inline text toolbar, and showroom figlet wheel.", "author": "LT Lo TeknowledG", "registryDependencies": [ "http://localhost:3050/registry/realmorphism.json" ], "dependencies": [ "embla-carousel", "embla-carousel-react" ], "files": [ { "type": "registry:lib", "path": "rolling-picker-types.ts", "target": "components/realmorphism/ui/rolling-picker-types.ts", "content": "import type { ReactNode } from \"react\";\n\n/** One row in a Y-axis rolling picker wheel. */\nexport type RollingPickerItem = {\n value: string;\n label: string;\n slide?: ReactNode;\n /** Receives whether this row is the wheel center (for active/inactive styling). */\n renderSlide?: (active: boolean) => ReactNode;\n labelSlide?: ReactNode;\n renderLabelSlide?: (active: boolean) => ReactNode;\n};\n\n/** Shared props for all rolling picker layouts (compact toolbar, expand-inline, showroom). */\nexport type RollingPickerProps = {\n items: RollingPickerItem[];\n value: string;\n onChange: (value: string) => void;\n onUserSelect?: (value: string) => void;\n ariaLabel: string;\n viewportClassName?: string;\n showTextWhileScrolling?: boolean;\n alwaysShowLabel?: boolean;\n /** Compact toolbar: brief snap label above the wheel after user spin. */\n showSnapHint?: boolean;\n /** Expand-inline: neighbors visible while scrolling (glyph toolbar strip). */\n wheelExpandOnScroll?: boolean;\n /** Showroom: full-height wheel with neighbors always visible. */\n wheelPinnedOpen?: boolean;\n wheelTransparent?: boolean;\n wheelNeighborCount?: number;\n slideHeightPx?: number;\n wheelScrollStep?: number;\n wheelMomentum?: boolean;\n wheelMomentumGain?: number;\n wheelMomentumFriction?: number;\n wheelMomentumDuration?: number;\n /** While spinning show label; when snapped show slide (title → rich preview). */\n wheelSettledShowsSlide?: boolean;\n inlinePanelClassName?: string;\n wheelFullWidth?: boolean;\n /** Embla infinite loop — on for every multi-item wheel unless explicitly disabled. */\n loop?: boolean;\n /** E2E / kit selector: compact | expand | showroom */\n rollerType?: string;\n};\n\n/** Compact icon or short-label toolbar roller (operator doc type, engine switch). */\nexport type CompactRollingPickerProps = Pick<\n RollingPickerProps,\n | \"items\"\n | \"value\"\n | \"onChange\"\n | \"onUserSelect\"\n | \"ariaLabel\"\n | \"viewportClassName\"\n | \"showTextWhileScrolling\"\n | \"alwaysShowLabel\"\n | \"showSnapHint\"\n | \"loop\"\n | \"rollerType\"\n>;\n\n/** Full-width toolbar strip with neighbor band (1-line title roller). */\nexport type ExpandRollingPickerProps = CompactRollingPickerProps &\n Pick<\n RollingPickerProps,\n | \"wheelExpandOnScroll\"\n | \"wheelTransparent\"\n | \"wheelNeighborCount\"\n | \"slideHeightPx\"\n | \"wheelScrollStep\"\n | \"wheelMomentum\"\n | \"wheelMomentumGain\"\n | \"wheelMomentumFriction\"\n | \"wheelMomentumDuration\"\n | \"wheelSettledShowsSlide\"\n | \"inlinePanelClassName\"\n | \"wheelFullWidth\"\n > & {\n wheelExpandOnScroll: true;\n };\n\n/** Pinned square showroom wheel with rich center row + text neighbors. */\nexport type ShowroomRollingPickerProps = Pick<\n RollingPickerProps,\n | \"items\"\n | \"value\"\n | \"onChange\"\n | \"onUserSelect\"\n | \"ariaLabel\"\n | \"viewportClassName\"\n | \"wheelNeighborCount\"\n | \"slideHeightPx\"\n | \"wheelScrollStep\"\n | \"wheelMomentum\"\n | \"loop\"\n | \"rollerType\"\n> & {\n wheelExpandOnScroll: true;\n wheelPinnedOpen: true;\n};\n" }, { "type": "registry:component", "path": "rolling-picker.tsx", "target": "components/realmorphism/ui/rolling-picker.tsx", "content": "\"use client\";\n\nimport { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type CSSProperties } from \"react\";\nimport type { EmblaCarouselType } from \"embla-carousel\";\nimport useEmblaCarousel from \"embla-carousel-react\";\nimport { cn } from \"@/lib/utils\";\nimport {\n applyIosPickerSlideStyles,\n applyPinnedShowroomSlideStyles,\n findClosestSnapIndex,\n} from \"@/lib/realmorphism/embla-ios-picker-loop\";\nimport {\n loopBoundaryWrapTarget,\n normalizeIndex,\n stepIndex,\n} from \"@/lib/realmorphism/rolling-picker-loop\";\nimport floatWheelStyles from \"./float-wheel-picker.module.css\";\nimport type { RollingPickerItem, RollingPickerProps } from \"./rolling-picker-types\";\n\nexport type { RollingPickerItem, RollingPickerProps } from \"./rolling-picker-types\";\nexport type {\n CompactRollingPickerProps,\n ExpandRollingPickerProps,\n ShowroomRollingPickerProps,\n} from \"./rolling-picker-types\";\n\nconst SNAP_ALIGN_THRESHOLD_PX = 0.5;\nconst WHEEL_DELTA_TRIGGER_PX = 4;\n/** Expand wheels (glyph toolbar, showroom) — overshoot + snap-back settle. */\nconst EXPAND_WHEEL_MOMENTUM_GAIN = 1.12;\nconst EXPAND_WHEEL_MOMENTUM_FRICTION = 0.93;\nconst EXPAND_WHEEL_MOMENTUM_DURATION = 62;\nconst EXPAND_DRAG_FLICK_VELOCITY_SCALE = 16;\n/** Pinned Price-Is-Right wheels — coast then dead-stop (no spring bounce). */\nconst PINNED_WHEEL_MOMENTUM_GAIN = 1.42;\nconst PINNED_WHEEL_MOMENTUM_FRICTION = 0.84;\nconst PINNED_WHEEL_MOMENTUM_DURATION = 38;\nconst PINNED_DRAG_FLICK_VELOCITY_SCALE = 26;\nconst PINNED_DRAG_FLICK_MAX_SLIDES = 36;\nconst PINNED_SNAP_DURATION = 16;\nconst PINNED_DRAG_THRESHOLD_PX = 1;\n/** Compact icon rollers (operator doc type, engine, export). */\nconst COMPACT_WHEEL_MOMENTUM_GAIN = 0.98;\nconst COMPACT_WHEEL_MOMENTUM_FRICTION = 0.9;\nconst COMPACT_WHEEL_MOMENTUM_DURATION = 54;\nconst COMPACT_DRAG_FLICK_VELOCITY_SCALE = 11;\nconst SNAP_HINT_VISIBLE_MS = 1400;\n\nfunction snapOffsetPx(emblaApi: EmblaCarouselType, index: number): number {\n const { scrollSnaps, location } = emblaApi.internalEngine();\n return Math.abs((scrollSnaps[index] ?? 0) - location.get());\n}\n\nfunction wheelStepsFromDelta(deltaY: number, baseStep: number): number {\n return Math.abs(deltaY) >= WHEEL_DELTA_TRIGGER_PX ? baseStep : 0;\n}\n\nfunction indexForValue(items: RollingPickerItem[], target: string): number {\n const idx = items.findIndex((item) => item.value.toLowerCase() === target.toLowerCase());\n return idx >= 0 ? idx : 0;\n}\n\nexport function RollingPicker({\n items,\n value,\n onChange,\n onUserSelect,\n ariaLabel,\n viewportClassName = \"h-7 w-7\",\n showTextWhileScrolling = true,\n alwaysShowLabel = false,\n showSnapHint = false,\n wheelExpandOnScroll = false,\n wheelPinnedOpen = false,\n wheelTransparent = false,\n wheelNeighborCount = 3,\n slideHeightPx = 28,\n wheelScrollStep = 1,\n wheelMomentum,\n wheelMomentumGain,\n wheelMomentumFriction,\n wheelMomentumDuration,\n wheelSettledShowsSlide = false,\n inlinePanelClassName,\n wheelFullWidth = false,\n loop: loopProp,\n rollerType,\n}: RollingPickerProps) {\n /** All multi-item pickers loop unless explicitly disabled (JS wrap + jump scroll). */\n const loopEnabled = loopProp ?? items.length > 1;\n /** Pinned showroom uses native Embla loop for continuous one-direction coast. */\n const emblaLoopEngine = wheelPinnedOpen && loopEnabled;\n const inlinePanelFullWidth =\n wheelFullWidth ||\n inlinePanelClassName?.includes(\"w-full\") ||\n viewportClassName.includes(\"w-full\");\n const compactToolbarFill =\n inlinePanelFullWidth || viewportClassName.includes(\"max-w-none\");\n const useWheelMomentum = wheelMomentum ?? true;\n const useExpandMomentum = wheelExpandOnScroll || wheelPinnedOpen;\n const usePinnedMomentum = wheelPinnedOpen;\n const dragFreeEnabled = !loopEnabled || wheelPinnedOpen;\n const resolvedMomentumGain =\n wheelMomentumGain ??\n (usePinnedMomentum\n ? PINNED_WHEEL_MOMENTUM_GAIN\n : useExpandMomentum\n ? EXPAND_WHEEL_MOMENTUM_GAIN\n : COMPACT_WHEEL_MOMENTUM_GAIN);\n const resolvedMomentumFriction =\n wheelMomentumFriction ??\n (usePinnedMomentum\n ? PINNED_WHEEL_MOMENTUM_FRICTION\n : useExpandMomentum\n ? EXPAND_WHEEL_MOMENTUM_FRICTION\n : COMPACT_WHEEL_MOMENTUM_FRICTION);\n const resolvedMomentumDuration =\n wheelMomentumDuration ??\n (usePinnedMomentum\n ? PINNED_WHEEL_MOMENTUM_DURATION\n : useExpandMomentum\n ? EXPAND_WHEEL_MOMENTUM_DURATION\n : COMPACT_WHEEL_MOMENTUM_DURATION);\n const resolvedDragFlickScale = usePinnedMomentum\n ? PINNED_DRAG_FLICK_VELOCITY_SCALE\n : useExpandMomentum\n ? EXPAND_DRAG_FLICK_VELOCITY_SCALE\n : COMPACT_DRAG_FLICK_VELOCITY_SCALE;\n const valueRef = useRef(value);\n valueRef.current = value;\n\n const itemsRef = useRef(items);\n itemsRef.current = items;\n\n const onChangeRef = useRef(onChange);\n onChangeRef.current = onChange;\n\n const onUserSelectRef = useRef(onUserSelect);\n onUserSelectRef.current = onUserSelect;\n\n const isProgrammaticScrollRef = useRef(false);\n const itemsLengthRef = useRef(items.length);\n const wheelInitDoneRef = useRef(false);\n const neighborsVisibleRef = useRef(false);\n const [showLabels, setShowLabels] = useState(false);\n const showLabelsRef = useRef(false);\n const [snapHint, setSnapHint] = useState(\"\");\n const snapHintTimerRef = useRef | null>(null);\n const [wheelSettled, setWheelSettled] = useState(true);\n const [centerIndex, setCenterIndex] = useState(() => indexForValue(items, value));\n\n const userDraggedRef = useRef(false);\n const userWheelPendingRef = useRef(false);\n const interactionDirectionRef = useRef(0);\n const pointerStartYRef = useRef(0);\n const handleWheelRef = useRef<(event: WheelEvent) => void>(() => {});\n const pickerHostRef = useRef(null);\n const wheelNotifyDebounceRef = useRef | null>(null);\n\n const setScrollingLabels = useCallback((active: boolean) => {\n showLabelsRef.current = active;\n setShowLabels(active);\n if (active && snapHintTimerRef.current) {\n clearTimeout(snapHintTimerRef.current);\n snapHintTimerRef.current = null;\n setSnapHint(\"\");\n }\n }, []);\n\n const showSnapHintLabel = useCallback(\n (embla: EmblaCarouselType) => {\n if (!showSnapHint || showLabelsRef.current || wheelExpandOnScroll) return;\n const closest = findClosestSnapIndex(embla);\n if (snapOffsetPx(embla, closest) > SNAP_ALIGN_THRESHOLD_PX) return;\n\n const list = itemsRef.current;\n const entry = list[normalizeIndex(closest, list.length)];\n if (!entry) return;\n\n setSnapHint(entry.label);\n if (snapHintTimerRef.current) clearTimeout(snapHintTimerRef.current);\n snapHintTimerRef.current = setTimeout(() => {\n setSnapHint(\"\");\n snapHintTimerRef.current = null;\n }, SNAP_HINT_VISIBLE_MS);\n },\n [showSnapHint, wheelExpandOnScroll],\n );\n\n const maxNeighborSteps = Math.max(0, Math.floor((wheelNeighborCount - 1) / 2));\n const expandedWheelHeightPx = slideHeightPx * wheelNeighborCount;\n const inlineExpandToolbar = wheelExpandOnScroll && !wheelPinnedOpen;\n const inlineExpandSpinning = inlineExpandToolbar && !wheelSettled;\n const emblaAlign = (wheelExpandOnScroll ? \"center\" : \"start\") as \"center\" | \"start\";\n const wheelStageHeightPx = wheelPinnedOpen\n ? expandedWheelHeightPx\n : inlineExpandToolbar && wheelSettled\n ? slideHeightPx\n : expandedWheelHeightPx;\n\n const iosPickerStyleOptions = useMemo(\n () => ({\n compact: true,\n rolodex: false,\n itemSizePx: slideHeightPx,\n maxNeighborSteps,\n centerEmphasis: wheelPinnedOpen,\n }),\n [slideHeightPx, maxNeighborSteps, wheelPinnedOpen],\n );\n\n /** Compact / inline expand toolbar — clear Embla-applied opacity so center title stays visible. */\n const resetCompactSlideStyles = useCallback((embla: EmblaCarouselType) => {\n embla.slideNodes().forEach((node) => {\n node.style.opacity = \"\";\n node.style.pointerEvents = \"\";\n node.style.transform = \"\";\n const inner = node.querySelector(\"[data-ios-picker-inner]\");\n if (inner) {\n inner.style.opacity = \"\";\n inner.style.pointerEvents = \"\";\n inner.style.transform = \"\";\n }\n });\n }, []);\n\n const hideNeighborPreviews = useCallback(\n (embla: EmblaCarouselType) => {\n neighborsVisibleRef.current = false;\n setWheelSettled(true);\n const center = findClosestSnapIndex(embla);\n setCenterIndex(center);\n // Inline expand toolbar: clip to one row — do not zero opacity (breaks long catalogs).\n if (wheelExpandOnScrollRef.current && !wheelPinnedOpenRef.current) {\n resetCompactSlideStyles(embla);\n return;\n }\n applyIosPickerSlideStyles(embla, \"settle\", {\n ...iosPickerStyleOptions,\n maxNeighborSteps: 0,\n });\n },\n [iosPickerStyleOptions, resetCompactSlideStyles],\n );\n\n const showNeighborPreviews = useCallback(\n (embla: EmblaCarouselType, eventName?: string) => {\n neighborsVisibleRef.current = true;\n setWheelSettled(false);\n const center = findClosestSnapIndex(embla);\n setCenterIndex(center);\n if (wheelPinnedOpen) {\n applyPinnedShowroomSlideStyles(embla, center, iosPickerStyleOptions);\n return;\n }\n applyIosPickerSlideStyles(embla, eventName, iosPickerStyleOptions);\n },\n [iosPickerStyleOptions, wheelPinnedOpen],\n );\n\n const showCompactSlidesDuringScroll = useCallback(\n (embla: EmblaCarouselType) => {\n resetCompactSlideStyles(embla);\n },\n [resetCompactSlideStyles],\n );\n\n const applyPinnedWheelAtRest = useCallback(\n (embla: EmblaCarouselType) => {\n neighborsVisibleRef.current = true;\n setWheelSettled(true);\n const center = findClosestSnapIndex(embla);\n setCenterIndex(center);\n applyPinnedShowroomSlideStyles(embla, center, iosPickerStyleOptions);\n },\n [iosPickerStyleOptions],\n );\n\n const settleWheelNeighbors = useCallback(\n (embla: EmblaCarouselType, eventName?: string) => {\n if (wheelPinnedOpen) {\n applyPinnedWheelAtRest(embla);\n } else {\n hideNeighborPreviews(embla);\n }\n },\n [wheelPinnedOpen, applyPinnedWheelAtRest, hideNeighborPreviews],\n );\n\n const endProgrammaticScroll = useCallback((embla: EmblaCarouselType) => {\n isProgrammaticScrollRef.current = false;\n }, []);\n\n const wheelScrollStepRef = useRef(wheelScrollStep);\n wheelScrollStepRef.current = wheelScrollStep;\n const useWheelMomentumRef = useRef(useWheelMomentum);\n useWheelMomentumRef.current = useWheelMomentum;\n const momentumGainRef = useRef(resolvedMomentumGain);\n momentumGainRef.current = resolvedMomentumGain;\n const momentumFrictionRef = useRef(resolvedMomentumFriction);\n momentumFrictionRef.current = resolvedMomentumFriction;\n const momentumDurationRef = useRef(resolvedMomentumDuration);\n momentumDurationRef.current = resolvedMomentumDuration;\n const wheelPinnedOpenRef = useRef(wheelPinnedOpen);\n wheelPinnedOpenRef.current = wheelPinnedOpen;\n const wheelExpandOnScrollRef = useRef(wheelExpandOnScroll);\n wheelExpandOnScrollRef.current = wheelExpandOnScroll;\n const loopEnabledRef = useRef(loopEnabled);\n loopEnabledRef.current = loopEnabled;\n const emblaLoopEngineRef = useRef(emblaLoopEngine);\n emblaLoopEngineRef.current = emblaLoopEngine;\n const dragFlickScaleRef = useRef(resolvedDragFlickScale);\n dragFlickScaleRef.current = resolvedDragFlickScale;\n const slideHeightPxRef = useRef(slideHeightPx);\n slideHeightPxRef.current = slideHeightPx;\n\n const applyWheelMomentum = useCallback((embla: EmblaCarouselType, deltaY: number) => {\n const engine = embla.internalEngine();\n engine.scrollBody\n .useFriction(momentumFrictionRef.current)\n .useDuration(momentumDurationRef.current);\n engine.animation.start();\n engine.scrollTo.distance(deltaY * momentumGainRef.current, false);\n }, []);\n\n const boostDragFlick = useCallback((embla: EmblaCarouselType) => {\n if (!useWheelMomentumRef.current) return;\n const engine = embla.internalEngine();\n const velocity = engine.scrollBody.velocity();\n const minVelocity = wheelPinnedOpenRef.current ? 0.02 : 0.04;\n if (Math.abs(velocity) < minVelocity) return;\n engine.scrollBody\n .useFriction(momentumFrictionRef.current)\n .useDuration(momentumDurationRef.current);\n engine.animation.start();\n let distance = velocity * dragFlickScaleRef.current;\n if (wheelPinnedOpenRef.current) {\n const maxDistance = slideHeightPxRef.current * PINNED_DRAG_FLICK_MAX_SLIDES;\n distance = Math.sign(distance) * Math.min(Math.abs(distance), maxDistance);\n }\n engine.scrollTo.distance(distance, false);\n }, []);\n\n const emblaWheelOptions = useMemo(\n () => ({\n loop: emblaLoopEngine,\n align: emblaAlign,\n containScroll: (emblaLoopEngine || loopEnabled ? false : \"trimSnaps\") as false | \"trimSnaps\",\n dragFree: dragFreeEnabled,\n skipSnaps: dragFreeEnabled,\n dragThreshold: wheelPinnedOpen ? PINNED_DRAG_THRESHOLD_PX : 10,\n }),\n [emblaAlign, loopEnabled, emblaLoopEngine, dragFreeEnabled, wheelPinnedOpen],\n );\n\n const reInitWheel = useCallback(\n (embla: EmblaCarouselType) => {\n embla.reInit(emblaWheelOptions);\n },\n [emblaWheelOptions],\n );\n\n const reInitWheelRef = useRef(reInitWheel);\n reInitWheelRef.current = reInitWheel;\n\n const [emblaRef, emblaApi] = useEmblaCarousel({\n axis: \"y\",\n loop: emblaLoopEngine,\n align: emblaAlign,\n containScroll: emblaLoopEngine || loopEnabled ? (false as const) : (\"trimSnaps\" as const),\n dragFree: dragFreeEnabled,\n skipSnaps: dragFreeEnabled,\n dragThreshold: wheelPinnedOpen ? PINNED_DRAG_THRESHOLD_PX : 10,\n watchDrag: true,\n duration: wheelPinnedOpen ? PINNED_SNAP_DURATION : wheelExpandOnScroll ? 28 : 22,\n });\n\n useEffect(() => {\n if (!emblaApi) return;\n reInitWheel(emblaApi);\n }, [emblaApi, reInitWheel, items.length]);\n\n const resolvedSnapIndex = useCallback((embla: EmblaCarouselType) => {\n if (loopEnabledRef.current && !wheelPinnedOpenRef.current) {\n return embla.selectedScrollSnap();\n }\n return findClosestSnapIndex(embla);\n }, []);\n\n const notifyUserSettled = useCallback((embla: EmblaCarouselType) => {\n const list = itemsRef.current;\n if (list.length === 0) return;\n const index = resolvedSnapIndex(embla);\n const entry = list[normalizeIndex(index, list.length)];\n if (!entry) return;\n if (entry.value !== valueRef.current) {\n onChangeRef.current(entry.value);\n }\n onUserSelectRef.current?.(entry.value);\n setCenterIndex(normalizeIndex(index, list.length));\n userDraggedRef.current = false;\n userWheelPendingRef.current = false;\n interactionDirectionRef.current = 0;\n }, [resolvedSnapIndex]);\n\n /** Pinned showroom — lock nearest snap with no spring overshoot. */\n const finalizePinnedShowroomSpin = useCallback(\n (embla: EmblaCarouselType) => {\n const list = itemsRef.current;\n if (list.length === 0) return;\n const closest = findClosestSnapIndex(embla);\n const entry = list[normalizeIndex(closest, list.length)];\n if (!entry) return;\n\n isProgrammaticScrollRef.current = true;\n embla.internalEngine().scrollBody.useDuration(PINNED_SNAP_DURATION);\n embla.scrollTo(closest, true);\n endProgrammaticScroll(embla);\n\n if (entry.value !== valueRef.current) {\n onChangeRef.current(entry.value);\n }\n onUserSelectRef.current?.(entry.value);\n setCenterIndex(normalizeIndex(closest, list.length));\n userDraggedRef.current = false;\n userWheelPendingRef.current = false;\n interactionDirectionRef.current = 0;\n applyPinnedWheelAtRest(embla);\n },\n [applyPinnedWheelAtRest, endProgrammaticScroll],\n );\n\n const commitSelection = useCallback((embla: EmblaCarouselType) => {\n const list = itemsRef.current;\n if (list.length === 0) return;\n const index = resolvedSnapIndex(embla);\n const entry = list[normalizeIndex(index, list.length)];\n if (!entry) return;\n const userActed = userDraggedRef.current || userWheelPendingRef.current;\n setCenterIndex(normalizeIndex(index, list.length));\n // Pinned showroom: never write controlled value while the user is spinning.\n if (wheelPinnedOpenRef.current && userActed) return;\n if (entry.value !== valueRef.current) {\n onChangeRef.current(entry.value);\n }\n if (!userActed) return;\n onUserSelectRef.current?.(entry.value);\n userDraggedRef.current = false;\n userWheelPendingRef.current = false;\n interactionDirectionRef.current = 0;\n }, [resolvedSnapIndex]);\n\n /** Expand-inline: write ascii / fire onUserSelect once wheel motion stops. */\n const maybeNotifyUserSettled = useCallback(\n (embla: EmblaCarouselType) => {\n if (!userDraggedRef.current && !userWheelPendingRef.current) return;\n notifyUserSettled(embla);\n },\n [notifyUserSettled],\n );\n\n /** Loop wheel: instant jump only when wrapping catalog ends; otherwise animate/momentum. */\n const finishLoopWheelStep = useCallback(\n (embla: EmblaCarouselType, nextIndex: number) => {\n endProgrammaticScroll(embla);\n const count = itemsRef.current.length;\n if (count <= 0) return;\n const normalized = normalizeIndex(nextIndex, count);\n const entry = itemsRef.current[normalized];\n if (entry && entry.value !== valueRef.current) {\n onChangeRef.current(entry.value);\n }\n if ((userDraggedRef.current || userWheelPendingRef.current) && entry) {\n onUserSelectRef.current?.(entry.value);\n }\n setCenterIndex(normalized);\n if (wheelExpandOnScrollRef.current) {\n settleWheelNeighbors(embla, \"settle\");\n } else {\n resetCompactSlideStyles(embla);\n }\n userDraggedRef.current = false;\n userWheelPendingRef.current = false;\n },\n [endProgrammaticScroll, settleWheelNeighbors, resetCompactSlideStyles],\n );\n\n const tryLoopDragWrap = useCallback(\n (embla: EmblaCarouselType): boolean => {\n if (!loopEnabledRef.current || wheelPinnedOpenRef.current) return false;\n const count = itemsRef.current.length;\n if (count <= 1) return false;\n\n const idx = embla.selectedScrollSnap();\n const dir = interactionDirectionRef.current;\n const wrapTo = loopBoundaryWrapTarget(idx, dir, count);\n if (wrapTo == null) return false;\n\n isProgrammaticScrollRef.current = true;\n embla.scrollTo(wrapTo, true);\n finishLoopWheelStep(embla, wrapTo);\n interactionDirectionRef.current = 0;\n return true;\n },\n [finishLoopWheelStep],\n );\n\n /** JS seam jump when pinned loop coast hits a finite catalog edge (fallback). */\n const tryPinnedLoopSeamJump = useCallback(\n (embla: EmblaCarouselType): boolean => {\n if (!loopEnabledRef.current || !wheelPinnedOpenRef.current || emblaLoopEngineRef.current) {\n return false;\n }\n const count = itemsRef.current.length;\n if (count <= 1) return false;\n\n const engine = embla.internalEngine();\n const { scrollSnaps, location } = engine;\n const current = location.get();\n let dir = interactionDirectionRef.current;\n const velocity = engine.scrollBody.velocity();\n if (dir === 0 && Math.abs(velocity) >= 0.01) {\n dir = velocity > 0 ? 1 : -1;\n }\n if (dir === 0) return false;\n\n const threshold = slideHeightPxRef.current * 0.4;\n const minSnap = scrollSnaps[0] ?? 0;\n const maxSnap = scrollSnaps[count - 1] ?? minSnap;\n let wrapTo: number | null = null;\n\n if (dir < 0 && current <= minSnap + threshold) {\n wrapTo = count - 1;\n } else if (dir > 0 && current >= maxSnap - threshold) {\n wrapTo = 0;\n } else {\n const idx = findClosestSnapIndex(embla);\n wrapTo = loopBoundaryWrapTarget(idx, dir, count);\n if (wrapTo == null) return false;\n if (snapOffsetPx(embla, idx) > threshold) return false;\n }\n\n isProgrammaticScrollRef.current = true;\n embla.scrollTo(wrapTo, true);\n endProgrammaticScroll(embla);\n setCenterIndex(wrapTo);\n showNeighborPreviews(embla, \"scroll\");\n return true;\n },\n [endProgrammaticScroll, showNeighborPreviews],\n );\n\n const ensureSnappedToCenter = useCallback((embla: EmblaCarouselType): boolean => {\n const closest = findClosestSnapIndex(embla);\n if (snapOffsetPx(embla, closest) > SNAP_ALIGN_THRESHOLD_PX) {\n isProgrammaticScrollRef.current = true;\n embla.scrollTo(closest);\n return true;\n }\n return false;\n }, []);\n\n useEffect(() => {\n return () => {\n if (snapHintTimerRef.current) clearTimeout(snapHintTimerRef.current);\n if (wheelNotifyDebounceRef.current) clearTimeout(wheelNotifyDebounceRef.current);\n };\n }, []);\n\n useEffect(() => {\n if (!emblaApi || !inlineExpandToolbar) return;\n if (wheelSettled) {\n hideNeighborPreviews(emblaApi);\n }\n }, [emblaApi, inlineExpandToolbar, wheelSettled, hideNeighborPreviews]);\n\n const mountShowroomWheel = useCallback(\n (embla: EmblaCarouselType) => {\n const panel = pickerHostRef.current?.querySelector(\"[data-float-wheel-panel]\");\n const hostHeight = wheelPinnedOpenRef.current\n ? (panel?.clientHeight ?? 0)\n : (embla.rootNode()?.clientHeight ?? 0);\n if (hostHeight < 4) return false;\n\n const index = indexForValue(itemsRef.current, valueRef.current);\n reInitWheelRef.current(embla);\n isProgrammaticScrollRef.current = true;\n embla.scrollTo(index, true);\n requestAnimationFrame(() => {\n endProgrammaticScroll(embla);\n settleWheelNeighbors(embla, \"reInit\");\n });\n return true;\n },\n [endProgrammaticScroll, settleWheelNeighbors],\n );\n\n useEffect(() => {\n if (!emblaApi || !wheelExpandOnScroll || items.length === 0) return;\n\n const initWheel = () => {\n const root = emblaApi.rootNode();\n if (!root) return false;\n return mountShowroomWheel(emblaApi);\n };\n\n if (!wheelInitDoneRef.current) {\n if (initWheel()) wheelInitDoneRef.current = true;\n } else {\n settleWheelNeighbors(emblaApi, \"reInit\");\n }\n\n const root = emblaApi.rootNode();\n if (!root) return;\n\n const observer = new IntersectionObserver(\n (entries) => {\n if (!entries.some((e) => e.isIntersecting)) return;\n if (!initWheel()) return;\n wheelInitDoneRef.current = true;\n },\n { threshold: 0.01 },\n );\n observer.observe(root);\n return () => observer.disconnect();\n }, [emblaApi, wheelExpandOnScroll, items.length, mountShowroomWheel]);\n\n useLayoutEffect(() => {\n if (!emblaApi) return;\n\n const onSelect = () => {\n if (isProgrammaticScrollRef.current) return;\n const engine = emblaApi.internalEngine();\n if (!engine.scrollBody.settled()) return;\n // Pinned showroom: intermediate snap settles during coast must not\n // commit onChange (figlet catalog re-render kills momentum feel).\n if (wheelPinnedOpenRef.current && (userDraggedRef.current || userWheelPendingRef.current)) {\n return;\n }\n commitSelection(emblaApi);\n };\n\n const onEmblaPointerDown = () => {\n userDraggedRef.current = true;\n if (wheelExpandOnScroll) {\n showNeighborPreviews(emblaApi, \"scroll\");\n } else {\n showCompactSlidesDuringScroll(emblaApi);\n if (showTextWhileScrolling) {\n setScrollingLabels(true);\n }\n }\n };\n\n const onNativePointerDown = (event: PointerEvent) => {\n userDraggedRef.current = true;\n interactionDirectionRef.current = 0;\n pointerStartYRef.current = event.clientY;\n };\n\n const onNativePointerUp = (event: PointerEvent) => {\n if (!loopEnabledRef.current || wheelPinnedOpenRef.current) {\n if (useWheelMomentumRef.current) {\n requestAnimationFrame(() => boostDragFlick(emblaApi));\n }\n return;\n }\n\n const dragDeltaY = event.clientY - pointerStartYRef.current;\n if (Math.abs(dragDeltaY) < 10) return;\n\n const dir = dragDeltaY > 0 ? 1 : -1;\n const count = itemsRef.current.length;\n if (count <= 1) return;\n\n const idx = findClosestSnapIndex(emblaApi);\n const atBoundary =\n (idx === 0 && dir < 0) || (idx === count - 1 && dir > 0);\n if (!atBoundary) return;\n\n interactionDirectionRef.current = dir;\n const attemptWrap = () => {\n if (!emblaApi.internalEngine().scrollBody.settled()) return;\n tryLoopDragWrap(emblaApi);\n };\n requestAnimationFrame(attemptWrap);\n };\n\n const onWheel = (event: WheelEvent) => {\n if (event.defaultPrevented) return;\n if (itemsRef.current.length <= 1) return;\n if (Math.abs(event.deltaY) < WHEEL_DELTA_TRIGGER_PX) return;\n\n event.preventDefault();\n event.stopPropagation();\n userDraggedRef.current = true;\n userWheelPendingRef.current = true;\n if (wheelNotifyDebounceRef.current) {\n clearTimeout(wheelNotifyDebounceRef.current);\n wheelNotifyDebounceRef.current = null;\n }\n\n if (wheelExpandOnScroll) {\n showNeighborPreviews(emblaApi, \"scroll\");\n } else {\n showCompactSlidesDuringScroll(emblaApi);\n }\n\n const direction = event.deltaY > 0 ? 1 : -1;\n interactionDirectionRef.current = direction;\n const steps = wheelStepsFromDelta(event.deltaY, wheelScrollStepRef.current);\n\n if (loopEnabledRef.current && !wheelPinnedOpenRef.current) {\n const count = itemsRef.current.length;\n const currentIndex = emblaApi.selectedScrollSnap();\n const nextIndex = normalizeIndex(currentIndex + direction * steps, count);\n if (nextIndex === currentIndex) return;\n\n // Looping wheels are discrete controls. Keep the visual snap and the\n // controlled value in one synchronous step so modulo wraps cannot drift.\n isProgrammaticScrollRef.current = true;\n emblaApi.scrollTo(nextIndex, true);\n finishLoopWheelStep(emblaApi, nextIndex);\n return;\n }\n\n const currentIndex = findClosestSnapIndex(emblaApi);\n const nextIndex = stepIndex(\n currentIndex + direction * steps,\n itemsRef.current.length,\n false,\n );\n\n if (useWheelMomentumRef.current) {\n applyWheelMomentum(emblaApi, event.deltaY);\n return;\n }\n\n isProgrammaticScrollRef.current = true;\n emblaApi.scrollTo(nextIndex);\n };\n\n handleWheelRef.current = onWheel;\n\n let rafId = 0;\n\n const bindInteraction = () => {\n const nodes = new Set();\n if (pickerHostRef.current) nodes.add(pickerHostRef.current);\n const root = emblaApi.rootNode();\n if (root instanceof HTMLElement) nodes.add(root);\n if (nodes.size === 0) return false;\n nodes.forEach((node) => {\n node.addEventListener(\"wheel\", onWheel, { passive: false, capture: true });\n node.addEventListener(\"pointerdown\", onNativePointerDown, { passive: true, capture: true });\n node.addEventListener(\"pointerup\", onNativePointerUp, { passive: true, capture: true });\n });\n return true;\n };\n\n if (!bindInteraction()) {\n rafId = requestAnimationFrame(() => {\n bindInteraction();\n });\n }\n\n const onSettle = () => {\n endProgrammaticScroll(emblaApi);\n\n if (!wheelExpandOnScrollRef.current) {\n const stillCentering = ensureSnappedToCenter(emblaApi);\n if (stillCentering) return;\n }\n\n const dragged = userDraggedRef.current || userWheelPendingRef.current;\n const momentumSpin = dragged && useWheelMomentumRef.current;\n\n if (!momentumSpin && wheelPinnedOpenRef.current) {\n finalizePinnedShowroomSpin(emblaApi);\n } else if (!momentumSpin) {\n commitSelection(emblaApi);\n if (dragged && loopEnabledRef.current) {\n tryLoopDragWrap(emblaApi);\n }\n }\n\n if (wheelExpandOnScroll && !(wheelPinnedOpenRef.current && momentumSpin)) {\n settleWheelNeighbors(emblaApi, \"settle\");\n } else if (!wheelExpandOnScroll) {\n setScrollingLabels(false);\n resetCompactSlideStyles(emblaApi);\n }\n\n if (dragged && !useWheelMomentumRef.current) {\n maybeNotifyUserSettled(emblaApi);\n showSnapHintLabel(emblaApi);\n }\n };\n\n const onScroll = () => {\n const engine = emblaApi.internalEngine();\n if (engine.dragHandler.pointerDown()) {\n const closest = findClosestSnapIndex(emblaApi);\n const prev = emblaApi.selectedScrollSnap();\n if (closest > prev) interactionDirectionRef.current = 1;\n else if (closest < prev) interactionDirectionRef.current = -1;\n }\n if (\n wheelPinnedOpenRef.current &&\n loopEnabledRef.current &&\n (userDraggedRef.current || userWheelPendingRef.current)\n ) {\n const velocity = engine.scrollBody.velocity();\n if (Math.abs(velocity) >= 0.01) {\n interactionDirectionRef.current = velocity > 0 ? 1 : -1;\n }\n tryPinnedLoopSeamJump(emblaApi);\n }\n if (wheelExpandOnScroll && !engine.scrollBody.settled()) {\n showNeighborPreviews(emblaApi, \"scroll\");\n } else if (!wheelExpandOnScroll && !engine.scrollBody.settled()) {\n showCompactSlidesDuringScroll(emblaApi);\n }\n if (\n useWheelMomentumRef.current &&\n (userWheelPendingRef.current || userDraggedRef.current)\n ) {\n if (wheelNotifyDebounceRef.current) clearTimeout(wheelNotifyDebounceRef.current);\n wheelNotifyDebounceRef.current = setTimeout(() => {\n wheelNotifyDebounceRef.current = null;\n if (!userDraggedRef.current && !userWheelPendingRef.current) return;\n if (!emblaApi.internalEngine().scrollBody.settled()) return;\n if (loopEnabledRef.current && !wheelPinnedOpenRef.current && tryLoopDragWrap(emblaApi)) return;\n if (wheelPinnedOpenRef.current) {\n finalizePinnedShowroomSpin(emblaApi);\n } else {\n commitSelection(emblaApi);\n }\n showSnapHintLabel(emblaApi);\n }, wheelPinnedOpenRef.current\n ? momentumDurationRef.current + 48\n : momentumDurationRef.current + 72);\n }\n if (engine.dragHandler.pointerDown()) return;\n if (!engine.scrollBody.settled()) return;\n if (useWheelMomentumRef.current && wheelExpandOnScroll) {\n showNeighborPreviews(emblaApi, \"scroll\");\n }\n if (\n useWheelMomentumRef.current &&\n !loopEnabledRef.current &&\n !wheelPinnedOpenRef.current\n ) {\n ensureSnappedToCenter(emblaApi);\n }\n };\n\n const onPointerUp = () => {\n if (!useWheelMomentumRef.current) return;\n if (loopEnabledRef.current && !wheelPinnedOpenRef.current) return;\n requestAnimationFrame(() => boostDragFlick(emblaApi));\n };\n\n emblaApi.on(\"select\", onSelect);\n emblaApi.on(\"pointerDown\", onEmblaPointerDown);\n emblaApi.on(\"pointerUp\", onPointerUp);\n emblaApi.on(\"settle\", onSettle);\n emblaApi.on(\"scroll\", onScroll);\n\n return () => {\n cancelAnimationFrame(rafId);\n emblaApi.off(\"select\", onSelect);\n emblaApi.off(\"pointerDown\", onEmblaPointerDown);\n emblaApi.off(\"pointerUp\", onPointerUp);\n emblaApi.off(\"settle\", onSettle);\n emblaApi.off(\"scroll\", onScroll);\n const unbindNodes = new Set();\n if (pickerHostRef.current) unbindNodes.add(pickerHostRef.current);\n const root = emblaApi.rootNode();\n if (root instanceof HTMLElement) unbindNodes.add(root);\n unbindNodes.forEach((node) => {\n node.removeEventListener(\"wheel\", onWheel, { capture: true });\n node.removeEventListener(\"pointerdown\", onNativePointerDown, { capture: true });\n node.removeEventListener(\"pointerup\", onNativePointerUp, { capture: true });\n });\n handleWheelRef.current = () => {};\n };\n }, [\n emblaApi,\n applyWheelMomentum,\n commitSelection,\n maybeNotifyUserSettled,\n notifyUserSettled,\n tryLoopDragWrap,\n tryPinnedLoopSeamJump,\n finalizePinnedShowroomSpin,\n finishLoopWheelStep,\n endProgrammaticScroll,\n ensureSnappedToCenter,\n boostDragFlick,\n settleWheelNeighbors,\n showNeighborPreviews,\n showTextWhileScrolling,\n wheelExpandOnScroll,\n setScrollingLabels,\n showCompactSlidesDuringScroll,\n resetCompactSlideStyles,\n showSnapHintLabel,\n ]);\n\n useEffect(() => {\n if (!emblaApi) return;\n if (itemsLengthRef.current === items.length) return;\n itemsLengthRef.current = items.length;\n isProgrammaticScrollRef.current = true;\n reInitWheelRef.current(emblaApi);\n emblaApi.scrollTo(indexForValue(itemsRef.current, valueRef.current), true);\n const onDone = () => {\n endProgrammaticScroll(emblaApi);\n if (wheelExpandOnScroll) settleWheelNeighbors(emblaApi, \"reInit\");\n emblaApi.off(\"settle\", onDone);\n };\n emblaApi.on(\"settle\", onDone);\n }, [emblaApi, items.length, wheelExpandOnScroll, settleWheelNeighbors, endProgrammaticScroll]);\n\n useEffect(() => {\n if (!emblaApi || items.length === 0 || wheelExpandOnScroll) return;\n const index = indexForValue(items, value);\n if (emblaApi.selectedScrollSnap() === index) return;\n isProgrammaticScrollRef.current = true;\n emblaApi.scrollTo(index, true);\n }, [emblaApi, items.length, value, wheelExpandOnScroll]);\n\n useEffect(() => {\n if (!emblaApi || !wheelExpandOnScroll || items.length === 0) return;\n if (!wheelPinnedOpen) {\n if (neighborsVisibleRef.current) return;\n } else if (\n neighborsVisibleRef.current ||\n userDraggedRef.current ||\n userWheelPendingRef.current ||\n !emblaApi.internalEngine().scrollBody.settled()\n ) {\n return;\n }\n const index = indexForValue(items, value);\n if (findClosestSnapIndex(emblaApi) === index) return;\n isProgrammaticScrollRef.current = true;\n emblaApi.scrollTo(index, true);\n const onDone = () => {\n endProgrammaticScroll(emblaApi);\n settleWheelNeighbors(emblaApi, \"settle\");\n emblaApi.off(\"settle\", onDone);\n };\n emblaApi.on(\"settle\", onDone);\n }, [\n emblaApi,\n items.length,\n value,\n wheelExpandOnScroll,\n wheelPinnedOpen,\n settleWheelNeighbors,\n endProgrammaticScroll,\n ]);\n\n const useLabelSlides = wheelSettledShowsSlide\n ? !wheelSettled\n : alwaysShowLabel ||\n (showTextWhileScrolling && showLabels) ||\n inlineExpandSpinning;\n const renderLabelSlide = (item: RollingPickerItem, isActive: boolean) => {\n if (item.renderLabelSlide) return item.renderLabelSlide(isActive);\n if (item.labelSlide) return item.labelSlide;\n return (\n \n {item.label}\n \n );\n };\n\n const renderSlideContent = (item: RollingPickerItem, isActive: boolean) => {\n if (item.renderSlide) {\n // Pinned showroom: selection band = sample + name; off-band rows = name only (no snap swap).\n if (wheelPinnedOpen && item.renderLabelSlide) {\n if (isActive) {\n return item.renderSlide(isActive);\n }\n return item.renderLabelSlide(false);\n }\n return item.renderSlide(isActive);\n }\n if (\n wheelSettledShowsSlide &&\n wheelExpandOnScroll &&\n (wheelSettled || wheelPinnedOpen) &&\n isActive\n ) {\n return item.slide ?? renderLabelSlide(item, true);\n }\n if (wheelSettledShowsSlide && wheelPinnedOpen && wheelSettled && !isActive) {\n return item.labelSlide ?? renderLabelSlide(item, false);\n }\n if (useLabelSlides) {\n return renderLabelSlide(item, isActive);\n }\n return item.slide;\n };\n\n const renderOptionSlide = (item: RollingPickerItem, index: number, isActive: boolean) => (\n \n \n \n {renderSlideContent(item, isActive)}\n \n \n \n );\n\n useEffect(() => {\n if (userDraggedRef.current || userWheelPendingRef.current) return;\n setCenterIndex(indexForValue(items, value));\n }, [items, value]);\n\n if (items.length === 0) {\n return (\n \n …\n \n );\n }\n\n const showroomPanelStyle = wheelPinnedOpen\n ? ({\n [\"--float-wheel-row-px\" as string]: `${slideHeightPx}px`,\n [\"--float-wheel-visible-rows\" as string]: `${wheelNeighborCount}`,\n [\"--showroom-wheel-band\" as string]: `${expandedWheelHeightPx}px`,\n height: expandedWheelHeightPx,\n width: `max(${expandedWheelHeightPx}px, 9.75rem)`,\n maxWidth: \"10.5rem\",\n overflow: \"hidden\",\n } as CSSProperties)\n : ({\n [\"--float-wheel-row-px\" as string]: `${slideHeightPx}px`,\n } as CSSProperties);\n\n const viewport = wheelExpandOnScroll ? (\n \n \n
\n
\n {items.map((item, index) =>\n renderOptionSlide(item, index, index === centerIndex),\n )}\n
\n
\n \n \n ) : (\n \n
\n {items.map((item, index) =>\n renderOptionSlide(item, index, index === centerIndex),\n )}\n
\n \n );\n\n return (\n \n {!wheelExpandOnScroll && showSnapHint && snapHint ? (\n \n {snapHint}\n \n ) : null}\n {viewport}\n \n );\n}\n" }, { "type": "registry:file", "path": "float-wheel-picker.module.css", "target": "components/realmorphism/ui/float-wheel-picker.module.css", "content": "/* Figlet wheel — fixed slot; neighbors extend outside while spinning (iOS picker) */\n\n.panel {\n position: relative;\n background: #000;\n border-radius: 2px;\n border: 1px solid #2d2d2d;\n}\n\n.panelTransparent {\n background: transparent;\n}\n\n.inline {\n height: var(--float-wheel-row-px);\n overflow: hidden;\n box-shadow: none;\n transition: height 160ms ease-out;\n}\n\n.inlineSpinning {\n overflow: visible;\n}\n\n.inlineFullWidth {\n width: 100%;\n min-width: 0;\n flex: 1 1 0%;\n height: 100%;\n min-height: var(--float-wheel-row-px);\n overflow: hidden;\n box-shadow: none;\n}\n\n.inlineFullWidth::after {\n left: 0;\n right: 0;\n}\n\n/* Settled expand toolbar — clip to one row (neighbors hidden via opacity). */\n.inlineSettled {\n overflow: hidden;\n}\n\n.spinningHost {\n z-index: 50;\n}\n\n/* Taller embla band, centered on the single-row slot */\n.wheelStage {\n position: absolute;\n left: 0;\n right: 0;\n top: 50%;\n transform: translateY(-50%);\n overflow: hidden;\n background: #000;\n transition:\n height 160ms ease-out,\n box-shadow 160ms ease-out;\n}\n\n.wheelStageTransparent {\n background: transparent;\n}\n\n.wheelStage.spinning {\n box-shadow: 0 0 16px rgba(0, 0, 0, 0.95);\n}\n\n.wheelStageTransparent.spinning {\n box-shadow: none;\n}\n\n/* Edge fade on the tall wheel band */\n.wheelStage::before {\n content: \"\";\n pointer-events: none;\n position: absolute;\n inset: 0;\n z-index: 2;\n background: linear-gradient(\n to bottom,\n rgba(0, 0, 0, 0.96) 0%,\n rgba(0, 0, 0, 0.1) 28%,\n rgba(0, 0, 0, 0.1) 72%,\n rgba(0, 0, 0, 0.96) 100%\n );\n}\n\n.wheelStageTransparent::before {\n display: none;\n}\n\n/* Selection band — fixed on the toolbar slot (center row) */\n.inline::after {\n content: \"\";\n pointer-events: none;\n position: absolute;\n left: 0;\n right: 0;\n top: calc(50% - var(--float-wheel-row-px) / 2);\n height: var(--float-wheel-row-px);\n z-index: 4;\n border-top: 1px solid rgba(255, 255, 255, 0.28);\n border-bottom: 1px solid rgba(255, 255, 255, 0.28);\n background: transparent;\n}\n\n.viewport {\n position: relative;\n z-index: 1;\n height: 100%;\n width: 100%;\n overflow: hidden;\n touch-action: pan-y;\n cursor: default;\n}\n\n.showroomPinned .viewport {\n cursor: grab;\n user-select: none;\n}\n\n.showroomPinned .viewport:active {\n cursor: grabbing;\n}\n\n/* Registry / showroom — compact square wheel (center + one neighbor each side). */\n.showroomPinned {\n --showroom-wheel-band: calc(var(--float-wheel-row-px) * var(--float-wheel-visible-rows, 3));\n height: var(--showroom-wheel-band);\n width: max(var(--showroom-wheel-band), 9.75rem);\n max-width: 10.5rem;\n overflow: hidden;\n}\n\n.showroomPinned .wheelStage {\n position: absolute;\n left: 0;\n right: 0;\n top: 50%;\n transform: translateY(-50%);\n height: var(--showroom-wheel-band);\n overflow: hidden;\n}\n\n.showroomPinned .wheelStage::before {\n z-index: 1;\n background: linear-gradient(\n to bottom,\n rgba(6, 7, 8, 0.42) 0%,\n rgba(6, 7, 8, 0) 38%,\n rgba(6, 7, 8, 0) 62%,\n rgba(6, 7, 8, 0.42) 100%\n );\n}\n\n.showroomPinned .viewport {\n z-index: 2;\n overflow: visible;\n}\n\n.showroomPinned::after {\n content: \"\";\n pointer-events: none;\n position: absolute;\n left: 0;\n right: 0;\n top: calc(50% - var(--float-wheel-row-px) / 2);\n height: var(--float-wheel-row-px);\n z-index: 4;\n border-top: 1px solid rgba(255, 255, 255, 0.28);\n border-bottom: 1px solid rgba(255, 255, 255, 0.28);\n background: transparent;\n}\n" }, { "type": "registry:lib", "path": "embla-ios-picker-loop.ts", "target": "lib/realmorphism/embla-ios-picker-loop.ts", "content": "import type { EmblaCarouselType } from \"embla-carousel\";\n\nexport const IOS_PICKER_ITEM_SIZE_PX = 22;\n\nexport type IosPickerStyleOptions = {\n /** Gentler opacity falloff for long lists (e.g. figlet fonts). */\n compact?: boolean;\n /** Stronger dimming off-center (showroom pinned wheel). */\n centerEmphasis?: boolean;\n /** Cylindrical rolodex: rotateX + translateZ on inner slide nodes. */\n rolodex?: boolean;\n /** Slide row height in px — used for translateZ radius. */\n itemSizePx?: number;\n /** Hide slides farther than N steps from center (e.g. 1 = one preview per side). */\n maxNeighborSteps?: number;\n};\n\nexport function numberWithinRange(n: number, min: number, max: number): number {\n return Math.min(Math.max(n, min), max);\n}\n\nexport function indexDistanceFromSnapCenter(\n index: number,\n centerIndex: number,\n count: number,\n loop = false,\n): number {\n if (count <= 1) return 0;\n const raw = Math.abs(index - centerIndex);\n return loop ? Math.min(raw, count - raw) : raw;\n}\n\n/**\n * Showroom pinned wheel — opacity from snap index, not scrollProgress (breaks on long lists).\n */\nexport function applyPinnedShowroomSlideStyles(\n emblaApi: EmblaCarouselType,\n centerIndex: number,\n options?: Pick,\n): void {\n const slides = emblaApi.slideNodes();\n if (!slides.length) return;\n\n const engine = emblaApi.internalEngine();\n const loop = engine.options.loop;\n const centerSlideIndex = engine.slideRegistry[centerIndex]?.[0] ?? centerIndex;\n const normalizedCenterIndex =\n ((centerSlideIndex % slides.length) + slides.length) % slides.length;\n const maxNeighborSteps = options?.maxNeighborSteps ?? 1;\n const centerEmphasis = options?.centerEmphasis ?? false;\n const minOpacity = centerEmphasis ? 0.28 : 0.35;\n const opacityFalloff = centerEmphasis ? 0.4 : 0.22;\n\n slides.forEach((_node, index) => {\n const node = pickerInnerNode(emblaApi, index);\n if (!node) return;\n\n const stepsFromCenter = indexDistanceFromSnapCenter(\n index,\n normalizedCenterIndex,\n slides.length,\n loop,\n );\n if (stepsFromCenter > maxNeighborSteps + 0.01) {\n node.style.opacity = \"0\";\n node.style.transform = \"scale(0.82)\";\n node.style.pointerEvents = \"none\";\n return;\n }\n\n const isCentered = stepsFromCenter < 0.55;\n const opacity = isCentered\n ? 1\n : numberWithinRange(1 - stepsFromCenter * opacityFalloff, minOpacity, 1);\n const scale = isCentered\n ? 1\n : numberWithinRange(1 - stepsFromCenter * 0.08, 0.82, 1);\n\n node.style.pointerEvents = isCentered ? \"\" : \"none\";\n node.style.opacity = `${opacity}`;\n node.style.transform = isCentered ? \"\" : `scale(${scale})`;\n });\n}\n\nfunction pickerInnerNode(emblaApi: EmblaCarouselType, slideIndex: number): HTMLElement | null {\n const slide = emblaApi.slideNodes()[slideIndex];\n if (!slide) return null;\n const inner = slide.querySelector(\"[data-ios-picker-inner]\");\n return inner ?? slide;\n}\n\n/** Embla [iOS-style picker](https://www.embla-carousel.com/docs/examples/predefined#ios-style-picker-default): 3D rolodex from scroll progress. */\nexport function applyIosPickerSlideStyles(\n emblaApi: EmblaCarouselType,\n eventName?: string,\n options?: IosPickerStyleOptions,\n): void {\n const engine = emblaApi.internalEngine();\n const scrollProgress = emblaApi.scrollProgress();\n const isScrollEvent = eventName === \"scroll\";\n const snapList = emblaApi.scrollSnapList();\n const snapCount = snapList.length;\n if (!snapCount) return;\n\n const compact = options?.compact ?? false;\n const centerEmphasis = options?.centerEmphasis ?? false;\n const rolodex = options?.rolodex ?? false;\n const maxNeighborSteps = options?.maxNeighborSteps;\n const itemSize = options?.itemSizePx ?? IOS_PICKER_ITEM_SIZE_PX;\n const snapSpacing = snapCount > 1 ? 1 / (snapCount - 1) : 1;\n const minOpacity = centerEmphasis ? 0.2 : compact ? 0.35 : 0.2;\n const maxRotate = compact ? 22 : 48;\n const opacityFalloff = centerEmphasis ? 0.4 : compact ? 0.22 : 0.14;\n const wheelRadius = (itemSize * Math.max(snapCount, 3)) / (2 * Math.PI);\n\n snapList.forEach((snap, snapIndex) => {\n let diffToTarget = snap - scrollProgress;\n const slidesInSnap = engine.slideRegistry[snapIndex] ?? [];\n\n slidesInSnap.forEach((slideIndex) => {\n if (\n isScrollEvent &&\n !compact &&\n !rolodex &&\n !emblaApi.slidesInView().includes(slideIndex)\n ) {\n return;\n }\n\n if (engine.options.loop) {\n engine.slideLooper.loopPoints.forEach((loopItem) => {\n const target = loopItem.target();\n if (slideIndex === loopItem.index && target !== 0) {\n const sign = Math.sign(target);\n if (sign === -1) {\n diffToTarget = snap - (1 + scrollProgress);\n }\n if (sign === 1) {\n diffToTarget = snap + (1 - scrollProgress);\n }\n }\n });\n }\n\n const stepsFromCenter = Math.abs(diffToTarget) / snapSpacing;\n const stepSigned = diffToTarget / snapSpacing;\n\n const node = pickerInnerNode(emblaApi, slideIndex);\n if (!node) return;\n\n if (maxNeighborSteps != null && stepsFromCenter > maxNeighborSteps + 0.35) {\n node.style.opacity = \"0\";\n node.style.transform = \"scale(0.82)\";\n node.style.pointerEvents = \"none\";\n return;\n }\n node.style.pointerEvents = \"\";\n\n const isCentered = stepsFromCenter < 0.55;\n const opacity = isCentered\n ? 1\n : numberWithinRange(1 - stepsFromCenter * opacityFalloff, minOpacity, 1);\n const rotateX = isCentered\n ? 0\n : numberWithinRange(stepSigned, -2.5, 2.5) * (-maxRotate / 2.5);\n const scale = isCentered\n ? 1\n : numberWithinRange(1 - stepsFromCenter * 0.08, 0.82, 1);\n\n let transform = `scale(${scale})`;\n if (rolodex) {\n const angleRad = stepSigned * (Math.PI / 7);\n const translateZ = wheelRadius * (Math.cos(angleRad) - 1);\n transform = `translateZ(${translateZ.toFixed(2)}px) rotateX(${rotateX.toFixed(2)}deg) scale(${scale})`;\n } else if (!compact) {\n transform = `rotateX(${rotateX.toFixed(2)}deg) scale(${scale})`;\n }\n\n node.style.opacity = `${opacity}`;\n node.style.transform = transform;\n });\n });\n}\n\nexport function indexForPickerValue(values: readonly string[], target: string): number {\n const idx = values.findIndex((v) => v.toLowerCase() === target.toLowerCase());\n return idx >= 0 ? idx : 0;\n}\n\nconst SNAP_ALIGN_THRESHOLD_PX = 0.5;\n\n/** Nearest snap index from scroll position in px (for dragFree centering). */\nexport function findClosestSnapIndex(emblaApi: EmblaCarouselType): number {\n const { scrollSnaps, location } = emblaApi.internalEngine();\n const current = location.get();\n let closestIndex = 0;\n let minDistance = Number.POSITIVE_INFINITY;\n\n scrollSnaps.forEach((snap, index) => {\n const distance = Math.abs(snap - current);\n if (distance < minDistance) {\n minDistance = distance;\n closestIndex = index;\n }\n });\n\n return closestIndex;\n}\n\nexport function snapOffsetPx(emblaApi: EmblaCarouselType, index: number): number {\n const { scrollSnaps, location } = emblaApi.internalEngine();\n return Math.abs((scrollSnaps[index] ?? 0) - location.get());\n}\n\n/** True when scroll position is not centered on a snap. */\nexport function pickerNeedsSnapToCenter(emblaApi: EmblaCarouselType): boolean {\n const closest = findClosestSnapIndex(emblaApi);\n return (\n snapOffsetPx(emblaApi, closest) > SNAP_ALIGN_THRESHOLD_PX ||\n emblaApi.selectedScrollSnap() !== closest\n );\n}\n\n/** Animate to the nearest snap — call on settle / pointerUp after dragFree. */\nexport function ensurePickerSnappedToCenter(emblaApi: EmblaCarouselType): boolean {\n const closest = findClosestSnapIndex(emblaApi);\n if (!pickerNeedsSnapToCenter(emblaApi)) return false;\n emblaApi.scrollTo(closest);\n return true;\n}\n\n/** @deprecated Use ensurePickerSnappedToCenter */\nexport function snapPickerToNearest(emblaApi: EmblaCarouselType): boolean {\n return ensurePickerSnappedToCenter(emblaApi);\n}\n" }, { "type": "registry:component", "path": "catalog-to-rolling-items.tsx", "target": "lib/realmorphism/catalog-to-rolling-items.tsx", "content": "import type { ReactNode } from \"react\";\n\nimport type { RollingPickerItem, RollingPickerProps } from \"@/components/realmorphism/ui/rolling-picker-types\";\n\nexport type RollingPickerRowMode = \"compact\" | \"expand\" | \"showroom\";\n\ntype CatalogRow = { value: string; label: string };\n\ntype EntryKeys = {\n getValue?: (entry: T) => string;\n getLabel?: (entry: T) => string;\n};\n\nexport type CatalogToRollingItemsOptions =\n | (EntryKeys & {\n mode: \"compact\";\n renderSlide: (entry: T) => ReactNode;\n })\n | (EntryKeys & {\n mode: \"expand\";\n renderSlide: (entry: T) => ReactNode;\n /** Defaults to the same node as renderSlide. */\n renderLabelSlide?: (entry: T) => ReactNode;\n })\n | (EntryKeys & {\n mode: \"showroom\";\n renderCenterSlide: (entry: T, active: boolean) => ReactNode;\n /** Defaults to label-only neighbor row. */\n renderNeighborSlide?: (entry: T, active: boolean) => ReactNode;\n });\n\nfunction isStringCatalog(catalog: readonly unknown[]): catalog is readonly string[] {\n return catalog.length > 0 && typeof catalog[0] === \"string\";\n}\n\nfunction asCatalogRow(entry: unknown): CatalogRow {\n if (typeof entry === \"string\") {\n return { value: entry, label: entry };\n }\n if (typeof entry === \"object\" && entry !== null) {\n const row = entry as { value?: string; id?: string; label?: string; title?: string };\n const value = row.value ?? row.id ?? \"\";\n const label = row.label ?? row.title ?? value;\n return { value, label };\n }\n return { value: \"\", label: \"\" };\n}\n\nfunction entryKeys(entry: T, options: EntryKeys): CatalogRow {\n if (options.getValue || options.getLabel) {\n const keys = asCatalogRow(entry);\n return {\n value: options.getValue?.(entry) ?? keys.value,\n label: options.getLabel?.(entry) ?? keys.label,\n };\n }\n return asCatalogRow(entry);\n}\n\n/** Map any catalog into Embla wheel rows for compact, expand, or showroom pickers. */\nexport function catalogToRollingItems(\n catalog: readonly T[] | readonly string[],\n options: CatalogToRollingItemsOptions,\n): RollingPickerItem[] {\n const rows = isStringCatalog(catalog) ? (catalog as readonly T[]) : catalog;\n\n return rows.map((entry) => {\n const { value, label } = entryKeys(entry, options);\n\n switch (options.mode) {\n case \"compact\":\n return {\n value,\n label,\n slide: options.renderSlide(entry),\n };\n case \"expand\": {\n const slide = options.renderSlide(entry);\n const labelSlide = options.renderLabelSlide?.(entry) ?? slide;\n return { value, label, slide, labelSlide };\n }\n case \"showroom\":\n return {\n value,\n label,\n renderSlide: (active) => options.renderCenterSlide(entry, active),\n renderLabelSlide: (active) =>\n options.renderNeighborSlide?.(entry, active) ?? (\n \n {label}\n \n ),\n };\n }\n });\n}\n\n/** Clamp a controlled value to the nearest catalog id (case-insensitive). */\nexport function resolveCatalogPickerValue(\n value: string,\n catalog: readonly T[] | readonly string[],\n getValue?: (entry: T) => string,\n): string {\n if (!catalog.length) return value;\n\n const rows = isStringCatalog(catalog)\n ? catalog.map((entry) => ({ value: entry, label: entry }))\n : catalog.map((entry) => entryKeys(entry, { getValue }));\n\n const match = rows.find((row) => row.value.toLowerCase() === value.toLowerCase());\n return match?.value ?? rows[0]?.value ?? value;\n}\n\nconst EXPAND_LAYOUT: Partial = {\n wheelExpandOnScroll: true,\n wheelNeighborCount: 3,\n slideHeightPx: 28,\n wheelScrollStep: 1,\n showTextWhileScrolling: false,\n wheelSettledShowsSlide: false,\n alwaysShowLabel: true,\n loop: true,\n};\n\nconst SHOWROOM_LAYOUT: Partial = {\n wheelExpandOnScroll: true,\n wheelPinnedOpen: true,\n wheelNeighborCount: 3,\n slideHeightPx: 44,\n wheelScrollStep: 1,\n showTextWhileScrolling: false,\n wheelSettledShowsSlide: false,\n loop: true,\n};\n\nconst COMPACT_LAYOUT: Partial = {\n showTextWhileScrolling: true,\n loop: true,\n};\n\n/** Preset RollingPicker layout flags for each kit roller mode. */\nexport function rollingPickerLayoutForMode(\n mode: RollingPickerRowMode,\n overrides?: Partial,\n): Partial {\n const base =\n mode === \"showroom\" ? SHOWROOM_LAYOUT : mode === \"expand\" ? EXPAND_LAYOUT : COMPACT_LAYOUT;\n return { ...base, ...overrides };\n}\n" }, { "type": "registry:lib", "path": "doc-type-icon.ts", "target": "lib/realmorphism/doc-type-icon.ts", "content": "const DOC_TYPE_ICONS: Record = {\n css: \"file_type_css.svg\",\n html: \"file_type_html.svg\",\n javascript: \"file_type_js.svg\",\n json: \"file_type_json.svg\",\n markdown: \"file_type_markdown.svg\",\n pdf: \"file_type_pdf.svg\",\n python: \"file_type_python.svg\",\n text: \"file_type_text.svg\",\n typescript: \"file_type_typescript.svg\",\n};\n\nexport const DOC_TYPE_ENTRIES = [\n { value: \"css\", label: \"CSS\" },\n { value: \"html\", label: \"HTML\" },\n { value: \"javascript\", label: \"JavaScript\" },\n { value: \"json\", label: \"JSON\" },\n { value: \"markdown\", label: \"Markdown\" },\n { value: \"pdf\", label: \"PDF\" },\n { value: \"python\", label: \"Python\" },\n { value: \"text\", label: \"Text\" },\n { value: \"typescript\", label: \"TypeScript\" },\n] as const;\n\nexport type DocTypeValue = (typeof DOC_TYPE_ENTRIES)[number][\"value\"];\n\nexport function docTypeIconFile(kind: string): string {\n return DOC_TYPE_ICONS[kind] ?? \"default_file.svg\";\n}\n\nfunction normalizePublicBase(base: string): string {\n return base.endsWith(\"/\") ? base : `${base}/`;\n}\n\n/** Vite demo uses BASE_URL; Next.js (Echo Mirage) serves from site root. */\nfunction resolvePublicAssetBase(): string {\n try {\n const env = (import.meta as ImportMeta & { env?: { BASE_URL?: string } }).env;\n const base = env?.BASE_URL;\n if (typeof base === \"string\" && base.length > 0) {\n return normalizePublicBase(base);\n }\n } catch {\n // Non-Vite bundlers may not define import.meta.env.\n }\n return \"/\";\n}\n\nexport function docTypeIconSrc(kind: string): string {\n return `${resolvePublicAssetBase()}vendor/vscode-icons/${docTypeIconFile(kind)}`;\n}\n" }, { "type": "registry:lib", "path": "demo-text-catalog.ts", "target": "lib/realmorphism/demo-text-catalog.ts", "content": "import { resolveCatalogPickerValue } from \"./catalog-to-rolling-items\";\n\nexport type DemoTextCatalogEntry = {\n id: string;\n title: string;\n content: string;\n};\n\n/** Standalone demo catalog for the kit text roller (no network). */\nexport const DEMO_TEXT_CATALOG: DemoTextCatalogEntry[] = [\n {\n id: \"100-dollar\",\n title: \"100$\",\n content: \" $$$$$\\n $$ $$\\n $$$$$$\",\n },\n {\n id: \"echo-line\",\n title: \"ECHO\",\n content: \" ___ ___\\n / _ \\\\/ _ \\\\\\n| __/ __/\",\n },\n {\n id: \"mirage-line\",\n title: \"MIRAGE\",\n content: \" __ __ ___\\n| \\\\/ |_ _|\\n| |\\\\/| || |\",\n },\n {\n id: \"ops-ready\",\n title: \"OPS READY\",\n content: \" ___ ___ ___\\n| _ \\\\| _ \\\\_ _|\",\n },\n {\n id: \"signal\",\n title: \"SIGNAL\",\n content: \" ___ ___\\n/ __||__ \\\\\\n\\\\__ \\\\|___/\",\n },\n];\n\nexport function resolveDemoTextValue(value: string, catalog: DemoTextCatalogEntry[]): string {\n return resolveCatalogPickerValue(value, catalog, (entry) => entry.id);\n}\n" }, { "type": "registry:lib", "path": "demo-figlet-catalog.ts", "target": "lib/realmorphism/demo-figlet-catalog.ts", "content": "import { resolveCatalogPickerValue } from \"./catalog-to-rolling-items\";\n\nexport const DEMO_FIGLET_FONTS = [\n \"Standard\",\n \"Slant\",\n \"Big\",\n \"Block\",\n \"Bubble\",\n \"Digital\",\n \"ANSI Shadow\",\n \"ANSI Regular\",\n \"ANSI Compact\",\n \"Small\",\n \"Mini\",\n \"Script\",\n \"Shadow\",\n \"Speed\",\n \"Star Wars\",\n \"Univers\",\n \"Whimsy\",\n \"3-D\",\n \"3D Diagonal\",\n \"3D-ASCII\",\n \"3x5\",\n \"4Max\",\n \"5 Line Oblique\",\n \"5x7\",\n \"Alligator\",\n \"Alpha\",\n \"Avatar\",\n \"Banner\",\n \"Bell\",\n \"Benjamin\",\n] as const;\n\nexport type DemoFigletFont = (typeof DEMO_FIGLET_FONTS)[number];\n\nexport const DEFAULT_DEMO_FIGLET_FONT: DemoFigletFont = \"Standard\";\n\n/** Tiny wheel previews — static demo art, not live figlet render. */\nexport const DEMO_FIGLET_WHEEL_PREVIEW: Record = {\n Standard: \"EM\",\n Slant: \"/EM\\\\\",\n Big: \"##\",\n Block: \"[]\",\n Bubble: \"oo\",\n Digital: \"01\",\n \"ANSI Shadow\": \"▓▓\",\n \"ANSI Regular\": \"ER\",\n \"ANSI Compact\": \"AC\",\n};\n\n/** Detail panel samples keyed by font (subset; others fall back to label). */\nexport const DEMO_FIGLET_DETAIL_PREVIEW: Record = {\n Standard: [\n \" _____ _ _ \",\n \" | ____| | | |\",\n \" | _| | |_| |\",\n \" | |___| _ |\",\n \" |_____|_| |_|\",\n ].join(\"\\n\"),\n \"ANSI Compact\": [\n \" ___ _ _ \",\n \" / _ \\\\| | | |\",\n \" | (_) | |_| |\",\n \" \\\\___/ \\\\__,_|\",\n ].join(\"\\n\"),\n Big: [\n \" ____ _ _ \",\n \" | __ )| | | |\",\n \" | _ \\\\| |_| |\",\n \" | |_) | _ |\",\n \" |____/|_| |_|\",\n ].join(\"\\n\"),\n};\n\nexport function resolveDemoFigletValue(value: string, fonts: readonly string[]): string {\n return resolveCatalogPickerValue(value, fonts);\n}\n\nexport function demoFigletWheelPreview(font: string): string {\n return DEMO_FIGLET_WHEEL_PREVIEW[font] ?? font.slice(0, 2).toUpperCase();\n}\n\nexport function demoFigletDetailPreview(font: string, text = \"ECHO\"): string {\n const sample = DEMO_FIGLET_DETAIL_PREVIEW[font];\n if (sample) return sample;\n return [\n `Font: ${font}`,\n \"\",\n `Preview: ${text}`,\n \"(Connect figlet render for live output)\",\n ].join(\"\\n\");\n}\n" }, { "type": "registry:component", "path": "doc-type-rolling-picker.tsx", "target": "components/realmorphism/doc-type-rolling-picker.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\n\n\n\nimport { RollingPicker } from \"./ui/rolling-picker\";\n\nimport {\n\n DOC_TYPE_ENTRIES,\n\n docTypeIconFile,\n\n docTypeIconSrc,\n\n type DocTypeValue,\n\n} from \"@/lib/realmorphism/doc-type-icon\";\n\nimport {\n\n catalogToRollingItems,\n\n rollingPickerLayoutForMode,\n\n} from \"@/lib/realmorphism/catalog-to-rolling-items\";\n\n\n\ntype DocTypeRollingPickerProps = {\n\n value: DocTypeValue;\n\n onChange: (value: DocTypeValue) => void;\n\n};\n\n\n\n/** Y-axis rolling picker for document types — matches Echo Mirage operator pane icons. */\n\nexport function DocTypeRollingPicker({ value, onChange }: DocTypeRollingPickerProps) {\n\n const items = React.useMemo(\n\n () =>\n\n catalogToRollingItems(DOC_TYPE_ENTRIES, {\n\n mode: \"compact\",\n\n renderSlide: (entry) => (\n\n \n\n ),\n\n }),\n\n [],\n\n );\n\n\n\n return (\n\n onChange(next as DocTypeValue)}\n\n ariaLabel=\"Document type\"\n\n rollerType=\"compact\"\n\n viewportClassName=\"h-7 w-7\"\n\n showSnapHint\n\n {...rollingPickerLayoutForMode(\"compact\")}\n\n />\n\n );\n\n}\n" }, { "type": "registry:component", "path": "text-rolling-picker.tsx", "target": "components/realmorphism/text-rolling-picker.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { RollingPicker } from \"./ui/rolling-picker\";\nimport { cn } from \"@/lib/utils\";\nimport {\n catalogToRollingItems,\n resolveCatalogPickerValue,\n rollingPickerLayoutForMode,\n} from \"@/lib/realmorphism/catalog-to-rolling-items\";\nimport {\n DEMO_TEXT_CATALOG,\n type DemoTextCatalogEntry,\n} from \"@/lib/realmorphism/demo-text-catalog\";\n\nconst TITLE_SLIDE_CLASS =\n \"flex w-full min-w-0 items-center justify-center overflow-hidden whitespace-nowrap px-1 font-mono text-[8px] leading-none tracking-[0.02em]\";\n\ntype TextRollingPickerProps = {\n value: string;\n onChange: (value: string) => void;\n catalog?: DemoTextCatalogEntry[];\n onUserSelect?: (value: string) => void;\n};\n\nfunction titleSlide(title: string) {\n return (\n \n {title}\n \n );\n}\n\n/** Expand-inline text toolbar roller — title in wheel, detail elsewhere. */\nexport function TextRollingPicker({\n value,\n onChange,\n catalog = DEMO_TEXT_CATALOG,\n onUserSelect,\n}: TextRollingPickerProps) {\n const items = React.useMemo(\n () =>\n catalogToRollingItems(catalog, {\n mode: \"expand\",\n getValue: (entry) => entry.id,\n getLabel: (entry) => entry.title,\n renderSlide: (entry) => titleSlide(entry.title),\n }),\n [catalog],\n );\n\n const resolvedValue = resolveCatalogPickerValue(value, catalog, (entry) => entry.id);\n\n React.useEffect(() => {\n if (resolvedValue === value) return;\n onChange(resolvedValue);\n }, [resolvedValue, value, onChange]);\n\n if (catalog.length === 0) {\n return (\n
\n …\n
\n );\n }\n\n return (\n \n );\n}\n" }, { "type": "registry:component", "path": "showroom-font-preview-slide.tsx", "target": "components/realmorphism/showroom-font-preview-slide.tsx", "content": "import { cn } from \"@/lib/utils\";\nimport { demoFigletWheelPreview } from \"@/lib/realmorphism/demo-figlet-catalog\";\n\ntype ShowroomFontPreviewSlideProps = {\n font: string;\n active: boolean;\n size?: \"wheel\" | \"lg\";\n};\n\nexport function ShowroomFontPreviewSlide({\n font,\n active,\n size = \"wheel\",\n}: ShowroomFontPreviewSlideProps) {\n const preview = demoFigletWheelPreview(font);\n const isLarge = size === \"lg\";\n\n return (\n
\n \n {preview}\n \n \n {font}\n \n
\n );\n}\n" }, { "type": "registry:component", "path": "showroom-font-preview-panel.tsx", "target": "components/realmorphism/showroom-font-preview-panel.tsx", "content": "import * as React from \"react\";\n\nimport { cn } from \"@/lib/utils\";\nimport { demoFigletDetailPreview } from \"@/lib/realmorphism/demo-figlet-catalog\";\n\ntype ShowroomFontPreviewPanelProps = {\n font: string;\n text?: string;\n className?: string;\n /** Echo Mirage injects live figlet render output. */\n children?: React.ReactNode;\n};\n\nexport function ShowroomFontPreviewPanel({\n font,\n text = \"ECHO\",\n className,\n children,\n}: ShowroomFontPreviewPanelProps) {\n const output = children ?? demoFigletDetailPreview(font, text);\n\n return (\n \n
\n Preview\n
\n {typeof output === \"string\" ? (\n
\n          {output}\n        
\n ) : (\n output\n )}\n \n );\n}\n" }, { "type": "registry:component", "path": "showroom-font-picker.tsx", "target": "components/realmorphism/showroom-font-picker.tsx", "content": "\"use client\";\n\nimport * as React from \"react\";\n\nimport { ShowroomFontPreviewSlide } from \"./showroom-font-preview-slide\";\nimport { RollingPicker } from \"./ui/rolling-picker\";\nimport { cn } from \"@/lib/utils\";\nimport {\n catalogToRollingItems,\n resolveCatalogPickerValue,\n rollingPickerLayoutForMode,\n} from \"@/lib/realmorphism/catalog-to-rolling-items\";\nimport {\n DEFAULT_DEMO_FIGLET_FONT,\n DEMO_FIGLET_FONTS,\n} from \"@/lib/realmorphism/demo-figlet-catalog\";\n\nconst FONT_SLIDE_CLASS =\n \"flex w-full min-w-0 items-center justify-center overflow-hidden whitespace-nowrap px-1 font-mono text-[8px] leading-none tracking-[0.04em]\";\n\nexport type ShowroomFontWheelPreviewRender = (font: string, active: boolean) => React.ReactNode;\n\ntype ShowroomFontPickerProps = {\n value: string;\n onChange: (font: string) => void;\n fonts?: readonly string[];\n onWheelSettled?: () => void;\n /** Echo Mirage can inject live figlet wheel previews. */\n renderWheelPreview?: ShowroomFontWheelPreviewRender;\n variant?: \"compact\" | \"showroom\";\n};\n\nfunction fontSlide(font: string) {\n return (\n \n {font}\n \n );\n}\n\nfunction neighborFontSlide(font: string, active: boolean) {\n return (\n \n {font}\n \n );\n}\n\n/** Y-axis font rolodex — showroom mode shows rich center row + text neighbors. */\nexport function ShowroomFontPicker({\n value,\n onChange,\n fonts = DEMO_FIGLET_FONTS,\n onWheelSettled,\n renderWheelPreview,\n variant = \"showroom\",\n}: ShowroomFontPickerProps) {\n const isShowroom = variant === \"showroom\";\n\n const items = React.useMemo(() => {\n if (!isShowroom) {\n return catalogToRollingItems(fonts, {\n mode: \"compact\",\n renderSlide: (font) => fontSlide(font),\n });\n }\n\n return catalogToRollingItems(fonts, {\n mode: \"showroom\",\n renderCenterSlide: (font, active) =>\n renderWheelPreview ? (\n renderWheelPreview(font, active)\n ) : (\n \n ),\n renderNeighborSlide: (font, active) => neighborFontSlide(font, active),\n });\n }, [fonts, isShowroom, renderWheelPreview]);\n\n const resolvedValue = resolveCatalogPickerValue(value, fonts);\n\n React.useEffect(() => {\n if (resolvedValue === value) return;\n onChange(resolvedValue);\n }, [resolvedValue, value, onChange]);\n\n return (\n onWheelSettled?.()}\n ariaLabel=\"Figlet font\"\n rollerType={isShowroom ? \"showroom\" : \"compact\"}\n viewportClassName={\n isShowroom\n ? \"w-full\"\n : \"h-7 min-w-0 w-full max-w-none overflow-hidden rounded border border-[#2d2d2d] bg-black [scrollbar-width:none]\"\n }\n wheelTransparent={false}\n {...(isShowroom\n ? rollingPickerLayoutForMode(\"showroom\")\n : {\n slideHeightPx: 28,\n wheelScrollStep: 1,\n showTextWhileScrolling: false,\n wheelSettledShowsSlide: false,\n loop: true,\n })}\n />\n );\n}\n\nexport { DEFAULT_DEMO_FIGLET_FONT };\n" }, { "type": "registry:file", "path": "default_file.svg", "target": "public/vendor/vscode-icons/default_file.svg", "content": "default_file" }, { "type": "registry:file", "path": "file_type_css.svg", "target": "public/vendor/vscode-icons/file_type_css.svg", "content": "CSS Logo\n" }, { "type": "registry:file", "path": "file_type_html.svg", "target": "public/vendor/vscode-icons/file_type_html.svg", "content": "file_type_html" }, { "type": "registry:file", "path": "file_type_js.svg", "target": "public/vendor/vscode-icons/file_type_js.svg", "content": "file_type_js" }, { "type": "registry:file", "path": "file_type_json.svg", "target": "public/vendor/vscode-icons/file_type_json.svg", "content": "file_type_json" }, { "type": "registry:file", "path": "file_type_markdown.svg", "target": "public/vendor/vscode-icons/file_type_markdown.svg", "content": "file_type_markdown" }, { "type": "registry:file", "path": "file_type_pdf.svg", "target": "public/vendor/vscode-icons/file_type_pdf.svg", "content": "file_type_pdf" }, { "type": "registry:file", "path": "file_type_python.svg", "target": "public/vendor/vscode-icons/file_type_python.svg", "content": "file_type_python" }, { "type": "registry:file", "path": "file_type_text.svg", "target": "public/vendor/vscode-icons/file_type_text.svg", "content": "file_type_text" }, { "type": "registry:file", "path": "file_type_typescript.svg", "target": "public/vendor/vscode-icons/file_type_typescript.svg", "content": "file_type_typescript" } ], "meta": { "installNote": "Install theme first, then add pickers. Doc-type icons land in public/vendor/vscode-icons/.", "components": [ "DocTypeRollingPicker", "TextRollingPicker", "ShowroomFontPicker", "RollingPicker" ] } }