diff --git a/components/grid-cards-demo/src/app/(share)/c/[slug]/ShareCard.tsx b/components/grid-cards-demo/src/app/(share)/c/[slug]/ShareCard.tsx index 7b95089a2..4e48f3cdf 100644 --- a/components/grid-cards-demo/src/app/(share)/c/[slug]/ShareCard.tsx +++ b/components/grid-cards-demo/src/app/(share)/c/[slug]/ShareCard.tsx @@ -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, @@ -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'; @@ -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(null); + const turnRef = useRef(null); // The theme is on , set by the root layout's boot script before the // 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 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 @@ -246,31 +302,35 @@ export function ShareCard({ design, brand, pitch, actions, alt }: ShareCardProps {/* The card's stage: the whole page; the card sits at its center. */}
- { - gl.toneMapping = NEUTRAL_TONE_MAPPING; - // Hidden until the intro brings it into focus. - gl.domElement.style.opacity = '0'; - }} - > - - - - - + {!flat && ( + setFlat(true)}> + { + gl.toneMapping = NEUTRAL_TONE_MAPPING; + // Hidden until the intro brings it into focus. + gl.domElement.style.opacity = '0'; + }} + > + + + + + + + )} {/* 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). */}
+ {flat && ( +
+ +
+ )} {!introDone && } {/* The playground's hint, under the card, until it has been turned. */} diff --git a/components/grid-cards-demo/src/app/page.tsx b/components/grid-cards-demo/src/app/page.tsx index cd565f4bc..624df41a8 100644 --- a/components/grid-cards-demo/src/app/page.tsx +++ b/components/grid-cards-demo/src/app/page.tsx @@ -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'; @@ -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(null); + const exportRef = useRef(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 diff --git a/components/grid-cards-demo/src/components/AppPanel/AppPanel.tsx b/components/grid-cards-demo/src/components/AppPanel/AppPanel.tsx index 79f356901..cd13d8478 100644 --- a/components/grid-cards-demo/src/components/AppPanel/AppPanel.tsx +++ b/components/grid-cards-demo/src/components/AppPanel/AppPanel.tsx @@ -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'; @@ -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; + exportRef?: React.MutableRefObject; /** 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. */ @@ -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 = ( + + ); // 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. @@ -136,14 +155,16 @@ export function AppPanel({ frontHost={frontHost} /> )} - + + +
{stageActions} diff --git a/components/grid-cards-demo/src/components/CardStage/CardStage.module.scss b/components/grid-cards-demo/src/components/CardStage/CardStage.module.scss index 341f5b5a6..96a0c666e 100644 --- a/components/grid-cards-demo/src/components/CardStage/CardStage.module.scss +++ b/components/grid-cards-demo/src/components/CardStage/CardStage.module.scss @@ -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; +} + diff --git a/components/grid-cards-demo/src/components/CardStage/CardStage.tsx b/components/grid-cards-demo/src/components/CardStage/CardStage.tsx index c9be87f9e..1446d85b2 100644 --- a/components/grid-cards-demo/src/components/CardStage/CardStage.tsx +++ b/components/grid-cards-demo/src/components/CardStage/CardStage.tsx @@ -38,21 +38,14 @@ import { CARD_FONT_FAMILY } from './card3d/cardFont'; import { CardMotion, ORIENT_ROLL } from './cardMotion'; import { flushDeferredPaints, holdDeferredPaints } from './card3d/deferredPaint'; import { installExportDevHook } from './export/devHook'; -import { CardExporter, type ExportPose } from './export/exportRenderer'; +import { CardExporter, type CardFrameSource, type ExportPose } from './export/exportRenderer'; import { useCardMomentSounds } from './cardSounds'; import { resizeCursor, rotateCursor } from './cursors'; import { CardIntro } from './CardIntro'; import { INTRO_END, introCard, stepIntro } from './introTimeline'; +import { clipToScreen, GLIDE_TAU, restOn, ShareFlight, towardSlot, type Placement } from './stagePlacement'; import styles from './CardStage.module.scss'; -/** Largest the card gets on stage, relative to its size in the phone. */ -const MAX_SCALE = 1.4; -const MIN_SCALE = 0.55; -/** Stage margin around the card. */ -const GUTTER_X = 28; -const GUTTER_Y = 120; -/** Glide time constant toward the rest position (seconds). */ -const GLIDE_TAU = 0.14; /** Camera distance, stage px. Scene units are stage px at z = 0. */ const CAMERA_Z = 2000; /** How far outside the brand's box (spec px) still grabs it. */ @@ -70,24 +63,6 @@ const ROTATE_STEP = 15; const SNAP_DEG = 3; /** Spec px → card px, the hit box's unit (the same along either axis). */ const CARD_PER_SPEC = CARD_W / FIGMA_CARD_W; -/** The card's fade under the screen's scroll edge has run out this far down - * the strip (the header's bottom edge). */ -const EDGE_FADE_RAMP_END = 0.8; -const EDGE_FADE_STOPS = 8; - -/** The parked card's mask under the scroll edge: from `top` (stage px) it - * fades in over `run` px, the alpha easing on a smoothstep so neither end of - * the ramp shows as a line, and reaching only as deep as `strength` (0..1). */ -function edgeMask(top: number, run: number, strength: number): string { - const stops: string[] = []; - for (let i = 0; i <= EDGE_FADE_STOPS; i++) { - const t = i / EDGE_FADE_STOPS; - const ease = t * t * (3 - 2 * t); - const alpha = 1 - strength * (1 - ease); - stops.push(`rgba(0,0,0,${alpha.toFixed(3)}) ${(top + run * t).toFixed(1)}px`); - } - return `linear-gradient(to bottom, ${stops.join(', ')})`; -} // Khronos PBR-neutral tone map keeps silver true (ACES warms highlights). const NEUTRAL_TONE_MAPPING = THREE.NeutralToneMapping ?? THREE.ACESFilmicToneMapping; @@ -125,14 +100,9 @@ interface Live { * stepped by the frame loop). */ shareWanted: boolean; shareT: number; - /** The angles the card had when the flight began: it turns to the pose - * along the flight, arriving as it lands. Null between flights. */ - shareFrom: ExportPose | null; shareLocked: boolean; onTurned: (() => void) | undefined; onSettled: ((settled: boolean) => void) | undefined; - /** The card is parked in the frame's slot and still (last told). */ - shareSettled: boolean; intro: Intro; /** Something the camera sees changed off the frame loop (a material, a * map, the exposure): the next frame paints. See CardRig's render gate. */ @@ -274,9 +244,6 @@ export interface ShareStageState { onSettled?: (settled: boolean) => void; } -/** The flight into the share frame and back (s). */ -const SHARE_FLIGHT_S = 0.6; - interface CardStageProps { design: CardDesign; home: CardHome; @@ -284,7 +251,7 @@ interface CardStageProps { onDesignChange?: (patch: Partial, opts?: DesignEditOptions) => void; /** Filled with the card's exporter once the mesh is mounted (the share flow * renders stills and video through it). */ - exportRef?: React.MutableRefObject; + exportRef?: React.MutableRefObject; share?: ShareStageState; /** The blueprint has dissolved and the card stands alone (the stage's * chrome waits for this). */ @@ -372,11 +339,9 @@ export function CardStage({ design, home, onDesignChange, exportRef, share, onIn orientation: design.orientation, shareWanted: false, shareT: 0, - shareFrom: null, shareLocked: false, onTurned: undefined, onSettled: undefined, - shareSettled: false, dirty: true, intro: { t: -1, @@ -1728,7 +1693,7 @@ interface CardRigProps { placement: React.MutableRefObject; onPlacement: (p: FacePlacement) => void; state: CardMeshState; - exportRef?: React.MutableRefObject; + exportRef?: React.MutableRefObject; } /** Drives the mesh and the DOM hit box every frame. Memoized: every prop @@ -1776,11 +1741,8 @@ const CardRig = memo(function CardRig({ exporter.dispose(); }; }, [exportRef, get, live, motion]); - const pos = useRef<{ x: number; y: number; s: number } | null>(null); - /** The share frame's slot as the card follows it: the slot moves and - * resizes with what the panel shows (Template, a hand), and the card - * glides after it rather than jumping. Null between flights. */ - const shareSlot = useRef<{ x: number; y: number; s: number } | null>(null); + const pos = useRef(null); + const shareFlight = useMemo(() => new ShareFlight(), []); // The turning card's air (see the frame loop), and how long it has been still. const airRef = useRef(null); const airStill = useRef(0); @@ -1873,12 +1835,7 @@ const CardRig = memo(function CardRig({ // The card's footprint on screen: flat or upright. const { orientation } = live.current; const foot = footprint(orientation); - // Rest position: centered on the stage, scaled to fit it. - const rest = { - x: r.width / 2, - y: r.height / 2, - s: Math.max(MIN_SCALE, Math.min(MAX_SCALE, (r.width - GUTTER_X * 2) / foot.w, (r.height - GUTTER_Y) / foot.h)), - }; + const rest = restOn(r.width, r.height, foot); // Glide toward rest (exponential approach), snapping on the first frame. const k = pos.current ? 1 - Math.exp(-dt / GLIDE_TAU) : 1; const p = pos.current ?? { ...rest }; @@ -1886,76 +1843,24 @@ const CardRig = memo(function CardRig({ p.y += (rest.y - p.y) * k; p.s += (rest.s - p.s) * k; pos.current = p; - // Phone up: interpolate toward the phone's live card slot and park there, - // at the scale that fits the slot (its width or its height, whichever - // the footprint needs). - let { x, y, s } = p; - const { t } = live.current; - if (t > 0) { - const slot = root.ownerDocument.querySelector('[data-card-slot]'); - if (slot) { - const b = slot.getBoundingClientRect(); - x += (b.left + b.width / 2 - r.left - x) * t; - y += (b.top + b.height / 2 - r.top - y) * t; - s += (Math.min(b.width / foot.w, b.height / foot.h) - s) * t; - } - } - // The share frame: the flight's clock steps toward wanted; the card - // interpolates to the frame's slot on the eased curve, as for the phone. + // Phone up, it interpolates toward the phone's live card slot and parks + // there; the share frame, open, takes it from there to its own slot. const lv = live.current; - const shareDir = lv.shareWanted ? 1 : -1; - const shareWas = lv.shareT; - lv.shareT = Math.max(0, Math.min(1, lv.shareT + (shareDir * dt) / (lv.reduceMotion ? 0.001 : SHARE_FLIGHT_S))); - const st = easeInOutCubic(lv.shareT); - // The turn rides the flight: where the card starts is read as the flight - // begins (either way), and the angles follow the flight's own curve so - // the pose lands with the card. Settled, the spring has it again. - const shareLanded = lv.shareT === 0 || lv.shareT === 1; - if (!shareLanded && shareWas !== lv.shareT && lv.shareFrom === null) lv.shareFrom = motion.pose; - if (shareLanded) lv.shareFrom = null; - const sharePath = lv.shareFrom ? { from: lv.shareFrom, u: lv.shareWanted ? st : 1 - st } : undefined; - let shareInPlace = false; - if (st > 0) { - const slot = root.ownerDocument.querySelector('[data-share-card-slot]'); - if (slot) { - const b = slot.getBoundingClientRect(); - const want = { - x: b.left + b.width / 2 - r.left, - y: b.top + b.height / 2 - r.top, - s: Math.min(b.width / foot.w, b.height / foot.h), - }; - // As the flight begins the slot is where it is; landed, the card - // glides after a slot that moves (the same approach as toward rest). - const g = shareSlot.current; - const sk = g && shareWas > 0 && !lv.reduceMotion ? 1 - Math.exp(-dt / GLIDE_TAU) : 1; - const tgt = g ?? { ...want }; - tgt.x += (want.x - tgt.x) * sk; - tgt.y += (want.y - tgt.y) * sk; - tgt.s += (want.s - tgt.s) * sk; - shareSlot.current = tgt; - x += (tgt.x - x) * st; - y += (tgt.y - y) * st; - s += (tgt.s - s) * st; - // Nearly in place: landed, the glide within a tenth of the card's - // width of the slot, the turn within twenty degrees. The glide and - // the spring take about half a second from here, and what waits on - // the card (the hand) arrives over that. - const near = want.s * foot.w * 0.1; - shareInPlace = - lv.shareT === 1 && - Math.abs(want.x - tgt.x) < near && - Math.abs(want.y - tgt.y) < near && - Math.abs(want.s - tgt.s) < want.s * 0.1 && - motion.nearRest(20); - } - } else { - shareSlot.current = null; - } - if (shareInPlace !== lv.shareSettled) { - lv.shareSettled = shareInPlace; - lv.onSettled?.(shareInPlace); - } - const shareFlying = lv.shareT > 0 && lv.shareT < 1; + const { t } = lv; + const flight = shareFlight.step(dt, { + wanted: lv.shareWanted, + reduceMotion: lv.reduceMotion, + motion, + doc: root.ownerDocument, + r, + foot, + at: t > 0 ? towardSlot(p, t, root.ownerDocument, r, foot) : p, + onSettled: lv.onSettled, + }); + lv.shareT = shareFlight.t; + const { x, y, s } = flight.at; + const sharePath = flight.path; + const shareFlying = shareFlight.flying; const { intro } = live.current; const pose = motion.step(dt, { @@ -2044,37 +1949,9 @@ const CardRig = memo(function CardRig({ hit.style.setProperty('--card-scale', s.toFixed(4)); } - // Parked, the card is screen content: the phone's content layer paints - // over it (AppShell), and the card is clipped to the screen, so a push - // that slides its slot past the bezel can't show it over the shell. (Not - // during the flight in or out, when it crosses the bezel on purpose.) - // The screen's scroll edge ([data-card-fade], the strip under the status - // bar and header) blurs and tints the content scrolling under it; the card - // is in this layer, out of the blur's reach, so it fades out over the - // strip instead as the page scroll carries it up there. - let clip = ''; - let mask = ''; - if (t >= CARD_PARKED_T) { - const doc = root.ownerDocument; - const screen = doc.querySelector('[data-screen-body]'); - if (screen) { - const b = screen.getBoundingClientRect(); - const ins = (v: number) => Math.max(0, v).toFixed(1); - clip = `inset(${ins(b.top - r.top)}px ${ins(r.right - b.right)}px ${ins(r.bottom - b.bottom)}px ${ins(b.left - r.left)}px)`; - } - const fade = doc.querySelector('[data-card-fade]'); - if (fade) { - // The strip's own strength (it comes in with the scroll) scales the - // card's fade; the stops ease like the strip's tint. - const strength = Number(fade.style.getPropertyValue('--edge-fade')) || 0; - if (strength > 0.005) { - const f = fade.getBoundingClientRect(); - mask = edgeMask(f.top - r.top, f.height * EDGE_FADE_RAMP_END, strength); - } - } - } - if (root.style.clipPath !== clip) root.style.clipPath = clip; - if (root.style.maskImage !== mask) root.style.maskImage = mask; + // Clipped to the screen once parked; not during the flight in or out, + // when it crosses the bezel on purpose. + clipToScreen(root, r, t >= CARD_PARKED_T); // ── The render gate ────────────────────────────────────────────────── const last = painted.current; diff --git a/components/grid-cards-demo/src/components/CardStage/CardStageBoundary.tsx b/components/grid-cards-demo/src/components/CardStage/CardStageBoundary.tsx new file mode 100644 index 000000000..006e95f15 --- /dev/null +++ b/components/grid-cards-demo/src/components/CardStage/CardStageBoundary.tsx @@ -0,0 +1,31 @@ +'use client'; + +import { Component, type ErrorInfo, type ReactNode } from 'react'; + +interface CardStageBoundaryProps { + /** What stands in for the 3D card once it has failed (the flat card). */ + fallback: ReactNode; + onFallback?: () => void; + children: ReactNode; +} + +/** Catches the 3D card failing (no WebGL, so no context to render with, or + * anything else it throws) so the rest of the page stays up with `fallback` + * in its place. Uncaught, React unmounts the whole tree and Next shows its + * error page. */ +export class CardStageBoundary extends Component { + state = { failed: false }; + + static getDerivedStateFromError() { + return { failed: true }; + } + + componentDidCatch(error: Error, info: ErrorInfo) { + console.error('The 3D card failed; showing the flat card instead.', error, info.componentStack); + this.props.onFallback?.(); + } + + render() { + return this.state.failed ? this.props.fallback : this.props.children; + } +} diff --git a/components/grid-cards-demo/src/components/CardStage/FlatCard.module.scss b/components/grid-cards-demo/src/components/CardStage/FlatCard.module.scss new file mode 100644 index 000000000..d0102da59 --- /dev/null +++ b/components/grid-cards-demo/src/components/CardStage/FlatCard.module.scss @@ -0,0 +1,29 @@ +/* The 3D stage's camera distance (2000 stage px), in the card px of a box + scaled by --card-scale, so the turn foreshortens as the 3D card does. */ +.card { + position: relative; + perspective: calc(2000px / var(--card-scale, 1)); +} + +.turn { + position: absolute; + inset: 0; + transform-style: preserve-3d; + will-change: transform; +} + +.face { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + border-radius: var(--card-radius); + backface-visibility: hidden; + box-shadow: + 0 1px 2px rgb(0 0 0 / 0.12), + 0 16px 40px -12px rgb(0 0 0 / 0.35); + + &[data-side='back'] { + transform: rotateY(180deg); + } +} diff --git a/components/grid-cards-demo/src/components/CardStage/FlatCard.tsx b/components/grid-cards-demo/src/components/CardStage/FlatCard.tsx new file mode 100644 index 000000000..684cf642c --- /dev/null +++ b/components/grid-cards-demo/src/components/CardStage/FlatCard.tsx @@ -0,0 +1,161 @@ +'use client'; + +import { useEffect, useRef, useState, type MutableRefObject, type Ref } from 'react'; +import { footprint } from '@/apps/card/cardMetrics'; +import { currentCredentials, type CardCredentials } from '@/apps/shared/card/cardholder'; +import type { CardDesign, Orientation } from '@/data/design'; +import { CARD_R } from './card3d/cardGeometry'; +import type { Side } from './card3d/faceFrame'; +import { loadFaceAssets, makeCanvas, paintBack, paintFront, TEX_H, TEX_W, type FaceAssets } from './card3d/facePaint'; +import { useLoadedImage } from './card3d/useLoadedImage'; +import type { Pose } from './cardMotion'; +import type { FlatFaces } from './export/flatExporter'; +import { introCard } from './introTimeline'; +import styles from './FlatCard.module.scss'; + +interface FlatCardProps { + design: CardDesign; + /** The element that turns (see `poseTransform`), for the caller's frame loop. */ + turnRef?: Ref; + /** Filled with the painted faces, for pictures of the card (FlatExporter). */ + facesRef?: MutableRefObject; + issued?: boolean; + /** What the back prints once issued. Absent, the current card's. */ + credentials?: CardCredentials; + frozen?: boolean; + closed?: boolean; + /** The front has painted for the first time. */ + onPainted?: () => void; +} + +/** The motion's pose as a CSS transform: the 3D stage's Euler XYZ, with the + * y axis flipped from the scene's (up) to the page's (down). The upright + * card is laid out upright, so its quarter-turn roll is not added. */ +export function poseTransform(p: Pose): string { + return `rotateX(${(-p.rotX).toFixed(3)}deg) rotateY(${p.rotY.toFixed(3)}deg) rotateZ(${(-p.rotZ).toFixed(3)}deg)`; +} + +/** The intro's look on the flat card at real time `t` (the 3D stage puts the + * same on its canvas and mesh): hidden, then fading in as the blur clears + * and it settles down to size. */ +export function applyIntroLook(el: HTMLElement, t: number) { + const look = introCard(Math.max(0, t)); + el.style.opacity = String(look.opacity); + el.style.filter = look.blur > 0.05 ? `blur(${look.blur.toFixed(2)}px)` : ''; + el.style.transform = `scale(${look.scale})`; +} + +export function clearIntroLook(el: HTMLElement) { + el.style.opacity = ''; + el.style.filter = ''; + el.style.transform = ''; +} + +/** A face's texture (the landscape blank's, see faceFrame) onto `canvas` as + * the face is held: an upright card's artwork is turned a quarter in the + * texture, the front's one way and the back's the other, so it is turned + * back here. */ +function present(canvas: HTMLCanvasElement, texture: HTMLCanvasElement, o: Orientation, side: Side) { + const upright = o === 'portrait'; + const w = upright ? TEX_H : TEX_W; + const h = upright ? TEX_W : TEX_H; + if (canvas.width !== w) canvas.width = w; + if (canvas.height !== h) canvas.height = h; + const ctx = canvas.getContext('2d')!; + ctx.setTransform(1, 0, 0, 1, 0, 0); + ctx.clearRect(0, 0, w, h); + if (upright) { + if (side === 'front') ctx.setTransform(0, 1, -1, 0, TEX_H, 0); + else ctx.setTransform(0, -1, 1, 0, 0, TEX_W); + } + ctx.drawImage(texture, 0, 0); + ctx.setTransform(1, 0, 0, 1, 0, 0); +} + +/** + * The card without WebGL: the same painted faces the 3D card wears, as two + * flat sides turned in CSS 3D by the caller's frame loop. Laid out in card px + * (its footprint as held); the caller places and scales it. + */ +export function FlatCard({ + design, + turnRef, + facesRef, + issued = false, + credentials, + frozen = false, + closed = false, + onPainted, +}: FlatCardProps) { + const frontRef = useRef(null); + const backRef = useRef(null); + const texture = useRef(null); + const onPaintedRef = useRef(onPainted); + onPaintedRef.current = onPainted; + const paintedOnce = useRef(false); + + const [assets, setAssets] = useState(null); + useEffect(() => { + let alive = true; + loadFaceAssets().then( + (a) => alive && setAssets(a), + (e) => console.error(e), + ); + return () => { + alive = false; + }; + }, []); + const { img: logo, pending: logoPending } = useLoadedImage(design.logoUrl); + const { img: art, pending: artPending } = useLoadedImage(design.backgroundUrl); + + // The faces are offered for pictures from their first paint (so there is + // never a blank card to render) until the card goes. + useEffect(() => { + if (!facesRef) return; + return () => { + facesRef.current = null; + }; + }, [facesRef]); + // A logo or art on its way: the last paint stays up, but isn't the design. + useEffect(() => { + if (facesRef?.current && (logoPending || artPending)) facesRef.current.ready = false; + }, [facesRef, logoPending, artPending]); + + useEffect(() => { + const canvas = frontRef.current; + if (!assets || logoPending || artPending || !canvas) return; + texture.current ??= makeCanvas(TEX_W, TEX_H); + paintFront(texture.current.getContext('2d')!, { design, logo, art, frozen, closed }, assets); + present(canvas, texture.current, design.orientation, 'front'); + if (facesRef && backRef.current) facesRef.current = { front: canvas, back: backRef.current, ready: true }; + if (!paintedOnce.current) { + paintedOnce.current = true; + onPaintedRef.current?.(); + } + }, [assets, design, logo, art, logoPending, artPending, frozen, closed, facesRef]); + + useEffect(() => { + const canvas = backRef.current; + if (!assets || !canvas) return; + texture.current ??= makeCanvas(TEX_W, TEX_H); + paintBack( + texture.current.getContext('2d')!, + { design, personalized: issued ? 1 : 0, credentials: credentials ?? currentCredentials(), frozen, closed }, + assets, + ); + present(canvas, texture.current, design.orientation, 'back'); + }, [assets, design, issued, credentials, frozen, closed]); + + const foot = footprint(design.orientation); + return ( +
+
+ + +
+
+ ); +} diff --git a/components/grid-cards-demo/src/components/CardStage/FlatCardStage.tsx b/components/grid-cards-demo/src/components/CardStage/FlatCardStage.tsx new file mode 100644 index 000000000..8c4fbf8e5 --- /dev/null +++ b/components/grid-cards-demo/src/components/CardStage/FlatCardStage.tsx @@ -0,0 +1,354 @@ +'use client'; + +import clsx from 'clsx'; +import { IconRotate360Right } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconRotate360Right'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + type MutableRefObject, + type PointerEvent as ReactPointerEvent, +} from 'react'; +import { cancelFrame, frame, useReducedMotion } from 'motion/react'; +import { footprint } from '@/apps/card/cardMetrics'; +import { programNameOf } from '@/apps/shared/brand/BrandContext'; +import type { CardHome } from '@/apps/shared/card'; +import { AnimatedLock } from '@/apps/shared/icons'; +import { CARD_PARKED_T, easeInOutCubic, usePhoneBoot } from '@/components/DotGridCanvas/PhoneBootContext'; +import type { CardDesign } from '@/data/design'; +import { canScrollBy } from '@/lib/scroll'; +import { play } from '@/lib/sounds'; +import type { ShareStageState } from './CardStage'; +import { CardIntro } from './CardIntro'; +import { CardMotion } from './cardMotion'; +import { useCardMomentSounds } from './cardSounds'; +import { installExportDevHook } from './export/devHook'; +import type { CardFrameSource } from './export/exportRenderer'; +import { FlatExporter, type FlatFaces } from './export/flatExporter'; +import { applyIntroLook, clearIntroLook, FlatCard, poseTransform } from './FlatCard'; +import { INTRO_END, stepIntro } from './introTimeline'; +import { clipToScreen, GLIDE_TAU, restOn, ShareFlight, towardSlot, type Placement } from './stagePlacement'; +import styles from './CardStage.module.scss'; + +interface FlatCardStageProps { + design: CardDesign; + home: CardHome; + /** Filled with the flat card's exporter (the share renders through it). */ + exportRef?: MutableRefObject; + share?: ShareStageState; + /** The 3D card played the intro before it failed: the flat one starts shown. */ + introPlayed?: boolean; + /** The blueprint has dissolved and the card stands alone. */ + onIntroDone?: () => void; +} + +/** + * The stage without WebGL: the flat card where the 3D one would be, on the + * same motion (drag to turn, fling, tilt under the pointer, the idle bob) and + * the same intro. It rests in the middle of the stage, flies into the + * phone's slot as the phone comes up, and into the share frame's while it is + * open. The brand and art are placed from the design panel; there is no + * dragging them on the card here. + */ +export function FlatCardStage({ design, home, exportRef, share, introPlayed = false, onIntroDone }: FlatCardStageProps) { + const { bootProgress } = usePhoneBoot(); + const reduceMotion = useReducedMotion() ?? false; + const rootRef = useRef(null); + const hitRef = useRef(null); + const lookRef = useRef(null); + const turnRef = useRef(null); + const overlayRef = useRef(null); + const motion = useMemo(() => new CardMotion(), []); + const { issued, issuing, card, isDeclined, isTap } = home; + + const t = easeInOutCubic(bootProgress); + const phoneUp = bootProgress > 0; + const inFlightNow = phoneUp && t < CARD_PARKED_T; + const stateShown = phoneUp && !inFlightNow; + const dimmed = stateShown && (card.frozen || card.closed); + const [introDone, setIntroDone] = useState(introPlayed); + useEffect(() => { + if (introDone && !introPlayed) onIntroDone?.(); + }, [introDone, introPlayed, onIntroDone]); + // Nothing says the card can be turned; say it once, until the first drag. + const [dragged, setDragged] = useState(false); + + useCardMomentSounds({ issued, issuing, frozen: card.frozen, closed: card.closed }); + useEffect(() => { + if (!isDeclined) return; + motion.shake(); + play('decline'); + }, [isDeclined, motion]); + + // The share frame: open, the card flies into its slot and is posed there; + // closed, it flies back and settles on a face again (as on the 3D stage). + const shareOpen = !!share?.open && !phoneUp; + const shareLocked = !!share?.locked; + useEffect(() => { + motion.free = shareOpen && !shareLocked; + if (!shareOpen) { + const p = motion.pose; + motion.setPose({ rotX: Math.round(p.rotX / 180) * 180, rotY: Math.round(p.rotY / 180) * 180 }); + } + }, [shareOpen, shareLocked, motion]); + const sharePose = share?.pose ?? null; + useEffect(() => { + if (shareOpen && sharePose) motion.setPose(sharePose); + }, [shareOpen, sharePose, motion]); + const shareFlight = useMemo(() => new ShareFlight(), []); + + // Read by the frame loop and the pointer handlers, which outlive renders. + const liveNow = { + t, + orientation: design.orientation, + wantBack: card.page === 'numbers', + tap: isTap, + inert: dimmed, + reduceMotion, + shareWanted: shareOpen, + shareLocked, + onTurned: share?.onTurned, + onSettled: share?.onSettled, + }; + const live = useRef(liveNow); + live.current = liveNow; + const inFlight = () => (live.current.t > 0 && live.current.t < CARD_PARKED_T) || shareFlight.flying; + const inPhone = () => live.current.t >= CARD_PARKED_T; + const inShare = () => shareFlight.t > 0; + + // The share's pictures of the card, from its painted faces. + const facesRef = useRef(null); + const designRef = useRef(design); + designRef.current = design; + useEffect(() => { + if (!exportRef) return; + const exporter = new FlatExporter({ + faces: () => facesRef.current, + orientation: () => live.current.orientation, + livePose: () => motion.pose, + }); + exportRef.current = exporter; + const uninstall = installExportDevHook(exporter, () => designRef.current); + return () => { + uninstall(); + if (exportRef.current === exporter) exportRef.current = null; + }; + }, [exportRef, motion]); + + // The intro's clock: -1 until the front has painted, then seconds. + const intro = useRef(introPlayed ? { t: INTRO_END, done: true } : { t: -1, done: false }); + const onPainted = useCallback(() => { + if (intro.current.t < 0) intro.current.t = 0; + }, []); + + useLayoutEffect(() => { + let pos: Placement | null = null; + let last: number | null = null; + const step = ({ timestamp }: { timestamp: number }) => { + const root = rootRef.current; + const hit = hitRef.current; + const look = lookRef.current; + const turn = turnRef.current; + if (!root || !hit || !look || !turn) return; + const dt = last === null ? 0 : Math.min(0.05, (timestamp - last) / 1000); + last = timestamp; + const lv = live.current; + const it = intro.current; + const r = root.getBoundingClientRect(); + const foot = footprint(lv.orientation); + const rest = restOn(r.width, r.height, foot); + const k = pos ? 1 - Math.exp(-dt / GLIDE_TAU) : 1; + const p = pos ?? { ...rest }; + p.x += (rest.x - p.x) * k; + p.y += (rest.y - p.y) * k; + p.s += (rest.s - p.s) * k; + pos = p; + const flight = shareFlight.step(dt, { + wanted: lv.shareWanted, + reduceMotion: lv.reduceMotion, + motion, + doc: root.ownerDocument, + r, + foot, + at: lv.t > 0 ? towardSlot(p, lv.t, root.ownerDocument, r, foot) : p, + onSettled: lv.onSettled, + }); + const { x, y, s } = flight.at; + + // Held flat in flight (to the phone or the share frame), through the + // intro, at the reader, and locked or closed in the phone, as the 3D + // card is. Posed in the share frame it holds still (no bob). + const pose = motion.step(dt, { + wantBack: lv.wantBack, + hold: (lv.t > 0 && lv.t < CARD_PARKED_T) || shareFlight.flying || !it.done || lv.tap || lv.inert, + reduceMotion: lv.reduceMotion, + bob: shareFlight.t === 0, + path: flight.path, + }); + const bob = pose.dy * (1 - lv.t); + hit.style.transform = `translate(${x + pose.dx * s - foot.w / 2}px, ${y + bob - foot.h / 2}px) scale(${s})`; + hit.style.setProperty('--card-scale', s.toFixed(4)); + turn.style.transform = poseTransform(pose); + + // The intro: the blueprint draws, then the card comes into focus under + // it. A flow starting mid-intro (or reduced motion) ends it now. + if (!it.done) { + if (it.t >= 0) it.t += dt; + if (lv.t > 0 || lv.reduceMotion) it.t = INTRO_END; + if (overlayRef.current) stepIntro(overlayRef.current, it.t); + applyIntroLook(look, it.t); + if (it.t >= INTRO_END) { + it.done = true; + clearIntroLook(look); + setIntroDone(true); + } + } + clipToScreen(root, r, lv.t >= CARD_PARKED_T); + }; + // Placed before the first paint, then after Motion writes each frame's + // transforms (the phone's slot moves with them). + step({ timestamp: performance.now() }); + frame.postRender(step, true); + return () => cancelFrame(step); + }, [motion, shareFlight]); + + // ── Turning it by hand ───────────────────────────────────────────────── + const drag = useRef<{ id: number; x: number; y: number } | null>(null); + const onPointerDown = (e: ReactPointerEvent) => { + const lv = live.current; + if (inFlight() || !intro.current.done || e.button !== 0) return; + // On Card numbers the card is turned over for the reveal and stays so; at + // the reader it is held; locked or closed it is inert; in the hand it is + // held as the hand holds it. + if (lv.wantBack || lv.tap || lv.inert || (inShare() && lv.shareLocked)) return; + try { + e.currentTarget.setPointerCapture(e.pointerId); + } catch { + // Not capturable; the window-level release still ends the drag. + } + drag.current = { id: e.pointerId, x: e.clientX, y: e.clientY }; + setDragged(true); + motion.beginDrag(e.timeStamp); + e.currentTarget.classList.add(styles.hitDragging); + }; + const onPointerMove = (e: ReactPointerEvent) => { + const d = drag.current; + if (d && e.pointerId === d.id) { + motion.drag(e.clientX - d.x, e.clientY - d.y, e.timeStamp); + d.x = e.clientX; + d.y = e.clientY; + return; + } + // Posed in the share frame the card holds its angles: no tilt, so what is + // seen is what the picture will be. + if (inFlight() || inShare() || live.current.reduceMotion || live.current.inert || e.pointerType !== 'mouse') return; + const b = e.currentTarget.getBoundingClientRect(); + motion.setTilt((e.clientX - b.left) / b.width - 0.5, (e.clientY - b.top) / b.height - 0.5); + }; + /** Let go, whichever way the drag ended (a pointerup off the card or the + * embed, the capture lost): a drag left set would hold the card mid-turn. */ + const release = useCallback( + (now: number) => { + if (!drag.current) return; + drag.current = null; + motion.endDrag(now); + hitRef.current?.classList.remove(styles.hitDragging); + // In the share frame the card stays as turned; the row's pose lets go. + if (shareFlight.t > 0) live.current.onTurned?.(); + }, + [motion, shareFlight], + ); + const onPointerLeave = () => { + if (!drag.current) motion.clearTilt(); + }; + useEffect(() => { + const hit = hitRef.current; + const onUp = () => release(performance.now()); + // iOS Safari: keep a finger turning the card from scrolling the page + // (native and not passive; React's touch handlers can't cancel). + const onTouchMove = (e: TouchEvent) => { + if (drag.current && e.cancelable) e.preventDefault(); + }; + // Parked, the wheel over the card scrolls the phone's page, as it does + // anywhere else on it. + const onWheel = (e: WheelEvent) => { + if (!inPhone() || e.ctrlKey || !hit) return; + const scroller = hit.ownerDocument.querySelector('[data-card-scroller]'); + if (!scroller || getComputedStyle(scroller).overflowY !== 'auto' || !canScrollBy(scroller, e.deltaY)) return; + e.preventDefault(); + scroller.scrollTop += e.deltaY; + }; + hit?.addEventListener('touchmove', onTouchMove, { passive: false }); + hit?.addEventListener('wheel', onWheel, { passive: false }); + window.addEventListener('pointerup', onUp); + window.addEventListener('pointercancel', onUp); + window.addEventListener('blur', onUp); + return () => { + hit?.removeEventListener('touchmove', onTouchMove); + hit?.removeEventListener('wheel', onWheel); + window.removeEventListener('pointerup', onUp); + window.removeEventListener('pointercancel', onUp); + window.removeEventListener('blur', onUp); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [release]); + + const foot = footprint(design.orientation); + return ( +
+
release(e.timeStamp)} + onPointerCancel={(e) => release(e.timeStamp)} + onLostPointerCapture={() => release(performance.now())} + onPointerLeave={onPointerLeave} + // Locked, in the phone: the whole card is the tap for its status. + onClick={() => { + if (inPhone() && card.frozen) card.setSheet('freeze'); + }} + > + +
+
+ +
+
+ {!introDone && } + {stateShown && card.closed ? ( + + This card has been closed + + ) : stateShown && card.frozen ? ( + + + + ) : null} + + + Drag to turn it over + +
+
+ ); +} diff --git a/components/grid-cards-demo/src/components/CardStage/card3d/CardMesh.tsx b/components/grid-cards-demo/src/components/CardStage/card3d/CardMesh.tsx index c29608d9a..7c7134ccd 100644 --- a/components/grid-cards-demo/src/components/CardStage/card3d/CardMesh.tsx +++ b/components/grid-cards-demo/src/components/CardStage/card3d/CardMesh.tsx @@ -40,7 +40,6 @@ import { brandBox, brandRegion, loadFaceAssets, - loadImage, lockupBox, makeCanvas, paintArtMask, @@ -71,6 +70,7 @@ import { surfaceMapsReady, type BakeJob, } from './surfaceBakeClient'; +import { useLoadedImage } from './useLoadedImage'; export interface CardMeshState { design: CardDesign; @@ -125,31 +125,6 @@ const SURFACE: Record({ url: null, img: null }); - useEffect(() => { - if (!url) { - setState({ url: null, img: null }); - return; - } - let alive = true; - loadImage(url).then((img) => { - if (alive) setState({ url, img }); - }); - return () => { - alive = false; - }; - }, [url]); - const settled = state.url === url; - return { img: settled ? state.img : null, pending: !!url && !settled }; -} - /** * The Visa mark's silver foil, as the layer it is: a film stamped onto the * back, a hair proud of the face, with its own material. It is a mirror, and diff --git a/components/grid-cards-demo/src/components/CardStage/card3d/cardGeometry.ts b/components/grid-cards-demo/src/components/CardStage/card3d/cardGeometry.ts index b2ebf5949..016fd2b12 100644 --- a/components/grid-cards-demo/src/components/CardStage/card3d/cardGeometry.ts +++ b/components/grid-cards-demo/src/components/CardStage/card3d/cardGeometry.ts @@ -10,7 +10,7 @@ export const PX_PER_MM = CARD_W / 85.6; export const CARD_DEPTH_MM: Record = { plastic: 0.76, metal: 0.95 }; /** Corner: the phone's debit-card squircle (13 px Figma × 1.2, smoothing 0.12). */ export const CARD_R = 13 * 1.2; -const CORNER_SMOOTHING = 0.12; +export const CORNER_SMOOTHING = 0.12; const BEVEL = 0.3; /** Where a face's plane sits (|z|) for a material: half the depth plus the bevel. */ export const faceZOf = (material: CardMaterial) => (CARD_DEPTH_MM[material] * PX_PER_MM) / 2 + BEVEL; diff --git a/components/grid-cards-demo/src/components/CardStage/card3d/useLoadedImage.ts b/components/grid-cards-demo/src/components/CardStage/card3d/useLoadedImage.ts new file mode 100644 index 000000000..02706994c --- /dev/null +++ b/components/grid-cards-demo/src/components/CardStage/card3d/useLoadedImage.ts @@ -0,0 +1,27 @@ +import { useEffect, useState } from 'react'; +import { loadImage } from './facePaint'; + +/** The image at `url` once loaded (null on failure or with no url), and + * whether it is still on its way: a face is not painted against a missing + * logo or art, or the wordmark and the color would flash first. */ +export function useLoadedImage(url: string | null): { img: HTMLImageElement | null; pending: boolean } { + const [state, setState] = useState<{ + url: string | null; + img: HTMLImageElement | null; + }>({ url: null, img: null }); + useEffect(() => { + if (!url) { + setState({ url: null, img: null }); + return; + } + let alive = true; + loadImage(url).then((img) => { + if (alive) setState({ url, img }); + }); + return () => { + alive = false; + }; + }, [url]); + const settled = state.url === url; + return { img: settled ? state.img : null, pending: !!url && !settled }; +} diff --git a/components/grid-cards-demo/src/components/CardStage/export/devHook.ts b/components/grid-cards-demo/src/components/CardStage/export/devHook.ts index 67a51744c..87d05a367 100644 --- a/components/grid-cards-demo/src/components/CardStage/export/devHook.ts +++ b/components/grid-cards-demo/src/components/CardStage/export/devHook.ts @@ -16,11 +16,11 @@ import { type BackdropId, type Treatment, } from './compose'; -import type { CardExporter } from './exportRenderer'; +import type { CardFrameSource } from './exportRenderer'; import { renderSpinVideo } from './exportVideo'; import { renderStill, type StillFormat } from './stills'; -export function installExportDevHook(exporter: CardExporter, design: () => CardDesign) { +export function installExportDevHook(exporter: CardFrameSource, design: () => CardDesign) { if (process.env.NODE_ENV !== 'development') return () => {}; const w = window as unknown as Record; const paletteOf = async (backdrop: BackdropId) => { diff --git a/components/grid-cards-demo/src/components/CardStage/export/exportRenderer.ts b/components/grid-cards-demo/src/components/CardStage/export/exportRenderer.ts index 0dd80662a..fe8a6bf57 100644 --- a/components/grid-cards-demo/src/components/CardStage/export/exportRenderer.ts +++ b/components/grid-cards-demo/src/components/CardStage/export/exportRenderer.ts @@ -20,7 +20,7 @@ import type { CardMeshUserData } from '../card3d/CardMesh'; /** The stage camera's distance; the export keeps the same perspective so the * card's foreshortening matches what the visitor saw. Scene units are px. */ -const CAMERA_Z = 2000; +export const CAMERA_Z = 2000; /** The card's tone mapping exposure on the stage, by the backdrop it sits on. */ export const EXPOSURE_LIGHT = 1.25; export const EXPOSURE_DARK = 1.0; @@ -64,6 +64,18 @@ export interface ExportFrame { data: Uint8ClampedArray; } +/** What the share asks of the card: a picture of it at a pose. The 3D card's + * exporter (below), or the flat card's without WebGL (flatExporter). */ +export interface CardFrameSource { + /** The card is a finished picture: painted, nothing loading. */ + readonly ready: boolean; + readonly orientation: Orientation; + /** The card as it is turned on the stage, for a picture of what is seen. */ + readonly livePose: ExportPose; + warm(sizes: Array<{ width: number; height: number }>): void; + renderSafe(opts: ExportFrameOptions): ExportFrame; +} + /** Targets kept ready, by size: the share renders a few sizes in turn. */ interface Targets { scene: THREE.WebGLRenderTarget; @@ -72,7 +84,7 @@ interface Targets { } const TARGET_SIZES_KEPT = 3; -export class CardExporter { +export class CardExporter implements CardFrameSource { private readonly targetsBySize = new Map(); private readonly output = new OutputPass(); private readonly camera = new THREE.PerspectiveCamera(30, 1, 200, 6000); diff --git a/components/grid-cards-demo/src/components/CardStage/export/exportVideo.ts b/components/grid-cards-demo/src/components/CardStage/export/exportVideo.ts index 8470c3c17..423750dcb 100644 --- a/components/grid-cards-demo/src/components/CardStage/export/exportVideo.ts +++ b/components/grid-cards-demo/src/components/CardStage/export/exportVideo.ts @@ -7,7 +7,7 @@ import { ArrayBufferTarget, Muxer } from 'mp4-muxer'; import { compose, exposureFor, prepareTemplate, type Palette } from './compose'; -import type { CardExporter } from './exportRenderer'; +import type { CardFrameSource } from './exportRenderer'; import { flushDeferredPaintsNow } from '../card3d/deferredPaint'; import { cardFracFor } from './stills'; @@ -66,7 +66,7 @@ export interface VideoOptions { * Render and encode the spin. Resolves to the MP4, or null where the browser * has no encoder. Rejects on abort. */ -export async function renderSpinVideo(exporter: CardExporter, opts: VideoOptions): Promise { +export async function renderSpinVideo(exporter: CardFrameSource, opts: VideoOptions): Promise { if (!canEncodeVideo()) return null; flushDeferredPaintsNow(); const codec = await pickCodec(); diff --git a/components/grid-cards-demo/src/components/CardStage/export/flatExporter.ts b/components/grid-cards-demo/src/components/CardStage/export/flatExporter.ts new file mode 100644 index 000000000..4809b6d59 --- /dev/null +++ b/components/grid-cards-demo/src/components/CardStage/export/flatExporter.ts @@ -0,0 +1,203 @@ +/* The card rendered for a picture without WebGL: the flat card's two painted + faces, projected through the same camera as the 3D exporter (the card a + plane of card px at the origin, the camera CAMERA_Z out), so a pose frames + and foreshortens as it does there. Face on, the face is one affine draw; at + an angle it is a mesh of small triangles, each an affine draw, which holds + the perspective. No thickness and no lighting: it is the flat card. */ + +import { footprint } from '@/apps/card/cardMetrics'; +import { squirclePath } from '@/components/liquid-glass'; +import type { Orientation } from '@/data/design'; +import { CARD_R, CORNER_SMOOTHING } from '../card3d/cardGeometry'; +import { CAMERA_Z, type CardFrameSource, type ExportFrame, type ExportFrameOptions, type ExportPose } from './exportRenderer'; + +/** Mesh cells along the card's long edge (the short edge in proportion). */ +const CELLS_LONG = 18; +/** Each triangle's clip grows by this much (frame px) past its edges, so the + * anti-aliased seams between neighbors are covered. */ +const SEAM_PX = 0.75; + +/** The flat card's painted faces, as it is held (see FlatCard). Null until + * the front has painted once. */ +export interface FlatFaces { + front: HTMLCanvasElement; + back: HTMLCanvasElement; + /** Painted, with nothing loading. */ + ready: boolean; +} + +interface Deps { + faces: () => FlatFaces | null; + orientation: () => Orientation; + livePose: () => ExportPose; +} + +type Pt = { x: number; y: number }; + +/** `img`'s triangle `s` drawn onto the frame's triangle `d`. */ +function drawTriangle(ctx: CanvasRenderingContext2D, img: CanvasImageSource, s: [Pt, Pt, Pt], d: [Pt, Pt, Pt]) { + const [s0, s1, s2] = s; + const [d0, d1, d2] = d; + const den = (s1.x - s0.x) * (s2.y - s0.y) - (s2.x - s0.x) * (s1.y - s0.y); + if (Math.abs(den) < 1e-9) return; + const a = ((d1.x - d0.x) * (s2.y - s0.y) - (d2.x - d0.x) * (s1.y - s0.y)) / den; + const b = ((d1.y - d0.y) * (s2.y - s0.y) - (d2.y - d0.y) * (s1.y - s0.y)) / den; + const c = ((d2.x - d0.x) * (s1.x - s0.x) - (d1.x - d0.x) * (s2.x - s0.x)) / den; + const e = ((d2.y - d0.y) * (s1.x - s0.x) - (d1.y - d0.y) * (s2.x - s0.x)) / den; + // Scaled about the incenter by (r + SEAM_PX) / r, every edge moves out by + // SEAM_PX (the shared diagonal of a cell included). + const la = Math.hypot(d1.x - d2.x, d1.y - d2.y); + const lb = Math.hypot(d0.x - d2.x, d0.y - d2.y); + const lc = Math.hypot(d0.x - d1.x, d0.y - d1.y); + const perimeter = la + lb + lc; + const area = Math.abs((d1.x - d0.x) * (d2.y - d0.y) - (d2.x - d0.x) * (d1.y - d0.y)) / 2; + if (perimeter < 1e-6 || area < 1e-6) return; + const ix = (la * d0.x + lb * d1.x + lc * d2.x) / perimeter; + const iy = (la * d0.y + lb * d1.y + lc * d2.y) / perimeter; + const grow = 1 + SEAM_PX / ((2 * area) / perimeter); + const [g0, g1, g2] = [d0, d1, d2].map((p) => ({ x: ix + (p.x - ix) * grow, y: iy + (p.y - iy) * grow })); + ctx.save(); + ctx.beginPath(); + ctx.moveTo(g0.x, g0.y); + ctx.lineTo(g1.x, g1.y); + ctx.lineTo(g2.x, g2.y); + ctx.closePath(); + ctx.clip(); + ctx.setTransform(a, b, c, e, d0.x - a * s0.x - c * s0.y, d0.y - b * s0.x - e * s0.y); + ctx.drawImage(img, 0, 0); + ctx.restore(); +} + +export class FlatExporter implements CardFrameSource { + private canvas: HTMLCanvasElement | null = null; + + constructor(private readonly deps: Deps) {} + + get ready(): boolean { + return !!this.deps.faces()?.ready; + } + + get orientation(): Orientation { + return this.deps.orientation(); + } + + get livePose(): ExportPose { + return this.deps.livePose(); + } + + warm() {} + + renderSafe(opts: ExportFrameOptions): ExportFrame { + return this.render(opts); + } + + render({ width, height, pose, cardFrac }: ExportFrameOptions): ExportFrame { + // Before the first paint there is no card to picture, and while a logo or + // art loads the paint is the last design's: fail (the share offers a + // retry) rather than hand back a blank or stale frame. + const faces = this.deps.faces(); + if (!faces?.ready) throw new Error('The flat card is not painted yet'); + const canvas = (this.canvas ??= document.createElement('canvas')); + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext('2d', { willReadFrequently: true })!; + ctx.clearRect(0, 0, width, height); + this.paint(ctx, faces, width, height, pose, cardFrac); + return { width, height, data: ctx.getImageData(0, 0, width, height).data }; + } + + private paint( + ctx: CanvasRenderingContext2D, + faces: FlatFaces, + width: number, + height: number, + pose: ExportPose, + cardFrac: number, + ) { + const foot = footprint(this.orientation); + // Framed as the 3D exporter frames it: the long edge takes `cardFrac`. + const k = foot.w >= foot.h ? (width * cardFrac) / foot.w : (height * cardFrac) / foot.h; + // The scene's Euler XYZ (y up), the upright card laid out upright (no + // quarter-turn roll, as in FlatCard's CSS). + const rad = Math.PI / 180; + const [sx, cx] = [Math.sin(pose.rotX * rad), Math.cos(pose.rotX * rad)]; + const [sy, cy] = [Math.sin(pose.rotY * rad), Math.cos(pose.rotY * rad)]; + const rz = (pose.rotZ ?? 0) * rad; + const [sz, cz] = [Math.sin(rz), Math.cos(rz)]; + /** A point of the face (card px from its top left, y down, as painted) + * → the frame, for the front, or the back seen from behind. */ + const project = (u: number, v: number, back: boolean): Pt => { + const x0 = back ? foot.w / 2 - u : u - foot.w / 2; + const y0 = foot.h / 2 - v; + const x1 = x0 * cz - y0 * sz; + const y1 = x0 * sz + y0 * cz; + // The face is the plane z = 0, so the spin's z comes from x alone. + const x2 = x1 * cy; + const z2 = -x1 * sy; + const y3 = y1 * cx - z2 * sx; + const z3 = y1 * sx + z2 * cx; + const persp = CAMERA_Z / (CAMERA_Z - z3); + return { x: width / 2 + x2 * persp * k, y: height / 2 - y3 * persp * k }; + }; + + // The face toward the camera: the front's corners wind clockwise on screen. + const tl = project(0, 0, false); + const tr = project(foot.w, 0, false); + const bl = project(0, foot.h, false); + const back = (tr.x - tl.x) * (bl.y - tl.y) - (tr.y - tl.y) * (bl.x - tl.x) < 0; + const img = back ? faces.back : faces.front; + const toImg = img.width / foot.w; + + // The card's outline (the same squircle as the mesh), projected: the clip. + const nums = squirclePath(foot.w, foot.h, CARD_R, CORNER_SMOOTHING).match(/-?\d+(\.\d+)?/g)!.map(Number); + ctx.save(); + ctx.beginPath(); + for (let i = 0; i < nums.length; i += 2) { + const p = project(nums[i], nums[i + 1], false); + if (i === 0) ctx.moveTo(p.x, p.y); + else ctx.lineTo(p.x, p.y); + } + ctx.closePath(); + ctx.clip(); + ctx.imageSmoothingQuality = 'high'; + + const faceOn = Math.abs(sx) < 1e-6 && Math.abs(sy) < 1e-6; + const cols = faceOn ? 1 : foot.w >= foot.h ? CELLS_LONG : Math.max(2, Math.round((CELLS_LONG * foot.w) / foot.h)); + const rows = faceOn ? 1 : foot.h > foot.w ? CELLS_LONG : Math.max(2, Math.round((CELLS_LONG * foot.h) / foot.w)); + if (faceOn) { + // Affine: one draw, no seams. + const o = project(0, 0, back); + const ux = project(foot.w, 0, back); + const vy = project(0, foot.h, back); + ctx.setTransform( + (ux.x - o.x) / img.width, + (ux.y - o.y) / img.width, + (vy.x - o.x) / img.height, + (vy.y - o.y) / img.height, + o.x, + o.y, + ); + ctx.drawImage(img, 0, 0); + } else { + for (let j = 0; j < rows; j++) { + for (let i = 0; i < cols; i++) { + const u0 = (i / cols) * foot.w; + const u1 = ((i + 1) / cols) * foot.w; + const v0 = (j / rows) * foot.h; + const v1 = ((j + 1) / rows) * foot.h; + const s00 = { x: u0 * toImg, y: v0 * toImg }; + const s10 = { x: u1 * toImg, y: v0 * toImg }; + const s01 = { x: u0 * toImg, y: v1 * toImg }; + const s11 = { x: u1 * toImg, y: v1 * toImg }; + const d00 = project(u0, v0, back); + const d10 = project(u1, v0, back); + const d01 = project(u0, v1, back); + const d11 = project(u1, v1, back); + drawTriangle(ctx, img, [s00, s10, s11], [d00, d10, d11]); + drawTriangle(ctx, img, [s00, s11, s01], [d00, d11, d01]); + } + } + } + ctx.restore(); + } +} diff --git a/components/grid-cards-demo/src/components/CardStage/export/stills.ts b/components/grid-cards-demo/src/components/CardStage/export/stills.ts index 21f38831d..9f4751911 100644 --- a/components/grid-cards-demo/src/components/CardStage/export/stills.ts +++ b/components/grid-cards-demo/src/components/CardStage/export/stills.ts @@ -15,7 +15,7 @@ import { type Treatment, } from './compose'; import { flushDeferredPaintsNow } from '../card3d/deferredPaint'; -import type { CardExporter, ExportPose } from './exportRenderer'; +import type { CardFrameSource, ExportPose } from './exportRenderer'; export type StillFormat = 'post' | 'square'; @@ -94,7 +94,7 @@ export interface StillOptions { /** Render a still to a canvas (the sheet's preview draws it; the share * encodes it). `prepareTemplate` (and `prepareHand(id)`, for the hand) must * have resolved. */ -export function renderStillCanvas(exporter: CardExporter, opts: StillOptions): HTMLCanvasElement { +export function renderStillCanvas(exporter: CardFrameSource, opts: StillOptions): HTMLCanvasElement { // A still before the intro has let the back's maps paint gets them now. flushDeferredPaintsNow(); const size = stillSize(opts.format); @@ -127,6 +127,6 @@ export function renderStillCanvas(exporter: CardExporter, opts: StillOptions): H return compose(frame, { palette: opts.palette }); } -export async function renderStill(exporter: CardExporter, opts: StillOptions): Promise { +export async function renderStill(exporter: CardFrameSource, opts: StillOptions): Promise { return encodeCanvas(renderStillCanvas(exporter, opts)); } diff --git a/components/grid-cards-demo/src/components/CardStage/stagePlacement.ts b/components/grid-cards-demo/src/components/CardStage/stagePlacement.ts new file mode 100644 index 000000000..061eb0f40 --- /dev/null +++ b/components/grid-cards-demo/src/components/CardStage/stagePlacement.ts @@ -0,0 +1,194 @@ +/** + * Where the card sits on the stage, shared by the 3D stage (CardStage) and + * the flat one (FlatCardStage): at rest in the middle, scaled to fit; in the + * phone's `[data-card-slot]` as the phone comes up; clipped to the phone's + * screen once parked there; in the share frame's slot while it is open. + */ + +import { easeInOutCubic } from '@/components/DotGridCanvas/PhoneBootContext'; +import type { CardMotion } from './cardMotion'; + +/** Largest the card gets on stage, relative to its size in the phone. */ +const MAX_SCALE = 1.4; +const MIN_SCALE = 0.55; +/** Stage margin around the card. */ +const GUTTER_X = 28; +const GUTTER_Y = 120; +/** Glide time constant toward the rest position (seconds). */ +export const GLIDE_TAU = 0.14; +/** The card's fade under the screen's scroll edge has run out this far down + * the strip (the header's bottom edge). */ +const EDGE_FADE_RAMP_END = 0.8; +const EDGE_FADE_STOPS = 8; + +export interface Placement { + x: number; + y: number; + s: number; +} + +type Size = { w: number; h: number }; + +/** Rest: centered on a stage of `width` × `height`, scaled to fit it. */ +export function restOn(width: number, height: number, foot: Size): Placement { + return { + x: width / 2, + y: height / 2, + s: Math.max(MIN_SCALE, Math.min(MAX_SCALE, (width - GUTTER_X * 2) / foot.w, (height - GUTTER_Y) / foot.h)), + }; +} + +/** `p` moved `t` (0..1) of the way into the phone's card slot, at the scale + * that fits the slot (its width or its height, whichever the footprint + * needs). `r` is the stage's box. Unchanged if there is no slot. */ +export function towardSlot(p: Placement, t: number, doc: Document, r: DOMRect, foot: Size): Placement { + const slot = doc.querySelector('[data-card-slot]'); + if (!slot) return p; + const b = slot.getBoundingClientRect(); + return { + x: p.x + (b.left + b.width / 2 - r.left - p.x) * t, + y: p.y + (b.top + b.height / 2 - r.top - p.y) * t, + s: p.s + (Math.min(b.width / foot.w, b.height / foot.h) - p.s) * t, + }; +} + +/** The flight into the share frame and back (s). */ +const SHARE_FLIGHT_S = 0.6; + +/** + * The share frame: the card parks in its slot (`[data-share-card-slot]`), + * posed. The flight's clock steps toward wanted, and the card interpolates + * to the slot on the eased curve, as for the phone. The turn rides the + * flight: where the card starts is read as the flight begins (either way), + * and the angles follow the flight's own curve so the pose lands with the + * card. Settled, the spring has it again. + */ +export class ShareFlight { + /** 0 on the stage, 1 in the frame. */ + t = 0; + private from: { rotX: number; rotY: number } | null = null; + /** The slot as the card follows it: the slot moves and resizes with what + * the panel shows (Template, a hand), and the card glides after it rather + * than jumping. Null between flights. */ + private slot: Placement | null = null; + /** The card is parked in the slot and still (last told). */ + private settled = false; + + get flying() { + return this.t > 0 && this.t < 1; + } + + /** Advance the flight and return where the card is (`at`, its placement + * off the frame) and the path its turn takes, for `CardMotion.step`. */ + step( + dt: number, + o: { + wanted: boolean; + reduceMotion: boolean; + motion: CardMotion; + doc: Document; + r: DOMRect; + foot: Size; + at: Placement; + onSettled?: (settled: boolean) => void; + }, + ): { at: Placement; path?: { from: { rotX: number; rotY: number }; u: number } } { + const was = this.t; + this.t = Math.max(0, Math.min(1, this.t + ((o.wanted ? 1 : -1) * dt) / (o.reduceMotion ? 0.001 : SHARE_FLIGHT_S))); + const st = easeInOutCubic(this.t); + const landed = this.t === 0 || this.t === 1; + if (!landed && was !== this.t && this.from === null) this.from = o.motion.pose; + if (landed) this.from = null; + const path = this.from ? { from: this.from, u: o.wanted ? st : 1 - st } : undefined; + let { x, y, s } = o.at; + let inPlace = false; + const slot = st > 0 ? o.doc.querySelector('[data-share-card-slot]') : null; + if (slot) { + const b = slot.getBoundingClientRect(); + const want = { + x: b.left + b.width / 2 - o.r.left, + y: b.top + b.height / 2 - o.r.top, + s: Math.min(b.width / o.foot.w, b.height / o.foot.h), + }; + // As the flight begins the slot is where it is; landed, the card + // glides after a slot that moves (the same approach as toward rest). + const g = this.slot; + const sk = g && was > 0 && !o.reduceMotion ? 1 - Math.exp(-dt / GLIDE_TAU) : 1; + const tgt = g ?? { ...want }; + tgt.x += (want.x - tgt.x) * sk; + tgt.y += (want.y - tgt.y) * sk; + tgt.s += (want.s - tgt.s) * sk; + this.slot = tgt; + x += (tgt.x - x) * st; + y += (tgt.y - y) * st; + s += (tgt.s - s) * st; + // Nearly in place: landed, the glide within a tenth of the card's + // width of the slot, the turn within twenty degrees. The glide and + // the spring take about half a second from here, and what waits on + // the card (the hand) arrives over that. + const near = want.s * o.foot.w * 0.1; + inPlace = + this.t === 1 && + Math.abs(want.x - tgt.x) < near && + Math.abs(want.y - tgt.y) < near && + Math.abs(want.s - tgt.s) < want.s * 0.1 && + o.motion.nearRest(20); + } else if (st === 0) { + this.slot = null; + } + if (inPlace !== this.settled) { + this.settled = inPlace; + o.onSettled?.(inPlace); + } + return { at: { x, y, s }, path }; + } +} + +/** The parked card's mask under the scroll edge: from `top` (stage px) it + * fades in over `run` px, the alpha easing on a smoothstep so neither end of + * the ramp shows as a line, and reaching only as deep as `strength` (0..1). */ +function edgeMask(top: number, run: number, strength: number): string { + const stops: string[] = []; + for (let i = 0; i <= EDGE_FADE_STOPS; i++) { + const t = i / EDGE_FADE_STOPS; + const ease = t * t * (3 - 2 * t); + const alpha = 1 - strength * (1 - ease); + stops.push(`rgba(0,0,0,${alpha.toFixed(3)}) ${(top + run * t).toFixed(1)}px`); + } + return `linear-gradient(to bottom, ${stops.join(', ')})`; +} + +/** + * Parked, the card is screen content: the phone's content layer paints over + * it (AppShell), and the card is clipped to the screen, so a push that slides + * its slot past the bezel can't show it over the shell. The screen's scroll + * edge ([data-card-fade], the strip under the status bar and header) blurs + * and tints the content scrolling under it; the card is in this layer, out of + * the blur's reach, so it fades out over the strip instead as the page scroll + * carries it up there. Applied to the stage's root; cleared when not parked. + */ +export function clipToScreen(root: HTMLElement, r: DOMRect, parked: boolean) { + let clip = ''; + let mask = ''; + if (parked) { + const doc = root.ownerDocument; + const screen = doc.querySelector('[data-screen-body]'); + if (screen) { + const b = screen.getBoundingClientRect(); + const ins = (v: number) => Math.max(0, v).toFixed(1); + clip = `inset(${ins(b.top - r.top)}px ${ins(r.right - b.right)}px ${ins(r.bottom - b.bottom)}px ${ins(b.left - r.left)}px)`; + } + const fade = doc.querySelector('[data-card-fade]'); + if (fade) { + // The strip's own strength (it comes in with the scroll) scales the + // card's fade; the stops ease like the strip's tint. + const strength = Number(fade.style.getPropertyValue('--edge-fade')) || 0; + if (strength > 0.005) { + const f = fade.getBoundingClientRect(); + mask = edgeMask(f.top - r.top, f.height * EDGE_FADE_RAMP_END, strength); + } + } + } + if (root.style.clipPath !== clip) root.style.clipPath = clip; + if (root.style.maskImage !== mask) root.style.maskImage = mask; +} diff --git a/components/grid-cards-demo/src/components/ShareSheet/SharePanel.tsx b/components/grid-cards-demo/src/components/ShareSheet/SharePanel.tsx index d0f3fe8e8..f9bc03101 100644 --- a/components/grid-cards-demo/src/components/ShareSheet/SharePanel.tsx +++ b/components/grid-cards-demo/src/components/ShareSheet/SharePanel.tsx @@ -14,7 +14,7 @@ import { IconPlusSmall } from '@central-icons-react/round-outlined-radius-3-stro import { IconVideoClip } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconVideoClip'; import { IconX } from '@central-icons-react/round-outlined-radius-3-stroke-1.5/IconX'; import type { ShareStageState } from '@/components/CardStage/CardStage'; -import type { CardExporter, ExportPose } from '@/components/CardStage/export/exportRenderer'; +import type { CardFrameSource, ExportPose } from '@/components/CardStage/export/exportRenderer'; import { BACKDROPS, brandSurfaceFor, @@ -67,7 +67,7 @@ import styles from './SharePanel.module.scss'; interface SharePanelProps { open: boolean; - exporterRef: React.MutableRefObject; + exporterRef: React.MutableRefObject; design: CardDesign; /** The share the playground was opened from, if any. */ shared: SharedCard | null; diff --git a/components/grid-cards-demo/src/components/glass-gl/StageGL.tsx b/components/grid-cards-demo/src/components/glass-gl/StageGL.tsx index 41ecd0a2d..0d266221a 100644 --- a/components/grid-cards-demo/src/components/glass-gl/StageGL.tsx +++ b/components/grid-cards-demo/src/components/glass-gl/StageGL.tsx @@ -77,6 +77,9 @@ const DEFAULT_LENS: StageGLLens = { // hair under the gap, so the first off-frame dot hides behind the edge). The // visible canvas samples the inner window via the shader's toUV. const DOT_BLEED = 48; +/** Without WebGL, when the dot field is a background image: how long a resize + * must hold still before the image is encoded again (ms). */ +const FLAT_SETTLE_MS = 150; // Stashed: the original behavior fired the ripple immediately on pointer-down. // Now the grid presses in on hold and fires the ripple on RELEASE. Flip this to @@ -758,10 +761,15 @@ export const StageGL = forwardRef(function StageGL( }; stage.addEventListener('pointerdown', onDown, { capture: true }); + // Without WebGL the dot field is drawn straight onto the canvas (see below). + let paintFlat: (() => void) | null = null; + let flatSettle = 0; + // Re-read the palette on theme flip and repaint the dot texture once. const stopTheme = observeTheme(() => { palette = readDotGridPalette(offCtx); - invalidate(true); + if (paintFlat) paintFlat(); + else invalidate(true); }); let ro: ResizeObserver | null = null; @@ -800,11 +808,46 @@ export const StageGL = forwardRef(function StageGL( invalidate(true); }); ro.observe(canvas); + } else { + // No WebGL: the same lattice, still, with no ripple and no lens. A failed + // WebGL context leaves the canvas free for a 2D one; a context whose + // shaders failed holds the canvas, so the lattice goes on the offscreen + // canvas and shows as this one's background instead. + const flatCtx = canvas.getContext('2d'); + paintFlat = () => { + dpr = Math.min(window.devicePixelRatio || 1, 2); + cssW = canvas.clientWidth; + cssH = canvas.clientHeight; + if (cssW <= 0 || cssH <= 0) return; + const target = flatCtx ? canvas : off; + target.width = Math.round(cssW * dpr); + target.height = Math.round(cssH * dpr); + drawDotField(flatCtx ?? offCtx, cssW, cssH, 0, dpr, palette, bg ?? palette.bg, edge); + if (!flatCtx) { + canvas.style.backgroundImage = `url(${off.toDataURL()})`; + canvas.style.backgroundSize = '100% 100%'; + } + }; + paintFlat(); + // Encoding the background is a stage-sized PNG: through a resize it waits + // for the size to settle (the last image stretches meanwhile). + ro = new ResizeObserver(() => { + palette = readDotGridPalette(offCtx); + if (flatCtx) { + paintFlat?.(); + return; + } + clearTimeout(flatSettle); + flatSettle = window.setTimeout(() => paintFlat?.(), FLAT_SETTLE_MS); + }); + ro.observe(canvas); } return () => { cancelAnimationFrame(raf); ro?.disconnect(); + clearTimeout(flatSettle); + canvas.style.backgroundImage = ''; stopTheme(); pointer?.dispose(); press?.dispose(); diff --git a/components/grid-cards-demo/src/lib/share/client.ts b/components/grid-cards-demo/src/lib/share/client.ts index 80003f7f3..5207e9c4e 100644 --- a/components/grid-cards-demo/src/lib/share/client.ts +++ b/components/grid-cards-demo/src/lib/share/client.ts @@ -2,7 +2,7 @@ pictures, and remember the maker's edit tokens so a card they come back to is still theirs. */ -import type { CardExporter, ExportPose } from '@/components/CardStage/export/exportRenderer'; +import type { CardFrameSource, ExportPose } from '@/components/CardStage/export/exportRenderer'; import { hexToRgb, prepareHand, @@ -159,7 +159,7 @@ async function shrinkRaster(blob: Blob): Promise { } export interface CreateShareOptions { - exporter: CardExporter; + exporter: CardFrameSource; design: CardDesign; kind: ShareCreateInput['kind']; slug?: string;