Skip to content

Commit 31cf0e0

Browse files
lefarcenaudit
andauthored
fix(plugins-home): deck slide-tour bakes, CJK font fix, framing + reveal height (#4020)
* fix(plugins-home): anchor baked preset previews to the hero top The example-prompt preset tiles render wider than the gallery's 1.31:1 baked clip, so object-fit:cover was cropping the hero headline out of the vertical middle. Anchor the preset video/poster to the top so the hero stays in frame. Scoped to the preset cards; the Community grid (which matches the clip aspect) is untouched. * fix(plugins-home): bake decks as slide tours, fix CJK font tofu, unclip reveal Three gallery-preview fixes found reviewing the baked Community gallery: - Decks (fixed-viewport PPT/slideshow pages driven by arrow/wheel, not scroll) were vertically panned like tall landing pages — capturing ~20s of a dead WebGL background at the wrong 1.31 aspect (the page's compact responsive variant, hero headline gone). The bake now detects them (page no taller than the viewport), re-renders at 16:9, and walks their slides (arrow keys, falling back to wheel then a "next" control, detected by a structural slide signal) for a slide-tour the gallery loops on hover. BAKE_VERSION -> 2 re-bakes all. - CJK-heavy templates baked tofu boxes: they pull Noto Serif/Sans SC from Google Fonts with display=swap and the CI runner had no CJK fallback. The bake job now installs fonts-noto-cjk + emoji, and the bake double-awaits fonts.ready around a force-load so a late-registered display face isn't captured as its fallback. - The Home templates reveal clipped its last rows: a fixed max-height:6000px ceiling + overflow:hidden, but the gallery is ~7300px and grows. Switched to the repo's canonical grid-template-rows 0fr->1fr auto-height pattern (inner wrapper owns the overflow) so it expands to the gallery's natural height. * fix(bake): probe-detect deck nav + capture at the 1.31 tile aspect Refines the deck path after live review: - Detection was scrollHeight-based, so a deck whose DOM stacks slides vertically got misread as a scroll page and vertically panned. Now PROBE the navigation: press the arrow key, then nudge the wheel, and use whichever actually moves a slide (deckSignal ignores the WebGL background); only a page that neither navigates nor fits the viewport keeps the linear pan. - Captured decks at 16:9, which object-fit:cover then side-cropped in the 1.31 tile (hero edges cut). Decks collapse to a compact variant at the normal 1440 width, NOT at the 1.31 aspect — so capture at 1.31 (1760x1344, wide enough to clear the breakpoint): full layout AND a clip that fills the tile, no crop. --------- Co-authored-by: audit <a@b.c>
1 parent ec47346 commit 31cf0e0

5 files changed

Lines changed: 177 additions & 33 deletions

File tree

.github/workflows/bake-plugin-previews.yml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,14 @@ jobs:
3737
- name: Setup workspace
3838
uses: ./.github/actions/setup-workspace
3939

40-
- name: Install ffmpeg + puppeteer-core
40+
- name: Install ffmpeg + CJK fonts + puppeteer-core
4141
run: |
4242
sudo apt-get update
43-
sudo apt-get install -y ffmpeg
43+
# ffmpeg encodes the clips; the Noto CJK + emoji fonts give headless
44+
# Chrome a real CJK/emoji fallback so templates that rely on system
45+
# fonts for 中文/日本語/한국어 (or load Noto SC from Google Fonts but
46+
# race) render glyphs instead of missing-glyph tofu boxes.
47+
sudo apt-get install -y ffmpeg fonts-noto-cjk fonts-noto-color-emoji
4448
# npm chokes on the workspace:* root manifest; use pnpm to drop
4549
# puppeteer-core into the workspace node_modules (CI-only, not committed).
4650
pnpm add -w puppeteer-core

apps/web/src/components/HomeTemplatesReveal.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export function HomeTemplatesReveal({ enabled, children }: Props) {
7373
className="home-templates-reveal__body"
7474
aria-hidden={!revealed}
7575
>
76-
{children}
76+
<div className="home-templates-reveal__inner">{children}</div>
7777
</div>
7878

7979
{revealed ? (

apps/web/src/styles/home/home-hero.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2076,6 +2076,14 @@
20762076
transform: scale(0.172);
20772077
}
20782078

2079+
/* Baked clips are framed 1.31:1 for the Community grid; the wider preset tiles
2080+
would otherwise object-fit:cover-crop the hero out of the middle. Anchor to
2081+
the top so the hero headline stays in frame. Scoped to the preset cards. */
2082+
.home-hero__plugin-preset-preview .plugins-home__media-video,
2083+
.home-hero__plugin-preset-preview .plugins-home__media-img {
2084+
object-position: top center;
2085+
}
2086+
20792087
.home-hero__plugin-preset-title {
20802088
min-width: 0;
20812089
overflow: hidden;

apps/web/src/styles/home/plugins-home.css

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1145,20 +1145,28 @@ button.plugins-home__use-menu-item:focus-visible {
11451145
display: flex;
11461146
flex-direction: column;
11471147
}
1148-
/* Collapsed: clip the gallery to zero height and fade it out. The large
1149-
max-height ceiling lets the expand transition animate to natural height
1150-
without measuring it. */
1148+
/* Collapsed: clip the gallery to zero height and fade it out. Uses the repo's
1149+
canonical auto-height pattern (grid-template-rows 0fr -> 1fr) so the expand
1150+
animates to the gallery's NATURAL height — a fixed max-height ceiling (was
1151+
6000px) clipped the last rows once the gallery outgrew it. The inner wrapper
1152+
owns overflow:hidden so the collapsed rows clip cleanly. */
11511153
.home-templates-reveal__body {
1152-
max-height: 0;
1154+
display: grid;
1155+
grid-template-rows: 0fr;
11531156
opacity: 0;
1154-
overflow: hidden;
11551157
transition:
1156-
max-height 420ms cubic-bezier(0.23, 1, 0.32, 1),
1158+
grid-template-rows 420ms cubic-bezier(0.23, 1, 0.32, 1),
11571159
opacity 320ms cubic-bezier(0.23, 1, 0.32, 1);
11581160
}
1161+
.home-templates-reveal__inner {
1162+
min-height: 0;
1163+
overflow: hidden;
1164+
}
11591165
.home-templates-reveal.is-revealed .home-templates-reveal__body {
1160-
max-height: 6000px;
1166+
grid-template-rows: 1fr;
11611167
opacity: 1;
1168+
}
1169+
.home-templates-reveal.is-revealed .home-templates-reveal__inner {
11621170
/* Leave room for the fixed collapse pill so it never covers the last row. */
11631171
padding-bottom: 64px;
11641172
}

scripts/bake-plugin-previews.mjs

Lines changed: 147 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,23 @@ import path from 'node:path';
4242

4343
// Bump when the bake recipe changes (capture geometry, timing, encoder, waits…)
4444
// so every plugin re-bakes even though its page content is byte-identical.
45-
const BAKE_VERSION = 1;
45+
// v2: deck mode (16:9 + slide-walk for fixed-viewport PPT/slideshow pages) +
46+
// force-load webfonts before capture (CJK templates were baking tofu).
47+
const BAKE_VERSION = 2;
4648

4749
// ---- config ---------------------------------------------------------------
4850
const BASE_URL = process.env.BASE_URL || 'http://127.0.0.1:17579';
4951
const RENDER_W = 1440; // pages lay out at their desktop width
5052
const VIEW_H = 1099; // 1.31-aspect window showing the FULL width (no clip)
53+
// Decks (PPT/slideshow: a fixed 100vh page navigated by arrow keys or the wheel,
54+
// NOT vertical scroll) are captured at the SAME 1.31 tile aspect as everything
55+
// else — so the clip fills the card with no crop or letterbox — but at a larger
56+
// width. At the normal 1440 width decks hit a width breakpoint and collapse into
57+
// a compact variant (hero headline -> condensed strip); 1760 clears it.
58+
const DECK_W = 1760;
59+
const DECK_H = 1344; // 1760/1344 = 1.31, the tile aspect
60+
const SLIDE_MS = 1150; // deck per-slide dwell: ~.9s CSS transition + settle
61+
const MAX_SLIDES = 6; // advance budget; HOLD + slides stays ~<=9s
5162
const OUT_W = Number(process.env.PREVIEW_W || 640); // small — tile renders ~393px
5263
const FPS = Number(process.env.PREVIEW_FPS || 30); // 30 is smooth for a gentle pan, half the bytes of 60
5364
const VELOCITY = 0.30; // px/ms — base pan pace (snappy but readable)
@@ -99,6 +110,60 @@ async function discoverIds() {
99110
return LIMIT ? ids.slice(0, LIMIT) : ids;
100111
}
101112

113+
// ---- deck (PPT/slideshow) driving -----------------------------------------
114+
// A structural fingerprint of the current slide, robust across deck styles:
115+
// the transform of any slide-rail wider/taller than the viewport, the scroll
116+
// offset of a horizontal track, and any active-slide marker. Deliberately does
117+
// NOT read canvas pixels, so a continuously-animating WebGL background doesn't
118+
// look like a slide change. Runs in the page; must stay self-contained.
119+
function deckSignal() {
120+
const parts = [location.hash];
121+
for (const el of document.querySelectorAll('*')) {
122+
const r = el.getBoundingClientRect();
123+
if (r.width > window.innerWidth * 1.5 || r.height > window.innerHeight * 1.5) {
124+
parts.push(getComputedStyle(el).transform);
125+
}
126+
if (el.scrollWidth > el.clientWidth + 4) parts.push(`sl${el.scrollLeft}`);
127+
}
128+
const a = document.querySelector('.active,.is-active,[aria-current="true"],[data-active="true"]');
129+
if (a) parts.push((a.id || '') + (a.className || ''));
130+
return parts.join('|').slice(0, 4000);
131+
}
132+
133+
async function driveDeck(page, driver) {
134+
if (driver === 'arrow') { await page.keyboard.press('ArrowRight'); return; }
135+
if (driver === 'wheel') {
136+
await page.evaluate(() => {
137+
const mk = () => new WheelEvent('wheel', { deltaY: 800, bubbles: true, cancelable: true });
138+
(document.querySelector('#deck,main,section') || document.body).dispatchEvent(mk());
139+
window.dispatchEvent(mk());
140+
});
141+
return;
142+
}
143+
await page.evaluate(() => {
144+
const b = document.querySelector('[class*="next" i],[aria-label*="next" i],[data-dir="next"],.arrow-right,.next');
145+
if (b) b.click();
146+
});
147+
}
148+
149+
// Walk a deck through its slides with the driver detection already found (arrow
150+
// or wheel), played past the HOLD on hover. Stops at the last slide (an input
151+
// that no longer changes deckSignal). A null driver is a fixed single slide with
152+
// no navigation — just hold. Returns the wall-clock span (the manifest durationMs).
153+
async function walkSlides(page, driver) {
154+
if (!driver) return SLIDE_MS;
155+
let moved = 0;
156+
const t0 = Date.now();
157+
for (let s = 0; s < MAX_SLIDES; s += 1) {
158+
const before = await page.evaluate(deckSignal);
159+
await driveDeck(page, driver);
160+
await sleep(SLIDE_MS);
161+
if ((await page.evaluate(deckSignal)) === before) break; // reached the last slide
162+
moved += 1;
163+
}
164+
return Math.max(SLIDE_MS, Date.now() - t0);
165+
}
166+
102167
// ---- render + encode one plugin -------------------------------------------
103168
async function bakeOne(browser, id, hash) {
104169
const page = await browser.newPage();
@@ -110,6 +175,39 @@ async function bakeOne(browser, id, hash) {
110175
} catch (e) { await page.close(); return { id, skipped: `load ${e.message}` }; }
111176
await sleep(1000);
112177

178+
// Classify navigation by PROBING, not guessing from scrollHeight: press the
179+
// arrow key, then nudge the wheel, and see whether a slide actually moves
180+
// (deckSignal ignores the WebGL background, so only real navigation counts).
181+
// Most decks are arrow-key; some advance on the wheel; the rest are ordinary
182+
// vertical-scroll pages (incl. up/down decks) and keep the linear pan. Probing
183+
// advances the deck, so decks reload to reset to slide 1 before capturing.
184+
let deckDriver = null;
185+
{
186+
const sig0 = await page.evaluate(deckSignal);
187+
await driveDeck(page, 'arrow');
188+
await sleep(900);
189+
if ((await page.evaluate(deckSignal)) !== sig0) deckDriver = 'arrow';
190+
else {
191+
await driveDeck(page, 'wheel');
192+
await sleep(900);
193+
if ((await page.evaluate(deckSignal)) !== sig0) deckDriver = 'wheel';
194+
}
195+
}
196+
const vScrollable = await page.evaluate(
197+
() => document.documentElement.scrollHeight > window.innerHeight * 1.15,
198+
);
199+
// A horizontally-navigable deck OR a fixed-viewport single slide is a deck:
200+
// capture at the deck aspect and walk it. A page that only scrolls vertically
201+
// (a landing page, or an up/down deck) keeps the linear pan.
202+
const isDeck = deckDriver !== null || !vScrollable;
203+
let capW = RENDER_W, capH = VIEW_H;
204+
if (isDeck) {
205+
capW = DECK_W; capH = DECK_H;
206+
await page.setViewport({ width: capW, height: capH, deviceScaleFactor: 1 });
207+
try { await page.reload({ waitUntil: 'domcontentloaded', timeout: 25000 }); } catch {}
208+
await sleep(1000);
209+
}
210+
113211
// Trigger lazy images by scrolling through, then wait for them, then reset.
114212
await page.evaluate(async () => {
115213
const h = document.documentElement.scrollHeight;
@@ -129,7 +227,27 @@ async function bakeOne(browser, id, hash) {
129227
try {
130228
await page.evaluate((capMs) => {
131229
const cap = new Promise((r) => setTimeout(r, capMs));
132-
const fonts = document.fonts ? document.fonts.ready.catch(() => {}) : Promise.resolve();
230+
// CJK-heavy templates pull Noto Serif/Sans SC from Google Fonts with
231+
// display=swap; awaiting fonts.ready alone can resolve on the swap
232+
// fallback. Force every registered face to load, THEN await ready, so the
233+
// capture isn't a fallback/tofu render. (The CI bake also installs
234+
// fonts-noto-cjk so the fallback itself carries CJK glyphs if a webfont is
235+
// slow or blocked, instead of rendering missing-glyph boxes.)
236+
// Double-pass: await ready FIRST so faces registered by a late-arriving
237+
// Google Fonts stylesheet are present, force-load any still unloaded, then
238+
// await ready again — a single pass can enumerate before display=swap
239+
// faces exist and capture the fallback (e.g. a Shrikhand/Playfair heading).
240+
const fonts = document.fonts
241+
? document.fonts.ready
242+
.catch(() => {})
243+
.then(() => Promise.all(
244+
Array.from(document.fonts).map((f) =>
245+
f.status === 'loaded' ? null : f.load().catch(() => {}),
246+
),
247+
))
248+
.then(() => document.fonts.ready)
249+
.catch(() => {})
250+
: Promise.resolve();
133251
const imgs = Array.from(document.images)
134252
.filter((i) => !i.complete)
135253
.map((i) => new Promise((res) => {
@@ -171,31 +289,37 @@ async function bakeOne(browser, id, hash) {
171289
try { await client.send('Page.screencastFrameAck', { sessionId: e.sessionId }); } catch {}
172290
});
173291

174-
const maxY = await page.evaluate(() =>
292+
const maxY = isDeck ? 0 : await page.evaluate(() =>
175293
Math.max(0, document.documentElement.scrollHeight - window.innerHeight));
176-
// Pre-computed from the measured page height so the pan always reaches the
177-
// bottom within MAX_PAN (whole clip stays ~<=10s): base VELOCITY for normal
178-
// pages, auto-sped-up (capped duration) for tall ones.
179-
const durMs = maxY <= 0 ? 2500 : Math.min(MAX_PAN, Math.round(maxY / VELOCITY));
180294

181295
await client.send('Page.startScreencast',
182-
{ format: 'jpeg', quality: 80, everyNthFrame: 1, maxWidth: RENDER_W, maxHeight: VIEW_H });
183-
// Phase 1 — HOLD at the top, capturing the page's in-place animation. The
184-
// gallery loops this leading span while idle (no pan), so animated pages
185-
// still look alive without auto-scrolling.
296+
{ format: 'jpeg', quality: 80, everyNthFrame: 1, maxWidth: capW, maxHeight: capH });
297+
// Phase 1 — HOLD on the first slide / page top, capturing in-place animation.
298+
// The gallery loops this leading span while idle (no advance), so animated
299+
// pages still look alive without auto-playing.
186300
await sleep(HOLD_MS);
187-
// Phase 2 — linear pan top -> bottom (played past the hold on hover).
188-
await page.evaluate((dur, my) => new Promise((res) => {
189-
if (my <= 0) { setTimeout(res, dur); return; }
190-
let start = null;
191-
function step(t) {
192-
if (start === null) start = t;
193-
const e = Math.min(1, (t - start) / dur);
194-
window.scrollTo(0, Math.round(my * e)); // linear = constant velocity
195-
if (e < 1) requestAnimationFrame(step); else res();
196-
}
197-
requestAnimationFrame(step);
198-
}), durMs, maxY);
301+
// Phase 2 — played past the hold on hover: decks walk their slides; scrollable
302+
// pages linear-pan (constant velocity) top -> bottom.
303+
let durMs;
304+
if (isDeck) {
305+
durMs = await walkSlides(page, deckDriver);
306+
} else {
307+
// Pre-computed from the measured page height so the pan always reaches the
308+
// bottom within MAX_PAN (whole clip stays ~<=10s): base VELOCITY for normal
309+
// pages, auto-sped-up (capped duration) for tall ones.
310+
durMs = maxY <= 0 ? 2500 : Math.min(MAX_PAN, Math.round(maxY / VELOCITY));
311+
await page.evaluate((dur, my) => new Promise((res) => {
312+
if (my <= 0) { setTimeout(res, dur); return; }
313+
let start = null;
314+
function step(t) {
315+
if (start === null) start = t;
316+
const e = Math.min(1, (t - start) / dur);
317+
window.scrollTo(0, Math.round(my * e)); // linear = constant velocity
318+
if (e < 1) requestAnimationFrame(step); else res();
319+
}
320+
requestAnimationFrame(step);
321+
}), durMs, maxY);
322+
}
199323
await client.send('Page.stopScreencast');
200324
await page.close();
201325

0 commit comments

Comments
 (0)