diff --git a/apps/caramel-app/src/components/HeroSection.tsx b/apps/caramel-app/src/components/HeroSection.tsx
index 88cfbff..0be4760 100644
--- a/apps/caramel-app/src/components/HeroSection.tsx
+++ b/apps/caramel-app/src/components/HeroSection.tsx
@@ -493,9 +493,19 @@ export default function HeroSection() {
reduce={!!reduceMotion}
/>
+ {/* The canvas raster BLEEDS 48px/40px past the layout
+ box (must match CANVAS_BLEED_X/Y_PX in
+ HeroTicketScene, which subtracts the bleed from its
+ fit math) so a tilted coupon or drifting droplet
+ never touches the raster edge — the section's
+ overflow-hidden still crops safely far away at the
+ SECTION edge. 48px of left bleed = exactly the
+ column gap (gap-12), so the pointer-capturing
+ canvas never covers the copy column's links. The
+ anchor box keeps its reserved h-[34rem] — zero CLS. */}
{showCanvas && (
({
origin: new THREE.Vector3(
(Math.random() - 0.5) * 7,
- (Math.random() - 0.5) * 6,
+ (Math.random() - 0.5) * 5,
(Math.random() - 0.5) * 4 - 1,
),
speed: 0.2 + Math.random() * 0.5,
@@ -242,34 +262,40 @@ function StatCoupon3D({
color={isDark ? CARAMEL_LIGHT : '#fff2e6'}
/>
{/* Soft dark outline-blur = printed-ink shadow, so the type
- reads as pressed into the caramel glass. */}
+ reads as pressed into the caramel glass (deliberately a
+ touch stronger on the number so it pops off the caramel). */}
{shown}
+ {/* Label fit math (longest label = "SUPPORTED STORES", 16
+ glyphs): Poppins-Bold uppercase averages ~0.62em advance,
+ so width ≈ 16 × 0.11 × (0.62 + 0.08 letterSpacing) ≈ 1.23 —
+ inside the usable face width of STAT.w 1.7 − 2 × notch r
+ 0.14 = 1.42, with ~0.095 margin per side. That's why the
+ letterSpacing dropped 0.12 → 0.08 alongside the size bump. */}
{stat.label.toUpperCase()}
@@ -384,16 +410,26 @@ function Droplets({ isDark }: { isDark: boolean }): React.JSX.Element {
function SceneContents({ isDark }: { isDark: boolean }): React.JSX.Element {
// The vertical FOV keeps world height constant, so on the narrow hero column
// the main ticket + the stat-coupon row can overflow the frustum — scale the
- // whole ticket group down to fit both dimensions (with a small margin).
- // Reactive to resize via R3F size state; no per-frame camera work.
- const aspect = useThree(state => state.size.width / state.size.height)
+ // whole ticket group down to fit both dimensions. The canvas raster is BLED
+ // past the layout box (CANVAS_BLEED_*_PX), so the fit is computed against
+ // the layout-box slice of the frustum, not the whole canvas: the composition
+ // keeps the size it had when canvas == layout box, and the bleed becomes
+ // pure headroom that tilted geometry can swing into without clipping. The
+ // 0.9 margin (was 0.94) leaves ~10% of the layout box as rest-pose slack
+ // before the bleed even starts. Reactive to resize via R3F size state; no
+ // per-frame camera work.
+ const { width, height } = useThree(state => state.size)
const dist = 7
const halfH = Math.tan((42 * Math.PI) / 180 / 2) * dist
- const halfW = halfH * aspect
+ const worldPerPx = (halfH * 2) / height
+ const layoutHalfW =
+ (Math.max(1, width - CANVAS_BLEED_X_PX * 2) / 2) * worldPerPx
+ const layoutHalfH =
+ (Math.max(1, height - CANVAS_BLEED_Y_PX * 2) / 2) * worldPerPx
const fit = Math.min(
1,
- (halfW * 0.94) / CONTENT_HALF_W,
- (halfH * 0.94) / CONTENT_HALF_H,
+ (layoutHalfW * 0.9) / CONTENT_HALF_W,
+ (layoutHalfH * 0.9) / CONTENT_HALF_H,
)
return (
@@ -416,8 +452,12 @@ function SceneContents({ isDark }: { isDark: boolean }): React.JSX.Element {
+ {/* Inside the fit group ON PURPOSE: the droplet field is wider
+ than the layout box in raw world units, so scaling it with
+ the content keeps every sphere inside the (bled) frustum
+ instead of slicing them mid-sphere at the raster edge. */}
+
- = {
green: 'bg-green-100 text-green-700 ring-green-200 dark:bg-green-900/30 dark:text-green-300 dark:ring-green-900/50',
amber: 'bg-amber-100 text-amber-700 ring-amber-200 dark:bg-amber-900/30 dark:text-amber-300 dark:ring-amber-900/50',
- grey: 'bg-gray-100 text-gray-600 ring-gray-200 dark:bg-gray-800 dark:text-gray-300 dark:ring-gray-700',
+ grey: 'bg-gray-100 text-gray-600 ring-gray-200 dark:bg-white/10 dark:text-gray-300 dark:ring-white/20',
red: 'bg-red-100 text-red-700 ring-red-200 dark:bg-red-900/30 dark:text-red-300 dark:ring-red-900/50',
}
@@ -55,7 +55,7 @@ export default function CouponCard({ coupon, index }: CouponCardProps) {
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
transition={{ duration: 0.3, delay: index * 0.05 }}
- className="group relative overflow-hidden rounded-3xl border border-orange-100 bg-gradient-to-br from-orange-50/50 via-white to-orange-50/40 p-5 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:border-orange-200 hover:shadow-lg dark:border-orange-900/50 dark:from-gray-900 dark:via-gray-900 dark:to-gray-900 dark:hover:border-orange-800/70"
+ className="group relative overflow-hidden rounded-3xl border border-orange-100 bg-gradient-to-br from-orange-50/50 via-white to-orange-50/40 p-5 shadow-md transition-all duration-200 hover:-translate-y-0.5 hover:border-orange-200 hover:shadow-lg dark:border-orange-900/50 dark:from-darkSurface dark:via-darkSurface dark:to-darkSurface dark:hover:border-orange-800/70"
>