Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit 763578d6327c28883f45c596af328f39bc846ef2
parent a2012e191bf2737d0a3bd543279fc591ac029b6f
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Wed,  7 Oct 2026 12:06:32 -0400

Merge feat/teaser-lead (a teaser line can carry a small tier above it)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mumtool/report-to-video/README.md | 6+++++-
Mumtool/report-to-video/chrome-teaser.mjs | 15++++++++++++++-
Mumtool/report-to-video/chrome-teaser.test.mjs | 26+++++++++++++++++++++++++-
Mumtool/report-to-video/deck.mjs | 16++++++++++++----
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`); }