@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.
- package/LICENSE +27 -0
- package/README.md +20 -0
- package/package.json +38 -0
- package/src/CaptionTrack.tsx +209 -0
- package/src/EdlVideo.tsx +87 -0
- package/src/SceneLayer.tsx +184 -0
- package/src/VideoStage.tsx +264 -0
- package/src/anim.ts +26 -0
- package/src/components/BulletList.tsx +91 -0
- package/src/components/ChatMock.tsx +96 -0
- package/src/components/FlowDiagram.tsx +138 -0
- package/src/components/RuleCard.tsx +86 -0
- package/src/components/ScreenshotFrame.tsx +94 -0
- package/src/components/StatCard.tsx +90 -0
- package/src/components/StrikethroughReveal.tsx +130 -0
- package/src/components/TerminalMock.tsx +105 -0
- package/src/components/TitleCard.tsx +95 -0
- package/src/content-crop.ts +179 -0
- package/src/editable.ts +51 -0
- package/src/fit.ts +536 -0
- package/src/geometry.ts +8 -0
- package/src/index.ts +5 -0
- package/src/source-fit.ts +218 -0
- package/src/stage.ts +764 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { RuleCardProps, type Theme } from "@ossclip/core/browser";
|
|
4
|
+
import { pop, rise, useEnter } from "../anim";
|
|
5
|
+
import { editStyle, type ElementEdits } from "../editable";
|
|
6
|
+
|
|
7
|
+
export const RuleCard: React.FC<{
|
|
8
|
+
props: z.infer<typeof RuleCardProps>;
|
|
9
|
+
theme: Theme;
|
|
10
|
+
edits?: ElementEdits;
|
|
11
|
+
}> = ({ props, theme, edits }) => {
|
|
12
|
+
const p0 = useEnter(0);
|
|
13
|
+
const p1 = useEnter(8);
|
|
14
|
+
return (
|
|
15
|
+
<div
|
|
16
|
+
style={{
|
|
17
|
+
display: "flex",
|
|
18
|
+
flexDirection: "column",
|
|
19
|
+
alignItems: "center",
|
|
20
|
+
gap: 26,
|
|
21
|
+
fontFamily: theme.fontDisplay,
|
|
22
|
+
width: "100%",
|
|
23
|
+
padding: "0 30px",
|
|
24
|
+
}}
|
|
25
|
+
>
|
|
26
|
+
<div
|
|
27
|
+
style={{
|
|
28
|
+
...pop(p0),
|
|
29
|
+
width: "100%",
|
|
30
|
+
background: theme.fg,
|
|
31
|
+
color: theme.bg,
|
|
32
|
+
borderRadius: theme.radiusPx,
|
|
33
|
+
padding: "40px 48px",
|
|
34
|
+
textAlign: "left",
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<div
|
|
38
|
+
data-edit-id="kicker"
|
|
39
|
+
style={{
|
|
40
|
+
fontSize: 30,
|
|
41
|
+
fontWeight: 800,
|
|
42
|
+
letterSpacing: "0.28em",
|
|
43
|
+
textTransform: "uppercase",
|
|
44
|
+
color: "#55555E",
|
|
45
|
+
marginBottom: 14,
|
|
46
|
+
fontFamily: theme.fontMono,
|
|
47
|
+
...editStyle(edits, "kicker"),
|
|
48
|
+
}}
|
|
49
|
+
>
|
|
50
|
+
{props.kicker}
|
|
51
|
+
</div>
|
|
52
|
+
<div
|
|
53
|
+
data-edit-id="text"
|
|
54
|
+
style={{
|
|
55
|
+
fontSize: 72,
|
|
56
|
+
fontWeight: 900,
|
|
57
|
+
lineHeight: 1.02,
|
|
58
|
+
textTransform: "uppercase",
|
|
59
|
+
...editStyle(edits, "text"),
|
|
60
|
+
}}
|
|
61
|
+
>
|
|
62
|
+
{props.text}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
{props.struck ? (
|
|
66
|
+
<div
|
|
67
|
+
data-edit-id="struck"
|
|
68
|
+
style={{
|
|
69
|
+
...rise(p1),
|
|
70
|
+
fontSize: 44,
|
|
71
|
+
fontWeight: 800,
|
|
72
|
+
color: theme.muted,
|
|
73
|
+
textDecoration: "line-through",
|
|
74
|
+
textDecorationThickness: 6,
|
|
75
|
+
textDecorationColor: theme.danger,
|
|
76
|
+
textTransform: "uppercase",
|
|
77
|
+
letterSpacing: "0.04em",
|
|
78
|
+
...editStyle(edits, "struck"),
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
{props.struck}
|
|
82
|
+
</div>
|
|
83
|
+
) : null}
|
|
84
|
+
</div>
|
|
85
|
+
);
|
|
86
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { Img, interpolate, staticFile, useCurrentFrame, useVideoConfig } from "remotion";
|
|
4
|
+
import { ScreenshotFrameProps, type Theme } from "@ossclip/core/browser";
|
|
5
|
+
import { pop, useEnter } from "../anim";
|
|
6
|
+
import { editStyle, type ElementEdits } from "../editable";
|
|
7
|
+
|
|
8
|
+
export const ScreenshotFrame: React.FC<{
|
|
9
|
+
props: z.infer<typeof ScreenshotFrameProps>;
|
|
10
|
+
theme: Theme;
|
|
11
|
+
edits?: ElementEdits;
|
|
12
|
+
}> = ({ props, theme, edits }) => {
|
|
13
|
+
const p = useEnter(0);
|
|
14
|
+
const labelP = useEnter(8);
|
|
15
|
+
const frame = useCurrentFrame();
|
|
16
|
+
const { fps } = useVideoConfig();
|
|
17
|
+
const drift = props.kenBurns
|
|
18
|
+
? interpolate(frame, [0, fps * 8], [1.0, 1.08], { extrapolateRight: "clamp" })
|
|
19
|
+
: 1;
|
|
20
|
+
const src = props.src
|
|
21
|
+
? /^https?:\/\//.test(props.src)
|
|
22
|
+
? props.src
|
|
23
|
+
: staticFile(props.src)
|
|
24
|
+
: null;
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
data-edit-id="image"
|
|
28
|
+
style={{ ...pop(p), position: "relative", width: "94%", ...editStyle(edits, "image") }}
|
|
29
|
+
>
|
|
30
|
+
<div
|
|
31
|
+
style={{
|
|
32
|
+
overflow: "hidden",
|
|
33
|
+
borderRadius: theme.radiusPx / 2,
|
|
34
|
+
border: `2px solid ${theme.cardBorder}`,
|
|
35
|
+
background: theme.cardBg,
|
|
36
|
+
boxShadow: "0 24px 80px rgba(0,0,0,0.5)",
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
{src ? (
|
|
40
|
+
<Img
|
|
41
|
+
src={src}
|
|
42
|
+
style={{ width: "100%", display: "block", transform: `scale(${drift})` }}
|
|
43
|
+
/>
|
|
44
|
+
) : (
|
|
45
|
+
<div
|
|
46
|
+
style={{
|
|
47
|
+
height: 420,
|
|
48
|
+
display: "flex",
|
|
49
|
+
flexDirection: "column",
|
|
50
|
+
gap: 14,
|
|
51
|
+
padding: 30,
|
|
52
|
+
transform: `scale(${drift})`,
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{[0.9, 0.75, 0.85, 0.6, 0.8, 0.5].map((w, i) => (
|
|
56
|
+
<div
|
|
57
|
+
key={i}
|
|
58
|
+
style={{
|
|
59
|
+
height: 22,
|
|
60
|
+
width: `${w * 100}%`,
|
|
61
|
+
borderRadius: 6,
|
|
62
|
+
background: i % 3 === 0 ? theme.cardBorder : "#22222B",
|
|
63
|
+
}}
|
|
64
|
+
/>
|
|
65
|
+
))}
|
|
66
|
+
</div>
|
|
67
|
+
)}
|
|
68
|
+
</div>
|
|
69
|
+
{props.label ? (
|
|
70
|
+
<div
|
|
71
|
+
data-edit-id="label"
|
|
72
|
+
style={{
|
|
73
|
+
...pop(labelP),
|
|
74
|
+
position: "absolute",
|
|
75
|
+
right: -8,
|
|
76
|
+
bottom: -20,
|
|
77
|
+
background: theme.fg,
|
|
78
|
+
color: theme.bg,
|
|
79
|
+
fontFamily: theme.fontMono,
|
|
80
|
+
fontSize: 28,
|
|
81
|
+
fontWeight: 800,
|
|
82
|
+
letterSpacing: "0.08em",
|
|
83
|
+
padding: "12px 22px",
|
|
84
|
+
borderRadius: 10,
|
|
85
|
+
textTransform: "uppercase",
|
|
86
|
+
...editStyle(edits, "label"),
|
|
87
|
+
}}
|
|
88
|
+
>
|
|
89
|
+
{props.label}
|
|
90
|
+
</div>
|
|
91
|
+
) : null}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { StatCardProps, type Theme } from "@ossclip/core/browser";
|
|
4
|
+
import { pop, rise, useEnter } from "../anim";
|
|
5
|
+
import { editStyle, type ElementEdits } from "../editable";
|
|
6
|
+
|
|
7
|
+
export const StatCard: React.FC<{
|
|
8
|
+
props: z.infer<typeof StatCardProps>;
|
|
9
|
+
theme: Theme;
|
|
10
|
+
edits?: ElementEdits;
|
|
11
|
+
}> = ({ props, theme, edits }) => {
|
|
12
|
+
const p0 = useEnter(0);
|
|
13
|
+
const p1 = useEnter(6);
|
|
14
|
+
const inverted = props.inverted;
|
|
15
|
+
return (
|
|
16
|
+
<div
|
|
17
|
+
style={{
|
|
18
|
+
display: "flex",
|
|
19
|
+
flexDirection: "column",
|
|
20
|
+
alignItems: "center",
|
|
21
|
+
gap: 30,
|
|
22
|
+
fontFamily: theme.fontDisplay,
|
|
23
|
+
width: "100%",
|
|
24
|
+
padding: "0 30px",
|
|
25
|
+
}}
|
|
26
|
+
>
|
|
27
|
+
<div
|
|
28
|
+
style={{
|
|
29
|
+
...pop(p0),
|
|
30
|
+
display: "flex",
|
|
31
|
+
alignItems: "center",
|
|
32
|
+
justifyContent: "space-between",
|
|
33
|
+
gap: 40,
|
|
34
|
+
width: "100%",
|
|
35
|
+
background: inverted ? theme.fg : theme.cardBg,
|
|
36
|
+
color: inverted ? theme.bg : theme.fg,
|
|
37
|
+
border: `2px solid ${inverted ? theme.fg : theme.cardBorder}`,
|
|
38
|
+
borderRadius: theme.radiusPx,
|
|
39
|
+
padding: "44px 52px",
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
<div
|
|
43
|
+
data-edit-id="label"
|
|
44
|
+
style={{
|
|
45
|
+
fontSize: 42,
|
|
46
|
+
fontWeight: 800,
|
|
47
|
+
textTransform: "uppercase",
|
|
48
|
+
letterSpacing: "0.06em",
|
|
49
|
+
lineHeight: 1.15,
|
|
50
|
+
maxWidth: "55%",
|
|
51
|
+
...editStyle(edits, "label"),
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
{props.label}
|
|
55
|
+
</div>
|
|
56
|
+
<div
|
|
57
|
+
data-edit-id="value"
|
|
58
|
+
style={{
|
|
59
|
+
fontSize: 110,
|
|
60
|
+
fontWeight: 900,
|
|
61
|
+
whiteSpace: "nowrap",
|
|
62
|
+
...editStyle(edits, "value"),
|
|
63
|
+
}}
|
|
64
|
+
>
|
|
65
|
+
{props.value}
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
{props.caption ? (
|
|
69
|
+
<div
|
|
70
|
+
data-edit-id="caption"
|
|
71
|
+
style={{
|
|
72
|
+
...rise(p1),
|
|
73
|
+
fontSize: 38,
|
|
74
|
+
fontWeight: 800,
|
|
75
|
+
color: theme.fg,
|
|
76
|
+
background: theme.cardBg,
|
|
77
|
+
border: `2px solid ${theme.cardBorder}`,
|
|
78
|
+
borderRadius: theme.radiusPx / 2,
|
|
79
|
+
padding: "16px 30px",
|
|
80
|
+
textTransform: "uppercase",
|
|
81
|
+
letterSpacing: "0.08em",
|
|
82
|
+
...editStyle(edits, "caption"),
|
|
83
|
+
}}
|
|
84
|
+
>
|
|
85
|
+
{props.caption}
|
|
86
|
+
</div>
|
|
87
|
+
) : null}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
};
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { StrikethroughRevealProps, type Theme } from "@ossclip/core/browser";
|
|
4
|
+
import { rise, useEnter } from "../anim";
|
|
5
|
+
import { revealMetrics, revealRows } from "../fit";
|
|
6
|
+
import { editStyle, type ElementEdits } from "../editable";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* One RENDERED row, with its own strike rule.
|
|
10
|
+
*
|
|
11
|
+
* The rule must be per rendered row, not per logical line: when a line wrapped,
|
|
12
|
+
* the absolutely-positioned bar sat at 50% of the whole two-line block and so
|
|
13
|
+
* struck the gap between the rows rather than either of them (FINDINGS §27).
|
|
14
|
+
*
|
|
15
|
+
* `editId` is keyed by the LOGICAL line (props.lines index), not the rendered
|
|
16
|
+
* row: a wrapped line produces multiple rows here, and all of them share the
|
|
17
|
+
* same id/edits so a user's nudge moves every row of that line together
|
|
18
|
+
* rather than only the first fragment.
|
|
19
|
+
*/
|
|
20
|
+
const Row: React.FC<{
|
|
21
|
+
text: string;
|
|
22
|
+
struck: boolean;
|
|
23
|
+
/** Verdict glyph (R16 §66) — ✗/✓ leading the row, none for plain lines. */
|
|
24
|
+
mark: "none" | "cross" | "check";
|
|
25
|
+
delay: number;
|
|
26
|
+
fontSize: number;
|
|
27
|
+
theme: Theme;
|
|
28
|
+
editId: string;
|
|
29
|
+
edits?: ElementEdits;
|
|
30
|
+
}> = ({ text, struck, mark, delay, fontSize, theme, editId, edits }) => {
|
|
31
|
+
const p = useEnter(delay);
|
|
32
|
+
const strike = useEnter(delay + 8);
|
|
33
|
+
return (
|
|
34
|
+
<div
|
|
35
|
+
data-edit-id={editId}
|
|
36
|
+
style={{ ...rise(p, 30), position: "relative", display: "inline-block", ...editStyle(edits, editId) }}
|
|
37
|
+
>
|
|
38
|
+
<span
|
|
39
|
+
style={{
|
|
40
|
+
fontSize,
|
|
41
|
+
fontWeight: 900,
|
|
42
|
+
textTransform: "uppercase",
|
|
43
|
+
lineHeight: 1.08,
|
|
44
|
+
whiteSpace: "nowrap",
|
|
45
|
+
color: struck ? theme.muted : theme.fg,
|
|
46
|
+
}}
|
|
47
|
+
>
|
|
48
|
+
{mark !== "none" ? (
|
|
49
|
+
<span
|
|
50
|
+
style={{
|
|
51
|
+
color: mark === "cross" ? theme.danger : theme.success,
|
|
52
|
+
marginRight: "0.3em",
|
|
53
|
+
}}
|
|
54
|
+
>
|
|
55
|
+
{mark === "cross" ? "✗" : "✓"}
|
|
56
|
+
</span>
|
|
57
|
+
) : null}
|
|
58
|
+
{text}
|
|
59
|
+
</span>
|
|
60
|
+
{struck ? (
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
position: "absolute",
|
|
64
|
+
left: "-2%",
|
|
65
|
+
top: "50%",
|
|
66
|
+
height: fontSize * 0.11,
|
|
67
|
+
width: `${strike * 104}%`,
|
|
68
|
+
background: theme.danger,
|
|
69
|
+
borderRadius: fontSize * 0.055,
|
|
70
|
+
transform: "translateY(-50%) rotate(-2deg)",
|
|
71
|
+
}}
|
|
72
|
+
/>
|
|
73
|
+
) : null}
|
|
74
|
+
</div>
|
|
75
|
+
);
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export const StrikethroughReveal: React.FC<{
|
|
79
|
+
props: z.infer<typeof StrikethroughRevealProps>;
|
|
80
|
+
theme: Theme;
|
|
81
|
+
widthPx?: number;
|
|
82
|
+
heightPx?: number;
|
|
83
|
+
edits?: ElementEdits;
|
|
84
|
+
}> = ({ props, theme, widthPx, heightPx, edits }) => {
|
|
85
|
+
// The verdict glyph rides in the width estimate ("✗ " ≈ its real footprint)
|
|
86
|
+
// so a marked line shrinks with its glyph instead of overflowing. `mark` is
|
|
87
|
+
// read defensively: baked render-props from before §66 carry no key.
|
|
88
|
+
const texts = props.lines.map((l) =>
|
|
89
|
+
(l.mark ?? "none") !== "none" ? `✗ ${l.text}` : l.text,
|
|
90
|
+
);
|
|
91
|
+
const fontSize = revealMetrics(texts, widthPx, heightPx);
|
|
92
|
+
// Each logical line becomes one or more rows; a line only breaks at an
|
|
93
|
+
// arrow, and the arrow leads the row it points into. lineIndex is kept
|
|
94
|
+
// alongside so every row can carry its logical line's data-edit-id. The
|
|
95
|
+
// glyph marks only the FIRST row of a wrapped line — one verdict per line.
|
|
96
|
+
const rows = props.lines.flatMap((line, lineIndex) =>
|
|
97
|
+
revealRows(line.text, fontSize, widthPx ?? 831).map((text, rowIdx) => ({
|
|
98
|
+
text,
|
|
99
|
+
struck: line.struck,
|
|
100
|
+
mark: rowIdx === 0 ? (line.mark ?? "none") : ("none" as const),
|
|
101
|
+
lineIndex,
|
|
102
|
+
})),
|
|
103
|
+
);
|
|
104
|
+
return (
|
|
105
|
+
<div
|
|
106
|
+
style={{
|
|
107
|
+
display: "flex",
|
|
108
|
+
flexDirection: "column",
|
|
109
|
+
alignItems: "center",
|
|
110
|
+
gap: fontSize * 0.2,
|
|
111
|
+
textAlign: "center",
|
|
112
|
+
fontFamily: theme.fontDisplay,
|
|
113
|
+
}}
|
|
114
|
+
>
|
|
115
|
+
{rows.map((row, i) => (
|
|
116
|
+
<Row
|
|
117
|
+
key={i}
|
|
118
|
+
text={row.text}
|
|
119
|
+
struck={row.struck}
|
|
120
|
+
mark={row.mark}
|
|
121
|
+
delay={i * 6}
|
|
122
|
+
fontSize={fontSize}
|
|
123
|
+
theme={theme}
|
|
124
|
+
editId={`line-${row.lineIndex}`}
|
|
125
|
+
edits={edits}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</div>
|
|
129
|
+
);
|
|
130
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { TerminalMockProps, type Theme } from "@ossclip/core/browser";
|
|
4
|
+
import { pop, rise, useEnter } from "../anim";
|
|
5
|
+
import { editStyle, type ElementEdits } from "../editable";
|
|
6
|
+
|
|
7
|
+
const Window: React.FC<{
|
|
8
|
+
title: string;
|
|
9
|
+
lines: string[];
|
|
10
|
+
delay: number;
|
|
11
|
+
theme: Theme;
|
|
12
|
+
editId: string;
|
|
13
|
+
edits?: ElementEdits;
|
|
14
|
+
}> = ({ title, lines, delay, theme, editId, edits }) => {
|
|
15
|
+
const p = useEnter(delay);
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
data-edit-id={editId}
|
|
19
|
+
style={{
|
|
20
|
+
...pop(p),
|
|
21
|
+
background: theme.cardBg,
|
|
22
|
+
border: `2px solid ${theme.cardBorder}`,
|
|
23
|
+
borderRadius: theme.radiusPx / 2,
|
|
24
|
+
overflow: "hidden",
|
|
25
|
+
width: "100%",
|
|
26
|
+
fontFamily: theme.fontMono,
|
|
27
|
+
...editStyle(edits, editId),
|
|
28
|
+
}}
|
|
29
|
+
>
|
|
30
|
+
<div
|
|
31
|
+
style={{
|
|
32
|
+
display: "flex",
|
|
33
|
+
alignItems: "center",
|
|
34
|
+
gap: 10,
|
|
35
|
+
padding: "14px 20px",
|
|
36
|
+
borderBottom: `2px solid ${theme.cardBorder}`,
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
{["#FF5F57", "#FEBC2E", "#28C840"].map((c) => (
|
|
40
|
+
<div key={c} style={{ width: 16, height: 16, borderRadius: 8, background: c }} />
|
|
41
|
+
))}
|
|
42
|
+
<div style={{ marginLeft: 10, fontSize: 24, color: theme.muted }}>{title}</div>
|
|
43
|
+
</div>
|
|
44
|
+
<div style={{ padding: "18px 22px", display: "flex", flexDirection: "column", gap: 8 }}>
|
|
45
|
+
{lines.map((line, i) => (
|
|
46
|
+
<div key={i} style={{ fontSize: 27, color: theme.fg, whiteSpace: "pre" }}>
|
|
47
|
+
{line}
|
|
48
|
+
</div>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export const TerminalMock: React.FC<{
|
|
56
|
+
props: z.infer<typeof TerminalMockProps>;
|
|
57
|
+
theme: Theme;
|
|
58
|
+
edits?: ElementEdits;
|
|
59
|
+
}> = ({ props, theme, edits }) => {
|
|
60
|
+
const tail = useEnter(props.windows.length * 6 + 4);
|
|
61
|
+
return (
|
|
62
|
+
<div
|
|
63
|
+
style={{
|
|
64
|
+
display: "flex",
|
|
65
|
+
flexDirection: "column",
|
|
66
|
+
alignItems: "center",
|
|
67
|
+
gap: 22,
|
|
68
|
+
width: "100%",
|
|
69
|
+
padding: "0 30px",
|
|
70
|
+
}}
|
|
71
|
+
>
|
|
72
|
+
{props.windows.map((w, i) => (
|
|
73
|
+
<Window
|
|
74
|
+
key={i}
|
|
75
|
+
title={w.title}
|
|
76
|
+
lines={w.lines}
|
|
77
|
+
delay={i * 6}
|
|
78
|
+
theme={theme}
|
|
79
|
+
editId={`window-${i}`}
|
|
80
|
+
edits={edits}
|
|
81
|
+
/>
|
|
82
|
+
))}
|
|
83
|
+
{props.fanOut ? (
|
|
84
|
+
<div
|
|
85
|
+
// The last un-transformable element on any component (R17 §85
|
|
86
|
+
// audit): with an id it joins the generic drag/resize/retype
|
|
87
|
+
// machinery like everything else.
|
|
88
|
+
data-edit-id="fanOut"
|
|
89
|
+
style={{
|
|
90
|
+
...rise(tail),
|
|
91
|
+
fontFamily: theme.fontDisplay,
|
|
92
|
+
fontSize: 40,
|
|
93
|
+
fontWeight: 900,
|
|
94
|
+
letterSpacing: "0.2em",
|
|
95
|
+
color: theme.fg,
|
|
96
|
+
textTransform: "uppercase",
|
|
97
|
+
...editStyle(edits, "fanOut"),
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
⌄ {props.fanOut}
|
|
101
|
+
</div>
|
|
102
|
+
) : null}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { z } from "zod/v4";
|
|
3
|
+
import { TitleCardProps, type Theme } from "@ossclip/core/browser";
|
|
4
|
+
import { rise, useEnter } from "../anim";
|
|
5
|
+
import { editStyle, type ElementEdits } from "../editable";
|
|
6
|
+
|
|
7
|
+
export const TitleCard: React.FC<{
|
|
8
|
+
props: z.infer<typeof TitleCardProps>;
|
|
9
|
+
theme: Theme;
|
|
10
|
+
edits?: ElementEdits;
|
|
11
|
+
}> = ({ props, theme, edits }) => {
|
|
12
|
+
const p0 = useEnter(0);
|
|
13
|
+
const p1 = useEnter(5);
|
|
14
|
+
const p2 = useEnter(10);
|
|
15
|
+
// The producer sometimes emits the same string for both fields; the huge
|
|
16
|
+
// emphasis wins and the redundant title is skipped (FINDINGS §5).
|
|
17
|
+
const emphasis = props.emphasis?.trim();
|
|
18
|
+
const titleIsRedundant =
|
|
19
|
+
!!emphasis && emphasis.toLowerCase().includes(props.title.trim().toLowerCase());
|
|
20
|
+
return (
|
|
21
|
+
<div
|
|
22
|
+
style={{
|
|
23
|
+
display: "flex",
|
|
24
|
+
flexDirection: "column",
|
|
25
|
+
alignItems: "center",
|
|
26
|
+
gap: 28,
|
|
27
|
+
textAlign: "center",
|
|
28
|
+
fontFamily: theme.fontDisplay,
|
|
29
|
+
color: theme.fg,
|
|
30
|
+
padding: "0 40px",
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
{props.eyebrow ? (
|
|
34
|
+
<div
|
|
35
|
+
data-edit-id="eyebrow"
|
|
36
|
+
style={{
|
|
37
|
+
...rise(p0),
|
|
38
|
+
fontSize: 34,
|
|
39
|
+
fontWeight: 700,
|
|
40
|
+
letterSpacing: "0.35em",
|
|
41
|
+
color: theme.muted,
|
|
42
|
+
textTransform: "uppercase",
|
|
43
|
+
...editStyle(edits, "eyebrow"),
|
|
44
|
+
}}
|
|
45
|
+
>
|
|
46
|
+
{props.eyebrow}
|
|
47
|
+
</div>
|
|
48
|
+
) : null}
|
|
49
|
+
{emphasis ? (
|
|
50
|
+
<div
|
|
51
|
+
data-edit-id="emphasis"
|
|
52
|
+
style={{
|
|
53
|
+
...rise(p1, 40),
|
|
54
|
+
fontSize: 210,
|
|
55
|
+
fontWeight: 900,
|
|
56
|
+
lineHeight: 0.95,
|
|
57
|
+
...editStyle(edits, "emphasis"),
|
|
58
|
+
}}
|
|
59
|
+
>
|
|
60
|
+
{emphasis}
|
|
61
|
+
</div>
|
|
62
|
+
) : null}
|
|
63
|
+
{!titleIsRedundant ? (
|
|
64
|
+
<div
|
|
65
|
+
data-edit-id="title"
|
|
66
|
+
style={{
|
|
67
|
+
...rise(emphasis ? p2 : p1, 34),
|
|
68
|
+
fontSize: emphasis ? 64 : 96,
|
|
69
|
+
fontWeight: 900,
|
|
70
|
+
lineHeight: 1.05,
|
|
71
|
+
textTransform: "uppercase",
|
|
72
|
+
letterSpacing: "0.02em",
|
|
73
|
+
...editStyle(edits, "title"),
|
|
74
|
+
}}
|
|
75
|
+
>
|
|
76
|
+
{props.title}
|
|
77
|
+
</div>
|
|
78
|
+
) : null}
|
|
79
|
+
{props.sub ? (
|
|
80
|
+
<div
|
|
81
|
+
data-edit-id="sub"
|
|
82
|
+
style={{
|
|
83
|
+
...rise(p2),
|
|
84
|
+
fontSize: 40,
|
|
85
|
+
fontWeight: 600,
|
|
86
|
+
color: theme.muted,
|
|
87
|
+
...editStyle(edits, "sub"),
|
|
88
|
+
}}
|
|
89
|
+
>
|
|
90
|
+
{props.sub}
|
|
91
|
+
</div>
|
|
92
|
+
) : null}
|
|
93
|
+
</div>
|
|
94
|
+
);
|
|
95
|
+
};
|