feat(registry): add hero-radial-burst block with time-of-day themes#76
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (8)
✅ Files skipped from review due to trivial changes (1)
🚧 Files skipped from review as they are similar to previous changes (6)
👮 Files not reviewed due to content moderation or server errors (1)
📝 WalkthroughWalkthroughAdds RadialBurst (canvas) and RadialBurstHero (composed section) with six time-of-day themes, a ThemeSwitcher dropdown, palette crossfading, pointer-reactive ray behavior (toggleable via ChangesRadial Burst Hero Component
Sequence DiagramsequenceDiagram
participant User
participant RadialBurstHero
participant ThemeSwitcher
participant RadialBurst
participant Canvas
User->>RadialBurstHero: Mount (defaultTheme)
RadialBurstHero->>RadialBurst: render(theme)
RadialBurst->>Canvas: draw rays, dots, bloom
User->>ThemeSwitcher: open & select theme
ThemeSwitcher->>RadialBurstHero: onChange(newTheme)
RadialBurstHero->>RadialBurst: update palette target
RadialBurst->>Canvas: crossfade colors / animate
Estimated code review effort🎯 4 (Complex) | ⏱️ ~50 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 10
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@apps/www/components/ui/hero-radial-burst.tsx`:
- Around line 562-578: The effect in CountUp uses the local formatter function
fmt (which closes over prefix and suffix) but those props are not listed in the
useEffect dependency array, so changes to prefix/suffix won't update
mid-animation; update the dependency list for the useEffect that references fmt
(the effect which reads ref.current and calls animate/controls.stop()) to
include prefix and suffix (or alternatively memoize/derive fmt with
useCallback/useMemo tied to prefix/suffix) so the formatter is up-to-date during
animations.
In `@apps/www/public/r/hero-radial-burst.json`:
- Line 16: ThemeSwitcher currently advertises listbox semantics but implements a
simple popover of buttons; remove the incorrect listbox semantics: delete
role="listbox" from the motion.ul and remove role="option" and aria-selected
from the inner buttons (keep the existing aria-haspopup/aria-expanded on the
trigger button so AT can still see it's a popup). This keeps the widget as a
plain button menu until/unless you implement full roving-focus and
ArrowUp/ArrowDown/Home/End handling for a real listbox.
- Line 16: The CountUp component's effect doesn't include prefix and suffix in
its dependency array so the displayed text won't update when those props change;
update the useEffect dependencies in CountUp to include prefix and suffix (in
addition to value, decimals, reduced) so fmt() is re-run and the
animated/instant render reflects changes to prefix/suffix; locate CountUp, its
fmt helper and the useEffect where animate(...) is started and add prefix and
suffix to that effect's dependency list.
In `@apps/www/public/registry.json`:
- Around line 4115-4118: The CountUp component currently outputs fmt(value) on
first render causing an initial flash of the final number; change its initial
rendered content to the start value (zero) when not reduced so the animation
progresses forward from 0 to value. Concretely, in CountUp update the span's
initial children to be reduced ? fmt(value) : fmt(0) (or otherwise render fmt(0)
when reduced is false), and keep the existing useEffect that animates from 0 →
value via animate; reference CountUp, ref, fmt, useReducedMotion and the animate
call to locate and modify the JSX/initial render only.
- Around line 4115-4118: The ThemeSwitcher uses listbox/option semantics but its
items are plain tabbable buttons without listbox keyboard handling; either
implement proper listbox keyboard focus/arrow handling for ThemeSwitcher or
change the semantics to a simple popup menu. Replace the listbox/option roles in
ThemeSwitcher (the motion.ul and each item button) with menu/menuitem semantics
(or remove listbox/option and aria-selected) and ensure the trigger button keeps
aria-haspopup and aria-expanded; update references in the component named
ThemeSwitcher, the motion.ul render block, and the item buttons that currently
set role="option" and aria-selected so ARIA matches actual keyboard behavior.
In `@apps/www/public/registry/hero-radial-burst.json`:
- Line 12: The ThemeSwitcher uses a motion.ul with role="listbox" but does not
move focus into the popup or implement arrow-key/active-option handling; update
ThemeSwitcher to either use a proper menu/radio pattern or fully implement
listbox keyboard behavior: when opening (setOpen true) move focus into the list
(focus first or selected item) and manage active item with keyboard handlers
(ArrowUp/ArrowDown/Home/End to change active, Enter/Space to select) using
focusable buttons or roving tabindex and aria-selected/aria-activedescendant;
ensure each option has correct role (e.g., role="option" or
role="menuitemradio"), that ThemeSwitcher root manages focus trapping/closing on
Escape/pointer outside, and that setOpen(false) restores focus to the trigger
button (the button in ThemeSwitcher) to preserve keyboard/AT expectations.
- Around line 11-18: The component imports cn from "`@/lib/utils`" but the bundled
util is utils/cn.ts exporting function cn, so update the import used in
blocks/hero/radial-burst/component.tsx (the line importing "`@/lib/utils`") to
reference the shipped module (the utils/cn.ts export) or alternatively add a
re-export that maps "`@/lib/utils`" to the bundled utils; ensure the symbol cn is
imported from the shipped module so the component uses the exported function cn
that actually exists in the artifact.
In `@registry.json`:
- Line 4116: ThemeSwitcher currently exposes a faux listbox (motion.ul has
role="listbox" and each item button has role="option" and aria-selected) but
doesn't implement listbox keyboard/focus behavior; fix by removing the
listbox/option semantics and related aria-selected attributes so the popup is a
simple popover of plain buttons (edit ThemeSwitcher: remove role="listbox" from
the motion.ul and remove role="option" and aria-selected from the item buttons),
keeping the existing pointer/escape handlers and the trigger's
aria-haspopup/aria-expanded to preserve accessibility.
- Line 4116: The RadialBurst canvas is decorative but not reliably hidden from
assistive tech; update the returned <canvas> in the RadialBurst component (the
element rendered by the RadialBurst function using canvasRef) to include an
explicit aria-hidden="true" attribute (do not move aria-hidden to any parent
wrapper or other readable content), ensuring the decorative canvas is removed
from the accessibility tree while keeping all visible content accessible.
In `@registry/blocks/hero/radial-burst/component.tsx`:
- Around line 562-578: The useEffect for updating the counter (the effect that
references ref, reduced, value, decimals and calls fmt) omits fmt's captured
props (prefix and suffix), causing stale formatting if those props change
mid-animation; fix by ensuring the effect depends on fmt (or the underlying
props) — e.g., include fmt or prefix and suffix in the dependency array of the
useEffect that contains the animate call (the effect using ref.current, reduced,
animate, and node.textContent) or alternatively memoize fmt with
useCallback/useMemo and add that memoized fmt to the dependencies so formatting
updates correctly during animation.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: cb9464d5-a167-4bec-a090-2e547cb269f6
📒 Files selected for processing (17)
apps/www/components/ui/hero-radial-burst.tsxapps/www/config/components.tsapps/www/config/demos.tsxapps/www/config/docs-scenarios.tsapps/www/public/r/hero-radial-burst.jsonapps/www/public/r/registry.jsonapps/www/public/registry.jsonapps/www/public/registry/changelogs.jsonapps/www/public/registry/hero-radial-burst.jsonapps/www/public/registry/index.jsonregistry.jsonregistry/blocks/hero/radial-burst/component.tsxregistry/blocks/hero/radial-burst/demo.tsxregistry/components/hero-radial-burst.jsonregistry/demos/demo-key-order.jsonregistry/demos/shared.tsxregistry/manifest.json
| "path": "blocks/hero/radial-burst/component.tsx", | ||
| "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n type Variants,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record<RadialBurstThemeId, ThemeDef>;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n\ntype Ray = {\n angle: number;\n length: number; // fraction of maxRadius\n reveal: number; // 0..1 stagger delay\n phase: number; // twinkle offset\n dots: { p: number; r: number; phase: number }[];\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the canvas background (reusable on its own).\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n}: RadialBurstProps) {\n const canvasRef = useRef<HTMLCanvasElement>(null);\n const reduced = useReducedMotion();\n\n // Reveal progress driven by Motion — read inside the canvas rAF loop.\n const progress = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef<Palette>(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<() => void>(() => {});\n\n // Mount reveal animation (Motion).\n useEffect(() => {\n if (reduced) {\n progress.set(1);\n return;\n }\n const controls = animate(progress, 1, {\n duration: 1.7,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [progress, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current();\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n originY = h * 0.99;\n maxRadius = h * 0.94;\n const count = Math.round(\n Math.min(340, Math.max(120, w / 4.8)) *\n Math.min(2, Math.max(0.4, density)),\n );\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n rays = Array.from({ length: count }, (_, i) => {\n const t = count > 1 ? i / (count - 1) : 0.5;\n const angle = lerp(aMin, aMax, t) + (Math.random() - 0.5) * 0.012;\n // Closeness to vertical → longer rays → a dome silhouette.\n const vert = Math.sin(Math.min(Math.PI, Math.max(0, angle)));\n const length =\n (0.34 + 0.66 * vert) * (0.78 + Math.random() * 0.3) +\n (Math.random() < 0.06 ? 0.12 : 0);\n const dotCount = 1 + Math.floor(Math.random() * 4);\n return {\n angle,\n length: Math.min(1.05, length),\n reveal: (1 - vert) * 0.42 + Math.random() * 0.12,\n phase: Math.random() * Math.PI * 2,\n dots: Array.from({ length: dotCount }, () => ({\n p: 0.2 + Math.random() * 0.8,\n r: 0.6 + Math.random() * 0.9,\n phase: Math.random() * Math.PI * 2,\n })),\n };\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current();\n };\n\n const render = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const prog = progress.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n\n // Central bloom.\n const bloomR = Math.min(w * 0.22, maxRadius * 0.5) * (0.6 + 0.4 * prog);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, dark ? 0.7 : 0.6));\n bloom.addColorStop(0.45, rgba(disp.core, dark ? 0.22 : 0.18));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n ctx.lineWidth = 0.7;\n ctx.lineCap = \"round\";\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n const lp = easeOut(\n Math.min(1, Math.max(0, (prog - ray.reveal) / (1 - 0.54))),\n );\n if (lp <= 0) continue;\n const twinkle = reduced ? 1 : 0.72 + 0.28 * Math.sin(now * 1.4 + ray.phase);\n const dx = Math.cos(ray.angle);\n const dy = -Math.sin(ray.angle);\n const len = ray.length * maxRadius * lp;\n const ex = originX + dx * len;\n const ey = originY + dy * len;\n\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0.0));\n grad.addColorStop(0.05, rgba(disp.rayBase, 0.98 * twinkle));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.55 * twinkle));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Dots — drift slowly outward for a living \"data\" feel.\n for (let d = 0; d < ray.dots.length; d++) {\n const dot = ray.dots[d];\n const dp = reduced ? dot.p : (dot.p + now * 0.025) % 1;\n if (dp > lp) continue;\n const px = originX + dx * ray.length * maxRadius * dp;\n const py = originY + dy * ray.length * maxRadius * dp;\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + dot.phase);\n ctx.fillStyle = rgba(\n lerpRGB(disp.dotBase, disp.dotTip, dp),\n (dark ? 0.85 : 0.9) * (0.4 + 0.6 * dtw) * (1 - dp * 0.35),\n );\n ctx.beginPath();\n ctx.arc(px, py, dot.r, 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render();\n return () => ro.disconnect();\n }\n\n const loop = () => {\n render();\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n };\n }, [density, progress, reduced]);\n\n return (\n <canvas\n ref={canvasRef}\n aria-hidden\n className={cn(\"absolute inset-0 h-full w-full\", className)}\n />\n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef<HTMLDivElement>(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n <div ref={rootRef} className=\"relative\">\n <button\n type=\"button\"\n onClick={() => setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n <Current className=\"h-4 w-4\" />\n </button>\n\n <AnimatePresence>\n {open && (\n <motion.ul\n role=\"listbox\"\n aria-label=\"Theme\"\n initial={{ opacity: 0, y: -6, scale: 0.96 }}\n animate={{ opacity: 1, y: 0, scale: 1 }}\n exit={{ opacity: 0, y: -6, scale: 0.96 }}\n transition={{ type: \"spring\", stiffness: 320, damping: 26 }}\n style={{ transformOrigin: \"top right\" }}\n className={cn(\n \"absolute right-0 top-11 z-30 w-40 overflow-hidden rounded-xl border p-1 shadow-xl backdrop-blur-md\",\n dark\n ? \"border-white/10 bg-[#1b1842]/90 text-white/80\"\n : \"border-slate-900/10 bg-white/90 text-slate-700\",\n )}\n >\n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n <li key={t.id}>\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={active}\n onClick={() => {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n <t.Icon className=\"h-4 w-4 shrink-0 opacity-80\" />\n {t.label}\n </button>\n </li>\n );\n })}\n </motion.ul>\n )}\n </AnimatePresence>\n </div>\n );\n}\n\n/* ------------------------------------------------------------------ *\n * Count-up stat (Motion-driven).\n * ------------------------------------------------------------------ */\n\ntype Stat = {\n prefix?: string;\n value: number;\n decimals?: number;\n suffix?: string;\n label: string;\n};\n\nconst DEFAULT_STATS: Stat[] = [\n { value: 135, suffix: \"+\", label: \"currencies and payment\\nmethods supported\" },\n { prefix: \"US$\", value: 1.9, decimals: 1, suffix: \"tn\", label: \"in payments volume\\nprocessed in 2025\" },\n { value: 99.999, decimals: 3, suffix: \"%\", label: \"historical uptime\\nfor Stripe services\" },\n { value: 200, suffix: \"M+\", label: \"active subscriptions\\nmanaged on Stripe Billing\" },\n];\n\nfunction CountUp({\n value,\n decimals = 0,\n prefix = \"\",\n suffix = \"\",\n}: Stat) {\n const ref = useRef<HTMLSpanElement>(null);\n const reduced = useReducedMotion();\n const fmt = (v: number) => {\n const fixed = v.toFixed(decimals);\n const [int, dec] = fixed.split(\".\");\n const withSep = int.replace(/\\B(?=(\\d{3})+(?!\\d))/g, \",\");\n return `${prefix}${dec !== undefined ? `${withSep}.${dec}` : withSep}${suffix}`;\n };\n\n useEffect(() => {\n const node = ref.current;\n if (!node) return;\n if (reduced) {\n node.textContent = fmt(value);\n return;\n }\n const controls = animate(0, value, {\n duration: 1.8,\n ease: [0.22, 1, 0.36, 1],\n onUpdate: (v) => {\n node.textContent = fmt(v);\n },\n });\n return () => controls.stop();\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [value, decimals, reduced]);\n\n return (\n <span ref={ref} className=\"tabular-nums\">\n {fmt(value)}\n </span>\n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition.\n * ------------------------------------------------------------------ */\n\nconst containerVariants: Variants = {\n hidden: {},\n visible: { transition: { staggerChildren: 0.08, delayChildren: 0.1 } },\n};\nconst itemVariants: Variants = {\n hidden: { opacity: 0, y: 16 },\n visible: {\n opacity: 1,\n y: 0,\n transition: { type: \"spring\", stiffness: 140, damping: 20 },\n },\n};\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n stats?: Stat[];\n burstProps?: Omit<RadialBurstProps, \"theme\">;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n <br />\n of global commerce\n </>\n ),\n stats = DEFAULT_STATS,\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState<RadialBurstThemeId>(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n <section\n {...rest}\n data-theme={theme}\n className={cn(\n \"relative isolate flex min-h-[100svh] w-full flex-col overflow-hidden\",\n dark ? \"text-white\" : \"text-slate-900\",\n className,\n )}\n >\n {/* Background gradient — crossfades between themes. */}\n <AnimatePresence initial={false}>\n <motion.div\n key={theme}\n aria-hidden\n initial={{ opacity: 0 }}\n animate={{ opacity: 1 }}\n exit={{ opacity: 0 }}\n transition={{ duration: 0.6, ease: \"easeInOut\" }}\n className=\"absolute inset-0 -z-10\"\n style={{ background: palette.bg }}\n />\n </AnimatePresence>\n\n {/* Canvas burst. */}\n <RadialBurst theme={theme} {...burstProps} />\n\n {/* Theme switcher. */}\n <div className=\"absolute right-5 top-5 z-20 sm:right-8 sm:top-8\">\n <ThemeSwitcher theme={theme} onChange={setTheme} mode={palette.mode} />\n </div>\n\n {/* Content. */}\n <motion.div\n variants={reduced ? undefined : containerVariants}\n initial={reduced ? false : \"hidden\"}\n animate={reduced ? undefined : \"visible\"}\n className=\"relative z-10 mx-auto flex w-full max-w-5xl flex-col px-6 pt-[12vh]\"\n >\n <motion.h1\n variants={reduced ? undefined : itemVariants}\n className=\"text-center text-balance text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl md:text-6xl\"\n >\n {title}\n </motion.h1>\n\n <motion.div\n variants={reduced ? undefined : itemVariants}\n className={cn(\n \"mt-14 grid grid-cols-2 gap-y-10 border-t pt-10 md:grid-cols-4\",\n dark ? \"border-white/10\" : \"border-slate-900/10\",\n )}\n >\n {stats.map((stat, i) => (\n <div key={i} className=\"px-2 text-center md:px-4\">\n <div\n className={cn(\n \"text-3xl font-medium tracking-tight sm:text-4xl\",\n i === 0\n ? dark\n ? \"text-white\"\n : \"text-slate-900\"\n : dark\n ? \"text-white/85\"\n : \"text-slate-500\",\n )}\n >\n <CountUp {...stat} />\n </div>\n <p\n className={cn(\n \"mx-auto mt-2 max-w-[16ch] whitespace-pre-line text-xs leading-relaxed sm:text-sm\",\n dark ? \"text-white/45\" : \"text-slate-500\",\n )}\n >\n {stat.label}\n </p>\n </div>\n ))}\n </motion.div>\n </motion.div>\n </section>\n );\n}\n\nexport default RadialBurstHero;\n", | ||
| "type": "registry:ui", | ||
| "integrity": "sha384-858wzmwi4WlpfzklkxQxjFDmEhxfmWG9OTc5CbN2ZvkxlulRZPmXVWJboYrK+/ux" | ||
| }, | ||
| { | ||
| "path": "utils/cn.ts", | ||
| "content": "import { type ClassValue, clsx } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n", |
There was a problem hiding this comment.
Make the bundled registry artifact self-consistent.
blocks/hero/radial-burst/component.tsx imports cn from @/lib/utils, but this artifact ships utils/cn.ts instead. That leaves the bundled util unused and can break installs in consumers that do not already provide @/lib/utils. Point the component at the shipped util, or emit the util at the path the component actually imports.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@apps/www/public/registry/hero-radial-burst.json` around lines 11 - 18, The
component imports cn from "`@/lib/utils`" but the bundled util is utils/cn.ts
exporting function cn, so update the import used in
blocks/hero/radial-burst/component.tsx (the line importing "`@/lib/utils`") to
reference the shipped module (the utils/cn.ts export) or alternatively add a
re-export that maps "`@/lib/utils`" to the bundled utils; ensure the symbol cn is
imported from the shipped module so the component uses the exported function cn
that actually exists in the artifact.
There was a problem hiding this comment.
Actionable comments posted: 1
♻️ Duplicate comments (1)
apps/www/public/registry.json (1)
4116-4116:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winARIA roles still don't match the switcher behavior.
The embedded
ThemeSwitcherstill exposes alistbox/optionrelationship, but the popup behaves like a tabbable button menu. Either add real listbox focus/arrow-key handling or switch the source component to menu semantics, then regenerate this manifest.Based on learnings: "Never edit generated manifests by hand — always run
pnpm build:registryto regenerate them."🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/www/public/registry.json` at line 4116, ThemeSwitcher currently exposes listbox/option semantics but behaves like a button menu; update ThemeSwitcher to use menu semantics: change the floating motion.ul from role="listbox" to role="menu" (adjust aria-label accordingly) and change each item button from role="option" to role="menuitem" (keep aria-selected removed or replace with aria-checked if needed), ensure the trigger button still has aria-expanded and aria-haspopup=\"menu\", and keep existing Escape/Pointer handlers; after making these role changes in ThemeSwitcher (the motion.ul and the mapped <button> items inside RADIAL_BURST_THEMES), run pnpm build:registry to regenerate the manifest rather than editing it by hand.
🧹 Nitpick comments (1)
apps/www/components/ui/hero-radial-burst.tsx (1)
652-696: ⚡ Quick winListbox semantics declared without full keyboard support.
The dropdown uses
role="listbox"androle="option"but lacks the required ArrowUp/ArrowDown/Home/End roving focus behavior. Screen readers will announce listbox interaction model but keyboard users won't get it.Either implement full listbox keyboard navigation or simplify to a plain button menu by removing
role="listbox",role="option", andaria-selected, keeping onlyaria-haspopup="menu"on the trigger.Simplify to button menu (if not implementing full listbox)
<motion.ul - role="listbox" - aria-label="Theme" + role="menu" + aria-label="Select theme" initial={{ opacity: 0, y: -6, scale: 0.96 }} ... > {RADIAL_BURST_THEMES.map((t) => { const active = t.id === theme; return ( - <li key={t.id}> + <li key={t.id} role="none"> <button type="button" - role="option" - aria-selected={active} + role="menuitem" + aria-current={active ? "true" : undefined} onClick={() => {Also update the trigger:
- aria-haspopup="listbox" + aria-haspopup="menu"🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@apps/www/components/ui/hero-radial-burst.tsx` around lines 652 - 696, The list uses listbox semantics without keyboard roving — remove the incorrect ARIA roles and attributes: drop role="listbox" from motion.ul and remove role="option" and aria-selected from each button rendered in the RADIAL_BURST_THEMES map (keep the existing onClick, setOpen, onChange, and visual classes intact), and treat the items as a simple button menu; then update the dropdown trigger (the element that toggles setOpen) to include aria-haspopup="menu" and a corresponding aria-expanded toggle so assistive tech knows it's a menu.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@registry.json`:
- Line 4116: RadialBurstHero is missing the count-up stat row promised in the PR
(the hero now only renders the headline); restore the removed stats block by
reintroducing the stat row JSX into the RadialBurstHero return (between the
headline motion.h1 and the section close), using the same structure/semantics as
the original design (a container with numeric counters and labels), wire it to
any existing props or local constants if needed, and ensure styles/aria match
surrounding elements; look for the RadialBurstHero function and the place where
title is rendered (motion.h1) to locate where to insert the stat row and update
any exports/props (RadialBurstHeroProps / burstProps) if the stats require
external data.
---
Duplicate comments:
In `@apps/www/public/registry.json`:
- Line 4116: ThemeSwitcher currently exposes listbox/option semantics but
behaves like a button menu; update ThemeSwitcher to use menu semantics: change
the floating motion.ul from role="listbox" to role="menu" (adjust aria-label
accordingly) and change each item button from role="option" to role="menuitem"
(keep aria-selected removed or replace with aria-checked if needed), ensure the
trigger button still has aria-expanded and aria-haspopup=\"menu\", and keep
existing Escape/Pointer handlers; after making these role changes in
ThemeSwitcher (the motion.ul and the mapped <button> items inside
RADIAL_BURST_THEMES), run pnpm build:registry to regenerate the manifest rather
than editing it by hand.
---
Nitpick comments:
In `@apps/www/components/ui/hero-radial-burst.tsx`:
- Around line 652-696: The list uses listbox semantics without keyboard roving —
remove the incorrect ARIA roles and attributes: drop role="listbox" from
motion.ul and remove role="option" and aria-selected from each button rendered
in the RADIAL_BURST_THEMES map (keep the existing onClick, setOpen, onChange,
and visual classes intact), and treat the items as a simple button menu; then
update the dropdown trigger (the element that toggles setOpen) to include
aria-haspopup="menu" and a corresponding aria-expanded toggle so assistive tech
knows it's a menu.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 2a5b3179-fff2-4814-918f-d95ad43cdc9e
📒 Files selected for processing (13)
apps/www/components/ui/hero-radial-burst.tsxapps/www/config/components.tsapps/www/config/demos.tsxapps/www/config/docs-scenarios.tsapps/www/public/r/hero-radial-burst.jsonapps/www/public/r/registry.jsonapps/www/public/registry.jsonapps/www/public/registry/changelogs.jsonapps/www/public/registry/hero-radial-burst.jsonregistry.jsonregistry/blocks/hero/radial-burst/component.tsxregistry/blocks/hero/radial-burst/demo.tsxregistry/components/hero-radial-burst.json
✅ Files skipped from review due to trivial changes (3)
- apps/www/config/components.ts
- apps/www/config/demos.tsx
- apps/www/public/r/registry.json
🚧 Files skipped from review as they are similar to previous changes (2)
- registry/blocks/hero/radial-burst/demo.tsx
- apps/www/config/docs-scenarios.ts
| "files": [ | ||
| { | ||
| "path": "blocks/hero/radial-burst/component.tsx", | ||
| "content": "\"use client\";\n\nimport {\n useEffect,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from \"react\";\nimport {\n motion,\n AnimatePresence,\n animate,\n useMotionValue,\n useReducedMotion,\n} from \"motion/react\";\nimport {\n CloudMoon,\n Sunrise,\n Sun,\n SunDim,\n Sunset,\n Moon,\n type LucideIcon,\n} from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\n\n/* ------------------------------------------------------------------ *\n * Themes — a day cycle. Each palette drives the background gradient,\n * the canvas burst colors, and (via `mode`) the text/UI contrast.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstThemeId =\n | \"pre-dawn\"\n | \"sunrise\"\n | \"daytime\"\n | \"dusk\"\n | \"sunset\"\n | \"night\";\n\ntype RGB = [number, number, number];\n\ntype Palette = {\n mode: \"light\" | \"dark\";\n /** CSS background for the section (crossfaded on theme change). */\n bg: string;\n /** Central bloom color. */\n core: RGB;\n /** Streamline color near the origin (bright) … */\n rayBase: RGB;\n /** … fading to this color at the tip. */\n rayTip: RGB;\n /** Dot color near the origin … */\n dotBase: RGB;\n /** … to this color at the tip. */\n dotTip: RGB;\n};\n\ntype ThemeDef = {\n id: RadialBurstThemeId;\n label: string;\n Icon: LucideIcon;\n palette: Palette;\n};\n\nexport const RADIAL_BURST_THEMES: ThemeDef[] = [\n {\n id: \"pre-dawn\",\n label: \"Pre-dawn\",\n Icon: CloudMoon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 90% at 50% 102%, #3730a3 0%, #221d63 40%, #100e2e 74%, #07061a 100%)\",\n core: [165, 180, 252],\n rayBase: [199, 210, 254],\n rayTip: [99, 102, 241],\n dotBase: [199, 210, 254],\n dotTip: [129, 140, 248],\n },\n },\n {\n id: \"sunrise\",\n label: \"Sunrise\",\n Icon: Sunrise,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #bfdbfe 0%, #dbeafe 40%, #eff6ff 72%, #f8fafc 100%)\",\n core: [147, 197, 253],\n rayBase: [37, 99, 235],\n rayTip: [96, 165, 250],\n dotBase: [29, 78, 216],\n dotTip: [59, 130, 246],\n },\n },\n {\n id: \"daytime\",\n label: \"Daytime\",\n Icon: Sun,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #e9d5ff 0%, #f3e8ff 42%, #faf5ff 74%, #fcfcfe 100%)\",\n core: [216, 180, 254],\n rayBase: [124, 58, 237],\n rayTip: [219, 39, 119],\n dotBase: [30, 64, 175],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"dusk\",\n label: \"Dusk\",\n Icon: SunDim,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #c4b5fd 0%, #ddd6fe 42%, #ece9fe 74%, #f6f4ff 100%)\",\n core: [167, 139, 250],\n rayBase: [109, 40, 217],\n rayTip: [236, 72, 153],\n dotBase: [76, 29, 149],\n dotTip: [219, 39, 119],\n },\n },\n {\n id: \"sunset\",\n label: \"Sunset\",\n Icon: Sunset,\n palette: {\n mode: \"light\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #fed7aa 0%, #fde4cf 38%, #fff3e6 70%, #fffaf4 100%)\",\n core: [253, 186, 116],\n rayBase: [244, 63, 94],\n rayTip: [251, 146, 60],\n dotBase: [99, 102, 241],\n dotTip: [236, 72, 153],\n },\n },\n {\n id: \"night\",\n label: \"Night\",\n Icon: Moon,\n palette: {\n mode: \"dark\",\n bg: \"radial-gradient(125% 92% at 50% 102%, #4f46e5 0%, #312e81 38%, #1e1b4b 70%, #14132e 100%)\",\n core: [224, 231, 255],\n rayBase: [237, 233, 254],\n rayTip: [129, 140, 248],\n dotBase: [224, 231, 255],\n dotTip: [165, 180, 252],\n },\n },\n];\n\nconst THEME_BY_ID = Object.fromEntries(\n RADIAL_BURST_THEMES.map((t) => [t.id, t]),\n) as Record<RadialBurstThemeId, ThemeDef>;\n\n/* ------------------------------------------------------------------ *\n * Small helpers\n * ------------------------------------------------------------------ */\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst lerpRGB = (a: RGB, b: RGB, t: number): RGB => [\n lerp(a[0], b[0], t),\n lerp(a[1], b[1], t),\n lerp(a[2], b[2], t),\n];\nconst rgba = (c: RGB, a: number) =>\n `rgba(${c[0] | 0}, ${c[1] | 0}, ${c[2] | 0}, ${a})`;\nconst clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));\nconst easeOut = (t: number) => 1 - Math.pow(1 - t, 3);\n/** Wrap an angle into (-π, π]. */\nconst wrapAngle = (a: number) => {\n let x = a;\n while (x > Math.PI) x -= 2 * Math.PI;\n while (x < -Math.PI) x += 2 * Math.PI;\n return x;\n};\n/** Distance from point (px,py) to segment (ax,ay)-(bx,by). */\nconst segDist = (\n px: number,\n py: number,\n ax: number,\n ay: number,\n bx: number,\n by: number,\n) => {\n const dx = bx - ax;\n const dy = by - ay;\n const len2 = dx * dx + dy * dy || 1;\n const t = clamp(((px - ax) * dx + (py - ay) * dy) / len2, 0, 1);\n const cx = ax + t * dx;\n const cy = ay + t * dy;\n return Math.hypot(px - cx, py - cy);\n};\n\n/** Horizontal spread multiplier — widens the fan without changing its height. */\nconst SPREAD_X = 1.3;\n\n/** One fiber: a streamline that grows, holds, extends, fades, then respawns. */\ntype Ray = {\n angle: number; // base emission angle (radians)\n maxLen: number; // target length as a fraction of maxRadius\n speed: number; // life units per second (→ lifetime ≈ 1/speed)\n life: number; // < 0 staggered delay, 0..1 active\n width: number; // core stroke width\n bright: number; // base brightness 0..1\n phase: number; // twinkle offset\n react: number; // smoothed pointer reaction 0..1\n bend: number; // smoothed angular bend toward the pointer\n hasDot: boolean; // whether a glowing dot rides this fiber's tip\n dotR: number; // tip-dot radius\n dotPhase: number; // tip-dot twinkle offset\n};\n\n/* ------------------------------------------------------------------ *\n * RadialBurst — the interactive canvas background (reusable on its own).\n *\n * Fibers stream out of a bottom-center origin in a wide radial fan. Each\n * one continuously grows, slightly over-extends, fades, and regenerates\n * with fresh angle/length/speed/opacity, while glowing dots travel along\n * it. Rays near the pointer brighten, stretch, and bend toward it, then\n * ease back to their drift. The burst stays in the lower band so it never\n * reaches the headline above.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstProps = {\n className?: string;\n /** Palette id. */\n theme?: RadialBurstThemeId;\n /** Ray-count multiplier (0.4–2). */\n density?: number;\n /** Disable pointer reactivity. */\n interactive?: boolean;\n};\n\nexport function RadialBurst({\n className,\n theme = \"night\",\n density = 1,\n interactive = true,\n}: RadialBurstProps) {\n const canvasRef = useRef<HTMLCanvasElement>(null);\n const reduced = useReducedMotion();\n\n // Global intro fade, driven by Motion — read inside the canvas rAF loop.\n const intro = useMotionValue(reduced ? 1 : 0);\n\n // Target palette + a smoothed \"displayed\" palette so theme switches lerp.\n const targetRef = useRef<Palette>(THEME_BY_ID[theme].palette);\n const dispRef = useRef<{\n core: RGB;\n rayBase: RGB;\n rayTip: RGB;\n dotBase: RGB;\n dotTip: RGB;\n }>({\n core: [...THEME_BY_ID[theme].palette.core] as RGB,\n rayBase: [...THEME_BY_ID[theme].palette.rayBase] as RGB,\n rayTip: [...THEME_BY_ID[theme].palette.rayTip] as RGB,\n dotBase: [...THEME_BY_ID[theme].palette.dotBase] as RGB,\n dotTip: [...THEME_BY_ID[theme].palette.dotTip] as RGB,\n });\n const renderRef = useRef<(dt: number) => void>(() => {});\n\n // Mount fade-in (Motion).\n useEffect(() => {\n if (reduced) {\n intro.set(1);\n return;\n }\n const controls = animate(intro, 1, {\n duration: 1.6,\n ease: [0.22, 1, 0.36, 1],\n });\n return () => controls.stop();\n }, [intro, reduced]);\n\n // Update the target palette when the theme changes; redraw if static.\n useEffect(() => {\n targetRef.current = THEME_BY_ID[theme].palette;\n if (reduced) {\n const d = dispRef.current;\n const p = targetRef.current;\n d.core = [...p.core] as RGB;\n d.rayBase = [...p.rayBase] as RGB;\n d.rayTip = [...p.rayTip] as RGB;\n d.dotBase = [...p.dotBase] as RGB;\n d.dotTip = [...p.dotTip] as RGB;\n renderRef.current(0);\n }\n }, [theme, reduced]);\n\n useEffect(() => {\n const canvas = canvasRef.current;\n if (!canvas) return;\n const ctx = canvas.getContext(\"2d\");\n if (!ctx) return;\n\n let raf = 0;\n let rays: Ray[] = [];\n let maxRadius = 0;\n let originX = 0;\n let originY = 0;\n\n const pointer = { x: 0, y: 0, active: false };\n\n const respawn = (ray: Ray, initial: boolean) => {\n const aMin = -0.06 * Math.PI;\n const aMax = 1.06 * Math.PI;\n const angle = lerp(aMin, aMax, Math.random()) + (Math.random() - 0.5) * 0.05;\n // Mild bias toward vertical, but side rays stay long so the fan fills\n // the full width along the bottom rather than tapering to a dome.\n const vert = Math.sin(clamp(angle, 0, Math.PI));\n ray.angle = angle;\n ray.maxLen = Math.min(\n 1.05,\n (0.8 + 0.2 * vert) * (0.6 + Math.random() * 0.45) +\n (Math.random() < 0.06 ? 0.12 : 0),\n );\n ray.speed = 0.085 + Math.random() * 0.13; // lifetime ≈ 4.5–11.8s\n ray.life = initial ? Math.random() : -Math.random() * 0.6;\n ray.width = 0.55 + Math.random() * 0.5;\n ray.bright = 0.5 + Math.random() * 0.5;\n ray.phase = Math.random() * Math.PI * 2;\n // A single glowing dot sits at the tip — it rides the growing tip, it\n // does not travel along the fiber.\n ray.hasDot = Math.random() < 0.72;\n ray.dotR = 0.8 + Math.random() * 1;\n ray.dotPhase = Math.random() * Math.PI * 2;\n };\n\n const seed = () => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n originX = w / 2;\n // Origin sits on the bottom edge so the burst touches the bottom.\n originY = h;\n // Lower-band height — kept well below the headline, ~100px shorter.\n maxRadius = Math.max(h * 0.4, h * 0.6 - 100);\n const count = Math.round(\n Math.min(400, Math.max(220, w / 3.2)) * clamp(density, 0.4, 2),\n );\n rays = Array.from({ length: count }, () => {\n const ray: Ray = {\n angle: 0,\n maxLen: 0,\n speed: 0,\n life: 0,\n width: 1,\n bright: 1,\n phase: 0,\n react: 0,\n bend: 0,\n hasDot: false,\n dotR: 1,\n dotPhase: 0,\n };\n respawn(ray, true);\n return ray;\n });\n };\n\n const resize = () => {\n const { clientWidth, clientHeight } = canvas;\n const dpr = Math.min(window.devicePixelRatio || 1, 2);\n canvas.width = Math.round(clientWidth * dpr);\n canvas.height = Math.round(clientHeight * dpr);\n ctx.setTransform(dpr, 0, 0, dpr, 0, 0);\n seed();\n if (reduced) renderRef.current(0);\n };\n\n const render = (dt: number) => {\n const w = canvas.clientWidth;\n const h = canvas.clientHeight;\n const now = performance.now() / 1000;\n const introA = intro.get();\n const disp = dispRef.current;\n const target = targetRef.current;\n\n // Ease displayed colors toward the target palette (theme crossfade).\n const k = reduced ? 1 : 0.08;\n disp.core = lerpRGB(disp.core, target.core, k);\n disp.rayBase = lerpRGB(disp.rayBase, target.rayBase, k);\n disp.rayTip = lerpRGB(disp.rayTip, target.rayTip, k);\n disp.dotBase = lerpRGB(disp.dotBase, target.dotBase, k);\n disp.dotTip = lerpRGB(disp.dotTip, target.dotTip, k);\n\n ctx.clearRect(0, 0, w, h);\n const dark = target.mode === \"dark\";\n // Dark themes glow additively; light themes paint normally.\n ctx.globalCompositeOperation = dark ? \"lighter\" : \"source-over\";\n ctx.lineCap = \"round\";\n\n // Central bloom — a soft, diffuse glow rather than a hard bright disc.\n const bloomR =\n Math.min(w * 0.22, maxRadius * 0.6) * (0.7 + 0.3 * introA);\n const bloom = ctx.createRadialGradient(\n originX,\n originY,\n 0,\n originX,\n originY,\n bloomR,\n );\n bloom.addColorStop(0, rgba(disp.core, (dark ? 0.5 : 0.44) * introA));\n bloom.addColorStop(0.3, rgba(disp.core, (dark ? 0.18 : 0.15) * introA));\n bloom.addColorStop(0.65, rgba(disp.core, (dark ? 0.06 : 0.05) * introA));\n bloom.addColorStop(1, rgba(disp.core, 0));\n ctx.fillStyle = bloom;\n ctx.beginPath();\n ctx.arc(originX, originY, bloomR, 0, Math.PI * 2);\n ctx.fill();\n\n const pointerOn = interactive && !reduced && pointer.active;\n const reactR = 170; // px radius of pointer influence\n // No reaction near the origin — only the middle/tip of fibers respond.\n const originGuard = maxRadius * 0.22;\n const pointerNearOrigin =\n Math.hypot(pointer.x - originX, pointer.y - originY) < originGuard;\n\n for (let i = 0; i < rays.length; i++) {\n const ray = rays[i];\n\n if (!reduced) {\n ray.life += ray.speed * dt;\n if (ray.life >= 1) respawn(ray, false);\n }\n if (ray.life < 0) continue;\n\n const life = reduced ? 0.62 : ray.life;\n const growT = clamp(life / 0.7, 0, 1);\n const lenFrac = easeOut(growT);\n const extend = life > 0.7 ? (life - 0.7) / 0.3 : 0;\n const env = reduced\n ? 1\n : Math.min(1, life / 0.12) *\n (life > 0.8 ? clamp(1 - (life - 0.8) / 0.2, 0, 1) : 1);\n if (env <= 0) continue;\n\n const baseLen =\n ray.maxLen * maxRadius * lenFrac * (1 + 0.06 * extend);\n\n // Pointer reaction — engage quickly, return slowly. Hit-test only the\n // outer 35%→tip span so the dense near-origin zone stays calm.\n if (pointerOn && !pointerNearOrigin) {\n const cx = Math.cos(ray.angle) * SPREAD_X;\n const cy = -Math.sin(ray.angle);\n const ix = originX + cx * baseLen * 0.35;\n const iy = originY + cy * baseLen * 0.35;\n const bx = originX + cx * baseLen;\n const by = originY + cy * baseLen;\n const d = segDist(pointer.x, pointer.y, ix, iy, bx, by);\n let reactTarget = 0;\n let bendTarget = 0;\n if (d < reactR) {\n reactTarget = 1 - d / reactR;\n reactTarget *= reactTarget;\n const pAng = Math.atan2(\n -(pointer.y - originY),\n (pointer.x - originX) / SPREAD_X,\n );\n bendTarget = clamp(wrapAngle(pAng - ray.angle), -0.4, 0.4);\n }\n ray.react +=\n (reactTarget - ray.react) * (reactTarget > ray.react ? 0.14 : 0.06);\n ray.bend += (bendTarget * ray.react - ray.bend) * 0.1;\n } else if (ray.react !== 0 || ray.bend !== 0) {\n ray.react += -ray.react * 0.06;\n ray.bend += -ray.bend * 0.1;\n }\n\n const react = ray.react;\n const drawAngle = ray.angle + ray.bend;\n const dirx = Math.cos(drawAngle) * SPREAD_X;\n const diry = -Math.sin(drawAngle);\n const effLen = baseLen * (1 + 0.12 * react);\n const ex = originX + dirx * effLen;\n const ey = originY + diry * effLen;\n\n const twinkle = reduced ? 1 : 0.82 + 0.18 * Math.sin(now * 1.3 + ray.phase);\n const aBase = clamp(\n env * introA * ray.bright * twinkle * (1 + 0.9 * react),\n 0,\n 1,\n );\n\n // Brightness builds up *along* the fiber: near-zero through the dense\n // convergence zone at the origin (so overlapping starts don't blow out\n // to white), peaking once the fibers have fanned apart, fading at the tip.\n const grad = ctx.createLinearGradient(originX, originY, ex, ey);\n grad.addColorStop(0, rgba(disp.rayBase, 0));\n grad.addColorStop(0.16, rgba(disp.rayBase, 0.22 * aBase));\n grad.addColorStop(0.4, rgba(disp.rayBase, 0.85 * aBase));\n grad.addColorStop(0.75, rgba(disp.rayBase, 0.36 * aBase));\n grad.addColorStop(1, rgba(disp.rayTip, 0));\n ctx.strokeStyle = grad;\n\n // Soft wide pass → subtle blur/glow.\n ctx.lineWidth = ray.width * (dark ? 3.2 : 2.6);\n ctx.globalAlpha = dark ? 0.22 : 0.18;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // Crisp core pass.\n ctx.lineWidth = ray.width;\n ctx.globalAlpha = 1;\n ctx.beginPath();\n ctx.moveTo(originX, originY);\n ctx.lineTo(ex, ey);\n ctx.stroke();\n\n // A single glowing dot sits at the very tip — it rides the growing\n // tip but never travels along the fiber.\n if (ray.hasDot) {\n const dtw = reduced ? 1 : 0.5 + 0.5 * Math.sin(now * 2 + ray.dotPhase);\n // Dim dots whose tip still sits inside the convergence zone.\n const tipFade = clamp(effLen / (maxRadius * 0.28), 0, 1);\n ctx.fillStyle = rgba(\n disp.dotTip,\n clamp(\n env * introA * tipFade * (dark ? 0.95 : 1) * (0.45 + 0.55 * dtw) *\n (1 + 0.6 * react),\n 0,\n 1,\n ),\n );\n ctx.beginPath();\n ctx.arc(ex, ey, ray.dotR * (1 + 0.4 * react), 0, Math.PI * 2);\n ctx.fill();\n }\n }\n\n ctx.globalAlpha = 1;\n ctx.globalCompositeOperation = \"source-over\";\n };\n renderRef.current = render;\n\n resize();\n const ro = new ResizeObserver(resize);\n ro.observe(canvas);\n\n if (reduced) {\n render(0);\n return () => ro.disconnect();\n }\n\n const onMove = (e: PointerEvent) => {\n const rect = canvas.getBoundingClientRect();\n const x = e.clientX - rect.left;\n const y = e.clientY - rect.top;\n if (x < 0 || y < 0 || x > rect.width || y > rect.height) {\n pointer.active = false;\n return;\n }\n pointer.x = x;\n pointer.y = y;\n pointer.active = true;\n };\n const onLeave = () => {\n pointer.active = false;\n };\n if (interactive) {\n window.addEventListener(\"pointermove\", onMove, { passive: true });\n window.addEventListener(\"blur\", onLeave);\n }\n\n let last = performance.now();\n const loop = (t: number) => {\n const dt = Math.min(0.05, (t - last) / 1000);\n last = t;\n render(dt);\n raf = requestAnimationFrame(loop);\n };\n raf = requestAnimationFrame(loop);\n\n return () => {\n cancelAnimationFrame(raf);\n ro.disconnect();\n if (interactive) {\n window.removeEventListener(\"pointermove\", onMove);\n window.removeEventListener(\"blur\", onLeave);\n }\n };\n }, [density, intro, reduced, interactive]);\n\n return (\n <canvas\n ref={canvasRef}\n aria-hidden\n className={cn(\"absolute inset-0 h-full w-full\", className)}\n />\n );\n}\n\n/* ------------------------------------------------------------------ *\n * ThemeSwitcher — the corner dropdown.\n * ------------------------------------------------------------------ */\n\nfunction ThemeSwitcher({\n theme,\n onChange,\n mode,\n}: {\n theme: RadialBurstThemeId;\n onChange: (id: RadialBurstThemeId) => void;\n mode: \"light\" | \"dark\";\n}) {\n const [open, setOpen] = useState(false);\n const rootRef = useRef<HTMLDivElement>(null);\n const Current = THEME_BY_ID[theme].Icon;\n const dark = mode === \"dark\";\n\n useEffect(() => {\n if (!open) return;\n const onPointer = (e: PointerEvent) => {\n if (!rootRef.current?.contains(e.target as Node)) setOpen(false);\n };\n const onKey = (e: KeyboardEvent) => {\n if (e.key === \"Escape\") setOpen(false);\n };\n document.addEventListener(\"pointerdown\", onPointer);\n document.addEventListener(\"keydown\", onKey);\n return () => {\n document.removeEventListener(\"pointerdown\", onPointer);\n document.removeEventListener(\"keydown\", onKey);\n };\n }, [open]);\n\n return (\n <div ref={rootRef} className=\"relative\">\n <button\n type=\"button\"\n onClick={() => setOpen((v) => !v)}\n aria-haspopup=\"listbox\"\n aria-expanded={open}\n aria-label={`Theme: ${THEME_BY_ID[theme].label}. Change theme`}\n className={cn(\n \"inline-flex h-9 w-9 cursor-pointer items-center justify-center rounded-lg border backdrop-blur transition-colors\",\n dark\n ? \"border-white/15 bg-white/5 text-white/80 hover:bg-white/10\"\n : \"border-slate-900/10 bg-white/70 text-slate-600 hover:bg-white\",\n )}\n >\n <Current className=\"h-4 w-4\" />\n </button>\n\n <AnimatePresence>\n {open && (\n <motion.ul\n role=\"listbox\"\n aria-label=\"Theme\"\n initial={{ opacity: 0, y: -6, scale: 0.96 }}\n animate={{ opacity: 1, y: 0, scale: 1 }}\n exit={{ opacity: 0, y: -6, scale: 0.96 }}\n transition={{ type: \"spring\", stiffness: 320, damping: 26 }}\n style={{ transformOrigin: \"top right\" }}\n className={cn(\n \"absolute right-0 top-11 z-30 w-40 overflow-hidden rounded-xl border p-1 shadow-xl backdrop-blur-md\",\n dark\n ? \"border-white/10 bg-[#1b1842]/90 text-white/80\"\n : \"border-slate-900/10 bg-white/90 text-slate-700\",\n )}\n >\n {RADIAL_BURST_THEMES.map((t) => {\n const active = t.id === theme;\n return (\n <li key={t.id}>\n <button\n type=\"button\"\n role=\"option\"\n aria-selected={active}\n onClick={() => {\n onChange(t.id);\n setOpen(false);\n }}\n className={cn(\n \"flex w-full cursor-pointer items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-sm transition-colors\",\n active\n ? dark\n ? \"bg-white/10 text-white\"\n : \"bg-slate-900/5 text-slate-900\"\n : dark\n ? \"hover:bg-white/5\"\n : \"hover:bg-slate-900/5\",\n )}\n >\n <t.Icon className=\"h-4 w-4 shrink-0 opacity-80\" />\n {t.label}\n </button>\n </li>\n );\n })}\n </motion.ul>\n )}\n </AnimatePresence>\n </div>\n );\n}\n\n/* ------------------------------------------------------------------ *\n * RadialBurstHero — full composition: headline over the interactive burst.\n * ------------------------------------------------------------------ */\n\nexport type RadialBurstHeroProps = Omit<\n ComponentProps<\"section\">,\n \"children\" | \"title\"\n> & {\n /** Initial theme; also synced if it changes (e.g. from a site toggle). */\n defaultTheme?: RadialBurstThemeId;\n title?: ReactNode;\n burstProps?: Omit<RadialBurstProps, \"theme\">;\n};\n\nexport function RadialBurstHero({\n className,\n defaultTheme = \"night\",\n title = (\n <>\n The backbone\n <br />\n of global commerce\n </>\n ),\n burstProps,\n ...rest\n}: RadialBurstHeroProps) {\n const [theme, setTheme] = useState<RadialBurstThemeId>(defaultTheme);\n const reduced = useReducedMotion();\n\n // Keep in sync if the consumer (or docs theme toggle) changes defaultTheme.\n useEffect(() => {\n setTheme(defaultTheme);\n }, [defaultTheme]);\n\n const palette = THEME_BY_ID[theme].palette;\n const dark = palette.mode === \"dark\";\n\n return (\n <section\n {...rest}\n data-theme={theme}\n className={cn(\n \"relative isolate flex min-h-[100svh] w-full flex-col overflow-hidden\",\n dark ? \"text-white\" : \"text-slate-900\",\n className,\n )}\n >\n {/* Background gradient — crossfades between themes. */}\n <AnimatePresence initial={false}>\n <motion.div\n key={theme}\n aria-hidden\n initial={{ opacity: 0 }}\n animate={{ opacity: 1 }}\n exit={{ opacity: 0 }}\n transition={{ duration: 0.6, ease: \"easeInOut\" }}\n className=\"absolute inset-0 -z-10\"\n style={{ background: palette.bg }}\n />\n </AnimatePresence>\n\n {/* Interactive burst — masked so it fades out below the headline. */}\n <div\n aria-hidden\n className=\"absolute inset-0\"\n style={{\n WebkitMaskImage:\n \"linear-gradient(to bottom, transparent 0%, transparent 24%, #000 48%, #000 100%)\",\n maskImage:\n \"linear-gradient(to bottom, transparent 0%, transparent 24%, #000 48%, #000 100%)\",\n }}\n >\n <RadialBurst theme={theme} {...burstProps} />\n </div>\n\n {/* Theme switcher. */}\n <div className=\"absolute right-5 top-5 z-20 sm:right-8 sm:top-8\">\n <ThemeSwitcher theme={theme} onChange={setTheme} mode={palette.mode} />\n </div>\n\n {/* Headline. */}\n <div className=\"relative z-10 mx-auto flex w-full max-w-5xl flex-col px-6 pt-[14vh]\">\n <motion.h1\n initial={reduced ? false : { opacity: 0, y: 18 }}\n animate={reduced ? undefined : { opacity: 1, y: 0 }}\n transition={{ type: \"spring\", stiffness: 140, damping: 20, delay: 0.1 }}\n className=\"text-center text-balance text-4xl font-semibold leading-[1.05] tracking-tight sm:text-5xl md:text-6xl\"\n >\n {title}\n </motion.h1>\n </div>\n </section>\n );\n}\n\nexport default RadialBurstHero;\n", |
There was a problem hiding this comment.
Restore the stat row promised by this block.
RadialBurstHero now renders only the headline, and Line 4143 explicitly records that the count-up stat row was removed. The PR objective for hero-radial-burst still calls for a count-up stats section, so this entry now ships the wrong user-facing component.
Also applies to: 4131-4145
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@registry.json` at line 4116, RadialBurstHero is missing the count-up stat row
promised in the PR (the hero now only renders the headline); restore the removed
stats block by reintroducing the stat row JSX into the RadialBurstHero return
(between the headline motion.h1 and the section close), using the same
structure/semantics as the original design (a container with numeric counters
and labels), wire it to any existing props or local constants if needed, and
ensure styles/aria match surrounding elements; look for the RadialBurstHero
function and the place where title is rendered (motion.h1) to locate where to
insert the stat row and update any exports/props (RadialBurstHeroProps /
burstProps) if the stats require external data.
Type
Summary
A Stripe-style hero block built from the provided reference designs: a canvas radial burst of fine rays rising from a bright bottom-center core, beneath a headline, with six time-of-day themes (Pre-dawn, Sunrise, Daytime, Dusk, Sunset, Night) selectable from an in-block switcher.
Screenshots / video (UI changes only)
Canvas/animated — not a single static frame. Verified by rendering the live block and screenshotting Night, Sunrise, Daytime, and Sunset (all match the reference palettes). Switching themes crossfades the background gradient and eases the burst colors; fibers continuously grow, fade, and respawn; reduced-motion renders a calm static frame.
Test plan
pnpm test(66 repo + 92 www tests)pnpm build:registry— diff limited tohero-radial-burst+ aggregates (cross-link baseline test still passes)pnpm registry:validate(65 entries) ·pnpm check:reduced-motion(covered)pnpm --dir apps/www build(202/202 static pages) ·tsc --noEmit·eslintNew component checklist
Registry sources
registry/blocks/hero/radial-burst/component.tsxcreatedregistry/components/hero-radial-burst.jsoncreated —registryblock (deps incl.lucide-react) +docs(name, description, category, props)order/docsOrderviapnpm new:componentregistry/blocks/hero/radial-burst/demo.tsxadded + key appended toregistry/demos/demo-key-order.json;RadialBurstHeroimport added toregistry/demos/shared.tsxpnpm build:registryrun — no unexpected generated diffComponent quality gate
"use client"(hooks + canvas)classNameand merges viacnOmit<ComponentProps<"section">, …>)motionfrommotion/react—useMotionValue/animatedrive the burst intro fade and theme color blend; motion variants for chrome entrance;AnimatePresencefor the dropdownuseReducedMotion— static full burst, final stat values, no rAFResizeObservercancelled/disconnected on unmount; dropdown listeners removed on closewindow/documentat module scope;getContextnull-guardedrole=listbox/option, Escape + outside-click close); decorative canvas isaria-hiddendependencies(motion,lucide-react,clsx,tailwind-merge)apps/www/tests/blocks.test.tsx(auto-includes registered demos)🤖 Generated with Claude Code
Summary by CodeRabbit