Linear
A Linear workspace where an agent comments on an issue and moves it to done.
Customize
import {Scene, Video} from "odori";
import {Linear} from "../components/linear/linear";
<Video>
<Scene id="linear" duration="9s">
<Linear
workspace="Odori"
team="Engineering"
id="ENG-128"
title="Export size differs between identical commits"
status="In Progress"
movesTo="Done"
thinkingFrames={30}
theme="light"
charactersPerSecond={34}
/>
</Scene>
</Video>Installation
pnpm odori add component linearnpx odori add component linearbunx odori add component linearEdit the source in videos/components/linear/. Use odori diff to compare upstream changes and odori update to apply updates.
Copy these files into your project
videos/components/linear/linear.tsx
import {Easing, Fill, interpolate, spring, typingFrames, useDesignScale, useFrame, useTyping, useVideo} from "odori";
import type {ReactNode} from "react";
export type LinearProps = {
/** The workspace name, beside its mark at the top of the sidebar. */
workspace?: string;
/** The team whose section the issue belongs to. */
team?: string;
/** The issue identifier. */
id?: string;
title?: string;
/** The issue body, above the activity. */
body?: string;
/** The status it starts in, and the one the agent moves it to. */
status?: string;
movesTo?: string;
priority?: string;
labels?: string[];
assignee?: string;
/** What a person asks in the comment box, typed in and posted. */
request?: {author: string; body: string};
/** The agent's comment, which arrives whole after a working indicator. */
agent?: {name: string; body: string};
thinkingFrames?: number;
theme?: "light" | "dark";
charactersPerSecond?: number;
};
/**
* Linear's palette, read off the running app.
*
* Linear authors its colours in LCH and steps them by lightness rather than
* by name, so these are that ladder resolved to sRGB: four grounds, four
* inks, two borders. Guessing them produced a workspace a shade too dark
* and a shade too contrasty at every level at once.
*/
const THEMES = {
light: {
app: "#F9F9FA",
pane: "#FFFFFF",
hover: "#EBEBEC",
sunken: "#EFEFF0",
ink: "#1B1B1B",
second: "#2F2F31",
third: "#5C5C5E",
fourth: "#9C9D9F",
edge: "#F1F1F1",
edgeStrong: "#DEDEDE",
},
dark: {
app: "#121213",
pane: "#161617",
hover: "#1F1F21",
sunken: "#1A1A1B",
ink: "#F7F8F8",
second: "#E3E4E6",
third: "#9C9D9F",
fourth: "#6F7071",
edge: "#1F1F21",
edgeStrong: "#2A2A2C",
},
};
const FONT = '"Inter Variable", Inter, "SF Pro Display", -apple-system, "Segoe UI", Roboto, sans-serif';
const ACCENT = "#5E6AD2";
const PROGRESS = "#E2A336";
const DONE = "#5E6AD2";
const MARK = "#D6247F";
const AVATAR = "#0FA891";
/**
* A Linear workspace with an agent working an issue and closing it.
*
* The whole app is drawn because an issue tracker is a place, not a card: the
* sidebar with its inbox and its teams, the issue in the middle, and the
* properties rail on the right where status actually lives. That rail is why
* the shot works. The comment is what the agent wrote, the status chip is what
* it changed, and they sit far enough apart that the eye reads the sentence
* first and catches the state change second.
*/
export const Linear = ({
workspace = "Odori",
team = "Engineering",
id = "ENG-128",
title = "Export size differs between identical commits",
body = "Two runs of the same commit produced files 40MB apart. Suspect the encoder defaults differ between machines.",
status = "In Progress",
movesTo = "Done",
priority = "Urgent",
labels = ["render", "bug"],
assignee = "johndoe",
request = {author: "John Doe", body: "Can you work out why and pin whatever is drifting?"},
agent = {
name: "Odori",
body: "Pinned both binaries and added a regression test. The two runs are byte identical now.",
},
thinkingFrames = 30,
theme = "light",
charactersPerSecond = 34,
}: LinearProps) => {
const frame = useFrame();
const {fps} = useVideo();
const scale = useDesignScale();
const colors = THEMES[theme];
const px = (value: number) => value * scale;
/* The typing is the person's: they write the ask into the comment box and
post it, and the agent's comment arrives whole. */
const typeFrom = 14;
const askAt = typeFrom + typingFrames(request.body, {charactersPerSecond}) + 10;
const workingAt = askAt + 16;
const commentAt = workingAt + thinkingFrames;
const enter = interpolate(frame, [0, 14], [0, 1], {easing: Easing.standard});
const typed = useTyping(request.body, {from: typeFrom, charactersPerSecond, chunk: 2});
const asked = spring({frame, fps, delayInFrames: askAt, stiffness: 150, damping: 16});
const posted = spring({frame, fps, delayInFrames: commentAt, stiffness: 150, damping: 16});
const sent = frame >= askAt;
const landed = frame >= commentAt;
const working = frame >= workingAt && frame < commentAt;
const moved = interpolate(landed ? frame : 0, [0, 14], [0, 1], {easing: Easing.standard});
const done = moved > 0.5;
/** The round member mark Linear draws for a person with no picture. */
const person = (name: string, size: number) => (
<span
style={{
alignItems: "center",
background: AVATAR,
borderRadius: px(999),
color: "#FFFFFF",
display: "flex",
flex: "none",
fontSize: px(size * 0.42),
fontWeight: 500,
height: px(size),
justifyContent: "center",
width: px(size),
}}
>
{name.slice(0, 2).toUpperCase()}
</span>
);
/** A sidebar row: an icon slot, then the label, at the row height Linear uses. */
const row = (label: string, icon: ReactNode, options: {active?: boolean; indent?: number; dot?: boolean} = {}) => (
<div
key={label}
style={{
alignItems: "center",
background: options.active ? colors.hover : "transparent",
borderRadius: px(10),
color: options.active ? colors.ink : colors.second,
display: "flex",
fontSize: px(24),
gap: px(12),
height: px(42),
margin: `${px(1)}px ${px(12)}px`,
padding: `0 ${px(12)}px 0 ${px(12 + (options.indent ?? 0))}px`,
}}
>
<span style={{alignItems: "center", color: colors.third, display: "flex", flex: "none", height: px(24), width: px(24)}}>
{icon}
</span>
{label}
{options.dot ? (
<span style={{background: ACCENT, borderRadius: px(999), height: px(10), marginLeft: "auto", width: px(10)}} />
) : null}
</div>
);
/** A collapsible sidebar heading, which Linear sets small and tertiary. */
const heading = (label: string) => (
<div
key={label}
style={{
alignItems: "center",
color: colors.third,
display: "flex",
fontSize: px(21),
fontWeight: 500,
gap: px(6),
margin: `${px(18)}px ${px(12)}px ${px(2)}px`,
padding: `0 ${px(12)}px`,
}}
>
{label}
<svg viewBox="0 0 20 20" width={px(20)} height={px(20)} fill="currentColor">
<path d="M5.5 8h9L10 13.5z" />
</svg>
</div>
);
const stroke = (d: string, weight = 1.7) => (
<svg viewBox="0 0 20 20" width={px(24)} height={px(24)} fill="none" stroke="currentColor" strokeWidth={weight} strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
);
/** The dashed ring Linear draws for Backlog, and the arc for In Progress. */
const statusMark = (size: number) =>
done ? (
<svg viewBox="0 0 20 20" width={px(size)} height={px(size)}>
<circle cx="10" cy="10" r="8" fill={DONE} />
<path d="M6.4 10.2l2.5 2.5 4.8-5.1" fill="none" stroke="#FFFFFF" strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.2} />
</svg>
) : (
<svg viewBox="0 0 20 20" width={px(size)} height={px(size)}>
<circle cx="10" cy="10" r="7" fill="none" stroke={PROGRESS} strokeWidth={2} />
<path d="M10 10V4a6 6 0 0 1 6 6z" fill={PROGRESS} />
</svg>
);
const property = (label: ReactNode) => (
<div style={{alignItems: "center", color: colors.second, display: "flex", fontSize: px(24), gap: px(10), padding: `${px(7)}px 0`}}>
{label}
</div>
);
const railHeading = (label: string) => (
<div style={{color: colors.third, fontSize: px(22), fontWeight: 500, margin: `${px(26)}px 0 ${px(8)}px`}}>{label}</div>
);
return (
<Fill style={{background: colors.app, padding: px(52)}}>
<div
style={{
background: colors.app,
borderRadius: px(14),
display: "flex",
fontFamily: FONT,
height: "100%",
opacity: enter,
overflow: "hidden",
transform: `translateY(${(1 - enter) * px(12)}px)`,
width: "100%",
}}
>
{/* The sidebar sits on the app ground; the issue floats on white. */}
<div style={{display: "flex", flex: "none", flexDirection: "column", paddingTop: px(14), width: px(330)}}>
<div style={{alignItems: "center", display: "flex", gap: px(10), padding: `0 ${px(20)}px ${px(14)}px`}}>
<span
style={{
alignItems: "center",
background: MARK,
borderRadius: px(8),
color: "#FFFFFF",
display: "flex",
flex: "none",
fontSize: px(15),
fontWeight: 700,
height: px(32),
justifyContent: "center",
width: px(32),
}}
>
{workspace.slice(0, 3).toUpperCase()}
</span>
<span style={{color: colors.ink, fontSize: px(24), fontWeight: 500}}>{workspace}</span>
<svg viewBox="0 0 20 20" width={px(20)} height={px(20)} fill={colors.third} style={{flex: "none"}}>
<path d="M5.5 8h9L10 13.5z" />
</svg>
<span style={{alignItems: "center", color: colors.third, display: "flex", gap: px(14), marginLeft: "auto"}}>
{stroke("M9 3.5a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11M13 13l4 4")}
{stroke("M4 16h12M12.5 4.5l3 3L8 15H5v-3z")}
</span>
</div>
{row("Inbox", stroke("M3 11h4l1.5 2.5h3L13 11h4M3 11l2.5-6h9L17 11v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"))}
{row("My issues", stroke("M10 3.5a6.5 6.5 0 1 0 0 13 6.5 6.5 0 0 0 0-13M10 8a2 2 0 1 0 0 4 2 2 0 0 0 0-4"), {active: true})}
{row("Reviews", stroke("M6 4v8M6 16a2 2 0 1 0 0-4 2 2 0 0 0 0 4M14 4a2 2 0 1 0 0 4 2 2 0 0 0 0-4M14 8v2a4 4 0 0 1-4 4H8"), {dot: true})}
{row("Agent", stroke("M4 4l12 6-12 6z"))}
{heading("Workspace")}
{row("Projects", stroke("M10 3l6.5 3.6v6.8L10 17l-6.5-3.6V6.6zM3.5 6.6L10 10.2l6.5-3.6M10 10.2V17"))}
{row("Views", stroke("M10 3.5l7 3.6-7 3.6-7-3.6zM3 11.4l7 3.6 7-3.6"))}
{row("More", stroke("M5 10h.01M10 10h.01M15 10h.01", 2.6))}
{heading("Your teams")}
<div
style={{
alignItems: "center",
color: colors.ink,
display: "flex",
fontSize: px(24),
gap: px(12),
height: px(42),
margin: `${px(1)}px ${px(12)}px`,
padding: `0 ${px(12)}px`,
}}
>
<span style={{alignItems: "center", color: MARK, display: "flex", flex: "none", height: px(24), width: px(24)}}>
{stroke("M4 4l12 12M16 4L4 16", 2)}
</span>
{team}
<svg viewBox="0 0 20 20" width={px(20)} height={px(20)} fill={colors.third} style={{marginLeft: px(4)}}>
<path d="M5.5 8h9L10 13.5z" />
</svg>
</div>
{row("Home", stroke("M4 9l6-5 6 5v6a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"), {indent: 20})}
{row("Issues", stroke("M5 5h10v10H5zM5 8h10"), {indent: 20})}
{row("Projects", stroke("M10 3l6.5 3.6v6.8L10 17l-6.5-3.6V6.6z"), {indent: 20})}
{row("Views", stroke("M10 3.5l7 3.6-7 3.6-7-3.6z"), {indent: 20})}
{heading("Try")}
{row("Initiatives", stroke("M10 4a6 6 0 0 0-6 6M10 4a6 6 0 0 1 6 6M4 10h12M10 4v12"))}
</div>
{/* The issue, on its own panel. */}
<div
style={{
background: colors.pane,
border: `${px(1)}px solid ${colors.edgeStrong}`,
borderRadius: px(12),
display: "flex",
flex: 1,
flexDirection: "column",
margin: `${px(8)}px ${px(8)}px ${px(8)}px 0`,
minWidth: 0,
overflow: "hidden",
}}
>
<div
style={{
alignItems: "center",
color: colors.second,
display: "flex",
fontSize: px(24),
gap: px(10),
padding: `${px(18)}px ${px(24)}px`,
}}
>
<span style={{color: colors.third}}>My issues</span>
<span style={{color: colors.fourth}}>›</span>
<span style={{color: colors.third}}>{id}</span>
<span style={{color: colors.ink}}>{title.split(" ").slice(0, 4).join(" ")}</span>
<span style={{alignItems: "center", color: colors.fourth, display: "flex", gap: px(14), marginLeft: px(6)}}>
{stroke("M10 3.5l2 4.2 4.5.6-3.3 3.2.8 4.5-4-2.1-4 2.1.8-4.5L3.5 8.3l4.5-.6z")}
{stroke("M5 10h.01M10 10h.01M15 10h.01", 2.6)}
</span>
</div>
<div style={{display: "flex", flex: 1, minHeight: 0}}>
<div style={{flex: 1, minWidth: 0, padding: `${px(18)}px ${px(56)}px`}}>
<div style={{color: colors.ink, fontSize: px(46), fontWeight: 600, letterSpacing: "-0.02em"}}>{title}</div>
<div style={{color: colors.third, fontSize: px(26), lineHeight: 1.6, marginTop: px(16)}}>{body}</div>
<div style={{color: colors.fourth, fontSize: px(24), marginTop: px(22)}}>+ Add sub-issues</div>
<div style={{borderTop: `${px(1)}px solid ${colors.edgeStrong}`, marginTop: px(30), paddingTop: px(24)}}>
<div style={{color: colors.ink, fontSize: px(26), fontWeight: 600}}>Activity</div>
<div style={{alignItems: "center", color: colors.third, display: "flex", fontSize: px(23), gap: px(12), marginTop: px(16)}}>
{person(request.author, 26)}
<span>
<span style={{color: colors.second}}>{request.author}</span> created the issue · just now
</span>
</div>
{sent ? (
<div style={{marginTop: px(20), opacity: asked, transform: `translateY(${(1 - asked) * px(8)}px)`}}>
<div style={{alignItems: "center", display: "flex", gap: px(12)}}>
{person(request.author, 26)}
<span style={{color: colors.ink, fontSize: px(23), fontWeight: 500}}>{request.author}</span>
</div>
<div style={{color: colors.second, fontSize: px(24), lineHeight: 1.6, marginTop: px(8), paddingLeft: px(38)}}>
{request.body}
</div>
</div>
) : null}
{frame >= workingAt ? (
<div
style={{
marginTop: px(20),
opacity: working ? 1 : posted,
transform: `translateY(${(1 - (working ? 1 : posted)) * px(8)}px)`,
}}
>
<div style={{alignItems: "center", display: "flex", gap: px(12)}}>
<span style={{background: ACCENT, borderRadius: px(7), flex: "none", height: px(26), width: px(26)}} />
<span style={{color: colors.ink, fontSize: px(23), fontWeight: 500}}>{agent.name}</span>
<span
style={{
border: `${px(1)}px solid ${colors.edgeStrong}`,
borderRadius: px(999),
color: colors.third,
fontSize: px(17),
padding: `${px(1)}px ${px(9)}px`,
}}
>
agent
</span>
</div>
<div style={{minHeight: px(34), paddingLeft: px(38)}}>
{working ? (
<span style={{alignItems: "center", display: "inline-flex", gap: px(7), height: px(34)}}>
{[0, 1, 2].map((dot) => (
<span
key={dot}
style={{
background: colors.fourth,
borderRadius: px(999),
height: px(9),
opacity: 0.35 + 0.65 * Math.max(0, Math.sin(((frame - dot * 4) / fps) * Math.PI * 2)),
width: px(9),
}}
/>
))}
</span>
) : (
<div style={{color: colors.second, fontSize: px(24), lineHeight: 1.6, marginTop: px(8)}}>{agent.body}</div>
)}
</div>
</div>
) : null}
{landed ? (
<div style={{alignItems: "center", color: colors.third, display: "flex", fontSize: px(23), gap: px(12), marginTop: px(18), opacity: moved}}>
<span style={{background: ACCENT, borderRadius: px(7), flex: "none", height: px(26), width: px(26)}} />
<span>
<span style={{color: colors.second}}>{agent.name}</span> changed status from {status} to {movesTo}
</span>
</div>
) : null}
{/* The comment box, where the ask is written. */}
<div
style={{
border: `${px(1)}px solid ${colors.edgeStrong}`,
borderRadius: px(10),
color: sent ? colors.fourth : colors.ink,
fontSize: px(24),
lineHeight: 1.6,
marginTop: px(24),
minHeight: px(64),
padding: `${px(16)}px ${px(18)}px`,
}}
>
{sent ? (
"Leave a comment..."
) : (
<>
{typed.text}
{typed.caret ? (
<span
style={{
background: colors.ink,
display: "inline-block",
height: px(24),
marginLeft: px(2),
transform: `translateY(${px(4)}px)`,
width: px(2),
}}
/>
) : null}
</>
)}
</div>
</div>
</div>
{/* The properties rail, where status actually lives. */}
<div style={{flex: "none", padding: `${px(18)}px ${px(28)}px`, width: px(400)}}>
<div style={{color: colors.third, fontSize: px(22), fontWeight: 500}}>Properties</div>
{property(
<>
{statusMark(24)}
<span
style={{
color: done ? colors.ink : colors.second,
transform: `scale(${1 + Math.sin(Math.min(1, moved) * Math.PI) * 0.06})`,
}}
>
{done ? movesTo : status}
</span>
</>,
)}
{property(
<>
<span style={{alignItems: "center", color: colors.third, display: "flex", height: px(24), width: px(24)}}>
{stroke("M4 13h2.5M8.75 9.5h2.5M13.5 6h2.5", 2.2)}
</span>
<span>{priority}</span>
</>,
)}
{property(
<>
{person(assignee, 24)}
<span>{assignee}</span>
</>,
)}
{railHeading("Labels")}
<div style={{display: "flex", flexWrap: "wrap", gap: px(8)}}>
{labels.map((label) => (
<span
key={label}
style={{
alignItems: "center",
border: `${px(1)}px solid ${colors.edgeStrong}`,
borderRadius: px(999),
color: colors.second,
display: "inline-flex",
fontSize: px(21),
gap: px(8),
padding: `${px(4)}px ${px(12)}px`,
}}
>
<span style={{background: colors.fourth, borderRadius: px(999), height: px(9), width: px(9)}} />
{label}
</span>
))}
</div>
{railHeading("Project")}
<div style={{alignItems: "center", color: colors.fourth, display: "flex", fontSize: px(24), gap: px(10)}}>
<span style={{alignItems: "center", display: "flex", height: px(24), width: px(24)}}>
{stroke("M10 3l6.5 3.6v6.8L10 17l-6.5-3.6V6.6z")}
</span>
Add to project
</div>
</div>
</div>
</div>
</div>
</Fill>
);
};
videos/components/linear/linear.preview.tsx
import {defineComponentPreview} from "odori/preview";
import {Linear} from "./linear";
export default defineComponentPreview({
title: "Linear",
category: "Products",
description: "A Linear workspace where an agent comments on an issue and moves it to done.",
component: Linear,
canvas: {width: 1920, height: 1080, duration: "9s"},
controls: {
workspace: {type: "text", defaultValue: "Odori", maxLength: 24},
team: {type: "text", defaultValue: "Engineering", maxLength: 24},
id: {type: "text", defaultValue: "ENG-128", maxLength: 16},
title: {type: "text", defaultValue: "Export size differs between identical commits", maxLength: 72},
status: {type: "text", defaultValue: "In Progress", maxLength: 20},
movesTo: {type: "text", defaultValue: "Done", maxLength: 20},
thinkingFrames: {type: "number", defaultValue: 30, min: 0, max: 90, step: 2},
theme: {type: "select", defaultValue: "light", options: ["light", "dark"]},
charactersPerSecond: {type: "number", defaultValue: 34, min: 10, max: 80, step: 1},
},
examples: [
{name: "Closing it", props: {}},
{
name: "Triage",
props: {
id: "ENG-204",
title: "Studio pane unusable beside an editor",
body: "At editor width the inspector takes half the window and the stage keeps the leftovers.",
status: "Triage",
movesTo: "In Progress",
priority: "High",
labels: ["studio"],
agent: {name: "Odori", body: "Reproduced at 380px. The inspector is fixed width; making it draggable fixes it."},
},
},
],
});
Add it to a scene
import {Scene, Video} from "odori";
import {Linear} from "../components/linear/linear";
<Video>
<Scene id="linear" duration="9s">
<Linear
workspace="Odori"
team="Engineering"
id="ENG-128"
title="Export size differs between identical commits"
status="In Progress"
movesTo="Done"
thinkingFrames={30}
theme="light"
charactersPerSecond={34}
/>
</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
- 270 frames · 9s
- Minimum duration
- 150 frames · 5s
- Entrance
- 14 frames
- Exit
- 12 frames
- Reduced motion
- status shown resolved, no chip transition
- Requires
- sans font, mono font
- Content limits
- id ≤ 16, title ≤ 72, status ≤ 20, movesTo ≤ 20, workspace ≤ 24, team ≤ 24
Props
| Prop | Type | Default | Required |
|---|---|---|---|
workspaceThe workspace name, beside its mark at the top of the sidebar. | string | "Odori" | — |
teamThe team whose section the issue belongs to. | string | "Engineering" | — |
idThe issue identifier. | string | "ENG-128" | — |
title | string | "Export size differs between identical commits" | — |
bodyThe issue body, above the activity. | string | "Two runs of the same commit produced files 40MB apart. Suspect the encoder defaults differ between machines." | — |
statusThe status it starts in, and the one the agent moves it to. | string | "In Progress" | — |
movesTo | string | "Done" | — |
priority | string | "Urgent" | — |
labels | string[] | ["render" | — |
assignee | string | "johndoe" | — |
requestWhat a person asks in the comment box, typed in and posted. | {author: string; body: string} | {author: "John Doe" | — |
agentThe agent's comment, which arrives whole after a working indicator. | {name: string; body: string} | — | — |
thinkingFrames | number | 30 | — |
theme | "light" | "dark" | "light" | — |
charactersPerSecond | number | 34 | — |
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.GitHub pull requestA pull request whose checks resolve one at a time and unlock the merge.iMessageMessages with the conversation list beside the thread, the reply growing from the typing bubble.SlackA Slack channel where a question is answered by an app in its thread.