my-you-eye 0.4.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/COMPONENTS.md CHANGED
@@ -132,7 +132,7 @@ Step (`TerminalStep`):
132
132
  | `command?` | `string` | Command typed after the prompt. Omit for an output-only entry (a banner, a log tail). |
133
133
  | `output?` | `string` | Output body shown under the command. |
134
134
  | `language?` | `string` | Language id for highlighting `output`. |
135
- | `exitCode?` | `number` | Process exit code — renders a badge (0 reads success, non-zero danger). |
135
+ | `exitCode?` | `number` | Process exit code — renders a plain status line (0 reads success, non-zero danger). |
136
136
  | `spinner?` | `string` | Shows a spinner with this label while the step runs, before `output` lands. For a step that should read as "this takes a while". |
137
137
  | `cwd?` | `string` | Per-entry overrides of the prompt chrome — each independently optional, and each PERSISTS to every following entry until overridden again, mirroring `Terminal`'s own per-entry override semantics (a real shell's `cd`/`ssh` changes the prompt for every command after it, not just that one line). Falls back to the scene-level prop of the same name when never set by any entry up to this point. |
138
138
  | `user?` | `string` | — |
@@ -672,7 +672,7 @@ Also accepts everything from `HTMLAttributes<HTMLDivElement>`.
672
672
  | `Card` | `my-you-eye` | variant: **default** / elevated / outlined | Variants, With footer actions, Size |
673
673
  | `CodeBlock` | `my-you-eye` | variant: **default** / elevated | Bare (no header, no language), Language-only (badge overlay, no header bar), With header + language, Elevated, Line numbers, No wrap (horizontal scroll), Syntax highlighting (TS), Line highlights, Line highlights (implicit gutter), Multi-color highlights, Substring highlights, Substring highlights on a long line (wrap forced off), Merged highlights, Focus range (dims everything outside it), Bare (embedded in another surface), Syntax highlighting (CSS / HTML / SQL / YAML / Python) |
674
674
  | `DeviceFrame` | `my-you-eye` | variant: **browser** / phone / window | Browser, Window, Phone |
675
- | `DiffBlock` | `my-you-eye` | variant: **default** / elevated | Unified, Unified — word diff, Split, Split — word diff, Elevated |
675
+ | `DiffBlock` | `my-you-eye` | variant: **default** / elevated | Unified, Unified — word diff, Unified — word diff, heavily rewritten lines, Split, Split — word diff, Elevated |
676
676
  | `EmptyState` | `my-you-eye` | — | Default, With icon and action |
677
677
  | `Image` | `my-you-eye` | fit: contain / **cover** / fill / none / scaleDown<br>radius: full / lg / **md** / none / sm<br>aspect: **auto** / square / tall / video / wide<br>bordered: true<br>shadowed: true | Fit modes, Border radius, Aspect ratio, Styles, With caption |
678
678
  | `Kbd` | `my-you-eye` | — | Default, Combinations |
@@ -680,7 +680,7 @@ Also accepts everything from `HTMLAttributes<HTMLDivElement>`.
680
680
  | `ScrollArea` | `my-you-eye` | orientation: **both** / horizontal / vertical | Vertical scroll, Horizontal scroll, Both axes, Rounded corners (radius on ScrollArea itself, not a wrapper), Edge fade |
681
681
  | `Separator` | `my-you-eye` | orientation: **horizontal** / vertical | Horizontal, Vertical |
682
682
  | `StatusDot` | `my-you-eye` | variant: danger / info / **neutral** / success / warning<br>size: **md** / sm | Variants, Sizes, Pulsing |
683
- | `Terminal` | `my-you-eye` | variant: **default** / elevated<br>scheme: amber / **default** / matrix<br>chrome: **dots** / none | Prompt glyphs, Title bar, Variant, Color schemes, Chrome decorator, Fixed height, scrolls as content grows, Prompt segments, changed mid-session |
683
+ | `Terminal` | `my-you-eye` | variant: **default** / elevated<br>scheme: amber / **default** / matrix<br>chrome: **dots** / none | Prompt glyphs, Title bar, Exit status & spinner, Variant, Color schemes, Chrome decorator, Fixed height, scrolls as content grows, Prompt segments, changed mid-session |
684
684
 
685
685
  ### display — props
686
686
 
@@ -1450,7 +1450,7 @@ Also accepts everything from `HTMLAttributes<HTMLDivElement>`.
1450
1450
  | `SceneRenderer` | `my-you-eye/scenes` | — | Title, Bullets, Diagram, chart, stat |
1451
1451
  | `SequenceScene` | `my-you-eye/scenes` | — | Playing |
1452
1452
  | `StatScene` | `my-you-eye/scenes` | — | Playing |
1453
- | `TerminalScene` | `my-you-eye/scenes` | — | Playing, Pinned mid-typing, entry 1 (frame 12/160), Pinned on entry 2's spinner (frame 72/160), Pinned at rest (frame 160/160), Prompt change (rows + scheme) |
1453
+ | `TerminalScene` | `my-you-eye/scenes` | — | Playing, Pinned mid-typing, entry 1 (frame 12/160), Pinned on entry 2's spinner (frame 72/160), Spinner, animating, Pinned at rest (frame 160/160), Prompt change (rows + scheme) |
1454
1454
  | `TitleScene` | `my-you-eye/scenes` | — | Centered, playing, Left-aligned, no chapter/subtitle, Pinned mid-stagger (frame 12/90), Pinned at rest (frame 90/90) |
1455
1455
  | `Validation` | `my-you-eye/scenes` | — | Deliberately broken video, Valid, minimal video |
1456
1456
  | `WalkthroughScene` | `my-you-eye/scenes` | — | Playing |
package/components.json CHANGED
@@ -1148,10 +1148,11 @@
1148
1148
  "Playing",
1149
1149
  "Pinned mid-typing, entry 1 (frame 12/160)",
1150
1150
  "Pinned on entry 2's spinner (frame 72/160)",
1151
+ "Spinner, animating",
1151
1152
  "Pinned at rest (frame 160/160)",
1152
1153
  "Prompt change (rows + scheme)"
1153
1154
  ],
1154
- "description": "Wraps Terminal, revealing one entry per step: command types in, spinner holds, output and badge land."
1155
+ "description": "Wraps Terminal, revealing one entry per step: command types in, spinner cycles, output and exit status land."
1155
1156
  },
1156
1157
  {
1157
1158
  "name": "TitleScene",
@@ -2918,6 +2919,7 @@
2918
2919
  "demos": [
2919
2920
  "Unified",
2920
2921
  "Unified — word diff",
2922
+ "Unified — word diff, heavily rewritten lines",
2921
2923
  "Split",
2922
2924
  "Split — word diff",
2923
2925
  "Elevated"
@@ -3221,13 +3223,14 @@
3221
3223
  "demos": [
3222
3224
  "Prompt glyphs",
3223
3225
  "Title bar",
3226
+ "Exit status & spinner",
3224
3227
  "Variant",
3225
3228
  "Color schemes",
3226
3229
  "Chrome decorator",
3227
3230
  "Fixed height, scrolls as content grows",
3228
3231
  "Prompt segments, changed mid-session"
3229
3232
  ],
3230
- "description": "Prompt/command/output/exit-code sequences, composing CodeBlock for output bodies. Data-driven via a typed entries array.",
3233
+ "description": "Prompt/command/output/exit-status sequences, composing CodeBlock for output bodies. Data-driven via a typed entries array.",
3231
3234
  "variantDefaults": {
3232
3235
  "variant": "default",
3233
3236
  "scheme": "default",
@@ -4862,7 +4865,7 @@
4862
4865
  "name": "exitCode",
4863
4866
  "optional": true,
4864
4867
  "type": "number",
4865
- "description": "Process exit code — renders a badge (0 reads success, non-zero danger)."
4868
+ "description": "Process exit code — renders a plain status line (0 reads success, non-zero danger)."
4866
4869
  },
4867
4870
  {
4868
4871
  "name": "spinner",
@@ -4,16 +4,16 @@ import {
4
4
  import {
5
5
  SceneRenderer,
6
6
  sceneDuration
7
- } from "./chunk-VU2IBHKV.js";
7
+ } from "./chunk-AAPYU7IQ.js";
8
8
  import {
9
9
  Badge,
10
10
  Progress,
11
11
  cn
12
- } from "./chunk-NZO3FHA7.js";
12
+ } from "./chunk-OBWYV42U.js";
13
13
  import {
14
14
  MotionRoot,
15
15
  resolveBeatFrames
16
- } from "./chunk-VFEYYIUB.js";
16
+ } from "./chunk-YWX2ZFH4.js";
17
17
 
18
18
  // src/video/VideoRoot.tsx
19
19
  import { Fragment as Fragment2 } from "react";
@@ -20,6 +20,7 @@ import {
20
20
  Link,
21
21
  MergedHighlight,
22
22
  PieChart,
23
+ SPINNER_FRAMES,
23
24
  ScatterPlot,
24
25
  ScrollArea,
25
26
  SequenceDiagram,
@@ -34,12 +35,12 @@ import {
34
35
  layered,
35
36
  lcsDiffFlags,
36
37
  pairDiffLines,
38
+ refinedLineDiff,
37
39
  renderHighlightedLine,
38
40
  snap,
39
41
  splitTokensByLine,
40
- tokenize,
41
- wordDiff
42
- } from "./chunk-NZO3FHA7.js";
42
+ tokenize
43
+ } from "./chunk-OBWYV42U.js";
43
44
  import {
44
45
  Camera,
45
46
  CountUp,
@@ -57,7 +58,7 @@ import {
57
58
  resolveBeatFrames,
58
59
  useProgress,
59
60
  useSequence
60
- } from "./chunk-VFEYYIUB.js";
61
+ } from "./chunk-YWX2ZFH4.js";
61
62
  import {
62
63
  formatBytesParts,
63
64
  formatCurrencyParts,
@@ -1156,7 +1157,7 @@ function CodeDiffRow({ row, language, delayFrames, durationFrames, lineHeight, c
1156
1157
  const isAddedOnly = Boolean(row.right && !row.left);
1157
1158
  const isRemovedOnly = Boolean(row.left && !row.right);
1158
1159
  const { ref: markerRef, anchorX } = useAnchorX();
1159
- const diff = isChanged ? wordDiff(row.left.content, row.right.content) : null;
1160
+ const diff = isChanged ? refinedLineDiff(row.left.content, row.right.content) : null;
1160
1161
  const oldRects = useMemo(
1161
1162
  () => diff ? segmentsToRects(diff.oldSegments, anchorX, charWidth, lineHeight) : [],
1162
1163
  [diff, anchorX, charWidth, lineHeight]
@@ -1165,7 +1166,7 @@ function CodeDiffRow({ row, language, delayFrames, durationFrames, lineHeight, c
1165
1166
  () => diff ? segmentsToRects(diff.newSegments, anchorX, charWidth, lineHeight) : [],
1166
1167
  [diff, anchorX, charWidth, lineHeight]
1167
1168
  );
1168
- if (isChanged && diff) {
1169
+ if (isChanged) {
1169
1170
  const oldOpacity = Math.max(0, 1 - progress / 0.45);
1170
1171
  const newOpacity = Math.max(0, (progress - 0.55) / 0.45);
1171
1172
  return /* @__PURE__ */ jsxs3("div", { className: "relative", children: [
@@ -1176,7 +1177,7 @@ function CodeDiffRow({ row, language, delayFrames, durationFrames, lineHeight, c
1176
1177
  style: { opacity: oldOpacity },
1177
1178
  children: [
1178
1179
  /* @__PURE__ */ jsx3("span", { ref: markerRef, className: cn("w-5 shrink-0 text-center", MARKER_TEXT.removed), children: MARKER_GLYPH.removed }),
1179
- diff.oldSegments.map((seg) => seg.text).join(""),
1180
+ row.left.content,
1180
1181
  oldRects.length > 0 && /* @__PURE__ */ jsx3(MergedHighlight, { rects: oldRects, color: WORD_COLOR.old, strokeColor: WORD_COLOR.old })
1181
1182
  ]
1182
1183
  }
@@ -1188,7 +1189,7 @@ function CodeDiffRow({ row, language, delayFrames, durationFrames, lineHeight, c
1188
1189
  style: { opacity: newOpacity },
1189
1190
  children: [
1190
1191
  /* @__PURE__ */ jsx3("span", { className: cn("w-5 shrink-0 text-center", MARKER_TEXT.added), children: MARKER_GLYPH.added }),
1191
- diff.newSegments.map((seg) => seg.text).join(""),
1192
+ row.right.content,
1192
1193
  newRects.length > 0 && /* @__PURE__ */ jsx3(MergedHighlight, { rects: newRects, color: WORD_COLOR.new, strokeColor: WORD_COLOR.new })
1193
1194
  ]
1194
1195
  }
@@ -1409,6 +1410,10 @@ function useCodeAnnotations(annotations, containerRef, blockId) {
1409
1410
  // src/scenes/code-scene/CodeScene.tsx
1410
1411
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1411
1412
  var DIFF_SHARE = 0.55;
1413
+ function typedSlice(source, progress) {
1414
+ const cut = Math.floor(source.length * progress);
1415
+ return source.slice(0, cut) + source.slice(cut).replace(/[^\n]/g, "");
1416
+ }
1412
1417
  function currentStepIndex(scene, ranges, frame) {
1413
1418
  let index = 0;
1414
1419
  scene.steps.forEach((step, i) => {
@@ -1439,7 +1444,15 @@ function CodeScene({ scene }) {
1439
1444
  const diffFrames = Math.max(1, Math.round((range.endFrame - range.startFrame) * DIFF_SHARE));
1440
1445
  const isDiff = !step.typed && nextSource !== prevSource && frame < range.startFrame + diffFrames;
1441
1446
  const isTyped = Boolean(step.typed);
1442
- const typedCode = isTyped ? nextSource.slice(0, Math.floor(nextSource.length * stepProgress)) : nextSource;
1447
+ const typedCode = isTyped ? typedSlice(nextSource, stepProgress) : nextSource;
1448
+ const blockProps = {
1449
+ language: scene.lang,
1450
+ header: scene.file,
1451
+ highlight: highlightEnabled,
1452
+ showLineNumbers,
1453
+ focusRange: step.focus,
1454
+ highlightRanges: highlightRanges.length > 0 ? highlightRanges : void 0
1455
+ };
1443
1456
  return /* @__PURE__ */ jsx5("div", { className: "flex h-full w-full flex-col gap-stack bg-bg p-panel-xl text-fg", children: /* @__PURE__ */ jsx5("div", { ref: containerRef, className: "relative min-h-0 flex-1", children: /* @__PURE__ */ jsxs5(Camera, { keyframes, className: "h-full w-full", children: [
1444
1457
  /* @__PURE__ */ jsx5("div", { className: "flex h-full w-full items-center justify-center", children: isDiff ? /* @__PURE__ */ jsx5(
1445
1458
  CodeDiff,
@@ -1453,16 +1466,38 @@ function CodeScene({ scene }) {
1453
1466
  className: "max-h-full"
1454
1467
  },
1455
1468
  index
1469
+ ) : isTyped ? (
1470
+ /* Reserve the FINAL layout up front — the same trick TypeText's
1471
+ `preserveLayout` uses (src/motion/type-text/TypeText.tsx), for
1472
+ the same reason: a box that grows with the text is a box that
1473
+ moves under everything measuring it. Both blocks occupy the
1474
+ one grid cell, the hidden one (full source) sizing it and the
1475
+ visible one (typed prefix) stretching into it, so block width,
1476
+ block height and gutter width are final from frame 1 and the
1477
+ only thing that changes per frame is which characters are
1478
+ painted. The container therefore never resizes while typing,
1479
+ so `useCodeCameraKeyframes`' ResizeObserver stays quiet and
1480
+ the centring wrapper below has nothing left to re-centre.
1481
+ The ghost deliberately gets NO `lineId`: duplicate element ids
1482
+ would make the camera's `getElementById` measure the invisible
1483
+ copy instead of the real one. */
1484
+ /* @__PURE__ */ jsxs5("div", { className: "grid max-h-full", children: [
1485
+ /* @__PURE__ */ jsx5("div", { "aria-hidden": true, className: "invisible col-start-1 row-start-1 min-w-0", children: /* @__PURE__ */ jsx5(CodeBlock, { code: nextSource, ...blockProps, className: "max-h-full" }) }),
1486
+ /* @__PURE__ */ jsx5(
1487
+ CodeBlock,
1488
+ {
1489
+ code: typedCode,
1490
+ ...blockProps,
1491
+ lineId: (n) => lineElementId(blockId, n),
1492
+ className: "col-start-1 row-start-1 min-w-0 max-h-full"
1493
+ }
1494
+ )
1495
+ ] }, index)
1456
1496
  ) : /* @__PURE__ */ jsx5(
1457
1497
  CodeBlock,
1458
1498
  {
1459
- code: typedCode,
1460
- language: scene.lang,
1461
- header: scene.file,
1462
- highlight: highlightEnabled,
1463
- showLineNumbers,
1464
- focusRange: step.focus,
1465
- highlightRanges: highlightRanges.length > 0 ? highlightRanges : void 0,
1499
+ code: nextSource,
1500
+ ...blockProps,
1466
1501
  lineId: (n) => lineElementId(blockId, n),
1467
1502
  className: "max-h-full"
1468
1503
  },
@@ -1501,7 +1536,11 @@ function phaseWindows(entry) {
1501
1536
  }
1502
1537
  return windows;
1503
1538
  }
1504
- function currentEntryView(entry, localProgress) {
1539
+ var SPINNER_FRAME_HOLD = 3;
1540
+ function spinnerGlyphAt(frame) {
1541
+ return SPINNER_FRAMES[Math.floor(frame / SPINNER_FRAME_HOLD) % SPINNER_FRAMES.length];
1542
+ }
1543
+ function currentEntryView(entry, localProgress, frame) {
1505
1544
  const windows = phaseWindows(entry);
1506
1545
  const command = entry.command != null && windows.command ? entry.command.slice(0, Math.floor(entry.command.length * clamp01((localProgress - windows.command.start) / (windows.command.end - windows.command.start)))) : entry.command;
1507
1546
  const spinnerVisible = windows.spinner != null && localProgress >= windows.spinner.start && localProgress < windows.spinner.end;
@@ -1510,6 +1549,7 @@ function currentEntryView(entry, localProgress) {
1510
1549
  return {
1511
1550
  command,
1512
1551
  spinner: spinnerVisible ? entry.spinner : void 0,
1552
+ spinnerGlyph: spinnerVisible ? spinnerGlyphAt(frame) : void 0,
1513
1553
  output: outputVisible ? entry.output : void 0,
1514
1554
  language: entry.language,
1515
1555
  exitCode: exitVisible ? entry.exitCode : void 0,
@@ -1544,7 +1584,7 @@ function TerminalScene({ scene }) {
1544
1584
  }
1545
1585
  const range = ranges[stepName(entry.id, i)];
1546
1586
  const span = Math.max(1, range.endFrame - range.startFrame);
1547
- visible.push(currentEntryView(entry, clamp01((frame - range.startFrame) / span)));
1587
+ visible.push(currentEntryView(entry, clamp01((frame - range.startFrame) / span), frame));
1548
1588
  }
1549
1589
  return /* @__PURE__ */ jsx6("div", { className: "flex h-full w-full items-center justify-center bg-bg px-panel-xl text-fg", children: /* @__PURE__ */ jsx6(
1550
1590
  Terminal,
@@ -1577,10 +1617,13 @@ function OutroScene({ scene }) {
1577
1617
  }
1578
1618
 
1579
1619
  // src/scenes/diagram-scene/DiagramScene.tsx
1580
- import { useMemo as useMemo4 } from "react";
1620
+ import { useMemo as useMemo4, useState as useState6 } from "react";
1581
1621
 
1582
1622
  // src/scenes/diagram-scene/DiagramScene.layout.ts
1583
1623
  var NODE_WIDTH = 10 * GRID;
1624
+ var LAYER_GAP = 5;
1625
+ var NODE_GAP = 5;
1626
+ var GROUP_LABEL_BAND = 1.5 * GRID;
1584
1627
  var PRESET_DEFAULTS = {
1585
1628
  architecture: { layout: "layered-horizontal", edgeKind: "sync", edgeRoute: "orthogonal", nodeShape: "box" },
1586
1629
  dataflow: { layout: "layered-horizontal", edgeKind: "data", edgeRoute: "orthogonal", nodeShape: "box" },
@@ -1606,10 +1649,8 @@ function resolveNodeRects(nodes, edges, preset, layoutOverride) {
1606
1649
  const footprint = { nodeWidth: NODE_WIDTH / GRID, nodeHeight: tallest / GRID };
1607
1650
  const positions = layoutKind === "grid" ? grid(layoutNodes, footprint) : layered(layoutNodes, layoutEdges, {
1608
1651
  direction: layoutKind === "layered-vertical" ? "vertical" : "horizontal",
1609
- // A tighter inter-layer gap than layout.ts's default: now that the
1610
- // gap is a real gap on top of a 10-cell node, 4 cells of extra air
1611
- // pushed a routine 4-node chain past the width of a 16:9 stage.
1612
- layerGap: 3,
1652
+ layerGap: LAYER_GAP,
1653
+ nodeGap: NODE_GAP,
1613
1654
  ...footprint
1614
1655
  });
1615
1656
  const positionOf = new Map(positions.map((p) => [p.id, p]));
@@ -1640,10 +1681,20 @@ function resolveGroupRects(groups, nodeRects) {
1640
1681
  }
1641
1682
  return out;
1642
1683
  }
1643
- function centerOffset(bounds, canvas) {
1684
+ var FIT_PADDING = 2 * GRID;
1685
+ function fitScale(bounds, canvas) {
1686
+ const width = canvas.width - 2 * FIT_PADDING;
1687
+ const height = canvas.height - 2 * FIT_PADDING;
1688
+ if (width <= 0 || height <= 0) return 1;
1689
+ return Math.min(1, fitZoom({ x: 0, y: 0, width: bounds.width, height: bounds.height }, width, height, 1));
1690
+ }
1691
+ function centerOffset(bounds, canvas, zoom = 1) {
1692
+ const scale = zoom > 0 ? zoom : 1;
1693
+ const viewWidth = canvas.width / scale;
1694
+ const viewHeight = canvas.height / scale;
1644
1695
  return {
1645
- dx: Math.max(0, snap((canvas.width - bounds.width) / 2)),
1646
- dy: Math.max(0, snap((canvas.height - bounds.height) / 2))
1696
+ dx: snap(-bounds.x + Math.max(0, (viewWidth - bounds.width) / 2)),
1697
+ dy: snap(-bounds.y + Math.max(0, (viewHeight - bounds.height) / 2))
1647
1698
  };
1648
1699
  }
1649
1700
  function shiftRects(rects, dx, dy) {
@@ -1654,10 +1705,13 @@ function shiftRects(rects, dx, dy) {
1654
1705
  }
1655
1706
  function contentBounds(nodeRects, groupRects) {
1656
1707
  const rects = [...nodeRects.values(), ...groupRects.values()];
1657
- if (rects.length === 0) return { width: 4 * NODE_WIDTH, height: 8 * GRID };
1708
+ if (rects.length === 0) return { x: 0, y: 0, width: 4 * NODE_WIDTH, height: 8 * GRID };
1709
+ const tops = [...nodeRects.values()].map((r) => r.y).concat([...groupRects.values()].map((r) => r.y - GROUP_LABEL_BAND));
1710
+ const minX = Math.min(...rects.map((r) => r.x));
1711
+ const minY = Math.min(...tops);
1658
1712
  const maxX = Math.max(...rects.map((r) => r.x + r.width));
1659
1713
  const maxY = Math.max(...rects.map((r) => r.y + r.height));
1660
- return { width: maxX + GRID, height: maxY + GRID };
1714
+ return { x: minX - GRID, y: minY - GRID, width: maxX - minX + 2 * GRID, height: maxY - minY + 2 * GRID };
1661
1715
  }
1662
1716
 
1663
1717
  // src/scenes/diagram-scene/DiagramScene.geometry.ts
@@ -1794,7 +1848,9 @@ function DiagramScene({ scene }) {
1794
1848
  const laidOutGroups = useMemo4(() => resolveGroupRects(scene.groups ?? [], laidOutNodes), [scene.groups, laidOutNodes]);
1795
1849
  const bounds = useMemo4(() => contentBounds(laidOutNodes, laidOutGroups), [laidOutNodes, laidOutGroups]);
1796
1850
  const { ref: canvasRef, size: canvasSize } = useCanvasSize(bounds);
1797
- const offset = useMemo4(() => centerOffset(bounds, canvasSize), [bounds, canvasSize]);
1851
+ const [zoomOverride, setZoomOverride] = useState6(null);
1852
+ const zoom = zoomOverride ?? fitScale(bounds, canvasSize);
1853
+ const offset = useMemo4(() => centerOffset(bounds, canvasSize, zoom), [bounds, canvasSize, zoom]);
1798
1854
  const nodeRects = useMemo4(() => shiftRects(laidOutNodes, offset.dx, offset.dy), [laidOutNodes, offset]);
1799
1855
  const groupRects = useMemo4(() => shiftRects(laidOutGroups, offset.dx, offset.dy), [laidOutGroups, offset]);
1800
1856
  const index = currentDiagramStepIndex(scene, ranges, frame);
@@ -1850,87 +1906,97 @@ function DiagramScene({ scene }) {
1850
1906
  const annotations = currentStep?.annotate ?? [];
1851
1907
  return /* @__PURE__ */ jsxs7("div", { className: "flex h-full w-full flex-col gap-stack bg-bg p-panel-xl text-fg", children: [
1852
1908
  scene.title && /* @__PURE__ */ jsx8("h2", { className: "text-xl font-semibold text-fg", children: scene.title }),
1853
- /* @__PURE__ */ jsxs7(Canvas, { ref: canvasRef, className: "min-h-0 flex-1 rounded-ui border border-border", children: [
1854
- [...groupRects.values()].map((rect) => {
1855
- const dimmed = focusIds != null && !focusIds.has(rect.id);
1856
- const revealRange = findRevealRange(rect.id, scene, ranges);
1857
- return /* @__PURE__ */ jsx8(Reveal, { asChild: true, from: "fade", delay: revealRange?.startFrame ?? 0, duration: revealRange ? "normal" : "instant", children: /* @__PURE__ */ jsx8(
1858
- GraphGroup,
1859
- {
1860
- x: rect.x,
1861
- y: rect.y,
1862
- width: rect.width,
1863
- height: rect.height,
1864
- label: rect.data.label,
1865
- border: rect.data.border,
1866
- className: cn(dimmed && "opacity-dim")
1867
- }
1868
- ) }, rect.id);
1869
- }),
1870
- [...nodeRects.values()].map((rect) => {
1871
- const node = rect.data;
1872
- const dimmed = focusIds != null && !focusIds.has(node.id);
1873
- const revealRange = findRevealRange(node.id, scene, ranges);
1874
- const isExpanded = live.expandedNodeId === node.id;
1875
- return /* @__PURE__ */ jsx8(Reveal, { asChild: true, from: "scale", delay: revealRange?.startFrame ?? 0, duration: revealRange ? "normal" : "instant", children: /* @__PURE__ */ jsx8(
1876
- GraphNode,
1877
- {
1878
- x: rect.x,
1879
- y: rect.y,
1880
- style: { width: rect.width },
1881
- variant: isExpanded ? "selected" : rect.simple ? "simple" : "default",
1882
- shape: preset.nodeShape,
1883
- header: node.label,
1884
- subtitle: node.sublabel,
1885
- headerDots: false,
1886
- headerStatus: node.status ? /* @__PURE__ */ jsx8(StatusDot, { variant: node.status, size: "sm" }) : void 0,
1887
- footer: node.metric,
1888
- accent: Boolean(node.accent),
1889
- accentColor: node.accent,
1890
- className: cn(dimmed && "opacity-dim", live.isLive && "cursor-pointer"),
1891
- onMouseEnter: () => live.onNodeHover(node.id),
1892
- onMouseLeave: () => live.onNodeHover(null),
1893
- onClick: (e) => {
1894
- e.stopPropagation();
1895
- live.onNodeClick(node.id);
1896
- }
1897
- }
1898
- ) }, node.id);
1899
- }),
1900
- /* @__PURE__ */ jsx8(ConnectionLayer, { edges: connectionEdges }),
1901
- flowingEdgeIds.map((edgeId2) => {
1902
- const geo = edgeGeometry.get(edgeId2);
1903
- if (!geo) return null;
1904
- const d = generatePath(geo.from, geo.to, geo.route, { obstacles: geo.obstacles });
1905
- return /* @__PURE__ */ jsx8(
1906
- Trace,
1907
- {
1908
- d,
1909
- viewBox: `0 0 ${canvasSize.width} ${canvasSize.height}`,
1910
- className: "absolute inset-0 h-full w-full",
1911
- color: KIND_TRACE_COLOR[geo.kind],
1912
- count: 2,
1913
- duration: "slow"
1914
- },
1915
- edgeId2
1916
- );
1917
- }),
1918
- annotations.map((a, i) => {
1919
- const rect = nodeRects.get(a.target) ?? groupRects.get(a.target);
1920
- if (!rect) return null;
1921
- const side = a.side ?? "right";
1922
- return /* @__PURE__ */ jsx8(
1923
- Annotation,
1924
- {
1925
- target: borderCenter(rect, side),
1926
- label: a.text,
1927
- side,
1928
- progress: annotateProgress
1929
- },
1930
- `${a.target}-${i}`
1931
- );
1932
- })
1933
- ] })
1909
+ /* @__PURE__ */ jsxs7(
1910
+ Canvas,
1911
+ {
1912
+ ref: canvasRef,
1913
+ zoom,
1914
+ onZoomChange: setZoomOverride,
1915
+ className: "min-h-0 flex-1 rounded-ui border border-border",
1916
+ children: [
1917
+ [...groupRects.values()].map((rect) => {
1918
+ const dimmed = focusIds != null && !focusIds.has(rect.id);
1919
+ const revealRange = findRevealRange(rect.id, scene, ranges);
1920
+ return /* @__PURE__ */ jsx8(Reveal, { asChild: true, from: "fade", delay: revealRange?.startFrame ?? 0, duration: revealRange ? "normal" : "instant", children: /* @__PURE__ */ jsx8(
1921
+ GraphGroup,
1922
+ {
1923
+ x: rect.x,
1924
+ y: rect.y,
1925
+ width: rect.width,
1926
+ height: rect.height,
1927
+ label: rect.data.label,
1928
+ border: rect.data.border,
1929
+ labelPlacement: "outside-top",
1930
+ className: cn(dimmed && "opacity-dim")
1931
+ }
1932
+ ) }, rect.id);
1933
+ }),
1934
+ [...nodeRects.values()].map((rect) => {
1935
+ const node = rect.data;
1936
+ const dimmed = focusIds != null && !focusIds.has(node.id);
1937
+ const revealRange = findRevealRange(node.id, scene, ranges);
1938
+ const isExpanded = live.expandedNodeId === node.id;
1939
+ return /* @__PURE__ */ jsx8(Reveal, { asChild: true, from: "scale", delay: revealRange?.startFrame ?? 0, duration: revealRange ? "normal" : "instant", children: /* @__PURE__ */ jsx8(
1940
+ GraphNode,
1941
+ {
1942
+ x: rect.x,
1943
+ y: rect.y,
1944
+ style: { width: rect.width },
1945
+ variant: isExpanded ? "selected" : rect.simple ? "simple" : "default",
1946
+ shape: preset.nodeShape,
1947
+ header: node.label,
1948
+ subtitle: node.sublabel,
1949
+ headerDots: false,
1950
+ headerStatus: node.status ? /* @__PURE__ */ jsx8(StatusDot, { variant: node.status, size: "sm" }) : void 0,
1951
+ footer: node.metric,
1952
+ accent: Boolean(node.accent),
1953
+ accentColor: node.accent,
1954
+ className: cn(dimmed && "opacity-dim", live.isLive && "cursor-pointer"),
1955
+ onMouseEnter: () => live.onNodeHover(node.id),
1956
+ onMouseLeave: () => live.onNodeHover(null),
1957
+ onClick: (e) => {
1958
+ e.stopPropagation();
1959
+ live.onNodeClick(node.id);
1960
+ }
1961
+ }
1962
+ ) }, node.id);
1963
+ }),
1964
+ /* @__PURE__ */ jsx8(ConnectionLayer, { edges: connectionEdges }),
1965
+ flowingEdgeIds.map((edgeId2) => {
1966
+ const geo = edgeGeometry.get(edgeId2);
1967
+ if (!geo) return null;
1968
+ const d = generatePath(geo.from, geo.to, geo.route, { obstacles: geo.obstacles });
1969
+ return /* @__PURE__ */ jsx8(
1970
+ Trace,
1971
+ {
1972
+ d,
1973
+ viewBox: `0 0 ${canvasSize.width} ${canvasSize.height}`,
1974
+ className: "absolute inset-0 h-full w-full",
1975
+ color: KIND_TRACE_COLOR[geo.kind],
1976
+ count: 2,
1977
+ duration: "slow"
1978
+ },
1979
+ edgeId2
1980
+ );
1981
+ }),
1982
+ annotations.map((a, i) => {
1983
+ const rect = nodeRects.get(a.target) ?? groupRects.get(a.target);
1984
+ if (!rect) return null;
1985
+ const side = a.side ?? "right";
1986
+ return /* @__PURE__ */ jsx8(
1987
+ Annotation,
1988
+ {
1989
+ target: borderCenter(rect, side),
1990
+ label: a.text,
1991
+ side,
1992
+ progress: annotateProgress
1993
+ },
1994
+ `${a.target}-${i}`
1995
+ );
1996
+ })
1997
+ ]
1998
+ }
1999
+ )
1934
2000
  ] });
1935
2001
  }
1936
2002
 
@@ -2239,7 +2305,7 @@ import { jsx as jsx13, jsxs as jsxs11 } from "react/jsx-runtime";
2239
2305
  function renderPane(pane) {
2240
2306
  switch (pane.content) {
2241
2307
  case "code":
2242
- return /* @__PURE__ */ jsx13(CodeBlock, { code: pane.code, language: pane.lang, header: pane.label, className: "h-full" });
2308
+ return /* @__PURE__ */ jsx13(CodeBlock, { code: pane.code, language: pane.lang, header: pane.label, className: "h-full border-0 rounded-none" });
2243
2309
  case "text":
2244
2310
  return /* @__PURE__ */ jsx13("div", { className: "whitespace-pre-wrap p-panel text-sm text-fg", children: pane.text });
2245
2311
  case "image":
@@ -2250,6 +2316,9 @@ function renderPane(pane) {
2250
2316
  }
2251
2317
  }
2252
2318
  }
2319
+ function paneLabel(pane) {
2320
+ return pane.content === "code" ? void 0 : pane.label;
2321
+ }
2253
2322
  function CompareScene({ scene }) {
2254
2323
  const ranges = useSequence(sceneSteps(scene), scene.pace);
2255
2324
  const range = Object.values(ranges)[0];
@@ -2266,8 +2335,8 @@ function CompareScene({ scene }) {
2266
2335
  mode: mode === "wipe" ? "wipe" : "side-by-side",
2267
2336
  before: beforeNode,
2268
2337
  after: afterNode,
2269
- beforeLabel: scene.before.label,
2270
- afterLabel: scene.after.label,
2338
+ beforeLabel: paneLabel(scene.before),
2339
+ afterLabel: paneLabel(scene.after),
2271
2340
  progress,
2272
2341
  className: "w-full"
2273
2342
  }
@@ -2276,7 +2345,45 @@ function CompareScene({ scene }) {
2276
2345
  }
2277
2346
 
2278
2347
  // src/scenes/walkthrough-scene/WalkthroughScene.tsx
2279
- import { useLayoutEffect as useLayoutEffect6, useMemo as useMemo6, useRef as useRef5, useState as useState6 } from "react";
2348
+ import { useLayoutEffect as useLayoutEffect6, useMemo as useMemo6, useRef as useRef5, useState as useState7 } from "react";
2349
+
2350
+ // src/scenes/walkthrough-scene/WalkthroughScene.spotlight.ts
2351
+ var SPOTLIGHT_DIM = 0.6;
2352
+ var FADE_SHARE = 0.2;
2353
+ var FADE_CAP_FRAMES = 12;
2354
+ function lerp(a, b, t) {
2355
+ return a + (b - a) * t;
2356
+ }
2357
+ function lerpRect(from, to, t) {
2358
+ return {
2359
+ x: lerp(from.x, to.x, t),
2360
+ y: lerp(from.y, to.y, t),
2361
+ width: lerp(from.width, to.width, t),
2362
+ height: lerp(from.height, to.height, t)
2363
+ };
2364
+ }
2365
+ function fadeFrames(span) {
2366
+ return Math.min(FADE_CAP_FRAMES, Math.max(1, Math.round(span * FADE_SHARE)));
2367
+ }
2368
+ function spotlightPlan(steps, ranges, index, frame) {
2369
+ const step = steps[index];
2370
+ const range = step ? ranges[stepName(step.id, index)] : void 0;
2371
+ const fallbackDelay = range?.startFrame ?? 0;
2372
+ if (!step?.spotlight || !range) return { focus: null, dim: 0, delay: fallbackDelay };
2373
+ let runStart = index;
2374
+ while (runStart > 0 && steps[runStart - 1].spotlight) runStart -= 1;
2375
+ const delay = ranges[stepName(steps[runStart].id, runStart)]?.startFrame ?? fallbackDelay;
2376
+ const span = Math.max(1, range.endFrame - range.startFrame);
2377
+ const ramp = fadeFrames(span);
2378
+ const previous = index > runStart ? steps[index - 1].spotlight : void 0;
2379
+ const entry = previous ? clamp01((frame - range.startFrame) / ramp) : 1;
2380
+ const focus = previous ? lerpRect(previous, step.spotlight, entry) : step.spotlight;
2381
+ const handsOverToDark = !steps[index + 1]?.spotlight;
2382
+ const exit = handsOverToDark ? clamp01((range.endFrame - frame) / ramp) : 1;
2383
+ return { focus, dim: SPOTLIGHT_DIM * exit, delay };
2384
+ }
2385
+
2386
+ // src/scenes/walkthrough-scene/WalkthroughScene.tsx
2280
2387
  import { jsx as jsx14, jsxs as jsxs12 } from "react/jsx-runtime";
2281
2388
  var ACTION_MAP = {
2282
2389
  none: void 0,
@@ -2293,7 +2400,7 @@ function currentIndex3(steps, ranges, frame) {
2293
2400
  }
2294
2401
  function useFrameSize() {
2295
2402
  const ref = useRef5(null);
2296
- const [size, setSize] = useState6({ width: 0, height: 0 });
2403
+ const [size, setSize] = useState7({ width: 0, height: 0 });
2297
2404
  useLayoutEffect6(() => {
2298
2405
  const el = ref.current;
2299
2406
  if (!el) return void 0;
@@ -2344,13 +2451,13 @@ function WalkthroughScene({ scene }) {
2344
2451
  });
2345
2452
  return out;
2346
2453
  }, [scene.steps, ranges, size]);
2347
- const spotlightFocus = step?.spotlight && size.width > 0 ? rectToPx(step.spotlight, size) : { x: 0, y: 0, width: 0, height: 0 };
2348
- const spotlightDim = step?.spotlight ? 0.6 : 0;
2454
+ const plan = spotlightPlan(scene.steps, ranges, index, frame);
2455
+ const spotlightFocus = plan.focus && size.width > 0 ? rectToPx(plan.focus, size) : { x: 0, y: 0, width: 0, height: 0 };
2349
2456
  const target = step ? resolveTarget(step) : void 0;
2350
2457
  const annotateTarget = step?.annotate && target && size.width > 0 ? toPx(target, size) : void 0;
2351
2458
  const screenshot = /* @__PURE__ */ jsx14(Image, { src: scene.image, alt: scene.title ?? "screenshot", fit: "cover", radius: "none", className: "h-full w-full" });
2352
2459
  return /* @__PURE__ */ jsx14("div", { className: "flex h-full w-full items-center justify-center bg-bg p-panel-xl text-fg", children: /* @__PURE__ */ jsx14("div", { className: "w-full max-w-4xl", children: /* @__PURE__ */ jsx14(DeviceFrame, { variant: scene.frame ?? "browser", url: scene.url, title: scene.title, className: "aspect-video w-full", children: /* @__PURE__ */ jsxs12("div", { ref: frameRef, className: "relative h-full w-full", children: [
2353
- /* @__PURE__ */ jsx14(Spotlight, { focus: spotlightFocus, dim: spotlightDim, delay: range.startFrame, duration: "normal", className: "h-full w-full", children: screenshot }),
2460
+ /* @__PURE__ */ jsx14(Spotlight, { focus: spotlightFocus, dim: plan.dim, delay: plan.delay, duration: "normal", className: "h-full w-full", children: screenshot }),
2354
2461
  /* @__PURE__ */ jsx14(Cursor, { events }),
2355
2462
  annotateTarget && /* @__PURE__ */ jsx14(
2356
2463
  "div",