Skip to content

2026 10 05 embed stage decision

github-actions[bot] edited this page Oct 10, 2026 · 7 revisions

Decision: the ChatGPT embed is one stage with two controls

Status: Decision record. Date: 2026-10-05. Decided by Mateo, the Composer lane owner. The Reader-lane items (the Chamber's chrome: 'none', setColourTheme and the palette override in §7; PR 2 in §9) need Seth's review before they are built. The two questions in §11 are open.

What it supersedes, for the ChatGPT embed only: the row "The embed's control set and the constructor guards" and the C3 guard "Live and embed origins keep the controls.js set" in the Consolidated Reader §5; the "persistent embedded notice" in the ChatGPT demo design; the once-reported 640 px height in LIVE-MCP. It answers gap 1 of the embed safety check. Each of those carries a dated pointer back here. It adds to the Composer decision and the Consolidated Reader decisions; it does not rewrite them.

Ground: origin/main at fcc88280 plus #423 (branch plugin-conformance-1: d9b9b06a, 1621bdde, a7a9d526; open when this was written), the measured baseline of 2026-10-05, the five verified plugin maps (working notes, not in the tree), and the pages fetched that day: https://developers.openai.com/plugins/concepts/ui-guidelines, https://developers.openai.com/plugins/reference, https://developers.openai.com/plugins/build/chatgpt-ui, https://developers.openai.com/plugins/build/mcp-server, https://raw.githubusercontent.com/modelcontextprotocol/ext-apps/main/specification/2026-01-26/apps.mdx. Nothing was run. Every pixel number below is computed from the baseline and the CSS; the first PR renders them. Line numbers cite d9b9b06a.

This starts from the judged winner (Stage card: one inline box, inline only, the 481 px floor, four Settings rows, the Chamber's chrome switched off, the relay kept, no server work beyond the metadata the docs name) and grafts what the three judges agreed to graft from the other design: the no-prose poster, the speech-unavailable glyph, the hidden whole-reading list, the Settings sheet's focus contract, hidden descriptions instead of visible hints, the end state that holds the last frame, one Chamber hook for colour, and data-embed on the root. Where the judges disagreed, the choice and the reason are stated in place.


1. Decision

The owner's rule, 2026-10-05, in his words: "all of that text probably shouldn't even be there. honestly maybe the only options should be pause play and settings for the visual changes that can be applied in real time."

Applied: while a reading is on screen in ChatGPT, the card shows the reading and two controls, Play/Pause and Settings. Settings holds only what changes the picture or the type at once: intensity (calmer to vivid), the nine colour themes, still imagery, text size. Begin is the first Play. Stop folds into Pause and the host's teardown. Every sentence that stood under the reading goes: the status line, the microphone note, the replay notice, the typed Visual change box, About this passage, the Transcript, the device notes. What a screen reader needs (the state, the words) lives in ARIA, never in visible prose. The one visible sentence left is a failure.

What it supersedes in docs/product/CONSOLIDATED-READER.md §5: the row "The embed's control set and the constructor guards | controls.js, current.test.js" (:591) and the C3 guard "Live and embed origins keep the controls.js set" (:604). For the embed only: the control set becomes the two objects above, in a new module; controls.js stays as it is for the standalone /live page; the constructor guards in src/core/current.test.js:45-64 (one Session builder, one Player builder) are untouched. docs/superpowers/specs/2026-10-01-chatgpt-demo.md:17 ("A persistent embedded notice explains this") is superseded: the poster carries the fact. docs/product/tasks/LIVE-005.json:14 ("Stop cancels pending speech and future work") is met by Pause (the voice is held at once, src/live/runtime.js:402-412, src/live/voices/browser.js:128-137) and by teardown (everything cancelled, src/live/host/LiveHost.js:592-599); its wording changes with this record.

Fixed and untouched: rise.current.v1 (schema, nine theme ids, three visual ids, 16 segments), rise.experience-program.v1, the compiler input and sentence chunking (src/core/rise-current.js:293), the Player API, RISE_CURRENT_THEMES (rise-current.js:122-132), ATTRACTOR_VISUAL_MANIFEST and validateVisualCommand (src/core/visual-control-contract.js:1-43), the admission path (rise_present's call() at worker/mcp-server.mjs:168-192, CURRENT_GUIDE, worker/mcp-gate0.mjs).

Scope: everything the Composer edition is meant to do, polished. Out: Dive, Surface, the microphone, the realtime adapters, later model calls into an open widget (docs/product/discussions/2026-10-04-composer-decision.md:15,24,29-30).


2. The card

The card is one box, the stage: the theme's ground edge to edge, the Chamber's field and text panel, and two 44 px round glass objects, Play/Pause at the bottom-left and Settings at the bottom-right. The box has one height per width (§5) and never changes it on its own, so the thread does not jump at Play. Nothing inside scrolls: "No nested scrolling. Cards should auto fit their content and prevent internal scrolling" (https://developers.openai.com/plugins/concepts/ui-guidelines). Two controls: "Support up to two actions maximum, with one primary CTA and one optional secondary CTA" (same page).

Hidden elements, present in every state after admission: p.rise-stage__status (role="status", aria-live="polite", visually hidden) for the state sentence; ol.rise-stage__text (aria-label="The whole reading", visually hidden) holding every sentence of the Current from admission, since the adapter pushes the whole Current at once (src/live/adapters/mcp-app.js:127-133); p.rise-stage__said (aria-live="polite", visually hidden) that takes each committed sentence only while the reading is silent, and is emptied and set aria-live="off" while a voice speaks. The words are reachable without sight or sound (the Transcript's purpose, src/live/host/controls.js:8-9), and a silent screen-reader user hears whole sentences, not the word-by-word reveal that aria-live on #atom-display would read (.atom-word[data-pending], src/components/read/Chamber.js:2791-2792, 2884). Judges 1 and 3 asked for the list; judges 1 and 2 asked for progress while silent; the committed-sentence region gives both without the chatter judge 3 rejected.

State Visible Play/Pause Settings Assistive tech hears
waiting (before the answer) The box at its height on RISE's ground (#06051A, src/live/hosts/mcp-relay.js:73; src/design-system.css:98). Nothing else. absent absent status "Waiting for the answer…" (LiveHost.js:578, now visually hidden)
poster (admitted, before Play) The ground in the Current's theme (LiveHost.js:733-740). Centred: h1.live-title in Crimson Pro 400, clamp(1.5rem, 4.5vw, 2.25rem), line-height 1.15, max-width 32 rem, text-wrap: balance, clamped to three lines (the full title in aria-label); 24 px below it one 64 px glass circle with a 26 px play glyph (button.live-start). No eyebrow, no gradient, no caption, no Settings. 64 px, aria-label="Play"; press, Enter or Space starts the reading; nothing starts by itself (composer-decision :11) absent region "RISE"; status "Answer ready."; button "Play"
starting The poster with the object disabled; the ground stays until the Chamber's field paints its first frame (the director fades a field in over 320 ms, src/visuals/visual-field-director.js:63-68); then the stage: field, empty band, both objects. disabled, aria-label="Starting" appears with the stage status "Starting…"
playing The field moves behind the band; one sentence at a time; the 1 px progress hairline (Chamber.css:480-488). Both objects at full opacity (no idle dimming: WCAG 1.4.11 asks 3:1 for components, and "Avoid fixed heights or hover-only actions", https://developers.openai.com/plugins/deploy/troubleshooting). On a device with no voice a 14 px slashed-speaker glyph sits at the object's top-right (data-voice="none"). pause glyph; press calls runtime.interrupt() (runtime.js:402-412) opens the sheet without pausing status "Playing." with a browser voice; "Reading silently, paced as if spoken." with the paced voice (controls.js:27 wording kept); "Reading. The voice stopped; the reading carries on at its own pace." when the voice is lost (controls.js:17-18); while silent, each committed sentence in rise-stage__said
paused The field holds its frame; after a resize it repaints (src/visuals/attractor.js:346-347, #420). The sentence stays. play glyph; press calls runtime.resume() (runtime.js:415-419) works while paused; Intensity applies in one repaint (attractor.js:627-631) status "Paused." ; button "Play"
ended On the Player's complete (runtime.js:254-259 sets ended) the field holds its last frame (director.pause(), visual-field-director.js:130-134; under chrome: 'none' the Chamber's fade and closing screen never run, §7) and the sentence stays. No new element. a replay arrow, not the play glyph (amended after the LIVE-004 witness: with the same triangle as a pause, the end could not be seen); aria-label="Play again"; press stops the finished runtime, builds a new one from the admitted events and starts it (runtime.start throws ALREADY_STARTED on a used runtime, runtime.js:372; LiveHost.beginEmbedded re-entered with embeddedBeginStarted reset, the duplicate-click test kept). The end never goes through LiveHost.ended(), which closes the port (LiveHost.js:830-846), so teardown is still heard. stays status "Finished." (controls.test.js:195 wording) or "Finished reading what arrived. The answer stopped early: …" (controls.js:39-41)
failed The last frame stays. Over it one sentence in the existing error style (src/live/host/LiveHost.css:162-168), role="alert": before Play the sentences LiveHost already says ("Ask the assistant again. The Current was refused: …", "Ask the assistant again. No Current arrived in time.", "The assistant cancelled this answer.", "Could not start: …", LiveHost.js:590,613,638,654,702,788, all pinned by LiveHost.test.js); after Play, "It could not be answered: …" (controls.js:44). hidden hidden the alert once ("Errors, including unexpected ones, must be handled with clear messaging or fallback behaviors", https://developers.openai.com/plugins/plugin-guidelines)
speech unavailable As playing or paused plus the glyph on the object. No sentence. Set when buildVoices falls back to the paced voice (LiveHost.js:463-481). aria-label="Pause (silent, no voice is installed)" / "Play (silent, no voice is installed)" unchanged status "Reading silently, paced as if spoken. No voice is installed for this browser." or "… This browser cannot speak." (src/live/capabilities.js:77-81 wording); the committed-sentence region is live. LIVE-002's "visible speech-unavailable state" is the glyph
reduced motion (system preference, or the Still switch) The fields hold one frame (attractor.js:625; src/visuals/klee-field.js:112-116), words arrive whole (Chamber.js:3387-3389), every stage transition is 0 ms, the sheet appears without motion. No new element. unchanged the Still switch shows on; disabled while the system asks for it status gains "Imagery stays still." (capabilities.js:90 wording); the switch's hidden description "Your system asks for reduced motion."
stopped / closed Nothing new: on ui/resource-teardown the port answers first (src/live/hosts/mcp-port.js:140-143; spec: "Host SHOULD wait for a response before tearing down the resource"), then the runtime stops, the stage is destroyed and the Chamber dismissed (LiveHost.js:592-599). There is no Stop button and no "Stopped." card. gone gone status "Finished." (LiveHost.test.js:1081)

Keyboard: Tab reaches the two objects and, when open, the sheet; Enter and Space activate the focused object; after Play, focus stays on the same element, which is now Pause, so Space toggles without a global shortcut. No document-level key handler: the Chamber's Space handler pauses the Player outside the runtime (Chamber.js:1433-1435, 3451) and leaves the status wrong, and it goes with the chrome (§7). Every target is at least 44 px; focus ring 2 px in var(--color-light) at 2 px offset (LiveHost.css:97-105 pattern).


3. The Settings control

The sheet: section#rise-settings, role="dialog", aria-modal="false", aria-label="Settings", positioned inside the stage root (never the host's page), anchored 8 px above the Settings object at the right, width min(320px, 100% - 24px); glass like the text tile (rgba(10,10,12,0.36), blur 16 px, 14 px radius, 1 px border rgba(232,232,236,0.08), Chamber.css:1582-1593); padding 16 px; a 32 px header row with "Settings" and a 44 px close target; four rows of 44 px; about 240 px tall, so it fits a 481 px card with scrollHeight === clientHeight at every width. Labels in var(--font-sans, system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif) ("Always inherit the system font stack"; "Use partner styling … only within content areas, not for structural UI", https://developers.openai.com/plugins/concepts/ui-guidelines), ink from the theme (Q6, docs/product/discussions/2026-10-05-consolidated-reader-decisions.md). Opens in 180 ms, closes in 140 ms, 0 under reduced motion. Focus moves to the first enabled control on open and back to the Settings object on close; Escape, the close target, a press outside, or the Settings object closes it. It does not pause the reading. Hints for a disabled row are hidden <span>s referenced by aria-describedby, never visible text and never a title attribute.

Row Control and range What it drives Applied live Scope
Intensity input[type=range], label "Intensity", min 0.40 max 0.75 step 0.05, initial value the field's target (0.65), aria-valuetext "N percent vivid" where N = round((v − 0.40) / 0.35 × 100) runtime.controlVisual({ surface: 'attractor', parameter: 'intensity', value }) (runtime.js:124-145 → LiveHost.js:396 → Chamber.js:2582-2586 → attractor.js:609-643), bounded by the fixed contract 320 ms ease while playing (attractor.js:635-640); one repaint while paused (:627-631). The value is remembered for the reading and re-sent when the snapshot's segmentId changes, because the director cancels a control at every new cue (visual-field-director.js:36-38; attractor.js:645-656). After each accepted receipt the stage root gets data-intensity="<effective>" so a test reads the value, not a sentence. Disabled, with hidden description "Not on this passage", when runtime.discoverVisual() returns null (still and genesis passages, visual-field-director.js:81-87). this reading
Theme select, label "Theme": "As written" (the Current's own) then Classic, Amethyst, Prism, Ember, Cobalt, Jade, Rose, Citrine, Silver (src/core/jev-color-themes.js:9 order); default "As written" LiveHost.paintEmbedTheme(theme) for the frame's four variables (LiveHost.js:39, 733-740) and the new chamber.setColourTheme(theme) (§7, Reader lane), which sets the same variables on the container as applySessionColors does (Chamber.js:882-900), calls attractorField.setPalette(RISE_CURRENT_THEMES[theme].attractor.palette) on a live filament (attractor.js:762-767), and makes later attractor cues mount with the override's palette (one line in mountVisualFieldCue, Chamber.js:2421-2427) one frame for ground, ink and filament; a genesis passage keeps the Current's preset (klee-field.js:140-141 locks one per episode) and only its ground and ink follow; the attractor keeps its system and form until the next passage this reading. RISE_CURRENT_THEMES is read, never edited
Still imagery role="switch" checkbox, label "Still imagery"; default the system preference or the saved setting chamber.onSettingsChange('reducedMotion', on), wired by the factory (src/app/chamber-session-factory.js:456-459) into app.js:1048-1059 → 1091-1108, which sets the root class reduced-motion at once: the attractor (attractor.js:625), genesis (klee-field.js:112-116) and text arrival (Chamber.js:3387-3389) read the class. On and disabled while prefers-reduced-motion is on (app.js:1104), with hidden description "Your system asks for reduced motion." saved in the frame's own rise-settings (app.js:1054); a per-viewer convenience in ChatGPT's partitioned storage (docs/experiments/EMBED-SAFETY-2026-10-05.md:7), not core state ("Avoid localStorage for core state", https://developers.openai.com/plugins/build/chatgpt-ui). Closes EMBED-SAFETY gap 1 (:68-71)
Text size role="radiogroup", label "Text size", four 44 px chips S M L XL (FONT_SIZE_CHIPS without Fit, src/core/chamber-type-size.js:9-14; intents 0.82 / 1 / 1.18 / 1.36 at :20-25); default M chamber.onSettingsChange('fontSize', size) → app.js:1064-1068 → chamber.applyChamberTypeSize() (Chamber.js:927-935) one frame; Chamber.css:348 and :2093 multiply by --font-size-intent. "Support text resizing without breaking layouts" (ui-guidelines): the band's overflow-wrap: anywhere holds at XL on 320 px (Chamber.css:2099) saved like Still

Which rows wait on B2 (the nine themes reaching every engine): none of the four. Theme uses RISE_CURRENT_THEMES and jevColors, which exist; what B2 adds later (the same vocabulary in the Reader's own chrome) does not change the embed's path. If B2 renames a map, the Theme row reads the new name; the ids are fixed.

LIVE-005's "durable presentation intent versus session preferences", answered for the embed: Still imagery and Text size are the reader's preferences (saved in the frame's storage); Intensity and Theme are session preferences for this reading only, because the Current's theme is the author's choice and the model wrote it.


4. The stage

The field is the Chamber's: attractor or genesis behind the words on the theme's ground, or the plain ground for an all-still Current (rise-current.js:267; Chamber.js:2313-2316). Default intensity 0.65; device pixel ratio at most 1.5 (attractor.js:332); the radial legibility scrim (Chamber.css:1861-1873) stays, it is content.

The text panel is #atom-display as the Reader draws it: Crimson Pro (Chamber.css:343; design-system.css:68), centred, line-height 1.4 (1.45 in the band). Sizes at text size M, from Chamber.css:348 and src/core/recitation.js:83-90 (72 px × 1, 56/72, 40/72, 32/72 for up to 20, 40, 60 and more characters):

Card Layout Type (a 41–60 character sentence) Panel Field padding
390 × 481 ≤ 640 band (Chamber.css:2083-2144) 20.9 px (measured in the baseline) full width, 12 px 16 px, hairlines, no radius 0 + 64 px bottom (embed rule)
560 × 481 ≤ 640 band; 481 escapes the phone-landscape block (:2250, max-height 480) 23.8 px (measured) full width 0 + 64 px bottom
760 × 502 tile (:1582-1593), max-width 80 % (:352) 40 px (measured) 24 px 32 px, 14 px radius 24 px (embed rule, was 48) + 64 px bottom
1280 × 560 (the e2e host) tile 40 px as 760 as 760
fullscreen not declared, not used (§5) — — —

Two embed-only rules, scoped by the root attribute html[data-embed="mcp"] that LiveHost sets in startEmbedded: .chamber-field { padding: 24px 24px 64px } above 640 px and padding-bottom: 64px at or below, so the centred band clears the 44 px objects and a 760 card gives the picture 48 px more width. The same attribute is the hook for the Reader lane to exclude the phone-landscape block (Chamber.css:2250-2281) from the embed later, which would retire the 481 floor. Nothing else in Chamber.css changes.

Margins: the card is edge to edge (prefersBorder: false, worker/mcp-server.mjs:205); the objects sit 16 px from the card's edges (12 px at or below 560 px) plus --safe-* from the host (§5). At 390 × 481 a 200-character sentence makes six lines of 20.9 px × 1.45 plus 24 px padding = 206 px, centred in the 417 px above the objects; its bottom clears them by 60 px.

Material: one glass for the tile and the objects, rgba(10,10,12,0.36), blur 16 px saturate 1.12, 1 px border rgba(232,232,236,0.08). Glyphs are inline SVG, 20 px (26 px on the poster's 64 px circle), 1.75 px stroke, currentColor = var(--color-light), monochrome and outlined ("Use either system icons or custom iconography that fits within ChatGPT's visual world—monochromatic and outlined", ui-guidelines). Hover on hover-capable devices lightens the glass 12 %; pressed scales 0.96 for 80 ms. The poster's radial gradient (LiveHost.css:121-123) and the Begin pill's glow (:153, :159) are deleted ("Avoid custom gradients or patterns that break ChatGPT's minimal look"). Fonts: the words and the title in Crimson Pro (content); every label in the system stack; Instrument Serif and Instrument Sans leave the embed's own CSS (LiveHost.css:14, 19), though fonts.css is still loaded by the shell. Contrast: the nine inks over their grounds and over the glass meet WCAG AA, the guideline's one hard rule.

Motion: passage opacity 150 ms (Chamber.css:354); field crossfade 320 ms (visual-field-director.js:13); Intensity ease 320 ms; the Play object's change of glyph is instant; the sheet 180 ms up, 140 ms down; under reduced motion every duration is 0 and the fields hold one frame. There is no poster field: a canvas per card in a thread and 1.2 s of genesis growth before Play is motion before Play (composer-decision :11); the poster is the ground, the title and the object. First word: within 1.0 s of the press on desktop and 1.5 s on a phone, as a measured target in the baseline render and the witness session, not a CI assertion; the runtime, Chamber and voice modules are already prefetched before the press (LiveHost.js:121-124, 149-157).

Seam labels between passages (‹title› · N, Chamber.css:1988-2028, inferred to render for Current segments) are hidden by an embed-scoped rule; they are chrome.


5. Sizing and the host

Display modes. ui/initialize sends appCapabilities: { availableDisplayModes: ['inline'] } (today {} at src/live/hosts/mcp-port.js:203): "View MUST declare all display modes it supports in appCapabilities.availableDisplayModes during initialization" (apps.mdx). The resource carries _meta['openai/ui'].availableDisplayModes: ['inline']: "Set _meta[openai/ui].availableDisplayModes on the resource contents returned by your server" and "Keep declaring availableDisplayModes during MCP Apps initialization too" (https://developers.openai.com/plugins/reference). Inline only, because "Host MUST NOT switch the View to a display mode that does not appear in its appCapabilities.availableDisplayModes, if set" (apps.mdx) binds the host, and because fullscreen's own rule cannot be met: "The composer is always present in fullscreen, so make sure your experience supports conversational prompts that can trigger tool calls" (ui-guidelines); "Design the experience to work with ChatGPT's composer, which remains available in fullscreen" (chatgpt-ui). A Composer widget takes no later call (composer-decision :24). The inline card's own example is "an audio player" (ui-guidelines), which is what this card is. All three judges rejected the Full screen row; it would also be a fifth row outside the owner's rule. Picture-in-picture is not declared either: OpenAI's own extension spec says ChatGPT supports inline and fullscreen only (apps-sdk-research map, confirmed), and its rules need composer reactions too. Adding fullscreen later is additive.

Host context. LiveHost reads port.hostContext() after connect() and subscribes with port.onHostContext() (both exist, mcp-port.js:211-220; the port merges field by field, "the View SHOULD merge received fields with its current context state", apps.mdx). What each field drives: containerDimensions.height fixed → the page fills it ("Host controls the size. View should fill the available space"); containerDimensions.maxHeight → the cap below; width and maxWidth → nothing, the card is always the frame's width; safeAreaInsets → --safe-top/right/bottom/left on the stage root, added to the objects' and the sheet's insets ("Safe area boundaries in pixels"; the repo's env() insets are 0 in frames, design-system.css:173); styles.variables → --font-sans and --border-radius-md for the sheet, each with a fallback ("Views should set default fallback values", apps.mdx; ChatGPT sends them since 2026-05-28, https://developers.openai.com/plugins/changelog); theme → stored, drives nothing visible (the stage keeps the reading's ground, Q6); displayMode, locale, platform, deviceCapabilities → stored; hover decides only whether the hover lightening applies.

The one height. stageHeight = clamp(round(width × 0.66), 481, 560), then min(stageHeight, containerDimensions.maxHeight) when the host gives one. The 481 floor is a fact, not taste: the Chamber's phone-landscape block applies to any frame at most 900 px wide, at most 480 px tall and wider than tall (Chamber.css:2250) and would set the words at clamp(17px, 3.6vh, 23px) with no --font-size-intent, about 15–17 px, and the Text size row would do nothing. All three judges rejected the alternative clamp(360, 0.75w, 560) for this reason. Results: 390 → 481; 560 → 481; 760 → 502; 1280 → 560. The waiting card, the poster, the reading and the end share the box. Judge 1 noted that "Avoid fixed heights" still bites the waiting and poster states; accepted, because a card that grows at Play would move the conversation under the reader's finger, and the poster's empty ground is the theme's own.

Reporting. ui/notifications/size-changed { height } after the handshake and whenever the computed height changes: a ResizeObserver on the stage root plus every host-context change, coalesced to one animation frame, sent only on a changed value ("The View SHOULD send this notification when rendered content body size changes (e.g. using ResizeObserver API)"; for flexible dimensions hosts "MUST listen for ui/notifications/size-changed … and update the iframe dimensions accordingly", apps.mdx; "Avoid fixed heights", troubleshooting). Width is never reported: the port already drops a non-finite field (mcp-port.js:255-260, pinned by mcp-port.test.js:490-493), the host owns the width, and a once-reported width was the suspected, unverified cause of the right-edge clipping (stage-sizing map). This replaces the fixed 640 sent once (LiveHost.js:36, 603).

Fullscreen. Not requested and not declared. If a host switches the view anyway, the same layout fills containerDimensions and the objects keep clear of safeAreaInsets.bottom. The Chamber's browser Fullscreen button (Chamber.js:1084-1099), which cannot work in the sandbox, leaves with the chrome (§7), and the relay's allow drops fullscreen with it: allow="autoplay" (mcp-relay.js:76). autoplay stays, as the judges asked: speech synthesis and Web Audio start on the press inside RISE's frame, and the delegation is unverified either way.

Phone. 360–430 px wide gives a 481 px portrait card capped by maxHeight; the band layout (Chamber.css:2083-2144); 44 px targets; 12 px insets; no hover-only affordance. Rotation and window changes arrive as a resize and as host-context-changed; the height is re-reported and a paused attractor presents its held frame at the new size (attractor.js:346-347); a paused genesis after resize is not verified (§12). No mobile ChatGPT host has been witnessed (docs/experiments/LIVE-002-HOST-2026-10-05.md:45), and developer mode is web-only (docs/adr/0002-gate-0-realtime-host.md:17), so a phone is witnessed only after a listing; "Plugins must function reliably in ChatGPT on both desktop and mobile" (plugin-guidelines) stays an open gate.


6. The server and the package

Done in #423 (branch plugin-conformance-1, open when this was written), not re-proposed: the typed input schema and outputSchema (worker/mcp-server.mjs:60-141), securitySchemes: [{ type: 'noauth' }] (:144), the "Use this when" description with no "ask about it" (:133), the invoking/invoked strings (:150-151), openai/widgetDescription (:208), no microphone permission (:202-206), a malformed MCP-Protocol-Version header refused with 400 while a newer version is answered in this server's newest (:260-263; a7a9d526), the port's host-context merge and tool-cancelled (mcp-port.js:146-157), a cancel that arrives after the answer is delivered keeps the answer and its Begin (1621bdde), the relay with no microphone (mcp-relay.js:76), /live in run_worker_first with worker/production-config.test.js guarding it. Both judged designs proposed these as new; #423 did them.

What this design changes, each with its rule:

Change Where Rule
_meta['openai/ui'] = { availableDisplayModes: ['inline'] } beside _meta.ui on the resources/read contents mcp-server.mjs:201-209 "Set _meta[openai/ui].availableDisplayModes on the resource contents"; "Lets ChatGPT choose a display mode before loading the component" (reference)
appCapabilities: { availableDisplayModes: ['inline'] } mcp-port.js:203 "View MUST declare all display modes it supports" (apps.mdx)
'openai/toolInvocation/invoked': 'The reading is ready to play' (28 characters) mcp-server.mjs:151 "≤ 64 chars" (reference); "Descriptions must match the tool's schema and actual behavior" (plugin-guidelines): Begin is now Play
widgetDescription and the description's "presses Begin" become "presses Play" mcp-server.mjs:133, 208 same
relay allow="autoplay" mcp-relay.js:76 nothing in the embed calls the Fullscreen API once the chrome is off; "Apps SHOULD NOT assume permissions are granted" (apps.mdx)

Not changed, and why: APP_URI stays ui://rise/current: "When you make a breaking change to the HTML, JavaScript, or CSS, publish a new URI" (chatgpt-ui), and an allow attribute and a metadata key are not breaking; ChatGPT may serve cached contents for up to an hour (app-review), which developer-mode Refresh covers. The flat _meta['ui/resourceUri'] stays until a host session shows ui.resourceUri alone is read (LIVE-002 cannot tell which key ChatGPT used). _meta.ui.domain is set only for a public listing (owner question 1): the reference says only "required when submitting a plugin with UI; must be unique per plugin. Defaults to https://web-sandbox.oaiusercontent.com", and what value changes the sandbox origin, and what that does to frame-ancestors * (mcp-server.mjs:297), is unverified. The csp (frameDomains: [origin], empty connect and resource domains) and prefersBorder: false stay ("Specifying an explicit value for this is recommended", apps.mdx). The relay stays: every message this design uses (initialize with hostContext, host-context-changed, size-changed, tool-result, tool-cancelled, resource-teardown, ping) is on the standard bridge, which the relay forwards unchanged (mcp-relay.js:33-42), and "use window.openai only for capabilities that the shared specification does not cover" (chatgpt-ui); the window.openai extras it hides (widgetState, requestClose, setOpenInAppUrl) serve nothing in scope. The admission path, CURRENT_GUIDE, handleLive, handleMcp's transport and wrangler.production.jsonc (MCP_ENABLED stays "false" until the owner switches it on) are untouched. Rejected by the judges as outside this change: the connect-src rewrite in handleLive, structured refusal logs, the .well-known challenge route, the privacy rewrite, the wrangler.demo.jsonc edit.

Needed for a public listing only (owner question 1): _meta.ui.domain; the /.well-known/openai-apps-challenge token served as text/plain (the SPA fallback would answer with index.html, wrangler.production.jsonc:14); a privacy policy naming ChatGPT and MCP (public/privacy.html names neither); the written iframe justification ("You must still provide a justification when submitting a plugin that uses iframes", plugin-guidelines; draft: the widget frames https://rise.syberlabs.io/live?embed=mcp, RISE's own reading page on the MCP server's own domain, because the reading is a full web application that cannot run under the sandbox policy; the page receives only the tool result through the MCP Apps bridge and makes no network requests of its own); the plugin ZIP, five positive and three negative test prompts, a demo video, four HTTPS listing URLs, and a listing name that survives "Avoid overly generic names, especially single-word dictionary terms" (plugin-guidelines). Developer mode needs none of these.


7. What is removed, and where each old requirement now lives

Removed Was Requirement, and where it lives now
Status sentence controls.js:71, 265; describeStatus :15-50 "every state has a plain sentence" (controls.test.js:7): the hidden rise-stage__status. The new module imports describeStatus for the kept sentences rather than copying them; "Held where you are…" becomes "Paused." and "Asking…" becomes "Starting…" through the dive: false branch
Interrupt / Resume controls.js:73, 272-274, 459-465 the Play/Pause object; same runtime calls (runtime.js:402-419); decision point 5 (composer-decision :13)
Stop and the "Stopped." card controls.js:76, 494; LiveHost.js:810-827 none: Pause holds the voice at once; teardown cancels everything; LIVE-005 :14 reworded
Microphone privacy note, "Listen for a visual change", the microphone line controls.js:86-87, 94, 373-377; src/live/mic/listener.js:31-33 none: out of scope (CONSOLIDATED-READER.md:579; chatgpt-demo spec :9); "No duplicate inputs. Don't replicate ChatGPT features in a card" (ui-guidelines). The embed passes no mic and skips prefetchMic (LiveHost.js:124, 775, 780)
Replay notice "Reopening starts this reading from the beginning" LiveHost.js:37, 780, 826, 845; controls.js:126-132 the poster: a reload shows the title over Play; the end shows Play again. chatgpt-demo spec :17 superseded
Typed Visual change box, "Change visual", the refusal sentences controls.js:88-96, 400-457; src/live/visual-control.js the Intensity row, same command and bounds. The compound-refusal case ends; visual-control.js and its test stay for the standalone page
About this passage controls.js:97-109, 206-252; src/live/host/passage.js none: a sealed Current carries no condition and no sources (src/live/adapters/current-events.js:8-9), so it only ever showed its two empty lines
Transcript controls.js:110-113, 185-196; LiveHost.css:234-235 the hidden whole-reading list and the committed-sentence region
Device notes controls.js:114-125; capabilities.js:74-96 no voice → the glyph plus the status; reduced motion → the Still switch plus the status; no canvas → the status says "without imagery"; the WebGL2 and speech-recognition notes have no Composer meaning and go
Error line controls.js:78, 170-174 the single failure sentence, role="alert"
"ANSWER READY." eyebrow, the gradient, the pill's glow LiveHost.css:121-123, 135-140, 153, 159 the hidden status "Answer ready."; the glow has no requirement
Begin's "Starting…" text LiveHost.js:763-767 the disabled object plus the hidden status
The fixed #live-controls bar: position: fixed, 32dvh cap, inner scroll; the poster's scroll; the transcript's scroll LiveHost.css:128-133, 177-201, 235 none: "No nested scrolling"
Instrument Serif on the title, Instrument Sans on the chrome LiveHost.css:14, 19, 200 Crimson Pro for content; the system stack for labels
The Chamber's chrome under a host-played reading: the hover bar (Chamber.js:601-726), the key handlers (:1414-1473, bound at :4737-4738), Escape's exit dialog (:4514-4561), the closing screen with "Write a reflection" and the Workshop route (:730-795, 4590-4630), the Settings overlay, "Look under this passage", Page view, the browser Fullscreen button, Kaleidoscope Reader-lane shared code one new option chrome: 'none', passed by chamber-session-factory.js:444-450 for live sessions (live !== null), builds none of them, binds no key handler in activate(), and in onSessionComplete() calls this._visualFieldDirector?.pause() and nothing else. Plus setColourTheme(theme) (§3) and the palette override in mountVisualFieldCue. The standalone /live page inherits the same loss of the Chamber bar; its browser suites (e2e/live.spec.js, live-control, live-voice, live-openai, live-gemini) do not use it (inferred from their selectors, not run)

8. Contracts and tests

Fixed contracts untouched: see §1. The A0 snapshots (FND-006) of JEV_COLOR_THEMES, RISE_CURRENT_THEMES, RISE_CURRENT_VISUALS, ATTRACTOR_VISUAL_MANIFEST hold; the rows read them. src/live/visual-control.test.js untouched (the grammar is unused by the embed). src/core/current.test.js:45-64 untouched: Play again rebuilds through createSessionPlayer.

Every test that changes, by path (line numbers at d9b9b06a):

e2e/live-mcp.spec.js

  • :90 the begin helper: button "Play"; every "Begin" count-0 assertion (:181, :267, :533, :543, :567) becomes "Play" count 0.
  • :188 height 640 → 560 at the 1280 px host page; add: narrowing the page to 760 produces a second report with 502.
  • :193, :230, :255, :472, :510, :558, :560 .live-controls__status → .rise-stage__status (the regexes stay).
  • :195-199, :232-238, :302-316, :391-392, :515-516, :557-559 Interrupt/Resume → the one object named "Pause" then "Play"; "Held where you are" → "Paused.".
  • :226-228, :362-365, :502-508 notes → [data-voice="none"] on the object with the status containing "No voice is installed"; the Still switch aria-checked="true" with the status containing "Imagery stays still."; the colour assertion goes.
  • :239-240, :374-376, :460-461 Stop → removed; :450-464 becomes "while paused the field holds one frame" (picturesOverASecond = 1); the teardown test keeps its no-drawing assertion.
  • :284, :292, :587 replay notice → removed; the reload test keeps poster-again and no-autoplay; :587 expects "Finished." in .live-embed.
  • :307-310, :517-526 typed box → open Settings, set the range to 0.55, assert data-intensity="0.55" on the stage root and the same held sentence.
  • :466-475 closing screen → after "Finished." the field holds one frame, #chamber-display stays visible, button "Play again" is shown.
  • :490-527 short frame → at 900, 600 and 420 wide (host height 481) both objects toBeInViewport({ ratio: 1 }), the sheet opens with scrollHeight === clientHeight, Intensity works at 600 and 420; the "panel scrolls > 0" assertion is inverted.
  • :548-562 no Dive → the form no longer exists; keep "no button named /Dive|Look under/" and no sampling; add "no Stop, no Interrupt".
  • :183, :376, :586, :615 #live-controls count 0 → #rise-stage-controls count 0.
  • :143-175 CJK poster → the poster does not scroll (scrollHeight === clientHeight) and "Play" is fully in viewport at 320 and 375 px.
  • :647-674 themed poster → the body-ground, --reading-scrim and filament assertions stay; the placeholder and the three quiet selectors become the sheet's label colour (the theme ink).
  • :385-399 resize while held → Interrupt becomes Pause; add the second size-changed.
  • New: screenshots test-results/live-mcp-stage-{390,560,760}.png, held with the sheet open, as :229 already does for the silent case.

e2e/live-red-team.spec.js :112 "Begin" → "Play"; :122-125 no form, Pause/Play. :65 is the harness frame's own allow; untouched.

src/live/host/LiveHost.test.js

  • :718 innerWidth: 390 → the first size-changed carries 481; add: hostContext.containerDimensions.maxHeight: 400 caps it at 400; a host-context-changed with a new maxHeight re-reports once.
  • :753, :801-808, :842, :859, :881, :914, :943, :960, :982, :1014, :1034, :1046-1048, :1061, :1078, :1149, :1240, :1291, :1373, :1383 .live-start textContent 'Begin' → aria-label 'Play'; .disabled flags stay.
  • :764 onward (the ~25 waits for size-changed as the connected signal): hold.
  • :865-903 notes li, [data-capability=speechOutput], "paced as if" once → the Still switch checked, the data-voice badge, the status saying "paced as if spoken" once.
  • :1377-1387 poster children → ['P','live-embed','Answer ready.'] (visually hidden, role="status" stays), ['H1','live-title',title], ['BUTTON','live-start',''] with aria-label 'Play' and type="button".
  • :758-774 (error-only result re-arms the wait), :776-791 (cancel), :1071-1085 ("Finished." on teardown), :1396-1408 (theme variables): unchanged. The refusal timer is not changed by this design.

src/live/host/controls.test.js: unchanged, because controls.js stays for the standalone page. :183-198 ("offers no Dive in a Composer presentation") describes an option the embed no longer passes and is deleted in the same PR as the stage module.

New src/live/host/stage-controls.test.js: the object's accessible name per runtime status (Play, Pause, Play again, the silent names); the hidden status sentence per status; the whole-reading list holds every sentence at mount; the committed-sentence region is polite only while silent; the sheet's four rows and what each calls (a fake runtime records controlVisual; a fake chamber records setColourTheme and onSettingsChange); Intensity disabled with its description when discoverVisual() is null; Intensity re-sent when segmentId changes; data-intensity stamped after an accepted receipt; focus moves in and back; Escape closes; no element in the stage has overflow other than visible; destroy twice is fine.

worker/mcp-server.test.js :189-190 the invoked string; :393 _meta.ui toEqual holds (the new key sits beside it); add _meta['openai/ui']).toEqual({ availableDisplayModes: ['inline'] }); :394 and :176 the Begin regexes become Play.

src/live/hosts/mcp-port.test.js :351 add appCapabilities: { availableDisplayModes: ['inline'] } to the toMatchObject; :490-493 unchanged.

src/live/hosts/mcp-relay.test.js :101-103 allow="autoplay".

Reader lane: a Chamber unit test that new Chamber(container, { chrome: 'none', hostPlays: true, … }) builds no #chamber-controls, no #chamber-post, binds no keydown in activate(), and on complete pauses the director and leaves #chamber-display shown; that setColourTheme('jade') sets --color-void #061912 on the container and calls setPalette('jade') on a mounted attractor; a chamber-session-factory test that a live session passes chrome: 'none'. LiveHost.test.js:641 already mounts with hostPlays: true.

Docs: CONSOLIDATED-READER.md:591, :604 amended with a pointer to this record; chatgpt-demo spec :17; docs/plans/LIVE-MCP.md:81 ("asks its host for 640 px of height once"); LIVE-005.json:14; EMBED-SAFETY-2026-10-05.md:68-71 gap 1 closed.


9. Build plan

Ordered pull requests, each small, tests written first, one owner each. The owner's PC runs only the single unit file a PR touches; every browser suite and the full unit suite run on CI. Each PR ends with the AGENTS.md §5 check.

PR 0. The decision record (Mateo). This document under docs/product/discussions/, with the four doc edits in §8. No code. Verification: node scripts/ci-hygiene.mjs and the wiki link check on CI. Done: the pull request that adds this file carries the four edits and the tracker rows.

PR 1. Declare inline (Mateo). mcp-port.js:203 appCapabilities; mcp-server.mjs:201-209 _meta['openai/ui']. Tests first: mcp-port.test.js:351; mcp-server.test.js new assertion. Verification: those two unit files locally; CI. Independent of everything else.

PR 2. The Chamber under a host (Seth's lane, or Mateo with Seth's review; owner question 2). chrome: 'none'; setColourTheme(theme); the palette override in mountVisualFieldCue; onSessionComplete under chrome: 'none'; the factory passes it for live sessions. Tests first: the Chamber and factory unit tests in §8; e2e/live-mcp.spec.js:466-475 updated to "the display stays visible". Verification: the two unit files locally; live-mcp and the Chamber suites on CI. The hidden status still reads "Finished." through controls.js until PR 4.

PR 3. Fit the frame (Mateo). LiveHost: read hostContext, subscribe to changes, the height formula, the ResizeObserver report, data-embed="mcp" on the root, --safe-* and --font-sans variables, the two embed-scoped field rules. Tests first: LiveHost.test.js:718 and the two new cases; e2e/live-mcp.spec.js:188 and the new second-report assertion in :385-399. Verification: LiveHost.test.js locally; live-mcp on CI with the three screenshots at 390, 560 and 760 (poster and held) as artifacts. This is the render that checks §4's numbers; if the words at 560 × 481 are not 23.8 px, the floor moves before PR 4.

PR 4. The stage: two objects, hidden ARIA, Intensity (Mateo). Depends on 2 and 3. New src/live/host/stage-controls.js and its CSS in LiveHost.css; the poster becomes title plus Play; Play/Pause; the end state with Play again; failure; the hidden status, whole-reading list and committed-sentence region; the Settings sheet with the Intensity row only; the embed stops passing mic, notice, notes, dive; the relay's allow="autoplay"; the invoked string, description and widgetDescription say Play. Tests first: stage-controls.test.js; the LiveHost.test.js changes; live-mcp and live-red-team changes; mcp-relay.test.js:103; mcp-server.test.js:176, :189-190, :394; delete controls.test.js:183-198. Verification: the four unit files locally, one at a time; live-mcp, live-red-team and the gate on CI; the three screenshots again, held with the sheet open.

PR 5. The other three rows (Mateo). Depends on 4. Theme, Still imagery, Text size in the sheet; the themed-poster e2e (:647-674) and the reduced-motion e2e (:340-378) updated; stage-controls.test.js extended. Verification: as PR 4.

PR 6. The witness (Mateo, by hand). Deploy the demo Worker from the exact candidate as the LIVE-002 runbook did (docs/plans/CHATGPT-DEMO.md:13-19), connect in developer mode, run §10, record docs/experiments/EMBED-STAGE-HOST-<date>.md, make the Worker inert. Then LIVE-004 is witnessed on the same candidate (or again, per CONSOLIDATED-READER.md:648).

The sequence in ten lines: 0 record the decision; 1 declare inline in both places; 2 switch the Chamber's chrome off under a host and add the colour hook; 3 fit the frame and render the three widths; 4 replace the bar with Play/Pause, Settings (Intensity) and the hidden ARIA; 5 add Theme, Still and Text size; 6 witness in ChatGPT and log the host; then LIVE-004; then switch production on; then, if listing, the listing work of §6.


10. Witness

One ChatGPT developer-mode session on the exact candidate after PR 5, desktop web, with the frame's DevTools open on RISE's origin. The port keeps hostContext(); a ?log=host query, test-only, writes these to console.log as JSON, nothing else is instrumented. What to log and what each answer changes:

Log How If … then …
hostContext at ui/initialize: containerDimensions, displayMode, availableDisplayModes, safeAreaInsets, theme, styles.variables keys, platform, deviceCapabilities the initialize result, once containerDimensions.height is fixed the page fills it; the 481 floor matters only if it is under 481 (then the Reader lane scopes the landscape block out by data-embed, first)
containerDimensions.maxHeight and the visible card height after the first report the context and a ruler on the screenshot maxHeight < 481 at 560 wide same fallback; the floor is replaced by the data-embed exclusion
whether size-changed height is applied: report 502 at 760 wide, measure the frame DevTools on the relay's iframe not applied inline height is the host's; the ResizeObserver report stays (harmless) and the layout is checked at whatever height the host gives
whether a reported width would have been applied: send one test size-changed { width: innerWidth − 100 } from the console once, then undo applied the stale-width hypothesis for the LIVE-002 clipping is confirmed; width stays omitted
availableDisplayModes from the host and whether a Full screen affordance appears on the card with ['inline'] declared the context and the card's own chrome the host still offers Expand nothing changes now; a fullscreen layout becomes a later, additive PR
safeAreaInsets in inline; platform the context non-zero bottom inset the objects already add --safe-bottom; confirm visually
prefers-reduced-motion inside the frame with the OS setting on matchMedia in the frame false the Still switch is the only channel; say so in the LIVE-005 record
first word after Play: performance.now() at the press and at the first atom event (LiveHost.js:389) the ?measure=1 log that exists > 1.0 s find where the second went (module load, voice list, Chamber mount) before LIVE-004
the theme ChatGPT reports and whether its light mode makes the card's edge wrong hostContext.theme and a screenshot in light mode the dark card looks cut against a light thread consider prefersBorder: true (owner), nothing else
reload: the poster again, no autoplay; teardown on closing the thread: the status "Finished." as LIVE-002 :23 otherwise a defect, fixed before LIVE-004

11. Owner questions

  1. Public listing or developer mode? LIVE-009 as a directory listing needs _meta.ui.domain, the challenge route, the privacy rewrite, the iframe justification, the ZIP, test prompts, a video, four URLs and a mobile witness; developer mode needs none. The design is the same either way; only §6's second list and the paperwork differ.

    Answer (Mateo, 2026-10-05): developer mode for now. The listing paperwork waits until LIVE-007.

  2. May PR 2 (the Chamber's chrome: 'none', setColourTheme, the palette override) land in the Reader lane now, before LIVE-004 is witnessed, with LIVE-004 then witnessed on the result, as CONSOLIDATED-READER.md:648 allows? Without it the hover bar, Space, K, D, the exit dialog and the closing screen stay inside the widget, and the colour row has no path to the mounted field. Who builds it: Seth, or Mateo with Seth's review.

    Answer (Mateo, 2026-10-05): yes. The Chamber change lands now, built by the coordinator with Seth reviewing, and LIVE-004 is witnessed on the result.

Everything else is decided here: the two objects and their placement, the no-prose poster, the four rows and their live paths, the 481 floor and height-only reports, inline only, the relay kept with allow="autoplay", the metadata, the test changes, and the PR order.


12. Risks

  • Nothing was run. Every pixel size is computed from the measured baseline and the CSS. PR 3's render at 760, 560 and 390 is the first proof, and the plan stops there if the numbers are wrong.
  • ChatGPT's real inline containerDimensions, maxHeight and width were never recorded. A fixed height under 481 on a card wider than tall puts the words at 15–17 px through Chamber.css:2250-2281. The lasting fix is the data-embed exclusion in the Reader lane; the witness session decides whether it is needed now.
  • The Reader-lane change is shared code (Chamber.js, the factory). The standalone /live page loses the Chamber bar with it; its suites are inferred not to use it, not run.
  • Genesis paused and resized. #420 covers the attractor (attractor.js:346-347); KleeField._resize begins a fresh episode (klee-field.js:129-133), and whether a paused composition survives a rotation is not verified. A paused genesis card may go blank until Play.
  • Intensity re-sent at each passage. The director cancels a control on every new cue (visual-field-director.js:36-38), so the reader sees a 320 ms ease from 0.65 to their value at each attractor passage. If that reads as a flicker, the director should keep a reader's control across cues: a Reader-lane change.
  • Colour on an attractor passage changes the palette in place but keeps the filament's system and form until the next passage (setPalette only; setForm exists but is not called). Jade over Classic shows aizawa in jade until the next passage. Honest, and cheaper than a remount that would restart the motion.
  • Play again rebuilds the runtime. runtime.start throws on a used runtime (runtime.js:372); the old runtime is stopped first, embeddedBeginStarted is reset deliberately, and the voice restarts on a fresh press. The end must never call LiveHost.ended(), which closes the port.
  • Chrome's user-activation rule for speechSynthesis: Play and Play again are clicks inside RISE's frame, so activation exists; a host that synthesises clicks, as the LIVE-002 automation did, may not grant it. Unverified.
  • Very long sentences at 390 px: sentence chunking (fixed) can produce a 300-character sentence, nine lines in a 481 px card, clipped by the field's overflow: hidden (Chamber.css:101). The models' guide should keep sentences under about 160 characters; that is Composer-lane prose in current-guide.js, outside this change.
  • Custom fonts. The words and the title stay in Crimson Pro although the guideline says "Don't use custom fonts, even in full screen modes"; the plugin guidelines call these the optional UI guidelines, and the face is the content. A review risk only at public submission; the title can move to the system stack in one line.
  • Settings persistence for Still and Text size rides on the frame's partitioned localStorage; it can be empty in private windows. It is a convenience, not core state.
  • No mobile host witnessed. Developer mode is web-only, so a phone is witnessed only after a listing, while the plugin guidelines require desktop and mobile to work.
  • Test churn is large and must be deliberate (the owner's rule): about twenty LiveHost unit assertions, about twenty live-mcp browser assertions, the worker, relay and port tests, and the new Chamber tests. §8 is the map; none of it was run here.
  • Omitting width from size-changed hedges an unverified mechanism; if ChatGPT needs a width to lay the frame out, the fake host did not show it and the witness session will.

Amended 2026-10-08: the two-control rule in §1 and §2 was ChatGPT's two-action guideline ("Support up to two actions maximum", https://developers.openai.com/plugins/concepts/ui-guidelines), not a rule of RISE's own. The Claude card now carries the transport bar (PLY-001, docs/product/tasks/PLY-001.json): back a passage, Play/Pause, forward a passage, say this passage again, the pace, full screen where the host offers it, and Settings, under a line of one tick per passage; each moves the voice first and the words follow it. createStageControls({ transport: 'minimal' }) keeps the two-object form recorded above for the ChatGPT edition, which will pass it; nothing passes it yet. The app now declares inline, fullscreen and pip at ui/initialize; the resource's _meta['openai/ui'] stays inline only. Everything else in this record stands.

Amended 2026-10-09: the Claude card plays beds under the voice (SND-001, docs/product/tasks/SND-001.json). A beat's sound starts on the app's audio engine, ducked under the browser's voice and levelled to one band with the rest of the catalogue (docs/evals/sound/SOUND-LEVELS.md). The Settings sheet gains a fifth row, Sound, a switch on by default and remembered with Still imagery and Text size; off silences the reading's sound and on restores it where the reading is. Everything else in this record stands.

Amended 2026-10-09: the card's Sign in moves off the reading (phone card pass 1, PLY-001). The rise-account-control pill was mounted in every card since the account entrance arrived (#564, #574), over the reading at the top right, contrary to §2; it is no longer mounted in a host card. Sign in is in the Settings sheet's head, beside its title, where it costs the sheet no height, a link the app asks its host to open outside the card (ui/open-link), so the sign-in page never replaces the reading in the chat. Everything else in this record stands.

Amended 2026-10-09: the composition on small cards (phone card pass 3, PLY-001). §4's one stage, the picture with the words over it, holds for ambient looks. When a figure or a generated scene is on the stage of a card 640 px wide or less, the stage is stacked instead: the picture above, a strip kept for three lines of the words, then the bar's room, so the words never cover a diagram's labels. The strip and the picture are sized once per card, never while the reading plays, and the words in it cannot be moved. A card under 350 px tall keeps the overlay with the words at the picture's foot. Everything else in this record stands.

Amended 2026-10-09: the words on a stacked card can be moved (PLY-001, after the owner's iPhone report "the tap to move text isn't working"). The amendment above said the words in the strip cannot be moved; that is reversed. The strip is where the words rest, not a cage: a tap selects them, a drag lifts them over the picture as far as 8 px under the field's top edge, and a drag down returns them to the strip and stops there. A caption moves with them. The lift is kept for the reading and not saved, so every picture reading starts with its words in the strip; the picture's box never changes for a drag. No visible hint teaches the gesture: §1 and §2 keep visible prose off the card. Everything else in this record stands.


Published from docs/product/discussions/2026-10-05-embed-stage-decision.md. Edits made here are overwritten on the next push to main.

Start here

Release

Pilot

The Archive - texts and their editing

Authoring - scores, rooms, and rendering

Reading surfaces

Imagery - what may be shown, and on whose authority

The Chapel

Direction and unbuilt work

Affect and the Emotions room

Reading decision service and retired gate

Product direction and team tracking


Documentation index · Repository

Clone this wiki locally