Skip to content

0.0.23 plan: rendering performance, Noctiluca's images, Doom's core gaps and rich-rst 2.2.0

Status: published on 2026-10-27, every crate from main at 2624ce5 by one release run, with rs-rich 0.0.11 on PyPI (release notes). Every item but Memo's illustrations (item 3) was merged: the versions and the first items (#772), the first wave (#773: performance, the hidden switch, lighter strings), Noctiluca's media (#780), the third wave (#783: console.py, rs-rich-doom's core gaps, rich-rst 2.2.0, sound in tapes, the API checks, the official keys and the marketplace going live), the extras (#795) and the gallery (#823). The release PR (#824) prepared the cohort (see Delivered). Started on 2026-10-25 as a list of what 0.0.22 handed on, it grew as the maintainer brought items forward from 0.0.24 and from the plan to 1.0.0's later stages. It belongs to stage 2 of that plan.

Handed on from 0.0.22

Both were the maintainer's call on 2026-10-25.

1. rs-rich-doom's core gaps: art, intuituive, record

rs-rich-doom, rebuilt on the 0.0.21 crates, lists what it still builds for itself in its core gaps. Each is generic: each names a use other than Doom, and nothing of Doom's (WAD, DMX, MUS, OPL, its keys) goes into core. Gap 4, made-up releases with a first-press hold and a repeat hold, lands in 0.0.22.

  • Pictures: a live frame image node in intuiTUIve's art plugin (gap 1); a public Sixel encoder for indices and a palette (gap 2) and Kitty raw-pixel transmission (gap 3) in rs-rich-art.
  • The clock and stats: a worker that steps in lockstep with the app's clock (gap 5) and draw timings in FrameStats (gap 6).
  • Audio: a voice mixer for effects (gap 7), a render-callback output for synthesizers (gap 8), a paced test sink and capture (gap 9), and ALSA's messages kept off the screen for the stream's life (gap 10).
  • Pixels: a palette-keeping two-colour rule for Braille and quadrants (gap 11) and an aspect-fitted pixel buffer (gap 12).
  • Recordings: sound in rich record tapes (gap 13).

Each gap's entry has its proposed API and acceptance tests. When one lands, rs-rich-doom drops its own version of it.

Status (2026-10-26, branch 023-doom): gaps 1, 3 (finished here with transmit_raw, transmit_rgb, placement ids and zlib) and 6 were closed by waves 1 and 2, and gap 4 by 0.0.22. Built here: gap 2 (sixel::encode_palette, index_exact), 11 (Node::pixel_colors), 12 (PixelBuf::draw_fitted), 5 (stepped_worker), 9 (OutputOptions::null, Output::tap), 7 (audio::Mixer), 8 (audio::output_with) and 10 (ALSA's handler on each stream's thread, thread-local rather than process-wide). Gap 13, sound in tapes, landed on 023-avmedia with the maintainer's option (a), the RICH_RECORD_AUDIO contract: Set Audio on or rich record --audio, intuiTUIve's outputs writing to the recorder, an AAC track in an MP4 and a WAV beside the other outputs. Each gap's entry says where it landed.

The same branch makes gap 2's encoder the default for pictures that fit: SixelArt::encode and encode_cells keep the exact colours of a picture of 256 colours or fewer (sixel::encode_rgba), and quantize only past that.

The maintainer's decisions of 2026-10-26 (all accepted, applied in the wave 3 integration):

  1. Gap 13, sound in tapes: option (a), in 0.0.23 (item 13). An environment contract: the recorder sets RICH_RECORD_AUDIO=<path>, and intuiTUIve's output() opens a null output tapped to that file. The tape side is Set Audio on or rich record --audio; outputs other than MP4 get a WAV beside them. The gaps doc says so. First set for 0.0.24, it came into 0.0.23 the same day.
  2. Palette Sixel for pictures of 256 colours or fewer: in 0.0.23 (item 14). First deferred to 0.0.24 because it changes tape bytes, it came into 0.0.23 the same day. ImageView::aspect, cell_px and keep_last_row are dropped; the gaps doc says so under gap 1.
  3. Gap 9's null output stays on the real clock.
  4. Two dev-dependencies are accepted: rs-rich-record on rs-rich-art (a path dependency, for tests only) and intuiTUIve on hound (for tests only).

2. rich-rst 2.2.0: cli

Move the rich-rst pin in UPSTREAM.toml from 1.3.2 to 2.2.0 (the plan to 1.0.0 names it), and port rich --rst to it with fresh goldens.

Done (2026-10-26). UPSTREAM.toml pins rich-rst 2.2.0 (its docutils is vendored, so the docutils pin went). rich_ext::rst now builds a docutils document tree, applies docutils' transforms and ports 2.2.0's visitor; the 2.2.0 outputs replaced the 1.3.2 goldens, with three new documents, and every one matches byte for byte, in colour too. rich 15.0.0 is enough for rich-rst 2.2.0 (it asks for rich>=12). DIVERGENCES §35 lists the directives not ported.

The maintainer's decision of 2026-10-26: rs-rich-ext's breaking change is accepted. rst::parse returns the document tree (rst::Element), and the CHANGELOG's [Unreleased] section has a migration note. The nesting cap of 100 and the directives not ported (DIVERGENCES §35) are accepted as they are.

3. Memo's illustrations: docs

0.0.22 ships the cartoon crate spots as hand-written SVG. The maintainer wants them closer to their own reference pictures (made with an AI image tool) and will revisit the art; the flat SVGs stay as the icon tier either way. Noctiluca's "Memo amazed" stays in the flat style until then.

4. Noctiluca's media, to the PoC's look: noctiluca, embed

The maintainer's calls on 2026-10-25: images in 0.0.23, video in 0.0.24. Later that day, the maintainer asked for Noctiluca to look like their PoC in 0.0.23, so video and the rest of 0.0.24 moved here. This is phases 0.3 (apart from its layout fixtures) and 0.4 of the design note.

The target is the PoC's evidence (archived-bbc-news.png, archived-youtube-watch.png):

  • page text as terminal text, with real pictures inline where the page puts them, each with its caption;
  • a video's poster with a play mark, and the video playing in its region once started;
  • a header with the mode and the URL, and a status line with the media state (media PLAYING), the row, and the keys.

A news-page fixture (local, no network) is recorded by a tape, and its screenshot sits beside the PoC's in the guide.

Images:

  • Pictures in the projection view: each media region shows the image itself, not the alt text and a shaded box. Kitty, Sixel and iTerm2 draw real pixels; other terminals get half blocks.
  • Pictures partly in view: a Sixel or iTerm2 picture is drawn clipped to the rows shown, not as half blocks until all of it is on screen. Kitty already works row by row.
  • Animated GIFs: they play in their region within the frame budget. Kitty's own animation frames are used where the terminal has them, and freezing a GIF stops its bytes.
  • Lazy images load: with Chrome's scroll following ours (the rendering plan), loading=lazy images below the fold load as they come into view.
  • The recordings show them: the replayed snapshots behind the tapes carry image stills, so the docs' recordings show pictures.

It builds on the rendering plan's image layer: a picture is uploaded once and placed by reference, and a live frame node replaces Doom's gap 1.

Video:

  • A playing video plays in its region. Chrome's screencast is cropped to that region, only while the video plays.
  • Controls: play, pause, seek and mute, both from the media controls and from keys. Sound already plays with --audio since 0.0.22.
  • A frame budget per region. Each region's frame rate comes from a total byte budget. The PoC's 8 fps is a ceiling, not a target.
  • Stepping down on slow terminals: Kitty, then iTerm2, then Sixel, then quadrant or half-block art. Over SSH, video starts as character art.
  • Tests:
  • the PoC's motion.webm fixture plays, pauses and seeks;
  • the region's frame rate stays under its cap, counted in bytes over two seconds;
  • over a slow simulated terminal, it steps down to character art;
  • the about page's codec table matches canPlayType.

Done on branch 023-video (pushed to claude/next-priority-items-3c5p8e-wip-video), on A's picture API: all four tests pass against Chrome for Testing as a normal user, with the header and status bar of the PoC. The budget is a guess until the painter measures the terminal's output rate (see the design note's Media regions).

The maintainer's direction, 2026-10-25, later that day:

  • Rendered by intuiTUIve and Noctiluca. Every cell and every pixel comes from our own stack: Noctiluca's model and projection, drawn by intuiTUIve's nodes and painter, pictures encoded by rs-rich-art and intuiTUIve's art feature (Kitty, Sixel, iTerm2, half blocks). No chafa or other converter, and no replay of a captured page standing in for rendering. Chrome supplies only the DOM, the layout, resource bytes and, while a video plays, cropped screencast frames. The PoC's "CHAFA-SIXEL" is the look to match, not the means.
  • No site-specific code. Nothing checks for a site: no host names, no per-site selectors, no "this is a chat" case. Pictures come from <img>, <picture> and CSS background images; video and audio from <video> and <audio>, with their poster, controls and play state; side by side from grids and flex rows, by geometry; live updates from DOM mutations plus ARIA role=log and aria-live, so any live log sticks to the bottom; inputs and buttons from their roles and names. Fixtures are test pages named by what they exercise, in ordinary HTML. A test runs the same projection on two differently built pages with the same constructs (a log as a <ul aria-live> and as a <div role=log>) and gets the same result.
  • Terminal adaptations are welcome when generic: each is a named, documented rule about constructs and cells, applied to every page alike, with a test on a fixture. They are listed in the design note's Terminal adaptations.
  • A video page a bit more visual than the PoC's: a local fixture (media-grid.html, no network, our own pictures) with a player whose poster then motion.webm plays in place, a row of thumbnails with titles under them side by side, small avatars, and a compact comment list under the player. Its own tape and a screenshot in the guide.
  • A live stream page with chat: a local fixture (stream-with-log.html) with the video region playing motion.webm in a loop as the stream, the channel's name, avatar and title, and a role=log panel beside it that the page's own script fills on a timer. Noctiluca follows the log through the change ping without reading the whole page again, sticks to its bottom unless the user scrolled up, shows each message's coloured name and its small pictures (badges, emotes), and its input types and sends (the page echoes the message). Its own tape and a screenshot in the guide. Tests: new messages appear within the budget without a full re-read; stick-to-bottom holds and scrolling up pauses it; a sent message reads back; the video keeps playing while the log updates, within its byte cap.

Where the picture work goes (the maintainer's call, 2026-10-25): picture handling is rs-rich-art's, with no new crate. Its picture feature holds a picture's identity and version, decoding, fitting, every protocol's encoding, cells for composing into rows, and byte accounting. Its frames feature, next to gif and with no new dependencies, is video frame display: RGBA frames to Kitty, Sixel, iTerm2 and blocks (Kitty's raw f=32, Doom's gap 3), pacing within a byte budget, and the step down. Decoding video files is out of scope (a separate rs-rich-video crate later, if ever), and Chrome's screencast stays in rs-rich-embed. intuiTUIve's art feature holds only the glue: the live frame node (picture_of, Doom's gap 1 in part) and the placement. Noctiluca decides what shows where. The API is sketched in the design note's Media regions.

Package impact:

Package Change
rs-rich-art new picture and frames features, kitty::transmit_rgba
rs-rich-intuituive art: Picture, Pixels, ImageView re-exported from rs-rich-art, picture_of, picture_bytes; a Kitty picture whose first row is out of view is uploaded
rs-rich-embed screencast frames reach DevTools listeners (the video branch)
rs-rich-interact a woken session reads a waiting key at once
noctiluca pictures, GIFs and video in the projection; logs; adaptations

The work runs on three branches from a522384a: pictures and GIFs (023-media), video and the header and status chrome (023-video), and logs with the terminal adaptations and their switches (023-logs).

Status: the three branches are merged on 023-media (pushed to claude/next-priority-items-3c5p8e-wip-media). Pictures are drawn from the page's own files in their regions and in lines of text, GIFs move within the byte budget they share with video, and the about page, captions and page colours follow the adaptations. The news, video and stream pages have tapes that pass rich record --check, and the guide shows them. Wave 1 is merged in too. With no [media] bandwidth, the budget is the guess lowered to the painter's measured output rate (FrameStats). Left: an inline SVG picture shows its alt text.

Video fixtures (branch claude/next-priority-items-3c5p8e-wip-noctvideo): motion.webm is drawn by make_video.py, and bloom.webm is cut by make_bloom.py from the maintainer's AI-generated promo (MIT), whose 9.5 MB master stays out of the repository. The news and watch pages play the bloom, the stream page the generated clip; Chrome's frames of both are recorded for the scripted engine, and the tapes are re-recorded with rich record no longer keeping half-drawn synchronized updates. The full promo plays on the guide's front page.

5. Rendering performance: ext, intuituive, noctiluca, web

The rendering plan:

  • Measuring first: frame stats, a timedemo and a benchmark.
  • Then optimising what the profile shows.
  • Paint strategies, with best: the default from this release, since 0.0.24 folded in. It becomes the default only where the benchmark shows it at least matching cells on every scene; a scene that loses gets its heuristic fixed first, or best ships opt-in.
  • A virtual scroll view with a near-end hook.
  • Infinite scroll in Noctiluca.
  • rs-rich-web on the damage list.

Work started on 2026-10-25 on two branches. Frame stats, the timedemo, the paint benchmark and the strategies landed on 023-core, with best still opt-in; the virtual scroll view, Noctiluca's infinite scroll and the web's damage list on 023-scroll.

best is the default (2026-10-26). CI's release benchmark (paint_strategies_benchmark) has it within 5% of the best fixed strategy on every scene, so PaintStrategy::default() and an app that does not choose are best. cells and the others stay explicit. Scrolling stays off on Windows, under tmux, inline and over pictures, and App::terminal_scroll overrides it. rs-rich-ext's Surface follows with the same default and guards (Surface::paint_strategy, Surface::terminal_scroll); a bare Painter still starts on cells. Moving Surface to best was item 12, the maintainer's call on 2026-10-26.

6. A hidden switch child missing updates: intuituive

  • Found during 0.0.22's Noctiluca fixes. After a page was opened from a modal prompt, a view that switch kept alive but hidden went on drawing the page it last showed.
  • 0.0.22 worked around it. Noctiluca's page area moved to keyed.
  • The fix belongs in core. Find why a kept, hidden child misses the signal update after a modal, fix it, and add a regression test there.

Fixed (2026-10-26). The modal was not the cause. A switch never hid the page it stopped showing, so the page kept its drawn state; a scroll box inside it kept its offscreen copy, and its rows, never drawn while hidden, missed the changes. Shown again, it blitted the old copy. Switch now hides the outgoing page (as a hidden node is hidden), so it draws afresh when it comes back. tests/switch_hidden_child.rs fails without the fix. Noctiluca's page area stays keyed: it costs a rebuild per view change, and the switch back is a separate decision.

7. Lighter strings in core, and rs-rich-ext's fast path: rich, ext, intuituive

  • The decision: the maintainer's call on 2026-10-25, "make the strings lighter", brought forward from the 0.1.0 API review.
  • The change: rich's per-cell public strings (Segment.text, Color.name) move to a light string type, so a short run or a hex colour never allocates.
  • The guarantees: output stays byte-identical, proven by the goldens, the parity tables and the fuzzer. Callers keep working through deref and as_str.
  • Breaking for rs-rich 0.0.x, with a migration note in the release notes.
  • Measured: against rs-rich-ext's fast path, which stays the default for ext and intuiTUIve.

Landed on the 023-core branch, with rs-rich-ext's fast path (also the maintainer's call on 2026-10-25). The measurements are in the render performance note.

  • rs-rich (breaking): Segment.text and Color.name are rich::CompactStrings. Output is byte-identical.
  • rs-rich-ext: the fast path (surface::Draw, TextRun, Screen::write_runs), implemented by the charts.
  • intuiTUIve: Canvas::draw, drawn, drawn_described; the viz nodes draw through the fast path.

The maintainer's decisions of 2026-10-25 (the core agent's three questions, all accepted):

  1. The CompactString break goes past the two fields. Segment::new no longer takes a char, and a value that is impl Into<String> must become a String first. The migration note says both.
  2. compact_str 0.9 is a new third-party dependency of rs-rich, the mirror crate. The mirror accepts it for byte-identical speed; docs/PORTING.md's "Performance notes" records it.
  3. intuiTUIve's described stays on rich's path. drawn_described is the fast alternative, so third-party AccessibleText types keep working.

For the release notes, under Migration:

Segment.text and Color.name are now rich::CompactString (compact_str's, kept inline up to 24 bytes) instead of String. Reading them is unchanged: they deref to &str and compare with &str and String. Where your code moved or cloned one into a String, write .to_string() (or .into_string() to move it; .as_str() to borrow). Where it assigned a String to one, add .into(). Segment::new and Segment::control take impl Into<CompactString>: &str, String, &String, Cow<str> and Box<str> work as before; a char or an impl Into<String> needs converting to a String first. The Python package is unchanged.

8. console.py fully ported: rich, cli

Not on the list handed on from 0.0.22: the rest of upstream's Console, ported into crates/rich in the third wave (the CHANGELOG's entry and the console row of PORTING list it; golden fixture console.tsv). Two of its changes are upstream behaviour that the rich binary does not take.

The maintainer's decisions of 2026-10-26 (all accepted):

  1. FORCE_COLOR and the CLI. The core honours FORCE_COLOR and TTY_COMPATIBLE as rich 15.0.0 does. The rich binary keeps its documented behaviour ("FORCE_COLOR: not supported; redirected stdout stays plain"): every console it prints through starts from rich_cli's console_builder(), which sets force_terminal from whether standard output is a terminal. Test: crates/rich-cli/tests/console_contract.rs, with FORCE_COLOR=1 (and TTY_COMPATIBLE=1) and stdout redirected, the output stays plain.
  2. Broken pipe. The core exits with status 1 when the reader closes the pipe, as upstream does. For the binary a closed pipe stays a success: console_builder() sets ConsoleBuilder::on_broken_pipe to make the console quiet, and the command ends normally. Test: the same file, rich --markdown - | head -c1 exits 0.
  3. Accepted as they are: the *_recorded export names, and DIVERGENCES §47 to §49 (UTC log times, render hooks handed rendered output, closure captures not nesting, the size read once at build).
  4. In 0.0.23, after this wave: Progress.log (item 9), Live calling set_live (item 10) and the CLI demo's update (item 11). First moved to 0.0.24, they came into 0.0.23 the same day.

Brought in on 2026-10-26

The maintainer's call on 2026-10-26: what the third wave first handed on to 0.0.24 comes into 0.0.23. Each was built on its own branch and merged into the third wave's branch.

9. Progress.log: rich

Done (023-parity). Console.log is ported (item 8), so Progress.log, which calls it, is too: LiveProgress::log and log_at print above the tasks through the live display, and Live, AutoLive and LiveHandle gain log. Golden cases from rich 15.0.0, as for the console.

10. Live calls set_live: rich

Done (023-parity, then 023-nested). Live pushes itself on its console's live stack and clears it on stop, as upstream does. At the maintainer's request (2026-10-26) a nested Live also draws inside the first display, as a group of every display on the stack, and the stack is shared by a console's clones, as Python's one console object is. Golden cases from rich 15.0.0 cover refreshes from either display, transient displays, both stop orders, a file and the alternate screen. What still differs is in DIVERGENCES §17. rich 15.0.0 never raises LiveError; only its docstring names it.

11. The CLI demo shows the new console: cli

Done (023-parity). rich --demo shows console.log and progress.log (with a fixed clock and the pseudo-file <demo>, so the tapes are stable), a live status on a terminal, and a record=True console's text, HTML and SVG exports. The tour tape gains the log and record screenshots.

12. rs-rich-ext's Surface on best: ext

Done (023-parity). Surface paints with best unless Surface::paint_strategy or RICH_PAINT_STRATEGY chooses another, with the same scrolling guards as the App (tmux, Windows, inline, pictures) and Surface::terminal_scroll to override them. A vt100 replay shows best equal to a full repaint.

13. Sound in tapes: record, intuituive, cli

Done (023-avmedia). Doom's gap 13 (the gaps doc), option (a), the maintainer's choice on 2026-10-26.

  • An environment contract. The recorder sets RICH_RECORD_AUDIO=<path> for the program it records. intuiTUIve's output() sees it and opens a null output (gap 9's OutputOptions::null) tapped to that file (Output::tap, Tap::to_wav), with its start put on the recording's clock. rs-rich-record still does not depend on intuiTUIve.
  • The tape side. Set Audio on in a tape, or rich record --audio. MP4 output carries the sound as AAC, in sync with the frames' timestamps. Outputs with no sound track (GIF, APNG, the HTML player) get a WAV beside them.
  • Acceptance: the gaps doc's test. A tape running a program that plays a 1 s 440 Hz tone from its second frame produces an MP4 whose tone starts within one frame of that frame and lasts 1 s, ±1 frame.

14. Palette Sixel for pictures of 256 colours or fewer: art

Done (023-avmedia): sixel::encode_rgba is the default for SixelArt::encode and encode_cells. Three Noctiluca tapes (media-grid, news, stream-with-log) were re-recorded. Doom's gap 2 left Picture's Sixel path quantizing. sixel::encode_palette there, for frames of 256 colours or fewer, keeps their colours exactly. It changes the Sixel bytes of pictures already in tapes, so it lands with those tapes re-recorded and the change named in the release notes.

15. API checks, Python stubs and two fixes: all crates, PyPI

Brought forward from Stage 3 of the plan to 1.0.0 by the maintainer on 2026-10-26, with two fixes handed on by the third wave. Done on branch 023-apitools (pushed to claude/next-priority-items-3c5p8e-wip-apitools).

  • Public API snapshots. api/<crate>.txt for every published crate, made by cargo public-api 0.52.0 over nightly-2026-05-20's rustdoc JSON (all features, except the CLI and rs-rich-lumis). CI's new public API and SemVer job runs scripts/public_api.py --check; a contributor runs --bless. The tools are pinned in api/tools.toml.
  • cargo-semver-checks 0.51.0, against each crate's last release.
  • The plan's gate: release.py plan --semver-checks (run by release.yml) refuses a bump smaller than the change, by Cargo's rules. A 0.0.x bump is a breaking one, so it fits any change.
  • CI's check for 0.0.x: each crate is checked as if its bump were a minor one. A breaking change must be listed in api/semver-breaks.toml with its reason.
  • This release's known breaks: rs-rich-ext's rst tree (item 2) and surface::Cell (item 5); rs-rich-intuituive's FrameStats fields (item 1) and ImageView and Pixels, now rs-rich-art's (item 4); noctiluca's added struct fields and enum variants, taken wholesale (items = ["*"]), since its library is the browser's own model. rs-rich's CompactString change (item 7) is a change of field and parameter types, which cargo-semver-checks does not see, so it is not listed. The snapshots are taken after it.
  • Python stubs. _native.pyi matches the compiled module under mypy's stubtest. CI checks it against the built wheel, and runs a typed program under mypy --strict (scripts/check_python_stubs.py), in python.yml and in pypi-release.yml. The minimum stays Python 3.9.
  • rich-py's console size follows upstream's Console.size: with LINES set, a legacy Windows console was a column wider than upstream's.
  • playground/spike/Cargo.lock is regenerated with cargo. A check that catches it: cargo metadata --locked --manifest-path playground/spike/Cargo.toml in CI's release workflow regressions job.

The maintainer's decisions of 2026-10-26 (the API checks agent's four questions, all accepted):

  1. noctiluca keeps its wildcard. Its entries in api/semver-breaks.toml take every item of a lint (items = ["*"]) while the crate is young, since its library is the browser's own model.
  2. The 258 stub-default acceptances are the policy. scripts/check_python_stubs.py accepts a runtime default PyO3 shows as ... (a default taken from a Rust constant), and a runtime None where the stub keeps rich's default (the bindings take None for "rich's default"). Neither is reported.
  3. The API snapshots are Linux-only for now. api/*.txt are made on Linux, with all features (ALSA's headers for intuiTUIve's audio), and CI checks them on Linux only.
  4. The cheap lockfile check is added. CI's release workflow regressions job runs cargo metadata --locked --format-version 1 --manifest-path playground/spike/Cargo.toml, so the spike's lockfile cannot go stale unseen again.

16. The marketplace's official keys: marketplace, cli

Done. The maintainer made the main and backup minisign pairs on 2026-10-26 and kept the private halves in their password manager, the backup also offline. The public halves are crates/rich-marketplace/keys/keys-1.json, compiled into KeySet::official, and the same file is the marketplace repository's keys/keys-1.json (generation 1). rich plugin trusts the official marketplace with no setting; --marketplace-keys and RICH_MARKETPLACE_KEYS still name others. The main key is the marketplace-signing environment's secret in the marketplace repository.

17. The marketplace goes live: marketplace repository, cli, plugin API, micro

The maintainer's call on 2026-10-26: the marketplace is live in 0.0.23. Done, and live (023-mktlive here, live-0.0.23 in rs-rich-marketplace): the maintainer set up the signing environment, and the signing workflow signed the first index, serial 1, on 2026-10-26 at 23:01 UTC.

Done:

  • Listings for every official plugin and the built-in micro packs, in rs-rich-marketplace's packages/, valid under scripts/validate_marketplace.py with jsonschema:
  • the crates mermaid (rs-rich-mermaid 0.0.8), dot (rs-rich-diagram 0.0.5, feature plugin) and lumis (rs-rich-lumis 0.0.6): the 0.0.22 cohort, with the .crate files' real SHA-256 and size, checked against crates.io's index checksums. Older versions are not listed: each needs the plugin API crate of its own cohort, so it does not build beside a newer host.
  • textcase 0.1.0, the official WASM plugin (crates/rich-plugin-api/examples/wasm/textcase.wat: the upcase and downcase transforms and a loud fence-ansi), listed with rich plugin publish.
  • the micro packs status, dev and fun 1.0.0, as .richmicro archives of crates/rich-micro/builtin/, listed with rich plugin publish.
  • The WASM plugin and the packs are built reproducibly by crates/rich-cli/tests/support/official.rs; tests/official_artifacts.rs checks each rebuild against the hashes pinned in tests/fixtures/official-artifacts.txt, and its ignored write_official_artifacts writes them. The marketplace serves them from its own artifacts/ at raw.githubusercontent.com URLs on main, without Git LFS (.gitattributes keeps the bytes exact).
  • scripts/add_crate_version.py in the marketplace repository: one command adds a crates.io version to a crate's listing, with the hash checked against crates.io's index.
  • The marketplace's pull request checks (check-listings.yml, on pull_request with a read-only token and no secrets): every listing validates with rs-rich-cli's validator at RS_RICH_CLI_REF; each new or changed version's artifact is downloaded and its size and SHA-256 checked (a crate's against crates.io's checksum); a listed version never changes; a tier set or raised needs a maintainer's tier-approved label; and each new WASM plugin, micro pack and theme is installed with rich, built at the same ref, from a copy of the marketplace signed with a throwaway key. The checks run from the base branch's scripts, so a pull request cannot weaken its own. A README says how to submit a listing.
  • Signing pushes with a deploy key. The maintainer cannot let GitHub Actions bypass main's ruleset, so sign-index.yml reads with the workflow token (contents: read) and pushes the signed index with MARKETPLACE_DEPLOY_KEY, an environment secret. The signer's commit touches only index.json and its signature, so it starts neither workflow.
  • Compatibility in CI. The plugin marketplace compatibility job's fixture marketplace now lists textcase and the micro packs as the real one does, served from the same URLs: the job checks the pinned hashes, installs textcase and runs its transform and fence, installs each pack and checks that it loads into micro's user layer. marketplace-live.yml (weekly and by hand) installs from the live marketplace with the compiled-in keys; it passes with a notice until the first index is signed.
  • The end-to-end proof, run locally against a copy of the live-0.0.23 branch with an index built by build_index.py and signed with a throwaway key: search, info, install (textcase and status), list and verify pass, and install mermaid refuses with cargo add rs-rich-mermaid@=0.0.8.
  • The CLI calls the official tier "rs-rich's own, built from its repository", since the WASM plugin and packs are not built by the release workflow.

Waiting on the maintainer (in order; the steps):

  1. In rs-rich-marketplace: the marketplace-signing environment with its reviewer, main only, and the secrets MARKETPLACE_MINISIGN_KEY, MARKETPLACE_MINISIGN_PASSWORD and MARKETPLACE_DEPLOY_KEY; the deploy key with write access, on main's ruleset bypass list; the variable RS_RICH_CLI_REF (release-0.0.22 now, release-0.0.23 once tagged); the tier-approved label.
  2. Merge the live-0.0.23 pull request, labelled tier-approved (its listings are official). textcase's source_revision is rs-rich-cli's commit 124ea705, on this branch: merge the branch rather than squash it, so the commit stays reachable, or re-run rich plugin publish with a commit on main.
  3. Approve the signing run the merge starts. The first index has serial 1.
  4. After the 0.0.23 crates are published, run add_crate_version.py for rs-rich-mermaid 0.0.9, rs-rich-diagram 0.0.6 and rs-rich-lumis 0.0.7 and merge that pull request.

Installed packs load (the coordinator's follow-up, 2026-10-26): a micro pack installed with rich plugin install loads on every run with no other step, into micro's user layer after the user's own directory, once its file still hashes to what the lockfile records; rich plugin disable stops it, and rich micro packs lists it. rs-rich-micro is unchanged: the CLI reads the lockfile and adds the packs through MicroRegistry::add. rich plugin publish fills a new micro pack listing's description, license and author from its pack.json unless they are given.

Installed themes are usable (approved by the maintainer, 2026-10-26): --theme NAME, when the user's config has no theme of that name, falls back to an installed, enabled theme whose id is NAME. Its file is read only if it still hashes to the lockfile, as micro packs are; otherwise the run warns and the name is unknown. The config's own themes win a clash, a disabled theme is not found, and an unknown name's error says that rich plugin search lists themes. The bindings sit where a config theme's do: over --theme-file, under --theme-style. A config's own theme = "..." setting still names only a config theme.

The official set grows (2026-10-26, approved by the maintainer; 023-official2 here, official-2 in rs-rich-marketplace, whose pull request follows the live-0.0.23 one):

  • Five themes, from crates/rich-cli/official/themes/, listed at 1.0.0 with tier official: dusk and ocean (dark), paper (light), high-contrast and mono. Each sets every style of rich's default theme that has a colour (but the colour names, such as red), so no default colour shows through, and parses with --theme-file and with Python rich 15.0.0's Theme.read. The coloured themes name the background they are made for, and every text colour meets WCAG AA (4.5:1) against it, lines and borders 3:1; high-contrast meets AAA (7:1) against black and has no dim text. mono has no colour: its styles name default, the terminal's own.
  • textcase 0.2.0: titlecase, snakecase, kebabcase and camelcase beside upcase, downcase and loud, at the same capability numbers. Listed above 0.1.0, which is unchanged.
  • wordplay 0.1.0, a second official WASM plugin: rot13, reverse and slug. It declares no permissions; textcase keeps ansi-output for its loud fence.
  • Every transform is UTF-8 safe. The ASCII-only ones pass a non-ASCII character through as part of its word. reverse moves character clusters (combining marks, variation selectors, skin tones, ZWJ sequences, flags) whole, without Unicode tables, so it is not full UAX #29. Each takes at least 256 KiB per call within the default fuel.
  • Each WASM version is its own file, crates/rich-cli/official/wasm/ID-VERSION.wat, so a listed version's source never changes; textcase 0.1.0 stays crates/rich-plugin-api/examples/wasm/textcase.wat. The builder's all_artifacts() adds the new set; artifacts() stays the 0.0.22 set that plugin_marketplace.rs's fixture marketplace lists.
  • tests/official_artifacts.rs pins all eleven hashes; tests/official_packages.rs checks the themes (parse, coverage, contrast, rendering through --theme-file) and the plugins (the host's loader on ASCII and non-ASCII input, the limits, --transform).
  • The listings were written with rich plugin publish from commit d26de521, and pass check_artifacts.py --base live-0.0.23 --allow-tier, the validator with jsonschema and test_scripts.py. A scratch copy signed with a throwaway key installs a theme, textcase@0.2.0 and wordplay, runs --transform titlecase and --transform rot13, and renders with --theme-file on the installed theme.
  • These seven versions are hosted as release assets of the marketplace (releases/download/ID-vVERSION/ID-VERSION.EXT), not raw main URLs.

Downloads, ratings and reviews (2026-10-26, the maintainer's choice: GitHub Releases for downloads, GitHub Discussions for reviews; rich never phones home):

  • The marketplace repository (official-2): check_artifacts.py reads a release asset of the repository from artifacts/ID/ and checks its tag and name; publish-artifacts.yml makes each missing release after the merge and reads the asset back (contents: write, no environment); stats.yml writes stats.json nightly to the stats branch (schemas/stats.schema.json); discussions.yml opens one Reviews discussion per package. The name check refuses reserved and lookalike ids and "official" wording in community listings without the name-approved label. Tests replay recorded crates.io responses and GitHub-shaped fixtures.
  • The CLI: rich plugin info shows downloads, 👍/👎 and the reviews link and its report gains stats; rich plugin search --sort downloads. Unsigned, display-only, a 3-second timeout, silent when missing (tests/plugin_stats.rs).
  • Waiting on the maintainer: enable Discussions and create the "Reviews" category (Announcement format), create the name-approved label, then run "Review discussions", "Stats" and "Publish artifacts" once by hand.

Searching it, on the web and locally (023-mktweb, done):

  • The docs' Marketplace page reads index.json and every listing from raw.githubusercontent.com (which sends Access-Control-Allow-Origin: *, even on a 404), checks each listing's SHA-256 against the index, and lists them with search (ids, names, descriptions, capabilities, authors), filters (kind, tier, official only, recommended only), sorts (official first, name, newest, downloads, rating, recommended), the tier and Recommended badges, "Authored by" lines, the stats (stats.json from the stats branch: unsigned, display-only, "—" for null), every version with its requirements and yanked status, deep links (marketplace/#id) and copy buttons. Before the first index it says the index is being signed. It does not check the signature; rich does. Plain JS and CSS, no CDN. scripts/check_marketplace_page.mjs tests its logic under Node and the page in Chromium against docs/tapes/fixtures/marketplace (the docs workflow runs it).
  • rich plugin search gains --kind, --tier and --recommended, matches capabilities and authors, needs every word, and points at browse and the page when nothing matches. docs/tapes/plugin-search.tape records search, info and browse against the fixture.
  • Telling packages apart: every command marks the tier (✓ official, approved for reviewed, community), says who publishes and authored each package, and reports add tier_label; tests/plugin_tiers.rs.
  • recommended, a maintainer's pick, is a new optional listing key (since, note of at most 140 characters): in the schema, the validator, Listing::recommended (rs-rich-marketplace's API snapshot re-blessed) and the CLI. The marketplace's validator stays pinned at release-0.0.22 until 0.0.23 is tagged, so no listing uses it before then.

18. Fuzz targets, brought forward from Stage 4 (2026-10-26)

Done (023-fuzz). The maintainer's call on 2026-10-26: the 1.0 plan's fuzzing item (Stage 4) comes into 0.0.23, so the exit criterion's four weeks of clean nightly runs can start. The four-week clock starts with the first scheduled run of fuzz.yml on main after this pull request merges (01:43 UTC, so expected the night of the merge); its date is recorded in the fuzzing design.

  • fuzz/, a cargo-fuzz workspace of its own (its own Cargo.lock, never built by the main workspace), with 19 targets over the crates' public entry points: markup, the ANSI decoder, Markdown and Json in core; DOT; Mermaid; CSV/TSV and JSONL; intuiTUIve's stylesheets; the VT emulator and tapes; the web input decoder and WebSocket frames; the marketplace's listings, lockfile, keys files, signatures, signed index and artifact checks; the plugin manifest and WASM module loading. Rendering targets bound their input and console width. Each has a small seed corpus from the crates' fixtures.
  • fuzz.yml, nightly (01:43 UTC) and by hand: one job per target, ten minutes each, with nightly-2026-05-20 and cargo-fuzz 0.13.2 pinned, the corpus kept in the Actions cache per target and a failing input uploaded. Not on pull requests: CI's check job only formats and checks the fuzz crate (cargo check --locked).
  • Found and fixed, each with a regression test:
  • core's AnsiDecoder panicked on a multi-byte character after ESC (. It skips one character, as rich 15.0.0 does (the test is captured from it).
  • a WASM plugin looping over memory.grow overflowed the host's stack in an optimised build, aborting it (wasmi 2.0.0's tail-call dispatch). rs-rich-ext builds wasmi with its portable dispatch.
  • wasmi 2.0.0 divided by zero on a code section with bytes but no functions. rs-rich-ext validates a module with wasmparser first.
  • Recorded: vt100 0.16 panics on a one-column screen and on some resizes. rs-rich-record already reports that as an error and tapes cannot ask for one column, so the vt target keeps to tape sizes.
  • A version bump makes fuzz/Cargo.lock stale, as it does the docs playground's: refresh it with cargo metadata --manifest-path fuzz/Cargo.toml > /dev/null, which CI's --locked check asks for.

19. Dependabot's open pull requests: ext, micro, marketplace, interact, cli, CI

Done (023-deps), except lumis.

  • Applied, lockfile only: wat 1.262.0, libc 0.2.191, syn 3.0.9, serde_json 1.0.154, trybuild 1.0.122, flate2 1.1.10 and rustix 1.1.5, in the root and crates/rich-py lockfiles (the playground's already had them).
  • Applied, actions: setup-node 7 (the same node24 runtime; we pin Node 22 and use neither its cache nor registry auth) and gh-action-pypi-publish 1.14.3, pinned to its tag's commit.
  • zip 9: names are decoded on demand, so ZipFile::name and ZipArchive::file_names return results. rs-rich-ext lists an undecodable name lossily (extraction still refuses it), rs-rich-micro refuses the pack. by_name and index_for_name now match raw bytes, which only differs for non-UTF-8 (CP437) names.
  • signal-hook 0.4 in rs-rich-interact: no code change. crossterm 0.29 still uses 0.3, so both are in the tree, sharing one registry.
  • Stale: Dependabot #52, #53 and #54 (main already has github-script 9, deploy-pages 5 and upload-pages-artifact 5).
  • Not in 0.0.23: lumis 0.17. It builds and passes rs-rich-lumis's tests unchanged, but raises lumis's minimum Rust from 1.91 to 1.95. No tape or guide screenshot is highlighted by lumis, so nothing needs re-recording. The bump waits on branch claude/next-priority-items-3c5p8e-wip-deps-023-lumis for the maintainer to raise rs-rich-lumis's minimum.
  • CI: dependabot.yml groups minor and patch updates into one pull request a week per ecosystem (local to this repository, under the "managed centrally" header). ci.yml, python.yml and pr-hygiene.yml cancel a pull request's superseded runs; other events get a group of their own, so they are never cancelled.

20. Themes reach Markdown, and a theme style replaces: rich, cli

Done (023-themefix). Two parity gaps the official themes (item 17) found:

  • Core's Markdown ignored the console's theme. Its styles were constants, so the themes' markdown.* entries had no effect in rich -m. It now resolves each style rich 15.0.0's markdown.py reads through the console's theme (console.get_style(name, default="none")): the default theme's are used to parse, and a console with another theme parses again with its own. The style stack follows upstream's order (the document style, each enclosing quote's and item's, the paragraph's or heading's, then the inline tags and the link in the order they opened), and <kbd> enters markdown.kbd, which it did not before. Golden markdown_theme.tsv: five cases (every style coloured, attributes only, a theme that does not inherit, links on and off). The default theme renders as before.
  • --theme-style repr.number=bold kept the numbers cyan. Not core (its theme stack already replaces a named style, golden highlight.tsv's attributes_only_number): the CLI installed rs-rich-ext's example NumberHighlighter, which painted every run of digits a fixed bold cyan over the theme's repr.number. Upstream rich-cli has no such highlighter, so the CLI's rendering consoles no longer install it (they keep rs-rich-ext's syntect code highlighting). Digits the repr highlighter leaves alone (abc123) are plain, and 2.5 is one span, as upstream prints them.
  • tests/official_packages.rs now checks each theme's Markdown colours through rich -m.

21. HTTPS trusts the system's certificate store; WASM tests optimised in CI: marketplace, cli, repository

Done (023-tlsci).

  • The system's certificate store. rich plugin install dusk failed behind a TLS-inspecting proxy (invalid peer certificate: UnknownIssuer): rs-rich-marketplace's HttpsTransport, the CLI's rich <url> and its marketplace statistics fetch trusted only the bundled webpki roots. A new rich_marketplace::tls module (behind http) tries ureq's platform verifier first and the bundled roots once more on a TLS or I/O failure, as rs-rich-doom's fetch-freedoom does; all three clients use it. HTTPS-only, the redirect limits, signatures and SHA-256 pins are unchanged.
  • Redirects, one request at a time. With TLS through, the same install then failed with protocol: chunk length cannot be read as a number: ureq (to 3.5.0) takes a redirect as soon as its Location header has arrived when it follows redirects itself without a cookie store, and the blank line ending the headers, arriving in a later TLS record, is read as the chunked body. The proxy forwards GitHub's 302 in many small records. rich_marketplace::redirect::get_following follows redirects with ureq's own off (max_redirects(0)), with the same limits (5 for the transport, 10 for rich <url>, 3 for the statistics) and the HTTPS-only rule. Worth reporting to ureq.
  • Tests: tests/https_trust.rs serves a test CA's certificate over rustls, puts the CA in the "system" store with SSL_CERT_FILE, checks the bundled roots refuse it and the transport accepts it, and serves the chunked 302 a piece at a time (Linux and the BSDs, where the variable is honoured). Checked against the live marketplace through the sandbox's proxy: before, UnknownIssuer; after, Installed dusk 1.0.0.
  • New crates, all permissive and within the 1.90 MSRV (the highest rust-version is 1.85): rustls-platform-verifier 0.7, rustls-native-certs 0.8, openssl-probe 0.2, webpki-root-certs 1, and per platform security-framework, core-foundation and schannel.
  • Release-mode WASM tests in CI. A new wasm-plugins-release job, in ci-ok's needs, runs rs-rich-ext's wasm_plugins tests and the CLI's plugin_marketplace, official_artifacts and plugins tests with --release, where the memory.grow stack overflow (item 18) showed.

22. Noctiluca on real sites: docs, noctiluca (2026-10-27)

Done (023-realsites). A gallery page, Noctiluca on real sites, with screenshots of BBC News, Wikipedia, Hacker News, MDN, The Guardian, GitHub, YouTube and Twitch, recorded on 2026-10-27 against Chrome for Testing 153.

  • Eight tapes in docs/tapes/live/, outputs in docs/media/tapes/live/. CI's tape job checks only docs/tapes/*.tape, so these are recorded by hand and never re-run or compared; each provenance.json adds the recording date, the sites and the browser.
  • Seven bugs found and fixed in noctiluca, each with a test (unit tests and two fixture pages reduced from the sites, rank-table.html and narrow-grid.html): a table holding a table is read as blocks; a text box with no area, or text moved off the page, no longer lands at the page's top; <html> and <body> are never appended-to live logs; find highlights by byte offset; a table row's cells start on one terminal row and rows keep their order (Hacker News's ranks); a snapped grid scales its boxes to an item's new width rather than clamping them to one column; a picture in a format the decoder does not read (The Guardian's AVIF) comes from Chrome's pixels, and a captioned placeholder no longer repeats its alt text. No new image codecs: Chrome's pixels cover AVIF and WebP without growing the binary.
  • Not shown: YouTube answered with its bot wall. Open: The Guardian's narrow caption column cut short at 120 columns; MDN's gaps; Twitch's repeated sidebar tooltips.

23. WebP decoding on by default: art, noctiluca, micro, cli (2026-10-27)

Done (023-webp), the maintainer's call: item 22 left WebP to Chrome's pixels, and WebP is common enough on the web and in docs to decode it ourselves.

  • The workspace's image dependency gains webp: image's pure-Rust image-webp 0.2.4 (MIT OR Apache-2.0, rust-version 1.80.1, under our 1.90) and quick-error 2.0.1 (MIT/Apache-2.0). Measured on a release probe binary that only decodes images (thin LTO, as the workspace's release profile): +304 KiB unstripped, +268 KiB stripped, about 0.9% of the rich CLI's 34.8 MB.
  • rs-rich-art: graphics::decode_animation, AnimatedArt and Picture::decode_animated read an animated WebP as they do a GIF (a still WebP is one frame); everything that went through image::open or load_from_memory reads WebP by content and by .webp.
  • noctiluca: decodable() now passes WebP, so a page's WebP pictures are decoded from the file, and animated ones move; AVIF still falls back to Chrome's pixels.
  • rs-rich-micro: the pipeline (rich micro create) reads WebP, and a WebP animation plays like a GIF or APNG one instead of drawing its still.
  • rs-rich-cli: rich view plays an animated WebP (a VP8X header with the animation flag) as it does a GIF; --help, the unsupported-format message, docs/cli.md and the CLI reference list WebP.
  • Tests: a still and an animated lossless WebP made from ball.gif by crates/rich-art/tests/fixtures/make_webp.py (Pillow), compared with the GIF's frames; CLI, Noctiluca and micro tests.
  • AVIF is handed on to 0.0.24.

24. The marketplace's own site: docs, cli, marketplace mirror (2026-10-27)

Done (023-gallery). The marketplace now has its own web site, buchochelliq-labs.github.io/rs-rich-marketplace (the marketplace repository's site/, published by its pages.yml).

  • Links: where rs-rich-cli tells people to browse the marketplace on the web (the README, Marketplace, the CLI guide, Extending, the design note), the site comes first and the docs' Marketplace page second. rich plugin --help has an "On the web" section with both, and rich plugin search names the site first when nothing matches; the CLI reference is regenerated.
  • The mirror: docs/marketplace-repo/ gains the site's files from marketplace main (0bfd0bf), byte for byte: pages.yml, site-check.yml, scripts/build_site.py, scripts/check_site.mjs, the changed test_scripts.py and fixtures/README.md, scripts/fixtures/site/, and site/ with its fonts and their licences. Only the README differs from marketplace main; it describes the site and adds the Pages setup step.
  • Tapes re-recorded after merging items 22 and 23: jump (the guide gained noctiluca/real-sites.md: one more file, eleven more headings), noctiluca and noctiluca-reading (item 22's layout fixes: a box with no area no longer pulls the article's masthead to column 0, and a table cell wider than its column grows left instead of being cut).

Delivered

Tested SHA: b4bcaee (main after #823; its tree is #823's head, d1341be, where CI passed), with the release PR's prep commits on top (b4bcaee..023-release: the Textual audit, docs and the changelog only, no code or manifest). See the release notes.

Item PR Merged as
Versions opened, 0.0.22's publication notes, this plan #772: every package one patch past 0.0.22 7e5e6ea
5, 6, 7. Rendering performance, the hidden switch, lighter strings and the fast path #773: paint strategies with best the default, the timedemo, scroll_virtual, frame compression, the memory limit fix, CompactString, rs-rich-ext's Draw, rich's fast paths 5f9a293
4. Noctiluca's media #780: pictures, GIFs, video, the PoC's header and status bar, live logs, terminal adaptations 98854f7 (squashed)
1, 2, 8 to 17. The third wave #783: rs-rich-doom's core gaps, rich-rst 2.2.0, console.py, Progress.log, nested Live, the demo, Surface on best, sound in tapes, palette Sixel, the API checks and stubs, the official keys, the marketplace's listings and signing 61a2a6d
17 (rest), 18 to 21. The extras #795: fuzz targets, Dependabot's pull requests (not lumis 0.17), themes in Markdown, system TLS trust and redirects, the marketplace's statistics, search filters and official themes and plugins, Noctiluca's videos c67d2b8
22 to 24. The gallery #823: Noctiluca on real sites, WebP, the marketplace's own site b4bcaee
Added: intuiTUIve against Textual The release PR: the parity audit, its gaps filed as #797 to #815
3. Memo's illustrations Not done: waits on the maintainer's reference pictures; moves to 0.0.24

The marketplace went live on 2026-10-26 (item 17). AVIF and lumis 0.17 move to 0.0.24.

Package impact

Against the 0.0.22 cohort's versions (published 2026-10-25). The versions are open: every package moved one patch past 0.0.22, in the commit after 0.0.22's publication notes (the commit docs-versions.toml names for 0.0.22's docs). Cargo's 0.0.x requirements are exact, so a crate that depends on one that moves moves too. What each carries is in the release notes.

Package Opened at
rs-rich 0.0.13
rs-rich-macros 0.0.7
rs-rich-plugin-api 0.0.7
rs-rich-art 0.0.16
rs-rich-diagram 0.0.6
rs-rich-mermaid 0.0.9
rs-rich-lumis 0.0.7
rs-rich-ext 0.0.20
rs-rich-micro 0.0.9
rs-rich-record 0.0.11
rs-rich-interact 0.0.11
rs-rich-ratatui 0.0.8
rs-rich-intuituive 0.0.8
rs-rich-embed 0.0.6
rs-rich-web 0.0.6
rs-rich-data 0.0.7
rs-rich-marketplace 0.0.2
noctiluca 0.0.2
rs-rich-cli 0.0.23
rs-rich (PyPI) 0.0.11