composure 0.2.3

Discord bots over HTTP Interactions, no gateway: slash commands, components, modals and JSX-like views, on Cloudflare Workers (WASM) or any server
Documentation
# Changelog

Changes to `composure`, which has its own version.

Entries are generated by [knope](https://knope.tech) from the change files in `.changeset/`.
Up to 0.1.3 every crate shared one version: those releases are in the
[workspace changelog](../../CHANGELOG.md).
## 0.2.3 (2026-10-26)

### Fixes

#### README updates

Each crate's README shows the Composure logo and drops some marketing copy. The `composure` README now gives the example bot's bundle size as about 307 KB gzipped, matching the Cloudflare adapter's measurements, instead of the stale 344 KB.

## 0.2.2 (2026-10-23)

### Fixes

#### Repeated select option values are a `ViewError`

A View whose select repeats an option `value` now fails `View::check` with `ViewError::Duplicate`, naming the value (a `HandlerError` in handlers), instead of Discord dropping the whole response: the user saw "The application did not respond" and the bot logged nothing.

## 0.2.1 (2026-10-23)

### Fixes

#### Fewer allocations per component and modal

`custom_id` state is written straight into the `custom_id` (one allocation, not one per field) and read in place: a field is copied only when it has escapes, so numbers and ids decode without allocating. A modal's submitted `value` is read by its JSON type instead of through `#[serde(untagged)]`, which buffered each one. In the example bot, a View's button click makes 35 allocations instead of 41, a modal submit 40 instead of 43.

A malformed id now reads "expected an id as a string of digits" instead of "expected SnowflakeVisitor".

## 0.2.0 (2026-10-22)

### Breaking Changes

- recoverable parsing, every error reported, expansion continues
- complete tags and attributes from rust-analyzer's completion marker
- completion that rust-analyzer can map
- attributes highlight as constants, tags as their type
- expand to builders, as Leptos's view! does (ADR 0017)

#### `Platform::bot_token` returns `Option<&str>`

`Platform::bot_token()` now borrows the token (`Option<&str>`) instead of returning a copy, so `ctx.bot()` and `EventCtx::bot()` no longer allocate one per call. Implementations of `Platform` (test stand-ins included) change `fn bot_token(&self) -> Option<String>` to `Option<&str>`.

#### `ImageFormat::extension` replaces `AsRef<str>`; `strum` is gone

`ImageFormat` no longer implements `AsRef<str>`, which returned the variant name (`"Png"`), not a file extension. `ImageFormat::extension()` returns `png`, `jpg`, `webp` or `gif`, and `ImageFormat` is now `Clone` and `Copy`. `composure` no longer depends on `strum`.

#### The Theme is removed; `#[app(errors = ..)]` renders handler errors

The Theme filled in embed colors and branding and, for Components V2, only top-level Container accents, and never reached REST messages, so bots set their colors themselves anyway (ADR 0015). Removed: `command::Theme`, `#[app(theme = ..)]`, `RouteDefinition::THEME`, `InteractionHandler::theme`, `ctx.theme()` / `DeferredCtx::theme()`, `Unthemed`, `MessageCallbackData::unthemed` and `HandlerError::response_with`. Keep your colors as `const`s and set them where you build messages, for example with a wrapper View per tone (see "Styling" in the Views chapter).

Handler errors are rendered by a hook instead: `#[app(.., errors = render)]` with `fn render(error: &HandlerError) -> InteractionResponse` (`command::ErrorRenderer`), used for the initial response and when deferred work fails. At most one App in a tree sets it; the root uses it for the whole tree. Adapters call the new `InteractionHandler::render_error`; without a hook, errors are the ephemeral text of `HandlerError::response`. `HandlerError::user_message()` is now public, for renderers. `Ctx::with_theme` / `Invocation::with_theme` become `with_errors`, and the `Route::dispatch*` methods take the `ErrorRenderer` instead of a theme.

#### A View carries the whole message: ephemeral, allowed mentions, files

`View` gains `.ephemeral()`, `.allowed_mentions(..)`, `.suppress_notifications()` and `.file(..)` (with `files()` and `flags()` to read them), and `IntoResponse for View` sends them. `<File>`, `<Thumbnail>` and `<Item>` take `src` (or `url`) as a URL, a `types::File` to upload with the message (its `attachment://` URL is set for you), or an `&Attachment`, through the new `IntoMedia` trait. A placed sub-View's files, flags and mentions go to the View it is placed in. `Update(..)` now drops the ephemeral flag, which an update cannot change, and deferred edits keep the files.

Breaking: `IntoChildren` is gone. What markup can place is now a trait per parent (`ContainerChild`, `RowChild`, ...; see "`rsx!` expands to builders"); a type rendering to a View implements `IntoView` instead, and is placed wherever a View can go.

### Features

#### CDN URLs for avatars, icons, banners, emojis and stickers

`User::avatar_url`, `default_avatar_url` and `display_avatar_url`, `Member::guild_avatar_url` and `display_avatar_url`, `PartialMember::guild_avatar_url`, `icon_url`/`splash_url`/`discovery_splash_url`/`banner_url` on `Guild`, `PartialGuild` and `GuildPreview`, `Role::icon_url`, `Emoji::url`/`PartialEmoji::url` and `Sticker::url`/`StickerItem::url` build Discord CDN URLs, ready for embed thumbnails. Each image has a `*_url_with(format, size)` variant; animated (`a_`) images default to GIF. `types::image_url` and `CDN_BASE` build any other CDN URL.

#### Discord's component limits are checked

A View or `ModalView` answered with (or sent through REST) is checked against Discord's limits in one walk, without allocating: 40 components, 4000 characters of text, 5 buttons or one select per row, 25 select options, 2-10 radio and 1-10 checkbox options, 1-3 texts per section, 10 gallery items, 100-character `custom_id`s, 80-character button labels, and a modal's 5 top-level components and 45-character title. A violation is a `ViewError::Limit` naming the limit and the count (a `HandlerError` in handlers) instead of Discord's bare "This interaction failed". The limits are `types::limits`; `View::check` and `ModalView::check` run the walk. `rsx!` reports the counts it can see at compile time: more than 5 buttons in a `<Row>`, a select beside anything else, more than 3 texts in a `<Section>`, more than 5 children in a `<Modal>`, more than 25 `<Option>`s (10 in a group) or 10 `<Item>`s.

#### Handler errors keep their causes

An error converted into a `HandlerError` with `?` keeps its `source()` chain as text: `HandlerError::sources()` lists it, `{:#}` prints `message: cause: cause` (plain `{}` is unchanged), and `HandlerError::with_source(error)` adds causes to an error built by hand. Logged handler errors carry a `sources` field when there are causes, and the Cloudflare adapter without `tracing` logs the whole chain. What the user sees is unchanged.

#### REST message bodies from a View

`CreateMessage`, `CreateFollowup`, `EditMessage` and `ExecuteWebhook` implement `TryFrom<&View>`: the components, `IS_COMPONENTS_V2`, the View's other flags (ephemeral only where the Endpoint takes it), its allowed mentions and its files. Building the body fails with a `ViewError`, before anything is sent, when the View cannot be: a `custom_id` that could not be built, or past Discord's limits. `bot.create_message(channel, &CreateMessage::try_from(&view)?)` replaces calling `into_components()` and setting the flag by hand.

#### Linked Roles, onboarding, stage instances and lobbies Endpoints

Four new `BotClient` groups, in `full`:

- `rest-role-connections`: `get_role_connection_metadata`, `update_role_connection_metadata` (`types::ApplicationRoleConnectionMetadata`), for Linked Roles
- `rest-onboarding`: `get_guild_onboarding`, `modify_guild_onboarding` (`ModifyOnboarding`), `get_guild_welcome_screen`, `modify_guild_welcome_screen` (`ModifyWelcomeScreen`)
- `rest-stage-instances`: `create_stage_instance`, `get_stage_instance`, `modify_stage_instance`, `delete_stage_instance`
- `rest-lobbies`: lobbies (`create_lobby`, `create_or_join_lobby`, `get_lobby`, `modify_lobby`, `delete_lobby`, `link_lobby_channel`), members (`add_lobby_member`, `remove_lobby_member`, `bulk_update_lobby_members`, `leave_lobby`, guild invites) and messages (`get_lobby_messages`, `send_lobby_message`, `update_lobby_message_moderation_metadata`); `LobbyMessage` gains `moderation_metadata`

#### `rsx!` expands to builders: attributes and placement checked by the compiler, completed by the IDE

As Leptos's `view!` does, `rsx!` markup is now a chain of builder calls: a tag is its type's `builder()` (`Container::builder()`, at the tag), each attribute a documented method (`.accent_color(..)`, `.primary(true)`, `.on_click(..)`), each child a `.child(..)` bounded by a trait per parent (`ContainerChild`, `RowChild`, `SectionChild`, ...), then `.build()`. The builders are public and work by hand too. So:

- an unknown attribute is the compiler's "no method named `accent` found for `ContainerBuilder`", with the closest name; a misplaced child is "`&str` cannot go in a `<Row>`", pointing at it
- rust-analyzer completes and documents attributes as methods, highlights them apart from tags, and completes a tag being typed (`<Con`, also inside another tag) from the built-in tags and the sub-Views in scope; hover and go-to-definition on `<Container>`/`</Container>` reach the type
- the macro's own errors (required attributes, counts, inline handlers) are deduplicated, capped at 16 and each at the offending token; a tag with one is still generated, so it keeps completing while typed
- a missing Prop of a sub-View points at its tag

The markup, its attributes and the messages it builds are unchanged.

#### `composure::testing`: test a bot without Discord

The new `testing` feature (for dev-dependencies) adds `composure::testing`: Interaction builders (`testing::command("echo").option("text", "hi")`, `user_command`, `message_command`, `autocomplete(..).focused(..)`, `component(..).values(..)`, `modal(..).field(..)`, with `.by(user)`, `.in_guild(id)`, `.locale(..)` and more), `TestPlatform<T>` (a `Platform` and `HasData` whose Background Work runs when the test says so), `MockTransport` (records requests, answers from a queue or by route), `TestKey` (signs requests for `adapter::handle_interaction`/`handle_event`), `testing::run`/`answer` (dispatch as an Adapter does) and `ResponseExt` (`text()`, `is_ephemeral()`, `button(label)`, `custom_id(label)`, `embeds()`, `choices()`). The Testing chapter is rewritten around it.