Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Mumtool/report-to-video/chrome-posts.mjs | 103++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------------
Mumtool/report-to-video/chrome-posts.test.mjs | 17+++++++++++++++--
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`);