@ossclip/scenes 0.1.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.
@@ -0,0 +1,264 @@
1
+ import React from "react";
2
+ import { AbsoluteFill, useCurrentFrame, useVideoConfig } from "remotion";
3
+ import type {
4
+ ContentRectSegment,
5
+ FaceCrop,
6
+ SceneCue,
7
+ Theme,
8
+ ZoomSegment,
9
+ } from "@ossclip/core/browser";
10
+ import { zoomScaleAt } from "@ossclip/core/browser";
11
+ import { activeCueAt, backdropOpacityAt, videoSlotAt } from "./stage";
12
+ import {
13
+ contentBox,
14
+ contentRectAtOutput,
15
+ sourceFitBox,
16
+ type ContentCropMode,
17
+ type SourceFit,
18
+ type SpanLike,
19
+ } from "./content-crop";
20
+
21
+ /**
22
+ * The stage (PHASE1 §1): a solid backdrop that fades in when a scene demotes
23
+ * the speaker, and an animated video slot (rect, circular mask, blur, dim)
24
+ * that the EDL video renders inside. The children — <EdlVideo> — stay mounted
25
+ * at all times, so the BASE AUDIO TRACK IS CONTINUOUS regardless of layout.
26
+ */
27
+ export const VideoStage: React.FC<{
28
+ cues: SceneCue[];
29
+ theme: Theme;
30
+ /** Measured face box; null/undefined falls back to the assumed selfie framing. */
31
+ face?: FaceCrop | null;
32
+ /** Micro zoom punches (FINDINGS §15), precomputed from phrase boundaries. */
33
+ zoomPlan?: ZoomSegment[];
34
+ /**
35
+ * Burned-in text in the source, in OUTPUT time. The crop window is nudged so
36
+ * it never slices one of these bands in half (FINDINGS §36).
37
+ */
38
+ sourceTextRegions?: Array<{ y: number; h: number; startSec: number; endSec: number }>;
39
+ /**
40
+ * The source's framing over SOURCE time (PLAN Task C). Present only for a
41
+ * source whose framing CHANGES mid-take — a uniformly letterboxed one is
42
+ * cropped by ffmpeg into the mezzanine long before this runs, and passing a
43
+ * timeline for it would crop the same bars twice.
44
+ */
45
+ contentTimeline?: ContentRectSegment[];
46
+ /** Kept spans, needed to read the timeline's SOURCE clock at an output time. */
47
+ spans?: SpanLike[];
48
+ /** The source's own pixel dimensions — the frame the timeline is measured in. */
49
+ sourceSize?: { width: number; height: number };
50
+ /**
51
+ * How a letterboxed stretch renders: `cover` fills the slot from the strip
52
+ * (face-biased crop), `fit` insets the strip whole — the fallback when the
53
+ * strip is too small to cover without visible softening (option (b)).
54
+ */
55
+ contentCropMode?: ContentCropMode;
56
+ /**
57
+ * How the SOURCE meets the slot (`--source-fit`). `contain` shows the whole
58
+ * frame inset against the backdrop instead of cover-cropping it — the
59
+ * landscape escape hatch. Needs `sourceSize` to know the frame's shape;
60
+ * without it there is nothing to fit and this falls back to cover.
61
+ */
62
+ sourceFit?: SourceFit;
63
+ children: React.ReactNode;
64
+ }> = ({
65
+ cues,
66
+ theme,
67
+ face,
68
+ zoomPlan,
69
+ sourceTextRegions,
70
+ contentTimeline,
71
+ spans,
72
+ sourceSize,
73
+ contentCropMode = "cover",
74
+ sourceFit = "cover",
75
+ children,
76
+ }) => {
77
+ const frame = useCurrentFrame();
78
+ const { fps, width, height } = useVideoConfig();
79
+ const t = frame / fps;
80
+ // The composition already knows its own frame — hand it to the geometry so
81
+ // a landscape export crops against 16:9 instead of the portrait assumption
82
+ // baked in when 9:16 was the only output (R15).
83
+ const outFrame = { width, height };
84
+ const slot = videoSlotAt(cues, t, face ?? undefined, sourceTextRegions ?? [], outFrame);
85
+ const backdrop = backdropOpacityAt(cues, t);
86
+
87
+ const wPx = slot.rect.w * width;
88
+ const hPx = slot.rect.h * height;
89
+ const radiusPx = (slot.cornerRadius * Math.min(wPx, hPx)) / 2;
90
+
91
+ // The user's per-scene crop correction (`overrides.json` -> cue.video).
92
+ // Composed with the idle zoom rather than replacing it, so a scene that was
93
+ // nudged still breathes; NOT lerped across the layout transition, because a
94
+ // correction belongs to one scene and interpolating it into its neighbour
95
+ // would drag the neighbour's crop with it.
96
+ const activeCue = activeCueAt(cues, t);
97
+ const userVideo = activeCue?.video;
98
+
99
+ // §15: the idle zoom fades with the slot (graphic-only suppresses it) and
100
+ // is damped on the bubble — a zooming bubble reads as a wobble. Both fall
101
+ // out of the already-lerped slot state, so the damping stays continuous
102
+ // through layout transitions. It composes with EdlVideo's cut-driven
103
+ // punch-in multiplicatively (nested transforms). Per scene the AUTOMATIC
104
+ // layer is switchable (`autoZoom: false` — PLAN 2026-07-30 Task A3):
105
+ // "adjust the zoomed part" = switch it off and dial your own scale, or
106
+ // leave it on and correct multiplicatively on top.
107
+ const zoomRaw = zoomPlan && zoomPlan.length > 0 ? zoomScaleAt(zoomPlan, t) : 1;
108
+ const zoomDamp = Math.max(0, slot.opacity * (1 - 0.6 * slot.cornerRadius));
109
+ const autoZoom = userVideo?.autoZoom !== false;
110
+ // `contain` promises the WHOLE frame; the idle push would immediately crop
111
+ // it back — a 1.08 scale on an exactly-fitted picture trims 8% off every
112
+ // edge. So the automatic layer is off in that mode by construction, not by
113
+ // asking the user to remember to switch it off per scene. An explicit
114
+ // `cue.video.scale` still applies: that one is a decision, not a default.
115
+ const fitContain = sourceFit === "contain" && sourceSize !== undefined;
116
+ const zoom = autoZoom && !fitContain ? 1 + (zoomRaw - 1) * zoomDamp : 1;
117
+ const userScale = userVideo?.scale ?? 1;
118
+ const userDx = userVideo?.dx ?? 0;
119
+ const userDy = userVideo?.dy ?? 0;
120
+ const contentTransform =
121
+ [
122
+ userDx !== 0 || userDy !== 0 ? `translate(${userDx}px, ${userDy}px)` : "",
123
+ zoom * userScale !== 1 ? `scale(${zoom * userScale})` : "",
124
+ ]
125
+ .filter(Boolean)
126
+ .join(" ") || undefined;
127
+
128
+ return (
129
+ <AbsoluteFill>
130
+ <AbsoluteFill style={{ background: theme.bg, opacity: backdrop }} />
131
+ <div
132
+ // Which scene's framing a grab on the picture edits (PLAN 2026-07-30
133
+ // Task B). ATTRIBUTE ONLY — no cursor, no pointerEvents here: editor
134
+ // affordances stay in the editor; the renderer just states whose
135
+ // video this is right now.
136
+ data-edit-video={activeCue?.id}
137
+ style={{
138
+ position: "absolute",
139
+ left: `${slot.rect.x * 100}%`,
140
+ top: `${slot.rect.y * 100}%`,
141
+ width: wPx,
142
+ height: hPx,
143
+ borderRadius: radiusPx,
144
+ overflow: "hidden",
145
+ opacity: slot.opacity,
146
+ // Under `contain` the picture no longer reaches the slot's edges.
147
+ // Paint the theme's own backdrop behind it so the inset reads as a
148
+ // deliberate frame rather than as black bars: the root fill is
149
+ // black, and every layout except pip/graphic-only leaves the stage
150
+ // backdrop at opacity 0, so without this the gap would be black.
151
+ backgroundColor: fitContain ? theme.bg : undefined,
152
+ // Slightly lift the bubble off the backdrop like the reference.
153
+ boxShadow: slot.cornerRadius > 0.5 ? "0 18px 60px rgba(0,0,0,0.55)" : undefined,
154
+ }}
155
+ >
156
+ <div
157
+ style={{
158
+ position: "absolute",
159
+ inset: 0,
160
+ filter: slot.blurPx > 0.5 ? `blur(${slot.blurPx}px)` : undefined,
161
+ transform: contentTransform,
162
+ // Zoom toward the face, which the crop bias keeps in the upper part.
163
+ transformOrigin: "50% 40%",
164
+ // Crop bias consumed by EdlVideo's object-position (FINDINGS §11/§13):
165
+ // derived from the measured face so the head lands in the band.
166
+ ["--ossclip-obj-y" as string]: `${slot.objectPosY * 100}%`,
167
+ // …and the horizontal one, which only leaves centre for a source
168
+ // wider than the slot (a landscape take in a vertical frame).
169
+ ["--ossclip-obj-x" as string]: `${slot.objectPosX * 100}%`,
170
+ }}
171
+ >
172
+ {fitContain ? (
173
+ // The whole frame, inset and centred. The box carries the SOURCE's
174
+ // aspect, so EdlVideo's `object-fit: cover` has no overflow left to
175
+ // crop and its object-position bias becomes a no-op — the same
176
+ // property the mixed-framing fit path relies on. Takes precedence
177
+ // over the content-rect path below: `contain` means "exactly as
178
+ // recorded", bars in the source included.
179
+ <FitBox source={sourceSize!} slot={{ width: wPx, height: hPx }}>
180
+ {children}
181
+ </FitBox>
182
+ ) : (
183
+ <ContentCrop
184
+ timeline={contentTimeline}
185
+ spans={spans}
186
+ sourceSize={sourceSize}
187
+ mode={contentCropMode}
188
+ tSec={t}
189
+ slot={{ width: wPx, height: hPx }}
190
+ posX={slot.objectPosX}
191
+ posY={slot.objectPosY}
192
+ >
193
+ {children}
194
+ </ContentCrop>
195
+ )}
196
+ </div>
197
+ <div style={{ position: "absolute", inset: 0, background: "black", opacity: slot.dim }} />
198
+ </div>
199
+ </AbsoluteFill>
200
+ );
201
+ };
202
+
203
+ /** Shows the whole source frame, centred inside the slot (`--source-fit contain`). */
204
+ const FitBox: React.FC<{
205
+ source: { width: number; height: number };
206
+ slot: { width: number; height: number };
207
+ children: React.ReactNode;
208
+ }> = ({ source, slot, children }) => {
209
+ const box = sourceFitBox(source, slot);
210
+ return (
211
+ <div
212
+ style={{ position: "absolute", width: box.width, height: box.height, left: box.left, top: box.top }}
213
+ >
214
+ {children}
215
+ </div>
216
+ );
217
+ };
218
+
219
+ /**
220
+ * Crops the video to the source's ACTIVE content rect (PLAN Task C).
221
+ *
222
+ * A no-op — literally the same `inset: 0` box as before — unless the source's
223
+ * framing changes mid-take AND this moment is inside a letterboxed stretch.
224
+ * Keeping the untouched path byte-identical is deliberate: the uniform case is
225
+ * what every existing geometry test covers, and it must not drift because the
226
+ * mixed case needed something.
227
+ *
228
+ * When it does apply, the FULL frame is sized and offset so the content rect
229
+ * covers the slot. The resulting box carries the source's own aspect ratio, so
230
+ * the video's `object-fit: cover` has no overflow left to crop and its
231
+ * `object-position` becomes a no-op — the bias has already been spent here.
232
+ */
233
+ const ContentCrop: React.FC<{
234
+ timeline?: ContentRectSegment[];
235
+ spans?: SpanLike[];
236
+ sourceSize?: { width: number; height: number };
237
+ mode: ContentCropMode;
238
+ tSec: number;
239
+ slot: { width: number; height: number };
240
+ posX: number;
241
+ posY: number;
242
+ children: React.ReactNode;
243
+ }> = ({ timeline, spans, sourceSize, mode, tSec, slot, posX, posY, children }) => {
244
+ const passthrough = <div style={{ position: "absolute", inset: 0 }}>{children}</div>;
245
+ if (!timeline || timeline.length < 2 || !sourceSize) return passthrough;
246
+
247
+ const rect = contentRectAtOutput(timeline, spans ?? [], tSec, sourceSize);
248
+ if (rect.full) return passthrough;
249
+
250
+ const box = contentBox(mode, sourceSize, rect, slot, posX, posY);
251
+ return (
252
+ <div
253
+ style={{
254
+ position: "absolute",
255
+ width: box.width,
256
+ height: box.height,
257
+ left: box.left,
258
+ top: box.top,
259
+ }}
260
+ >
261
+ {children}
262
+ </div>
263
+ );
264
+ };
package/src/anim.ts ADDED
@@ -0,0 +1,26 @@
1
+ import { spring, useCurrentFrame, useVideoConfig } from "remotion";
2
+ import type { CSSProperties } from "react";
3
+
4
+ /**
5
+ * Entrance progress 0→1, scene-local (components are mounted inside a
6
+ * <Sequence> per cue, so frame 0 === the cue's start). Stagger children by
7
+ * passing increasing delays (~4-6 frames, PHASE1 §3).
8
+ */
9
+ export function useEnter(delayFrames = 0): number {
10
+ const frame = useCurrentFrame();
11
+ const { fps } = useVideoConfig();
12
+ return spring({
13
+ frame: frame - delayFrames,
14
+ fps,
15
+ config: { damping: 200, stiffness: 130 },
16
+ durationInFrames: Math.round(fps * 0.5),
17
+ });
18
+ }
19
+
20
+ export function rise(progress: number, px = 26): CSSProperties {
21
+ return { opacity: progress, transform: `translateY(${(1 - progress) * px}px)` };
22
+ }
23
+
24
+ export function pop(progress: number): CSSProperties {
25
+ return { opacity: progress, transform: `scale(${0.9 + 0.1 * progress})` };
26
+ }
@@ -0,0 +1,91 @@
1
+ import React from "react";
2
+ import { z } from "zod/v4";
3
+ import { BulletListProps, type Theme } from "@ossclip/core/browser";
4
+ import { rise, useEnter } from "../anim";
5
+ import { bulletMetrics } from "../fit";
6
+ import { editStyle, type ElementEdits } from "../editable";
7
+
8
+ /**
9
+ * An enumeration (R16 §67): one bullet row per listed item, optional kicker
10
+ * title. Self-fitting like StrikethroughReveal — rows are nowrap (a wrapped
11
+ * bullet stops reading as a list), so the type solves against the slot in
12
+ * `bulletMetrics` and the stage's uniform scale stays out of it.
13
+ */
14
+ const Item: React.FC<{
15
+ text: string;
16
+ index: number;
17
+ fontSize: number;
18
+ theme: Theme;
19
+ edits?: ElementEdits;
20
+ }> = ({ text, index, fontSize, theme, edits }) => {
21
+ const editId = `item-${index}`;
22
+ const p = useEnter(index * 7);
23
+ return (
24
+ <div
25
+ data-edit-id={editId}
26
+ style={{
27
+ ...rise(p, 26),
28
+ display: "flex",
29
+ alignItems: "baseline",
30
+ gap: fontSize * 0.35,
31
+ ...editStyle(edits, editId),
32
+ }}
33
+ >
34
+ <span style={{ fontSize: fontSize * 0.75, color: theme.accent, fontWeight: 900 }}>▸</span>
35
+ <span
36
+ style={{
37
+ fontSize,
38
+ fontWeight: 900,
39
+ textTransform: "uppercase",
40
+ lineHeight: 1.15,
41
+ whiteSpace: "nowrap",
42
+ color: theme.fg,
43
+ }}
44
+ >
45
+ {text}
46
+ </span>
47
+ </div>
48
+ );
49
+ };
50
+
51
+ export const BulletList: React.FC<{
52
+ props: z.infer<typeof BulletListProps>;
53
+ theme: Theme;
54
+ widthPx?: number;
55
+ heightPx?: number;
56
+ edits?: ElementEdits;
57
+ }> = ({ props, theme, widthPx, heightPx, edits }) => {
58
+ const fontSize = bulletMetrics(props.items, widthPx, heightPx, Boolean(props.title));
59
+ const titleEnter = useEnter(0);
60
+ return (
61
+ <div
62
+ style={{
63
+ display: "flex",
64
+ flexDirection: "column",
65
+ alignItems: "flex-start",
66
+ gap: fontSize * 0.45,
67
+ fontFamily: theme.fontDisplay,
68
+ }}
69
+ >
70
+ {props.title ? (
71
+ <div
72
+ data-edit-id="title"
73
+ style={{
74
+ ...rise(titleEnter, 20),
75
+ fontSize: fontSize * 0.36,
76
+ fontWeight: 700,
77
+ letterSpacing: "0.28em",
78
+ textTransform: "uppercase",
79
+ color: theme.muted,
80
+ ...editStyle(edits, "title"),
81
+ }}
82
+ >
83
+ {props.title}
84
+ </div>
85
+ ) : null}
86
+ {props.items.map((text, i) => (
87
+ <Item key={i} text={text} index={i} fontSize={fontSize} theme={theme} edits={edits} />
88
+ ))}
89
+ </div>
90
+ );
91
+ };
@@ -0,0 +1,96 @@
1
+ import React from "react";
2
+ import { z } from "zod/v4";
3
+ import { ChatMockProps, type Theme } from "@ossclip/core/browser";
4
+ import { pop, useEnter } from "../anim";
5
+ import { chatBubbles, chatMetrics } from "../fit";
6
+ import { editStyle, type ElementEdits } from "../editable";
7
+
8
+ const Bubble: React.FC<{
9
+ from: "user" | "agent";
10
+ text: string;
11
+ delay: number;
12
+ fontSize: number;
13
+ theme: Theme;
14
+ editId: string;
15
+ edits?: ElementEdits;
16
+ }> = ({ from, text, delay, fontSize, theme, editId, edits }) => {
17
+ const p = useEnter(delay);
18
+ const mine = from === "user";
19
+ return (
20
+ <div
21
+ data-edit-id={editId}
22
+ style={{
23
+ ...pop(p),
24
+ alignSelf: mine ? "flex-end" : "flex-start",
25
+ background: mine ? theme.fg : theme.cardBg,
26
+ color: mine ? theme.bg : theme.fg,
27
+ border: `2px solid ${mine ? theme.fg : theme.cardBorder}`,
28
+ borderRadius: 28,
29
+ [mine ? "borderBottomRightRadius" : "borderBottomLeftRadius"]: 8,
30
+ // Padding is a HARD boundary, not a suggestion: the type is sized so
31
+ // the longest unbreakable word fits inside bubble-minus-padding, which
32
+ // is what stops a single word like "AGENTS" rendering edge to edge
33
+ // and spilling past the rounded rect (FINDINGS §28a).
34
+ padding: `${fontSize * 0.6}px ${fontSize * 0.85}px`,
35
+ fontSize,
36
+ fontWeight: 700,
37
+ maxWidth: "82%",
38
+ fontFamily: theme.fontDisplay,
39
+ ...editStyle(edits, editId),
40
+ }}
41
+ >
42
+ {text}
43
+ </div>
44
+ );
45
+ };
46
+
47
+ /**
48
+ * The comment-CTA keyword gets the reference's quote-and-caps treatment —
49
+ * `"AGENTS"` — wherever it appears in a message. The producer only marks the
50
+ * word (props.keyword); formatting lives here, never in LLM output
51
+ * (FINDINGS §16). Quotes the LLM already added are absorbed, not doubled.
52
+ */
53
+ export function applyCtaKeyword(text: string, keyword: string | undefined): string {
54
+ if (!keyword) return text;
55
+ const escaped = keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
56
+ return text.replace(new RegExp(`"?\\b${escaped}\\b"?`, "gi"), `"${keyword.toUpperCase()}"`);
57
+ }
58
+
59
+ export { chatBubbles } from "../fit";
60
+
61
+ export const ChatMock: React.FC<{
62
+ props: z.infer<typeof ChatMockProps>;
63
+ theme: Theme;
64
+ widthPx?: number;
65
+ heightPx?: number;
66
+ edits?: ElementEdits;
67
+ }> = ({ props, theme, widthPx, heightPx, edits }) => {
68
+ const bubbles = chatBubbles(props);
69
+ // Self-fitting (R11 Task 3): the type solves against the REAL slot, both
70
+ // axes — the stage no longer magnifies this component.
71
+ const fontSize = chatMetrics(bubbles.map((b) => b.text), { widthPx, heightPx });
72
+ return (
73
+ <div
74
+ style={{
75
+ display: "flex",
76
+ flexDirection: "column",
77
+ gap: fontSize * 0.5,
78
+ width: "100%",
79
+ padding: "0 40px",
80
+ }}
81
+ >
82
+ {bubbles.map((b, i) => (
83
+ <Bubble
84
+ key={i}
85
+ from={b.from}
86
+ text={b.text}
87
+ delay={i * 8}
88
+ fontSize={fontSize}
89
+ theme={theme}
90
+ editId={`message-${i}`}
91
+ edits={edits}
92
+ />
93
+ ))}
94
+ </div>
95
+ );
96
+ };
@@ -0,0 +1,138 @@
1
+ import React from "react";
2
+ import { z } from "zod/v4";
3
+ import { FlowDiagramProps, type Theme } from "@ossclip/core/browser";
4
+ import { pop, useEnter } from "../anim";
5
+ import { flowMetrics } from "../fit";
6
+ import { editStyle, type ElementEdits } from "../editable";
7
+
8
+ const Chip: React.FC<{
9
+ text: string;
10
+ emphasized: boolean;
11
+ delay: number;
12
+ fontSize: number;
13
+ theme: Theme;
14
+ editId: string;
15
+ edits?: ElementEdits;
16
+ }> = ({ text, emphasized, delay, fontSize, theme, editId, edits }) => {
17
+ const p = useEnter(delay);
18
+ return (
19
+ <div
20
+ data-edit-id={editId}
21
+ style={{
22
+ ...pop(p),
23
+ background: emphasized ? theme.fg : theme.cardBg,
24
+ color: emphasized ? theme.bg : theme.fg,
25
+ border: `2px solid ${emphasized ? theme.fg : theme.cardBorder}`,
26
+ borderRadius: theme.radiusPx / 2,
27
+ padding: `${fontSize * 0.55}px ${fontSize * 0.8}px`,
28
+ fontSize,
29
+ fontWeight: 900,
30
+ textTransform: "uppercase",
31
+ letterSpacing: "0.04em",
32
+ whiteSpace: "nowrap",
33
+ fontFamily: theme.fontDisplay,
34
+ ...editStyle(edits, editId),
35
+ }}
36
+ >
37
+ {text}
38
+ </div>
39
+ );
40
+ };
41
+
42
+ const Arrow: React.FC<{ delay: number; fontSize: number; theme: Theme; down?: boolean }> = ({
43
+ delay,
44
+ fontSize,
45
+ theme,
46
+ down = false,
47
+ }) => {
48
+ const p = useEnter(delay);
49
+ return (
50
+ <div
51
+ style={{
52
+ opacity: p,
53
+ color: theme.muted,
54
+ fontSize: fontSize * 1.15,
55
+ fontWeight: 700,
56
+ lineHeight: 1,
57
+ paddingLeft: down ? 0 : fontSize * 0.55,
58
+ }}
59
+ >
60
+ {down ? "↓" : "→"}
61
+ </div>
62
+ );
63
+ };
64
+
65
+ /**
66
+ * Row vs stack, decided from content (FINDINGS §1/§12): fit-to-width scales
67
+ * the type down for a single row, and when real copy can't fit even at the
68
+ * font floor — the golden fixture's short labels hid this — the diagram
69
+ * becomes a vertical stack with downward arrows instead of ever wrapping.
70
+ *
71
+ * The width budget is the container's, not a constant: the stage now scales
72
+ * every graphic to fill its slot (§23), so the diagram is laid out at a width
73
+ * derived from that scale rather than against a hardcoded 820px that no longer
74
+ * corresponds to any slot. The old row/stack font caps are gone with it —
75
+ * capping the type here is exactly what left the diagram an 8%-tall strip.
76
+ */
77
+ export function flowLayout(
78
+ nodes: readonly string[],
79
+ widthPx = DEFAULT_FLOW_WIDTH_PX,
80
+ heightPx?: number,
81
+ ): { mode: "row" | "stack"; fontSize: number } {
82
+ return flowMetrics(nodes, widthPx, heightPx);
83
+ }
84
+
85
+ /** Fallback when the stage isn't supplying a budget (tests, direct use). */
86
+ const DEFAULT_FLOW_WIDTH_PX = 864;
87
+
88
+ export const FlowDiagram: React.FC<{
89
+ props: z.infer<typeof FlowDiagramProps>;
90
+ theme: Theme;
91
+ /** The slot this component must fill — see flowMetrics (FINDINGS §23). */
92
+ widthPx?: number;
93
+ heightPx?: number;
94
+ edits?: ElementEdits;
95
+ }> = ({ props, theme, widthPx, heightPx, edits }) => {
96
+ // The stage hands down the budget; no measurement needed because the slot
97
+ // geometry is known up front.
98
+ const { mode, fontSize } = flowLayout(props.nodes, widthPx, heightPx);
99
+ const row = mode === "row";
100
+ return (
101
+ <div
102
+ style={{
103
+ display: "flex",
104
+ flexDirection: row ? "row" : "column",
105
+ flexWrap: "nowrap",
106
+ alignItems: "center",
107
+ justifyContent: "center",
108
+ gap: row ? 0 : fontSize * 0.45,
109
+ padding: "0 10px",
110
+ }}
111
+ >
112
+ {props.nodes.map((node, i) => (
113
+ // Arrow and its target chip are ONE flex item, and the arrow enters
114
+ // AFTER its chip — an arrow into nothing is impossible in either shape.
115
+ <div
116
+ key={i}
117
+ style={{
118
+ display: "flex",
119
+ flexDirection: row ? "row" : "column",
120
+ alignItems: "center",
121
+ gap: row ? fontSize * 0.55 : fontSize * 0.45,
122
+ }}
123
+ >
124
+ {i > 0 ? <Arrow delay={i * 6 + 3} fontSize={fontSize} theme={theme} down={!row} /> : null}
125
+ <Chip
126
+ text={node}
127
+ emphasized={props.emphasizeLast && i === props.nodes.length - 1}
128
+ delay={i * 6}
129
+ fontSize={fontSize}
130
+ theme={theme}
131
+ editId={`node-${i}`}
132
+ edits={edits}
133
+ />
134
+ </div>
135
+ ))}
136
+ </div>
137
+ );
138
+ };