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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/images/illustrations/backlog-menu-1.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/images/illustrations/backlog-menu-2.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/images/illustrations/big-card.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/images/illustrations/small-card.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
128 changes: 128 additions & 0 deletions components/illustrations/ai-overview-illu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
'use client';

import AiOverviewBase from '@/assets/images/illustrations/ai-overview-illu-base.webp';
import AiOverviewOverlay from '@/assets/images/illustrations/ai-overview-illu-overlay.webp';
import { motion } from 'framer-motion';
import Image from 'next/image';
import { IllustrationProps } from './types';

// DecoyReveal: ai-overview-illu-overlay.webp already has the whole card
// fully drawn in — heading, properties, comments, and objective. Instead of
// separate content-layer images, we hide each section behind a plain white
// decoy and fade the decoy away to reveal it. The overlay is now exported
// full-frame (2560x1392, matching the base), so — unlike the old card-sized
// export — both the overlay image and every decoy below are positioned as
// percentages of the whole frame, not a sub-box.

const BASE_SLIDE_DURATION = 0.6;

// The card slides up from below the frame once the base background has
// mostly settled, starting a touch before it finishes for a cascading feel
// rather than a strictly sequential one.
const OVERLAY_SLIDE_START = 0.35;
const OVERLAY_SLIDE_DURATION = 0.6;
const OVERLAY_SLIDE_END = OVERLAY_SLIDE_START + OVERLAY_SLIDE_DURATION;

// Section footprints, measured directly off the overlay art as a percentage
// of its own 2560x1392 canvas — each spans from just above its content down
// to just before the next section's divider line (dividers themselves are
// part of the card's always-visible shell, so they're left uncovered).
const CONTENT_LEFT = 21.68;
const CONTENT_WIDTH = 57.23;
const HEADING_SLOT = { top: 7.9, height: 10.42 };
const PROPERTIES_SLOT = { top: 19.4, height: 20.83 };
const COMMENTS_SLOT = { top: 42.75, height: 35.56 };
const OBJECTIVE_SLOT = { top: 80.82, height: 19.18 };

// Heading, properties, comments, and objective fade in, staggered, once the
// card has finished sliding up — in the same top-to-bottom reading order as
// before, just with the heading now leading the sequence.
const CONTENT_START = OVERLAY_SLIDE_END + 0.2;
const CONTENT_STAGGER = 0.3;
const CONTENT_FADE_DURATION = 0.45;

const HEADING_START = CONTENT_START;
const PROPERTIES_START = CONTENT_START + CONTENT_STAGGER;
const COMMENTS_START = CONTENT_START + CONTENT_STAGGER * 2;
const OBJECTIVE_START = CONTENT_START + CONTENT_STAGGER * 3;

function AiOverviewIllu({ className }: IllustrationProps) {
return (
<div className={className}>
<div className={'relative w-full aspect-[2560/1392] overflow-hidden'}>
<motion.div
className={'absolute inset-0'}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: BASE_SLIDE_DURATION, ease: 'easeIn' }}
>
<Image
src={AiOverviewBase}
alt={'AI overview'}
fill
sizes={'(max-width: 1280px) 100vw, 1280px'}
className={'object-contain'}
/>

{/* Card: nested inside the base's own wrapper so it rides the same
entrance slide with zero relative motion, then — on top of
that — slides up from below the frame on its own timer. Both
the overlay image and its decoys below share this same
full-frame `inset-0` box, so every decoy's percentages line up
with the overlay art directly — no separate card sub-box. */}
<motion.div
className={'absolute inset-0'}
initial={{ y: '100%' }}
animate={{ y: '0%' }}
transition={{ type: 'tween', duration: OVERLAY_SLIDE_DURATION, delay: OVERLAY_SLIDE_START, ease: 'easeOut' }}
>
<Image
src={AiOverviewOverlay}
alt={'AI Overview requirements'}
fill
className={'object-contain'}
/>

{/* Heading: emoji + title. */}
<motion.div
className={'absolute bg-white'}
style={{ left: `${CONTENT_LEFT}%`, top: `${HEADING_SLOT.top}%`, width: `${CONTENT_WIDTH}%`, height: `${HEADING_SLOT.height}%` }}
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: CONTENT_FADE_DURATION, delay: HEADING_START, ease: 'easeOut' }}
/>

{/* Product manager / status / due date */}
<motion.div
className={'absolute bg-white'}
style={{ left: `${CONTENT_LEFT}%`, top: `${PROPERTIES_SLOT.top}%`, width: `${CONTENT_WIDTH}%`, height: `${PROPERTIES_SLOT.height}%` }}
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: CONTENT_FADE_DURATION, delay: PROPERTIES_START, ease: 'easeOut' }}
/>

{/* Comments */}
<motion.div
className={'absolute bg-white'}
style={{ left: `${CONTENT_LEFT}%`, top: `${COMMENTS_SLOT.top}%`, width: `${CONTENT_WIDTH}%`, height: `${COMMENTS_SLOT.height}%` }}
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: CONTENT_FADE_DURATION, delay: COMMENTS_START, ease: 'easeOut' }}
/>

{/* Objective */}
<motion.div
className={'absolute bg-white'}
style={{ left: `${CONTENT_LEFT}%`, top: `${OBJECTIVE_SLOT.top}%`, width: `${CONTENT_WIDTH}%`, height: `${OBJECTIVE_SLOT.height}%` }}
initial={{ opacity: 1 }}
animate={{ opacity: 0 }}
transition={{ duration: CONTENT_FADE_DURATION, delay: OBJECTIVE_START, ease: 'easeOut' }}
/>
</motion.div>
</motion.div>
</div>
</div>
);
}

export default AiOverviewIllu;
153 changes: 153 additions & 0 deletions components/illustrations/backlog-illu.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
'use client';

import BacklogBase from '@/assets/images/illustrations/backlog-illu-base.webp';
import BacklogMenu1 from '@/assets/images/illustrations/backlog-menu-1.webp';
import BacklogMenu2 from '@/assets/images/illustrations/backlog-menu-2.webp';
import { motion } from 'framer-motion';
import Image from 'next/image';
import Cursor from './cursor';
import { IllustrationProps } from './types';

// DecoyReveal: backlog-illu-base.webp already has the table fully drawn in
// alongside the page chrome. Instead of a separate overlay image, we hide
// the table behind a plain white decoy and animate the decoy away to
// reveal it — no second layer to export.

const BASE_SLIDE_DURATION = 0.6;

// Emily: a quick intro beat before there's anything to look at yet — fades
// in and slides down, finishing just before the table starts growing in.
const EMILY_COLOR = '#14B8A6';
const EMILY_SPAWN = { left: 20, top: 50 };
const EMILY_DEST = { left: 20, top: 100 };
const EMILY_REVEAL_START = 0.25;
const EMILY_APPEAR_DURATION = 1.5;

// Table footprint, measured directly off backlog-illu-base.webp as a
// percentage of its own 2560x1392 canvas — spans from the border above the
// column headers down to the bottom of the frame (the last row's fade is
// already baked into the base image below that).
const TABLE_LEFT = 4.69;
const TABLE_TOP = 50;
const TABLE_WIDTH = 90.63;
const TABLE_HEIGHT = 50;

// Table decoy: height animates to 0, pinned via `bottom`, so it recedes
// downward — uncovering the header and rows top-to-bottom, one after
// another. Starts once Emily has landed, so the table appears to grow in
// right after she arrives.
const TABLE_REVEAL_START = 0.1;
const TABLE_REVEAL_DURATION = 1.5;
const TABLE_REVEAL_END = TABLE_REVEAL_START + TABLE_REVEAL_DURATION;

// Sized/positioned off the original composited backlog.webp reference: both
// cards share their native width (they're the same width in their source
// images), anchored from the right edge and bleeding off it slightly, with
// "Hidden properties" tucked behind-and-below "Visible properties" — hence
// menu-1 (the back card) is rendered first so menu-2 (the front card) paints
// on top of it, even though menu-2's fade-in still happens first below.
const MENU_WIDTH = 28;
const MENU_2_SLOT = { right: 14, top: 15 };
const MENU_1_SLOT = { right: -1, top: 50 };

const MENU_START = TABLE_REVEAL_END + 0.15;
const MENU_STAGGER = 0.2;
const MENU_FADE_DURATION = 0.5;

function BacklogIllu({ className }: IllustrationProps) {
return (
<div className={className}>
<div className={'relative w-full aspect-[2560/1392] overflow-hidden'}>
<motion.div
className={'absolute inset-0'}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: BASE_SLIDE_DURATION, ease: 'easeIn' }}
>
<Image
src={BacklogBase}
alt={'Backlog'}
fill
sizes={'(max-width: 1280px) 100vw, 1280px'}
className={'object-contain'}
/>

{/* Table decoy: nested inside the base's own wrapper for the same
zero-relative-motion reason as Emily and the menus below. */}
<div
className={'absolute'}
style={{ left: `${TABLE_LEFT}%`, top: `${TABLE_TOP}%`, width: `${TABLE_WIDTH}%`, height: `${TABLE_HEIGHT}%` }}
>
<motion.div
className={'absolute inset-x-0 bottom-0 bg-white'}
initial={{ height: '100%' }}
animate={{ height: '0%' }}
transition={{ duration: TABLE_REVEAL_DURATION, delay: TABLE_REVEAL_START, ease: 'easeInOut' }}
/>
</div>

{/* Emily: nested inside the base wrapper for the same zero-
relative-motion reason as the table decoy above — fades in and
slides down to her landing spot before the table starts
growing in beneath her. */}
<motion.div
className={'absolute inset-0'}
initial={{
opacity: 0,
x: `${EMILY_SPAWN.left - EMILY_DEST.left}%`,
y: `${EMILY_SPAWN.top - EMILY_DEST.top}%`,
}}
animate={{ opacity: 1, x: '0%', y: '0%' }}
transition={{ duration: EMILY_APPEAR_DURATION, delay: EMILY_REVEAL_START, ease: 'easeIn' }}
>
<div
className={'absolute'}
style={{ left: `${EMILY_DEST.left}%`, top: `${EMILY_DEST.top}%` }}
>
<Cursor
label={'Emily'}
color={EMILY_COLOR}
direction={'left-top'}
/>
</div>
</motion.div>

{/* Menu cards: fade + slide up, staggered, once the table has
finished growing in. Hidden properties (the back card) is
rendered first so Visible properties (the front card) paints
on top of it, even though Visible is the one that fades in
first. */}
<motion.div
className={'absolute'}
style={{ right: `${MENU_1_SLOT.right}%`, top: `${MENU_1_SLOT.top}%`, width: `${MENU_WIDTH}%` }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: MENU_FADE_DURATION, delay: MENU_START + MENU_STAGGER, ease: 'easeOut' }}
>
<Image
src={BacklogMenu1}
alt={'Hidden properties'}
className={'h-auto w-full'}
/>
</motion.div>

<motion.div
className={'absolute'}
style={{ right: `${MENU_2_SLOT.right}%`, top: `${MENU_2_SLOT.top}%`, width: `${MENU_WIDTH}%` }}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: MENU_FADE_DURATION, delay: MENU_START, ease: 'easeOut' }}
>
<Image
src={BacklogMenu2}
alt={'Visible properties'}
className={'h-auto w-full'}
/>
</motion.div>
</motion.div>
</div>
</div>
);
}

export default BacklogIllu;
31 changes: 31 additions & 0 deletions components/illustrations/cursor-pointer-icon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { CSSProperties } from 'react';

export interface CursorPointerIconProps {
className?: string;
style?: CSSProperties;
color?: string;
}

function CursorPointerIcon({ className, style, color = 'currentColor' }: CursorPointerIconProps) {
return (
<svg
width={20}
height={21}
viewBox={'0 0 26 27'}
fill={'none'}
className={className}
style={style}
>
<path
d={
'M1.08301 3.53229C0.611953 1.84823 2.35671 0.480038 3.87891 1.19244L23.8525 10.5411C25.4724 11.2994 25.3646 13.6229 23.6797 14.2266L15.1689 17.2764C15.167 17.2771 15.1657 17.2779 15.165 17.2784L10.6377 25.0059C9.74589 26.5285 7.43745 26.2443 6.95898 24.5342L1.08301 3.53229Z'
}
fill={color}
stroke={'white'}
strokeWidth={2}
/>
</svg>
);
}

export default CursorPointerIcon;
74 changes: 74 additions & 0 deletions components/illustrations/cursor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
'use client';

import { AnimatePresence, motion } from 'framer-motion';
import { CSSProperties } from 'react';
import CursorPointerIcon from './cursor-pointer-icon';

export type CursorDirection = 'left-top' | 'right-top';

export interface CursorProps {
className?: string;
style?: CSSProperties;
label: string;
color?: string;
direction?: CursorDirection;
}

const DEFAULT_COLOR = '#8427E0';

// Per direction: which corner of the bubble the arrow's tip sits in.
// `scaleX`/`scaleY` flip the arrow to point into that corner, `flexDirection`
// keeps the pill flowing away from the tip, and `anchor` is a percentage-based
// translate (relative to the bubble's own rendered size, so it holds even as
// the label text changes width) that pulls that corner back onto the origin.
const DIRECTION_CONFIG: Record<
CursorDirection,
{ scaleX: number; scaleY: number; flexDirection: CSSProperties['flexDirection']; anchor: string, pointerX: number, pointerY: number }
> = {
'left-top': { scaleX: 1, scaleY: 1, flexDirection: 'row', anchor: 'translate(0%, 0%)', pointerX: 10, pointerY: -20 },
'right-top': { scaleX: -1, scaleY: 1, flexDirection: 'row-reverse', anchor: 'translate(-100%, 0%)', pointerX: 10, pointerY: -20 },
};

// The arrow's tip is the anchor point: position/move this component by
// translating the root (e.g. `style={{ transform: 'translate3d(x, y, 0)' }}`),
// and pick `direction` for which corner of the bubble that tip sits in.
function Cursor({ className, style, label, color = DEFAULT_COLOR, direction = 'left-top' }: CursorProps) {
const { scaleX, scaleY, flexDirection, anchor, pointerX, pointerY } = DIRECTION_CONFIG[direction];

return (
<div
className={`pointer-events-none absolute left-0 top-0 ${className ?? ''}`}
style={style}
>
<div
className={'flex items-center gap-2'}
style={{ flexDirection, transform: anchor }}
>
<CursorPointerIcon
color={color}
className={'shrink-0 drop-shadow-[0_2px_4px_rgba(0,0,0,0.25)]'}
style={{ transform: `scale(${scaleX}, ${scaleY}) translate(${pointerX}px, ${pointerY}px)` }}
/>
<AnimatePresence
mode={'popLayout'}
initial={false}
>
<motion.span
key={label}
layout
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className={'whitespace-nowrap rounded-full px-4 py-2 text-sm font-medium text-white shadow-sm'}
style={{ backgroundColor: color }}
>
{label}
</motion.span>
</AnimatePresence>
</div>
</div>
);
}

export default Cursor;
Loading
Loading