Skip to content

RFC: Unified Renderer - #633

Open
johnthecat wants to merge 2 commits into
rfc/worker-lifecyclefrom
rfc/unified-renderer
Open

RFC: Unified Renderer#633
johnthecat wants to merge 2 commits into
rfc/worker-lifecyclefrom
rfc/unified-renderer

Conversation

@johnthecat

@johnthecat johnthecat commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

RFC: docs/rfcs/unified-renderer.md.

Third of a four-PR stack: Subscription Typed Interrupt Payload (#631) → Worker Lifecycle (#632) → Unified Renderer (#633) → Input Modality (#634). Based on the worker-lifecycle branch.

Why this RFC exists

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, and a press on a tree button is indistinguishable from a press on a button the host drew for an Actions message. The Input Modality needs a product-drawn candidate and the Pocket modality a card face. One service with a RenderContext per surface serves all of them instead of a render callback and an action stream per surface.

What it defines

  • A Renderer worker service beside Chat: a host-initiated render stream of RendererNode and a product-side action_subscribe. Both carry a RenderContext naming where the body lives: ChatMessage { room_id, message_id, message_type }, InputWidget { candidate_id }, PocketCard { card_id }. A new surface is a new variant.
  • The render request is { context, payload }; a surface that needs a content discriminator carries it in its context variant. The action item is { context, action_id, payload: Vec<u8> }; the payload is empty for a button press and the UTF-8 bytes of the new value for a text-field change.
  • Both streams use CallError<GenericError> as their interrupt type. A render stream opens when the body comes on screen, closes when it leaves, and each item replaces the whole tree. An open stream is one worker reference under the Worker Lifecycle RFC.
  • Chat has no custom_message_render; a Custom message renders through Renderer::render with a ChatMessage context. ChatActionPayload::ActionTriggered carries only presses on host-drawn Actions buttons.

Render tree

RendererNode is the chat CustomRendererNode vocabulary under a surface-neutral name, with these additions and shape changes. Product authors that build trees by hand or through a serializer need to know all of them.

New nodes

  • Image { modifiers, props: ImageProps }. ImageProps.source is an ImageSource: Bulletin(cid) for a Bulletin chain blob or Archive(path) for a file inside the product's executable archive, so the tree never carries a URL and the host does the fetch. ImageProps.fit is an optional ImageFit (None, Fill, Cover, Contain, ScaleDown) defaulting to Fill. An image that cannot be fetched draws as empty space.
  • Effect { props: EffectProps, children } applies one visual effect to its children. Effect is an enum whose variants each name an effect and carry its parameters; Rainbow is the first. The remaining variants are an open question.

New modifiers

  • Opacity(u8), 0 transparent to 100 opaque.
  • BlendingMode(BlendingMode) with the sixteen values common to CSS mix-blend-mode, SwiftUI BlendMode and Compose BlendMode: Normal, Multiply, Screen, Overlay, Darken, Lighten, ColorDodge, ColorBurn, HardLight, SoftLight, Difference, Exclusion, Hue, Saturation, Color, Luminosity.

New shape

  • Shape::Square, beside Rounded(radius) and Circle.

Shape changes

  • Leaf nodes carry no children: Spacer, TextField and Image have modifiers and props only. Effect has props and children but no modifiers.
  • Single-field variants are tuple variants: Shape::Rounded(Size), Modifier::Height(Size), Width, MinWidth, MinHeight, FillWidth(bool), FillHeight(bool). The SCALE encoding is the same as the one-field struct form.
  • OptionBool is used directly for enabled and loading; there is no OptionalBool alias. An absent value leaves the default to the host.
  • The tree nesting bound is host policy; a tree deeper than it is a decode failure of the stream.

Scope

The chat path is implemented here. The PocketCard context is the seam the Pocket modality (#609) adopts when it lands; card rendering is not implemented in this PR.

Implementation

Part of this PR.

  • truapi: api/renderer.rs, v01/renderer.rs (moved from v01/chat/custom_renderer.rs), versioned envelopes, latest re-exports; Chat::custom_message_render removed
  • truapi-codegen goldens and generated dispatcher, wire table, wasm bridge
  • truapi-server: render_custom_message becomes render with a ChatMessage context on ProductRuntimeControl, native and wasm surfaces, native_renderer.rs
  • @parity/truapi and @parity/truapi-host: renderer.onRender, renderer.actionSubscribe, host runtime plumbing
  • Playground worker and tests

@johnthecat
johnthecat requested a review from a team September 8, 2026 10:20
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<u8> that is empty for a button press.

@pgherveou pgherveou left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

please rewrite that as a 1 pager RFC issue, the details of all the types can live in the PR, the RFC is just here to express the high level concepts

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants