From 83b941262e7d1d077573c0c8f45f0c522543235a Mon Sep 17 00:00:00 2001 From: Andy Charlwood Date: Sun, 15 Feb 2026 13:49:15 +0000 Subject: [PATCH] Added logo animation to login screen, initial work --- .claude/settings.local.json | 4 +- LogoAnimation/logo/capsuleCode.svg | 8 + .../logo/capsuleCode.svg:Zone.Identifier | Bin 0 -> 25 bytes LogoAnimation/logo/capsuleData.svg | 10 + .../logo/capsuleData.svg:Zone.Identifier | Bin 0 -> 25 bytes LogoAnimation/logo/capsuleRx.svg | 12 + .../logo/capsuleRx.svg:Zone.Identifier | Bin 0 -> 25 bytes LogoAnimation/src/Composition.tsx | 323 ++++++++++++++++++ LogoAnimation/src/Root.tsx | 18 + LogoAnimation/src/index.css | 1 + LogoAnimation/src/index.ts | 4 + logo/capsuleCode.svg | 8 + logo/capsuleData.svg | 10 + logo/capsuleRx.svg | 12 + src/components/CvmisLogo.tsx | 170 +++++---- src/components/LoginScreen.tsx | 2 +- 16 files changed, 506 insertions(+), 76 deletions(-) create mode 100644 LogoAnimation/logo/capsuleCode.svg create mode 100644 LogoAnimation/logo/capsuleCode.svg:Zone.Identifier create mode 100644 LogoAnimation/logo/capsuleData.svg create mode 100644 LogoAnimation/logo/capsuleData.svg:Zone.Identifier create mode 100644 LogoAnimation/logo/capsuleRx.svg create mode 100644 LogoAnimation/logo/capsuleRx.svg:Zone.Identifier create mode 100644 LogoAnimation/src/Composition.tsx create mode 100644 LogoAnimation/src/Root.tsx create mode 100644 LogoAnimation/src/index.css create mode 100644 LogoAnimation/src/index.ts create mode 100644 logo/capsuleCode.svg create mode 100644 logo/capsuleData.svg create mode 100644 logo/capsuleRx.svg diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 99a2fbc..f9e8a37 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -29,7 +29,9 @@ "Bash(npx -y serve -l 3333 .)", "Bash(npx serve:*)", "Bash(timeout /t 3 /nobreak)", - "Bash(jq:*)" + "Bash(jq:*)", + "Bash(git stash:*)", + "Bash(npx tsc:*)" ] } } diff --git a/LogoAnimation/logo/capsuleCode.svg b/LogoAnimation/logo/capsuleCode.svg new file mode 100644 index 0000000..db82f6b --- /dev/null +++ b/LogoAnimation/logo/capsuleCode.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/LogoAnimation/logo/capsuleCode.svg:Zone.Identifier b/LogoAnimation/logo/capsuleCode.svg:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x + + + + + + + + + \ No newline at end of file diff --git a/LogoAnimation/logo/capsuleData.svg:Zone.Identifier b/LogoAnimation/logo/capsuleData.svg:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x + + + + + + \ No newline at end of file diff --git a/LogoAnimation/logo/capsuleRx.svg:Zone.Identifier b/LogoAnimation/logo/capsuleRx.svg:Zone.Identifier new file mode 100644 index 0000000000000000000000000000000000000000..d6c1ec682968c796b9f5e9e080cc6f674b57c766 GIT binary patch literal 25 dcma!!%Fjy;DN4*MPD?F{<>dl#JyUFr831@K2x = ({ src, x, y, rotation, zIndex }) => { + return ( + + ); +}; + +const withCapsuleTransform = ({ + ctx, + transform, + videoWidth, + videoHeight, + draw, +}: { + ctx: CanvasRenderingContext2D; + transform: PillTransform; + videoWidth: number; + videoHeight: number; + draw: () => void; +}) => { + ctx.save(); + // Match the CSS transform order and origin used for the visible pills. + ctx.translate(videoWidth / 2, videoHeight / 2); + ctx.translate(-PILL_WIDTH / 2, -PILL_HEIGHT / 2); + ctx.translate(transform.x, transform.y); + ctx.translate(PILL_WIDTH / 2, PILL_HEIGHT); + ctx.rotate((transform.rotation * Math.PI) / 180); + ctx.translate(-PILL_WIDTH / 2, -PILL_HEIGHT); + draw(); + ctx.restore(); +}; + +const drawCapsuleFill = ({ + ctx, + transform, + fill, + videoWidth, + videoHeight, +}: { + ctx: CanvasRenderingContext2D; + transform: PillTransform; + fill: string; + videoWidth: number; + videoHeight: number; +}) => { + withCapsuleTransform({ + ctx, + transform, + videoWidth, + videoHeight, + draw: () => { + ctx.fillStyle = fill; + ctx.beginPath(); + ctx.roundRect(0, 0, PILL_WIDTH, PILL_HEIGHT, PILL_WIDTH / 2); + ctx.fill(); + }, + }); +}; + +const buildPairBlendIntersection = ({ + outputCtx, + pairCtx, + maskCtx, + colorA, + colorB, + transformA, + transformB, + videoWidth, + videoHeight, +}: { + outputCtx: CanvasRenderingContext2D; + pairCtx: CanvasRenderingContext2D; + maskCtx: CanvasRenderingContext2D; + colorA: string; + colorB: string; + transformA: PillTransform; + transformB: PillTransform; + videoWidth: number; + videoHeight: number; +}) => { + // Build blended color result for the two pills. + pairCtx.clearRect(0, 0, videoWidth, videoHeight); + pairCtx.globalCompositeOperation = "source-over"; + drawCapsuleFill({ ctx: pairCtx, transform: transformA, fill: colorA, videoWidth, videoHeight }); + pairCtx.globalCompositeOperation = "multiply"; + drawCapsuleFill({ ctx: pairCtx, transform: transformB, fill: colorB, videoWidth, videoHeight }); + pairCtx.globalCompositeOperation = "source-over"; + + // Build hard intersection mask for the same pair. + maskCtx.clearRect(0, 0, videoWidth, videoHeight); + maskCtx.globalCompositeOperation = "source-over"; + drawCapsuleFill({ ctx: maskCtx, transform: transformA, fill: "#fff", videoWidth, videoHeight }); + maskCtx.globalCompositeOperation = "source-in"; + drawCapsuleFill({ ctx: maskCtx, transform: transformB, fill: "#fff", videoWidth, videoHeight }); + maskCtx.globalCompositeOperation = "source-over"; + + // Keep only overlap area from blended result. + pairCtx.globalCompositeOperation = "destination-in"; + pairCtx.drawImage(maskCtx.canvas, 0, 0); + pairCtx.globalCompositeOperation = "source-over"; + + // Accumulate pair overlap into final output. + outputCtx.drawImage(pairCtx.canvas, 0, 0); +}; + +export const MyComposition: React.FC = () => { + const frame = useCurrentFrame(); + const { fps, width, height } = useVideoConfig(); + const overlayBlendEndProgress = Math.min( + 1, + OVERLAY_BLEND_START_PROGRESS + OVERLAY_BLEND_TRANSITION_FRAMES / FAN_DURATION_FRAMES, + ); + + const codeSrc = staticFile("logo/capsuleCode.svg"); + const rxSrc = staticFile("logo/capsuleRx.svg"); + const dataSrc = staticFile("logo/capsuleData.svg"); + + const rise = interpolate(frame, [0, RISE_DURATION_FRAMES], [0, 1], { + easing: Easing.out(Easing.cubic), + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const fan = spring({ + frame: frame - FAN_START_FRAME, + fps, + durationInFrames: FAN_DURATION_FRAMES, + config: { damping: 200 }, + }); + + const centerY = interpolate(rise, [0, 1], [560, 76], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const leftY = interpolate(fan, [0, 1], [centerY, 74], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const rightY = interpolate(fan, [0, 1], [centerY, 74], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const leftX = interpolate(fan, [0, 1], [0, -36], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const rightX = interpolate(fan, [0, 1], [0, 36], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const leftRotation = interpolate(fan, [0, 1], [0, -50], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const rightRotation = interpolate(fan, [0, 1], [0, 50], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }); + + const overlapBlendProgress = + overlayBlendEndProgress >= 1 + ? interpolate(fan, [0, OVERLAY_BLEND_START_PROGRESS, 1], [0, 0, 1], { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }) + : interpolate( + fan, + [0, OVERLAY_BLEND_START_PROGRESS, overlayBlendEndProgress, 1], + [0, 0, 1, 1], + { + extrapolateLeft: "clamp", + extrapolateRight: "clamp", + }, + ); + + const overlapMaskSrc = useMemo(() => { + const centerTransform: PillTransform = { x: 0, y: centerY, rotation: 0 }; + const leftTransform: PillTransform = { x: leftX, y: leftY, rotation: leftRotation }; + const rightTransform: PillTransform = { x: rightX, y: rightY, rotation: rightRotation }; + + const outputCanvas = document.createElement("canvas"); + outputCanvas.width = width; + outputCanvas.height = height; + const outputCtx = outputCanvas.getContext("2d"); + + const pairCanvas = document.createElement("canvas"); + pairCanvas.width = width; + pairCanvas.height = height; + const pairCtx = pairCanvas.getContext("2d"); + + const maskCanvas = document.createElement("canvas"); + maskCanvas.width = width; + maskCanvas.height = height; + const maskCtx = maskCanvas.getContext("2d"); + + if (!outputCtx || !pairCtx || !maskCtx) { + return null; + } + + outputCtx.clearRect(0, 0, width, height); + + buildPairBlendIntersection({ + outputCtx, + pairCtx, + maskCtx, + colorA: COLOR_CODE, + colorB: COLOR_RX, + transformA: centerTransform, + transformB: leftTransform, + videoWidth: width, + videoHeight: height, + }); + + buildPairBlendIntersection({ + outputCtx, + pairCtx, + maskCtx, + colorA: COLOR_CODE, + colorB: COLOR_DATA, + transformA: centerTransform, + transformB: rightTransform, + videoWidth: width, + videoHeight: height, + }); + + buildPairBlendIntersection({ + outputCtx, + pairCtx, + maskCtx, + colorA: COLOR_RX, + colorB: COLOR_DATA, + transformA: leftTransform, + transformB: rightTransform, + videoWidth: width, + videoHeight: height, + }); + + const pixels = outputCtx.getImageData(0, 0, width, height).data; + let hasAlphaPixels = false; + for (let i = 3; i < pixels.length; i += 4) { + if (pixels[i] > 0) { + hasAlphaPixels = true; + break; + } + } + + if (!hasAlphaPixels) { + return null; + } + + return outputCanvas.toDataURL("image/png"); + }, [centerY, height, leftRotation, leftX, leftY, rightRotation, rightX, rightY, width]); + + return ( + + + + + {overlapMaskSrc ? ( + + ) : null} + + ); +}; diff --git a/LogoAnimation/src/Root.tsx b/LogoAnimation/src/Root.tsx new file mode 100644 index 0000000..59e6996 --- /dev/null +++ b/LogoAnimation/src/Root.tsx @@ -0,0 +1,18 @@ +import "./index.css"; +import { Composition } from "remotion"; +import { MyComposition } from "./Composition"; + +export const RemotionRoot: React.FC = () => { + return ( + <> + + + ); +}; diff --git a/LogoAnimation/src/index.css b/LogoAnimation/src/index.css new file mode 100644 index 0000000..f1d8c73 --- /dev/null +++ b/LogoAnimation/src/index.css @@ -0,0 +1 @@ +@import "tailwindcss"; diff --git a/LogoAnimation/src/index.ts b/LogoAnimation/src/index.ts new file mode 100644 index 0000000..f31c790 --- /dev/null +++ b/LogoAnimation/src/index.ts @@ -0,0 +1,4 @@ +import { registerRoot } from "remotion"; +import { RemotionRoot } from "./Root"; + +registerRoot(RemotionRoot); diff --git a/logo/capsuleCode.svg b/logo/capsuleCode.svg new file mode 100644 index 0000000..db82f6b --- /dev/null +++ b/logo/capsuleCode.svg @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/logo/capsuleData.svg b/logo/capsuleData.svg new file mode 100644 index 0000000..e90bc71 --- /dev/null +++ b/logo/capsuleData.svg @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/logo/capsuleRx.svg b/logo/capsuleRx.svg new file mode 100644 index 0000000..00fb4fd --- /dev/null +++ b/logo/capsuleRx.svg @@ -0,0 +1,12 @@ + + + + + + + \ No newline at end of file diff --git a/src/components/CvmisLogo.tsx b/src/components/CvmisLogo.tsx index e3b21df..a4544cd 100644 --- a/src/components/CvmisLogo.tsx +++ b/src/components/CvmisLogo.tsx @@ -8,103 +8,125 @@ interface CvmisLogoProps { className?: string } +// Pivot point: bottom-center of the pill stack (in viewBox coords) +const PX = 300 +const PY = 275 + +// Build a CSS transform that rotates around (PX, PY) then offsets by dx +function fanTransform(rotation: number, dx: number): string { + return [ + `translate(${dx}px, 0px)`, + `translate(${PX}px, ${PY}px)`, + `rotate(${rotation}deg)`, + `translate(${-PX}px, ${-PY}px)`, + ].join(' ') +} + +const IDENTITY_TRANSFORM = fanTransform(0, 0) +const FAN_EASING = 'cubic-bezier(0.34, 1.56, 0.64, 1)' + export function CvmisLogo({ size, cssHeight, animated = false, className }: CvmisLogoProps) { const prefersReducedMotion = useReducedMotion() - const [animationPhase, setAnimationPhase] = useState<'rise' | 'fan' | 'done'>( - animated && !prefersReducedMotion ? 'rise' : 'done' + const [phase, setPhase] = useState<'rising' | 'fanning' | 'done'>( + animated && !prefersReducedMotion ? 'rising' : 'done' ) useEffect(() => { if (!animated || prefersReducedMotion) return - const riseTimer = setTimeout(() => setAnimationPhase('fan'), 500) - const fanTimer = setTimeout(() => setAnimationPhase('done'), 1000) + const fanTimer = setTimeout(() => setPhase('fanning'), 500) + const doneTimer = setTimeout(() => setPhase('done'), 1000) return () => { - clearTimeout(riseTimer) clearTimeout(fanTimer) + clearTimeout(doneTimer) } }, [animated, prefersReducedMotion]) - const skipAnimation = !animated || prefersReducedMotion - const showAll = animationPhase === 'fan' || animationPhase === 'done' + const skip = !animated || prefersReducedMotion + const isFanned = phase === 'fanning' || phase === 'done' + const fanTarget = isFanned || skip + + const leftTransform = fanTarget ? fanTransform(-50, -16) : IDENTITY_TRANSFORM + const rightTransform = fanTarget ? fanTransform(50, 16) : IDENTITY_TRANSFORM + const fanTransition = skip ? 'none' : `transform 0.6s ${FAN_EASING}` + const fanTransitionDelayed = skip ? 'none' : `transform 0.6s ${FAN_EASING} 0.03s` - // The original SVG viewBox is 600pt x 506pt with an internal transform of - // scale(0.05, -0.05) translate(0, 506). We keep the original coordinate - // system and let the outer viewBox handle scaling. return ( - - {/* Capsule: Rx (Pharmacy) - Left — teal, tilts left in fan */} - - - - + {/* Rise group — all pills rise together from below */} + + {/* Rx pill — teal, fans left (bottom layer) */} + + + + + + + + - {/* Capsule: Terminal (Code) - Centre — amber, stays upright */} - - - + {/* Data pill — green, fans right (middle layer) */} + + + + + + + + + + + - {/* Capsule: Data (Analytics) - Right — green, the "rising" capsule */} - - - - + {/* Code pill — amber, center (top layer, no fan) */} + + + + + + + + ) } diff --git a/src/components/LoginScreen.tsx b/src/components/LoginScreen.tsx index ce1980c..0fbc77a 100644 --- a/src/components/LoginScreen.tsx +++ b/src/components/LoginScreen.tsx @@ -239,7 +239,7 @@ export function LoginScreen({ onComplete }: LoginScreenProps) { >