# `plot` interaction, fonts, legend and multi-plot — Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Make the y-axis label vertical, replace the 1-bit bitmap font with an anti-aliased embedded Adwaita Mono plus runtime external-font support, make cursors and areas directly draggable, add a signal legend, and add a multi-plot figure with a synchronised x axis. **Architecture:** The text pipeline is refactored first so a `TextBackend` owns its glyph atlas and staged glyphs carry a rotation basis — that single change is what makes both the new font and the vertical label possible. Everything else is additive: a `Legend` on the plot, a `Region` primitive plus direct-manipulation drag modes on the controller, and a `Figure` that stacks plots and shares an x range through a link group. `Renderer::draw(Plot&)` stays exactly as it is. **Tech stack:** C++20, CMake, OpenGL 3.3 core, `fontTools` (authoring-time only) for atlas generation, vendored `stb_truetype.h` for the optional runtime-font target. **Spec:** `docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md` (read it before starting any task). ## Global Constraints - `plot_core` and every public header under `include/plot/` MUST stay free of third-party code. The narrowed rule: only the optional `plot_font_ttf` target may use the vendored `third_party/stb_truetype.h`. - Public headers MUST be self-contained; the configure-time umbrella and self-containment checks must keep passing (a new header must be added to `plot.hpp` or configure fails). - C++20; our targets stay clean under `-Wall -Wextra -Wpedantic -Wconversion -Wshadow`, never suppressed per-file. - No exceptions across the public API boundary: fallible operations return `bool`/`Status` plus an out-param. Do not introduce a `Result`. - Vertex data stays in DATA space; pan/zoom is a uniform update; per-frame cost stays O(visible pixels + produced samples). - The renderer clears only its viewport region via scissor and hands back host GL state (extend the documented residue list for anything new). - Exact interval queries go through `Series::reduce`/`sum`/`sumsq`, never the pyramid's `*_buckets` forms. - OpenGL 3.3 core baseline; anything above stays behind `plot::gl::caps()`. - The 130 existing test cases keep passing. Any signature change is called out in the task report. - Every task runs `cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` and commits with the message given in its own section. --- ### Task 1: Text atlas interface and rotated glyphs **Files:** - Modify: `include/plot/render/text.hpp`, `include/plot/render/bitmap_font.hpp`, `src/render/bitmap_font.cpp`, `include/plot/render/primitives.hpp`, `include/plot/render/buffer.hpp`, `include/plot/render/program.hpp`, `include/plot/render/shaders.hpp`, `src/render/shaders.cpp`, `src/render/renderer.cpp`, `include/plot/gl/handle.hpp` - Test: `tests/test_primitives.cpp`, `tests/test_render_smoke.cpp` **Interfaces:** - Consumes: existing `BitmapFont`, `TextBatch`, `TextProgram`, `Texture::create_r8`/`upload_r8`, `Renderer`. - Produces: `plot::render::Glyph{u0,v0,u1,v1,xoff,yoff,advance}`; the widened `TextBackend` with `glyph(u32)`, `atlas_pixels()`, `atlas_width()`, `atlas_height()`, `revision()`; `TextBatch::add_text(const char* utf8, Vec2 pen, f32 px, TextBackend&, Color, Vec2 axis = {1,0})` and `add_glyph(..., Vec2 axis)`; the text instance as 6 floats `{x,y,u,v,ax,ay}`. - [ ] **Step 1: Replace the backend interface and the text instance layout** `include/plot/render/text.hpp` — add `Glyph` and widen `TextBackend` exactly as spec §1 shows. Keep `measure`/`height`/`ascent` as they are so `PlotLayout` is untouched by this step. `include/plot/render/buffer.hpp` — `TextInstance` becomes `{f32 x, y, u, v, ax, ay;}` with `ax=1, ay=0` meaning "unrotated". Update `TextVertexArray`'s attribute layout and stride accordingly, and document the basis in the header: `(ax, ay)` is the glyph's local +x direction in **pixels**, so `(0,-1)` is a run advancing upward (a 90° CCW y label). `src/render/shaders.cpp` — `kTextVS` builds the cell from the basis and its perpendicular instead of hardcoded `+x`/`+y`, and maps UVs consistently with the basis. Delete `kGlyphTexels`; the cell size comes from `u_px_range` and the glyph's rect comes from the instance UVs. Required UV convention: `(u0,v0)` is the glyph's top-left texel and `(u1,v1)` its bottom-right, with `v` increasing downward in the atlas. - [ ] **Step 2: Make the renderer upload the backend's atlas and re-upload on revision change** `src/render/renderer.cpp` — replace the hardcoded 128×56 upload with: upload `backend.atlas_pixels()` at `atlas_width() × atlas_height()` (GL_R8, `GL_UNPACK_ALIGNMENT` 1 while uploading, restored after), remember the `revision()` used, and re-upload whenever it changes. Handle a null atlas, a zero-sized atlas, and a null `glyph()` (draw the fallback box using a glyph the backend does provide, or skip if it provides none). - [ ] **Step 3: Tests** Commit in `tests/test_render_smoke.cpp`: - `rotated_text_advances_along_its_basis` — a run of 6 glyphs at `axis = (0,-1)` occupies a box taller than it is wide, and the ink is *above* the pen (negative y), not to its right. Assert with the pixel scanner, and fail if the basis is ignored. - `rotated_glyph_orientation_is_not_mirrored` — an asymmetric glyph (use `L`) rotated 90° CCW has its ink in the correct corner; compute the expected corner from the basis rather than hardcoding a pixel, so the test states its own rule. - `atlas_is_reuploaded_when_the_backend_revision_changes` — a test double `TextBackend` with a *different* atlas size that bumps `revision()` mid-run must render the new atlas; assert the rendered result changes and that a stale atlas would not produce it. In `tests/test_primitives.cpp`: `add_text` advances the pen by the backend's advances along the basis, and `measure` and the staged run agree in length for the same string. - [ ] **Step 4: Verify and commit** Run `cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure`; all cases pass, warning-clean, and each new test fails against the pre-change code (report the failure text). ```bash git add -A && git commit -m "refactor(render): TextBackend owns its atlas, and staged glyphs carry a rotation basis" ``` --- ### Task 2: Embedded Adwaita Mono atlas, generated at authoring time **Files:** - Create: `tools/fonts/gen_atlas.py`, `licences/OFL-1.1.txt`, `include/plot/render/embedded_mono_font.hpp`, `src/render/embedded_mono_font.cpp` - Delete: `include/plot/render/bitmap_font.hpp`, `src/render/bitmap_font.cpp` - Modify: every caller of `BitmapFont` (`src/render/renderer.cpp`, `adapters/*`, `examples/*`, `tests/*`) **Interfaces:** - Consumes: the widened `TextBackend` from Task 1. - Produces: `plot::render::EmbeddedMonoFont final : public TextBackend`, dropping in wherever `BitmapFont` was; and `tools/fonts/gen_atlas.py` regenerating `src/render/embedded_mono_font.cpp` deterministically. - [ ] **Step 1: Write the generator** `tools/fonts/gen_atlas.py` takes the source TTF path, an em size, and an output path; rasterises the coverage set with `fontTools` (`fontTools.pens`, grayscale supersampling with a simple coverage count — do not shell out to a rasteriser); packs glyphs into a single-row atlas in codepoint order; emits a C++ translation unit containing the pixel bytes, the per-glyph rect/bearing/advance table, and a header comment with provenance (source font file name, its version string, em size, supersample factor, generator invocation, coverage list, and the licence file path). Hard requirements: - Assert every requested codepoint produced non-empty ink, and exit non-zero if not. A silent blank glyph must be impossible. - Be deterministic: same input and em size must produce a byte-identical file (sort inputs, no timestamps, no dict-iteration order dependence). - Default coverage: ASCII 32..126, U+00B0, U+00B5, U+03A9, U+2013, U+2014, plus a fallback box glyph stored at a fixed index. - `--check` mode: regenerate into a temp file and fail if it differs from the committed one, so drift is caught in CI. - [ ] **Step 2: Generate and commit the atlas** Run it against `/usr/share/fonts/Adwaita/AdwaitaMono-Regular.ttf` at 13 px em with 4× supersampling. Commit the generated `.cpp`, `licences/OFL-1.1.txt` (copied verbatim from `/usr/share/licenses/adwaita-fonts/LICENSE`), and the generator. Rename `BitmapFont` to `EmbeddedMonoFont` in the same commit and migrate **every** caller — no alias, no re-export; `grep -rn "BitmapFont"` must return nothing when you are done. - [ ] **Step 3: Tests** - `embedded_atlas_covers_every_expected_codepoint` — every codepoint in the coverage set has a glyph whose rect is inside the atlas and whose ink is non-empty; the fallback has non-empty ink. - `embedded_font_metrics_are_sane` — `height() > ascent() > 0`; `measure("i") < measure("W")`; `measure("") == 0`; advance is monotone in string length. - `generator_is_deterministic_and_current` — run `gen_atlas.py --check` from the test (skip with a clear message if `fontTools` is absent, but do not silently pass), asserting the committed atlas matches its generator. - The existing `bitmap_font_bit_order_runs_left_to_right` case is replaced by an equivalent absolute-orientation assertion on the new atlas. - [ ] **Step 4: Verify the visual result** Because the whole point is that the old font looked terrible, produce evidence rather than an assertion: render a frame headlessly and report a crop of the axis-label region as ASCII ink coverage, plus the mean stroke weight and the ratio of antialiased (mid-grey) to fully-covered pixels. AA text must show a substantial mid-grey population — a 1-bit font cannot, so this number distinguishes the change. - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat(render): anti-aliased embedded Adwaita Mono atlas, generated and committed" ``` --- ### Task 3: Vertical y-axis label **Files:** - Modify: `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `src/render/renderer.cpp` - Test: `tests/test_plot.cpp`, `tests/test_render_smoke.cpp` **Interfaces:** - Consumes: Task 1's rotation basis; `PlotLayout`. - Produces: `PlotLayout::y_title_area` as a narrow vertical strip and `y_title_width` as that strip's width (one line height); the y title drawn bottom-to-top. - [ ] **Step 1: Change the layout** `Plot::layout()` — `y_title_area` becomes one text line **wide** (`height()` + padding) placed outside `y_label_area`, spanning the axes' vertical extent; `y_title_width` is that width. The spec's rule replaces "one row per titled y axis" with "one column per titled y axis", so a Left/Right titled pair still gets its own column and cannot collide. An axis with no title reserves nothing, as before. - [ ] **Step 2: Rotate the title** `draw_titles()` — stage the x title as today (axis `(1,0)`), and the y title with `axis = (0,-1)` starting from the strip's bottom-left, so it reads bottom-to-top. Keep the existing rule that a title is never drawn inside `axes`. - [ ] **Step 3: Tests** - `y_title_strip_is_narrow_when_a_y_title_is_set` — with a y title, `y_title_area.w` is about one line height and `y_title_area.h` is about the axes height; `axes` is inset by it; the strip lies outside `y_label_area`. - `y_title_ink_is_rotated_and_outside_the_axes` — pixel test: ink exists in `y_title_area`; its bounding box is taller than it is wide for a title that is wider than tall when horizontal; and no title-coloured ink appears inside `axes`. Must fail if the axis basis is left at `(1,0)`. - `two_titled_y_axes_get_two_columns` — a Left and a Right titled axis occupy distinct x ranges in the strip. - [ ] **Step 4: Verify and commit** Also run the GLFW demo headlessly and report the y-label region from its own frame so the change is visible, not just asserted. ```bash git add -A && git commit -m "feat(plot): vertical y-axis label via a rotated text run" ``` --- ### Task 4: Signal legend **Files:** - Create: `include/plot/plot/legend.hpp` - Modify: `include/plot/plot/trace.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `src/render/renderer.cpp`, `include/plot/plot.hpp` (umbrella) - Test: `tests/test_plot.cpp`, `tests/test_render_smoke.cpp` **Interfaces:** - Consumes: `TextBackend` metrics via the plot's backend, `Trace`, `PlotLayout`. - Produces: `Trace::name`; `plot::LegendPlace`; `plot::Legend{visible, place, frame, pad, swatch_w, swatch_h, row_gap, margin}`; `Plot::legend()`, `Plot::set_legend(const Legend&)`, and a computed `LegendLayout` (panel rect + per-row rects) that `layout()` exposes so tests and the renderer agree. - [ ] **Step 1: Trace name and legend config** Add `std::string name;` to `Trace` (empty means no entry), and the `Legend` struct per spec §4 with the stated defaults. Add the legend accessors to `Plot` and include the new header from the umbrella. - [ ] **Step 2: Legend layout** Compute it in `Plot::layout()` from the plot's `TextBackend`: entry rows are `max(swatch_h, line_height)` tall with `row_gap` between; the panel is `pad + swatch_w + gap + widest_name + pad` by `pad + rows + pad`, anchored per `place` and inset by `margin`, clamped inside `axes`. If the panel does not fit, drop entries from the end and add a `…` row; if nothing fits, produce an empty layout. No backend ⇒ no legend (nothing to measure), and a plot whose traces are all unnamed produces an empty layout and draws nothing. - [ ] **Step 3: Draw it** In `draw()` after the grid and traces, before text is flushed: a filled panel in `style.background` at partial alpha when `frame` is set, a 1 px border, a filled swatch per entry in that trace's colour, and the name in `axis_text`. A trace bound to a null series or marked invisible contributes no entry. Clamp so the panel cannot cross `axes`. - [ ] **Step 4: Tests** - Layout: `legend_panel_wraps_the_entries`, `legend_is_clamped_inside_the_axes` (place each of the four corners), `legend_drops_overflowing_entries`, `unnamed_traces_produce_no_legend`, `no_text_backend_produces_no_legend`. - Pixel: `legend_swatch_and_label_are_drawn` — the swatch colour and `axis_text` both appear inside the panel rect and not outside it; and the presence of the panel does not change trace pixel coverage outside `axes`. - `legend_does_not_cover_the_plot_when_placed_bottom_left` — assert the panel rect is inside `axes` and that no swatch pixel lands outside it. - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat(plot): per-trace legend with measured layout and swatches" ``` --- ### Task 5: Direct manipulation — grabbable cursors, regions and band edges **Files:** - Create: `include/plot/plot/region.hpp` - Modify: `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/input/controller.hpp`, `src/input/controller.cpp`, `src/render/renderer.cpp`, `include/plot/plot.hpp` (umbrella) - Test: `tests/test_controller.cpp`, `tests/test_plot.cpp`, `tests/test_render_smoke.cpp` **Interfaces:** - Consumes: `Plot::layout()`, the axis transforms, the existing cursor hit radius and `DragMode`. - Produces: `plot::Region`, `plot::RegionSnap`, `plot::RegionStats`; `Plot::add_region/region/set_region/region_stats/remove_region`; `DragMode::MoveRegion`, `DragMode::ResizeRegionLeft`, `DragMode::ResizeRegionRight`; `plot::input::RegionGrab{None, Left, Right, Body}` and `Controller::region_grab(u32) -> RegionGrab` reporting the hot zone. - [ ] **Step 1: The Region primitive** Add the type and the plot accessors per spec §5. Bounds are ordered on assignment (`set_region` stores `min`/`max`, so `x0 > x1` cannot be stored). `region_stats(region, trace)` is exact via `Series::reduce`/`sum`/`sumsq` over the region's sample interval, and caches on (region, trace, series identity, sample count, `x0()`) exactly as `Plot::measurement` does after its final-review fix — the sliding-window term included, or a streaming region shows frozen statistics, which is the bug that fix existed to prevent. - [ ] **Step 2: Cursors grabbable without a modifier** `Controller::begin_drag` — hit-test cursor markers **first**, before pan, using the same radius `hovered_cursor()` already uses; a hit enters `MoveCursor` with no modifier required. Ctrl+drag keeps working (it must not regress into a different mode). Add `Controller::grabbed_cursor()` and expose the hovered/grabbed cursor to the renderer so the marker can be drawn highlighted. - [ ] **Step 3: Region drag zones** Resolve the hot zone nearest-first: an edge within the radius beats the body; a region narrower than 3 px cannot be grabbed by its edges (so it stays movable once collapsed). `MoveRegion` translates both bounds preserving width; the resize modes move one bound and re-order via `set_region`. Snapping follows `RegionSnap` and applies on release. Pressing where no handle is hot preserves today's pan/box-zoom behaviour exactly. - [ ] **Step 4: Draggable measurement-band edges** Hit-test the cursor-bound band's two edges in the same pass, and map a grab to the cursor that owns that edge, so an existing measurement band can be resized by dragging either end while `Plot::measurement` updates as it does today. No new plumbing: it is the cursor drag path with a different hit target. - [ ] **Step 5: Draw regions and highlights** Filled `Region::fill` over the x interval between the axes bounds, a `Region::edge` line at each bound, an optional label, the grabbed/hovered handle highlighted. Extend `renderer.hpp`'s documented GL-state residue list if anything new is left behind. - [ ] **Step 6: Tests** Controller: `cursor_is_grabbed_without_a_modifier`; `pressing_away_from_a_handle_still_pans`; `region_edge_wins_over_region_body`; `region_body_move_preserves_width`; `region_bounds_stay_ordered_when_dragged_past_each_other`; `collapsed_region_cannot_be_grabbed_by_an_edge`; `band_edge_drag_moves_the_owning_cursor_and_updates_the_measurement`. Plot: `region_stats_match_bruteforce_over_an_unaligned_interval`; `region_stats_track_a_sliding_streaming_window` (the saturated-ring case). Pixel: `region_fill_and_edges_are_drawn_inside_the_axes`; `grabbed_handle_is_highlighted`. - [ ] **Step 7: Commit** ```bash git add -A && git commit -m "feat(input): draggable cursors, regions and measurement-band edges" ``` --- ### Task 6: Figure — stacked plots with a synchronised x axis **Files:** - Create: `include/plot/plot/figure.hpp`, `src/plot/figure.cpp` - Modify: `include/plot/render/renderer.hpp`, `src/render/renderer.cpp`, `include/plot/plot.hpp` (umbrella) - Test: `tests/test_figure.cpp`, `tests/test_render_smoke.cpp` **Interfaces:** - Consumes: `Plot`, `PlotLayout`, `Renderer::draw(Plot&)`, the controller's range setters. - Produces: `plot::Figure` and `plot::FigureLayout` per spec §6; `Renderer::draw(Figure&)`; `Figure::plot(u32)` row access, `add_x_link()`, `link_x(u32 row, u32 group)`, `set_x_link_range(u32, Range)`, `set_row_weights(std::span)`, `set_x_labels_only_on_bottom(bool)`. - [ ] **Step 1: Layout** `FigureLayout` holds `std::vector rows`, a shared bottom x-axis strip, and the inter-row gap. Row heights come from equal division or `set_row_weights` (normalised; a zero/negative weight set is rejected with `Status` rather than producing a degenerate rect). Each row's `PlotLayout` is derived by setting that plot's viewport to its row rect — so a row's own y-axis strips still come from its own `Plot::layout()`. Give every row a floor height so chrome cannot erase a plot (the same invariant the title strips needed). - [ ] **Step 2: The x link** A link group holds one `Range`. `link_x` moves a plot into a group and immediately applies that group's range to it. `set_x_link_range` sets the group and pushes to every member. A range change arriving from the controller on row *i* is applied to the group and pushed to all members before the next frame. **Propagation must be idempotent and must terminate**: a member receiving a propagated range must not re-propagate it — enforce this with an explicit flag or by comparing ranges before assigning, and test it. - [ ] **Step 3: Render** `Renderer::draw(Figure&)` — for each row: `plot.set_viewport(row rect)`, then the existing single-plot draw path; then draw one shared x axis under the bottom row. Interior rows suppress their x label strip when `set_x_labels_only_on_bottom(true)` (the default), so labels are not duplicated. `draw(Plot&)` is untouched. - [ ] **Step 4: Tests** - Layout: `row_rects_tile_the_figure_without_overlap`, `row_weights_change_relative_heights_and_sum_to_the_viewport`, `degenerate_row_weights_are_rejected`, `every_row_keeps_a_positive_height`. - Link: `link_x_applies_the_group_range_immediately`, `setting_a_range_on_any_member_propagates_to_all`, `propagation_is_idempotent_and_terminates` (drive a long alternating sequence and assert it converges in one pass), `unlinked_plots_keep_independent_ranges`. - Pixel: `two_rows_render_distinct_content` (different trace colours land in different row rects), `shared_x_axis_is_drawn_once` (count label ink in the bottom strip only, and none in the interior rows' strips when labels are bottom-only), `a_row_resize_does_not_rescale_a_stale_layout`. - [ ] **Step 5: Commit** ```bash git add -A && git commit -m "feat(plot): Figure with stacked rows and a synchronised x axis" ``` --- ### Task 7: Multi-plot example, legends everywhere, full verification **Files:** - Create: `examples/multiplot_demo.cpp` - Modify: `examples/{glfw,imgui,qt,gtk}_demo.cpp`, `examples/CMakeLists.txt`, `CMakePresets.json` if needed - Modify: `README`-class docs only if one exists (it does not — do not create one) **Interfaces:** - Consumes: everything above. - [ ] **Step 1: The multi-plot example** `examples/multiplot_demo.cpp`: a 3-row `Figure` on the GLFW host. Row 1 carries three named signals (a sine, a square, and a noisy ramp) on a shared y axis with a legend; row 2 carries two signals at a different scale; row 3 carries a single sparse signal. All three rows share one x link, so panning or zooming anywhere moves all three together. Every signal is named so the legend is populated. Add two cursors and one region with a stats readout, and drive it at a high sample rate through a `StreamSeries` so the sync is exercised while data slides. Reuse the project's `PLOT_DEMO_EXIT_AFTER_MS` / `PLOT_DEMO_SCREENSHOT` headless hooks. - [ ] **Step 2: Legends and interaction in the existing demos** Add names to the traces and enable the legend in all four existing demos, and add one region to at least one of them so the new interaction is reachable in the GLFW and Qt demos (the two with the richest input paths). Do not restructure those demos otherwise. - [ ] **Step 3: Full verification** ``` cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure cmake --preset linux-asan && cmake --build build/asan && ctest --test-dir build/asan --output-on-failure cmake --preset linux-release && cmake --build build/release ``` Run all five demos headlessly with their own screenshot paths and confirm, from each PPM: trace pixels present, axis text present, legend swatch colours present where a legend is on, and the region fill present. Then report each demo's measured frame time against the pre-change baselines (GLFW ~0.7-1.3 ms, Qt ~3.6 ms, GTK ~1.1-5.3 ms at 810x564, ImGui ~0.5-0.76 ms) so any regression is visible. - [ ] **Step 4: Commit** ```bash git add -A && git commit -m "feat(examples): multi-plot synced demo, legends in every demo, verified headlessly" ``` --- ### Task 8: Optional runtime TrueType font target Independent of Tasks 3-7 (it needs only Task 1's interface), so it can be done last. It exists because the request was for a font that is both embedded **and** user-customisable from external files. **Files:** - Create: `third_party/stb_truetype.h`, `include/plot/text/true_type_font.hpp`, `src/text/true_type_font.cpp`, `adapters/`-style `CMakeLists.txt` for the target - Modify: top-level `CMakeLists.txt` (new `PLOT_BUILD_TTF_FONT` option, default ON when the header is present), `include/plot/plot.hpp` **only if** the header is reachable without stb_truetype — see the note below - Test: `tests/test_true_type_font.cpp` **Interfaces:** - Consumes: the widened `TextBackend` from Task 1. - Produces: `plot::text::TrueTypeFont` with `static bool from_file(const char* path, f32 px, TrueTypeFont* out, Status* st = nullptr)` and `void set_coverage(std::span)`, implementing `TextBackend`. - [ ] **Step 1: Vendor and isolate** Download `stb_truetype.h` (public domain) into `third_party/` with its version and the source URL recorded in a comment at the top of our own wrapper, and in `third_party/README`-equivalent text inside the wrapper header — do not create a new markdown file. Define `STB_TRUETYPE_IMPLEMENTATION` in exactly one translation unit inside the new target. Confirm with `nm`/`ldd` that `plot_core` and the public headers are untouched: `plot_core` must link and include nothing new. - [ ] **Step 2: The backend** Implement `TrueTypeFont` over `stb_truetype`: bake the coverage set at `px` into a packed single-row R8 atlas, build the glyph table (rect, bearing, advance) from `stbtt_GetCodepointBitmap` and `stbtt_GetCodepointHMetrics`, and implement the full `TextBackend`, including `revision()` (monotone; bumped by `set_coverage` and by re-baking). Reject a missing file, an unparsable font, a non-positive `px`, and a bake that produced no ink, filling `Status`. **Important:** do **not** add this header to `include/plot/plot.hpp`. The umbrella must stay compilable with no third-party header on the include path, and the configure-time coverage check will reject a header it cannot compile standalone. Keep `plot/text/true_type_font.hpp` self-contained (it may include `plot/render/text.hpp` and the standard library, and must forward-declare nothing from stb_truetype), and document that it is built only by the optional target. - [ ] **Step 3: Tests** - `ttf_backend_bakes_the_requested_coverage` — load the Adwaita Mono TTF from the system path if present, else skip with a printed reason and a non-zero-skipped marker; assert every requested codepoint has non-empty ink and a plausible advance. - `ttf_failure_paths_report_status` — a nonexistent path, a directory path, a text file, and `px <= 0` each return false with a `Status` and leave `out` untouched. - `ttf_backend_renders_through_the_renderer` — bake at a size clearly different from the embedded font, render a frame with it as the plot's text backend, and assert the ink differs from the embedded font's for the same string (proving the renderer genuinely uses it, including the `revision()` re-upload path). - `plot_core_stays_free_of_stb_truetype` — assert the built `plot_core` archive contains no stb_truetype symbols and that no public header mentions it. - [ ] **Step 4: Verify and commit** Build with `PLOT_BUILD_TTF_FONT=OFF` as well and confirm everything still configures and passes, so the optional target is genuinely optional. ```bash git add -A && git commit -m "feat(text): optional vendored stb_truetype backend for runtime external fonts" ```