commit ff8449185ef7ba3a465e733dfd17174333c68f64
parent 21cc3458c9fac20f0393872240d1446cd13cb35d
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 1 Oct 2026 13:23:10 -0400
report-to-video: posts read as an interruption — cards slide in from the frame's edge, flare and settle, with an accent rail and a platform pill
Each card slides in from past the frame's edge on the column's side, its
accent rim flares as it lands and settles to a quiet glow; an accent rail runs
down its leading edge and the platform is a pill beside the handle. Text 24 px
(meta 18) with tighter padding, so the ferret cut's three-post stack still fits
the column. Every cue a fromTo; the planner is still the page's by embedFn;
the QR stays fully opaque.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
2 files changed, 81 insertions(+), 39 deletions(-)
diff --git a/umtool/report-to-video/chrome-posts.mjs b/umtool/report-to-video/chrome-posts.mjs
@@ -53,10 +53,13 @@ const r4 = (v) => Math.round(v * 10000) / 10000;
export const PLATFORM_LABEL = Object.freeze({ bluesky: "Bluesky", x: "X" });
/**
- * The motion. Seconds; `rise` is how far (px) a card comes up as it fades in,
- * `gap` the space between two cards in the column.
+ * 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.35, slide: 0.35, rise: 18, gap: 14 });
+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) {
@@ -82,16 +85,21 @@ export function embedFn(name, fn) {
*
* `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 enters at its `appear` (a fade and a rise of
- * `rise` px over `enter` s); 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).
+ * 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<j>`) 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).
*
* @returns {{ tops: number[], init: Record<string, object>,
* 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.35, slide = 0.35, rise = 18 }) {
+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,
+}) {
const R = (v) => Math.round(v * 10000) / 10000;
const MIN = 0.001;
const tops = [];
@@ -101,7 +109,10 @@ export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slid
acc += (heights[j] || 0) + gap;
}
const init = { stack: { y: 0 } };
- for (let j = 0; j < posts.length; j += 1) init[`c${j}`] = { autoAlpha: 0, y: rise, scale: 1 };
+ for (let j = 0; j < posts.length; j += 1) {
+ init[`c${j}`] = { autoAlpha: 0, x: enterX, scale: 1 };
+ init[`g${j}`] = { opacity: 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 });
@@ -120,7 +131,10 @@ export function postsCues({ posts, heights, column, gap = 14, enter = 0.35, slid
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, y: 0 }, "power3.out", `enter ${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}`);
visible.push(j);
}
for (const j of visible) {
@@ -209,11 +223,12 @@ export function postsHtml(schedule, render, window, opts = {}) {
const pad = 18;
const plateW = set.qrSize + 2 * pad;
- const metaSize = 17;
- const textSize = 23;
+ const rail = 6;
+ const metaSize = 18;
+ const textSize = 24;
const lineH = Math.round(textSize * 1.36);
- const top = mix(pal.bg, pal.fg, 0.095);
- const bottom = mix(pal.bg, pal.fg, 0.04);
+ 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) => {
@@ -221,16 +236,17 @@ export function postsHtml(schedule, render, window, opts = {}) {
const src = qrSrcs[p.id];
return (
`<article class="post" data-post="${esc(p.id)}" data-k="c${j}">` +
- `<div class="edge"></div>` +
`<div class="body">` +
- `<div class="meta"><span class="who"><span class="handle">${esc(name)}</span>` +
- (platform ? `<span class="sep">·</span><span class="platform">${esc(platform)}</span>` : "") +
- `</span><span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` +
+ `<div class="meta">` +
+ (platform ? `<span class="platform">${esc(platform)}</span>` : "") +
+ `<span class="who"><span class="handle">${esc(name)}</span></span>` +
+ `<span class="date">${esc(postDate(p, deck.subtitle.dateFormat))}</span></div>` +
`<div class="text">${postParagraphs(p.text).map((t) => `<p class="para">${esc(t)}</p>`).join("")}</div>` +
`</div>` +
`<div class="plate">` +
(src ? `<img src="${esc(src)}" width="${set.qrSize}" height="${set.qrSize}" alt="">` : "") +
`</div>` +
+ `<div class="glow" data-k="g${j}"></div>` +
`</article>`
);
})
@@ -247,7 +263,13 @@ export function postsHtml(schedule, render, window, opts = {}) {
gap: POSTS_MOTION.gap,
enter: POSTS_MOTION.enter,
slide: POSTS_MOTION.slide,
- rise: POSTS_MOTION.rise,
+ // 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 })),
};
@@ -275,30 +297,36 @@ export function postsHtml(schedule, render, window, opts = {}) {
#posts-clip { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
.stack { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; }
/* Hidden until the timeline places it: a frame taken before the faces
- are in shows nothing rather than an unplaced card. */
+ are in shows nothing rather than an unplaced card. A card is an
+ interruption, not a caption: lifted off the ground with a touch of
+ the accent, an accent rail down its leading edge. */
.post { position: absolute; left: 0; top: 0; width: ${W}px; min-height: ${set.qrSize + 2 * pad}px;
visibility: hidden; opacity: 0; border-radius: 10px; overflow: hidden;
+ border-left: ${rail}px solid ${pal.accent};
background: linear-gradient(180deg, ${top} 0%, ${bottom} 100%);
- box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.1)}; transform-origin: 50% 0%; }
- /* The deck's top edge, the same hairline: the accent in from the left,
- settling to a quiet rule. */
- .edge { position: absolute; left: 0; top: 0; width: ${W}px; height: 2px;
- background: linear-gradient(90deg, ${rgba(pal.accent, 0.95)} 0px, ${rgba(pal.accent, 0.4)} ${Math.round(W * 0.28)}px,
- ${rgba(pal.fg, 0.14)} ${Math.round(W * 0.62)}px, ${rgba(pal.fg, 0.14)} ${W}px); }
- .body { position: relative; width: ${W - plateW}px; padding: ${pad}px ${pad + 4}px ${pad + 2}px ${pad + 4}px; }
- .meta { display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
+ box-shadow: inset 0 0 0 1px ${rgba(pal.fg, 0.12)}; transform-origin: 50% 0%; }
+ /* The flare as a card lands, settling to a quiet accent rim. Last in the
+ card, over the QR's cell -- its blur stays inside the cell's padding,
+ clear of the code. */
+ .glow { position: absolute; left: 0; top: 0; right: 0; bottom: 0; opacity: 0; pointer-events: none;
+ box-shadow: inset 0 0 0 2px ${rgba(pal.accent, 0.95)}, inset 0 0 14px ${rgba(pal.accent, 0.55)}; }
+ .body { position: relative; width: ${W - plateW - rail}px; padding: ${pad - 4}px ${pad + 2}px ${pad - 3}px ${pad + 2}px; }
+ .meta { display: flex; align-items: center; gap: 10px;
font-size: ${metaSize}px; line-height: ${Math.round(metaSize * 1.3)}px; white-space: nowrap; }
- .who { overflow: hidden; text-overflow: ellipsis; min-width: 0; }
+ .who { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; min-width: 0; }
.handle { font-family: 'DeckSansBold', sans-serif; color: ${pal.fg}; letter-spacing: 0.005em; }
- .sep { color: ${pal.accent}; padding: 0 0.42em; font-family: 'DeckSansBold', sans-serif; }
- .platform { color: ${pal.muted}; }
+ /* Which platform, as a label you cannot miss. */
+ .platform { flex: none; font-family: 'DeckSansBold', sans-serif; font-size: ${metaSize - 3}px;
+ line-height: ${metaSize + 3}px; letter-spacing: 0.03em; color: ${pal.fg};
+ padding: 1px 10px 2px; border-radius: 999px;
+ background: ${rgba(pal.accent, 0.26)}; box-shadow: inset 0 0 0 1px ${rgba(pal.accent, 0.7)}; }
.date { color: ${pal.muted}; flex: none; font-variant-numeric: tabular-nums; }
- .text { margin-top: 10px; font-size: ${textSize}px; line-height: ${lineH}px; color: ${pal.fg}; }
+ .text { margin-top: 8px; font-size: ${textSize}px; line-height: ${lineH}px; color: ${pal.fg}; }
/* Each paragraph is clamped to what is left of maxLines when the page
measures (clampText), so the ellipsis always ends words, never a blank line. */
.para { white-space: pre-line; overflow-wrap: anywhere; overflow: hidden;
display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: ${set.maxLines}; }
- .para + .para { margin-top: ${Math.round(lineH * 0.42)}px; }
+ .para + .para { margin-top: ${Math.round(lineH * 0.36)}px; }
.para.gone { display: none; }
/* The source cell: the QR in a cell a shade down, as on the deck. */
.plate { position: absolute; right: 0; top: 0; bottom: 0; width: ${plateW}px;
@@ -359,14 +387,15 @@ export function postsHtml(schedule, render, window, opts = {}) {
}
const ready = Promise.all([
- document.fonts.load("23px DeckSans"),
- document.fonts.load("17px DeckSansBold"),
+ document.fonts.load("${textSize}px DeckSans"),
+ document.fonts.load("${metaSize}px DeckSansBold"),
]).catch(() => {}).then(() => {
const cards = P.ids.map((_, j) => byK["c" + j]);
cards.forEach(clampText);
const heights = cards.map((c) => c.offsetHeight);
const plan = postsCues({ posts: P.posts, heights, column: P.column, gap: P.gap,
- enter: P.enter, slide: P.slide, rise: P.rise });
+ enter: P.enter, slide: P.slide, enterX: P.enterX, glowAt: P.glowAt,
+ glowUp: P.glowUp, glowDown: P.glowDown, glowRest: P.glowRest });
cards.forEach((c, j) => { c.style.top = plan.tops[j] + "px"; });
for (const k of Object.keys(plan.init)) if (byK[k]) gsap.set(byK[k], plan.init[k]);
// The cues are in the cut's clock; the window plays [from, from + dur] of it.
diff --git a/umtool/report-to-video/chrome-posts.test.mjs b/umtool/report-to-video/chrome-posts.test.mjs
@@ -140,11 +140,24 @@ test("the page's times are postSchedule's: data, enter and leave cues", () => {
assert.equal(d.from, win.from);
near(d.dur, win.to - win.from, "the window's length");
// Whatever the heights, each card enters at its appear and leaves over its out.
- const plan = postsCues({ posts: d.posts, heights: [180, 220], column: d.column, gap: d.gap, enter: d.enter, slide: d.slide, rise: d.rise });
+ const plan = postsCues({
+ posts: d.posts, heights: [180, 220], column: d.column, gap: d.gap, enter: d.enter, slide: d.slide,
+ enterX: d.enterX, glowAt: d.glowAt, glowUp: d.glowUp, glowDown: d.glowDown, glowRest: d.glowRest,
+ });
+ // The column is at the frame's right edge: a card comes in from past it.
+ assert.equal(d.enterX, 1920 - postsGeometry(RENDER).x);
posts.forEach((p, j) => {
const enter = plan.cues.find((c) => c.k === `c${j}` && c.why === `enter ${p.id}`);
near(enter.at, p.appear, `${p.id} enters`);
- assert.deepEqual(enter.to, { autoAlpha: 1, y: 0 });
+ assert.deepEqual(enter.from, { autoAlpha: 0, x: d.enterX });
+ assert.deepEqual(enter.to, { autoAlpha: 1, x: 0 });
+ // The glow flares as it lands and settles, never to nothing while the card is up.
+ const flare = plan.cues.find((c) => c.k === `g${j}` && c.why === `glow ${p.id}`);
+ const settle = plan.cues.find((c) => c.k === `g${j}` && c.why === `settle ${p.id}`);
+ near(flare.at, p.appear + d.glowAt, `${p.id} flares`);
+ assert.deepEqual([flare.from, flare.to], [{ opacity: 0 }, { opacity: 1 }]);
+ assert.deepEqual([settle.from, settle.to], [{ opacity: 1 }, { opacity: d.glowRest }]);
+ assert.ok(d.glowRest > 0);
const leave = plan.cues.find((c) => c.k === `c${j}` && c.why === `leave ${p.id}`);
near(leave.at, p.out[0], `${p.id} leaves`);
near(leave.at + leave.dur, p.out[1], `${p.id} gone`);