Archilyzer · Source

archilyzer

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

commit 22c5b1c3393cefbf099839eb92cd993ab83a8825
parent d1739018e71ceb2073eddfff96124bbc2efc0f0e
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Thu,  1 Oct 2026 00:43:00 -0400

umtool: the On-screen section's Posts table, and the posts region in the live preview

Under the titles table, one row per post: its date, platform and handle, its
words clamped, "rides on" with the automatic clip as the select's first
option (auto: c06 — Estimated project cost…) and every clip of the cut after
it, a hide toggle, and a link to the post. One save sends only the posts and
keys that changed; a 409 is the titles table's: reloading keeps only real
edits and shows what another writer saved.

In the 16:9 preview, while the scrubber is inside a posts window, that
window's composition is laid at postsGeometry over the footage, seeked with
deck:seek in the cut's clock and marked ready on posts:ready; a window that did
not compose says why in the same rect. One mark per window under the segment
bar jumps the scrubber into it. The settings form gains the posts group, so a
settings save no longer drops deck.posts. The clip bench's strip asks for no
posts windows.

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

Diffstat:
Mumtool/components/projects/ClipBench.tsx | 2+-
Mumtool/components/projects/OnscreenSection.tsx | 474+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
2 files changed, 464 insertions(+), 12 deletions(-)

diff --git a/umtool/components/projects/ClipBench.tsx b/umtool/components/projects/ClipBench.tsx @@ -436,7 +436,7 @@ export default function ClipBench({ data }: { data: ClipBenchData }) { let live = true; void (async () => { const [pv, table] = await Promise.all([ - composePreview(data.project, null), + composePreview(data.project, null, {}, { posts: false }), fetch(`/api/report/onscreen?project=${encodeURIComponent(data.project)}`, { cache: "no-store" }) .then((r) => (r.ok ? r.json() : null)) .catch(() => null) as Promise<{ diff --git a/umtool/components/projects/OnscreenSection.tsx b/umtool/components/projects/OnscreenSection.tsx @@ -60,7 +60,19 @@ export type DeckSchedule = { multiChannel: boolean; segments: DeckSegment[]; }; -export type DeckPreviewDoc = { src: string; variant: string; geometry: DeckGeometry; schedule: DeckSchedule }; +export type PostSlot = { id: string; segment: string; slot: number; of: number; appear: number; out: [number, number] }; +/** One posts window's preview composition: `src` when it composed, `error` when it did not. */ +export type PostsWindow = { segment: string; from: number; to: number; src?: string; error?: string }; +export type DeckPreviewDoc = { + src: string; + variant: string; + geometry: DeckGeometry; + schedule: DeckSchedule & { posts?: PostSlot[] }; + /** The posts region: where it sits in the frame, and one composition per window. */ + posts?: { geometry: Rect; windows: PostsWindow[] }; +}; +/** An unsaved change to a post, as PUT /api/report/posts takes it. */ +export type PostPatch = { attachTo?: string | null; hide?: boolean }; /** What each segment's panel should say right now, by entry id. */ export type DeckTexts = Record<string, { title: string; subtitle: string }>; export type Onscreen = { title?: string; subtitle?: string }; @@ -83,16 +95,21 @@ export const onscreenValue = (d: { title: string; subtitle: string }): Onscreen return o.title || o.subtitle ? o : null; }; -/** Ask for a fresh preview composition. One door, used by both callers. */ +/** + * Ask for a fresh preview composition. One door, used by both callers. + * `postsDraft` is the Posts table's unsaved overrides; `posts: false` skips + * composing the posts windows (the bench's strip has no footage to put them on). + */ export async function composePreview( project: string, variant: string | null, draft: Record<string, Onscreen | null> = {}, + { postsDraft = {}, posts = true }: { postsDraft?: Record<string, PostPatch>; posts?: boolean } = {}, ): Promise<DeckPreviewDoc | { error: string }> { const r = await fetch("/api/report/chrome/preview", { method: "POST", headers: { "content-type": "application/json" }, - body: JSON.stringify({ project, variant: variant || undefined, draft }), + body: JSON.stringify({ project, variant: variant || undefined, draft, postsDraft, ...(posts ? {} : { posts: false }) }), }); const j = (await r.json().catch(() => ({}))) as Record<string, unknown>; if (!r.ok) return { error: String(j.error ?? r.status) }; @@ -244,6 +261,114 @@ export function NeutralFrame({ geometry: g, label = "footage" }: { geometry: Dec ); } +/** The posts window the scrubber is inside, if any. Windows of one cut do not overlap in practice; the first wins. */ +export const postsWindowAt = (windows: PostsWindow[] | undefined, t: number): PostsWindow | null => + (windows ?? []).find((w) => t >= w.from && t <= w.to) ?? null; + +// --------------------------------------------------------------------------- +// PostsOverlay: one posts window's composition, at the posts region's rect +// inside the 16:9 frame, while the scrubber is in that window. +// +// The same contract as the deck's frame, with its own message names: the page +// is loaded with `?preview=1`, posts `{type: "posts:ready"}` when it can be +// seeked, and takes `{type: "deck:seek", t}` in the CUT's clock -- the +// window's composition knows where it starts. Laid out at its own pixel size +// and scaled, so its text is measured at the width the render measures it. +// Same origin, not sandboxed, for the reason DeckFrame's header gives. +// --------------------------------------------------------------------------- +export function PostsOverlay({ + win, + geometry, + frame: g, + t, +}: { + win: PostsWindow; + geometry: Rect; + frame: DeckGeometry; + t: number; +}) { + const box = useRef<HTMLDivElement | null>(null); + const el = useRef<HTMLIFrameElement | null>(null); + const [width, setWidth] = useState(0); + const [readySrc, setReadySrc] = useState<string | null>(null); + const src = win.src ? `${win.src}${win.src.includes("?") ? "&" : "?"}preview=1` : null; + const pct = (n: number, of: number) => `${(n / of) * 100}%`; + + useEffect(() => { + const node = box.current; + if (!node) return; + const ro = new ResizeObserver(([e]) => setWidth(e.contentRect.width)); + ro.observe(node); + return () => ro.disconnect(); + }, []); + + useEffect(() => { + const onMsg = (e: MessageEvent) => { + if (e.source !== el.current?.contentWindow || e.origin !== window.location.origin) return; + if ((e.data as { type?: string } | null)?.type === "posts:ready") setReadySrc(src); + }; + window.addEventListener("message", onMsg); + return () => window.removeEventListener("message", onMsg); + }, [src]); + + const live = !!src && readySrc === src; + useEffect(() => { + if (live) el.current?.contentWindow?.postMessage({ type: "deck:seek", t }, window.location.origin); + }, [live, t]); + + const scale = width > 0 ? width / geometry.width : 0; + return ( + <div + ref={box} + data-testid="onscreen-posts-preview" + data-segment={win.segment} + data-posts-ready={live ? "1" : "0"} + className="pointer-events-none absolute" + style={{ + left: pct(geometry.x, g.W), + top: pct(geometry.y, g.H), + width: pct(geometry.width, g.W), + height: pct(geometry.height, g.H), + }} + > + {src ? ( + <iframe + ref={el} + key={src} + src={src} + title="posts preview" + data-testid="onscreen-posts-preview-iframe" + tabIndex={-1} + aria-hidden + style={{ + position: "absolute", + left: 0, + top: 0, + width: geometry.width, + height: geometry.height, + transform: `scale(${scale})`, + transformOrigin: "0 0", + border: 0, + background: "transparent", + colorScheme: "normal", + visibility: scale > 0 ? "visible" : "hidden", + }} + /> + ) : ( + <div + data-testid="onscreen-posts-preview-error" + className="absolute inset-0 flex items-start justify-center border border-dashed border-[var(--color-dirty)] p-2 text-center text-[11px] text-[var(--color-dirty)]" + title={win.error} + > + <span className="rounded bg-black/70 px-1.5 py-0.5"> + posts on {win.segment}: not composed — {win.error} + </span> + </div> + )} + </div> + ); +} + // --------------------------------------------------------------------------- // The settings form: every key of render.chrome.deck, flattened. // --------------------------------------------------------------------------- @@ -258,6 +383,7 @@ type DeckSettings = { qr: { show: boolean; size: number }; overCards: string; motion: { out: number; in: number; pip: number }; + posts: { show: boolean; seconds: number; position: string; width: number; qrSize: number; maxLines: number; inset: number }; }; type Field = @@ -307,6 +433,18 @@ const GROUPS: { name: string; fields: Field[] }[] = [ ], }, { + name: "posts", + fields: [ + { key: "posts.show", label: "show", kind: "bool", hint: "off leaves every post out of the cut" }, + { key: "posts.seconds", label: "seconds", kind: "num", step: 0.5, hint: "s, 0.5–10: each post alone before the next stacks on" }, + { key: "posts.position", label: "side", kind: "select", options: ["top-right", "top-left"], hint: "the corner of the footage the column hangs from" }, + { key: "posts.width", label: "width", kind: "int", hint: "px, 320–900, inside the footage" }, + { key: "posts.qrSize", label: "qr", kind: "num", hint: "px, 80–200, at most half the card" }, + { key: "posts.maxLines", label: "lines", kind: "int", hint: "2–14: longer posts end in an ellipsis" }, + { key: "posts.inset", label: "inset", kind: "num", hint: "px, 0–80 from the footage's edges" }, + ], + }, + { name: "motion", fields: [ { key: "motion.out", label: "out", kind: "num", step: 0.05, hint: "s, 0–2: the old title wiping out" }, @@ -378,6 +516,24 @@ type Row = { auto: { title: string; subtitle: string }; }; type Draft = { title: string; subtitle: string }; +type PostWhere = { entryId: string; rule: "attachTo" | "date" | "first"; clipDay: string | null; label: string }; +type PostRow = { + id: string; + platform: string; + author: string; + handle: string; + date: string; + text: string; + url: string; + attachTo: string | null; + hide: boolean; + auto: PostWhere | null; + effective: PostWhere | null; + timing: { segment: string; slot: number; of: number; appear: number; out: [number, number] } | null; +}; +type ClipOption = { id: string; label: string; day: string | null }; +/** A post's row as the form holds it: `attachTo` "" is the automatic clip. */ +type PostDraft = { attachTo: string; hide: boolean }; type JobView = { id: string; state: "running" | "done" | "failed"; @@ -390,6 +546,17 @@ type JobView = { const draftOf = (o: Onscreen | null): Draft => ({ title: o?.title ?? "", subtitle: o?.subtitle ?? "" }); const sameDraft = (a: Draft, b: Draft) => a.title.trim() === b.title.trim() && a.subtitle.trim() === b.subtitle.trim(); +const postDraftOf = (p: PostRow): PostDraft => ({ attachTo: p.attachTo ?? "", hide: p.hide }); +const samePost = (a: PostDraft, b: PostDraft) => a.attachTo === b.attachTo && a.hide === b.hide; +/** Only what changed, in the writer's shape. */ +const postPatch = (d: PostDraft, saved: PostDraft): PostPatch => { + const p: PostPatch = {}; + if (d.attachTo !== saved.attachTo) p.attachTo = d.attachTo || null; + if (d.hide !== saved.hide) p.hide = d.hide; + return p; +}; +const clipText = (id: string, label: string) => (label ? `${id} — ${label}` : id); +const postDay = (iso: string) => String(iso).slice(0, 10); const STALE = "the manifest changed since you opened this — reload the saved values (your edits are kept) and save again, or your edit would overwrite whatever was written"; @@ -428,6 +595,16 @@ export default function OnscreenSection({ }, [rows]); const [maxChars, setMaxChars] = useState(48); const [drafts, setDrafts] = useState<Record<string, Draft>>({}); + // The Posts table: the manifest's posts as saved, the clips one can ride on, + // and the unsaved overrides. Saved through the same token as everything else. + const [posts, setPosts] = useState<PostRow[]>([]); + const postsRef = useRef<PostRow[]>([]); + useEffect(() => { + postsRef.current = posts; + }, [posts]); + const [clips, setClips] = useState<ClipOption[]>([]); + const [postsShown, setPostsShown] = useState(true); + const [postDrafts, setPostDrafts] = useState<Record<string, PostDraft>>({}); const [note, setNote] = useState<string | null>(null); const [stale, setStale] = useState(false); const [busy, setBusy] = useState<string | null>(null); @@ -484,12 +661,36 @@ export default function OnscreenSection({ const loadRows = useCallback( async (keep: boolean) => { const r = await fetch(`/api/report/onscreen?${q}`, { cache: "no-store" }); - const j = (await r.json()) as { rows: Row[]; maxChars: number; token: string | null; error?: string }; + const j = (await r.json()) as { + rows: Row[]; + maxChars: number; + token: string | null; + posts?: PostRow[]; + clips?: ClipOption[]; + postsShown?: boolean; + error?: string; + }; if (!r.ok) { setLoadError(String(j.error ?? r.status)); return; } const before = new Map(rowsRef.current.map((row) => [row.id, draftOf(row.onscreen)])); + const postsBefore = new Map(postsRef.current.map((p) => [p.id, postDraftOf(p)])); + const nextPosts = j.posts ?? []; + setPosts(nextPosts); + setClips(j.clips ?? []); + setPostsShown(j.postsShown !== false); + // The titles' rule, for posts: only a real edit survives a reload. + setPostDrafts((prev) => { + const next: Record<string, PostDraft> = {}; + for (const p of nextPosts) { + const saved = postDraftOf(p); + const old = prev[p.id]; + const was = postsBefore.get(p.id); + next[p.id] = keep && old && was && !samePost(old, was) ? old : saved; + } + return next; + }); token.current = j.token; setRows(j.rows); setMaxChars(j.maxChars); @@ -508,10 +709,10 @@ export default function OnscreenSection({ ); const recompose = useCallback( - async (draft: Record<string, Onscreen | null> = {}) => { + async (draft: Record<string, Onscreen | null> = {}, postsDraft: Record<string, PostPatch> = {}) => { setComposing(true); setPreviewError(null); - const res = await composePreview(project, variant || null, draft); + const res = await composePreview(project, variant || null, draft, { postsDraft }); setComposing(false); if ("error" in res) { setPreviewError(res.error); @@ -627,6 +828,14 @@ export default function OnscreenSection({ () => Object.fromEntries(dirtyIds.map((id) => [id, onscreenValue(drafts[id])])) as Record<string, Onscreen | null>, [dirtyIds, drafts], ); + const dirtyPostIds = posts.filter((p) => postDrafts[p.id] && !samePost(postDrafts[p.id], postDraftOf(p))).map((p) => p.id); + const postsDraftMap = useCallback( + () => + Object.fromEntries( + posts.filter((p) => dirtyPostIds.includes(p.id)).map((p) => [p.id, postPatch(postDrafts[p.id], postDraftOf(p))]), + ) as Record<string, PostPatch>, + [posts, dirtyPostIds, postDrafts], + ); const saveSettings = useCallback(async () => { if (!doc) return; @@ -641,9 +850,9 @@ export default function OnscreenSection({ // The auto subtitles follow subtitle.parts and dateFormat; the counter // follows title.maxChars. Unsaved table edits ride through. await loadRows(true); - await recompose(draftMap()); + await recompose(draftMap(), postsDraftMap()); setNote("settings saved — the preview is recomposed with them"); - }, [doc, form, putChrome, loadChrome, loadRows, recompose, draftMap]); + }, [doc, form, putChrome, loadChrome, loadRows, recompose, draftMap, postsDraftMap]); const saveRows = useCallback( () => @@ -682,6 +891,39 @@ export default function OnscreenSection({ [queued, dirtyIds, project, draftMap], ); + /** The Posts table's one save: only the posts that changed, only the keys that changed. */ + const savePosts = useCallback( + () => + queued(async () => { + if (!dirtyPostIds.length) return; + setBusy("saving…"); + setNote(null); + const r = await fetch("/api/report/posts", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ project, posts: postsDraftMap(), token: token.current }), + }); + const j = (await r.json()) as Record<string, unknown>; + setBusy(null); + if (!r.ok) { + if (j.stale) { + setStale(true); + setNote(STALE); + } else setNote(`could not save: ${String(j.error ?? r.status)}`); + return; + } + token.current = String(j.token ?? ""); + setStale(false); + const n = Object.keys((j.posts ?? {}) as object).length; + // The effective clip and the timing are the server's to say: read them + // back, keeping any title edits, and redraw the windows. + await loadRows(true); + if (doc?.deckOn) await recompose(draftMap()); + setNote(`saved ${n} post${n === 1 ? "" : "s"}`); + }), + [queued, dirtyPostIds, project, postsDraftMap, loadRows, recompose, draftMap, doc], + ); + const reloadSaved = useCallback(async () => { await loadChrome(); await loadRows(true); @@ -754,6 +996,7 @@ export default function OnscreenSection({ const schedule = preview?.schedule ?? null; const current = schedule ? segmentAt(schedule, t) : null; + const postsWin = preview?.posts ? postsWindowAt(preview.posts.windows, t) : null; const backdropId = current && segmentOf.has(current.id) ? current.id : null; // The backdrop follows the scrubber: the built segment of whichever clip is @@ -924,6 +1167,173 @@ export default function OnscreenSection({ </div> ); + // ---- the posts ------------------------------------------------------------ + const clipById = new Map(clips.map((c) => [c.id, c])); + /** Where a post rides as DRAFTED: hidden, the chosen clip, or the automatic one. */ + const ridesOn = (p: PostRow, d: PostDraft): { id: string; label: string; how: "auto" | "chosen" } | null => { + if (d.hide) return null; + const chosen = d.attachTo ? clipById.get(d.attachTo) : null; + if (chosen) return { id: chosen.id, label: chosen.label, how: "chosen" }; + return p.auto ? { id: p.auto.entryId, label: p.auto.label, how: "auto" } : null; + }; + const autoText = (p: PostRow) => + p.auto ? `auto: ${clipText(p.auto.entryId, p.auto.label)}` : "auto: no clip in this cut"; + + const postsBlock = posts.length > 0 && ( + <div className="space-y-1.5" data-testid="onscreen-posts"> + <div className="flex flex-wrap items-center gap-2"> + <span className="micro">posts — {posts.length}</span> + <span className="text-[11px] text-[var(--color-dim)]"> + {postsShown + ? "each rides on the clip recorded most closely before it, and stacks on at that clip's end" + : "posts are switched off in the settings (posts → show); nothing below is drawn"} + </span> + <button + type="button" + data-testid="onscreen-posts-save" + className={`${buttonVariants({ variant: "primary", size: "sm" })} ml-auto`} + disabled={!!busy || dirtyPostIds.length === 0} + onClick={() => void savePosts()} + > + {dirtyPostIds.length ? `save ${dirtyPostIds.length} post${dirtyPostIds.length === 1 ? "" : "s"}` : "saved"} + </button> + {dirtyPostIds.length > 0 && ( + <button + type="button" + data-testid="onscreen-posts-discard" + className={buttonVariants({ size: "sm" })} + disabled={!!busy} + onClick={() => setPostDrafts(Object.fromEntries(posts.map((p) => [p.id, postDraftOf(p)])))} + > + discard + </button> + )} + </div> + <div className="overflow-x-auto"> + <table data-testid="onscreen-posts-table" className="w-full border-collapse text-[12px]"> + <thead> + <tr className="micro text-left"> + <th className="w-40 px-1.5 py-0.5">post</th> + <th className="px-1.5 py-0.5">words</th> + <th className="w-72 px-1.5 py-0.5">rides on</th> + <th className="w-12 px-1.5 py-0.5 text-center">hide</th> + </tr> + </thead> + <tbody> + {posts.map((p) => { + const d = postDrafts[p.id] ?? postDraftOf(p); + const dirty = !samePost(d, postDraftOf(p)); + const on = ridesOn(p, d); + const set = (patch: Partial<PostDraft>) => + setPostDrafts((prev) => ({ ...prev, [p.id]: { ...(prev[p.id] ?? postDraftOf(p)), ...patch } })); + const inWindow = postsWin && on && postsWin.segment === on.id; + return ( + <tr + key={p.id} + data-testid="onscreen-post-row" + data-post={p.id} + data-dirty={dirty ? "1" : "0"} + data-hidden={d.hide ? "1" : "0"} + data-effective={on?.id ?? ""} + data-current={inWindow ? "1" : "0"} + className={`border-t border-[var(--color-line)] align-top ${d.hide ? "opacity-50" : ""} ${inWindow ? "bg-[var(--color-panel-2)]" : ""}`} + > + <td className="px-1.5 py-1"> + <div className="num font-mono text-[11px] text-[var(--color-text)]" title={p.date}> + {postDay(p.date)} + {dirty && <span className="ml-1 text-[var(--color-dirty)]" title="unsaved">●</span>} + </div> + <div className="text-[11px] text-[var(--color-dim)]"> + <span className={badgeVariants({ size: "sm" })} data-testid="onscreen-post-platform"> + {p.platform} + </span>{" "} + <span data-testid="onscreen-post-handle">@{p.handle || p.author}</span> + </div> + <a + href={p.url} + target="_blank" + rel="noreferrer noopener" + data-testid="onscreen-post-link" + className="text-[11px] text-[var(--color-sel)] hover:underline" + > + open the post ↗ + </a> + </td> + <td className="px-1.5 py-1"> + <p + data-testid="onscreen-post-text" + className="line-clamp-3 whitespace-pre-line text-[12px] leading-snug text-[var(--color-text)]" + title={p.text} + > + {p.text} + </p> + </td> + <td className="px-1.5 py-1"> + <select + data-testid="onscreen-post-attach" + value={d.attachTo} + disabled={d.hide} + onChange={(e) => set({ attachTo: e.target.value })} + className={`${input} w-full text-[11px]`} + title="The clip this post rides on: automatic (by date), or any clip of the cut" + > + <option value="" data-testid="onscreen-post-auto"> + {autoText(p)} + </option> + {clips.map((c) => ( + <option key={c.id} value={c.id}> + {clipText(c.id, c.label)} + </option> + ))} + </select> + <div className="mt-0.5 flex flex-wrap items-center gap-1 text-[11px] text-[var(--color-dim)]"> + {on ? ( + <> + <span data-testid="onscreen-post-effective"> + {on.how === "auto" ? "automatic" : "chosen"}:{" "} + <span className="font-mono text-[var(--color-text)]">{on.id}</span> + {on.how === "auto" && p.auto?.clipDay ? ` (${p.auto.clipDay})` : ""} + </span> + {!dirty && p.timing && schedule && ( + <button + type="button" + data-testid="onscreen-post-jump" + className="num font-mono text-[var(--color-sel)] hover:underline" + onClick={() => setT(Math.min(schedule.total, Math.round((p.timing!.appear + 0.25) * 1000) / 1000))} + title="show this post in the preview" + > + at {clock(p.timing.appear)} + </button> + )} + </> + ) : ( + <span data-testid="onscreen-post-effective">hidden — not in the cut</span> + )} + </div> + </td> + <td className="px-1.5 py-1 text-center"> + <input + type="checkbox" + data-testid="onscreen-post-hide" + checked={d.hide} + onChange={(e) => set({ hide: e.target.checked })} + aria-label={`hide post ${p.id}`} + /> + </td> + </tr> + ); + })} + </tbody> + </table> + </div> + {dirtyPostIds.length > 0 && doc.deckOn && ( + <p className="text-[11px] text-[var(--color-dirty)]"> + the preview shows saved posts; recompose to see the unsaved {dirtyPostIds.length === 1 ? "change" : "changes"} + </p> + )} + </div> + ); + return ( <section data-testid="onscreen-section" @@ -1008,6 +1418,15 @@ export default function OnscreenSection({ ) : ( <NeutralFrame geometry={preview.geometry} label={current ? `${current.id} · no segment built` : "footage"} /> )} + {postsWin && preview.posts && ( + <PostsOverlay + key={`${postsWin.segment}:${postsWin.src ?? "none"}`} + win={postsWin} + geometry={preview.posts.geometry} + frame={preview.geometry} + t={t} + /> + )} </DeckFrame> ) : ( <div className="flex aspect-video w-full items-center justify-center rounded border border-dashed border-[var(--color-line)] text-[12px] text-[var(--color-dim)]"> @@ -1045,6 +1464,28 @@ export default function OnscreenSection({ /> ))} </div> + {(preview?.posts?.windows.length ?? 0) > 0 && ( + // Where the posts are up: one mark per window, a click to + // the moment its last card has stacked on. + <div className="relative h-1.5 w-full" data-testid="onscreen-posts-windows"> + {preview!.posts!.windows.map((w) => ( + <button + key={w.segment} + type="button" + data-posts-window={w.segment} + title={`posts on ${w.segment}${w.error ? " · not composed" : ""}`} + onClick={() => setT(Math.round(((w.from + w.to) / 2) * 1000) / 1000)} + className={`absolute top-0 h-full rounded-sm ${ + postsWin?.segment === w.segment ? "bg-[var(--color-sel)]" : w.error ? "bg-[var(--color-dirty)]" : "bg-[var(--color-meter)]" + }`} + style={{ + left: `${(w.from / schedule.total) * 100}%`, + width: `${Math.max(0.4, ((w.to - w.from) / schedule.total) * 100)}%`, + }} + /> + ))} + </div> + )} <div className="flex items-center gap-2"> <input type="range" @@ -1086,8 +1527,8 @@ export default function OnscreenSection({ data-testid="onscreen-recompose" className={buttonVariants({ size: "sm" })} disabled={composing} - onClick={() => void recompose(draftMap())} - title="Compose the preview again from the manifest, with the table's unsaved rows on top" + onClick={() => void recompose(draftMap(), postsDraftMap())} + title="Compose the preview again from the manifest, with the tables' unsaved rows on top" > {composing ? "recomposing…" : "recompose"} </button> @@ -1302,7 +1743,10 @@ export default function OnscreenSection({ never uses the panel should not carry a nineteen-row form, and one that will can have its titles written before the switch. */} {on ? ( - words + <> + {words} + {postsBlock} + </> ) : ( <details data-testid="onscreen-words-folded"> <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]"> @@ -1312,6 +1756,14 @@ export default function OnscreenSection({ <div className="mt-1.5">{words}</div> </details> )} + {!on && postsBlock && ( + <details data-testid="onscreen-posts-folded"> + <summary className="cursor-pointer text-[11px] text-[var(--color-dim)]"> + posts — {posts.length}; drawn only when the panel is on + </summary> + <div className="mt-1.5">{postsBlock}</div> + </details> + )} </section> ); }