# `plot` — figure alignment, crosshair line and style, cursor dragging > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. **Goal:** Fix four defects reported from use: figure rows whose y axes do not line up; the crosshair's missing horizontal line in a figure; a crosshair that is too heavy and solid to read as chrome; and cursor dragging that grabs poorly and hops between samples. **Spec:** `docs/superpowers/specs/2026-09-28-plot-interaction-and-fonts-design.md` sections 16, 17, 18. Read the relevant section before each task. ## Global Constraints - `plot_core` and every public header under `include/plot/` stay free of third-party code. - Public headers MUST be self-contained; the configure-time umbrella and self-containment checks must keep passing (optional-target headers are exempt via the existing narrow list). - C++20; our targets stay clean under `-Wall -Wextra -Wpedantic -Wconversion -Wshadow`, never suppressed per-file. - No exceptions across the public API boundary: `bool`/`Status` plus out-params. - **Data-space geometry never scales**; chrome sizes are logical via `Style::device()`. Pointer coordinates reaching the controller are device pixels. - Vertex data stays in DATA space; per-frame cost stays O(visible pixels + produced samples). Dashing is a shader effect, not new geometry. - Everything drawn over the axes is clipped to `axes` (§13); the renderer hands back host GL state and its documented residue list stays accurate. - OpenGL 3.3 core baseline. - The 256 existing test cases keep passing; any signature change is called out. - Every task ends with `cmake --preset linux-debug && cmake --build build/debug && ctest --test-dir build/debug --output-on-failure` green and warning-clean, and commits with the message in its own section. **Commit as soon as the tree builds** — several agents have died of turn exhaustion in this phase. --- ### Task 1: Figure rows share one y gutter **Files:** - Modify: `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/plot/figure.hpp`, `src/plot/figure.cpp` - Test: `tests/test_figure.cpp`, `tests/test_render_smoke.cpp` **Interfaces:** - Consumes: `Plot::layout()`, `PlotLayout::y_label_area`/`y_title_area`/`axes`, `TextBackend` metrics. - Produces: `Plot::set_min_y_gutter(f32)` / `Plot::min_y_gutter()` (a figure-level imposition consulted by `layout()`), and `FigureLayout` gaining whatever the rows need to agree. - [ ] **Step 1: Reproduce the misalignment first** With a `Figure` whose rows carry different magnitudes ("671m", "90", "2.51"), assert that every row's `axes.x` and `axes.w` are equal. It fails today; record the three actual `axes.x` values as the defect's size. Also assert the drawn consequence: a vertical feature at a given x lands at the same pixel column in every row. - [ ] **Step 2: Impose a common gutter** Lay each row out once to learn the strip width its own labels need, take the maximum across rows (and include the title columns, so §3's column rule still holds), then re-lay every row out with that width imposed. A `Plot` standing alone must keep sizing its own strip from its own data — so the imposition is the figure's decision, expressed as a figure-level override on the plot rather than a change to how a lone plot sizes itself. Say in the report which shape you chose and why. - [ ] **Step 3: Keep the invariants** Confirm: a single-plot host is unaffected (the override is not set and nothing changes); the `axes` floor still holds for a tiny viewport; a figure with one row behaves exactly as the plot does; `Figure::layout()` still lays each row out once per call, not repeatedly until it settles (a fixed-point loop here would be the tempting wrong answer — the gutter is a maximum, computable in one pass, so say why it needs no iteration); and the shared x axis still lands in the bottom row's strip. - [ ] **Step 4: Verify and commit** Report the three pre-fix `axes.x` values and the single post-fix value, plus the pixel column of a vertical feature in each row. Run the multiplot demo headlessly and report the rows' `axes.x`/`axes.w` from one frame. ```bash git add -A && git commit -m "fix(plot): a figure gives every row the same y gutter so the axes line up" ``` --- ### Task 2: Crosshair horizontal line in the hovered row, and a thinner dashed style **Files:** - Modify: `include/plot/render/style.hpp`, `include/plot/plot/crosshair.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/plot/figure.hpp`, `src/plot/figure.cpp`, `src/input/controller.cpp`, `src/render/renderer.cpp`, `src/render/shaders.cpp` - Test: `tests/test_plot.cpp`, `tests/test_figure.cpp`, `tests/test_render_smoke.cpp` **Interfaces:** - Consumes: `Crosshair{vertical, horizontal, x, y}`, `kLineVS`'s `v_along`, the line pipeline's per-draw uniforms. - Produces: `Style::crosshair_width`, `Style::crosshair_dash_on`, `Style::crosshair_dash_off` (all logical); an optional per-`Crosshair` style override; a dash uniform in the line shader; `Figure::set_crosshair(f64 x, u32 hovered_row, f64 y_in_row, bool active)` (or an equivalent that carries the hovered row and its local y). - [ ] **Step 1: The horizontal line in the hovered row** The figure's sync currently calls the single-coordinate `set_crosshair`, so no horizontal line is drawn anywhere — correct for a figure-wide y, which would be meaningless, but wrong for the user, who wants the line where it *does* mean something. Carry which row the pointer is in and that row's local y through the figure's crosshair sync, apply the shared x to every row and the y only to the hovered row. Assert: with the pointer in row 1, row 1 has a horizontal line and rows 0 and 2 do not, while all three have vertical lines at the same x; and no row ever shows another row's y. Also assert that moving the pointer from row 1 to row 2 moves the horizontal line and leaves the vertical lines alone. - [ ] **Step 2: The dash, in the fragment shader** `kLineVS` already computes `v_along` (distance travelled along the segment, in pixels). Add a per-draw uniform carrying the dash pattern in pixels and, in the fragment stage, discard (or zero the alpha of) fragments where `mod(v_along, on + off) >= on`. `off == 0` must mean solid, so the same pipeline serves every other line in the library unchanged. Keep vertices in data space; add no CPU geometry. Report the shader diff and confirm the other line users (grid, traces, axis lines, regions, cursors, the legend) are unaffected by the new uniform's default. - [ ] **Step 3: Thinner and configurable** Add the three logical style knobs with the spec's defaults (0.75 px, 4/4 dash), route the crosshair's stroke through `Style::device()`, and allow a per-`Crosshair` override so one plot can differ without touching global style. Confirm the dash phase is stable frame to frame for a stationary crosshair (a pattern that crawls would look like noise) and say how you anchored it. - [ ] **Step 4: Tests** - `figure_crosshair_draws_a_horizontal_line_only_in_the_hovered_row`. - `crosshair_dash_is_configurable_and_zero_off_means_solid` — pixel test: a dashed crosshair leaves gaps along its length, a solid one does not, and the gap count changes with `crosshair_dash_off`. - `crosshair_is_thinner_than_a_trace` — assert the default stroke is below the trace line width and that the ink width follows `crosshair_width` and `ui_scale`. - `grid_traces_and_cursors_are_unaffected_by_the_dash_uniform` — a regression guard for the shared pipeline. - `dash_phase_is_stable_across_frames` — render the same frame twice and assert byte-identical output. - [ ] **Step 5: Verify and commit** Run the GLFW and multiplot demos headlessly and report, from the frames: the crosshair's ink row count for the horizontal line (one in the hovered row, zero elsewhere), a dashes-versus-gaps count along the vertical line, and the stroke width in device pixels at `ui_scale` 1 and 2. ```bash git add -A && git commit -m "feat(plot): the figure crosshair marks the hovered row, and the stroke is thin, dashed and configurable" ``` --- ### Task 3: Cursor dragging follows the pointer and grabs reliably **Files:** - Modify: `include/plot/plot/cursor.hpp`, `include/plot/plot/plot.hpp`, `src/plot/plot.cpp`, `include/plot/input/controller.hpp`, `src/input/controller.cpp` - Test: `tests/test_controller.cpp`, `tests/test_plot.cpp` **Interfaces:** - Consumes: `Cursor::snap`, `Plot::set_cursor_x`, the controller's drag path and `kCursorHitRadiusLogical`. - Produces: snapping applied at the **end** of a drag rather than inside the setter, a documented free path for programmatic placement, and a hit-test order in which a cursor beats a nearby region handle. - [ ] **Step 1: Diagnose it with numbers before changing anything** Reproduce both halves of the report on a **sparse** row (few samples per pixel) and on a dense one: - **Snapping:** drive a sequence of pointer positions across the axes and record the cursor's `x` at each step, plus the pixel distance it moved versus the pointer. On a sparse row the cursor should be seen hopping to sample positions rather than tracking the pointer; quantify the hop size in pixels. Use an isolated worktree at the current HEAD for the before-numbers. - **Grabbing:** for a press at increasing distance from the marker, find the pixel distance at which the grab succeeds; and with a region overlapping the cursor, determine who wins. Report both numbers, then fix. - [ ] **Step 2: Snap on drop, not during the drag** Apply the snap when the drag ends: during the drag the cursor follows the pointer freely, and release commits the snapped position. Keep a documented free path on the setter for programmatic placement (and say whether `SnapMode::Free` remains meaningful). A host must be able to ask for the old behaviour if it wants it — so state what the default is and what the opt-out is. - [ ] **Step 3: Make the grab reliable** Raise the hit radius into the 6-8 logical px range with hover and grab sharing **one** value (no second constant), and give a cursor precedence over a region's edge or body within that radius, while a region still wins where no cursor is close. Confirm the region's own cases still pass and that a region with no cursor nearby remains fully draggable. - [ ] **Step 4: Tests** - `cursor_follows_the_pointer_freely_while_dragging` — on a sparse series the cursor's x tracks the pointer within a pixel at every move, not a sample position. - `cursor_snaps_when_the_drag_ends` — after release the stored x equals the nearest sample's x, and it did not during the drag. - `cursor_wins_the_grab_over_a_nearby_region_handle`, and `a_region_without_a_cursor_nearby_is_still_draggable`. - `hover_and_grab_use_the_same_radius` — a press exactly at the hover radius grabs, which is the property that makes the affordance honest. - [ ] **Step 5: Verify and commit** Report the before/after per-move pixel deltas on the sparse row, the grab-success distance before and after, and the dense-row behaviour (which must be unchanged in substance — it was already smooth). ```bash git add -A && git commit -m "fix(input): cursors follow the pointer while dragging and snap on drop" ```