Archilyzer · Source

archilyzer

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

commit bc5fcd54df2189b4b22e9af3f3786177ae842659
parent 480e7db7cab03b022cc6de4d3be19b3b021b1634
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Fri,  2 Oct 2026 11:04:56 -0400

report-to-video: the feed column has no empty plate -- before the first post it is its header over its ground (the element, its rule, its init and the first post's cue are gone, with FEED_MOTION.emptyOut); the README documents tailWait and the centred last line, and the feed's and the teaser's [Unreleased] bullets say so

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Meditor/CHANGELOG.md | 4++--
Mumtool/report-to-video/README.md | 24+++++++++++++++++++-----
Mumtool/report-to-video/chrome-feed.mjs | 23++++++++---------------
Mumtool/report-to-video/chrome-feed.test.mjs | 7++++---
4 files changed, 33 insertions(+), 25 deletions(-)

diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -7,10 +7,10 @@ - **umtool's report videos keep every clip's sound on its picture.** In a crossfaded cut each clip's audio was placed by the audio's own length and its picture by the picture's, and an encoded clip's audio is routinely a few to twenty milliseconds shorter or longer than its video, so the sound drifted further ahead clip by clip: by the end of a seventeen-clip cut it was a third of a second early, and two seconds on one with title and sources cards. Each clip's sound is now padded or trimmed to exactly its picture's length before the crossfade. Every crossfaded report video changes when it is rebuilt, and is in sync; a hard-cut video was not affected. - **umtool's report videos can wear an on-screen deck: one panel under the footage for the whole cut, with a pip timeline, a title per clip, its source and date, and its QR.** A report manifest whose `render` says `"chrome": { "engine": "hyperframes", "layout": "deck" }` scales the footage into a box above a 190 px panel (both sizes are settings) and draws, over the whole cut, one unlabelled pip per clip on a track that fills as the cut plays, the clip's own title from `onscreen.title`, a subtitle naming the recording and its date (the channel too when the cut spans more than one; `onscreen.subtitle` replaces it), and the clip's QR. At each clip change the marker travels to the next pip and the title, subtitle and QR hand over; over a card the panel slides away and comes back after. The citation header, the corner QR and the section footer are not drawn on such a cut, and chapters take the clip's on-screen title. Every setting (sizes, spacing, date format, what the subtitle names, whether cards keep the panel, the motion's timings) is in `render.chrome.deck` and checked when it is saved; an unknown or out-of-range one is refused with a sentence saying why. The panel is rendered once per cut by HyperFrames (pinned to 0.8.24; `HYPERFRAMES_PKG` or `HYPERFRAMES_BIN` override it) and reused until its text or settings change. `build-video.mjs --chrome-only` redraws it over the built segments without rebuilding or fetching anything, `--no-chrome` builds the framed cut without it, and `--chrome-preview <at> <dur>` renders a short window. In umtool, the report page has an **On-screen** section — a switch, the settings, a table of every entry's title and subtitle with the automatic subtitle as its placeholder and a character counter, a live preview with a scrubber, a true still, **Re-render on-screen** and the built video — and the clip bench has on-screen title and subtitle fields with the panel previewed over the clip. The deck changes nothing, byte for byte, in a cut whose manifest has no `render.chrome`. - **A report cut that wears the on-screen deck can show posts — Bluesky or X statements — as cards over the footage.** A report manifest's `posts` list (each with its platform, handle, date, words and link) is drawn near the end of the clip each post belongs with: the clip whose recording most closely precedes it by date, unless the post names one with `attachTo`; `hide` leaves one out. A clip's posts appear four seconds apart and stack down a column at the frame's top right; as the first appears, the footage eases aside (to 86 % of its box, at the far side) to make room, and the clip's last frame is held, in silence, for 2.5 seconds so the last post can be read; then they all leave together in the change to the next clip, which comes in at the normal size. When the column is full the oldest slide up and out. Each card slides in from the edge of the frame and flares in the deck's accent as it lands; it has an accent rail down its edge and shows the post's date, a platform label ("Bluesky" or "X") beside `@handle`, its words in paragraphs up to seven lines with an ellipsis, and a QR of the post's link, in the deck's colours and faces. The hold and the move are made where the cut is joined, not in a clip, so `--chrome-only` changes them without rebuilding one; chapters and the deck's timing count the hold. The timing, the hold (`hold`, 0 turns it off), the move (`shift`: its scale and seconds, or `false`), the column's side, width and inset, the QR size and the line limit are settings under `render.chrome.deck.posts`, and a bad post or setting is refused with a sentence before a build fetches anything. Only the seconds the cards are up are rendered, one short sequence per clip, cached like the deck; `--chrome-only`, `--chrome-preview` and a hard-cut cut lay them as they lay the deck, and `--no-chrome` draws neither — though it still holds and moves the footage, which are part of the cut rather than the chrome. A first post that appears inside the hold still moves the footage, and a hold is a whole number of frames. `posts` changes nothing in a cut that has none, and without the deck it is not drawn at all. -- **A report cut's posts can be a feed: a column beside the footage for the whole cut, each post ticking in as its clip starts, with no pause.** `render.chrome.deck.posts.layout: "feed"` (the default, `"popup"`, is the cards described above) puts every post in one column on the right, standing on the deck so the two read as one L-shaped panel around the picture. The footage of every clip and still is framed, for the whole cut, into the box left beside the column (1272×716 at 1920×1080 with the default 600 px column, against 1574×886 under the deck alone); nothing moves and nothing is held, so the cut is as long as its clips. Before the first post the column shows its header — the platform and the handle, or "Posts" when there are several authors — and an empty state. Each post ticks in at the start of the clip it belongs with, a crossfade's length in (just after the crossfade into it, and as far into the first clip, which has none), a clip's next ones `posts.seconds` apart (closer on a short clip): it lands at the top with an accent flare and keeps a lit rail while it is the newest, and the posts already up slide down to make room; when the column is full the oldest fade out at the bottom. Each card shows the post's date, its words up to `maxLines`, and its QR. Over a card or the teaser the column slides out of the frame with the deck and comes back after. The column is drawn by one composition for the whole cut, cached like the deck's. Switching the layout reframes every segment, so it takes a normal build (with `--skip-fetch` it re-cuts from the cached windows); `--chrome-only` over segments framed for the other layout is refused with a sentence naming them, because each segment's `<id>.cut.json` now records the box it was framed into. In umtool, the posts settings have a **layout** switch, and the live preview shows the column for the whole scrub with the footage in its box. A cut in the popup layout, or without posts, builds exactly as before. +- **A report cut's posts can be a feed: a column beside the footage for the whole cut, each post ticking in as its clip starts, with no pause.** `render.chrome.deck.posts.layout: "feed"` (the default, `"popup"`, is the cards described above) puts every post in one column on the right, standing on the deck so the two read as one L-shaped panel around the picture. The footage of every clip and still is framed, for the whole cut, into the box left beside the column (1272×716 at 1920×1080 with the default 600 px column, against 1574×886 under the deck alone); nothing moves and nothing is held, so the cut is as long as its clips. Before the first post the column is just its header — the platform and the handle, or "Posts" when there are several authors — over its ground. Each post ticks in at the start of the clip it belongs with, a crossfade's length in (just after the crossfade into it, and as far into the first clip, which has none), a clip's next ones `posts.seconds` apart (closer on a short clip): it lands at the top with an accent flare and keeps a lit rail while it is the newest, and the posts already up slide down to make room; when the column is full the oldest fade out at the bottom. Each card shows the post's date, its words up to `maxLines`, and its QR. Over a card or the teaser the column slides out of the frame with the deck and comes back after. The column is drawn by one composition for the whole cut, cached like the deck's. Switching the layout reframes every segment, so it takes a normal build (with `--skip-fetch` it re-cuts from the cached windows); `--chrome-only` over segments framed for the other layout is refused with a sentence naming them, because each segment's `<id>.cut.json` now records the box it was framed into. In umtool, the posts settings have a **layout** switch, and the live preview shows the column for the whole scrub with the footage in its box. A cut in the popup layout, or without posts, builds exactly as before. - **A report clip can go silent partway through, a report cut can fade out at its end, and the deck's QR names its site in larger type.** A clip's `muteFrom` (in the recording's own seconds, inside the clip) silences it from that second to its end while the picture plays on, after a 40 ms fade that ends there, so nothing clicks and no next word leaks in; a hold on that clip stays silent. `render.endFade` (seconds; 0, the default, is off) fades the cut's last segment, whatever it is — a clip with its hold, a closing card or a teaser — to the background colour and to silence over its final seconds, all of it when the segment is shorter, and the deck stays drawn over it. Both are applied where the cut is joined, so `--chrome-only` changes them without rebuilding a clip, and a value out of range is refused with a sentence before a build fetches anything. Each clip build now writes `<id>.cut.json` beside its segment, saying where in the recording the segment really starts after its cut was snapped to a silence; `muteFrom` is measured from it, and a segment built before this measures from the clip's unsnapped start and says so. The site's name beside the deck's QR is now exactly as long as the code is tall, for any site. Neither key changes a cut that does not set it. - **umtool's clip bench stops exactly where a range ends, and sets a clip's mute mark.** The bench's **play selection**, the edge auditions, the auto-audition and a click on a transcript line now play the window's sound through the browser's Web Audio, from a decode made on the server by ffmpeg — the same timeline the build cuts on — and each stops on the audio clock where its range ends, at every speed. They used to play on the video element and were stopped when it next reported its time, which overran the end by up to a quarter of a second, by a different amount each time. The picture follows, muted. If the sound cannot be decoded, the video element plays as before and the bench says the playback is approximate and why. The mute mark sets the clip's `muteFrom`: `m` puts it at the playhead, **pick on waveform** puts it where you click, `;` and `'` nudge it (with shift, by half a second), and `M` or **clear mute** removes it. It is saved with the window like the edges, every playback goes silent at it with the build's own 40 ms fade, and a window save that would leave it outside the clip is refused unless the same save moves or clears it — or, when it is within 0.02 s of the new edge, moves it onto that edge. The decoded sound is served by a new `GET /api/report/audio`, at most 120 seconds of a cached window at a time, as WAV. -- **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in after the last line on its own. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. `"beat"` (0.4–2.5 seconds, default 0.7) sets the time from one pop — and its hit — to the next, the second tier and the tail's wait slowing with it; `seconds` may be left out for exactly the length the beats need, and a `seconds` too short for them is refused with that length rather than played faster. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. A line too long to fit the frame at its smallest size is refused with a sentence saying how many characters fit (a title holds 34). It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest. +- **A report cut can end on a teaser card: a few lines popping in over a dark cinematic ground, with a trailer hit under each.** A report manifest's `teaser` entry (`lines`, `seconds`, an optional `tail`) is a full-frame card drawn from its own words, one to five lines each popping in top to bottom with a scale overshoot, a blur that sharpens, and a flash of the accent; with three or more lines the first is a small overline, the last a mid-size date, and the ones between a big title. A line written as `{ "text": …, "break": … }` draws its ending as a smaller second tier a beat later, and the tail fades in on its own off the right of the last line, which stays centred by its own words; `"tailWait"` (0.3–6 seconds) sets how long after the last hit it comes. Under each pop is a synthesised boom, the title's the biggest, and under the tail a low swell; `"hits": false` makes the card silent. `"beat"` (0.4–2.5 seconds, default 0.7) sets the time from one pop — and its hit — to the next, the second tier and the tail's wait slowing with it; `seconds` may be left out for exactly the length the beats need, and a `seconds` too short for them is refused with that length rather than played faster. Put after the last clip, it joins with the ordinary crossfade and takes the cut's end fade. A line too long to fit the frame at its smallest size is refused with a sentence saying how many characters fit (a title holds 34). It is rendered once and re-rendered when its words change, `--chrome-only` included, and its chapter is its lines. umtool shows it as a card row named by its lines; its words are edited in the manifest. - **A report cut can go to black before its teaser, and the teaser rises out of the black.** A teaser entry's `"dip": { "fade": …, "black": … }` fades the whole frame before it — the footage, the on-screen deck, the posts feed and anything else drawn over the cut — to black over the previous segment's last `fade` seconds (0.3–4), its sound to silence with it, then holds `black` seconds (0–3) of black, taken to the nearest whole frame at the cut's frame rate. The teaser then opens out of it: the letterbox is already closed, the ground and its light stay dark until the first line slams in and come up with its hit, and a synthesised riser swells under the black into that first hit. The deck and the feed leave under the black instead of sliding away over the crossfade. The black is the start of the teaser's own segment, so the teaser is that much longer and nothing else moves; with a dip, the teaser's `seconds` counts from where the light comes up. The fade is made where the cut is joined, so `--chrome-only` changes it without rebuilding a clip. A dip anywhere but on a teaser, on the first entry, or out of range is refused with a sentence before a build fetches anything. A cut without a dip builds exactly as before. - **A report cut with `transition: 0` builds when its output folder was given as a relative path.** The hard-cut concat listed its segments relative to the working directory, and ffmpeg reads that list relative to the list file's own folder, so every hard-cut build with a relative `--out` failed at the concat. The list now names each segment by its full path. - **A post's QR in a report video opens the post's page on the archive, not on Bluesky or X.** A post drawn by a report cut's on-screen deck (the popup cards or the feed) used to carry a QR of its bsky.app or x.com link; it now opens the post on the archive the report was made from (`provenance.siteOrigin`), the site's post view at `?v=<channel>%2F<id>&vm=post`, which shows the post and links on to the original, so the QR keeps working if the post or the platform goes away. The build finds the archive channel that keeps each post (a post channel such as `piratesoftware-bsky` is its own channel on the archive) from the archive's `corpus.json` and its posts manifests, cached on disk like the cue lookups, and writes the link into `schedule.json`, so `--chrome-only` and umtool's On-screen preview show the same QR; the manifest is not rewritten. Each post gets a line in the build's log saying which channel it was found in, or that the archive does not have it, or did not answer, and that its QR then links the original — never a failed build. A post can pin its channel with `siteChannel`, or a page of its own with `siteUrl` (and give `postId` when its link does not carry its id), and `render.chrome.deck.posts.links: "original"` (the **qr links** setting in umtool) keeps the old QRs. Rebuilding a cut with posts, or `--chrome-only`, re-renders its posts with the new QRs. The archive MCP's `get_post` also names the post's archive page, as `- archive: <url>`, when the source is a site. diff --git a/umtool/report-to-video/README.md b/umtool/report-to-video/README.md @@ -486,7 +486,8 @@ falls on the teaser. { "text": "The Largest Ferret Rescue in the United States", "break": "in the United States" }, "February 2027"], - "tail": "?", // optional: appended to the LAST line, fades in on its own + "tail": "?", // optional: hangs off the LAST line, fades in on its own + "tailWait": 2, // optional: seconds from the last hit to the tail (default: the beat's) "beat": 0.7, // optional, default 0.7: seconds from one pop to the next "hits": true, // optional, default true: false makes the card silent "dip": { "fade": 1.2, "black": 0.6 } } // optional: go to black before it (below) @@ -505,7 +506,9 @@ falls on the teaser. frame shrinks to its role's floor and no further, so each row is also held to what fits at that floor — a **title** at most **34** characters, a **kicker** 56, an **overline** 64, a `break`'s second tier 66; the tail and - the space before it count on the row that carries it. An 80-character title + the space before it count TWICE on the row that carries it, because that row + is centred by its own words and the tail hangs off its right (so a tail can + never be pushed past the frame's edge by a wide line). An 80-character title spilled past both edges of the frame. A longer title fits by setting its end as a `break`. The counts were measured in the face on ordinary words in capitals (a title holds 36–37 there); a row of only wide capitals (M, W) can @@ -530,7 +533,18 @@ falls on the teaser. 8.1 at 1.3 — its `"seconds": 7` (a little over a second more still at the end) holds beats up to about 0.99. - **`tail`** — at most 8 characters, in the accent, set a little apart from - the last line. + the last line (0.32 em). **The last line is centred by its own words**: the + tail hangs off its right in a zero-width box on its baseline, so "FEBRUARY + 2027" sits on the frame's centre and the "?" beside it, not the two centred + as one unit. (Before, the year sat left of centre by half the tail.) +- **`tailWait`** — optional, 0.3 to 6 seconds, and only with a tail: the time + from the last hit — the last line's impact, or its second tier's pop when it + has a `break` — to the tail's entrance. Left out, it is what the beat gives + (8/7 of a beat from the last pop, less the 0.2 s slam: 0.6 s at 0.7, 1.0 s at + 1.05), so a teaser without it keeps its timing. The swell under the tail and + the card's length follow it; the tail's 1.7 s fade and the 1.2 s end room do + not change. The ferret finale at beat 1.05 with `tailWait: 2`: the year hits + at 3.3 s, the tail starts at 5.3 s and the card is 8.2 s (7.2 without it). - **The chapter** is the lines joined with " — ", the tail after the last (an authored `chapter` still wins). The deck slides away over a teaser whatever `overCards` says — it is full frame, never framed into the footage @@ -1350,8 +1364,8 @@ moves: no hold, no footage move, and the cut is as long as its segments. - **The column** (`chrome-feed.mjs`, one composition for the whole cut, region-local at the column, transparent outside its panel): a one-row header (the platform and `@handle`, or "Posts" over several authors -- no running - count and no caption, which a viewer reads off the column itself), then an - empty plate ("No posts yet") until the first post. Each + count and no caption, which a viewer reads off the column itself), over its + ground and nothing else until the first post (no empty plate). Each post TICKS IN at the top, newest first: the cards already in slide down by its height over `push` while it enters from the column's outer edge, its accent rim flares and settles to a lit rail, which goes out when the next diff --git a/umtool/report-to-video/chrome-feed.mjs b/umtool/report-to-video/chrome-feed.mjs @@ -9,8 +9,8 @@ // --------------------------------------------------------------------------- // What the column does // --------------------------------------------------------------------------- -// Before the first post it shows its header (who posted, on what) and an -// empty plate. Each post TICKS IN at its `in` (deck.mjs postSchedule: its +// Before the first post it is its header (who posted, on what) over its +// ground, and nothing else. Each post TICKS IN at its `in` (deck.mjs postSchedule: its // clip's start + D, the transition's length -- the first clip's too, which // has no dissolve into it; several on one clip a `posts.seconds` apart): it lands at the TOP, newest first, as a timeline // reads, and every card already in slides DOWN by its height. The newest @@ -55,11 +55,11 @@ const r4 = (v) => Math.round(v * 10000) / 10000; * in over it -- the two never overlap on screen. Its highlight flares `glowAt` * into the entrance over `glowUp`, settles to `newest` over `glowDown`, and * goes out over `calm` when the next post arrives. A card pushed past the - * column's bottom fades over `leave`. The empty state fades over `emptyOut`. + * column's bottom fades over `leave`. */ export const FEED_MOTION = Object.freeze({ gap: 16, push: 0.4, lag: 0.22, enter: 0.6, glowAt: 0.2, glowUp: 0.2, glowDown: 1.2, newest: 0.55, calm: 0.8, - leave: 0.45, emptyOut: 0.35, + leave: 0.45, }); /** @@ -106,7 +106,7 @@ export function feedLayout(render) { * * Card j of n sits at y = Σ (height + gap) of the cards newer than it that * are in; it is `c<j>` (autoAlpha, x, y), its highlight `h<j>` (opacity), the - * empty plate `empty`, the whole column `col` (x). + * whole column `col` (x). * * @returns {{ init: Record<string, object>, gone: Array<number|null>, * cues: Array<{ k: string, at: number, dur: number, from: object, to: object, ease: string, why: string }> }} @@ -114,12 +114,12 @@ export function feedLayout(render) { export function feedCues({ posts, heights, column, visibility = [], startsHidden = false, slideX = 600, enterX = 600, gap = 16, push = 0.4, lag = 0.22, enter = 0.6, glowAt = 0.2, glowUp = 0.2, glowDown = 1.2, newest = 0.55, - calm = 0.8, leave = 0.45, emptyOut = 0.35, + calm = 0.8, leave = 0.45, }) { const R = (v) => Math.round(v * 10000) / 10000; const MIN = 0.001; const n = posts.length; - const init = { col: { x: startsHidden ? slideX : 0 }, empty: { autoAlpha: 1 } }; + const init = { col: { x: startsHidden ? slideX : 0 } }; for (let j = 0; j < n; j += 1) { init[`c${j}`] = { autoAlpha: 0, x: enterX, y: 0 }; init[`h${j}`] = { opacity: 0 }; @@ -150,7 +150,6 @@ export function feedCues({ } // The newest hands its highlight on. if (m > 0 && gone[m - 1] === null) add(`h${m - 1}`, t, calm, { opacity: 0 }, "power1.out", `calm for ${id}`); - if (m === 0) add("empty", t, emptyOut, { autoAlpha: 0 }, "power1.out", `first ${id}`); add(`c${m}`, t + lag, enter, { autoAlpha: 1, x: 0 }, "expo.out", `enter ${id}`); add(`h${m}`, t + lag + glowAt, glowUp, { opacity: 1 }, "power2.out", `glow ${id}`); add(`h${m}`, t + lag + glowAt + glowUp, glowDown, { opacity: newest }, "power2.inOut", `settle ${id}`); @@ -320,11 +319,6 @@ export function feedHtml(schedule, render, opts = {}) { height: 1px; background: ${rgba(pal.fg, 0.12)}; } .stack { position: absolute; left: ${lay.stack.x}px; top: ${lay.stack.y}px; width: ${lay.stack.width}px; height: ${lay.stack.height}px; overflow: hidden; } - .empty { position: absolute; left: 0; top: 0; width: ${lay.stack.width}px; height: ${c.plate}px; - border-radius: 10px; border: 1px dashed ${rgba(pal.fg, 0.22)}; - display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; - visibility: hidden; opacity: 0; } - .empty b { font-family: 'DeckSansBold', sans-serif; font-weight: 400; font-size: 20px; color: ${rgba(pal.fg, 0.7)}; } /* A card: lifted off the column with a touch of the accent, its rail dim until it is the newest. Hidden until the timeline places it. */ .post { position: absolute; left: 0; top: 0; width: ${lay.stack.width}px; min-height: ${c.plate}px; @@ -378,7 +372,6 @@ export function feedHtml(schedule, render, opts = {}) { </div> <div class="rule"></div> <div class="stack"> - <div class="empty" data-k="empty"><b>No posts yet</b></div> ${cardHtml} </div> </div> @@ -436,7 +429,7 @@ export function feedHtml(schedule, render, opts = {}) { posts: F.posts, heights, column: F.column, visibility: F.visibility, startsHidden: F.startsHidden, slideX: F.slideX, enterX: F.enterX, gap: M.gap, push: M.push, lag: M.lag, enter: M.enter, glowAt: M.glowAt, glowUp: M.glowUp, glowDown: M.glowDown, newest: M.newest, calm: M.calm, - leave: M.leave, emptyOut: M.emptyOut, + leave: M.leave, }); for (const k of Object.keys(plan.init)) if (byK[k]) gsap.set(byK[k], plan.init[k]); const inner = gsap.timeline({ paused: true }); diff --git a/umtool/report-to-video/chrome-feed.test.mjs b/umtool/report-to-video/chrome-feed.test.mjs @@ -152,8 +152,9 @@ test("feedCues: each card ticks in at its time, at the top, and the ones in move // The highlight: the newest flares and settles; the one before it goes out. assert.deepEqual(byWhy(plan.cues, /^calm for b$/).map((c) => [c.k, c.to.opacity]), [["h0", 0]]); assert.deepEqual(byWhy(plan.cues, /^settle z$/).map((c) => [c.k, c.to.opacity]), [["h3", FEED_MOTION.newest]]); - // The empty plate leaves with the first post; there is no count to keep. - assert.deepEqual(byWhy(plan.cues, /^first a$/).map((c) => [c.k, c.at, c.to.autoAlpha]), [["empty", 10, 0]]); + // No empty plate: before the first post the column is its header over its ground. + assert.ok(!("empty" in plan.init)); + assert.ok(!plan.cues.some((c) => c.k === "empty" || /^first /.test(c.why))); assert.equal(plan.cues.some((c) => /^n\d+$/.test(c.k)), false); assert.equal(Object.keys(plan.init).some((k) => /^n\d+$/.test(k)), false); assert.equal(plan.gone.every((x) => x === null), true); @@ -228,7 +229,7 @@ test("the page: one card per post with its date, words and QR; the header; the c assert.ok(html.includes('<span class="platform">Bluesky</span>')); assert.equal((html.match(/class="handle"/g) ?? []).length, 0); assert.ok(html.includes('<span class="date">Oct 19, 2024</span>')); - assert.ok(html.includes('data-k="empty"')); + assert.ok(!html.includes('data-k="empty"') && !html.includes("No posts yet") && !html.includes(".empty")); // No running count and no caption: the header is who posted, on what. assert.equal(/ of 4<|data-k="n\d|class="caption"|class="count"/.test(html), false); // The cue times are the schedule's.