From 75015d9ebabeefbcb6d8cfdc23ee352ac44c4c58 Mon Sep 17 00:00:00 2001 From: Adam Tyler Date: Tue, 23 Jun 2026 10:39:05 -0500 Subject: [PATCH] Fill see-through corner gaps on rounded dice during rolls A rounded die is a hollow CSS cube: each face carries border-radius, so at large dieCornerRadius the rounded-away corners of adjacent faces let the page background show through the 8 vertices while the die spins. Add a solid inner core cube in faceColor that renders only while the die is mid-roll (new isRolling state). It sits strictly inside the shell silhouette, so it backs the gaps with the die's own color without ever poking past the faces. The fillers are skipped when cornerRadius is 0 or would consume the whole die. At rest the die sits face-on and needs no fillers. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/Die.test.tsx | 57 ++++++++++++++++++++++++++++++++++++++++++++++++ lib/Die.tsx | 44 +++++++++++++++++++++++++++++++++++++ 2 files changed, 101 insertions(+) diff --git a/lib/Die.test.tsx b/lib/Die.test.tsx index 95d7079..b76bd39 100644 --- a/lib/Die.test.tsx +++ b/lib/Die.test.tsx @@ -259,5 +259,62 @@ describe('Die', () => { const face = container.querySelector('.face') as HTMLElement expect(face.style.boxShadow).toBe('') }) + + it('does not render gap fillers while the die is at rest', () => { + const { container } = renderDie({ dieCornerRadius: 10 }) + expect(container.querySelectorAll('.die-core')).toHaveLength(0) + }) + + it('renders a solid core to back the corner gaps while rolling', () => { + vi.useFakeTimers() + try { + const { container, ref } = renderDie({ dieCornerRadius: 10, dieSize: 60, faceColor: 'rgb(255, 0, 0)' }) + act(() => ref.current!.rollDie(4)) + const core = container.querySelectorAll('.die-core') + expect(core).toHaveLength(6) + const coreFace = core[0] as HTMLElement + expect(coreFace.style.background).toBe('rgb(255, 0, 0)') + // dieSize - 2 * cornerRadius = 60 - 20 = 40 + expect(coreFace.style.width).toBe('40px') + expect(coreFace.style.height).toBe('40px') + } finally { + vi.useRealTimers() + } + }) + + it('removes the gap fillers once the roll settles', () => { + vi.useFakeTimers() + try { + const { container, ref } = renderDie({ dieCornerRadius: 10, rollTime: 2 }) + act(() => ref.current!.rollDie(4)) + expect(container.querySelectorAll('.die-core')).toHaveLength(6) + act(() => vi.advanceTimersByTime(2000)) + expect(container.querySelectorAll('.die-core')).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + it('omits the fillers when cornerRadius would consume the whole die', () => { + vi.useFakeTimers() + try { + const { container, ref } = renderDie({ dieCornerRadius: 30, dieSize: 60 }) + act(() => ref.current!.rollDie(4)) + expect(container.querySelectorAll('.die-core')).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) + + it('omits the fillers when cornerRadius is 0', () => { + vi.useFakeTimers() + try { + const { container, ref } = renderDie({ dieCornerRadius: 0 }) + act(() => ref.current!.rollDie(4)) + expect(container.querySelectorAll('.die-core')).toHaveLength(0) + } finally { + vi.useRealTimers() + } + }) }) }) diff --git a/lib/Die.tsx b/lib/Die.tsx index 8b30593..d21b2e9 100644 --- a/lib/Die.tsx +++ b/lib/Die.tsx @@ -64,6 +64,10 @@ const Die = forwardRef( // className-reset + forced-reflow hack that could fail to restart on // rapid/overlapping rolls). const [rollKey, setRollKey] = useState(0) + // Only fill the corner/interior gaps while the die is mid-roll. At rest the + // die sits face-on, where a rounded cube correctly shows empty corners — the + // fillers there would just be visible scallops poking past the silhouette. + const [isRolling, setIsRolling] = useState(false) // Only d6 faces are rendered; clamp to 1-6 regardless of sides prop const getRandomInt = () => { @@ -79,8 +83,10 @@ const Die = forwardRef( setDieValue(roll) setHasRolled(true) setRollKey((k) => k + 1) + setIsRolling(true) if (timeoutRef.current !== null) clearTimeout(timeoutRef.current) timeoutRef.current = setTimeout(() => { + setIsRolling(false) onRollDone(roll) timeoutRef.current = null }, rollTime * 1000) @@ -150,6 +156,35 @@ const Die = forwardRef( bottom: `${dieSize / 6}px`, right: `${dieSize / 6}px`, } + // Solid inner core to back the gaps left by rounded faces. The cube is + // hollow, so when dieCornerRadius is large the rounded-away corners of + // adjacent faces let the page background show through during a roll. A + // smaller solid cube in faceColor sits just inside the shell so those gaps + // reveal the die's own color instead of empty space. It stays strictly + // inside the shell silhouette, so it never pokes past the faces. + const coreSize = Math.max(dieSize - dieCornerRadius * 2, 0) + const showFillers = isRolling && dieCornerRadius > 0 && coreSize > 0 + const coreInset = (dieSize - coreSize) / 2 + const coreFaceStyle: React.CSSProperties = { + background: faceColor, + height: `${coreSize}px`, + width: `${coreSize}px`, + position: 'absolute', + top: `${coreInset}px`, + left: `${coreInset}px`, + // visible from both sides so a gap always shows faceColor at any angle + backfaceVisibility: 'visible', + WebkitBackfaceVisibility: 'visible', + } + const coreTransforms = [ + `rotateY(0deg) translateZ(${coreSize / 2}px)`, + `rotateX(180deg) translateZ(${coreSize / 2}px)`, + `rotateY(90deg) translateZ(${coreSize / 2}px)`, + `rotateY(-90deg) translateZ(${coreSize / 2}px)`, + `rotateX(90deg) translateZ(${coreSize / 2}px)`, + `rotateX(-90deg) translateZ(${coreSize / 2}px)`, + ] + // roll styles const rollStyle = { animationDuration: `${rollTime}s`, @@ -180,6 +215,15 @@ const Die = forwardRef( className={`die ${hasRolled ? 'roll' : 'init-roll'}${dieValue}`} style={rollStyle} > + {showFillers && + coreTransforms.map((transform, i) => ( +