~14 min · attachments, pause, ownership, ux, pasted-text
Level 0Muted
0 XP0/64 lessons0/14 achievements
0/100 XP to next level100 XP to go0% complete
"Share images or files with her: they go out with your next words." — the voice screen's paperclip
The Only Way Was to Leave
The first broadcast found the next gap. Dad wanted to hand Pippa an article, a picture, a screenshot of something he was looking at, and the voice screen had no way in. He had to close the face, drop the file into the composer, and come back, which broke the talk at exactly the moment there was finally something to look at together. The fix gives the voice screen a place of its own for things that should be seen: the stage.
Three Ways In, One Way Out
While the face screen is up, a file can reach it three ways: dropped onto it, pasted into it (a screenshot, a copied image), or picked with the paperclip. Every one of them lands on the stage, marked as going out with his next words, up to ten files and ten pieces of pasted text at a time. When he next speaks, they ride that utterance as its attachments, through the same send path as every voice turn, so the three routes from this track's second lesson (send, queue, composer) and the recording's facts all apply unchanged. If that send fails, everything it took goes back on the stage, in front of anything he shared meanwhile, in the lesson's code and the WebUI alike. Past ten, the two differ in one place. The lesson's code refuses the new share and the screen says so, so nothing waiting is ever pushed off. The WebUI keeps the latest ten, so there the oldest waiting share falls off. That is this track's never-dropped rule again, applied to things instead of words: nothing Dad handed over disappears because a request didn't start.
Text Stays Text
Text was the one thing that did not fit. The first version turned pasted text into a small .txt file, so a paste never vanished, but the words went out as a file. The same night Dad ruled that text stays text. A link, a headline or a whole article pasted or dropped on the voice screen now waits on the stage as text and rides his next words as quote blocks after them. On the stage it shows as a quote, and it opens large like a card.
The dictated-turn instruction gained a line to match: quote blocks after his words were pasted, not heard, so she reads them exactly as written. A transcribed turn's standing rule is to read it for what he meant, and without that line a URL could look like a word she misheard and quietly correct. The phone's voice screen gained a Paste button for a link, text or a picture, and the phone and Firekeeper build the same message through the shared voice kit.
What the Stage Shows
The stage shows, in order: what is waiting to go out, then the attachments of his latest turn in this talk (until he clears them), then her cards. On a wide screen with On Air lit, the stage stands beside the face instead of under it, because a recording wants both in view. When his shared items fill that side column, her cards go under her face instead, so a card she puts up is never appended below his images and out of view.
A Lightbox That Resumes Only What It Paused
Click an image or a card and it opens large in a lightbox. The arrow keys or the buttons walk through every item on the stage, and Esc closes it. Looking at something together is not talking, so opening the lightbox while the talk is listening pauses it, exactly as Pause does: the microphone is released and nothing is heard until the lightbox lets go. While she is replying, the lightbox only shows. Dad opens an image to put it in front of the viewers while she talks about it, so her reply goes on. If her reply ends while the lightbox is still open, the microphone goes off then, unless Dad has pressed Pause or Resume himself in the meantime.
The subtle part is closing it. The lightbox resumes the talk on close only if the lightbox was the one that paused it. If Dad had already paused before he opened a screenshot, closing the screenshot leaves him paused, because that pause was his and not the lightbox's. The rule is small and easy to get wrong in the other direction: a lightbox that always resumes on close turns a quiet moment Dad chose into a microphone he didn't ask for.
A flag alone gets two cases wrong. If the talk stops on its own while he is looking (a closed socket), or Dad takes the pause over with his own Pause, the lightbox no longer owns the pause, and closing it must leave the talk where it is. The WebUI's lightbox now keeps one claim that lapses the moment the talk leaves the pause, or Dad presses Pause or Resume or taps the face; it resumes only while that claim stands and the talk is still paused. The web has one thing that pauses a talk this way, so one claim is enough there. The lesson's code keeps holds by name for the day a dialog joins the lightbox: a hold pauses a live listen, only shows while she speaks, takes the pause when her reply ends under it, and the talk resumes only when the last holder lets go.
The Soul Knows What Is On Screen
The files are not only for Dad's eyes. They reach her as attachments of his turn, and while On Air is lit her instruction adds that what he shares is already on screen beside her, where the viewers see it: she should talk about it as something they are both looking at.
The Mac Door Too
Firekeeper's panel got the same stage the same evening: drop onto the panel, paste with Command-V while it is in front, or use the paperclip; thumbnails wait under the face and ride his next words. The same change fixed a keyboard rule. Firekeeper's pause key had been a system-wide hotkey, so a Space typed in some other window resumed a paused talk. Now the panel takes a plain key only while it is the key window, and elsewhere Space types as usual; a modifier tapped on its own still pauses from anywhere.
Code
A stage whose files and pasted text ride the next words, and pauses taken only from a live listen and resumed only by whoever took them·typescript
// Things shared mid-talk ride the next words; a pause is resumed only by whoever took it.
const MAX_SHARES = 10;
/** Dad's words, then each pasted text as a quote block after them. */
export function withPastedText(words: string, texts: string[]): string {
const quotes = texts
.map((text) => text.trim())
.filter(Boolean)
.map((text) => text.split('\n').map((line) => (line.trim() ? `> ${line}` : '>')).join('\n'));
return [words.trim(), ...quotes].filter(Boolean).join('\n\n');
}
export class Stage {
private files: File[] = [];
private texts: string[] = [];
/** Up to ten wait at once. Past that a new share is refused, and the screen
* says so; nothing already on the stage is ever pushed off. */
addFiles(files: File[]): File[] {
const room = Math.max(0, MAX_SHARES - this.files.length);
this.files = [...this.files, ...files.slice(0, room)];
return files.slice(room);
}
addTexts(texts: string[]): string[] {
const kept = texts.map((text) => text.trim()).filter(Boolean);
const room = Math.max(0, MAX_SHARES - this.texts.length);
this.texts = [...this.texts, ...kept.slice(0, room)];
return kept.slice(room);
}
get waiting() {
return [...this.files.map((file) => file.name), ...this.texts.map((text) => `text: ${text.split('\n')[0]}`)];
}
/** The send takes everything waiting; a send that fails gives it all back. */
async sendWith(words: string, send: (message: string, files: File[]) => Promise<void>) {
const files = this.files;
const texts = this.texts;
this.files = [];
this.texts = [];
try {
await send(withPastedText(words, texts), files);
} catch {
// Back in front of anything shared meanwhile, and never trimmed: the cap
// refuses new shares, it never takes back what he already handed over.
this.files = [...files, ...this.files];
this.texts = [...texts, ...this.texts];
throw new Error('Could not send: what he shared is back on the stage');
}
}
}
type Phase = 'listening' | 'speaking' | 'paused' | 'stopped';
/** The talk as a holder sees it: its phase, Pause and Resume, and a word
* whenever the phase moves (her reply starting or ending, a stop). Her reply
* moves only a live talk: a pause taken mid-reply stops the reading, so the
* reply's end leaves the talk paused instead of listening again. */
export class Talk {
phase: Phase = 'listening';
onChange: (from: Phase) => void = () => {};
private go(next: Phase) { const from = this.phase; this.phase = next; this.onChange(from); }
pause() { this.go('paused'); }
resume() { this.go('listening'); }
replyStarts() { if (this.phase === 'listening') this.go('speaking'); }
replyEnds() { if (this.phase === 'speaking') this.go('listening'); }
stopsOnItsOwn() { this.go('stopped'); }
}
/** Anything that pauses the talk for a while (the lightbox, a dialog) holds it
* by name. A hold pauses a live listen; while she is speaking it only shows,
* and if her reply ends while a hold stands, the pause is taken then. The talk
* resumes when the last holder lets go, and only if it is still in the pause
* they took: not after it stopped on its own, not after Dad paused it himself. */
export class PauseHolds {
private holders = new Set<string>();
private tookIt = false;
private dadOwns = false;
constructor(private talk: Talk) {
talk.onChange = (from) => {
if (talk.phase !== 'paused') this.tookIt = false;
if (this.holders.size > 0 && !this.dadOwns && from === 'speaking') this.take();
};
}
private take() {
if (this.talk.phase !== 'listening') return; // she is speaking: only show
this.tookIt = true;
this.talk.pause();
}
hold(who: string) {
if (this.holders.size === 0) {
this.dadOwns = false;
this.take();
}
this.holders.add(who);
}
release(who: string) {
if (!this.holders.delete(who) || this.holders.size > 0) return;
const ours = this.tookIt;
this.tookIt = false;
this.dadOwns = false;
if (ours && this.talk.phase === 'paused') this.talk.resume();
}
/** Dad pressed Pause or Resume himself: from now on the pause is his. */
dadDecides() {
this.tookIt = false;
if (this.holders.size > 0) this.dadOwns = true;
}
}
async function main() {
const talk = new Talk();
const stage = new Stage();
stage.addFiles([new File(['<png bytes>'], 'screenshot.png')]);
stage.addTexts(['https://example.com/news/42\nA headline Dad copied']);
console.log('waiting:', stage.waiting);
await stage.sendWith('what do you make of this?', async () => {
stage.addFiles([new File(['<png bytes>'], 'shared-meanwhile.png')]); // arrives mid-send
throw new Error('offline');
}).catch((error: Error) => console.log(error.message));
console.log('after the failed send:', stage.waiting);
await stage.sendWith('what do you make of this?', async (message, files) => {
console.log(`sent, with ${files.map((file) => file.name).join(', ')}:\n${message}`);
});
console.log('after the send:', stage.waiting);
const many = Array.from({ length: 11 }, (_, i) => new File(['x'], `photo-${i + 1}.jpg`));
const refused = stage.addFiles(many);
console.log('eleven at once: kept', stage.waiting.length, '| refused', refused.map((file) => file.name));
const holds = new PauseHolds(talk);
holds.hold('lightbox'); console.log('lightbox open while listening ->', talk.phase);
holds.release('lightbox'); console.log('lightbox closed ->', talk.phase);
talk.replyStarts(); holds.hold('lightbox');
console.log('lightbox open while she speaks ->', talk.phase);
talk.replyEnds(); console.log('her reply ends while it is open ->', talk.phase);
holds.release('lightbox'); console.log('lightbox closed ->', talk.phase);
talk.pause(); holds.hold('lightbox'); holds.release('lightbox');
console.log('Dad paused first, then a screenshot opened and closed ->', talk.phase);
talk.resume();
holds.hold('lightbox'); talk.stopsOnItsOwn(); holds.release('lightbox');
console.log('the talk stopped on its own while he looked ->', talk.phase);
talk.resume();
holds.hold('lightbox'); holds.hold('dialog'); holds.release('lightbox');
console.log('lightbox closed, dialog still open ->', talk.phase);
holds.release('dialog'); console.log('dialog closed too ->', talk.phase);
holds.hold('lightbox'); holds.dadDecides(); holds.release('lightbox');
console.log('Dad pressed Pause himself while he looked ->', talk.phase);
talk.resume();
talk.replyStarts(); holds.hold('lightbox');
holds.dadDecides(); talk.pause(); // his Pause button, mid-reply
talk.replyEnds(); holds.release('lightbox');
console.log('Dad paused while she spoke, then her reply ended ->', talk.phase);
}
void main();
Run the code with npx tsx and follow the stage through a failed send and a good one, then the lightbox through each of its cases, including the one where it opens while she speaks. Then add the paste handler for a real page: read the clipboard from a paste event, hand any files to addFiles, and only when there are none, hand the plain text to addTexts. Finally wire a real Pause button to PauseHolds: which call does the button make while the lightbox is open, and why?
Hint
In a paste event, images arrive as file items and text as a string item; ask for both and prefer the files when both exist. For the last question: the button calls dadDecides() and then pauses or resumes. From that press on the pause is Dad's, so closing the lightbox must leave the talk however he set it, the same reason a talk that stopped on its own stays stopped and says why on the face.
Progress
Progress is local-only — sign in to sync across devices.