Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
bfefa2a
Let scene cards take a form, an outline and a colour
milanb17 Nov 3, 2026
e65f031
Draw a frame's remaining time along its step underline while playing
milanb17 Nov 3, 2026
49ea4bf
Keep an animation's height steady as the reader steps through frames
milanb17 Nov 3, 2026
d065b9b
Scale an expanded animation up to the room it is given
milanb17 Nov 3, 2026
0f9ae42
Size a scene's cards to what they say
milanb17 Nov 3, 2026
a94a074
Draw scene edges level and straight, and let a frame's motion play
milanb17 Nov 3, 2026
a33dc74
Let a scene group its cards, mark what a review changed, and explain …
milanb17 Nov 3, 2026
e21ce52
Centre a store's label in its drum, and show a card's dot only as a s…
milanb17 Nov 3, 2026
ceabb4d
Add a video platform architecture to the reference animations
milanb17 Nov 3, 2026
4ff9621
Bow an edge no further than the card it clears, and hide other frames…
milanb17 Nov 3, 2026
77b6903
Give a grouped scene room between its layers
milanb17 Nov 3, 2026
d34d3cc
Keep a fullscreen stage at its inline width, scaling down as well as up
milanb17 Nov 3, 2026
c80bc87
Draw the legend glyph with a transform, not zoom
milanb17 Nov 3, 2026
ffc9f9b
Weight the legend glyph's lines for its scale
milanb17 Nov 3, 2026
f625eed
Keep chips and edges above the cards
milanb17 Nov 3, 2026
7b50cd8
Let a chip wait at its head until the next frame
milanb17 Nov 3, 2026
e26a4ac
Lay the video platform reference out as four layers, top to bottom
milanb17 Nov 3, 2026
c8ef4c7
Centre a scene row under a narrower one with half-step columns
milanb17 Nov 3, 2026
fe38c4f
Start every chip's ride over when the frame timeline is rebuilt
milanb17 Nov 3, 2026
beabc35
Park a frame's chips at their origins when it is drawn without motion
milanb17 Nov 3, 2026
2a55e2d
Rest a frame's message as the label of its edge
milanb17 Nov 3, 2026
6566964
Give the hand-made references their parts' silhouettes
milanb17 Nov 3, 2026
a1cb76d
Wait for the measuring copy before holding a scene to its height
milanb17 Nov 4, 2026
e2386bb
Fold a scene layer by layer and keep its groups as bands
milanb17 Nov 4, 2026
f819d07
Keep a scene's columns at a minimum width and scroll the block sideways
milanb17 Nov 4, 2026
60259eb
Measure the scaled canvas above the stage's sheet in the expand test
milanb17 Nov 4, 2026
8418461
Keep the step strip on one row and scroll it to the step showing
milanb17 Nov 4, 2026
ecfe124
Scroll any stage sideways past its content, keep titles to a line, cl…
milanb17 Nov 4, 2026
8f207e4
Hold a group's title clear of the cards below it
milanb17 Nov 4, 2026
a78cc96
Merge remote-tracking branch 'origin/mb/animations-component-redesign…
milanb17 Nov 4, 2026
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
458 changes: 387 additions & 71 deletions packages/review/app/src/animation/animation-block.tsx

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion packages/review/app/src/animation/clip-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -190,7 +190,7 @@ export function useClipPlayer(
>
{playing ? pauseIcon : playIcon}
</ControlButton>
<StopCaption stop={stops[activeIndex]} />
<StopCaption stops={stops} activeIndex={activeIndex} />
</>
),
};
Expand Down
149 changes: 138 additions & 11 deletions packages/review/app/src/animation/frame-player.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,10 @@ import {
StopCaption,
pauseIcon,
playIcon,
timerState,
timerVars,
} from "./stage-controls";
import type { StagePlayer, StageSend } from "./stage-player";
import type { FrameTimer, StagePlayer, StageSend } from "./stage-player";
import type { StageMessage } from "./stage-protocol";

const DEFAULT_DURATION = 3000;
Expand All @@ -40,6 +42,13 @@ export function useFramePlayer(
const [playing, setPlaying] = useState(false);
const [settled, setSettled] = useState(-1);
const [timeUp, setTimeUp] = useState(-1);
// The frame's clock, as the strip and the dots draw it, and how far a
// pause left it so play resumes from there.
const [timer, setTimer] = useState<FrameTimer | null>(null);
const heldRef = useRef({ index: -1, fraction: 0 });
// Whether the frame showing was drawn without its motion: a new stage's
// first frame, or a jump. Play shows such a frame again, with it.
const stillRef = useRef(true);
const indexRef = useRef(index);
indexRef.current = index;

Expand Down Expand Up @@ -81,6 +90,7 @@ export function useFramePlayer(

if (next < 0 || next === indexRef.current) return;
setIndex(next);
stillRef.current = instant;
send({
type: "frame",
index: next,
Expand Down Expand Up @@ -124,6 +134,7 @@ export function useFramePlayer(
const next = keys.includes(currentKey) ? indexRef.current : fallback;

if (next !== indexRef.current) setIndex(next);
stillRef.current = true;

// Refresh even a preserved key: lifecycle ranges, held scene changes,
// and its timeline can differ when active order changes.
Expand Down Expand Up @@ -173,6 +184,7 @@ export function useFramePlayer(

sequenceRef.current = sequence;
setSequenceKeys(sequence);
stillRef.current = true;

if (sequence && !sequence.includes(frames[indexRef.current]!.key)) {
indexRef.current = frames.findIndex((item) => item.key === sequence[0]);
Expand All @@ -195,18 +207,61 @@ export function useFramePlayer(
};
};

// Play: hold each active-sequence frame for its duration and until its motion ends.
// Play: hold each active-sequence frame for its duration and until its
// motion ends. The clock runs from where a pause left this frame, and the
// cleanup notes how far it got.
useEffect(() => {
if (!playing) return;

const timer = setTimeout(
() => setTimeUp(index),
frame.duration ?? DEFAULT_DURATION,
);
const duration = frame.duration ?? DEFAULT_DURATION;
const held = heldRef.current;
const from = held.index === index ? held.fraction : 0;
const remaining = Math.round((1 - from) * duration);
const began = performance.now();
setTimer({ fraction: from, remaining, running: true });
const timeout = setTimeout(() => setTimeUp(index), remaining);

return () => clearTimeout(timer);
return () => {
clearTimeout(timeout);

const ran = Math.min(
1,
(performance.now() - began) / Math.max(1, remaining),
);

heldRef.current = { index, fraction: from + (1 - from) * ran };
};
}, [playing, index, frame.duration]);

// Play on a frame drawn without its motion shows it again with it, so a
// new stage's first frame, and the first frame after Replay, move too.
useEffect(() => {
if (!playing || !stillRef.current) return;
stillRef.current = false;
send({
type: "frame",
index: indexRef.current,
instant: false,
direction: "forward",
adjacent: true,
});
}, [playing, index, send]);

// Paused: the marker holds where it was. A frame reached by hand has no
// clock, and forgets the one it held.
useEffect(() => {
if (playing) return;

const held = heldRef.current;

if (held.index === index)
setTimer({ fraction: held.fraction, remaining: 0, running: false });
else {
heldRef.current = { index: -1, fraction: 0 };
setTimer(null);
}
}, [playing, index]);

useEffect(() => {
if (!playing || timeUp !== index || settled !== index) return;

Expand Down Expand Up @@ -245,6 +300,7 @@ export function useFramePlayer(
strip: steps && {
label: "Steps",
items: steps.map((item) => ({ key: item.key, text: item.label! })),
timer,
},
onKeyDown(event: KeyboardEvent) {
if (event.key === "ArrowRight") go(activeIndex + 1, false);
Expand All @@ -267,7 +323,11 @@ export function useFramePlayer(
onClick={() => {
if (playing) return setPlaying(false);

if (activeIndex >= last) go(0, true);
// Replay starts the first frame's clock over.
if (activeIndex >= last) {
heldRef.current = { index: -1, fraction: 0 };
go(0, true);
}

setPlaying(true);
}}
Expand All @@ -281,14 +341,15 @@ export function useFramePlayer(
>
<path d="M9.5 6.5 15 12l-5.5 5.5" />
</ControlButton>
<StopCaption stop={frame} />
<StopCaption stops={activeFrames} activeIndex={activeIndex} />
{!steps && (
<div {...stylex.props(styles.dots)} role="group" aria-label="Frames">
{activeFrames.map((item, position) => (
<FrameDot
key={item.key}
title={item.title}
current={position === activeIndex}
timer={position === activeIndex ? timer : null}
onClick={() => pick(position)}
/>
))}
Expand All @@ -299,36 +360,71 @@ export function useFramePlayer(
};
}

/** One frame of a strip without labels. The current dot carries the clock
* while playing: a ring swept clockwise over a wash, whole when time is up.
* At rest, and while paused, it is the filled dot it always was. */
function FrameDot({
title,
current,
timer,
onClick,
}: {
title: string;
current: boolean;
timer: FrameTimer | null;
onClick(): void;
}) {
const tooltip = useTooltip(title, { instant: true });
const sweeping = timer?.running === true;

return (
<button
ref={tooltip}
type="button"
aria-label={title}
aria-current={current ? "step" : undefined}
data-frame-timer={timer ? timerState(timer) : undefined}
onClick={onClick}
{...stylex.props(styles.dot, current && styles.dotCurrent)}
/>
{...stylex.props(
styles.dot,
current && styles.dotCurrent,
sweeping && styles.dotSweeping,
)}
style={sweeping ? timerVars(timer) : undefined}
>
{sweeping && (
<svg
aria-hidden="true"
focusable="false"
viewBox="0 0 8 8"
{...stylex.props(styles.sweep)}
>
<circle
cx="4"
cy="4"
r="3.5"
pathLength={1}
{...stylex.props(styles.sweepRing)}
/>
</svg>
)}
</button>
);
}

// The ring drawn clockwise from twelve as the frame's time runs.
const sweep = stylex.keyframes({
to: { strokeDashoffset: 0 },
});

const styles = stylex.create({
dots: {
display: "flex",
flex: "0 0 auto",
gap: space.s6,
},
dot: {
position: "relative",
width: "8px",
height: "8px",
padding: 0,
Expand All @@ -345,4 +441,35 @@ const styles = stylex.create({
borderColor: tokens.accent,
backgroundColor: tokens.accent,
},
// While the clock runs, the dot is a wash ring the marker sweeps around.
dotSweeping: {
borderColor: {
default: tokens.accentStripe,
[stylex.env.reducedMotion]: tokens.accent,
},
backgroundColor: {
default: tokens.transparent,
[stylex.env.reducedMotion]: tokens.accent,
},
},
sweep: {
position: "absolute",
inset: "-1px",
display: { default: "block", [stylex.env.reducedMotion]: "none" },
width: "8px",
height: "8px",
overflow: "visible",
transform: "rotate(-90deg)",
},
sweepRing: {
fill: "none",
stroke: tokens.accent,
strokeWidth: 1.4,
strokeDasharray: 1,
strokeDashoffset: tokens.frameTimerRest,
animationName: sweep,
animationDuration: tokens.frameTimerMs,
animationTimingFunction: "linear",
animationFillMode: "forwards",
},
});
8 changes: 5 additions & 3 deletions packages/review/app/src/animation/references.browser.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
// jumps to each marker of every scenario, then plays to its end. Set
// VITE_ANIMATION_SHOTS=1 to save a screenshot of each frame under
// __screenshots__/references/<name>/<path>/ beside this file (gitignored),
// the way an author looks at their work without opening Desktop:
// the way an author looks at their work without opening Desktop, and
// VITE_ANIMATION_WIDTH=<px> to see them folded narrow:
//
// VITE_ANIMATION_SHOTS=1 pnpm --filter @dev.fast/review-canvas exec \
// vitest run --config vitest.config.ts --project browser \
Expand Down Expand Up @@ -163,8 +164,9 @@ async function mount(reference: Reference) {
};

const container = document.createElement("div");
// The document's width in Desktop, so layouts fold as they would there.
container.style.cssText = "width: 860px; margin: 0 auto;";
// The document's width in Desktop, so layouts fold as they would there;
// VITE_ANIMATION_WIDTH shows a narrower one.
container.style.cssText = `width: ${import.meta.env.VITE_ANIMATION_WIDTH || 860}px; margin: 0 auto;`;
document.body.append(container);

await act(async () => {
Expand Down
Loading
Loading