// The posts region's composition: one HyperFrames page per WINDOW -- the // stretch of the cut in which one clip's posts are on screen. // // PURE, like chrome-deck.mjs: a schedule, a render block and a window in, an // HTML string out. compose-chrome.mjs copies the assets in beside it, writes it // and renders it; nothing here touches a file. // // --------------------------------------------------------------------------- // Why a window and not the whole cut // --------------------------------------------------------------------------- // A post is on screen for a few seconds at the end of the clip it rides on. A // sequence for the whole cut would be ten thousand transparent frames to buy // fifteen seconds of cards, so each clip that carries posts gets its own short // sequence (`postWindows`), overlaid at its own start. Frame 1 of a window is // cut time `from`; the windows are snapped OUTWARD to the frame grid // (`snapWindow`) so frame i lands exactly on the cut's frame f0 + i. // // --------------------------------------------------------------------------- // Why the stack is planned in the page, by a function that lives here // --------------------------------------------------------------------------- // When the next card would overflow the column the oldest slide up and out -- // and whether one would overflow depends on how tall each card is, which is // how its words wrap in the deck's own face. Only the browser knows that, and // only once the faces are in. So the page measures every card after the fonts // load and hands the heights to `postsCues` -- THIS module's function, written // into the page by its source text. The plan (every cue, its from, its time) // is the same function the tests call with heights of their choosing; the // browser holds no logic a test cannot see. The timeline is built, and then // registered, inside that fonts-loaded callback: the renderer awaits // document.fonts.ready before its first seek. // // Every cue is a fromTo whose FROM is stated, for the deck's reason: a render // is a seek per frame, from parallel workers, in any order. import { formatDeckDate } from "./attribution.mjs"; import { pageDuration, postsGeometry, resolveDeck, snapWindow } from "./deck.mjs"; import { mix, rgba } from "./chrome-deck.mjs"; // The window arithmetic is deck.mjs's (pure, and loaded by the build without // this page module); re-exported for the page's own callers. export { snapWindow }; const esc = (s) => String(s ?? "") .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); const r4 = (v) => Math.round(v * 10000) / 10000; /** How a platform is named on a card. */ export const PLATFORM_LABEL = Object.freeze({ bluesky: "Bluesky", x: "X", web: "Web" }); /** * The motion. Seconds. A card slides in from the frame's edge over `enter`; * its accent glow flares to full over `glowUp`, starting `glowAt` into the * entrance, then settles to `glowRest` over `glowDown`. `gap` is the space between two cards in the column. */ export const POSTS_MOTION = Object.freeze({ enter: 0.55, slide: 0.35, gap: 14, glowAt: 0.3, glowUp: 0.18, glowDown: 1.1, glowRest: 0.3, }); /** The schedule's posts for one window's segment, in slot order. */ export function windowPosts(schedule, segment) { return (schedule.posts ?? []).filter((p) => p.segment === segment).sort((a, b) => a.slot - b.slot); } /** * A module function written into the page under a FIXED name. The page calls * it by that name, and `fn.toString()` alone would declare whatever name the * function has here -- which a bundler minifying server code renames (umtool's * production build turned `postsCues` into `d`, and the page then threw a * ReferenceError and drew nothing). As a named const of a parenthesised * function expression, the page's name never depends on the module's. */ export function embedFn(name, fn) { return `const ${name} = (${fn.toString()});`; } /** * Everything the posts timeline does, as data. PURE and SELF-CONTAINED: the * page carries this function's own source text and calls it with the heights * it measured, so it may reference nothing outside its own body. * * `posts` are `[{ id, appear, out: [a, b] }]` in slot order (oldest first), * times in the CUT's clock; `heights` are the cards' heights in px; `column` * is the region's height. Card j slides in at its `appear` from `enterX` px * to the side (the frame's edge) over `enter` s, and its glow (`g`) flares * as it lands and settles to `glowRest`; cards stack top-down `gap` apart; * when card j would overflow the column, the oldest slide up and out (the * whole stack moves up over `slide` s, the departing cards fading as they go); * every card still up leaves over its `out` (a front-loaded fade and a slight * shrink). * * `pans` are, per card, how many px its screenshot runs past its viewport (0: * it fits). A tall screenshot is drawn at the card's full width -- readable -- * in a viewport no taller than the column allows, and pans up through the * rest (`s`, its y): it holds `panHold` s on its top once the card has * landed, glides to its bottom, and holds there `panHold` s before it leaves. * With too little time for the holds, the glide takes all of it. * * @returns {{ tops: number[], init: Record, * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }} */ export function postsCues({ posts, heights, column, gap = 14, enter = 0.55, slide = 0.35, enterX = 624, glowAt = 0.3, glowUp = 0.18, glowDown = 1.1, glowRest = 0.3, pans = [], panHold = 1.2, }) { const R = (v) => Math.round(v * 10000) / 10000; const MIN = 0.001; const tops = []; let acc = 0; for (let j = 0; j < posts.length; j += 1) { tops.push(acc); acc += (heights[j] || 0) + gap; } const init = { stack: { y: 0 } }; for (let j = 0; j < posts.length; j += 1) { init[`c${j}`] = { autoAlpha: 0, x: enterX, scale: 1 }; init[`g${j}`] = { opacity: 0 }; if (pans[j] > 0) init[`s${j}`] = { y: 0 }; } const ev = []; const add = (k, at, dur, to, ease, why) => ev.push({ k, at: R(at), dur: R(Math.max(MIN, dur)), to, ease, why }); const visible = []; let shift = 0; for (let j = 0; j < posts.length; j += 1) { const p = posts[j]; const t = p.appear; // The oldest go until card j fits below what is left. const gone = []; while (visible.length && tops[j] + (heights[j] || 0) - shift > column) { gone.push(visible.shift()); shift = visible.length ? tops[visible[0]] : tops[j]; } if (gone.length) { add("stack", t, slide, { y: -shift }, "power2.inOut", `slide for ${p.id}`); for (const g of gone) add(`c${g}`, t, slide, { autoAlpha: 0 }, "power1.in", `slide for ${p.id}`); } add(`c${j}`, t, enter, { autoAlpha: 1, x: 0 }, "expo.out", `enter ${p.id}`); // The flare, as the card lands; then it settles to a quiet rim. add(`g${j}`, t + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${p.id}`); add(`g${j}`, t + glowAt + glowUp, glowDown, { opacity: glowRest }, "power2.inOut", `settle ${p.id}`); if (pans[j] > 0) { const landed = t + enter; const leave = p.out[0]; let a = landed + panHold; let b = leave - panHold; if (b - a < 1) { a = landed; b = Math.max(landed + MIN, leave); } add(`s${j}`, a, b - a, { y: -pans[j] }, "sine.inOut", `pan ${p.id}`); } visible.push(j); } for (const j of visible) { const [a, b] = posts[j].out; // Front-loaded: mostly gone by the mid-dissolve, where the deck hands over. add(`c${j}`, a, b - a, { autoAlpha: 0, scale: 0.97 }, "power2.out", `leave ${posts[j].id}`); } // Order, clamp (a cue never starts before the last one on its element has // ended), and state every from. ev.forEach((e, n) => { e.n = n; }); ev.sort((x, y) => x.at - y.at || x.n - y.n); const state = {}; for (const k of Object.keys(init)) state[k] = { ...init[k] }; const freeAt = {}; const cues = []; for (const e of ev) { let { at, dur } = e; const free = freeAt[e.k] ?? -Infinity; if (at < free) { const end = at + dur; at = R(free); dur = R(Math.max(MIN, end - at)); } const cur = state[e.k] ?? (state[e.k] = {}); const from = {}; for (const q of Object.keys(e.to)) from[q] = cur[q]; Object.assign(cur, e.to); freeAt[e.k] = R(at + dur); cues.push({ k: e.k, at, dur, from, to: e.to, ease: e.ease, why: e.why, n: cues.length }); } // A clamp only ever moves a cue later on its own element, so this re-sort // keeps every element's own order (and so every from). cues.sort((x, y) => x.at - y.at || x.n - y.n); for (const c of cues) delete c.n; return { tops, init, cues }; } /** A card's head: `@handle · Bluesky`, the separator in the deck's accent. */ export function postWho(post) { const handle = String(post.handle ?? "").trim(); const name = handle ? `@${handle.replace(/^@/, "")}` : String(post.author ?? "").trim(); const platform = PLATFORM_LABEL[post.platform] ?? String(post.platform ?? ""); return { name, platform }; } /** * A post's words as paragraphs: split on blank lines, single newlines kept * inside each (the card draws them `pre-line`). A blank line is a gap between * blocks rather than an empty line, so it costs none of `maxLines`. */ export function postParagraphs(text) { return String(text ?? "") .replace(/\r\n?/g, "\n") .split(/\n[ \t]*\n+/) .map((p) => p.replace(/^\n+|\s+$/g, "")) .filter((p) => p.trim()); } /** A post's date as the deck writes dates; a date-time is drawn as its day. */ export function postDate(post, dateFormat = "long") { return formatDeckDate(String(post.date ?? "").slice(0, 10), dateFormat); } /** * The posts composition's HTML, for ONE window (a `snapWindow` result). * * `fonts` = `{ regular, bold }` asset-relative paths (DeckSans / DeckSansBold), * `qrSrcs` = `{ [postId]: "assets/pqrNN.png" }`, `gsap` = the vendored script. * `shotSrcs` = `{ [postId]: "assets/shotNN.png" }`: a post with a screenshot * (`posts[].shot`) draws it in place of its text card, its QR cell kept. * `logoSrcs` = `{ [postId]: "assets/logoNN.png" }`: a post's `logo`, drawn in * the card's top corner above its QR. A post's `accent` colours its rail and * rim (and its flag); its `flag` is a short label above its words or picture. * The region is `postsGeometry(render)`, region-local and transparent outside * the cards. `?still=` and the preview's `deck:seek` take CUT seconds. */ export function postsHtml(schedule, render, window, opts = {}) { const deck = resolveDeck(render); const set = deck.posts; const geo = postsGeometry(render); const pal = render.palette; const W = geo.width, H = geo.height; const fonts = opts.fonts ?? {}; const qrSrcs = opts.qrSrcs ?? {}; const shotSrcs = opts.shotSrcs ?? {}; const logoSrcs = opts.logoSrcs ?? {}; const gsapSrc = opts.gsap ?? "assets/gsap.min.js"; const posts = windowPosts(schedule, window.segment); if (!posts.length) throw new Error(`posts: no post rides on ${window.segment}`); const dur = r4(window.to - window.from); if (!(dur > 0)) throw new Error(`posts: the window for ${window.segment} is empty`); const pad = 18; const plateW = set.qrSize + 2 * pad; const LOGO_H = 72; const rail = 6; const metaSize = 18; const textSize = 24; const lineH = Math.round(textSize * 1.36); const top = mix(mix(pal.bg, pal.fg, 0.1), pal.accent, 0.07); const bottom = mix(mix(pal.bg, pal.fg, 0.045), pal.accent, 0.04); const cardHtml = posts .map((p, j) => { const { name, platform } = postWho(p); const src = qrSrcs[p.id]; const shot = shotSrcs[p.id]; const logo = logoSrcs[p.id]; const acc = typeof p.accent === "string" && /^#[0-9a-fA-F]{6}$/.test(p.accent) ? p.accent : null; const style = acc ? ` style="--acc: ${acc}; --acc-rim: ${rgba(acc, 0.95)}; --acc-glow: ${rgba(acc, 0.55)}"` : ""; const flag = p.flag ? `
${esc(p.flag)}
` : ""; return ( `
` + (shot ? `
${flag}
` : `
${flag}` + `
` + (platform ? `${esc(platform)}` : "") + `${esc(name)}` + `${esc(postDate(p, deck.subtitle.dateFormat))}
` + `
${postParagraphs(p.text).map((t) => `

${esc(t)}

`).join("")}
` + `
`) + `
` + (logo ? `` : "") + (src ? `` : "") + `
` + `
` + `
` ); }) .join("\n "); const data = { segment: window.segment, from: r4(window.from), to: r4(window.to), dur, column: H, maxLines: set.maxLines, lineH, gap: POSTS_MOTION.gap, enter: POSTS_MOTION.enter, slide: POSTS_MOTION.slide, // From just past the frame's own edge on the column's side, so a card // comes in from outside the picture, not out of the region's boundary. enterX: set.position === "top-left" ? -(geo.x + W) : (render.width ?? 1920) - geo.x, glowAt: POSTS_MOTION.glowAt, glowUp: POSTS_MOTION.glowUp, glowDown: POSTS_MOTION.glowDown, glowRest: POSTS_MOTION.glowRest, ids: posts.map((p) => p.id), posts: posts.map((p) => ({ id: p.id, appear: p.appear, out: p.out })), }; // `` inside a JSON string would close the tag; nothing in here is // trusted text, but a segment id is the manifest's and costs nothing to guard. const json = JSON.stringify(data).replace(/
${cardHtml}
`; }