Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
115 changes: 90 additions & 25 deletions components/grid-cards-demo/src/app/(share)/c/[slug]/ShareCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,11 @@
import { IconRotate360Right } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconRotate360Right';
import { Canvas, useFrame, useThree } from '@react-three/fiber';
import clsx from 'clsx';
import { useReducedMotion } from 'motion/react';
import { cancelFrame, frame, useReducedMotion } from 'motion/react';
import {
useCallback,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
Expand All @@ -31,6 +32,8 @@ import { flushDeferredPaints, holdDeferredPaints } from '@/components/CardStage/
import { preheatSoon } from '@/lib/sounds';
import { CardIntro } from '@/components/CardStage/CardIntro';
import { CardMotion, ORIENT_ROLL } from '@/components/CardStage/cardMotion';
import { CardStageBoundary } from '@/components/CardStage/CardStageBoundary';
import { applyIntroLook, clearIntroLook, FlatCard, poseTransform } from '@/components/CardStage/FlatCard';
import { exposureFor, paletteOn, TEMPLATE_TUPLE, type Palette } from '@/components/CardStage/export/compose';
import { INTRO_END, introCard, stepIntro } from '@/components/CardStage/introTimeline';
import { StageGL } from '@/components/glass-gl/StageGL';
Expand Down Expand Up @@ -92,6 +95,11 @@ export function ShareCard({ design, brand, pitch, actions, alt }: ShareCardProps
const [overCardNow, setOverCardNow] = useState(false);
/** The card has been turned by hand once: the hint has done its job. */
const [dragged, setDragged] = useState(false);
// Once the 3D card has failed (no WebGL, or its GPU gone) the card is flat,
// on the same motion and intro (see the flat rig below).
const [flat, setFlat] = useState(false);
const lookRef = useRef<HTMLDivElement>(null);
const turnRef = useRef<HTMLDivElement>(null);

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

const foot = footprint(design.orientation);

// ── The flat rig: what Rig does for the mesh, for the flat card ──────────
const flatIntro = useRef({ t: -1, done: false });
const onFlatPainted = useCallback(() => {
if (flatIntro.current.t < 0) flatIntro.current.t = 0;
}, []);
const flatLive = useRef({ orientation: design.orientation, reduceMotion, introDone });
flatLive.current = { orientation: design.orientation, reduceMotion, introDone };
useLayoutEffect(() => {
const stage = stageRef.current;
const hit = hitRef.current;
if (!flat || !stage || !hit) return;
// The 3D card played the intro before it failed: the flat one starts shown.
if (flatLive.current.introDone) flatIntro.current = { t: INTRO_END, done: true };
let last: number | null = null;
const step = ({ timestamp }: { timestamp: number }) => {
const look = lookRef.current;
const turn = turnRef.current;
if (!look || !turn) return;
const dt = last === null ? 0 : Math.min(0.05, (timestamp - last) / 1000);
last = timestamp;
const { orientation, reduceMotion: still } = flatLive.current;
const f = footprint(orientation);
const w = stage.clientWidth;
const h = stage.clientHeight;
const s = cardScale(w, h, f);
const it = flatIntro.current;
const p = motion.step(dt, { wantBack: false, hold: !it.done, reduceMotion: still, bob: it.done });
const bob = it.done ? p.dy : 0;
hit.style.transform = `translate(${w / 2 + p.dx * s - f.w / 2}px, ${h / 2 + bob - f.h / 2}px) scale(${s})`;
hit.style.setProperty('--card-scale', s.toFixed(4));
turn.style.transform = poseTransform(p);
if (!it.done) {
if (it.t >= 0) it.t += dt;
if (still && it.t >= 0) it.t = INTRO_END;
if (overlayRef.current && it.t >= 0) stepIntro(overlayRef.current, it.t);
applyIntroLook(look, it.t);
if (it.t >= INTRO_END) {
it.done = true;
clearIntroLook(look);
onIntroDone();
}
}
};
step({ timestamp: performance.now() });
frame.postRender(step, true);
return () => cancelFrame(step);
}, [flat, motion, onIntroDone]);

return (
// The pointer is handled here, on the page: the stage paints over the
// columns (the card is always on top) but lets the pointer through, so
Expand All @@ -246,38 +302,47 @@ export function ShareCard({ design, brand, pitch, actions, alt }: ShareCardProps

{/* The card's stage: the whole page; the card sits at its center. */}
<div ref={stageRef} className={styles.stage} role="img" aria-label={alt}>
<Canvas
className={styles.canvas}
dpr={[1, 2]}
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
camera={{ position: [0, 0, CAMERA_Z], near: 200, far: 6000 }}
onCreated={({ gl }) => {
gl.toneMapping = NEUTRAL_TONE_MAPPING;
// Hidden until the intro brings it into focus.
gl.domElement.style.opacity = '0';
}}
>
<StageCamera exposure={exposureFor(palette)} />
<CardEnv />
<directionalLight position={[2, 5, 6]} intensity={0.3} color="#eef2f8" />
<Rig
motion={motion}
state={meshState}
reduceMotion={reduceMotion}
hitRef={hitRef}
overlayRef={overlayRef}
onIntroDone={onIntroDone}
/>
</Canvas>
{!flat && (
<CardStageBoundary fallback={null} onFallback={() => setFlat(true)}>
<Canvas
className={styles.canvas}
dpr={[1, 2]}
gl={{ antialias: true, alpha: true, powerPreference: 'high-performance' }}
camera={{ position: [0, 0, CAMERA_Z], near: 200, far: 6000 }}
onCreated={({ gl }) => {
gl.toneMapping = NEUTRAL_TONE_MAPPING;
// Hidden until the intro brings it into focus.
gl.domElement.style.opacity = '0';
}}
>
<StageCamera exposure={exposureFor(palette)} />
<CardEnv />
<directionalLight position={[2, 5, 6]} intensity={0.3} color="#eef2f8" />
<Rig
motion={motion}
state={meshState}
reduceMotion={reduceMotion}
hitRef={hitRef}
overlayRef={overlayRef}
onIntroDone={onIntroDone}
/>
</Canvas>
</CardStageBoundary>
)}
{/* Rides with the card, its footprint in card px: the blueprint is
laid out on it. */}
laid out on it (or, flat, the card itself). */}
<div
ref={hitRef}
className={styles.hit}
style={{ width: foot.w, height: foot.h }}
data-stage-foreground
aria-hidden
>
{flat && (
<div ref={lookRef}>
<FlatCard design={design} turnRef={turnRef} onPainted={onFlatPainted} />
</div>
)}
{!introDone && <CardIntro ref={overlayRef} brand={brand} orientation={design.orientation} />}
{/* The playground's hint, under the card, until it has been turned. */}
<span className={clsx(styles.hint, (dragged || !introDone) && styles.hintGone)}>
Expand Down
4 changes: 2 additions & 2 deletions components/grid-cards-demo/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { IconArrowRight } from '@central-icons-react/round-outlined-radius-3-str
import { IconArrowLeft } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconArrowLeft';
import { ConfigurePanel } from '@/components/ConfigurePanel/ConfigurePanel';
import { AppPanel } from '@/components/AppPanel/AppPanel';
import type { CardExporter } from '@/components/CardStage/export/exportRenderer';
import type { CardFrameSource } from '@/components/CardStage/export/exportRenderer';
import { StageShareButton } from '@/components/ShareSheet/StageShareButton';
import { ApiPanel } from '@/components/ApiPanel/ApiPanel';
import { ColumnResizeHandle } from '@/components/ColumnResizeHandle/ColumnResizeHandle';
Expand Down Expand Up @@ -39,7 +39,7 @@ export default function Page() {
const logic = useCardsDemoLogic();
const { layoutRef, apiColRef, apiWidth, resizing, onResizeStart } = useColumnResize();
// The card's exporter (filled by the stage) and the share sheet over it.
const exportRef = useRef<CardExporter | null>(null);
const exportRef = useRef<CardFrameSource | null>(null);
const [shareOpen, setShareOpen] = useState(false);
const toggleShare = useCallback(() => setShareOpen((o) => !o), []);
// A flow brings the phone in and takes the card: the share is over, so
Expand Down
43 changes: 32 additions & 11 deletions components/grid-cards-demo/src/components/AppPanel/AppPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
'use client';

import clsx from 'clsx';
import { useState } from 'react';
import { useCallback, useState } from 'react';
import { DotGridCanvas } from '@/components/DotGridCanvas/DotGridCanvas';
import { CardStage, type ShareStageState } from '@/components/CardStage/CardStage';
import type { CardExporter } from '@/components/CardStage/export/exportRenderer';
import { CardStageBoundary } from '@/components/CardStage/CardStageBoundary';
import { FlatCardStage } from '@/components/CardStage/FlatCardStage';
import type { CardFrameSource } from '@/components/CardStage/export/exportRenderer';
import { SharePanel } from '@/components/ShareSheet/SharePanel';
import type { DesignEditOptions, SharedCard } from '@/hooks/useCardsDemoLogic';
import { DemoPhone } from '@/components/DemoPhone/DemoPhone';
Expand Down Expand Up @@ -37,7 +39,7 @@ export interface AppPanelProps {
cardOptions?: UseCardHomeOptions['card'];
onSettled?: () => void;
/** The card's exporter, filled by the stage (see CardStage). */
exportRef?: React.MutableRefObject<CardExporter | null>;
exportRef?: React.MutableRefObject<CardFrameSource | null>;
/** Extra chrome on the stage under the floating card (the Share button). */
stageActions?: React.ReactNode;
/** Share, on the stage: the card parks in the panel's frame. */
Expand Down Expand Up @@ -83,6 +85,23 @@ export function AppPanel({
phoneUp,
});
const theme = useThemeMode();
// The 3D card, until it fails (no WebGL, or its GPU gone): then the flat
// card takes the stage. One that failed after its intro doesn't play it again.
const [introPlayed, setIntroPlayed] = useState(false);
const introDone = useCallback(() => {
setIntroPlayed(true);
onIntroDone?.();
}, [onIntroDone]);
const flatStage = (
<FlatCardStage
design={design}
home={home}
exportRef={exportRef}
share={shareStage}
introPlayed={introPlayed}
onIntroDone={introDone}
/>
);
// Two states only: the card floats alone, or it is in the phone. The first
// flow brings the phone in and the card flies into its slot; the phone stays
// through later flows until the visitor sends it away.
Expand Down Expand Up @@ -136,14 +155,16 @@ export function AppPanel({
frontHost={frontHost}
/>
)}
<CardStage
design={design}
home={home}
onDesignChange={onDesignChange}
exportRef={exportRef}
share={shareStage}
onIntroDone={onIntroDone}
/>
<CardStageBoundary fallback={flatStage}>
<CardStage
design={design}
home={home}
onDesignChange={onDesignChange}
exportRef={exportRef}
share={shareStage}
onIntroDone={introDone}
/>
</CardStageBoundary>
<div ref={setFrontHost} className={styles.shareFront} aria-hidden />
{stageActions}
</DotGridCanvas>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -456,3 +456,11 @@
opacity: 0;
}

/* Without WebGL (FlatCardStage): the flat card in the hit box, dimming as the
3D canvas does. */
.flatCard {
transition:
opacity 0.45s ease,
filter 0.45s ease;
}

Loading