diff --git a/packages/cli/src/registry/captionLastGroup.chromium.test.ts b/packages/cli/src/registry/captionLastGroup.chromium.test.ts
new file mode 100644
index 0000000000..a602b862af
--- /dev/null
+++ b/packages/cli/src/registry/captionLastGroup.chromium.test.ts
@@ -0,0 +1,71 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+import { readFileSync } from "node:fs";
+import { createRequire } from "node:module";
+import puppeteer, { type Browser } from "puppeteer-core";
+
+const executablePath = process.env.PUPPETEER_EXECUTABLE_PATH;
+const componentHtml = readFileSync(
+ new URL(
+ "../../../../registry/components/caption-highlight/caption-highlight.html",
+ import.meta.url,
+ ),
+ "utf8",
+);
+const gsapScript = readFileSync(
+ createRequire(new URL("../../../core/package.json", import.meta.url)).resolve(
+ "gsap/dist/gsap.min.js",
+ ),
+ "utf8",
+);
+
+/** Scaled demo words: the last group opens before the old 7.95 s cap and its last word ends after it. */
+const STRETCH = 1.15;
+const LAST_WORD_START_S = 7.3 * STRETCH;
+const LAST_WORD_END_S = 7.7 * STRETCH;
+
+function stretchedComponent(): string {
+ const wordsStart = componentHtml.indexOf("var WORDS = [");
+ const wordsEnd = componentHtml.indexOf("];", wordsStart);
+ const words = componentHtml
+ .slice(wordsStart, wordsEnd)
+ .replace(/(start|end): ([\d.]+)/g, (_, key, value) => `${key}: ${Number(value) * STRETCH}`);
+ return (componentHtml.slice(0, wordsStart) + words + componentHtml.slice(wordsEnd)).replace(
+ /`,
+ );
+}
+
+describe.runIf(executablePath)("caption-highlight last group in Chromium", () => {
+ let browser: Browser;
+
+ beforeAll(async () => {
+ browser = await puppeteer.launch({ executablePath, args: ["--no-sandbox"] });
+ });
+
+ afterAll(async () => {
+ await browser?.close();
+ });
+
+ async function lastGroupVisibleAt(time: number): Promise {
+ const page = await browser.newPage();
+ try {
+ await page.setContent(stretchedComponent());
+ return await page.evaluate((t) => {
+ const timeline = (
+ window as unknown as { __timelines: Record }
+ ).__timelines["caption-highlight"];
+ timeline!.seek(t);
+ const groups = document.querySelectorAll(".hl-group");
+ const style = getComputedStyle(groups[groups.length - 1]!);
+ return style.visibility !== "hidden" && Number(style.opacity) > 0;
+ }, time);
+ } finally {
+ await page.close();
+ }
+ }
+
+ it("shows the last phrase from its first word to just before its last word ends", async () => {
+ expect(await lastGroupVisibleAt(LAST_WORD_START_S)).toBe(true);
+ expect(await lastGroupVisibleAt(LAST_WORD_END_S - 0.05)).toBe(true);
+ });
+});
diff --git a/packages/cli/src/registry/registryComponents.test.ts b/packages/cli/src/registry/registryComponents.test.ts
index 4f3cfa1969..0352fa464b 100644
--- a/packages/cli/src/registry/registryComponents.test.ts
+++ b/packages/cli/src/registry/registryComponents.test.ts
@@ -56,6 +56,18 @@ describe("registry components", () => {
expect(invalidMedia).toEqual([]);
});
+ it("never caps a caption's last group at a fixed demo-length constant", () => {
+ const capped: string[] = [];
+
+ for (const entry of readdirSync(componentsDir, { withFileTypes: true })) {
+ if (!entry.isDirectory() || !entry.name.startsWith("caption-")) continue;
+ const html = readFileSync(join(componentsDir, entry.name, `${entry.name}.html`), "utf8");
+ if (/nextStart\s*=[^;]*:\s*\d+(?:\.\d+)?;/.test(html)) capped.push(entry.name);
+ }
+
+ expect(capped).toEqual([]);
+ });
+
it("ships demos without source-less media", async () => {
const invalidMedia: string[] = [];
diff --git a/registry/components/caption-clip-wipe/caption-clip-wipe.html b/registry/components/caption-clip-wipe/caption-clip-wipe.html
index 9b5d55e599..41d882b460 100644
--- a/registry/components/caption-clip-wipe/caption-clip-wipe.html
+++ b/registry/components/caption-clip-wipe/caption-clip-wipe.html
@@ -153,7 +153,8 @@
var GROUPS = RAW_GROUPS.map(function (pair, gi) {
var ws = pair[0],
we = pair[1];
- var nextStart = gi + 1 < RAW_GROUPS.length ? WORDS[RAW_GROUPS[gi + 1][0]].start : 8.0;
+ var nextStart =
+ gi + 1 < RAW_GROUPS.length ? WORDS[RAW_GROUPS[gi + 1][0]].start : Infinity;
var gEnd = Math.min(WORDS[we].end + 0.5, nextStart - 0.05);
return { wordStart: ws, wordEnd: we, start: WORDS[ws].start, end: gEnd };
});
diff --git a/registry/components/caption-glitch-rgb/caption-glitch-rgb.html b/registry/components/caption-glitch-rgb/caption-glitch-rgb.html
index e44a67674f..fe212755ec 100644
--- a/registry/components/caption-glitch-rgb/caption-glitch-rgb.html
+++ b/registry/components/caption-glitch-rgb/caption-glitch-rgb.html
@@ -204,7 +204,7 @@
var ws = pair[0];
var we = pair[1];
var nextStart =
- gi + 1 < RAW_GROUPS.length ? TRANSCRIPT[RAW_GROUPS[gi + 1][0]].start : 8.0;
+ gi + 1 < RAW_GROUPS.length ? TRANSCRIPT[RAW_GROUPS[gi + 1][0]].start : Infinity;
var end = Math.min(TRANSCRIPT[we].end + 0.24, nextStart - 0.04);
return { wordStart: ws, wordEnd: we, start: TRANSCRIPT[ws].start, end: end };
});
diff --git a/registry/components/caption-highlight/caption-highlight.html b/registry/components/caption-highlight/caption-highlight.html
index 2617005887..615084e01f 100644
--- a/registry/components/caption-highlight/caption-highlight.html
+++ b/registry/components/caption-highlight/caption-highlight.html
@@ -178,7 +178,8 @@
var GROUPS = RAW_GROUPS.map(function (pair, gi) {
var ws = pair[0],
we = pair[1];
- var nextStart = gi + 1 < RAW_GROUPS.length ? WORDS[RAW_GROUPS[gi + 1][0]].start : 8.0;
+ var nextStart =
+ gi + 1 < RAW_GROUPS.length ? WORDS[RAW_GROUPS[gi + 1][0]].start : Infinity;
var gEnd = Math.min(WORDS[we].end + 0.5, nextStart - 0.05);
return { wordStart: ws, wordEnd: we, start: WORDS[ws].start, end: gEnd };
});
diff --git a/registry/components/caption-particle-burst/caption-particle-burst.html b/registry/components/caption-particle-burst/caption-particle-burst.html
index 2df4604db8..15ab9c5f06 100644
--- a/registry/components/caption-particle-burst/caption-particle-burst.html
+++ b/registry/components/caption-particle-burst/caption-particle-burst.html
@@ -174,7 +174,8 @@
var GROUPS = RAW_GROUPS.map(function (pair, gi) {
var ws = pair[0],
we = pair[1];
- var nextStart = gi + 1 < RAW_GROUPS.length ? WORDS[RAW_GROUPS[gi + 1][0]].start : 8.0;
+ var nextStart =
+ gi + 1 < RAW_GROUPS.length ? WORDS[RAW_GROUPS[gi + 1][0]].start : Infinity;
var gEnd = Math.min(WORDS[we].end + 0.35, nextStart - 0.05);
return { wordStart: ws, wordEnd: we, start: WORDS[ws].start, end: gEnd };
});