From 8133c08fcd84e9aa88c5a60f51b2e2098a51d8c7 Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Tue, 8 Sep 2026 12:10:10 +0200 Subject: [PATCH 1/5] docs(rfc): add Subscription Typed Interrupt Payload --- .../subscription-typed-interrupt-payload.md | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 docs/rfcs/subscription-typed-interrupt-payload.md diff --git a/docs/rfcs/subscription-typed-interrupt-payload.md b/docs/rfcs/subscription-typed-interrupt-payload.md new file mode 100644 index 000000000..82c192e52 --- /dev/null +++ b/docs/rfcs/subscription-typed-interrupt-payload.md @@ -0,0 +1,72 @@ +--- +title: "Subscription Typed Interrupt Payload" +owner: "@johnthecat" +status: draft +--- + +# RFC — Subscription Typed Interrupt Payload + +## Summary + +Every subscription names its interrupt type, and its stream can end with a value of that type at any point. The +`_interrupt` frame carries the value; a normal end is an empty frame, which the client delivers as `complete`. +`Subscription` replaces both `Subscription` and `Result, CallError>`, +because a start-time failure is an interrupt with no items before it. + +```ts +truapi.theme.subscribe({ + next: (theme) => apply(theme), + error: (error) => console.warn("theme stream failed:", error.reason), + complete: () => {}, +}); +``` + +## Motivation + +A live subscription cannot fail. `Subscription` yields items only, so when the platform stream behind +`theme.subscribe` errors, the runtime drops the error and the product's theme freezes on its last value. A chain-head +follow whose connection drops ends with the same frame as one that finished cleanly. + +The one place a typed reason exists, it is inconsistent. Methods declared `Result, CallError>` send +a typed `_interrupt` when the start fails, and the generated TS client surfaces it as +`error(SubscriptionError { reason })`. Plain methods send an empty `_interrupt` for the same failure, and the client +reports `complete`, so a host that does not support the method looks like a stream that ended normally. When a +result-kind stream ends normally, the empty frame is fed to the typed decoder, which throws. + +Host-initiated methods need the same in the other direction. A product that streams items to the host has no way to say +that it is done, or why it stopped, other than ending the stream or declining with a bare `[0]` byte that the host sees +as `GenericError { reason: "product interrupted the host-initiated subscription" }`. A stream that finishes with an +outcome has to encode that outcome as an error or lose it. + +## Approach + +`_interrupt` with a payload carries the method's interrupt value, encoded as declared and without further wrapping. +`_interrupt` with an empty payload is a normal end, which the TS client delivers as `complete`. `_stop` is unchanged. + +`Subscription` is a stream whose items are `Result`; the first `Err` is terminal. The +dispatcher encodes it as the `_interrupt` payload and drops the rest of the stream. A stream that ends without an `Err` +produces the empty frame. A method chooses the shape of `Interrupt`: `CallError` for a failure-only end, or +`Result<(), CallError>` when a normal end carries meaning of its own. Methods with a domain error declare +`CallError`, so their interrupt bytes are unchanged. Methods without one declare `CallError`, which is +what their platform streams yield, so the runtime forwards the platform error instead of dropping it. +`Subscription::interrupted(value)` is a stream that ends with the given interrupt and replaces `Subscription::empty()`. + +Codegen emits `ObservableLike`, `SubscriptionError` and an interrupt decoder for every +subscription; the decoder treats an empty payload as `complete`. A product that reads `error.reason` gets the declared +interrupt value. + +A product handler for a host-initiated method returns `Subscription`. When its observable errors with +the method's interrupt value, the client encodes the value into the `_interrupt` frame, the server preserves the bytes, +and the host's stream ends with `Err(value)`. A product-side stream whose end carries an outcome declares it as the +interrupt type instead of encoding it as an error. + +The frame format is unchanged, so there is no protocol version bump. A client without a decoder for the interrupt type +delivers any interrupt as `complete`, and a host that sends an empty frame on failure is read as `complete`. + +## Trade-offs + +- Every subscription implementer changes its return type, on all hosts, in one pass. +- A normal end is an empty `_interrupt` frame, not silence, so a product can distinguish a finished stream from a + waiting one. +- `CallError` carries a string, not a discriminated enum. A method that needs a richer reason declares its + own interrupt type. From be6c82057c86c28b6360dbd7f0da1a8da9175cdf Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Tue, 8 Sep 2026 12:10:10 +0200 Subject: [PATCH 2/5] docs(rfc): add Worker Lifecycle --- docs/rfcs/worker-lifecycle.md | 82 +++++++++++++++++++++++++++++++++++ 1 file changed, 82 insertions(+) create mode 100644 docs/rfcs/worker-lifecycle.md diff --git a/docs/rfcs/worker-lifecycle.md b/docs/rfcs/worker-lifecycle.md new file mode 100644 index 000000000..d6b5e640e --- /dev/null +++ b/docs/rfcs/worker-lifecycle.md @@ -0,0 +1,82 @@ +--- +title: "Worker Lifecycle" +owner: "@johnthecat" +status: draft +--- + +# RFC — Worker Lifecycle + +## Summary + +A product has one worker. The host runs it while a reference to it is held and may stop it when none is. Modality work +takes a reference for as long as it is on screen or in flight. Acknowledging a product grants one short run so the +worker can set up before anything references it. + +## Motivation + +The [Product Manifest Format](product-manifest.md) defines the worker as the product's single background process and +does not say when it runs. A worker that runs for the life of the host is a signing-capable process per product running +unobserved. A worker that runs only with the product's app view cannot serve a chat room while the view is closed. Every +modality that calls a worker needs the same rule. + +### Requirements + +1. **Single.** A product has one worker process, however many modalities call it. +2. **Demand-driven.** The worker runs only while the host has work that only the worker can do. +3. **Stoppable.** The host may stop an unreferenced worker at any time and the product keeps working. +4. **Setup.** An acknowledged product gets a run before anything calls its worker. +5. **Additive.** No wire change; the host starts and stops the worker executable. + +## Approach + +The host keeps one reference count per product worker. The first reference starts the worker. When the count returns to +zero the host may stop it. Products do not ask for a worker to run. + +The design has three parts: + +- **References**: what holds the worker. +- **Acknowledgement grant**: the one run without a reference. +- **Product rules**: what a worker must tolerate. + +### References + +A reference is held for exactly as long as its work is on screen or in flight. Several references of one product hold +one worker. A modality names its holders in its own RFC. App and Widget executables hold no reference; their lifetime is +their screen. + +Reference holders, by modality: + +| Holder | Held while | +| ------- | ----------------------------------------------------------------------------------------------------- | +| Chat | A room the product serves is on screen, or a message addressed to the product is in flight. | +| Pocket | An artifact the product contributed is on screen. | +| Funding | The product is the selected provider of a funding flow, from selection until the flow settles. | +| Input | An input surface is open, or a `Custom` candidate is on screen ([Input Modality](input-modality.md)). | + +### Acknowledgement grant + +When the host acknowledges a product, it takes a time-limited reference on the worker. Acknowledgement may be a pin, an +addition to widgets or pocket, or the adoption of a new deployment of an acknowledged product. Nothing calls the worker +during the grant. The window is host policy, and the host releases the reference whether or not the worker is done, so +setup is idempotent and resumes on the next start. + +### Product rules + +- A worker tolerates being stopped whenever nothing references it, including mid-setup and while its output is on + screen. State that must survive goes through host storage. +- A worker does not read being started as user intent. +- A worker does no background work of its own. Proactive wakeups are the host's to schedule and are a separate RFC. + +Nothing crosses the wire. The worker learns it is needed by receiving a modality call. + +## Trade-offs + +- Setup that outlives the grant completes on a later start. +- A worker may start and stop several times in one pocket scroll. The host may keep an unreferenced worker warm; the + product may not rely on it. +- Considered and dropped: an always-on worker, a start per call without counting, one worker per modality. + +## Open questions + +- Whether a worker is told what started it, a grant or a reference. Nothing distinguishes the two from inside the + worker. From 18b6a6ac82f29e64de939f77baae6d901132756e Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Tue, 8 Sep 2026 12:10:10 +0200 Subject: [PATCH 3/5] docs(rfc): add Unified Renderer --- docs/rfcs/unified-renderer.md | 465 ++++++++++++++++++++++++++++++++++ 1 file changed, 465 insertions(+) create mode 100644 docs/rfcs/unified-renderer.md diff --git a/docs/rfcs/unified-renderer.md b/docs/rfcs/unified-renderer.md new file mode 100644 index 000000000..e3fdd7e31 --- /dev/null +++ b/docs/rfcs/unified-renderer.md @@ -0,0 +1,465 @@ +--- +title: "Unified Renderer" +owner: "@johnthecat" +status: draft +--- + +# RFC — Unified Renderer + +## Summary + +One `Renderer` service draws every body a product renders itself and reports the presses inside it: a host-initiated +`render` stream and a product-side `action_subscribe`, each carrying a `RenderContext` that says where the body lives. +The body is a `RendererNode` tree over a closed vocabulary. `Chat::custom_message_render` and the renderer-tree half of +`ChatActionPayload::ActionTriggered` fold into it. The input modality uses it for product-drawn candidates, the pocket +modality for card faces, and any later surface adds a context variant instead of a rendering pair. + +## Motivation + +The only product-drawn body is a chat message with `ChatMessageContent::Custom`, and drawing it is a pair of chat +methods. The host calls `Chat::custom_message_render` with `message_id`, `message_type` and the stored payload; a press +on a `Button` node in the returned tree comes back on `Chat::action_subscribe` as +`ActionTriggered { message_id, action_id, payload }`. Both are chat-shaped: the request is keyed by message, and the +press item does not say whether the button was one the host drew for an `Actions` message or one the product drew in a +tree. + +The input modality needs the same thing for a candidate the product draws, and the pocket modality for a card face. +Copying the pair per surface gives a product one render callback and one action stream per surface, at four wire ids +each. + +## Requirements + +- **Surface-neutral.** A body is drawn and its presses reported the same way on every surface. +- **Closed.** A product names layouts and tokens from a fixed vocabulary, never markup, stylesheets or URLs. +- **Correlated.** A press carries enough for the product to find the body and the handler without a registry. +- **Live.** A product redraws a displayed body in place, and no stream is open for a body off screen. +- **Framed.** The host draws identity, bounds and dismissal around every body and interprets the tree itself. + +## Approach + +The design has three parts: + +- The `Renderer` service and the `RenderContext` that binds its two methods. +- The `RendererNode` tree. +- The action pipeline from a rendered node to the product. + +### Service + +`Renderer` is a worker service beside `Chat` in the canonical `truapi` crate, at `api/renderer.rs`. Its types live in +`v01::renderer` and are re-exported through `truapi::latest`. + +```rust +/// Where a product-rendered body lives, and the id that names it there. +pub enum RenderContext { + /// A message in a chat room. + ChatMessage { + /// Room the message was posted in. + room_id: String, + /// Message id, as returned by `Chat::post_message`. + message_id: String, + /// Product-defined discriminator, as stored in `ChatCustomMessage::message_type`. + message_type: String, + }, + /// A candidate answered to an input query. + InputWidget { + /// Candidate id, as the product answered it. + candidate_id: String, + }, + /// A card face in the host's Pocket collection. + PocketCard { + /// Card id, as declared in the product's worker manifest. + card_id: String, + }, +} + +/// A body the host needs drawn. +pub struct ProductRendererRenderRequest { + /// Where the body lives. + pub context: RenderContext, + /// Product-defined payload, opaque to the host. + pub payload: Vec, +} + +/// An action triggered inside a product-rendered body. +pub struct HostRendererActionSubscribeItem { + /// Where the body lives. + pub context: RenderContext, + /// Remote actor who triggered it. Absent when the local user did. + pub peer: Option, + /// Which action was triggered, as named in the renderer tree. + pub action_id: String, + /// Data the node attached to the action. Absent for a `Button` press. + pub payload: Option>, +} +``` + +```rust +/// Product-rendered bodies and the actions triggered inside them. +pub trait Renderer: Send + Sync { + /// Streams renderer trees for one product-rendered body. Each item + /// replaces the previous tree. The stream stays open while the body is + /// displayed so the product can redraw in place. + fn render( + &self, + _cx: &CallContext, + _request: ProductRendererRenderRequest, + ) -> Subscription> { + Subscription::interrupted(CallError::unavailable()) + } + + /// Subscribe to actions triggered inside this product's rendered bodies. + async fn action_subscribe( + &self, + _cx: &CallContext, + ) -> Subscription> { + Subscription::interrupted(CallError::unavailable()) + } +} +``` + +`RenderContext` has one variant per surface, holding the ids that surface uses to name a body. The host scopes an id to +the product that minted it, and for `InputWidget` also to the round the candidate was answered in. A render request and +the actions inside it carry the same context verbatim, so a product correlates by equality. A new surface adds a variant +and nothing else on the wire. + +A product registers one `render` handler, and the host calls it for every context of a surface the product's manifest +`includes`. A product that draws both chat messages and input widgets matches on `context`. Contexts for surfaces the +product does not include are never sent. + +### Render Tree + +A body is one `RendererNode`. An absent `OptionBool` leaves the default to the host. + +```rust +/// A size in logical pixels, SCALE-encoded as `Compact`. +pub type Size = Compact; + +/// Edge dimensions. `bottom` defaults to `top` and `start` to `end` when absent. +pub struct Dimensions { + pub top: Size, + pub end: Size, + pub bottom: Option, + pub start: Option, +} + +/// Typography presets, resolved by the host's design system. +pub enum TypographyStyle { + HeadlineLarge, + TitleMediumRegular, + BodyLargeRegular, + BodyMediumRegular, + BodySmallRegular, +} + +/// Button emphasis. +pub enum ButtonVariant { + Primary, + Secondary, + /// No background. + Text, +} + +/// Semantic color tokens, resolved by the host's theme. +pub enum ColorToken { + FgPrimary, + FgSecondary, + FgTertiary, + BgSurfaceMain, + BgSurfaceContainer, + BgSurfaceNested, + FgSuccess, + FgError, + FgWarning, +} + +/// Placement of content within a `Box`. +pub enum ContentAlignment { + TopStart, + TopCenter, + TopEnd, + CenterStart, + Center, + CenterEnd, + BottomStart, + BottomCenter, + BottomEnd, +} + +/// Cross-axis alignment of `Column` children. +pub enum HorizontalAlignment { + Start, + Center, + End, +} + +/// Cross-axis alignment of `Row` children. +pub enum VerticalAlignment { + Top, + Center, + Bottom, +} + +/// Main-axis distribution of children. +pub enum Arrangement { + Start, + End, + Center, + SpaceBetween, + SpaceAround, + SpaceEvenly, +} + +/// Outline of a background or border. +pub enum Shape { + Rounded(Size), + Circle, + Square, +} + +pub struct BorderStyle { + pub width: Size, + pub color: ColorToken, + pub shape: Option, +} + +pub struct Background { + pub color: ColorToken, + pub shape: Option, +} + +/// How a node composites with what is behind it. The values are those common to CSS +/// `mix-blend-mode`, SwiftUI `BlendMode` and Compose `BlendMode`. +pub enum BlendingMode { + Normal, + Multiply, + Screen, + Overlay, + Darken, + Lighten, + ColorDodge, + ColorBurn, + HardLight, + SoftLight, + Difference, + Exclusion, + Hue, + Saturation, + Color, + Luminosity, +} + +/// Layout and styling applied to one node. +pub enum Modifier { + /// Outer spacing. + Margin(Dimensions), + /// Inner spacing. + Padding(Dimensions), + Background(Background), + Border(BorderStyle), + Height(Size), + Width(Size), + MinWidth(Size), + MinHeight(Size), + FillWidth(bool), + FillHeight(bool), + /// 0 is transparent, 100 is opaque. + Opacity(u8), + BlendingMode(BlendingMode), +} +``` + +```rust +pub struct BoxProps { + pub content_alignment: Option, +} + +pub struct ColumnProps { + pub horizontal_alignment: Option, + pub vertical_arrangement: Option, +} + +pub struct RowProps { + pub vertical_alignment: Option, + pub horizontal_arrangement: Option, +} + +pub struct TextProps { + pub style: Option, + pub color: Option, +} + +pub struct ButtonProps { + /// Button label. + pub text: String, + pub variant: Option, + /// Whether the button accepts presses. + pub enabled: OptionBool, + /// Whether the button shows a loading state. A loading button accepts no + /// presses. + pub loading: OptionBool, + /// Action triggered on press. A button without one is inert. + pub click_action: Option, +} + +/// Where image bytes come from. The host fetches them; the tree carries no URL. +pub enum ImageSource { + /// A Bulletin chain blob, addressed by its CID. + Bulletin(String), + /// A file inside the product's executable archive, as a path relative to + /// the archive root. + Archive(String), +} + +pub enum ImageFit { + /// The image is not resized. + None, + /// Resized to fill the container without preserving the aspect ratio. + Fill, + /// Preserves the aspect ratio and fills the container, cutting overflow. + Cover, + /// Preserves the aspect ratio and fits inside the container, leaving empty space if needed. + Contain, + /// Whichever of `None` or `Contain` yields the smaller image. + ScaleDown, +} + +pub struct ImageProps { + pub source: ImageSource, + /// Defaults to `Fill`. + pub fit: Option, +} + +/// A visual effect. Each variant names one effect and carries its parameters. +pub enum Effect { + Rainbow, +} + +pub struct EffectProps { + pub effect: Effect, +} + +pub struct TextFieldProps { + /// Current value. + pub text: String, + /// Shown when the value is empty. + pub placeholder: Option, + pub label: Option, + /// Whether the field accepts input. + pub enabled: OptionBool, + /// Action triggered on every value change, carrying the new value. + pub value_change_action: Option, +} + +/// A node in a product-rendered tree. Container variants recurse through +/// `children`. +pub enum RendererNode { + /// Draws nothing. + Nil, + /// A text run. + String { text: String }, + /// Generic container. + Box { + modifiers: Vec, + props: BoxProps, + children: Vec, + }, + /// Vertical layout. + Column { + modifiers: Vec, + props: ColumnProps, + children: Vec, + }, + /// Horizontal layout. + Row { + modifiers: Vec, + props: RowProps, + children: Vec, + }, + /// Flexible space. + Spacer { + modifiers: Vec, + }, + /// Styled text. + Text { + modifiers: Vec, + props: TextProps, + children: Vec, + }, + Button { + modifiers: Vec, + props: ButtonProps, + children: Vec, + }, + /// Single-line text input. + TextField { + modifiers: Vec, + props: TextFieldProps, + }, + /// Image, sized by modifiers. + Image { + modifiers: Vec, + props: ImageProps, + }, + /// Applies its effect to its children. + Effect { + props: EffectProps, + children: Vec, + }, +} +``` + +The host draws every node from its own design system. + +An `ImageSource` is fetched from the Bulletin IPFS gateway or the product's executable archive. An image that cannot be +fetched draws as empty space. A tree nested deeper than the host's bound is a decode failure of the stream. + +### Actions + +An action id is product-chosen and opaque to the host. `Button::click_action` and `TextField::value_change_action` are +the action sites. + +Action payload by node, as the host sends it in `HostRendererActionSubscribeItem::payload`: + +| Node | Trigger | `payload` | +| ----------- | ------------ | ---------------------------------------------- | +| `Button` | Press | Absent | +| `TextField` | Value change | UTF-8 bytes of the new value, no length prefix | + +The host fills `context` from the render request whose tree the node belongs to. Only the current tree of an open +`render` stream is a source of actions. Actions published before the product subscribes are buffered until it does. +`peer` is absent for an `InputWidget` or `PocketCard` context. + +### Streams + +The host opens a `render` stream when the body comes on screen and closes it when the body leaves; the same body coming +back opens a fresh stream with the same request. Each item replaces the whole tree. A stream that ends cleanly leaves +the last tree on screen. After an error the host shows the product's identity and no body. A product that cannot draw +the body ends the stream with an error. + +An open `render` stream is one worker reference in [Worker Lifecycle](worker-lifecycle.md) terms, whichever surface +opened it. + +### Chat + +`Chat::custom_message_render` is removed. A `Custom` message is rendered through `Renderer::render` with a `ChatMessage` +context and the stored message payload as `payload`. Presses inside its tree arrive on `Renderer::action_subscribe` with +`peer` set to the room member who pressed. `ChatActionPayload::ActionTriggered` carries only a press on a button the +host draws for a `ChatMessageContent::Actions` message. + +## Compatibility + +`Renderer` is not additive for chat products. A product rendering custom messages moves its handler from +`chat.onCustomMessageRender` to `renderer.onRender` and its tree-button handling from `chat.actionSubscribe` to +`renderer.actionSubscribe` and re-encodes its trees as `RendererNode`. A product that posts no `Custom` messages is +unaffected. Hosts implement `Renderer` in the same change that removes the chat method. + +## Trade-offs + +- Chat products that render custom messages break once. +- A string `surface` field instead of the enum was dropped; it moves the id set into an untyped payload and loses + correlation by equality. + +## Open questions + +- The `Effect` variants beyond `Rainbow`, and the parameters each carries. +- Whether a pocket card drawn through `Renderer` needs a viewport signal beyond the stream closing. From 9d8b29f2ec1af755732915dfd9f46005a6a06224 Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Tue, 8 Sep 2026 19:25:38 +0200 Subject: [PATCH 4/5] docs(rfc): revise Unified Renderer prose and action item Lead the summary and motivation with the intent, tighten prose to the RFC style rules, drop the chat-specific peer field from the action item, and make the action payload a required Vec that is empty for a button press. --- docs/rfcs/unified-renderer.md | 64 +++++++++++++++-------------------- 1 file changed, 28 insertions(+), 36 deletions(-) diff --git a/docs/rfcs/unified-renderer.md b/docs/rfcs/unified-renderer.md index e3fdd7e31..3a8ec6a78 100644 --- a/docs/rfcs/unified-renderer.md +++ b/docs/rfcs/unified-renderer.md @@ -8,30 +8,27 @@ status: draft ## Summary -One `Renderer` service draws every body a product renders itself and reports the presses inside it: a host-initiated -`render` stream and a product-side `action_subscribe`, each carrying a `RenderContext` that says where the body lives. -The body is a `RendererNode` tree over a closed vocabulary. `Chat::custom_message_render` and the renderer-tree half of -`ChatActionPayload::ActionTriggered` fold into it. The input modality uses it for product-drawn candidates, the pocket -modality for card faces, and any later surface adds a context variant instead of a rendering pair. +Products draw parts of host surfaces, and the host keeps control of what reaches the screen. `Renderer` is the one +service for it. The product describes the body as a `RendererNode` tree over a closed vocabulary, the host draws the +tree from its own design system inside a frame it controls, and an action inside the tree reaches the product on one +stream. A `RenderContext` on the render request and on the action names the surface and the body. A custom chat message +is one such body, and `Chat` has no rendering pair of its own. ## Motivation -The only product-drawn body is a chat message with `ChatMessageContent::Custom`, and drawing it is a pair of chat -methods. The host calls `Chat::custom_message_render` with `message_id`, `message_type` and the stored payload; a press -on a `Button` node in the returned tree comes back on `Chat::action_subscribe` as -`ActionTriggered { message_id, action_id, payload }`. Both are chat-shaped: the request is keyed by message, and the -press item does not say whether the button was one the host drew for an `Actions` message or one the product drew in a -tree. +A product has one way to show its own information inside a host surface: a `ChatMessageContent::Custom` message, +rendered through `Chat::custom_message_render` and answered through `Chat::action_subscribe`. The request is keyed by +message, the press arrives beside posted messages and slash commands, and the press item does not say whether the button +was drawn by the host for an `Actions` message or by the product in a tree. -The input modality needs the same thing for a candidate the product draws, and the pocket modality for a card face. -Copying the pair per surface gives a product one render callback and one action stream per surface, at four wire ids -each. +The input modality needs a product to draw a candidate, and the pocket modality a card face. A render and action pair +per surface gives a product one render callback and one action stream per surface for the same tree type. ## Requirements -- **Surface-neutral.** A body is drawn and its presses reported the same way on every surface. +- **Surface-neutral.** A body is drawn and its actions reported the same way on every surface. - **Closed.** A product names layouts and tokens from a fixed vocabulary, never markup, stylesheets or URLs. -- **Correlated.** A press carries enough for the product to find the body and the handler without a registry. +- **Correlated.** An action carries enough for the product to find the body and the handler without a registry. - **Live.** A product redraws a displayed body in place, and no stream is open for a body off screen. - **Framed.** The host draws identity, bounds and dismissal around every body and interprets the tree itself. @@ -84,12 +81,10 @@ pub struct ProductRendererRenderRequest { pub struct HostRendererActionSubscribeItem { /// Where the body lives. pub context: RenderContext, - /// Remote actor who triggered it. Absent when the local user did. - pub peer: Option, /// Which action was triggered, as named in the renderer tree. pub action_id: String, - /// Data the node attached to the action. Absent for a `Button` press. - pub payload: Option>, + /// Data the node attached to the action. Empty for a `Button` press. + pub payload: Vec, } ``` @@ -118,13 +113,12 @@ pub trait Renderer: Send + Sync { ``` `RenderContext` has one variant per surface, holding the ids that surface uses to name a body. The host scopes an id to -the product that minted it, and for `InputWidget` also to the round the candidate was answered in. A render request and -the actions inside it carry the same context verbatim, so a product correlates by equality. A new surface adds a variant -and nothing else on the wire. +the product that minted it, and for `InputWidget` also to the query the candidate answered. A render request and the +actions inside it carry the same context verbatim, so a product correlates by equality. A new surface is a new variant. A product registers one `render` handler, and the host calls it for every context of a surface the product's manifest -`includes`. A product that draws both chat messages and input widgets matches on `context`. Contexts for surfaces the -product does not include are never sent. +`includes`. A product that draws on more than one surface matches on `context`. Contexts for surfaces the product does +not include are never sent. ### Render Tree @@ -422,12 +416,11 @@ Action payload by node, as the host sends it in `HostRendererActionSubscribeItem | Node | Trigger | `payload` | | ----------- | ------------ | ---------------------------------------------- | -| `Button` | Press | Absent | +| `Button` | Press | Empty | | `TextField` | Value change | UTF-8 bytes of the new value, no length prefix | The host fills `context` from the render request whose tree the node belongs to. Only the current tree of an open `render` stream is a source of actions. Actions published before the product subscribes are buffered until it does. -`peer` is absent for an `InputWidget` or `PocketCard` context. ### Streams @@ -441,22 +434,21 @@ opened it. ### Chat -`Chat::custom_message_render` is removed. A `Custom` message is rendered through `Renderer::render` with a `ChatMessage` -context and the stored message payload as `payload`. Presses inside its tree arrive on `Renderer::action_subscribe` with -`peer` set to the room member who pressed. `ChatActionPayload::ActionTriggered` carries only a press on a button the -host draws for a `ChatMessageContent::Actions` message. +`Chat` has no `custom_message_render`. A `Custom` message is rendered through `Renderer::render` with a `ChatMessage` +context and the stored message payload as `payload`. Actions inside its tree arrive on `Renderer::action_subscribe`. +`ChatActionPayload::ActionTriggered` carries only a press on a button the host draws for a +`ChatMessageContent::Actions` message. ## Compatibility -`Renderer` is not additive for chat products. A product rendering custom messages moves its handler from -`chat.onCustomMessageRender` to `renderer.onRender` and its tree-button handling from `chat.actionSubscribe` to -`renderer.actionSubscribe` and re-encodes its trees as `RendererNode`. A product that posts no `Custom` messages is -unaffected. Hosts implement `Renderer` in the same change that removes the chat method. +The change is breaking for a product that renders `Custom` chat messages: its render handler is `renderer.onRender`, its +tree-action handler is `renderer.actionSubscribe`, and its trees are `RendererNode`. A product that posts no `Custom` +messages is unaffected. Hosts ship `Renderer` and the `Chat` change together. ## Trade-offs - Chat products that render custom messages break once. -- A string `surface` field instead of the enum was dropped; it moves the id set into an untyped payload and loses +- A string `surface` field instead of the `RenderContext` enum would move the id set into an untyped payload and lose correlation by equality. ## Open questions From b9ec013b30764c54459caa0dd123d72071a55196 Mon Sep 17 00:00:00 2001 From: Sergey Zhuravlev Date: Wed, 9 Sep 2026 13:45:53 +0200 Subject: [PATCH 5/5] docs(rfc): scale Opacity over the full u8 range --- docs/rfcs/unified-renderer.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfcs/unified-renderer.md b/docs/rfcs/unified-renderer.md index 3a8ec6a78..5332dfb87 100644 --- a/docs/rfcs/unified-renderer.md +++ b/docs/rfcs/unified-renderer.md @@ -256,7 +256,7 @@ pub enum Modifier { MinHeight(Size), FillWidth(bool), FillHeight(bool), - /// 0 is transparent, 100 is opaque. + /// 0 is transparent, 255 is opaque. Opacity(u8), BlendingMode(BlendingMode), }