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 0000000..d6c1ec6
Binary files /dev/null and b/LogoAnimation/logo/capsuleCode.svg:Zone.Identifier differ
diff --git a/LogoAnimation/logo/capsuleData.svg b/LogoAnimation/logo/capsuleData.svg
new file mode 100644
index 0000000..e90bc71
--- /dev/null
+++ b/LogoAnimation/logo/capsuleData.svg
@@ -0,0 +1,10 @@
+
\ 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 0000000..d6c1ec6
Binary files /dev/null and b/LogoAnimation/logo/capsuleData.svg:Zone.Identifier differ
diff --git a/LogoAnimation/logo/capsuleRx.svg b/LogoAnimation/logo/capsuleRx.svg
new file mode 100644
index 0000000..00fb4fd
--- /dev/null
+++ b/LogoAnimation/logo/capsuleRx.svg
@@ -0,0 +1,12 @@
+
\ 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 0000000..d6c1ec6
Binary files /dev/null and b/LogoAnimation/logo/capsuleRx.svg:Zone.Identifier differ
diff --git a/LogoAnimation/src/Composition.tsx b/LogoAnimation/src/Composition.tsx
new file mode 100644
index 0000000..28f4d29
--- /dev/null
+++ b/LogoAnimation/src/Composition.tsx
@@ -0,0 +1,323 @@
+import { useMemo } from "react";
+import { AbsoluteFill, Easing, Img, interpolate, spring, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
+
+type PillProps = {
+ src: string;
+ x: number;
+ y: number;
+ rotation: number;
+ zIndex: number;
+};
+
+type PillTransform = {
+ x: number;
+ y: number;
+ rotation: number;
+};
+
+const PILL_WIDTH = 200;
+const PILL_HEIGHT = 450;
+const RISE_DURATION_FRAMES = 150;
+const RISE_SETTLE_PAUSE_FRAMES = 0;
+const GAP_AFTER_RISE_FRAMES = 30;
+const FAN_DURATION_FRAMES = 236;
+const FAN_START_FRAME = RISE_DURATION_FRAMES + RISE_SETTLE_PAUSE_FRAMES + GAP_AFTER_RISE_FRAMES;
+
+const OVERLAY_BLEND_START_PROGRESS = 0.5;
+const OVERLAY_BLEND_TRANSITION_FRAMES = 150;
+const OVERLAP_BLEND_MAX_OPACITY = 0.2;
+const BACKGROUND_COLOR = "#efefef";
+const COLOR_CODE = "#E38B16";
+const COLOR_RX = "#0E7A7D";
+const COLOR_DATA = "#109E6C";
+
+const Pill: React.FC = ({ 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 (
)
}
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) {
>