The family's frame, Sizzle's page inside
Every family web app can carry Pippa beside its own screens in the kit's Sidekick: a panel that docks at the side or the bottom, or floats, around an iframe. The kit's own header describes that iframe as cwkPippa's embed surface for the app, the whole Pippa with her avatar, emotions and attachments. Sizzle takes the same chrome and points the iframe somewhere else: at its own /talk?key=… page. The architecture doc calls this the Easel pattern. The family chrome sits outside, Sizzle's conversation body sits inside, and the chef avatar with its current emotion stays visible above it.
Why not frame cwkPippa directly? Because a Sizzle thread is more than a chat. Pippa may end a reply with proposals (correct the reading, adopt a class, create a variant), and those apply to Sizzle's record through Sizzle's own route. The thread key, the actions and their buttons all belong to Sizzle, so the page that draws them belongs to Sizzle too. It is served from Sizzle's origin and talks only to Sizzle's engine. The browser never needs a cwkPippa address.
One path renders only the conversation
The router is small enough to read in one sitting: a hook over history.pushState and popstate, and a Link that leaves modified clicks to the browser. The engine answers every path that is not an API path with the app shell, so a reload lands on the same page. Seven routes exist: /, /p/<ULID>, /tree, /c/<ULID>, /kitchen, /settings and /talk.
App checks for the last one before anything else. On /talk it renders nothing but Talk, with the thread key from the query string and kitchen when none is given. Every other path gets the Shell: the header, the navigation, the brain status chip and the Sidekick launcher. That is why the frame never shows a second header inside the first.
Which thread the frame opens
The shell derives the thread from the page you are on. A plate page opens encounter:<ULID> under the subtitle "This plate", a class page opens class:<ULID> under "This class", and every other page opens kitchen. The Kitchen page is a conversation in itself, so it renders Talk inline and hides both the Sidekick and its launcher. The engine accepts exactly those three shapes of key: kitchen, or encounter: or class: followed by a 26-character ULID.
The architecture doc writes these bindings as sizzle:encounter:<id>. The code has two spellings instead, one per side of the boundary. Inside Sizzle the key is encounter:<ULID>. Toward cwkPippa the engine turns it into a client id by prefixing sizzle_ and replacing every colon with an underscore, so the conversation is sizzle_encounter_<ULID>, created with the origin surface sizzle. Teach the two spellings the code uses; the doc's form exists in neither.
The frame tells the page
When Dad applies an action inside the frame, the record behind the page has changed. A corrected dish name or a newly adopted class should show at once, without a reload by hand. So Talk, after an apply succeeds, checks whether it is inside a frame and posts a message of type sizzle:changed to its parent, addressed to its own origin. The shell listens for messages, accepts one only when its origin equals the page's own origin and its type matches, and bumps a refresh counter. The plate, class and tree pages take that counter as a prop and load their data again.
Because the frame is Sizzle's own page, both ends share one origin by construction, so the check stays a single equality. That equality filters origins, not windows. A message from another site is ignored, but the shell never compares event.source with its frame, so any same-origin window that holds a reference to this one and posts sizzle:changed also bumps the counter. Here that costs one extra reload from the engine and nothing else. A message that changed state, instead of only asking for a reload, would need the source check too.
Her face stays above the conversation
Talk draws the avatar before the turns. While a turn is pending, the face is the serious one and the line under it reads "Pippa is thinking" followed by a timer that ticks every second. Otherwise it shows the emotion of the last finished turn. The timer comes from a family rule written beside it in ui.tsx: a waiting surface shows how long it has waited, because a bare spinner cannot tell a slow answer from a stuck one.
While any turn is pending, the thread is read again every 1.5 seconds. A send that fails keeps its request id, so pressing Send again asks the engine for the same turn, and the engine answers that replay instead of starting a second one. A turn that failed on the engine's side shows its reason and an "Ask again" button.
The phone has no frame
The iPhone and iPad app does not embed anything. Its TalkView is native SwiftUI on the same thread routes, with the same face above the turns, the same 1.5-second re-read while a turn is pending, and the same request id kept across a failed send. There is no iframe there, so there is no message either. The app model bumps a revision number whenever something an open screen shows may have changed, and the screens reload on it.
On the web, the Sidekick remembers whether it was open and in which mode, under sizzle. keys in local storage, and defaults to the side panel on screens at least 900 pixels wide and the dock below that. The kit's frame keeps its own three sizes beside them, side width, dock height and floating size, under sizzle.pippa. keys. Every read and write of those keys is wrapped, so when local storage refuses, the defaults stand. The code's comment calls that case private mode, which fits the older Safari private windows that threw on every write. A current private window usually keeps the choice while the window lives and forgets it when the window closes.