Skip to content

Commit c9f98b2

Browse files
cards demo: fall back to a flat card when WebGL is unavailable (#1075)
## Why A visitor whose browser can't create a WebGL context got Next's "Application error: a client-side exception has occurred" in place of the whole cards playground at `docs.lightspark.com/cards/demo`. The console showed `THREE.WebGLRenderer: A WebGL context could not be created ... GL_VENDOR = Disabled`. This happens when hardware acceleration is off, the GPU is blocklisted, or a managed machine disables WebGL, and Chrome no longer falls back to a software renderer. The 3D card threw during render and nothing caught it, so React unmounted the page. ## What changed - `CardStageBoundary` wraps the 3D stage on the playground (`AppPanel`) and the share page (`ShareCard`). When the 3D card throws, only the card is replaced; the configure panel, phone, and API panel stay up. - `FlatCard` shows the card's faces painted by the same `paintFront` and `paintBack` the 3D mesh uses, as two canvases turned in CSS 3D. Upright cards are turned back from the texture's quarter turn. - `FlatCardStage` runs the flat card on the same `CardMotion` (drag, fling, tilt, idle bob, decline shake), the same intro blueprint, the flight into the phone's card slot, and the flight into the share frame. - `FlatExporter` implements a new `CardFrameSource` interface (the part of `CardExporter` the share code uses). It projects the faces through the 3D exporter's camera with a mesh of affine triangle draws on a 2D canvas, so share stills, all four poses, the hand treatment, and the spin video render without WebGL. - `StageGL` draws the same dot lattice (`dotLayout`) on a 2D canvas when it can't get a WebGL context. There is no ripple or phone-glass refraction in that case. - The rest placement, phone-slot flight, screen clip, and share-frame flight moved from `CardRig` into `stagePlacement.ts` so both stages use one copy. The math is unchanged. ## Risk to the WebGL experience The 3D path runs the same code as before plus an idle error boundary. There is no WebGL probe on load; the boundary alone detects the failure. The `CardRig` frame loop was compared line by line with the old inline code. First Load JS for `/` goes from 568 kB to 573 kB and for `/c/[slug]` from 377 kB to 380 kB. One behavior change: any error thrown inside the 3D stage now swaps in the flat card instead of showing the error page. The error is still logged with `console.error`. Duplicate code between `FlatCardStage`, `CardStage`, and `ShareCard` (the per-frame placement, the intro clock, a few effects) is left for a follow-up so this PR doesn't rework the 3D frame loop further. ## Test plan - Chrome with `--disable-gpu --disable-software-rasterizer`, against the dev server and embedded in local Mintlify: the playground loads with the flat card, the intro plays, the card drags and flings, presets and uploaded art repaint it, portrait works, and Issue card, Reveal, and Lock fly it into the phone. - The same Chrome: the share sheet opens with the card in its frame; square, post, and hand stills render; Angled, Turned, and Back poses render with card art; the spin video encodes (5 s, about 6 s to render). - The same Chrome: `/c/acme-bank` shows the flat card with the dot grid, intro, and drag. - Chrome with the GPU on: the 3D intro, drag, flows into the phone, share sheet, 3D stills, and the share page behave as before. - Chrome with the GPU on, with `getContext` returning null for the renderer's context only: the boundary swaps in the flat card and the page stays up. - `tsc --noEmit` reports no new errors, and `next build` passes. - Not yet tested: Safari and iOS Safari, with or without WebGL. --------- Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent d202e24 commit c9f98b2

21 files changed

Lines changed: 1229 additions & 229 deletions

‎components/grid-cards-demo/src/app/(share)/c/[slug]/ShareCard.tsx‎

Lines changed: 90 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@
1010
import { IconRotate360Right } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconRotate360Right';
1111
import { Canvas, useFrame, useThree } from '@react-three/fiber';
1212
import clsx from 'clsx';
13-
import { useReducedMotion } from 'motion/react';
13+
import { cancelFrame, frame, useReducedMotion } from 'motion/react';
1414
import {
1515
useCallback,
1616
useEffect,
17+
useLayoutEffect,
1718
useMemo,
1819
useRef,
1920
useState,
@@ -31,6 +32,8 @@ import { flushDeferredPaints, holdDeferredPaints } from '@/components/CardStage/
3132
import { preheatSoon } from '@/lib/sounds';
3233
import { CardIntro } from '@/components/CardStage/CardIntro';
3334
import { CardMotion, ORIENT_ROLL } from '@/components/CardStage/cardMotion';
35+
import { CardStageBoundary } from '@/components/CardStage/CardStageBoundary';
36+
import { applyIntroLook, clearIntroLook, FlatCard, poseTransform } from '@/components/CardStage/FlatCard';
3437
import { exposureFor, paletteOn, TEMPLATE_TUPLE, type Palette } from '@/components/CardStage/export/compose';
3538
import { INTRO_END, introCard, stepIntro } from '@/components/CardStage/introTimeline';
3639
import { StageGL } from '@/components/glass-gl/StageGL';
@@ -92,6 +95,11 @@ export function ShareCard({ design, brand, pitch, actions, alt }: ShareCardProps
9295
const [overCardNow, setOverCardNow] = useState(false);
9396
/** The card has been turned by hand once: the hint has done its job. */
9497
const [dragged, setDragged] = useState(false);
98+
// Once the 3D card has failed (no WebGL, or its GPU gone) the card is flat,
99+
// on the same motion and intro (see the flat rig below).
100+
const [flat, setFlat] = useState(false);
101+
const lookRef = useRef<HTMLDivElement>(null);
102+
const turnRef = useRef<HTMLDivElement>(null);
95103

96104
// The theme is on <html>, set by the root layout's boot script before the
97105
// first paint. The page's colors come from the stylesheet off that
@@ -220,6 +228,54 @@ export function ShareCard({ design, brand, pitch, actions, alt }: ShareCardProps
220228

221229
const foot = footprint(design.orientation);
222230

231+
// ── The flat rig: what Rig does for the mesh, for the flat card ──────────
232+
const flatIntro = useRef({ t: -1, done: false });
233+
const onFlatPainted = useCallback(() => {
234+
if (flatIntro.current.t < 0) flatIntro.current.t = 0;
235+
}, []);
236+
const flatLive = useRef({ orientation: design.orientation, reduceMotion, introDone });
237+
flatLive.current = { orientation: design.orientation, reduceMotion, introDone };
238+
useLayoutEffect(() => {
239+
const stage = stageRef.current;
240+
const hit = hitRef.current;
241+
if (!flat || !stage || !hit) return;
242+
// The 3D card played the intro before it failed: the flat one starts shown.
243+
if (flatLive.current.introDone) flatIntro.current = { t: INTRO_END, done: true };
244+
let last: number | null = null;
245+
const step = ({ timestamp }: { timestamp: number }) => {
246+
const look = lookRef.current;
247+
const turn = turnRef.current;
248+
if (!look || !turn) return;
249+
const dt = last === null ? 0 : Math.min(0.05, (timestamp - last) / 1000);
250+
last = timestamp;
251+
const { orientation, reduceMotion: still } = flatLive.current;
252+
const f = footprint(orientation);
253+
const w = stage.clientWidth;
254+
const h = stage.clientHeight;
255+
const s = cardScale(w, h, f);
256+
const it = flatIntro.current;
257+
const p = motion.step(dt, { wantBack: false, hold: !it.done, reduceMotion: still, bob: it.done });
258+
const bob = it.done ? p.dy : 0;
259+
hit.style.transform = `translate(${w / 2 + p.dx * s - f.w / 2}px, ${h / 2 + bob - f.h / 2}px) scale(${s})`;
260+
hit.style.setProperty('--card-scale', s.toFixed(4));
261+
turn.style.transform = poseTransform(p);
262+
if (!it.done) {
263+
if (it.t >= 0) it.t += dt;
264+
if (still && it.t >= 0) it.t = INTRO_END;
265+
if (overlayRef.current && it.t >= 0) stepIntro(overlayRef.current, it.t);
266+
applyIntroLook(look, it.t);
267+
if (it.t >= INTRO_END) {
268+
it.done = true;
269+
clearIntroLook(look);
270+
onIntroDone();
271+
}
272+
}
273+
};
274+
step({ timestamp: performance.now() });
275+
frame.postRender(step, true);
276+
return () => cancelFrame(step);
277+
}, [flat, motion, onIntroDone]);
278+
223279
return (
224280
// The pointer is handled here, on the page: the stage paints over the
225281
// columns (the card is always on top) but lets the pointer through, so
@@ -246,38 +302,47 @@ export function ShareCard({ design, brand, pitch, actions, alt }: ShareCardProps
246302

247303
{/* The card's stage: the whole page; the card sits at its center. */}
248304
<div ref={stageRef} className={styles.stage} role="img" aria-label={alt}>
249-
<Canvas
250-
className={styles.canvas}
251-
dpr={[1, 2]}
252-
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
253-
camera={{ position: [0, 0, CAMERA_Z], near: 200, far: 6000 }}
254-
onCreated={({ gl }) => {
255-
gl.toneMapping = NEUTRAL_TONE_MAPPING;
256-
// Hidden until the intro brings it into focus.
257-
gl.domElement.style.opacity = '0';
258-
}}
259-
>
260-
<StageCamera exposure={exposureFor(palette)} />
261-
<CardEnv />
262-
<directionalLight position={[2, 5, 6]} intensity={0.3} color="#eef2f8" />
263-
<Rig
264-
motion={motion}
265-
state={meshState}
266-
reduceMotion={reduceMotion}
267-
hitRef={hitRef}
268-
overlayRef={overlayRef}
269-
onIntroDone={onIntroDone}
270-
/>
271-
</Canvas>
305+
{!flat && (
306+
<CardStageBoundary fallback={null} onFallback={() => setFlat(true)}>
307+
<Canvas
308+
className={styles.canvas}
309+
dpr={[1, 2]}
310+
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
311+
camera={{ position: [0, 0, CAMERA_Z], near: 200, far: 6000 }}
312+
onCreated={({ gl }) => {
313+
gl.toneMapping = NEUTRAL_TONE_MAPPING;
314+
// Hidden until the intro brings it into focus.
315+
gl.domElement.style.opacity = '0';
316+
}}
317+
>
318+
<StageCamera exposure={exposureFor(palette)} />
319+
<CardEnv />
320+
<directionalLight position={[2, 5, 6]} intensity={0.3} color="#eef2f8" />
321+
<Rig
322+
motion={motion}
323+
state={meshState}
324+
reduceMotion={reduceMotion}
325+
hitRef={hitRef}
326+
overlayRef={overlayRef}
327+
onIntroDone={onIntroDone}
328+
/>
329+
</Canvas>
330+
</CardStageBoundary>
331+
)}
272332
{/* Rides with the card, its footprint in card px: the blueprint is
273-
laid out on it. */}
333+
laid out on it (or, flat, the card itself). */}
274334
<div
275335
ref={hitRef}
276336
className={styles.hit}
277337
style={{ width: foot.w, height: foot.h }}
278338
data-stage-foreground
279339
aria-hidden
280340
>
341+
{flat && (
342+
<div ref={lookRef}>
343+
<FlatCard design={design} turnRef={turnRef} onPainted={onFlatPainted} />
344+
</div>
345+
)}
281346
{!introDone && <CardIntro ref={overlayRef} brand={brand} orientation={design.orientation} />}
282347
{/* The playground's hint, under the card, until it has been turned. */}
283348
<span className={clsx(styles.hint, (dragged || !introDone) && styles.hintGone)}>

‎components/grid-cards-demo/src/app/page.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { IconArrowRight } from '@central-icons-react/round-outlined-radius-3-str
66
import { IconArrowLeft } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconArrowLeft';
77
import { ConfigurePanel } from '@/components/ConfigurePanel/ConfigurePanel';
88
import { AppPanel } from '@/components/AppPanel/AppPanel';
9-
import type { CardExporter } from '@/components/CardStage/export/exportRenderer';
9+
import type { CardFrameSource } from '@/components/CardStage/export/exportRenderer';
1010
import { StageShareButton } from '@/components/ShareSheet/StageShareButton';
1111
import { ApiPanel } from '@/components/ApiPanel/ApiPanel';
1212
import { ColumnResizeHandle } from '@/components/ColumnResizeHandle/ColumnResizeHandle';
@@ -39,7 +39,7 @@ export default function Page() {
3939
const logic = useCardsDemoLogic();
4040
const { layoutRef, apiColRef, apiWidth, resizing, onResizeStart } = useColumnResize();
4141
// The card's exporter (filled by the stage) and the share sheet over it.
42-
const exportRef = useRef<CardExporter | null>(null);
42+
const exportRef = useRef<CardFrameSource | null>(null);
4343
const [shareOpen, setShareOpen] = useState(false);
4444
const toggleShare = useCallback(() => setShareOpen((o) => !o), []);
4545
// A flow brings the phone in and takes the card: the share is over, so

‎components/grid-cards-demo/src/components/AppPanel/AppPanel.tsx‎

Lines changed: 32 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
'use client';
22

33
import clsx from 'clsx';
4-
import { useState } from 'react';
4+
import { useCallback, useState } from 'react';
55
import { DotGridCanvas } from '@/components/DotGridCanvas/DotGridCanvas';
66
import { CardStage, type ShareStageState } from '@/components/CardStage/CardStage';
7-
import type { CardExporter } from '@/components/CardStage/export/exportRenderer';
7+
import { CardStageBoundary } from '@/components/CardStage/CardStageBoundary';
8+
import { FlatCardStage } from '@/components/CardStage/FlatCardStage';
9+
import type { CardFrameSource } from '@/components/CardStage/export/exportRenderer';
810
import { SharePanel } from '@/components/ShareSheet/SharePanel';
911
import type { DesignEditOptions, SharedCard } from '@/hooks/useCardsDemoLogic';
1012
import { DemoPhone } from '@/components/DemoPhone/DemoPhone';
@@ -37,7 +39,7 @@ export interface AppPanelProps {
3739
cardOptions?: UseCardHomeOptions['card'];
3840
onSettled?: () => void;
3941
/** The card's exporter, filled by the stage (see CardStage). */
40-
exportRef?: React.MutableRefObject<CardExporter | null>;
42+
exportRef?: React.MutableRefObject<CardFrameSource | null>;
4143
/** Extra chrome on the stage under the floating card (the Share button). */
4244
stageActions?: React.ReactNode;
4345
/** Share, on the stage: the card parks in the panel's frame. */
@@ -83,6 +85,23 @@ export function AppPanel({
8385
phoneUp,
8486
});
8587
const theme = useThemeMode();
88+
// The 3D card, until it fails (no WebGL, or its GPU gone): then the flat
89+
// card takes the stage. One that failed after its intro doesn't play it again.
90+
const [introPlayed, setIntroPlayed] = useState(false);
91+
const introDone = useCallback(() => {
92+
setIntroPlayed(true);
93+
onIntroDone?.();
94+
}, [onIntroDone]);
95+
const flatStage = (
96+
<FlatCardStage
97+
design={design}
98+
home={home}
99+
exportRef={exportRef}
100+
share={shareStage}
101+
introPlayed={introPlayed}
102+
onIntroDone={introDone}
103+
/>
104+
);
86105
// Two states only: the card floats alone, or it is in the phone. The first
87106
// flow brings the phone in and the card flies into its slot; the phone stays
88107
// through later flows until the visitor sends it away.
@@ -136,14 +155,16 @@ export function AppPanel({
136155
frontHost={frontHost}
137156
/>
138157
)}
139-
<CardStage
140-
design={design}
141-
home={home}
142-
onDesignChange={onDesignChange}
143-
exportRef={exportRef}
144-
share={shareStage}
145-
onIntroDone={onIntroDone}
146-
/>
158+
<CardStageBoundary fallback={flatStage}>
159+
<CardStage
160+
design={design}
161+
home={home}
162+
onDesignChange={onDesignChange}
163+
exportRef={exportRef}
164+
share={shareStage}
165+
onIntroDone={introDone}
166+
/>
167+
</CardStageBoundary>
147168
<div ref={setFrontHost} className={styles.shareFront} aria-hidden />
148169
{stageActions}
149170
</DotGridCanvas>

‎components/grid-cards-demo/src/components/CardStage/CardStage.module.scss‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -456,3 +456,11 @@
456456
opacity: 0;
457457
}
458458

459+
/* Without WebGL (FlatCardStage): the flat card in the hit box, dimming as the
460+
3D canvas does. */
461+
.flatCard {
462+
transition:
463+
opacity 0.45s ease,
464+
filter 0.45s ease;
465+
}
466+

0 commit comments

Comments
 (0)