commit 2f1d0acaae91710e60248c93d2e68b6524a55111
parent a2012e191bf2737d0a3bd543279fc591ac029b6f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 7 Oct 2026 12:06:32 -0400
report-to-video: a teaser line can carry a small tier above it (lead)
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
4 files changed, 56 insertions(+), 7 deletions(-)
diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md
@@ -777,7 +777,7 @@ falls on the teaser.
```
- **`lines`** — 1 to 8 in at most 5 rows (below), each one line of at most 80 characters: a string, or
- `{ text, break, replace?, role?, hold?, together? }`, where `break` is the END of `text` drawn as a smaller,
+ `{ text, break, replace?, role?, hold?, together?, lead? }`, where `break` is the END of `text` drawn as a smaller,
wide-tracked second tier under the rest that pops 3/7 of a beat after it
(0.3 s at the default `beat`).
The words are data: they are drawn uppercase, and kept as written everywhere
@@ -811,6 +811,10 @@ falls on the teaser.
WITH it, on the line's one hit, instead of 3/7 of a beat later on a
lighter hit of its own. For a row that steps through quote-and-date pairs,
so each step is one hit, not two.
+- **`lead`** — a few words drawn ABOVE a line as a small wide-tracked tier,
+ the way a `break` is drawn below it: a label for the line ("Breaking
+ Ground" over "2027"). It drops in with its line's pop, on that line's hit,
+ and fits as a second tier does (at most 66 characters).
- A teaser that uses none of these composes the page it always did,
byte for byte (`chrome-teaser.test.mjs` pins it), so no existing cut
re-renders.
diff --git a/umtool/report-to-video/chrome-teaser.mjs b/umtool/report-to-video/chrome-teaser.mjs
@@ -172,6 +172,14 @@ export function teaserCues({ lines, tail = "", seconds, motion = TEASER_MOTION,
add(`l${i}`, b.outAt - T(0.02), T(m.out), { scale: m.outScale }, "power2.in", `${why} pushed out (scale)`);
add(`l${i}.t`, b.outAt - T(0.02), T(m.out), { "--blur": `${m.outBlur}px` }, "power2.in", `${why} pushed out (blur)`);
}
+ // A lead -- the small tier above the line -- drops in with the line's pop,
+ // as a second tier rises in under one.
+ if (l.lead) {
+ put(`l${i}.lead`, { autoAlpha: 0, y: -16, scale: 1.12 });
+ put(`l${i}.leadt`, { "--blur": "10px" });
+ add(`l${i}.lead`, b.at, T(0.5), { autoAlpha: 1, y: 0, scale: 1 }, "expo.out", `${why} lead`);
+ add(`l${i}.leadt`, b.at, T(0.32), { "--blur": "0px" }, "power2.out", `${why} lead focus`);
+ }
if (l.sub && b.subAt != null) {
put(`l${i}.sub`, { autoAlpha: 0, y: 16, scale: 1.12 });
put(`l${i}.subt`, { "--blur": "10px" });
@@ -278,6 +286,9 @@ export function teaserHtml(entry, render, opts = {}) {
`<div class="flash" data-k="${k}.flash"></div>` +
`<div class="streak" data-k="${k}.streak"></div>` +
rules +
+ (l.lead
+ ? `<div class="sub lead" data-k="${k}.lead"><div class="row"><span class="subt" data-k="${k}.leadt">${esc(l.lead)}</span></div></div>`
+ : "") +
`<div class="pop" data-k="${k}"><div class="row">` +
`<span class="txt" data-k="${k}.t">${esc(l.head)}</span>${l.sub ? "" : tailHtml}</div></div>` +
(l.sub
@@ -356,7 +367,9 @@ export function teaserHtml(entry, render, opts = {}) {
.kicker .txt { color: ${pal.fg}; }
.overline { margin-bottom: 38px; }
.title + .title { margin-top: 10px; }
- .title .sub { margin-top: 14px; }
+ .title .sub { margin-top: 14px; }${lines.some((l) => l.lead) ? `
+ /* A lead: the small tier above its line, as a break is the one below. */
+ .line .sub.lead { margin-top: 0; margin-bottom: 10px; }` : ""}
.kicker { margin-top: 70px; }${slotted || lines.some((l, i) => l.role === "kicker" && i < lines.length - 1) ? `
/* Rows several lines take in turn: one grid cell, the lines stacked in
it; the row carries the margins its lines would have. */
diff --git a/umtool/report-to-video/chrome-teaser.test.mjs b/umtool/report-to-video/chrome-teaser.test.mjs
@@ -44,7 +44,9 @@ test("a valid teaser has nothing to say; every bad shape is a sentence", () => {
assert.match(bad({ lines: [{ text: "Abc", brk: "c" }] }), /lines\[0\]\.brk is not a teaser line field/);
assert.match(bad({ lines: [{ text: "The big one", break: "small" }] }), /must be the end of its text/);
assert.match(bad({ lines: [{ text: "whole", break: "whole" }] }), /leaves nothing for the first tier/);
- assert.match(bad({ lines: [42] }), /string or \{ text, break, replace, role, hold, together \}/);
+ assert.match(bad({ lines: [42] }), /string or \{ text, break, replace, role, hold, together, lead \}/);
+ assert.match(bad({ lines: ["a", { text: "b", lead: "x".repeat(67) }] }), /lines\[1\]\.lead is 67 characters, and at most 66 fit/);
+ assert.match(bad({ lines: ["a", { text: "b", lead: "" }] }), /lines\[1\]\.lead must be words/);
assert.match(bad({ lines: ["a", { text: "b", together: true }] }), /lines\[1\]\.together pops the second tier with its line, and it has no break/);
assert.match(bad({ lines: ["a", { text: "b c", break: "c", together: 1 }] }), /lines\[1\]\.together must be true or false/);
assert.match(bad({ lines: [{ text: "First", replace: true }] }), /lines\[0\]\.replace: the first line has no line before it/);
@@ -499,3 +501,25 @@ test("together: a line's second tier pops with it, on its one hit", () => {
const subs = teaserHits(together).filter((h) => h.role === "sub").map((h) => h.at);
assert.deepEqual(subs, [beats.lines[1].subAt]);
});
+
+test("lead: a small tier above its line, popping with it on its one hit", () => {
+ const entry = {
+ ...ESTIMATES,
+ lines: [...ESTIMATES.lines.slice(0, 5), { text: "2027", replace: true, role: "title", lead: "Breaking Ground" }],
+ };
+ assert.deepEqual(validateTeaser(entry), []);
+ const lines = teaserLines(entry);
+ assert.equal(lines[5].lead, "Breaking Ground");
+ const html = teaserHtml(entry, RENDER);
+ // Above the year, in its line, before its pop; the tail still hangs off the year.
+ const at = html.indexOf('data-line="5"');
+ assert.ok(at < html.indexOf('data-k="l5.lead"') && html.indexOf('data-k="l5.lead"') < html.indexOf('data-k="l5"'));
+ assert.ok(html.indexOf('data-k="l5"') < html.indexOf('data-k="tail"'));
+ assert.ok(html.includes("Breaking Ground"));
+ // It drops in at the line's pop; no hit of its own.
+ const { cues, beats } = teaserCues({ lines, tail: "?", seconds: 16, motion: teaserMotion(1) });
+ assert.equal(cues.find((c) => c.why === "line 5 lead").at, beats.lines[5].at);
+ assert.equal(teaserHits(entry).filter((h) => h.kind === "hit").length, teaserHits(ESTIMATES).filter((h) => h.kind === "hit").length);
+ // A teaser without a lead carries none of its style.
+ assert.ok(!teaserHtml(ESTIMATES, RENDER).includes(".sub.lead"));
+});
diff --git a/umtool/report-to-video/deck.mjs b/umtool/report-to-video/deck.mjs
@@ -1398,7 +1398,7 @@ export const TEASER_LIMITS = Object.freeze({
fit: Object.freeze({ overline: 64, title: 34, kicker: 56, sub: 66 }),
});
-const LINE_KEYS = ["text", "break", "replace", "role", "hold", "together"];
+const LINE_KEYS = ["text", "break", "replace", "role", "hold", "together", "lead"];
/** The roles a line may be drawn in; a line's `role` names one of them. */
export const TEASER_ROLES = Object.freeze(["overline", "title", "kicker"]);
@@ -1419,10 +1419,12 @@ export const TEASER_ROLES = Object.freeze(["overline", "title", "kicker"]);
* (seconds held after the line before the next one pops) are present only
* when set, so an entry that uses neither normalises exactly as it did. So is
* `together`: a line with a `break` whose second tier pops WITH it, on its one
- * hit, rather than a beat-fraction later on a lighter hit of its own.
+ * hit, rather than a beat-fraction later on a lighter hit of its own. And
+ * `lead`: a small wide-tracked tier ABOVE the line (a label: "Breaking
+ * ground" over "2027"), drawn as a second tier is and popping with its line.
*
* @returns {Array<{ text: string, head: string, sub: string|null, role: "overline"|"title"|"kicker",
- * row: number, replace?: true, hold?: number, together?: true }>}
+ * row: number, replace?: true, hold?: number, together?: true, lead?: string }>}
*/
export function teaserLines(entry) {
const lines = Array.isArray(entry?.lines) ? entry.lines : [];
@@ -1444,9 +1446,11 @@ export function teaserLines(entry) {
const role = own ?? (replace ? out[i - 1].role : byRow);
const hold = isObj(l) && typeof l.hold === "number" && l.hold > 0 ? l.hold : null;
const together = !!sub && isObj(l) && l.together === true;
+ const lead = isObj(l) && typeof l.lead === "string" && l.lead.trim() ? l.lead.trim() : null;
out.push({
text, head, sub, role, row: r,
...(replace ? { replace: true } : {}), ...(hold ? { hold } : {}), ...(together ? { together: true } : {}),
+ ...(lead ? { lead } : {}),
});
});
return out;
@@ -1820,7 +1824,7 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"
lines.forEach((l, i) => {
const w = `${where}.lines[${i}]`;
if (typeof l === "string") { oneLine(l, w); return; }
- if (!isObj(l)) { errors.push(`${w} must be a string or { text, break, replace, role, hold, together }`); return; }
+ if (!isObj(l)) { errors.push(`${w} must be a string or { text, break, replace, role, hold, together, lead }`); return; }
for (const k of Object.keys(l)) if (!LINE_KEYS.includes(k)) errors.push(`${w}.${k} is not a teaser line field`);
if (l.replace !== undefined && l.replace !== null) {
if (typeof l.replace !== "boolean") errors.push(`${w}.replace must be true or false`);
@@ -1835,6 +1839,10 @@ export function validateTeaser(entry, where = `timeline entry ${entry?.id ?? "?"
errors.push(`${w}.together pops the second tier with its line, and it has no break`);
}
}
+ if (l.lead !== undefined && l.lead !== null && oneLine(l.lead, `${w}.lead`)
+ && l.lead.trim().length > TEASER_LIMITS.fit.sub) {
+ errors.push(`${w}.lead is ${l.lead.trim().length} characters, and at most ${TEASER_LIMITS.fit.sub} fit the frame as a small tier`);
+ }
if (l.hold !== undefined && l.hold !== null && !numIn(l.hold, 0, TEASER_LIMITS.hold)) {
errors.push(`${w}.hold must be from 0 to ${TEASER_LIMITS.hold} seconds`);
}