Skip to content

iOS: rebuild the listening session — PiP keeper, acknowledged keyboard contract, hop-and-return, keep-open card; engine: never paste a rewrite that reads its instruction back - #311

Open
nbisht-aai wants to merge 34 commits into
mainfrom
feat/ios-held-session

Conversation

@nbisht-aai

Copy link
Copy Markdown
Contributor

What & why

The iPhone app's dictation only worked keyboard-first on a phone: capture was being restarted from the background on every press, which iOS refuses (!rec), and the window, the keyboard's press and the capture failures had no owner, no acknowledgement and no names. This rebuilds the session on main from the roots, on the mechanism Typeless uses (measured on an iPhone 13, iOS 18.7.8).

  • Session contract (BlurtiOSCore/SessionContract.swift): one readiness record (ListeningState: 15 s lease, published on every transition and by a 5 s heartbeat), a per-command CommandAck, keyboard presence compare-and-written by process, FailureReason, hop URLs blurt://start?reason=&host= and blurt://setup?need=fullAccess. Every number sits in DESIGN.md's "two processes" table and is pinned by SessionContractNumbersTests.
  • Window (ListeningWindowMachine + ListeningWindow): a pure, generation-tokened state machine (closed / opening / open / interrupted / closing). The foreground gate lives in it; the lifetime counts from the last word; interruptions pause and resume, never close. IdleMode is PictureInPictureIdle — a 369 × 0.079 pt AVSampleBufferDisplayLayer keeper with hidden controls, started in front, which keeps the process UserInteractiveFocal so later presses start capture from the keyboard with no hop and the orange dot is off between dictations — with HeldIdle as the fallback.
  • Keyboard (CommandRouter, OwnerWatchdog, PressHandshake, VoiceNotice): a press is acked started / busy / notListening(reason) or times out at 1.2 s into "Tap to open Blurt"; release and cancel only from the owner or the on-screen keyboard; a dead keyboard's dictation is released by the watchdog within 10 s; one status line under the mic says why a press cannot record.
  • Capture failures: typed (ListeningSourceFailure), observed (AVCaptureSession runtime error and interruption, route changes), mapped to reasons; DeviceTrace JSONL and KeyboardLog; scripts/ios-device.sh (preflight / install / launch / kill / pull / syslog) for the real-phone loop; simulator fault injection through BLURT_SIM_FAULT.
  • Keyboard profiles: classic (iOS ≤ 18) and glass (iOS 26+) geometry and colours per OS generation; the slim bar's mic on the keyboard's centre line. The classic numbers are the plan's fallbacks: Xcode 27 cannot install an iOS 18 simulator runtime, so they stay unmeasured until the on-phone screenshots.
  • Hop-and-return (HostApp, HostReturn, HopReturn): with the session off, the mic tap opens Blurt, which turns the session on and brings the host app back by itself once the window is open and the keeper is up (cap 1.5 s). Private API, knowingly: the keyboard reads the host's bundle id off its controller (_hostBundleID, read only where a getter or ivar is declared) and the app calls LSApplicationWorkspace openApplicationWithBundleID: — what Typeless does, seen in its logs. Both fall back to the "◀" hint when absent or refused.
  • Keep-open card (KeepOpenView): the app-switcher card shows "Keep Blurt in the background" with the brand screensaver while a session is on and the scene is not active.
  • Engine (RewriteGrounding, AssemblyAITranscriber+Transcript): the dictation API's fast rewrite model answers a transcript the cleanup empties ("Yeah.") with the user's style instruction word for word (6/6 against the live route, every prompt ordering, llm_error null). The app no longer pastes a rewrite that is a run of the instruction, or that says nothing, and pastes the verbatim words instead. The service-side fix is a separate DeepLearning PR.
  • -BlurtDeveloperMode / -BlurtDeviceTrace persist across the hop's relaunch (LaunchFlags), so the phone's logs no longer stop at the first hop.

Not in this PR, tracked in the plan: the touch-model step (a lift after a sent press is a stop; grille only), consent v2 and its copy, the DeviceFlows probe, the on-phone geometry measurement.

How it was tested

  • scripts/check.sh on the merged tree: ios-check 424 iPhone tests, BlurtiOSCore line coverage 96.3 %, swiftlint analyze and periphery clean; engine 1233 tests; the three formatting steps the merge of main tripped (design-sync, swift-format, prettier) fixed in c210324.

  • iPhone 13 / iOS 18.7.8, installed with scripts/ios-device.sh: the window opens, the PiP keeper comes up, presses from Messages are accepted:started with capture start parked=true running=true, Recording: 1 only during dictations, results inserted, and no start recording in the background / 561145187 line in the system log. The style-text leak was reproduced against the API and is fixed.

  • Not yet verified on the phone: the hop's automatic return (the host-id read and the LSApplicationWorkspace call) and the keep-open card — the phone was unavailable for the last install. The trace's hop host= and return opened= lines decide.

  • scripts/check.sh passes

  • I read AGENTS.md and this doesn't reintroduce anything deliberately removed — the silent loop and background startRunning stay out; check-invariants.sh passes

  • Docs updated: App/BlurtiOS/README.md ("Testing on a phone"), App/BlurtiOS/DESIGN.md ("The two processes, and every number"), AGENTS.md (the iPhone's platform scope)

🤖 Generated with Claude Code

nbisht-aai and others added 30 commits November 4, 2026 17:13
…rds, hop URLs

The records the app and the keyboard will exchange about the listening
window: one readiness record with a 15 s lease (never distantFuture), a
typed FailureReason, an ack per command, a presence record only its own
process may clear, and blurt://start?reason= / blurt://setup?need=. The
command and phase payloads gain their ids and reasons with defaults, so
every existing writer still compiles; SharedStore.isListening reads the
record when there is one and the old keys until then.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…iP keeper onto main

From feat/ios-pip-mic (park/startParked/hold, the first-frame wait, the
simulator source's scripted speech) and the proven proto/ios-pip-keeper
spike (the keeper). Nothing wires them yet: ListeningWindow still holds
the mic for the window. The debug API-key block returns for scripted
simulators; the digital-silence check gains its test row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ion-tokened state machine

Core, no clock, no audio, no store: the interpreter feeds it events
(open requested, opened, utterance edges, interruption, resume, expiry,
media reset, stop, closed, idle change) and carries out the effects it
answers with (session, source, expiry timer, background task, the
ListeningState record, a log line). The foreground gate lives in the
machine; open-while-open refreshes; the lifetime counts from the last
word with a dictation's start as the floor; an interruption keeps the
window and resumes on the interruption's end (poll) or the next
foreground (start); a failed resume stays interrupted; stale
generations are logged and dropped. Every publish bumps the sequence
and carries a real lease. 30 tests, one per row plus the record's
invariants over a whole life.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…rd command, and the owner-based release

CommandRouter.route decides one keyboard command from values alone (the command, the
ListeningState record, the coordinator's phase, the dictation in flight and its owner,
the keyboard on screen, the ids already answered): the CommandAck to write, the submit
to carry out (press / release / cancel), and whether to remember the id. A press is
never submitted to a window that is not open; a release or cancel is accepted from the
owner or from the keyboard on screen, and only for the dictation in flight.

OwnerWatchdog.shouldRelease ends a dictation whose owner's presence record is missing,
not on screen, or older than BlurtShared.ownerWatchdog (10 s); another keyboard on
screen does not keep it alive. A dictation with no owner on record is kept by whoever
is on screen.

Every row is a test: 26 in CommandRouterTests, 8 in OwnerWatchdogTests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… state machine

Every finger event, ack, phase, tick and readiness record comes in as an
Event; every send, gate reset, line and hop goes out as an Action for
KeyboardModel to perform. A press is answered by the ack or by a phase that
names it, resent once at 600 ms, given up at 1.2 s (gate reset, "unanswered",
the next lift hops — never a dead release). Owned, foreign and stopping each
have their rows; a hold lifted before the answer waits for the dictation's id
and releases at once. 33 tests, one per row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ery row tested

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ccessors out, no force unwraps

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ult injection

ListeningSourceFailure gains interrupted(reason), runtimeError(code), configureFailed(code),
inputSilent, couldNotResume and backgroundStartRefused(code), each its own sentence, and stays
Equatable. ListeningSource gains `events: AsyncStream<SourceEvent>` (interrupted,
interruptionEnded, runtimeError, routeChanged, silent). WindowedAudioSource registers the
session's runtimeError / wasInterrupted / interruptionEnded and the audio session's route
change on the control queue before the first startRunning, logs each (Capture, public) and
throws the typed case a start finds — never "No microphone is available." for a refusal; a
parked start left not running is backgroundStartRefused. UtteranceFeed keeps the 1 s silence
watchdog (DESIGN.md row), held off during a parked bring-up until the first live frame.
CaptureFailure maps every failure and event to the keyboard's FailureReason and a sentence.
SimulatorAudioSource takes BLURT_SIM_FAULT (README) and, for tests, runs with no Mac mic.
PCMConverter moves to its own file for the lint budget.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…eflight, install, launch, kill, pull, syslog

device-install.sh (the vault-side helper) moves into the repo as one script with
subcommands, all on `xcrun devicectl` and nothing else — no Xcode window, no
debugger:

- preflight: the phone is `available` (the list's State column, filtered, since
  the JSON carries no such field), Developer Mode on (`device info details`),
  unlocked since boot, Blurt's install state; then the checklist of what no
  script can see (UI Automation, the keyboard with Full Access, an API key).
  Exits non-zero naming what is missing.
- install: xcodegen under BLURT_TEAM, a Debug build for `id=<udid>` with
  automatic signing into a per-checkout derived-data directory, `devicectl
  device install app`.
- launch [args…]: `--terminate-existing`, default `-BlurtDeveloperMode YES`;
  the app's arguments go after `--` (devicectl reads `-Blurt…` as its own `-t`
  otherwise); a locked phone is reported plainly.
- kill blurt|keyboard: the pid by executable path from `device info
  processes`, then SIGKILL.
- pull DIR [--syslog] [--last 15m]: Library/Logs/Blurt from the app's data
  container (a missing folder — CoreDevice error 7000 — is not a failure), and
  with --syslog the phone's system log through `log collect --device-udid`
  under osascript's administrator-password dialog, the archive chown'd back.
- syslog ARCHIVE [--last 15m] [--negative]: Blurt's lines, the system daemons'
  lines about it and any background-start refusal, minus the usual noise; or
  the negative predicate every gate run asserts (no `start recording in the
  background` / 561145187 / `!rec` line naming Blurt), exit 1 with the lines.
- flows, check: stubs (exit 2) until DeviceFlows lands with the device gate.

README › Testing on a phone is now that protocol; AGENTS.md lists the script
in the repo map and the verification block.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…S generation, the slim bar's mic at W/2

KeyboardProfile (BlurtKeyboard) decides the generation once per appearance from the OS
(ProcessInfo, read in that one place), the width, the globe and the host's container inset read
from the input view's superview at viewIsAppearing: glass only on iOS 26+ with a container
detected, classic otherwise (iOS 18 phones, un-adopted iOS 26 hosts). It exposes the margins,
gaps, key height and corner, the band above the keys, the bottom inset and whether the surface is
painted; KeyGeometry lays the classic rows out as W/10 cells, KeyboardLayout.height takes the
profile, KeyboardRootView reads its margins and sets keyboardInContainer from it, KeyboardPalette
offers Apple's classic colours as the `system` theme (the store's default) on classic with the
brand one row away. The numbers are the `classic/*` and `glass/*` token sets in tokens.json; the
classic ones are the plan's fallback rule, marked unmeasured, because Xcode 27's downloader
refuses every iOS 18.x runtime (22G86, 22F77, 22E238) — apple-geometry.sh gained --profile, the
device-type scale and the os/runtime fields (ios-lib.sh: BLURT_SIM_RUNTIME, ios_device_scale) so
the classic-390 and classic-375 JSONs can replace the fallback on an iOS 18.6 simulator.

Same step: the slim bar places the mic in keyboard coordinates (centre = W/2, delete and return
hanging on its trailing side, inboard at an edge; SlimBarLayout, tested) and the probe's
placement check measures every layout against the keyboard's width; the panel's element is
capped at min(box, row − 2·(clearance + glyph hit)); FullKeyboardView is bottom-aligned in its
reader; the carousel's pages carry the side margin themselves; AZERTY's third row gains its
apostrophe; the Face ID bottom row (123 · space · return) is documented and tested at every width.

Tests: KeyboardProfileTests (generation, size class, every row adds up at 375/390/393/402/430 ×
both generations, the three profile JSONs), AppleGeometryTests over the three JSONs,
DesignTokensTests heights per profile and a modifier-contrast test (1.3 for the system set, 1.09
for the brand's quieter modifiers — 1.21/1.10 painted, 1.13/1.28 in the container — so neither
fades), VoiceRowLayoutTests at rows 362/377/389/417 and SlimBarLayoutTests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
What the machine carries across transitions, each pinned by a test:
the closed record keeps its reason (the failed open's, the refusal's,
the cause's) across heartbeats; a hop while closing is remembered —
the closing record says opening (Cause.reopen) and closed(g) goes
straight to opening(g+1) with one openSource, a Stop meanwhile takes
it back; Event.windowMinutesChanged applies Settings live (an open
window's end moves from now, a dictation's floor holds, until-stopped
cancels the timer, any other state only stores it); a source lost
while opening lands opened in interrupted(resumable) with a background
task and the expiry scheduled; a refresh republishes the last
heartbeat's micLive (lastMicLive, true on opened and resumed); the
switch turned off while interrupted logs the same stopWhileInterrupted
line as Stop; openRequested documents inForeground as
applicationState != .background. The types move to +Types.swift and
the rig to +Rig.swift to keep every file under 400 lines. 46 tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
"In flight" means one thing for every row: a release or cancel with a terminal
phase acks nothingInFlight whatever ref was left, and only an in-flight phase
with a ref can be stopped or cancelled. The app never acks suspended: a lapsed
lease is answered from the window (reasonForPress). A resend is answered with
its original outcome — Input.seen becomes answered: [UUID: Outcome] — so the
keyboard's 600 ms resend sees started(d) again; Outcome.duplicate stays in the
contract, the router no longer produces it. An accepted release or cancel
carries recipient = the keyboard that sent it.

37 rows in CommandRouterTests (+Ending); BlurtiOSCore line coverage 93.87%.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F1 a finger down over a latched dictation that ended unseen takes no press
   from a closed or lapsed window: unready(reason) + [resetGate, show], lift hops.
F2 foreign carries liftStops: false via ack busy (the tap's lift leaves it
   running, the next tap stops it), true via a finger down over a running one;
   a hold's lift always releases.
F3 ack stale/duplicate/nothingInFlight for the press keeps it waiting; the
   deadline settles it. An answer at or past the deadline is late, tick or not.
F4 Action.armGate: the one action on which the model arms the gate.
F5 cancelTapped is the × only; canUndoBeforeRecording + undoBeforeRecording(now:)
   for the flip — pressing and owned(connecting) cancel, a recording keeps its words.
F6 a lapsed lease or no record while owned/foreign/pressing → suspended, gate
   reset; a record merely not ready while owned keeps owned.
F7 a finger down or up over owned(processing) buzzes, nothing sent.
F8 the lift half moves to PressHandshake+Stop.swift; tests split likewise, a
   third suite for the gate and the record.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Input.heldError: the last .error snapshot KeyboardModel holds until the next
micDown; its row applies while the live snapshot is idle, so the line outlasts
the ring's 2 s dwell. Handshake gains .foreign (silent, like .owned); pressing
and stopping drop their since: payloads (the deadline is PressHandshake's).
Tap.none → Tap.buzz. Precedence pinned: a notice beats the handshake in every
state. The doc comment lists the signature deviation from the plan.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the status line and the hop

The keyboard's half of the session contract. KeyboardModel drives
PressHandshake for every finger event, signal and tick: a press goes out
with the process, controller, sequence and record it decided on, is resent
once and given up at its deadline; a lift stops with the dictation's id; a
refused or expired press hops with its reason (blurt://start?reason=, or
blurt://setup?need=fullAccess without Full Access). Presence is one
compare-and-written record per process, with the older keys kept for a
release; the host leaving or the keyboard leaving ends only a dictation it
owns; deinit detaches the model. VoiceRowHost holds the three-way switch
for the bar and the panel, with the status line under the element (on the
bar's bottom edge, where nothing fits under a 32 pt box in a 44 pt row).
The error's exclamation shows whether or not Blurt is ready. Tests:
gate/lifecycle/fuzz rewritten on the handshake, the rig writes the
ListeningState record and the old keys, the fuzz gains the ack, record,
deadline and host events and the handshake invariants; the large_tuple
in KeyboardProfileTests is a struct.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ned and watched; PiP idle mode

The app side of the session contract, finished from the wip commit.

ListeningWindow is the interpreter of ListeningWindowMachine: injected source,
audio session (AudioSessionControlling, the Typeless options, haptics flag,
preferred input), background tasks, clock and idle mode, with production
defaults; one serial loop; the source's and the audio session's events as the
machine's events with the generation; expiry timer; 5 s heartbeat with micLive
from the idle mode; every transition logged and traced. HeldIdle and
PictureInPictureIdle (the proven keeper recipe, untouched; a parked start iOS
refuses is the source lost until the next foreground, never a counter, never a
closed window); -BlurtIdleMode held|pip; the keeper's layer hosted on RootView.

DictationCoordinator: start() writes ListeningState.launched; every keyboard
command goes through CommandRouter and is acked (resends answered the same,
32 ids kept); a press's dictation is owned and named on every phase with the
press it answers; OwnerWatchdog every keyboard beat and on the keyboard signal;
a release runs under a 25 s background task; the result goes to the keyboard
that sent the accepted stop, else the owner, re-signalled once at 1 s; every
capture failure is said in the source's words with a typed reason; the hop URLs
blurt://start?reason= and blurt://setup?need=fullAccess. The home card reads the
record; a failure dwells 3 s and clears on any window-state change. Settings:
window minutes applied live, "Use AirPods mic".

Tests: ListeningWindowTests over fakes and a manual clock, the coordinator's
ack path, ProtocolTests on the record, KeyboardRig.listening writing the
record, HomeStatus on the record, the silence watchdog's start grace.
DESIGN.md's two-processes table gains the window's rows; README the window's
launch arguments and log predicate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e — onto the app's window interpreter

Fixtures unified on the keyboard branch's KeyboardRig (launch, sequence, record, answer, latchRecording, capturingHops).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…UG_API_KEY) so a keyless simulator can open the window

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
F1 A press is never accepted by a closing window: the machine publishes the
   closing record before it ends the dictation and closes the source, and the
   coordinator routes commands on ListeningWindow.liveState (the machine's
   state this instant, ListeningWindowMachine.current) rather than the record
   last written.
F2 Idle-mode reports carry the generation they were made for; the window drops
   one for another generation ("stale idle report"); the keeper's park and
   hold tasks are cancelled by windowClosing and re-check after every await.
F3 The owner watchdog ticks every DictationCoordinator.watchdogTick (2 s)
   while a dictation is in flight, on the coordinator's clock, and stands down
   for a stop already accepted (stopAccepted): no double release.
F4 sessionInterrupted resets on every openSource and on resumed(g), so a Stop
   during a call does not drop the next window's capture-session interruption.
F5 The heartbeat runs from ListeningWindow.start() for as long as the process
   lives, in every window state: the machine's heartbeat while a window
   exists, ListeningState.renewed (through heartbeat(renewing:now:), so the
   sequence keeps climbing) while closed. A closed window reads sessionOff on
   the keyboard, never suspended, while the app is alive.
F6 A hop over an interrupted PiP window awaits windowClosing(), which waits
   for the keeper's didStop (PictureInPictureIdle.keeperStopWait, 1 s) before
   the next source opens; windowOpened never parks under a keeper that is
   stopping, and a start wanted while stopping runs on didStop.
F7 SystemBackgroundTasks' expiration handler ends the task synchronously on
   the main actor; the 25 s own timer stays.
F8 Coordinator tests over the real session with a stub transcript: the
   accepted press and its phases, the owner's release and the result's
   address, a stranger's release, the watchdog (and not after an accepted
   stop), a resend, a press during a close.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
DESIGN.md › The two processes: the keyboard's rows (press and stop acks,
the retry, the keyboard beat and presence record, leaving with a stop
out, the hop reasons, the status line and its strings, the held error,
the line's place, the log subsystem) with every number a named constant,
and a marked place for the app side's rows; SessionContractNumbersTests
pins each row's constant and greps the table for it. The keyboard's
Container logger and the app's HostIdentity share BlurtShared.logSubsystem.
A keyboard leaving with its own release out keeps its presence record on
screen until the ack or KeyboardModel.leaveGrace, so the watchdog never
releases the same dictation a second time. The bar's status line takes
the row's bottom text line at size/line and the row draws in the band
above it, so the grille sits clear of the words; the panel's line hangs a
gap under the box. The fuzz invariant names the handshake's states and
recovers() walks a dead app: lease lapse → suspended → the tap hops.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A dictation that caught no words comes back with an empty transcript and a
rewrite the model wrote from the style instruction alone; on the phone that
pasted the instruction's own words. The choice is now a pure function with
a test: no spoken words, nothing pasted.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…nstruction back

The rewrite model answers a transcript its cleanup rules empty out ("Yeah.")
with the tail of its own instruction — the user's style preferences, word
for word — and a lone "Um." with ".": 6/6 each against the route on
2026-11-05, under every ordering of the instruction tried, llm_error null.
On the phone the style text was pasted as a dictation. `RewriteGrounding`
keeps a rewrite only when it says something and is not a run of the
instruction the request carried; `chooseTranscript` falls back to the
verbatim transcript otherwise. The choice and its tests move to sibling
files (file length).

ios: `-BlurtDeveloperMode` / `-BlurtDeviceTrace` outlive their process
(`LaunchFlags`): the hop's relaunch carried neither, so the dictation log
and the trace stopped at the first hop. `ios-device.sh launch` passes both
by default.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the keyboard and the tests

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t URL, the app returns to it once the window is open

The keyboard reads the app it is up in off its controller (`HostApp`,
`_hostBundleID`, read only where a getter or ivar is declared) and puts it
on `blurt://start?reason=…&host=…`. The app holds it (`returnHost`) and,
on the first record that says open with the mic staying live — at once
under a held mic, on the keeper's `pip` report, or `BlurtShared.hopReturnCap`
1.5 s past the open — asks iOS for the host (`HostReturn`,
`LSApplicationWorkspace openApplicationWithBundleID:`, as Typeless does)
and leaves. A window closing before it opened, or the user leaving first,
cancels it; no host, or the call refused, leaves the "◀" hint as before.
`ListeningWindow.onRecord` reports every record; `DictationCoordinator`
takes `appState` and `hostReturner` for the tests.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Port the Blurt-is-on screen from feat/ios-keep-open as the card iOS shows
for Blurt in the app switcher. RootView fades KeepOpenView over the app
whenever the scene leaves the front with a listening window on, and out
again as it returns, so the card — and the snapshot iOS keeps — says to
keep Blurt in the background and for how long it listens. The words are
KeepOpen and the DVD screensaver is Bounce, both in Core and tested; no
close, no stop, no swipe hint: the scene is inactive whenever it shows.
Tokens for the tints, the bounce, the flare and the fade; Wordmark takes a
height and a tint; -BlurtKeepOpen renders it as the root for a capture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
nbisht-aai and others added 4 commits November 5, 2026 13:17
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…t, an idle layer carries the window, a capture change is verified on a phone

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… Mac project, wrap two test lines

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Comment on lines +140 to +146
for wallX in 1...wallsX {
// The drift at which x touches this wall, and where y is then.
let atX = CGFloat(wallX) * x.travel - x.start
let distanceY = y.distance(atX)
let wallY = (distanceY / y.travel).rounded()
guard wallY >= 1, abs(distanceY - wallY * y.travel) <= tolerance else { continue }
let atY = wallY * y.travel - y.start

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

cornersHit(byDrift:) revisits every x-wall hit on each rendered frame, so its per-frame cost grows with animation duration and repeatedly recalculates past corners.

Show fix
Suggested change
for wallX in 1...wallsX {
// The drift at which x touches this wall, and where y is then.
let atX = CGFloat(wallX) * x.travel - x.start
let distanceY = y.distance(atX)
let wallY = (distanceY / y.travel).rounded()
guard wallY >= 1, abs(distanceY - wallY * y.travel) <= tolerance else { continue }
let atY = wallY * y.travel - y.start
let wallY = (y.distance(drift) / y.travel).rounded()
let distanceY = wallY * y.travel
guard wallY >= 1, abs(y.distance(drift) - distanceY) <= tolerance else { return (0, nil) }
let atY = wallY * y.travel - y.start
for wallX in 1...wallsX {
// The drift at which x touches this wall, and where y is then.
let atX = CGFloat(wallX) * x.travel - x.start
guard abs(y.distance(atX) - distanceY) <= tolerance else { continue }
Details

✨ AI Reasoning
​The screensaver computes a frame from elapsed time and calls cornersHit(byDrift:) each time. That function iterates from the first x-wall hit through all hits so far, recomputing historical corner events on every frame. Because the animation can continue indefinitely, the per-frame work keeps growing and repeats prior work.

Reply on this comment to give feedback or ignore the issue.
More info

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant