From 2bb434a96596c99297c022bfc598fae5351f462d Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 09:14:39 -0400 Subject: [PATCH 1/2] feat(video): storyboard the v0.22 release --- .changeset/video-titles-land.md | 2 + scripts/launch-video/capture.ts | 160 ++++++++++++++--- scripts/launch-video/compose.mjs | 281 +++++++++++++++++++++++++++--- skills/hunk-launch-video/SKILL.md | 7 +- 4 files changed, 399 insertions(+), 51 deletions(-) create mode 100644 .changeset/video-titles-land.md diff --git a/.changeset/video-titles-land.md b/.changeset/video-titles-land.md new file mode 100644 index 000000000..a845151cc --- /dev/null +++ b/.changeset/video-titles-land.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/scripts/launch-video/capture.ts b/scripts/launch-video/capture.ts index b41171ba6..359934096 100644 --- a/scripts/launch-video/capture.ts +++ b/scripts/launch-video/capture.ts @@ -76,6 +76,25 @@ function runGit(args: string[], cwd: string, env: NodeJS.ProcessEnv = {}) { } } +/** Drag the left mouse button between zero-based terminal cells. */ +async function dragMouse( + session: Awaited>, + startX: number, + startY: number, + endX: number, + endY: number, +) { + session.writeRaw(`\x1b[<0;${startX + 1};${startY + 1}M`); + for (let step = 1; step <= 5; step += 1) { + const x = Math.round(startX + ((endX - startX) * step) / 5); + const y = Math.round(startY + ((endY - startY) * step) / 5); + await sleep(20); + session.writeRaw(`\x1b[<32;${x + 1};${y + 1}M`); + } + session.writeRaw(`\x1b[<0;${endX + 1};${endY + 1}m`); + await session.waitIdle(); +} + async function launchHunk(args: string[], options: { cwd?: string } = {}) { return launchApp({ command: process.execPath, @@ -103,7 +122,8 @@ async function launchHunkShell(cwd: string) { * "before" tree, overlay the "after" tree as the working diff. */ function createDemoRepo() { - const repoDir = makeTempDir("hunk-video-repo-"); + const repoDir = join(makeTempDir("hunk-video-repo-"), "task-review-demo"); + mkdirSync(repoDir); runGit(["init"], repoDir); runGit(["config", "user.name", "Demo"], repoDir); runGit(["config", "user.email", "demo@example.com"], repoDir); @@ -180,6 +200,19 @@ async function captureHistoryScene() { await sleep(600); await snap(session, "history-overview"); + await session.press("f10"); + await session.press("right"); + await session.waitForText(/Graph view/, { timeout: 10_000 }); + await session.click(/Graph view/); + await sleep(500); + await snap(session, "history-graph"); + + await session.press("f10"); + await session.press("right"); + await session.waitForText(/Graph view/, { timeout: 10_000 }); + await session.click(/Graph view/); + await sleep(400); + for (let step = 0; step < 3; step += 1) { await session.press("j"); await sleep(180); @@ -208,7 +241,31 @@ async function captureHistoryScene() { } // --------------------------------------------------------------------------- -// Scene: line-level review — the cursor moves with j/k, `c` comments there. +// Scene: static history output for pipes and explicit non-interactive use. +// --------------------------------------------------------------------------- +async function captureStaticHistoryScene() { + console.log("scene: static-history"); + const repoDir = createHistoryDemoRepo(); + const session = await launchHunkShell(repoDir); + try { + await session.waitForText(/❯/, { timeout: 15_000 }); + await sleep(300); + await typeCommand(session, keyframer, "hunk log --static --max-count 5", { + 8: "static-history-typing-1", + 20: "static-history-typing-2", + }); + await snap(session, "static-history-typed"); + await session.press("enter"); + await session.waitForText(/Make history range-selectable/, { timeout: 60_000 }); + await sleep(500); + await snap(session, "static-history-output"); + } finally { + session.close(); + } +} + +// --------------------------------------------------------------------------- +// Scene: multiline selection becomes a comment and a navigable thread. // --------------------------------------------------------------------------- async function captureReviewScene() { console.log("scene: review"); @@ -218,34 +275,95 @@ async function captureReviewScene() { await session.waitForText(/src\//, { timeout: 60_000 }); await ensureKeyboardIsLive(session, HUNK_KEYBOARD_PROBE); await sleep(500); + await snap(session, "review-overview"); - // Walk the cursor down and back up, snapping every step so playback shows - // the line cursor actually traveling through the diff. - let walkFrame = 0; - const walk = async (key: "j" | "k", steps: number) => { - for (let step = 0; step < steps; step += 1) { - await session.press(key); - await sleep(120); - await snap(session, `review-walk-${String(walkFrame).padStart(2, "0")}`); - walkFrame += 1; - } - }; - await walk("j", 10); - await walk("k", 4); + await session.press("v"); + await sleep(200); + await snap(session, "review-selection-1"); + for (let selected = 2; selected <= 4; selected += 1) { + await session.press("j"); + await sleep(180); + await snap(session, `review-selection-${selected}`); + } + await session.waitForText(/c Comment\s+y Copy\s+Esc Clear/, { timeout: 10_000 }); await session.press("c"); await session.waitForText(/Draft note/, { timeout: 10_000 }); await sleep(300); await snap(session, "review-draft"); - await session.type("edge case: empty task list renders a blank summary"); + await session.type("This branch needs an empty-state guard."); await sleep(300); await snap(session, "review-typed"); await session.type("\x13"); // Ctrl+S saves the note await session.waitForText(/Your note/, { timeout: 10_000 }); await sleep(500); - await snap(session, "review-note"); + await snap(session, "review-note-root"); + + await session.type("R"); + await session.waitForText(/Reply -/, { timeout: 10_000 }); + await sleep(250); + await snap(session, "review-reply-draft"); + await session.type("Agreed - I'll add a regression test."); + await session.type("\x13"); + await session.waitForText(/Agreed - I'll add a regression test\./, { timeout: 10_000 }); + await sleep(500); + await snap(session, "review-reply-saved"); + + await session.type("N"); + await sleep(300); + await snap(session, "review-note-previous"); + await session.press("n"); + await sleep(300); + await snap(session, "review-note-next"); + } finally { + session.close(); + } +} + +// --------------------------------------------------------------------------- +// Scene: direct layout keys and the responsive files pane. +// --------------------------------------------------------------------------- +async function capturePolishScene() { + console.log("scene: polish"); + const repoDir = createDemoRepo(); + const session = await launchHunk(["diff", "--mode", "unified"], { cwd: repoDir }); + try { + await session.waitForText(/src\//, { timeout: 60_000 }); + await ensureKeyboardIsLive(session, HUNK_KEYBOARD_PROBE); + await sleep(400); + await snap(session, "polish-unified"); + + await session.press("2"); + await sleep(500); + await snap(session, "polish-split"); + + await session.press("1"); + await sleep(400); + await snap(session, "polish-unified-return"); + + await session.press("s"); + await sleep(600); + await snap(session, "polish-sidebar"); + + const snapshot = await session.text({ immediate: true }); + const dividerColumn = snapshot + .split("\n") + .map((line) => line.indexOf("│")) + .find((column) => column > 0); + if (dividerColumn === undefined) { + throw new Error("Files pane divider is not visible."); + } + await dragMouse(session, dividerColumn, 6, dividerColumn + 13, 6); + await session.waitForText(/⌄ src\//, { timeout: 10_000 }); + await sleep(400); + await snap(session, "polish-sidebar-tree"); + + await session.click(/⌄ src\//, { first: true }); + await session.waitForText(/› src\//, { timeout: 10_000 }); + await sleep(350); + await snap(session, "polish-sidebar-collapsed"); } finally { session.close(); } @@ -456,14 +574,16 @@ async function captureFileViewScene( } } -// The current storyboard uses history; opt into reusable legacy scenes with a -// comma-separated override, e.g. SCENES=review,pager. -const wants = makeSceneFilter(process.env.SCENES ?? "history"); +// The current full-release storyboard uses these scenes; opt into reusable +// legacy scenes with a comma-separated override, e.g. SCENES=pager,triage. +const wants = makeSceneFilter(process.env.SCENES ?? "history,static-history,review,polish"); async function main() { try { if (wants("history")) await captureHistoryScene(); + if (wants("static-history")) await captureStaticHistoryScene(); if (wants("review")) await captureReviewScene(); + if (wants("polish")) await capturePolishScene(); if (wants("stml")) await captureStmlScene(); if (wants("cli")) await captureMarkupCliScene(); if (wants("pager")) await capturePagerScene(); diff --git a/scripts/launch-video/compose.mjs b/scripts/launch-video/compose.mjs index 8287ff1c2..9d310aae7 100644 --- a/scripts/launch-video/compose.mjs +++ b/scripts/launch-video/compose.mjs @@ -14,51 +14,86 @@ const scriptDir = dirname(fileURLToPath(import.meta.url)); const repoRoot = resolve(scriptDir, "../.."); const workDir = resolve(process.argv[2] ?? join(repoRoot, ".video-work")); -const HISTORY_TITLE = "hunk log — Git history"; +const HISTORY_TITLE = "hunk log - Git history"; +const REVIEW_TITLE = "hunk diff - precise review"; const FULL_FRAME = { x: 0.5, y: 0.5, scale: 1 }; -const HISTORY_CAMERA = { x: 0.39, y: 0.43, scale: 1.05 }; -const RANGE_CAMERA = { x: 0.4, y: 0.65, scale: 1.1 }; +const HISTORY_CAMERA = FULL_FRAME; +const RANGE_CAMERA = FULL_FRAME; const RANGE_HEIGHTS = [0.12, 0.27, 0.425, 0.515]; const HISTORY_ROWS = { x: 0.01, y: 0.02, - width: 0.93, + width: 0.96, height: 0.925, label: "day-grouped history", }; const OPEN_CARD = ` -
WHAT'S NEW
-

hunk

-
history is now a review surface
+
HUNK 0.22
+

Git history in Hunk

+
browse commits · select a range · stay in the flow
`; const HISTORY_CARD = `
NEW
-

Review Git history

+

Interactive history browser

hunk log
-
real commits · one terminal · no context switching
+
Git and Jujutsu · automatic interactive mode in a TTY
+`; + +const STATIC_CARD = ` +
TERMINAL-NATIVE
+

Static history output

+
+
hunk log --static --max-count 5
+
+
clean output for pipes, scripts, and captured logs
+`; + +const SELECTION_CARD = ` +
PRECISE REVIEW
+

Multiline selections

+
v starts a persistent range · then comment, copy, or clear
+`; + +const THREAD_CARD = ` +
KEYBOARD-FIRST
+

Keyboard note navigation

+
n / N moves between notes · R reply · E edit · D delete
+`; + +const POLISH_CARD = ` +
QUALITY OF LIFE
+

Review shortcuts

+
1 unified · 2 split · responsive files pane
+`; + +const FINISH_CARD = ` +
HUNK 0.22
+

Quality-of-life improvements

+
view and theme preferences persist · panes move smoothly · suspended jobs resume intact
+
faster wrapped diffs · lower idle CPU · direct reviews show revision context
`; const OUTRO_CARD = ` -
NEXT
-

From commits to comparisons

-
select a range · press Enter · review the whole story
-
github.com/modem-dev/hunk
+
AVAILABLE NOW
+

hunk 0.22

+
history browsing · range reviews · precise comments
+
hunk.dev · github.com/modem-dev/hunk
`; // One entry per storyboard shot; timing/caption semantics are documented in // @hunk/term-video/plan. const SHOTS = [ - { kind: "card", html: OPEN_CARD, dur: 2.6, enter: true }, - { kind: "card", html: HISTORY_CARD, dur: 2.8, enter: true }, + { kind: "card", html: OPEN_CARD, dur: 2.8, enter: true }, + { kind: "card", html: HISTORY_CARD, dur: 2.5, enter: true }, { kind: "term", img: "history-overview", title: HISTORY_TITLE, - dur: 2.4, + dur: 2, enter: true, camera: FULL_FRAME, cameraKey: "history", @@ -69,7 +104,7 @@ const SHOTS = [ kind: "term", img: "history-overview", title: HISTORY_TITLE, - dur: 3.2, + dur: 2.7, camera: HISTORY_CAMERA, cameraKey: "history", highlight: HISTORY_ROWS, @@ -78,11 +113,20 @@ const SHOTS = [ capKey: "history-browse", caption: `j/k moves through a responsive timeline`, }, + { + kind: "term", + img: "history-graph", + title: HISTORY_TITLE, + dur: 2.6, + camera: HISTORY_CAMERA, + capKey: "history-graph", + caption: `switch from day groups to the optional commit graph`, + }, ...Array.from({ length: 3 }, (_, i) => ({ kind: "term", img: `history-walk-${i + 1}`, title: HISTORY_TITLE, - dur: i === 2 ? 0.7 : 0.28, + dur: i === 2 ? 0.65 : 0.25, camera: HISTORY_CAMERA, cameraKey: "history", highlight: HISTORY_ROWS, @@ -93,13 +137,13 @@ const SHOTS = [ kind: "term", img: "history-range-1", title: HISTORY_TITLE, - dur: 2.5, + dur: 2, camera: RANGE_CAMERA, cameraKey: "history", highlight: { x: 0.01, y: 0.435, - width: 0.88, + width: 0.96, height: RANGE_HEIGHTS[0], label: "visual range", }, @@ -112,13 +156,13 @@ const SHOTS = [ kind: "term", img: `history-range-${i + 2}`, title: HISTORY_TITLE, - dur: i === 2 ? 1.1 : 0.4, + dur: i === 2 ? 0.9 : 0.35, camera: RANGE_CAMERA, cameraKey: "history", highlight: { x: 0.01, y: 0.435, - width: 0.88, + width: 0.96, height: RANGE_HEIGHTS[i + 1], label: `${i + 2} commits selected`, }, @@ -130,30 +174,209 @@ const SHOTS = [ kind: "term", img: "history-comparison", title: "hunk diff — selected commit range", - dur: 2.5, + dur: 2, camera: FULL_FRAME, motion: 0.8, capKey: "history-open", - caption: `press Enter — review the selected comparison`, + caption: `press Enter - review the selected comparison`, }, { kind: "term", img: "history-comparison", title: "hunk diff — selected commit range", - dur: 3.4, - camera: { x: 0.35, y: 0.24, scale: 1.2 }, + dur: 2.8, + camera: FULL_FRAME, highlight: { x: 0.01, y: 0.025, - width: 0.82, + width: 0.98, height: 0.29, label: "comparison context", }, motion: 0.9, capKey: "history-context", - caption: `commit context stays beside the code`, + caption: `commit context and revision IDs stay beside the code`, + }, + { kind: "card", html: STATIC_CARD, dur: 2.3, enter: true }, + ...["static-history-typing-1", "static-history-typing-2", "static-history-typed"].map( + (img, index) => ({ + kind: "term", + img, + title: "shell - history output", + dur: index === 2 ? 0.7 : 0.3, + camera: { x: 0.32, y: 0.22, scale: 1.35 }, + cameraKey: "static-history", + capKey: "static-history-command", + caption: `the same command has a compact plain-text path`, + enter: index === 0, + }), + ), + { + kind: "term", + img: "static-history-output", + title: "shell - history output", + dur: 2.8, + camera: { x: 0.39, y: 0.3, scale: 1.25 }, + capKey: "static-history-output", + caption: `use it in a pipe without launching the TUI`, + }, + { kind: "card", html: SELECTION_CARD, dur: 2.4, enter: true }, + { + kind: "term", + img: "review-overview", + title: REVIEW_TITLE, + dur: 1.5, + enter: true, + camera: FULL_FRAME, + cameraKey: "review", + capKey: "selection-start", + caption: `review still starts with the code`, + }, + ...Array.from({ length: 4 }, (_, i) => ({ + kind: "term", + img: `review-selection-${i + 1}`, + title: REVIEW_TITLE, + dur: i === 3 ? 1.8 : 0.35, + camera: FULL_FRAME, + cameraKey: "review", + capKey: "selection-grow", + caption: + i === 0 ? `press v, then move - the selection persists` : undefined, + })), + { + kind: "term", + img: "review-draft", + title: REVIEW_TITLE, + dur: 1.3, + camera: FULL_FRAME, + cameraKey: "review", + capKey: "selection-comment", + caption: `comment on the whole range with c`, + }, + { + kind: "term", + img: "review-typed", + title: REVIEW_TITLE, + dur: 1.5, + camera: FULL_FRAME, + cameraKey: "review", + capKey: "selection-comment", + }, + { + kind: "term", + img: "review-note-root", + title: REVIEW_TITLE, + dur: 2.5, + camera: FULL_FRAME, + cameraKey: "review", + capKey: "selection-saved", + caption: `the multiline anchor remains visible after saving`, + }, + { kind: "card", html: THREAD_CARD, dur: 2.2, enter: true }, + { + kind: "term", + img: "review-reply-draft", + title: REVIEW_TITLE, + dur: 1.4, + enter: true, + camera: FULL_FRAME, + cameraKey: "notes", + capKey: "thread-reply", + caption: `reply inline without leaving the review`, + }, + { + kind: "term", + img: "review-reply-saved", + title: REVIEW_TITLE, + dur: 2.1, + camera: FULL_FRAME, + cameraKey: "notes", + capKey: "thread-saved", + caption: `threads keep their inherited code anchor`, + }, + { + kind: "term", + img: "review-note-previous", + title: REVIEW_TITLE, + dur: 1.1, + camera: FULL_FRAME, + cameraKey: "notes", + capKey: "thread-navigation", + caption: `N / n moves the active note through the thread`, + }, + { + kind: "term", + img: "review-note-next", + title: REVIEW_TITLE, + dur: 1.1, + camera: FULL_FRAME, + cameraKey: "notes", + capKey: "thread-navigation", + }, + { kind: "card", html: POLISH_CARD, dur: 2.3, enter: true }, + { + kind: "term", + img: "polish-unified", + title: "hunk diff - unified", + dur: 1.6, + enter: true, + camera: FULL_FRAME, + cameraKey: "polish", + capKey: "layout", + caption: `1 picks a unified review`, + }, + { + kind: "term", + img: "polish-split", + title: "hunk diff - split", + dur: 2, + camera: FULL_FRAME, + cameraKey: "polish", + capKey: "layout-split", + caption: `2 switches to side-by-side`, + }, + { + kind: "term", + img: "polish-unified-return", + title: "hunk diff - unified", + dur: 0.8, + camera: FULL_FRAME, + cameraKey: "polish", + capKey: "layout-return", + caption: `the current review position stays put`, + }, + { + kind: "term", + img: "polish-sidebar", + title: "hunk diff - files", + dur: 1.2, + camera: FULL_FRAME, + cameraKey: "polish", + capKey: "sidebar", + caption: `the files pane adapts to the terminal`, + }, + { + kind: "term", + img: "polish-sidebar-tree", + title: "hunk diff - files", + dur: 1.4, + camera: FULL_FRAME, + cameraKey: "polish", + capKey: "sidebar-tree", + caption: `make it wider for a full folder tree`, + }, + { + kind: "term", + img: "polish-sidebar-collapsed", + title: "hunk diff - files", + dur: 2, + camera: FULL_FRAME, + cameraKey: "polish", + capKey: "sidebar-collapse", + caption: `collapse folders with the mouse - navigation reveals hidden files`, }, - { kind: "card", html: OUTRO_CARD, dur: 3.6, enter: true }, + { kind: "card", html: FINISH_CARD, dur: 3.4, enter: true }, + { kind: "card", html: OUTRO_CARD, dur: 3.8, enter: true }, ]; const result = await composeStoryboard({ shots: SHOTS, workDir, rootDir: repoRoot }); diff --git a/skills/hunk-launch-video/SKILL.md b/skills/hunk-launch-video/SKILL.md index bfcfb19e5..51bbcf21f 100644 --- a/skills/hunk-launch-video/SKILL.md +++ b/skills/hunk-launch-video/SKILL.md @@ -146,8 +146,8 @@ checked-in demo coverage and belongs to the submitted change. ## Per-video editorial surface The capture machinery is reusable, but the storyboard is editorial content for -one video. Rewrite it to match the video's scope. The checked-in reference is a -single-feature Git-history video, not a frozen release artifact: +one video. Rewrite it to match the video's scope. The checked-in reference is +the Hunk 0.22 full-release video, not a frozen release artifact: - `compose.mjs`: the whole `SHOTS` table; opening, feature, and outro cards; every caption; camera targets; and callout rectangles. A `NEW` badge is a @@ -297,6 +297,9 @@ Sandbox-specific bullets are marked; each cost real debugging time. `` amber highlight, `` muted. Cards use `badge` / `h1`/`h2` / `sub` / `cmds`+`cmd` / `foot` classes from `packages/term-video/src/stage.html`. +- Use short, literal feature names for card titles, usually two to four words. + Avoid slogans, metaphors, and sentence-style setup; put benefits and workflow + explanations in subtitles or captions instead. - Target pacing: money shots hold 3–4s, context shots 2–3s, typing/walk frames 0.2–0.6s; keep the total near 60s. From 6624f353445fe69edc6923dac7596e62dbb19a6d Mon Sep 17 00:00:00 2001 From: Ben Vinegar Date: Thu, 10 Sep 2026 11:13:12 -0400 Subject: [PATCH 2/2] docs(video): generalize editorial guidance --- skills/hunk-launch-video/SKILL.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/skills/hunk-launch-video/SKILL.md b/skills/hunk-launch-video/SKILL.md index 51bbcf21f..a7576905e 100644 --- a/skills/hunk-launch-video/SKILL.md +++ b/skills/hunk-launch-video/SKILL.md @@ -145,9 +145,10 @@ checked-in demo coverage and belongs to the submitted change. ## Per-video editorial surface -The capture machinery is reusable, but the storyboard is editorial content for -one video. Rewrite it to match the video's scope. The checked-in reference is -the Hunk 0.22 full-release video, not a frozen release artifact: +The capture machinery is reusable, but each storyboard is editorial content for +one video. Rewrite it to match every video's scope. Treat the checked-in +storyboard as an implementation example, not a template whose subject, +sequence, titles, captions, or pacing should carry forward: - `compose.mjs`: the whole `SHOTS` table; opening, feature, and outro cards; every caption; camera targets; and callout rectangles. A `NEW` badge is a @@ -297,9 +298,9 @@ Sandbox-specific bullets are marked; each cost real debugging time. `` amber highlight, `` muted. Cards use `badge` / `h1`/`h2` / `sub` / `cmds`+`cmd` / `foot` classes from `packages/term-video/src/stage.html`. -- Use short, literal feature names for card titles, usually two to four words. - Avoid slogans, metaphors, and sentence-style setup; put benefits and workflow - explanations in subtitles or captions instead. +- For every video, use short, literal feature names for card titles, usually two + to four words. Avoid slogans, metaphors, and sentence-style setup; put + benefits and workflow explanations in subtitles or captions instead. - Target pacing: money shots hold 3–4s, context shots 2–3s, typing/walk frames 0.2–0.6s; keep the total near 60s.