GitHub pull request
A pull request whose checks resolve one at a time and unlock the merge.
Customize
import {Scene, Video} from "odori";
import {GithubPr} from "../components/github-pr/github-pr";
<Video>
<Scene id="github-pr" duration="7s">
<GithubPr
title="Add agent and product surfaces"
number={128}
author="johndoe"
branch="feat/agent-surfaces"
base="main"
theme="light"
/>
</Scene>
</Video>Installation
pnpm odori add component github-prnpx odori add component github-prbunx odori add component github-prEdit the source in videos/components/github-pr/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/github-pr/github-pr.tsx
import {Easing, Fill, interpolate, spring, useBrand, useDesignScale, useFrame, useVideo} from "odori";
export type GithubCheck = {name: string; detail?: string};
export type GithubPrProps = {
/** The pull request title. */
title: string;
/** The number beside it. */
number?: number;
/** Who opened it. */
author?: string;
/** The branch it merges from. */
branch?: string;
/** The branch it merges into. */
base?: string;
/** Commits, as the byline states them. */
commits?: number;
/** Files and lines, as the summary under the byline states them. */
stats?: {files: number; additions: number; deletions: number};
/** Checks that resolve one after another, then unlock the merge. */
checks?: GithubCheck[];
theme?: "light" | "dimmed";
};
const PALETTE = {
light: {
page: "#FFFFFF",
card: "#F6F8FA",
edge: "#D1D9E0",
ink: "#1F2328",
faint: "#59636E",
open: "#1F883D",
chip: "#DDF4FF",
chipInk: "#0969DA",
},
/* Named for what it is: these are GitHub's dark-dimmed values, not its
default dark, and calling it "dark" set the wrong expectation. */
dimmed: {
page: "#212830",
card: "#262C36",
edge: "#3D444D",
ink: "#D1D7E0",
faint: "#9198A1",
open: "#347D39",
chip: "#143D79",
chipInk: "#478BE6",
},
};
const FONT = '"Mona Sans VF", "Mona Sans", -apple-system, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif';
const GREEN = "#1A7F37";
const SUCCESS = "#3FB950";
const CHECK_START = 34;
const CHECK_STEP = 14;
/**
* GitHub's default avatar is an identicon, not a letter.
*
* A five by five grid, mirrored down the middle, inset by a margin the way
* GitHub insets it, with the filled cells and the colour both derived from a
* hash of the login. GitHub keys it off the account id; the login stands in
* here, and the only property that matters is the one GitHub relies on: the
* same name always draws the same mark.
*/
const identicon = (name: string, size: number, px: (value: number) => number) => {
/* FNV-1a, then a mix, because a plain multiply-and-add leaves the low bits
barely changed between similar logins and every mark comes out alike. */
let hash = 0x811c9dc5;
for (const character of name) {
hash = (hash ^ character.codePointAt(0)!) >>> 0;
hash = Math.imul(hash, 0x01000193) >>> 0;
}
hash = (hash ^ (hash >>> 15)) >>> 0;
hash = Math.imul(hash, 0x2545f491) >>> 0;
hash = (hash ^ (hash >>> 13)) >>> 0;
const ink = `hsl(${hash % 360} 58% 50%)`;
const margin = size / 12;
const cell = (size - margin * 2) / 5;
const cells = [];
for (let column = 0; column < 3; column += 1) {
for (let row = 0; row < 5; row += 1) {
/* One bit per cell, taken from a different part of the word each time
so the three columns do not repeat one another. */
const bit = (Math.imul(hash, column * 5 + row + 1) >>> ((column * 5 + row) % 13)) & 1;
if (!bit) continue;
for (const x of column === 2 ? [2] : [column, 4 - column]) {
cells.push(
<rect
key={`${x}-${row}`}
x={margin + x * cell}
y={margin + row * cell}
width={cell}
height={cell}
fill={ink}
/>,
);
}
}
}
return (
<span style={{borderRadius: px(999), flex: "none", lineHeight: 0, overflow: "hidden"}}>
<svg width={px(size)} height={px(size)} viewBox={`0 0 ${size} ${size}`}>
<rect width={size} height={size} fill="#F0F0F0" />
{cells}
</svg>
</span>
);
};
/**
* A pull request with its checks going green and the merge button unlocking.
*
* Checks resolve in sequence rather than together, and the merge button only
* fills once the last one lands. That order is the whole story: a video that
* greens everything at once shows a result, and a video that greens them one
* at a time shows a system working.
*
* Set at the zoom a screen capture would be shown at, not at the browser's
* own point sizes: text that is comfortable to read on a laptop is a caption
* strip in a video frame, and this is a frame first.
*/
export const GithubPr = ({
title,
number = 128,
author = "johndoe",
branch = "feat/agent-surfaces",
base = "main",
commits = 3,
stats = {files: 6, additions: 214, deletions: 38},
checks = [
{name: "typecheck", detail: "12s"},
{name: "test", detail: "48s"},
{name: "build", detail: "1m 04s"},
],
theme = "light",
}: GithubPrProps) => {
const frame = useFrame();
const {fps} = useVideo();
const brand = useBrand();
const scale = useDesignScale();
const colors = PALETTE[theme];
const px = (value: number) => value * scale;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const settled = CHECK_START + Math.max(0, checks.length - 1) * CHECK_STEP + 12;
const merge = spring({frame, fps, delayInFrames: settled, stiffness: 160, damping: 16});
/* Springs overshoot, and a color-mix percentage past 100 invalidates the
whole declaration; the fill uses the clamped value, the motion the raw. */
const fill = Math.min(1, Math.max(0, merge));
const allDone = frame >= settled;
const chip = (name: string) => (
<span
style={{
background: colors.chip,
borderRadius: px(7),
color: colors.chipInk,
fontFamily: brand.typography.mono,
fontSize: px(24),
padding: `${px(3)}px ${px(10)}px`,
}}
>
{name}
</span>
);
/** The status octicon: a spinner that hands over to a filled green check. */
const status = (done: number) => (
<span
style={{
alignItems: "center",
display: "inline-flex",
height: px(36),
justifyContent: "center",
position: "relative",
width: px(36),
}}
>
<span
style={{
border: `${px(3.5)}px solid #D4A72C`,
borderRadius: px(999),
borderTopColor: "transparent",
height: px(26),
opacity: 1 - done,
position: "absolute",
transform: `rotate(${frame * 12}deg)`,
width: px(26),
}}
/>
<svg
viewBox="0 0 24 24"
width={px(32)}
height={px(32)}
style={{opacity: done, position: "absolute", transform: `scale(${0.6 + done * 0.4})`}}
>
<circle cx="12" cy="12" r="10" fill={SUCCESS} />
<path
d="M7.5 12.2l3 3 6-6.4"
fill="none"
stroke={theme === "light" ? "#FFFFFF" : "#0D1117"}
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2.6}
/>
</svg>
</span>
);
return (
<Fill style={{alignItems: "center", background: colors.page, justifyContent: "center", padding: `${px(70)}px ${px(120)}px`}}>
<div
style={{
fontFamily: FONT,
maxWidth: px(1460),
opacity: enter,
transform: `translateY(${(1 - enter) * px(16)}px)`,
width: "100%",
}}
>
{/* The title is regular weight with the number in grey, which is how
GitHub actually sets it; bolding it reads as a mock. */}
<div style={{color: colors.ink, fontSize: px(58), fontWeight: 400, letterSpacing: "-0.01em", lineHeight: 1.2}}>
{title} <span style={{color: colors.faint}}>#{number}</span>
</div>
<div style={{alignItems: "center", display: "flex", flexWrap: "wrap", gap: px(14), marginTop: px(22)}}>
<span
style={{
alignItems: "center",
background: colors.open,
borderRadius: px(999),
color: "#FFFFFF",
display: "inline-flex",
fontSize: px(26),
fontWeight: 600,
gap: px(9),
padding: `${px(9)}px ${px(11)}px ${px(9)}px ${px(11)}px`,
}}
>
<svg viewBox="0 0 16 16" width={px(26)} height={px(26)} fill="#FFFFFF" aria-hidden>
<path d="M1.5 3.25a2.25 2.25 0 1 1 3 2.122v5.256a2.251 2.251 0 1 1-1.5 0V5.372A2.25 2.25 0 0 1 1.5 3.25Zm5.677-.177L9.573.677A.25.25 0 0 1 10 .854V2.5h1A2.5 2.5 0 0 1 13.5 5v5.628a2.251 2.251 0 1 1-1.5 0V5a1 1 0 0 0-1-1h-1v1.646a.25.25 0 0 1-.427.177L7.177 3.427a.25.25 0 0 1 0-.354Z" />
</svg>
Open
</span>
{/* GitHub puts the author's picture in the byline, and for an
account without one that picture is an identicon. */}
{identicon(author, 30, px)}
<span style={{color: colors.faint, fontSize: px(27)}}>
<span style={{color: colors.ink, fontWeight: 600}}>{author}</span> wants to merge {commits} commit
{commits === 1 ? "" : "s"} into {chip(base)} from {chip(branch)}
</span>
</div>
<div style={{color: colors.faint, display: "flex", fontSize: px(24), gap: px(20), marginTop: px(18)}}>
<span>
<span style={{color: colors.ink, fontWeight: 600}}>{stats.files}</span> files changed
</span>
<span style={{color: SUCCESS, fontWeight: 600}}>+{stats.additions}</span>
<span style={{color: "#F85149", fontWeight: 600}}>-{stats.deletions}</span>
</div>
{/* The merge box, as the bottom of the conversation draws it. */}
<div
style={{
border: `${px(1)}px solid ${colors.edge}`,
borderRadius: px(10),
marginTop: px(36),
overflow: "hidden",
}}
>
<div
style={{
alignItems: "center",
background: colors.card,
borderBottom: `${px(1)}px solid ${colors.edge}`,
display: "flex",
gap: px(16),
padding: `${px(22)}px ${px(26)}px`,
}}
>
{status(merge)}
<div>
<div style={{color: colors.ink, fontSize: px(28), fontWeight: 600}}>
{allDone ? "All checks have passed" : "Some checks haven’t completed yet"}
</div>
<div style={{color: colors.faint, fontSize: px(22), marginTop: px(2)}}>
{checks.length} check{checks.length === 1 ? "" : "s"}
{allDone ? " successful" : " running"}
</div>
</div>
</div>
<div style={{display: "grid"}}>
{checks.map((check, index) => {
// Each check resolves on its own beat, and the tick springs in
// where the spinner was.
const done = spring({frame, fps, delayInFrames: CHECK_START + index * CHECK_STEP, stiffness: 200, damping: 15});
const finished = frame >= CHECK_START + index * CHECK_STEP;
return (
<div
key={check.name}
style={{
alignItems: "center",
borderTop: index === 0 ? "none" : `${px(1)}px solid ${colors.edge}`,
display: "flex",
gap: px(16),
padding: `${px(17)}px ${px(26)}px`,
}}
>
{status(done)}
<span style={{color: colors.ink, fontSize: px(26), fontWeight: 600}}>{check.name}</span>
<span style={{color: colors.faint, fontSize: px(24)}}>
{finished ? `Successful${check.detail ? ` in ${check.detail}` : ""}` : "In progress…"}
</span>
<span style={{color: colors.faint, fontSize: px(24), marginLeft: "auto"}}>Details</span>
</div>
);
})}
</div>
<div
style={{
alignItems: "center",
background: colors.card,
borderTop: `${px(1)}px solid ${colors.edge}`,
display: "flex",
gap: px(20),
padding: `${px(22)}px ${px(26)}px`,
}}
>
{/* GitHub's split button: the action, a seam, then the caret. It
fills from disabled grey to its green the moment the last
check lands, which is the frame the cut is waiting for. */}
<span
style={{
background: `color-mix(in srgb, ${GREEN} ${fill * 100}%, ${colors.card})`,
border: `${px(1)}px solid color-mix(in srgb, rgb(31 35 40 / 0.15) ${fill * 100}%, ${colors.edge})`,
borderRadius: px(8),
color: `color-mix(in srgb, #FFFFFF ${fill * 100}%, ${colors.faint})`,
display: "inline-flex",
fontSize: px(26),
fontWeight: 600,
overflow: "hidden",
}}
>
<span style={{padding: `${px(13)}px ${px(24)}px`}}>Merge pull request</span>
<span
style={{
alignItems: "center",
borderLeft: `${px(1)}px solid color-mix(in srgb, rgb(255 255 255 / 0.4) ${fill * 100}%, ${colors.edge})`,
display: "inline-flex",
padding: `0 ${px(14)}px`,
}}
>
<svg viewBox="0 0 16 16" width={px(20)} height={px(20)} fill="currentColor" aria-hidden>
<path d="M4.25 6l3.75 3.75L11.75 6" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
</span>
<span style={{color: colors.faint, fontSize: px(23), opacity: fill}}>
Merging can be performed automatically.
</span>
</div>
</div>
</div>
</Fill>
);
};
videos/components/github-pr/github-pr.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {GithubPr} from "./github-pr";
export default defineComponentPreview({
title: "GitHub pull request",
category: "Products",
description: "A pull request whose checks resolve one at a time and unlock the merge.",
component: GithubPr,
canvas: {width: 1920, height: 1080, duration: "7s"},
controls: {
title: {type: "text", defaultValue: "Add agent and product surfaces", maxLength: 72},
number: {type: "number", defaultValue: 128, min: 1, max: 99999, step: 1},
author: {type: "text", defaultValue: "johndoe", maxLength: 28},
branch: {type: "text", defaultValue: "feat/agent-surfaces", maxLength: 40},
base: {type: "text", defaultValue: "main", maxLength: 24},
theme: {type: "select", defaultValue: "light", options: ["light", "dimmed"]},
},
examples: [
{name: "Default", props: {}},
{name: "Dimmed", props: {theme: "dimmed"}},
{
name: "One check",
props: {title: "Pin the render toolchain", checks: [{name: "e2e", detail: "3m 12s"}]},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {GithubPr} from "../components/github-pr/github-pr";
<Video>
<Scene id="github-pr" duration="7s">
<GithubPr
title="Add agent and product surfaces"
number={128}
author="johndoe"
branch="feat/agent-surfaces"
base="main"
theme="light"
/>
</Scene>
</Video>Timing contract
Use these durations and content limits when composing a scene. Run odori test to check your video.
- Family
- Products
- Recommended duration
- 210 frames · 7s
- Minimum duration
- 120 frames · 4s
- Entrance
- 16 frames
- Exit
- 12 frames
- Reduced motion
- checks resolved from the first frame, no spinner
- Requires
- sans font, mono font
- Content limits
- title ≤ 72, branch ≤ 40, base ≤ 24, author ≤ 28
Props
| Prop | Type | Default | Required |
|---|---|---|---|
titleThe pull request title. | string | — | Yes |
numberThe number beside it. | number | 128 | — |
authorWho opened it. | string | "johndoe" | — |
branchThe branch it merges from. | string | "feat/agent-surfaces" | — |
baseThe branch it merges into. | string | "main" | — |
commitsCommits, as the byline states them. | number | 3 | — |
statsFiles and lines, as the summary under the byline states them. | {files: number; additions: number; deletions: number} | {files: 6 | — |
checksChecks that resolve one after another, then unlock the merge. | GithubCheck[] | — | — |
theme | "light" | "dimmed" | "light" | — |
Related components
All componentsDiscordA Discord server where a bot answers in a channel, quoting the message it replies to.Dock barA macOS-style dock where one app launches with a bounce.GitHub commentAn agent response on a GitHub pull request.iMessageMessages with the conversation list beside the thread, the reply growing from the typing bubble.LinearA Linear workspace where an agent comments on an issue and moves it to done.SlackA Slack channel where a question is answered by an app in its thread.