From c67e9dc1f843122225468ce63a67c57f70c2bba2 Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Thu, 23 Jul 2026 20:18:39 +0000 Subject: [PATCH 01/12] feat(registry): make caption-weight-shift data-driven via caption-data runtime --- .../caption-weight-shift.html | 381 ++++++++++++++---- 1 file changed, 300 insertions(+), 81 deletions(-) diff --git a/registry/components/caption-weight-shift/caption-weight-shift.html b/registry/components/caption-weight-shift/caption-weight-shift.html index a6f37fe0b7..8bcfbe9fbf 100644 --- a/registry/components/caption-weight-shift/caption-weight-shift.html +++ b/registry/components/caption-weight-shift/caption-weight-shift.html @@ -90,7 +90,7 @@ max-width: 1400px; line-height: 1.1; white-space: nowrap; - color: #ffffff; + color: var(--hf-caption-primary, #ffffff); font-family: "Montserrat", Arial, sans-serif; font-weight: 300; font-size: 72px; @@ -117,12 +117,188 @@ From 08620b75df5275739fd9b0b8f480527fdc1d9a98 Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Thu, 23 Jul 2026 20:46:26 +0000 Subject: [PATCH 02/12] feat(registry): make caption-pill-karaoke data-driven via caption-data runtime Co-Authored-By: Claude Sonnet 5 --- .../caption-pill-karaoke.html | 359 ++++++++++++++---- 1 file changed, 289 insertions(+), 70 deletions(-) diff --git a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html index 06b5a4ac2c..712823c802 100644 --- a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html +++ b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html @@ -131,56 +131,199 @@ From 020c8986f46f795757203900cd251ad551ccc620 Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Thu, 23 Jul 2026 21:01:24 +0000 Subject: [PATCH 03/12] =?UTF-8?q?fix(registry):=20force=20GSAP=20render=20?= =?UTF-8?q?at=20attach=20=E2=80=94=20seek(0)=20is=20a=20no-op=20on=20a=20f?= =?UTF-8?q?resh=20timeline?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../caption-pill-karaoke/caption-pill-karaoke.html | 6 +++++- .../caption-weight-shift/caption-weight-shift.html | 6 +++++- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html index 712823c802..741048027c 100644 --- a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html +++ b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html @@ -565,7 +565,11 @@ var layout = hfApplyStageConfig(data, v.durationS); var tl = hfBuild(v.words, layout, v.durationS); tl.set({}, {}, v.durationS); // pin timeline length to the data-derived duration - tl.seek(0); + // GSAP quirk (confirmed 3.14.2): a fresh timeline already reports position 0, + // so seek(0) is treated as a no-op and skips rendering .set() calls scheduled + // at t=0 — the first frame would show pre-timeline CSS defaults (usually + // hidden). render(time, suppressEvents, force) with force=true bypasses that. + tl.render(0, true, true); hfCurrentTimeline = tl; window.__timelines = window.__timelines || {}; window.__timelines[HF_COMPOSITION_ID] = tl; diff --git a/registry/components/caption-weight-shift/caption-weight-shift.html b/registry/components/caption-weight-shift/caption-weight-shift.html index 8bcfbe9fbf..2ead4d717f 100644 --- a/registry/components/caption-weight-shift/caption-weight-shift.html +++ b/registry/components/caption-weight-shift/caption-weight-shift.html @@ -580,7 +580,11 @@ var layout = hfApplyStageConfig(data, v.durationS); var tl = hfBuild(v.words, layout, v.durationS); tl.set({}, {}, v.durationS); // pin timeline length to the data-derived duration - tl.seek(0); + // GSAP quirk (confirmed 3.14.2): a fresh timeline already reports position 0, + // so seek(0) is treated as a no-op and skips rendering .set() calls scheduled + // at t=0 — the first frame would show pre-timeline CSS defaults (usually + // hidden). render(time, suppressEvents, force) with force=true bypasses that. + tl.render(0, true, true); hfCurrentTimeline = tl; window.__timelines = window.__timelines || {}; window.__timelines[HF_COMPOSITION_ID] = tl; From 392a9d251a4feadbf7703ba4f9157c1857c7a59b Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Thu, 23 Jul 2026 21:31:46 +0000 Subject: [PATCH 04/12] feat(registry): make caption-emoji-pop data-driven with generic emoji lexicon Retrofits caption-emoji-pop onto the shared HyperFrames caption-data runtime (Blocks A-D) and the emphasis heuristic (Block E), replacing the hardcoded 7-word emoji map, fixed KEYWORDS set, and TRANSITION_WORDS set with a generic ~40-entry lexicon and HF_STOPWORDS/hfIsEmphasisWord. The component now consumes brand.primaryColor (hfPrimaryColor) and brand.accentColor (hfAccentColors) and rescales stage/font/emoji sizing to the runtime resolution via layout.fontScale/scaleX/scaleY. Co-Authored-By: Claude Sonnet 5 --- .../caption-emoji-pop/caption-emoji-pop.html | 511 ++++++++++++++---- 1 file changed, 415 insertions(+), 96 deletions(-) diff --git a/registry/components/caption-emoji-pop/caption-emoji-pop.html b/registry/components/caption-emoji-pop/caption-emoji-pop.html index f8a863d2e8..d4107123b0 100644 --- a/registry/components/caption-emoji-pop/caption-emoji-pop.html +++ b/registry/components/caption-emoji-pop/caption-emoji-pop.html @@ -125,22 +125,185 @@ From 7d4e71d10b617b858fde2e764e15d530e9ffdf19 Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Thu, 23 Jul 2026 21:52:56 +0000 Subject: [PATCH 05/12] feat(registry): make caption-highlight data-driven with automatic grouping Replaces the hardcoded RAW_GROUPS index-pair array with hfMakeGroups, a real fitting/pause/punctuation-based grouper, and wires the component into the shared caption-data runtime (validate/gate/attach). Adds CSS brand hooks: .hl-word text color from --hf-caption-primary, .hl-word-bg gradient from --hf-caption-accent (second stop via color-mix()). --- .../caption-highlight/caption-highlight.html | 462 ++++++++++++++---- 1 file changed, 356 insertions(+), 106 deletions(-) diff --git a/registry/components/caption-highlight/caption-highlight.html b/registry/components/caption-highlight/caption-highlight.html index 2617005887..b53e981803 100644 --- a/registry/components/caption-highlight/caption-highlight.html +++ b/registry/components/caption-highlight/caption-highlight.html @@ -73,7 +73,7 @@ font-weight: 800; font-size: 80px; text-transform: uppercase; - color: #ffffff; + color: var(--hf-caption-primary, #ffffff); display: inline-block; letter-spacing: 0.02em; line-height: 1; @@ -86,7 +86,11 @@ .hl-word-bg { position: absolute; inset: 0; - background: linear-gradient(135deg, #ff1745 0%, #df1238 100%); + background: linear-gradient( + 135deg, + var(--hf-caption-accent, #ff1745) 0%, + color-mix(in srgb, var(--hf-caption-accent, #ff1745) 82%, #000) 100% + ); border-radius: 10px; box-shadow: 0 12px 30px rgba(229, 20, 58, 0.32); opacity: 0; @@ -117,6 +121,10 @@ From ed8973952dda2aa43931e0ffa05b3fc6ba0cb1cb Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Thu, 23 Jul 2026 22:15:26 +0000 Subject: [PATCH 06/12] feat(registry): make caption-editorial-emphasis data-driven with emphasis heuristic Replaces the hand-authored BLOCKS literal with hfMakeBlocks, a heuristic that groups words into blocks/lines from timing (pauses, punctuation, max words per block) and hfIsEmphasisWord (long, non-stopword content words) to decide which word gets the large Playfair Display emphasis treatment and its own slide-in line. computeLineSize/buildBlocks/fitBlocks and timeline construction now live inside hfBuild, closing over layout-scaled font sizes and widths. Adds the shared Blocks A-E caption-data runtime (attach/gate/brand config) and the .word/.word--emphasis CSS brand hook for --hf-caption-primary. This is the last of the five caption identities to go data-driven. Co-Authored-By: Claude Sonnet 5 --- .../caption-editorial-emphasis.html | 747 ++++++++++++------ 1 file changed, 512 insertions(+), 235 deletions(-) diff --git a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html index 50b41a1220..281c3aae76 100644 --- a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html +++ b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html @@ -80,7 +80,7 @@ .word { display: inline-block; - color: #f5f0d0; + color: var(--hf-caption-primary, #f5f0d0); text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6), 0 4px 24px rgba(0, 0, 0, 0.35); @@ -108,7 +108,7 @@ font-weight: 800; font-style: italic; line-height: 0.9; - color: #f5f0d0; + color: var(--hf-caption-primary, #f5f0d0); } @@ -129,8 +129,276 @@ From 18de2b1f1de8062a79ae7aa6fa4796e320fe6131 Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Thu, 23 Jul 2026 22:42:14 +0000 Subject: [PATCH 07/12] fix(caption-editorial-emphasis,caption-emoji-pop): remove O(n^2) redundant hide-all-others loop MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both identities looped over every OTHER group/block to force its opacity to 0 at each group's own start time, in addition to each group already setting its own opacity to 0 at its own end. Since groups/blocks occupy non-overlapping time windows and already own their full opacity lifecycle, that loop was dead weight — but it made timeline construction O(n^2) in the number of groups/blocks. Under the Task 7 stress transcript (long, frequent "emphasis" words forcing near single-word blocks/groups), n reached ~2000 and the page hung well past a 30s test timeout for both identities. Verified behavior-preserving: full templates.test.ts (41 tests, incl. opacity/seek assertions) and the new limits.test.ts stress suite pass against both identities after the removal. --- .../caption-editorial-emphasis.html | 11 ++++++++--- .../caption-emoji-pop/caption-emoji-pop.html | 10 +++++++--- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html index 281c3aae76..f859ab6aeb 100644 --- a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html +++ b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html @@ -586,9 +586,14 @@ var start = blockStart(bi); var nextStart = bi < BLOCKS.length - 1 ? blockStart(bi + 1) : DURATION; - allEls.forEach(function (other, oi) { - if (oi !== bi) tl.set(other, { opacity: 0 }, start); - }); + // Each block already owns its full opacity lifecycle (set 1 at its + // own start, then set back to 0 at its own nextStart below), and + // blocks occupy non-overlapping windows, so explicitly hiding every + // OTHER block here was redundant and made timeline construction + // O(blocks^2) — this collapsed under dense stress transcripts (many + // single-word emphasis blocks). Removed; behavior is unchanged + // (verified against templates.test.ts). + tl.set(el, { opacity: 1 }, start); block.line1.forEach(function (pair, wi) { diff --git a/registry/components/caption-emoji-pop/caption-emoji-pop.html b/registry/components/caption-emoji-pop/caption-emoji-pop.html index d4107123b0..da1692a5fa 100644 --- a/registry/components/caption-emoji-pop/caption-emoji-pop.html +++ b/registry/components/caption-emoji-pop/caption-emoji-pop.html @@ -703,9 +703,13 @@ groupIndex < GROUPS.length - 1 ? visibleStarts[groupIndex + 1] : DURATION; var exitStart = Math.max(visibleStart, visibleEnd - EXIT_DURATION); - allGroupEls.forEach(function (otherEl, otherIndex) { - if (otherIndex !== groupIndex) tl.set(otherEl, { opacity: 0 }, visibleStart); - }); + // Each group already owns its full opacity lifecycle (set 0 at its + // own visibleStart, animated to 1, then set back to 0 at its own + // visibleEnd below), and groups occupy non-overlapping windows, so + // explicitly hiding every OTHER group here was redundant and made + // timeline construction O(groups^2) — this collapsed under dense + // stress transcripts (many short groups). Removed; behavior is + // unchanged (verified against templates.test.ts). tl.set(groupEl, { opacity: 0, scaleX: 0.8, scaleY: 1 }, visibleStart); tl.to( From 5af6203ae7fcd506511ca5683a9097d666c4a63e Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Thu, 23 Jul 2026 22:55:37 +0000 Subject: [PATCH 08/12] fix(caption-weight-shift): remove O(n^2) redundant hide-all-others loop --- .../caption-weight-shift/caption-weight-shift.html | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/registry/components/caption-weight-shift/caption-weight-shift.html b/registry/components/caption-weight-shift/caption-weight-shift.html index 2ead4d717f..873182ea57 100644 --- a/registry/components/caption-weight-shift/caption-weight-shift.html +++ b/registry/components/caption-weight-shift/caption-weight-shift.html @@ -529,9 +529,13 @@ var visibleEnd = groupIndex < GROUPS.length - 1 ? visibleStarts[groupIndex + 1] : DURATION; - allGroupEls.forEach(function (otherEl, otherIndex) { - if (otherIndex !== groupIndex) tl.set(otherEl, { opacity: 0 }, visibleStart); - }); + // Each group already owns its full opacity lifecycle (set 0 at its + // own visibleStart, animated to 1, then set back to 0 at its own + // visibleEnd below), and groups occupy non-overlapping windows, so + // explicitly hiding every OTHER group here was redundant and made + // timeline construction O(groups^2) — this collapsed under dense + // stress transcripts (many short groups). Removed; behavior is + // unchanged (verified against templates.test.ts). tl.set(groupEl, { opacity: 0, scale: 0.85 }, visibleStart); tl.to( From 4f6994719196e72dc01ebe8f60d869e064ec7685 Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Fri, 24 Jul 2026 00:03:46 +0000 Subject: [PATCH 09/12] fix(registry): clear brand CSS custom properties on unbranded re-attach; remove dead italic path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit hfApplyStageConfig (Block B, verbatim across all 5 retrofitted caption identities) only ever set --hf-caption-primary/--hf-caption-accent when the corresponding brand.primaryColor/accentColor key was present, with no else branch to clear it when absent. Re-attaching a brand-less payload after a branded one left the custom property (and any JS-cached color derived from it, e.g. caption-pill-karaoke's hfColorActive and caption-emoji-pop's hfAccentColors) stuck at the stale value instead of reverting to the CSS fallback, violating idempotent re-attach. Also removes caption-editorial-emphasis's dead .word--italic CSS rule and CLASS_MAP.i entry — hfMakeBlocks only ever emits "n"/"e" tags, "i" was only reachable via the old hand-authored BLOCKS literal this task replaced. Co-Authored-By: Claude Sonnet 5 --- .../caption-editorial-emphasis.html | 13 +++++-------- .../caption-emoji-pop/caption-emoji-pop.html | 4 ++++ .../caption-highlight/caption-highlight.html | 4 ++++ .../caption-pill-karaoke/caption-pill-karaoke.html | 4 ++++ .../caption-weight-shift/caption-weight-shift.html | 4 ++++ 5 files changed, 21 insertions(+), 8 deletions(-) diff --git a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html index f859ab6aeb..38c86f294d 100644 --- a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html +++ b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html @@ -95,13 +95,6 @@ font-style: normal; } - .word--italic { - font-family: "Inter", sans-serif; - font-size: 86px; - font-weight: 400; - font-style: italic; - } - .word--emphasis { font-family: "Playfair Display", serif; font-size: 180px; @@ -236,9 +229,13 @@ var brand = (data && data.brand) || {}; if (typeof brand.primaryColor === "string" && brand.primaryColor) { root.style.setProperty("--hf-caption-primary", brand.primaryColor); + } else { + root.style.removeProperty("--hf-caption-primary"); } if (typeof brand.accentColor === "string" && brand.accentColor) { root.style.setProperty("--hf-caption-accent", brand.accentColor); + } else { + root.style.removeProperty("--hf-caption-accent"); } return { W: W, H: H, scaleX: W / 1920, scaleY: H / 1080, fontScale: Math.min(W, H) / 1080 }; } @@ -415,7 +412,7 @@ return minSize; } - var CLASS_MAP = { n: "word word--normal", i: "word word--italic", e: "word word--emphasis" }; + var CLASS_MAP = { n: "word word--normal", e: "word word--emphasis" }; var W = []; var BLOCKS = []; diff --git a/registry/components/caption-emoji-pop/caption-emoji-pop.html b/registry/components/caption-emoji-pop/caption-emoji-pop.html index da1692a5fa..e9837d5f61 100644 --- a/registry/components/caption-emoji-pop/caption-emoji-pop.html +++ b/registry/components/caption-emoji-pop/caption-emoji-pop.html @@ -232,9 +232,13 @@ var brand = (data && data.brand) || {}; if (typeof brand.primaryColor === "string" && brand.primaryColor) { root.style.setProperty("--hf-caption-primary", brand.primaryColor); + } else { + root.style.removeProperty("--hf-caption-primary"); } if (typeof brand.accentColor === "string" && brand.accentColor) { root.style.setProperty("--hf-caption-accent", brand.accentColor); + } else { + root.style.removeProperty("--hf-caption-accent"); } return { W: W, H: H, scaleX: W / 1920, scaleY: H / 1080, fontScale: Math.min(W, H) / 1080 }; } diff --git a/registry/components/caption-highlight/caption-highlight.html b/registry/components/caption-highlight/caption-highlight.html index b53e981803..b87f407e84 100644 --- a/registry/components/caption-highlight/caption-highlight.html +++ b/registry/components/caption-highlight/caption-highlight.html @@ -243,9 +243,13 @@ var brand = (data && data.brand) || {}; if (typeof brand.primaryColor === "string" && brand.primaryColor) { root.style.setProperty("--hf-caption-primary", brand.primaryColor); + } else { + root.style.removeProperty("--hf-caption-primary"); } if (typeof brand.accentColor === "string" && brand.accentColor) { root.style.setProperty("--hf-caption-accent", brand.accentColor); + } else { + root.style.removeProperty("--hf-caption-accent"); } return { W: W, diff --git a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html index 741048027c..f57bbd1635 100644 --- a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html +++ b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html @@ -238,9 +238,13 @@ var brand = (data && data.brand) || {}; if (typeof brand.primaryColor === "string" && brand.primaryColor) { root.style.setProperty("--hf-caption-primary", brand.primaryColor); + } else { + root.style.removeProperty("--hf-caption-primary"); } if (typeof brand.accentColor === "string" && brand.accentColor) { root.style.setProperty("--hf-caption-accent", brand.accentColor); + } else { + root.style.removeProperty("--hf-caption-accent"); } return { W: W, H: H, scaleX: W / 1920, scaleY: H / 1080, fontScale: Math.min(W, H) / 1080 }; } diff --git a/registry/components/caption-weight-shift/caption-weight-shift.html b/registry/components/caption-weight-shift/caption-weight-shift.html index 873182ea57..3689dfa40d 100644 --- a/registry/components/caption-weight-shift/caption-weight-shift.html +++ b/registry/components/caption-weight-shift/caption-weight-shift.html @@ -224,9 +224,13 @@ var brand = (data && data.brand) || {}; if (typeof brand.primaryColor === "string" && brand.primaryColor) { root.style.setProperty("--hf-caption-primary", brand.primaryColor); + } else { + root.style.removeProperty("--hf-caption-primary"); } if (typeof brand.accentColor === "string" && brand.accentColor) { root.style.setProperty("--hf-caption-accent", brand.accentColor); + } else { + root.style.removeProperty("--hf-caption-accent"); } return { W: W, H: H, scaleX: W / 1920, scaleY: H / 1080, fontScale: Math.min(W, H) / 1080 }; } From e2846eb7cc81821f7fc21a9c4dccdd85c7ef3429 Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Fri, 24 Jul 2026 05:25:41 +0000 Subject: [PATCH 10/12] fix(registry): encapsulate caption template runtimes in IIFEs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit caption-highlight was already IIFE-wrapped; the other four leaked their runtime (hfAttach/hfBuild/...) as script-scope globals. Harmless when boot was synchronous, but the data-driven retrofit defers attach behind fonts.ready + the sibling fetch — with two caption components pasted into one composition document, every script finishes before any deferred boot runs, the last script's definitions win the shared scope, and the first component never registers its timeline (a renderer waiting on it hangs to timeout). Wrapping each template's script keeps its internals private so each boot attaches its own component. window.__HF_CAPTION_ATTACH__ remains intentionally window-scoped (last-defined-wins). Co-Authored-By: Claude Fable 5 --- .../caption-editorial-emphasis.html | 1048 +++++++------- .../caption-emoji-pop/caption-emoji-pop.html | 1266 +++++++++-------- .../caption-pill-karaoke.html | 864 +++++------ .../caption-weight-shift.html | 938 ++++++------ 4 files changed, 2087 insertions(+), 2029 deletions(-) diff --git a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html index 38c86f294d..83f808425a 100644 --- a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html +++ b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html @@ -122,569 +122,581 @@ diff --git a/registry/components/caption-emoji-pop/caption-emoji-pop.html b/registry/components/caption-emoji-pop/caption-emoji-pop.html index e9837d5f61..6d6f15c697 100644 --- a/registry/components/caption-emoji-pop/caption-emoji-pop.html +++ b/registry/components/caption-emoji-pop/caption-emoji-pop.html @@ -125,669 +125,689 @@ diff --git a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html index f57bbd1635..341400fb80 100644 --- a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html +++ b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html @@ -131,478 +131,492 @@ diff --git a/registry/components/caption-weight-shift/caption-weight-shift.html b/registry/components/caption-weight-shift/caption-weight-shift.html index 3689dfa40d..f2c42f10ff 100644 --- a/registry/components/caption-weight-shift/caption-weight-shift.html +++ b/registry/components/caption-weight-shift/caption-weight-shift.html @@ -117,511 +117,523 @@ From 5c2981d066480000d623e4d023e9cf918b2772e3 Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Fri, 24 Jul 2026 05:55:21 +0000 Subject: [PATCH 11/12] fix(registry): reject non-numeric caption-data versions; boot fetch never clobbers a manual attach MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings on the caption-data runtime, applied to all 5 templates: - hfValidate's version gate used Math.floor(Number(v)) > HF_CONTRACT_VERSION, and Number("v2") is NaN — NaN comparisons are always false, so malformed versions slid through with no unsupported-version signal. An explicit Number.isFinite check closes it. - hfBoot's sibling-fetch .then called hfAttach unconditionally; a manual window.__HF_CAPTION_ATTACH__ call landing while the fetch or fonts.ready was still pending got clobbered by the late boot payload. Boot now yields if a timeline already exists. Co-Authored-By: Claude Fable 5 --- .../caption-editorial-emphasis.html | 13 +++++++++++-- .../caption-emoji-pop/caption-emoji-pop.html | 13 +++++++++++-- .../caption-highlight/caption-highlight.html | 13 +++++++++++-- .../caption-pill-karaoke/caption-pill-karaoke.html | 13 +++++++++++-- .../caption-weight-shift/caption-weight-shift.html | 13 +++++++++++-- 5 files changed, 55 insertions(+), 10 deletions(-) diff --git a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html index 83f808425a..3790946ee0 100644 --- a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html +++ b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html @@ -205,8 +205,13 @@ function hfValidate(data) { if (!data || typeof data !== "object") return { ok: false, reason: "not-an-object" }; - if (data.version != null && Math.floor(Number(data.version)) > HF_CONTRACT_VERSION) { - return { ok: false, reason: "unsupported-version" }; + if (data.version != null) { + // Number("v2") is NaN and NaN > x is always false — an explicit + // finite check keeps malformed versions from slipping past the gate. + var hfVersion = Number(data.version); + if (!Number.isFinite(hfVersion) || Math.floor(hfVersion) > HF_CONTRACT_VERSION) { + return { ok: false, reason: "unsupported-version" }; + } } if (!Array.isArray(data.segments) || data.segments.length === 0) { return { ok: false, reason: "no-segments" }; @@ -679,6 +684,10 @@ var ready = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve(); ready.then(function () { + // A manual window.__HF_CAPTION_ATTACH__ call that lands while the + // sibling fetch / fonts.ready are still pending must win — never + // clobber it with the late-arriving boot payload. + if (hfCurrentTimeline) return; hfAttach(data); }); } diff --git a/registry/components/caption-emoji-pop/caption-emoji-pop.html b/registry/components/caption-emoji-pop/caption-emoji-pop.html index 6d6f15c697..7c982e2781 100644 --- a/registry/components/caption-emoji-pop/caption-emoji-pop.html +++ b/registry/components/caption-emoji-pop/caption-emoji-pop.html @@ -208,8 +208,13 @@ function hfValidate(data) { if (!data || typeof data !== "object") return { ok: false, reason: "not-an-object" }; - if (data.version != null && Math.floor(Number(data.version)) > HF_CONTRACT_VERSION) { - return { ok: false, reason: "unsupported-version" }; + if (data.version != null) { + // Number("v2") is NaN and NaN > x is always false — an explicit + // finite check keeps malformed versions from slipping past the gate. + var hfVersion = Number(data.version); + if (!Number.isFinite(hfVersion) || Math.floor(hfVersion) > HF_CONTRACT_VERSION) { + return { ok: false, reason: "unsupported-version" }; + } } if (!Array.isArray(data.segments) || data.segments.length === 0) { return { ok: false, reason: "no-segments" }; @@ -790,6 +795,10 @@ var ready = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve(); ready.then(function () { + // A manual window.__HF_CAPTION_ATTACH__ call that lands while the + // sibling fetch / fonts.ready are still pending must win — never + // clobber it with the late-arriving boot payload. + if (hfCurrentTimeline) return; hfAttach(data); }); } diff --git a/registry/components/caption-highlight/caption-highlight.html b/registry/components/caption-highlight/caption-highlight.html index b87f407e84..a1d1b8be96 100644 --- a/registry/components/caption-highlight/caption-highlight.html +++ b/registry/components/caption-highlight/caption-highlight.html @@ -214,8 +214,13 @@ function hfValidate(data) { if (!data || typeof data !== "object") return { ok: false, reason: "not-an-object" }; - if (data.version != null && Math.floor(Number(data.version)) > HF_CONTRACT_VERSION) { - return { ok: false, reason: "unsupported-version" }; + if (data.version != null) { + // Number("v2") is NaN and NaN > x is always false — an explicit + // finite check keeps malformed versions from slipping past the gate. + var hfVersion = Number(data.version); + if (!Number.isFinite(hfVersion) || Math.floor(hfVersion) > HF_CONTRACT_VERSION) { + return { ok: false, reason: "unsupported-version" }; + } } if (!Array.isArray(data.segments) || data.segments.length === 0) { return { ok: false, reason: "no-segments" }; @@ -491,6 +496,10 @@ var ready = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve(); ready.then(function () { + // A manual window.__HF_CAPTION_ATTACH__ call that lands while the + // sibling fetch / fonts.ready are still pending must win — never + // clobber it with the late-arriving boot payload. + if (hfCurrentTimeline) return; hfAttach(data); }); } diff --git a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html index 341400fb80..c0025e2629 100644 --- a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html +++ b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html @@ -214,8 +214,13 @@ function hfValidate(data) { if (!data || typeof data !== "object") return { ok: false, reason: "not-an-object" }; - if (data.version != null && Math.floor(Number(data.version)) > HF_CONTRACT_VERSION) { - return { ok: false, reason: "unsupported-version" }; + if (data.version != null) { + // Number("v2") is NaN and NaN > x is always false — an explicit + // finite check keeps malformed versions from slipping past the gate. + var hfVersion = Number(data.version); + if (!Number.isFinite(hfVersion) || Math.floor(hfVersion) > HF_CONTRACT_VERSION) { + return { ok: false, reason: "unsupported-version" }; + } } if (!Array.isArray(data.segments) || data.segments.length === 0) { return { ok: false, reason: "no-segments" }; @@ -599,6 +604,10 @@ var ready = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve(); ready.then(function () { + // A manual window.__HF_CAPTION_ATTACH__ call that lands while the + // sibling fetch / fonts.ready are still pending must win — never + // clobber it with the late-arriving boot payload. + if (hfCurrentTimeline) return; hfAttach(data); }); } diff --git a/registry/components/caption-weight-shift/caption-weight-shift.html b/registry/components/caption-weight-shift/caption-weight-shift.html index f2c42f10ff..d6340fd6a2 100644 --- a/registry/components/caption-weight-shift/caption-weight-shift.html +++ b/registry/components/caption-weight-shift/caption-weight-shift.html @@ -200,8 +200,13 @@ function hfValidate(data) { if (!data || typeof data !== "object") return { ok: false, reason: "not-an-object" }; - if (data.version != null && Math.floor(Number(data.version)) > HF_CONTRACT_VERSION) { - return { ok: false, reason: "unsupported-version" }; + if (data.version != null) { + // Number("v2") is NaN and NaN > x is always false — an explicit + // finite check keeps malformed versions from slipping past the gate. + var hfVersion = Number(data.version); + if (!Number.isFinite(hfVersion) || Math.floor(hfVersion) > HF_CONTRACT_VERSION) { + return { ok: false, reason: "unsupported-version" }; + } } if (!Array.isArray(data.segments) || data.segments.length === 0) { return { ok: false, reason: "no-segments" }; @@ -616,6 +621,10 @@ var ready = document.fonts && document.fonts.ready ? document.fonts.ready : Promise.resolve(); ready.then(function () { + // A manual window.__HF_CAPTION_ATTACH__ call that lands while the + // sibling fetch / fonts.ready are still pending must win — never + // clobber it with the late-arriving boot payload. + if (hfCurrentTimeline) return; hfAttach(data); }); } From 8bf939043fb44e05f6dd7caba81822ac5ef18334 Mon Sep 17 00:00:00 2001 From: vanceingalls Date: Fri, 24 Jul 2026 06:10:00 +0000 Subject: [PATCH 12/12] fix(registry): liberal emoji-pop brand colors, weight-shift fit fixes, 8192 clamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - caption-emoji-pop: shadowForColor now builds its glow via color-mix() instead of hex-pair slicing, so the strict 6-digit brand-color gate is gone — any CSS color the sibling templates accept (#fff, rgb(), named) now renders instead of silently falling back to the default palette - caption-weight-shift: fitFontSize now sizes against the WIDEST split line rather than the joined group text (two-line groups no longer shrink unnecessarily), and avoidSingleWordGroups' merges re-check fitsInTwoLines like makeGroups' first pass does (merged groups can no longer overflow the split budget) - all 5: hfApplyStageConfig clamps resolution to the published validator's <=8192 bound (validator is optional pre-flight; unbounded stages OOM render workers), and fit floors carry a comment documenting that the minimum size is returned unverified by design Co-Authored-By: Claude Fable 5 --- .../caption-editorial-emphasis.html | 16 +++++- .../caption-emoji-pop/caption-emoji-pop.html | 52 +++++++++-------- .../caption-highlight/caption-highlight.html | 16 +++++- .../caption-pill-karaoke.html | 16 +++++- .../caption-weight-shift.html | 57 +++++++++++++++---- 5 files changed, 117 insertions(+), 40 deletions(-) diff --git a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html index 3790946ee0..2622b09a80 100644 --- a/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html +++ b/registry/components/caption-editorial-emphasis/caption-editorial-emphasis.html @@ -226,8 +226,17 @@ function hfApplyStageConfig(data, durationS) { var res = (data && data.resolution) || HF_DEFAULT_RESOLUTION; - var W = Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)); - var H = Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)); + // Mirror the published validator's <=8192 bound defensively — the + // validator is an optional pre-flight, and an unbounded stage size + // would OOM render workers. + var W = Math.min( + 8192, + Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)), + ); + var H = Math.min( + 8192, + Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)), + ); var root = document.getElementById(HF_ROOT_ID); [document.documentElement, document.body, root].forEach(function (el) { el.style.width = W + "px"; @@ -420,6 +429,9 @@ function fitFontSize(text, baseFontSize, fontWeight, fontFamily, maxWidth) { var size = baseFontSize; var minSize = Math.floor(baseFontSize * 0.45); + // minSize is a hard floor returned unverified below — self-heal + // accepts residual overflow at the floor (clipped by the stage) + // rather than shrinking below legibility. while (size > minSize) { _fitCtx.font = fontWeight + " " + size + "px " + fontFamily; if (_fitCtx.measureText(text).width <= maxWidth) return size; diff --git a/registry/components/caption-emoji-pop/caption-emoji-pop.html b/registry/components/caption-emoji-pop/caption-emoji-pop.html index 7c982e2781..11d7c77f86 100644 --- a/registry/components/caption-emoji-pop/caption-emoji-pop.html +++ b/registry/components/caption-emoji-pop/caption-emoji-pop.html @@ -229,8 +229,17 @@ function hfApplyStageConfig(data, durationS) { var res = (data && data.resolution) || HF_DEFAULT_RESOLUTION; - var W = Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)); - var H = Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)); + // Mirror the published validator's <=8192 bound defensively — the + // validator is an optional pre-flight, and an unbounded stage size + // would OOM render workers. + var W = Math.min( + 8192, + Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)), + ); + var H = Math.min( + 8192, + Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)), + ); var root = document.getElementById(HF_ROOT_ID); [document.documentElement, document.body, root].forEach(function (el) { el.style.width = W + "px"; @@ -532,6 +541,9 @@ function fitFontSize(text, baseFontSize, fontWeight, fontFamily, maxWidth) { var size = baseFontSize; var minSize = Math.floor(baseFontSize * 0.45); + // minSize is a hard floor returned unverified below — self-heal + // accepts residual overflow at the floor (clipped by the stage) + // rather than shrinking below legibility. while (size > minSize) { _fitCtx.font = fontWeight + " " + size + "px " + fontFamily; if (_fitCtx.measureText(text).width <= maxWidth) return size; @@ -605,24 +617,16 @@ } function shadowForColor(color) { - var hex = color.replace("#", ""); - var r = parseInt(hex.slice(0, 2), 16); - var g = parseInt(hex.slice(2, 4), 16); - var b = parseInt(hex.slice(4, 6), 16); + // color-mix handles ANY CSS color (hex of either length, rgb()/hsl(), + // named colors) — the old hex-pair slicing was why brand colors were + // gated behind a strict 6-digit regex, silently dropping payloads the + // sibling templates accept. return ( - "0 4px 8px rgba(0,0,0,0.7), 0 0 2px rgba(" + - r + - "," + - g + - "," + - b + - ",1), 0 0 8px rgba(" + - r + - "," + - g + - "," + - b + - ",0.6)" + "0 4px 8px rgba(0,0,0,0.7), 0 0 2px " + + color + + ", 0 0 8px color-mix(in srgb, " + + color + + " 60%, transparent)" ); } @@ -689,12 +693,14 @@ stage.style.bottom = Math.round(80 * layout.scaleY) + "px"; var rootEl = document.getElementById(HF_ROOT_ID); + // Any non-empty CSS color is honored (mirrors caption-pill-karaoke) — + // shadowForColor no longer needs parseable hex, so the old 6-digit + // gate (which silently dropped #fff / rgb() / named colors that the + // sibling templates accept) is gone. var primary = getComputedStyle(rootEl).getPropertyValue("--hf-caption-primary").trim(); var accent = getComputedStyle(rootEl).getPropertyValue("--hf-caption-accent").trim(); - hfPrimaryColor = /^#[0-9a-f]{6}$/i.test(primary) ? primary : "#FFFFFF"; - hfAccentColors = /^#[0-9a-f]{6}$/i.test(accent) - ? [accent] - : ["#FF76FF", "#FF0002", "#B2F7FF"]; + hfPrimaryColor = primary || "#FFFFFF"; + hfAccentColors = accent ? [accent] : ["#FF76FF", "#FF0002", "#B2F7FF"]; var WORDS = normalizeWords(words); var GROUPS = makeGroups(WORDS); diff --git a/registry/components/caption-highlight/caption-highlight.html b/registry/components/caption-highlight/caption-highlight.html index a1d1b8be96..2156930ceb 100644 --- a/registry/components/caption-highlight/caption-highlight.html +++ b/registry/components/caption-highlight/caption-highlight.html @@ -132,6 +132,9 @@ function fitFontSize(text, baseFontSize, fontWeight, fontFamily, maxWidth) { var size = baseFontSize; var minSize = Math.floor(baseFontSize * 0.45); + // minSize is a hard floor returned unverified below — self-heal + // accepts residual overflow at the floor (clipped by the stage) + // rather than shrinking below legibility. while (size > minSize) { _fitCtx.font = fontWeight + " " + size + "px " + fontFamily; if (_fitCtx.measureText(text).width <= maxWidth) return size; @@ -235,8 +238,17 @@ function hfApplyStageConfig(data, durationS) { var res = (data && data.resolution) || HF_DEFAULT_RESOLUTION; - var W = Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)); - var H = Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)); + // Mirror the published validator's <=8192 bound defensively — the + // validator is an optional pre-flight, and an unbounded stage size + // would OOM render workers. + var W = Math.min( + 8192, + Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)), + ); + var H = Math.min( + 8192, + Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)), + ); var root = document.getElementById(HF_ROOT_ID); [document.documentElement, document.body, root].forEach(function (el) { el.style.width = W + "px"; diff --git a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html index c0025e2629..5e19c8ed47 100644 --- a/registry/components/caption-pill-karaoke/caption-pill-karaoke.html +++ b/registry/components/caption-pill-karaoke/caption-pill-karaoke.html @@ -235,8 +235,17 @@ function hfApplyStageConfig(data, durationS) { var res = (data && data.resolution) || HF_DEFAULT_RESOLUTION; - var W = Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)); - var H = Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)); + // Mirror the published validator's <=8192 bound defensively — the + // validator is an optional pre-flight, and an unbounded stage size + // would OOM render workers. + var W = Math.min( + 8192, + Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)), + ); + var H = Math.min( + 8192, + Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)), + ); var root = document.getElementById(HF_ROOT_ID); [document.documentElement, document.body, root].forEach(function (el) { el.style.width = W + "px"; @@ -464,6 +473,9 @@ function fontSizeForGroup(words) { var size = BASE_FONT_SIZE; + // MIN_FONT_SIZE is a hard floor returned unverified below — self-heal + // accepts residual overflow at the floor (clipped by the stage) + // rather than shrinking below legibility. while (size > MIN_FONT_SIZE && !fitsInTwoLinesAtSize(words, size)) { size -= 2; } diff --git a/registry/components/caption-weight-shift/caption-weight-shift.html b/registry/components/caption-weight-shift/caption-weight-shift.html index d6340fd6a2..b42c4e59ae 100644 --- a/registry/components/caption-weight-shift/caption-weight-shift.html +++ b/registry/components/caption-weight-shift/caption-weight-shift.html @@ -221,8 +221,17 @@ function hfApplyStageConfig(data, durationS) { var res = (data && data.resolution) || HF_DEFAULT_RESOLUTION; - var W = Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)); - var H = Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)); + // Mirror the published validator's <=8192 bound defensively — the + // validator is an optional pre-flight, and an unbounded stage size + // would OOM render workers. + var W = Math.min( + 8192, + Math.max(1, Math.round(Number(res.width) || HF_DEFAULT_RESOLUTION.width)), + ); + var H = Math.min( + 8192, + Math.max(1, Math.round(Number(res.height) || HF_DEFAULT_RESOLUTION.height)), + ); var root = document.getElementById(HF_ROOT_ID); [document.documentElement, document.body, root].forEach(function (el) { el.style.width = W + "px"; @@ -345,6 +354,9 @@ function fitFontSize(text, baseFontSize, fontWeight, fontFamily, maxWidth) { var size = baseFontSize; var minSize = Math.floor(baseFontSize * 0.45); + // minSize is a hard floor returned unverified below — self-heal + // accepts residual overflow at the floor (clipped by the stage) + // rather than shrinking below legibility. while (size > minSize) { _fitCtx.font = fontWeight + " " + size + "px " + fontFamily; if (_fitCtx.measureText(text).width <= maxWidth) return size; @@ -401,7 +413,11 @@ if ( group.words.length === 1 && out.length > 0 && - out[out.length - 1].words.length < MAX_WORDS_PER_GROUP + out[out.length - 1].words.length < MAX_WORDS_PER_GROUP && + // makeGroups guards every addition with fitsInTwoLines; this + // second-pass merge must too, or it can produce a group that + // splitLines cannot fit within MAX_LINE_WIDTH. + fitsInTwoLines(out[out.length - 1].words.concat(group.words)) ) { out[out.length - 1] = makeGroup(out[out.length - 1].words.concat(group.words)); } else { @@ -411,10 +427,18 @@ for (var i = 0; i < out.length; i++) { if (out[i].words.length !== 1) continue; - if (i + 1 < out.length && out[i + 1].words.length < MAX_WORDS_PER_GROUP) { + if ( + i + 1 < out.length && + out[i + 1].words.length < MAX_WORDS_PER_GROUP && + fitsInTwoLines(out[i].words.concat(out[i + 1].words)) + ) { out[i] = makeGroup(out[i].words.concat(out[i + 1].words)); out.splice(i + 1, 1); - } else if (i > 0 && out[i - 1].words.length < MAX_WORDS_PER_GROUP) { + } else if ( + i > 0 && + out[i - 1].words.length < MAX_WORDS_PER_GROUP && + fitsInTwoLines(out[i - 1].words.concat(out[i].words)) + ) { out[i - 1] = makeGroup(out[i - 1].words.concat(out[i].words)); out.splice(i, 1); i--; @@ -464,13 +488,24 @@ function buildCaptions(groups) { var stage = document.getElementById("caption-stage"); groups.forEach(function (group, groupIndex) { - var groupText = group.words - .map(function (w) { - return w.text.toLowerCase(); - }) - .join(" "); + // Fit the WIDEST split line, not the joined group text: the group + // renders across up to two lines, so sizing against the joined + // string shrank two-line groups that already fit at full size. + var widestLineText = ""; + var widestLineWidth = -1; + group.lines.forEach(function (line) { + var lineWidth = measureLineWidth(line.words); + if (lineWidth > widestLineWidth) { + widestLineWidth = lineWidth; + widestLineText = line.words + .map(function (w) { + return w.text.toLowerCase(); + }) + .join(" "); + } + }); var computedSize = fitFontSize( - groupText, + widestLineText, CAPTION_FONT_SIZE, "700", "Montserrat",