60759f35b4
Polished-metal look: low-roughness (0.15) titanium + contrasty studio Environment (light bases + bright softbox strips) so the rounded edges catch hot reflection streaks that sweep as the phone rotates; shinier side buttons. Re-rendered all aspects + preview, redeployed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
352 lines
18 KiB
TypeScript
352 lines
18 KiB
TypeScript
import React, { useMemo, useState, useEffect } from "react";
|
|
import {
|
|
AbsoluteFill,
|
|
interpolate,
|
|
spring,
|
|
useCurrentFrame,
|
|
useVideoConfig,
|
|
delayRender,
|
|
continueRender,
|
|
staticFile,
|
|
} from "remotion";
|
|
import { ThreeCanvas } from "@remotion/three";
|
|
import { RoundedBox, Environment, Lightformer, MeshReflectorMaterial, ContactShadows } from "@react-three/drei";
|
|
import * as THREE from "three";
|
|
import { EffectComposer, Bloom, DepthOfField, Vignette } from "@react-three/postprocessing";
|
|
import { z } from "zod";
|
|
import { colorSchema } from "../lib/branding";
|
|
import { FONT } from "../lib/fonts";
|
|
import { useLayout } from "../lib/aspect";
|
|
import { hexToRgba, mixHex } from "../lib/anim";
|
|
|
|
export const appShowcase3DSchema = z.object({
|
|
appName: z.string(),
|
|
tagline: z.string(),
|
|
cta: z.string(),
|
|
/** Optional app screenshot (textured onto the phone screen). Empty → procedural mock UI. */
|
|
screenUrl: z.string(),
|
|
...colorSchema,
|
|
});
|
|
|
|
type Props = z.infer<typeof appShowcase3DSchema>;
|
|
|
|
// ── Screen texture: the user's uploaded app image, or a procedural mock UI ────
|
|
function useScreenTexture(screenUrl: string, accent: string, secondary: string): THREE.Texture {
|
|
const procedural = useMemo(() => {
|
|
const W = 540, H = 1170;
|
|
const c = document.createElement("canvas");
|
|
c.width = W; c.height = H;
|
|
const x = c.getContext("2d")!;
|
|
// background
|
|
x.fillStyle = "#ffffff"; x.fillRect(0, 0, W, H);
|
|
// status bar
|
|
x.fillStyle = "#0f172a";
|
|
x.font = "600 26px Arial"; x.textBaseline = "middle";
|
|
x.textAlign = "left"; x.fillText("9:41", 40, 48);
|
|
x.textAlign = "right";
|
|
for (let i = 0; i < 3; i++) { x.globalAlpha = 1; x.fillRect(W - 40 - i * 26, 40, 16, 16); }
|
|
x.globalAlpha = 1; x.textAlign = "left";
|
|
// header band (accent)
|
|
const hg = x.createLinearGradient(0, 90, W, 230);
|
|
hg.addColorStop(0, accent); hg.addColorStop(1, mixHex(accent, secondary, 0.6));
|
|
x.fillStyle = hg;
|
|
roundRect(x, 0, 90, W, 150, 0); x.fill();
|
|
// logo chip + title placeholder bars
|
|
x.fillStyle = "rgba(255,255,255,0.95)"; roundRect(x, 40, 130, 70, 70, 18); x.fill();
|
|
x.fillStyle = "rgba(255,255,255,0.95)"; roundRect(x, 130, 140, 220, 22, 11); x.fill();
|
|
x.fillStyle = "rgba(255,255,255,0.6)"; roundRect(x, 130, 178, 150, 16, 8); x.fill();
|
|
// hero card
|
|
const cg = x.createLinearGradient(40, 280, W - 40, 540);
|
|
cg.addColorStop(0, mixHex(accent, "#ffffff", 0.1)); cg.addColorStop(1, secondary);
|
|
x.fillStyle = cg; roundRect(x, 40, 280, W - 80, 260, 28); x.fill();
|
|
x.fillStyle = "rgba(255,255,255,0.9)"; roundRect(x, 70, 430, 200, 26, 13); x.fill();
|
|
x.fillStyle = "rgba(255,255,255,0.6)"; roundRect(x, 70, 472, 300, 18, 9); x.fill();
|
|
// list rows
|
|
for (let i = 0; i < 4; i++) {
|
|
const y = 580 + i * 110;
|
|
x.fillStyle = "#f1f5f9"; roundRect(x, 40, y, W - 80, 90, 22); x.fill();
|
|
x.fillStyle = accent; x.beginPath(); x.arc(95, y + 45, 28, 0, Math.PI * 2); x.fill();
|
|
x.fillStyle = "#cbd5e1"; roundRect(x, 145, y + 24, 240, 18, 9); x.fill();
|
|
x.fillStyle = "#e2e8f0"; roundRect(x, 145, y + 52, 160, 14, 7); x.fill();
|
|
}
|
|
// bottom tab bar
|
|
x.fillStyle = "#ffffff"; x.fillRect(0, H - 110, W, 110);
|
|
x.strokeStyle = "#e2e8f0"; x.lineWidth = 2; x.beginPath(); x.moveTo(0, H - 110); x.lineTo(W, H - 110); x.stroke();
|
|
for (let i = 0; i < 4; i++) {
|
|
x.fillStyle = i === 0 ? accent : "#cbd5e1";
|
|
x.beginPath(); x.arc(80 + i * 130, H - 55, 16, 0, Math.PI * 2); x.fill();
|
|
}
|
|
const tex = new THREE.CanvasTexture(c);
|
|
tex.colorSpace = THREE.SRGBColorSpace;
|
|
tex.anisotropy = 8;
|
|
return tex;
|
|
}, [accent, secondary]);
|
|
|
|
// Load the user's screenshot (if provided) as the screen texture; else procedural.
|
|
const [imgTex, setImgTex] = useState<THREE.Texture | null>(null);
|
|
useEffect(() => {
|
|
if (!screenUrl) { setImgTex(null); return; }
|
|
const handle = delayRender("load app screenshot");
|
|
const loader = new THREE.TextureLoader();
|
|
loader.setCrossOrigin("anonymous");
|
|
// Full http(s) URLs (user uploads / MinIO) load directly; bare paths resolve from public/.
|
|
const url = /^https?:\/\//.test(screenUrl) || screenUrl.startsWith("data:") ? screenUrl : staticFile(screenUrl);
|
|
loader.load(
|
|
url,
|
|
(t) => { t.colorSpace = THREE.SRGBColorSpace; t.anisotropy = 8; setImgTex(t); continueRender(handle); },
|
|
undefined,
|
|
() => continueRender(handle),
|
|
);
|
|
}, [screenUrl]);
|
|
|
|
return screenUrl && imgTex ? imgTex : procedural;
|
|
}
|
|
|
|
function roundRect(x: CanvasRenderingContext2D, X: number, Y: number, W: number, H: number, r: number) {
|
|
x.beginPath();
|
|
x.moveTo(X + r, Y);
|
|
x.arcTo(X + W, Y, X + W, Y + H, r);
|
|
x.arcTo(X + W, Y + H, X, Y + H, r);
|
|
x.arcTo(X, Y + H, X, Y, r);
|
|
x.arcTo(X, Y, X + W, Y, r);
|
|
x.closePath();
|
|
}
|
|
|
|
// A rounded-rectangle screen (real phones have rounded screen corners, not square).
|
|
const RoundedScreen: React.FC<{ w: number; h: number; radius: number; z: number; tex: THREE.Texture; opacity: number; glow: string; glowOpacity: number }> = ({ w, h, radius, z, tex, opacity, glow, glowOpacity }) => {
|
|
const geo = useMemo(() => {
|
|
const s = new THREE.Shape();
|
|
const x = -w / 2, y = -h / 2, r = Math.min(radius, w / 2, h / 2);
|
|
s.moveTo(x + r, y);
|
|
s.lineTo(x + w - r, y); s.quadraticCurveTo(x + w, y, x + w, y + r);
|
|
s.lineTo(x + w, y + h - r); s.quadraticCurveTo(x + w, y + h, x + w - r, y + h);
|
|
s.lineTo(x + r, y + h); s.quadraticCurveTo(x, y + h, x, y + h - r);
|
|
s.lineTo(x, y + r); s.quadraticCurveTo(x, y, x + r, y);
|
|
const g = new THREE.ShapeGeometry(s, 16);
|
|
g.computeBoundingBox();
|
|
const bb = g.boundingBox!;
|
|
const sx = bb.max.x - bb.min.x, sy = bb.max.y - bb.min.y;
|
|
const pos = g.attributes.position;
|
|
const uv: number[] = [];
|
|
for (let i = 0; i < pos.count; i++) uv.push((pos.getX(i) - bb.min.x) / sx, (pos.getY(i) - bb.min.y) / sy);
|
|
g.setAttribute("uv", new THREE.Float32BufferAttribute(uv, 2));
|
|
return g;
|
|
}, [w, h, radius]);
|
|
return (
|
|
<group position={[0, 0, z]}>
|
|
{glowOpacity > 0.001 && (
|
|
<mesh geometry={geo} position={[0, 0, -0.002]}>
|
|
<meshBasicMaterial color={glow} transparent opacity={glowOpacity} toneMapped={false} />
|
|
</mesh>
|
|
)}
|
|
<mesh geometry={geo}>
|
|
<meshBasicMaterial map={tex} toneMapped={false} transparent opacity={opacity} />
|
|
</mesh>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
// ── The 3D phone (generic premium flagship — Natural Titanium) ────────────────
|
|
const Phone: React.FC<{ screen: THREE.Texture; accent: string }> = ({ screen, accent }) => {
|
|
const frame = useCurrentFrame();
|
|
const { fps } = useVideoConfig();
|
|
const W = 2.02, H = 4.34, D = 0.26, rim = 0.05, bezel = 0.05;
|
|
const TITANIUM = "#c6c3ba"; // warm natural titanium
|
|
|
|
const enter = spring({ frame: frame - 4, fps, config: { damping: 14, stiffness: 70, mass: 0.9 } });
|
|
const scale = interpolate(enter, [0, 1], [0.7, 1]);
|
|
const floatY = Math.sin(frame / 34) * 0.08;
|
|
const sway = -0.2 + Math.sin(frame / 72) * 0.1; // show the titanium side edge
|
|
const tiltX = interpolate(enter, [0, 1], [0.45, -0.04]);
|
|
const screenOn = interpolate(frame, [12, 42], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
const btn = mixHex(TITANIUM, "#000000", 0.22);
|
|
|
|
return (
|
|
<group position={[0, floatY, 0]} rotation={[tiltX, sway, 0]} scale={scale}>
|
|
{/* titanium frame */}
|
|
<RoundedBox args={[W, H, D]} radius={0.33} smoothness={10} castShadow>
|
|
<meshStandardMaterial color={TITANIUM} metalness={1} roughness={0.15} envMapIntensity={2.3} />
|
|
</RoundedBox>
|
|
{/* glossy black glass front — edge-to-edge minus a thin titanium rim */}
|
|
<RoundedBox args={[W - rim * 2, H - rim * 2, 0.06]} radius={0.29} smoothness={10} position={[0, 0, D / 2 - 0.028]}>
|
|
<meshPhysicalMaterial color="#05070b" metalness={0} roughness={0.1} clearcoat={1} clearcoatRoughness={0.05} envMapIntensity={1.3} />
|
|
</RoundedBox>
|
|
{/* rounded, textured screen + power-on glow */}
|
|
<RoundedScreen w={W - rim * 2 - bezel * 2} h={H - rim * 2 - bezel * 2} radius={0.24} z={D / 2 + 0.006} tex={screen} opacity={screenOn} glow={accent} glowOpacity={(1 - screenOn) * 0.5} />
|
|
{/* dynamic island + camera */}
|
|
<group position={[0, H / 2 - rim - bezel - 0.16, D / 2 + 0.02]}>
|
|
<RoundedBox args={[0.6, 0.15, 0.05]} radius={0.075} smoothness={6}>
|
|
<meshStandardMaterial color="#02030a" roughness={0.25} metalness={0.2} />
|
|
</RoundedBox>
|
|
<mesh position={[0.2, 0, 0.03]}>
|
|
<circleGeometry args={[0.032, 20]} />
|
|
<meshStandardMaterial color="#0a1622" metalness={0.7} roughness={0.18} />
|
|
</mesh>
|
|
</group>
|
|
{/* side buttons (titanium) */}
|
|
<mesh position={[W / 2 + 0.006, 0.6, 0]}><boxGeometry args={[0.035, 0.52, 0.13]} /><meshStandardMaterial color={btn} metalness={1} roughness={0.3} /></mesh>
|
|
<mesh position={[W / 2 + 0.006, -0.05, 0]}><boxGeometry args={[0.035, 0.3, 0.13]} /><meshStandardMaterial color={btn} metalness={1} roughness={0.3} /></mesh>
|
|
<mesh position={[-W / 2 - 0.006, 0.75, 0]}><boxGeometry args={[0.035, 0.36, 0.13]} /><meshStandardMaterial color={btn} metalness={1} roughness={0.3} /></mesh>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
const Scene: React.FC<{ accent: string; secondary: string; phoneX: number; phoneY: number; phoneScale: number; screen: THREE.Texture }> = ({ accent, secondary, phoneX, phoneY, phoneScale, screen }) => {
|
|
const frame = useCurrentFrame();
|
|
const orbit = Math.sin(frame / 120) * 0.05;
|
|
return (
|
|
<group rotation={[0, orbit, 0]}>
|
|
{/* light, clean keynote studio */}
|
|
<ambientLight intensity={0.7} />
|
|
{/* key light (no harsh cast shadow — ContactShadows grounds the phone instead) */}
|
|
<directionalLight position={[3, 6, 6]} intensity={2.2} color="#ffffff" />
|
|
<directionalLight position={[-5, 3, 2]} intensity={0.7} color={mixHex("#ffffff", secondary, 0.3)} />
|
|
{/* sharp rim from behind for a hot metallic edge highlight */}
|
|
<spotLight position={[-3.5, 5, -3]} angle={0.6} penumbra={0.7} intensity={180} color="#ffffff" />
|
|
<pointLight position={[4, -1, 4]} intensity={12} color={mixHex("#ffffff", accent, 0.4)} />
|
|
{/* Contrasty studio env → bright softbox STREAKS on the titanium (the "shine").
|
|
A darker fill behind the bright strips gives metal the light/dark contrast that
|
|
reads as polished metal; the visible studio stays light (2D gradient behind canvas). */}
|
|
<Environment resolution={512}>
|
|
{/* light bases (keep the titanium silvery, not dark) front + back */}
|
|
<Lightformer form="rect" intensity={1.7} position={[0, 0, -6]} scale={[24, 24, 1]} color="#c3cad6" />
|
|
<Lightformer form="rect" intensity={1.5} position={[0, 0, 8]} scale={[24, 24, 1]} color="#eef2f7" />
|
|
{/* bright softbox strips → hot reflection streaks on the rounded edges */}
|
|
<Lightformer form="rect" intensity={9} position={[-2.6, 2.4, 4]} scale={[1.0, 9, 1]} color="#ffffff" />
|
|
<Lightformer form="rect" intensity={7} position={[2.9, 0.5, 3.5]} scale={[0.8, 8, 1]} color="#ffffff" />
|
|
<Lightformer form="rect" intensity={3} position={[0, 6, 2]} scale={[14, 3, 1]} color="#ffffff" />
|
|
</Environment>
|
|
|
|
<group position={[phoneX, phoneY, 0]} scale={phoneScale}>
|
|
<Phone screen={screen} accent={accent} />
|
|
</group>
|
|
|
|
{/* soft contact shadow grounds the phone cleanly */}
|
|
<ContactShadows position={[phoneX, -2.5 + phoneY * 0.05, 0]} scale={11} blur={2.8} opacity={0.32} far={6} color="#1e293b" />
|
|
{/* subtle reflective light floor for the keynote feel */}
|
|
<mesh rotation={[-Math.PI / 2, 0, 0]} position={[0, -2.5, 0]}>
|
|
<planeGeometry args={[40, 40]} />
|
|
<MeshReflectorMaterial blur={[300, 90]} resolution={1024} mixBlur={1.2} mixStrength={6} roughness={0.95} depthScale={0.5} minDepthThreshold={0.5} maxDepthThreshold={1.2} color="#e9ebf0" metalness={0.1} />
|
|
</mesh>
|
|
</group>
|
|
);
|
|
};
|
|
|
|
const StoreBadge: React.FC<{ kind: "ios" | "android"; L: ReturnType<typeof useLayout> }> = ({ kind, L }) => (
|
|
<div style={{ display: "flex", alignItems: "center", gap: L.vmin(8), background: "#0f172a", color: "#fff", borderRadius: L.vmin(12), padding: `${L.vmin(8)}px ${L.vmin(16)}px` }}>
|
|
<div style={{ width: L.vmin(26), height: L.vmin(26), borderRadius: "50%", background: "#fff", opacity: 0.95 }} />
|
|
<div style={{ textAlign: "left", lineHeight: 1.1 }}>
|
|
<div style={{ fontSize: L.vmin(12), opacity: 0.8 }}>{kind === "ios" ? "Download on the" : "GET IT ON"}</div>
|
|
<div style={{ fontSize: L.vmin(19), fontWeight: 700 }}>{kind === "ios" ? "App Store" : "Google Play"}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
// ── Finishing layers ─────────────────────────────────────────────────────────
|
|
const LightSweep: React.FC = () => {
|
|
const f = useCurrentFrame();
|
|
const { width } = useVideoConfig();
|
|
const x = interpolate(f, [26, 72], [-width * 0.35, width * 0.6], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
const op = interpolate(f, [26, 40, 64, 76], [0, 0.45, 0.45, 0], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
return (
|
|
<AbsoluteFill style={{ pointerEvents: "none", overflow: "hidden" }}>
|
|
<div style={{ position: "absolute", top: "-20%", height: "140%", width: 220, transform: `translateX(${x}px) rotate(12deg)`, background: "linear-gradient(90deg, transparent, rgba(255,255,255,0.7), transparent)", filter: "blur(34px)", opacity: op, mixBlendMode: "screen" }} />
|
|
</AbsoluteFill>
|
|
);
|
|
};
|
|
|
|
const Grain: React.FC = () => {
|
|
const f = useCurrentFrame();
|
|
return (
|
|
<AbsoluteFill style={{ pointerEvents: "none", opacity: 0.045, mixBlendMode: "overlay" }}>
|
|
<svg width="100%" height="100%">
|
|
<filter id="appGrain">
|
|
<feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="2" seed={f % 73} stitchTiles="stitch" />
|
|
</filter>
|
|
<rect width="100%" height="100%" filter="url(#appGrain)" />
|
|
</svg>
|
|
</AbsoluteFill>
|
|
);
|
|
};
|
|
|
|
export const AppShowcase3D: React.FC<Props> = ({
|
|
appName, tagline, cta, screenUrl, accentColor, secondaryColor, backgroundColor, textColor,
|
|
}) => {
|
|
const frame = useCurrentFrame();
|
|
const { width, height, fps } = useVideoConfig();
|
|
const L = useLayout();
|
|
const screen = useScreenTexture(screenUrl, accentColor, secondaryColor);
|
|
|
|
// Per-aspect re-flow: wide → phone left + text right; square/tall → phone up, text below.
|
|
const phoneX = L.pick(-1.85, 0, 0);
|
|
const phoneY = L.pick(0.1, 1.05, 0.55); // raise in square/tall so the phone clears the text
|
|
const phoneScale = L.pick(1.18, 0.8, 0.9); // shrink in square/tall so the whole device fits
|
|
const wide = L.isWide;
|
|
|
|
// Text reveals.
|
|
const nameSp = spring({ frame: frame - 70, fps, config: { damping: 13, stiffness: 90 } });
|
|
const nameY = interpolate(nameSp, [0, 1], [L.vmin(40), 0]);
|
|
const nameOp = interpolate(frame, [70, 90], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
const tagOp = interpolate(frame, [92, 112], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
const ctaSp = spring({ frame: frame - 116, fps, config: { damping: 12, stiffness: 110 } });
|
|
const ctaScale = interpolate(ctaSp, [0, 1], [0.6, 1]);
|
|
const ctaOp = interpolate(frame, [116, 134], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
const badgeOp = interpolate(frame, [134, 152], [0, 1], { extrapolateLeft: "clamp", extrapolateRight: "clamp" });
|
|
|
|
return (
|
|
<AbsoluteFill style={{ fontFamily: FONT, direction: "rtl", backgroundColor }}>
|
|
{/* light studio: soft vertical gradient + faint accent glow behind the phone */}
|
|
<AbsoluteFill style={{ background: `linear-gradient(180deg, #fbfbfd 0%, ${backgroundColor} 52%, ${mixHex(backgroundColor, "#000000", 0.09)} 100%)` }} />
|
|
<AbsoluteFill style={{ background: `radial-gradient(circle at ${L.pick("29%", "50%", "50%")} ${L.pick("48%", "38%", "32%")}, ${hexToRgba(accentColor, 0.2)} 0%, transparent 46%)` }} />
|
|
|
|
<ThreeCanvas
|
|
width={width}
|
|
height={height}
|
|
camera={{ position: [0, 0.2, 8.2], fov: 42 }}
|
|
shadows
|
|
style={{ position: "absolute", inset: 0 }}
|
|
gl={{ toneMapping: THREE.ACESFilmicToneMapping, antialias: true }}
|
|
>
|
|
<Scene accent={accentColor} secondary={secondaryColor} phoneX={phoneX} phoneY={phoneY} phoneScale={phoneScale} screen={screen} />
|
|
<EffectComposer>
|
|
<Bloom intensity={0.28} luminanceThreshold={0.75} luminanceSmoothing={0.3} mipmapBlur />
|
|
<DepthOfField focusDistance={0.012} focalLength={0.05} bokehScale={2.5} />
|
|
<Vignette eskil={false} offset={0.3} darkness={0.35} />
|
|
</EffectComposer>
|
|
</ThreeCanvas>
|
|
|
|
<LightSweep />
|
|
|
|
{/* text + CTA + badges — layout in LTR (predictable), text rendered RTL */}
|
|
<div
|
|
style={{
|
|
position: "absolute",
|
|
direction: "ltr",
|
|
display: "flex",
|
|
flexDirection: "column",
|
|
gap: L.vmin(6),
|
|
...(wide
|
|
? { top: 0, bottom: 0, right: width * 0.05, width: width * 0.4, justifyContent: "center", alignItems: "flex-end" }
|
|
: { left: 0, right: 0, bottom: L.vmin(80), alignItems: "center", paddingInline: L.vmin(50) }),
|
|
}}
|
|
>
|
|
<div style={{ direction: "rtl", textAlign: wide ? "right" : "center", transform: `translateY(${nameY}px)`, opacity: nameOp, fontWeight: 900, fontSize: L.pick(L.vmin(82), L.vmin(78), L.vmin(72)), color: textColor, lineHeight: 1.05, maxWidth: wide ? "100%" : width * 0.86 }}>
|
|
{appName}
|
|
</div>
|
|
<div style={{ direction: "rtl", textAlign: wide ? "right" : "center", opacity: tagOp, fontWeight: 500, fontSize: L.pick(L.vmin(30), L.vmin(29), L.vmin(27)), color: hexToRgba(textColor, 0.68), maxWidth: wide ? "100%" : width * 0.82 }}>
|
|
{tagline}
|
|
</div>
|
|
<div style={{ direction: "rtl", marginTop: L.vmin(24), opacity: ctaOp, transform: `scale(${ctaScale})`, padding: `${L.vmin(16)}px ${L.vmin(42)}px`, borderRadius: 999, background: `linear-gradient(135deg, ${accentColor}, ${secondaryColor})`, color: "#fff", fontWeight: 800, fontSize: L.vmin(28), boxShadow: `0 ${L.vmin(8)}px ${L.vmin(24)}px ${hexToRgba(accentColor, 0.35)}` }}>
|
|
{cta}
|
|
</div>
|
|
<div style={{ marginTop: L.vmin(16), opacity: badgeOp, display: "flex", gap: L.vmin(12) }}>
|
|
<StoreBadge kind="ios" L={L} />
|
|
<StoreBadge kind="android" L={L} />
|
|
</div>
|
|
</div>
|
|
|
|
<Grain />
|
|
</AbsoluteFill>
|
|
);
|
|
};
|