diff --git a/docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md b/docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md new file mode 100644 index 0000000..168ce8d --- /dev/null +++ b/docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md @@ -0,0 +1,950 @@ +# Badge bar, slide-over and dock -- step 1 (the shell) 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:** Replace the sidebar's tab switcher with a plot-edge slide-over panel reached from a badge bar under the plot (Trigger and Source badges), keeping Measure and Signals reachable in a temporary always-on dock, so the app is usable after every task. + +**Architecture:** Two new units, `SlideOver` (`src/ui_slideover.cpp`, a `GtkRevealer` + `GtkStack` placed by margins as one opaque `GtkOverlay` child above the plot) and `BadgeBar` (`src/ui_badges.cpp`, a fixed-height row between the plot and the status line). The existing trigger form and the source kind's `SourcePanel` widgets are *reparented* into slide-over pages. The right `AdwOverlaySplitView` sidebar stays, but its `AdwViewSwitcher`/`AdwViewStack` become one scroller holding the signal list, the cursors and the measurements. The left events pane and everything else is untouched (spec step 4). + +**Tech Stack:** C++20, GTK4, libadwaita >= 1.8 (`AdwToggleGroup` is 1.7+), CMake. No unit-test suite: verification is the app's own headless self-check (`xvfb-run`, mock sources) -- extend `src/ui.cpp` / `src/ui_probe.cpp` probes, never a separate harness. + +Spec: `docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md` (read it first). Branch: `feature/ui-badges-dock`. + +## Global Constraints + +Copied from the spec and the repository's rules; every task includes them. + +- **Layout must not depend on content** (`docs/claude/invariants-plot-and-layout.md`): the dock has a fixed width, the badge bar a fixed height, the slide-over overlays the plot and never resizes it. Rows that appear by trigger condition appear inside the slide-over's scroller. +- **A `GtkOverlay` child is picked wherever its allocation is**: the slide-over is one opaque widget aligned to the end edge by `halign=END` and a 360 px width, never a full-area transparent child, so pan, drop and hover on the rest of the plot are untouched. +- The header's Trigger / Cursors / Pause toggles keep their two-way mirroring with the panels. +- `SourcePanel{id, title, widget}` is unchanged: the E-1608 and Flat UDP panels need no change. +- Page ids: `trigger`, `signal:`, `signals`, `measure`, `cursors`, `events`, `source-`. (Step 1 implements `trigger` and `source-` only.) +- The open slide-over page is **not** saved. The session file drops `side_tab`; an old file's `side_tab` is ignored. +- Esc or a click outside closes the slide-over; one page at a time; each page has its own scroller. +- "Disable, do not hide": the dock is shown but insensitive until the stream has signals. +- Text buttons only for new controls (no icon-only buttons, no icons in badges). +- Every new source file carries the SPDX MIT header (`// SPDX-License-Identifier: MIT` / `// SPDX-FileCopyrightText: 2026 Martino Ferrari`). +- Baselines: a clean build emits exactly **one** warning (the icons' `-Woverlength-strings`), an incremental build **zero**; the dump's `gtk warnings:` reads `0`; no `Gtk-CRITICAL`/`Gtk-WARNING`. +- Verification runs are short and serial; kill every mock you start; do not re-run to chase cosmetics. Headless needs `GDK_BACKEND=x11` (the host has `WAYLAND_DISPLAY`) and, for screenshots, `GSK_RENDERER=cairo` + `import -window root`. +- Do not push, merge or amend. Commit per task on `feature/ui-badges-dock`. + +## Deliberate step-1 deviations from the spec (state them in the final report) + +- The dock keeps the sidebar's **320 px** (spec: ~280). The panels it holds were built for 320; the width change belongs to step 3 when they become compact readouts. +- The session file gains the dock's *shown* state only, not a width: the dock's width is fixed, so there is nothing to persist. +- The Trigger badge shows `off / armed / triggered` plus `capturing` and `disarmed`, so the label never claims a state the engine is not in. + +## File Structure + +| File | Create/Modify | Responsibility | +| --- | --- | --- | +| `src/ui_slideover.cpp` | Create | The slide-over: build, page registry, open/close/toggle, Esc, click-away, `slideover` probe. | +| `src/ui_badges.cpp` | Create | The badge bar: Trigger and Source badges, per-tick state, pure `trigger_badge_state()`, `badges` probe. | +| `src/ui_app.hpp` | Modify | `SlideOver`/`BadgeBar` structs on `App`, declarations, removal of `side_stack`/`side_switcher`/`side_tab`/`keep_tabs_icon_free`. | +| `src/ui/main-window.ui` | Modify | `plot_overlay` around `plot_box`; `badge_bar` row; the sidebar tab switcher/stack replaced by one dock scroller. | +| `src/ui_widgets.cpp` | Modify | CSS for badges and the slide-over (`install_css`). | +| `src/ui.cpp` | Modify | Build/wire both units in `on_activate`; tick sync; Esc; dock visibility; remove tab code. | +| `src/ui_source.cpp` | Modify | Source panels become slide-over pages; `keep_tabs_icon_free` deleted. | +| `src/ui_tour.cpp`, `src/ui_context_help.cpp` | Modify | Tour/context-help re-pointed (task 5). | +| `src/ui_layout.cpp`, `src/layout_store.{hpp,cpp}` | Modify | `side_tab` out, `dock_shown` in. | +| `src/shortcuts.{hpp,cpp}`, `src/ui/shortcuts.ui` | Modify | `SideTabMeasure/Signals` -> `ShowMeasure/ShowSignals`. | +| `src/ui_probe.cpp` | Modify | New probes; every probe that touched the tab stack re-pointed. | +| `CMakeLists.txt` | Modify | Add the two new `.cpp` files to the `udpscope` sources (next to `src/ui_source.cpp`). | +| `README.md`, `docs/claude/*.md` | Modify | Task 6. (`USER-GUIDE.md` and the Mallard manual's figures are the spec's step 4.) | + +--- + +### Task 1: SlideOver unit and the plot overlay (confirms the compositing risk) + +Spec step 1 says: confirm the overlay-above-GL risk *before* anything else is built on it. This task builds the slide-over with a throw-away probe page and proves composite, geometry and input. + +**Files:** +- Create: `src/ui_slideover.cpp` +- Modify: `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_widgets.cpp`, `src/ui_probe.cpp` (or put the probe in `ui_slideover.cpp`; `ui_app.hpp` declares it either way), `CMakeLists.txt`, `docs/claude/verification.md` (probe env var line) + +**Interfaces:** +- Produces (all in `namespace udpscope::ui_impl`, declared in `ui_app.hpp`): + +```cpp +/// The slide-over panel's widgets and state (src/ui_slideover.cpp). `page` is the open page's id, empty when closed. +struct SlideOver { + GtkWidget* overlay = nullptr; ///< the GtkOverlay around the plot (main-window.ui `plot_overlay`) + GtkWidget* revealer = nullptr; ///< the one overlay child; halign END, valign FILL, 360 px + GtkWidget* title = nullptr; ///< GtkLabel in the panel header + GtkWidget* stack = nullptr; ///< GtkStack: one scroller per page + GtkWidget* source_switch = nullptr; ///< AdwToggleGroup, shown on a source page when there are >= 2 + std::vector source_pages; + std::string page; + bool syncing = false; ///< true while we write the toggle group ourselves +}; +constexpr int kSlideOverWidth = 360; +void build_slideover(App& app); // once, after the plot_overlay exists +bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content); +bool slideover_has_page(const App& app, const std::string& id); +bool slideover_open(App& app, const std::string& id); // false when the id is unknown +void slideover_close(App& app); +bool slideover_toggle(App& app, const std::string& id); // open if closed or on another page, else close; false if unknown +const std::string& slideover_page(const App& app); // "" when closed +bool slideover_escape(App& app); // closes if open; true when it did +bool slideover_click_away(App& app, double x, double y); // x,y in overlay coords; closes unless inside the panel; true when it closed +gboolean slideover_probe(gpointer user); // UDPSCOPE_PROBE_SLIDEOVER_MS +``` + `App` gains `SlideOver slideover;` and `GtkWidget* plot_overlay = nullptr;`. Task 2 consumes `slideover_add_page`, `slideover_toggle`, `slideover_page`; task 3 consumes `source_pages`. + +- [ ] **Step 1: Capture the UDPS baseline from the current HEAD** + +The tabs are about to go, so freeze today's self-check first. Build `HEAD` (`cmake --build build -j`), then: + +```sh +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=8000 UDPSCOPE_ENABLE_ALL_MS=1000 \ + ./build/udpscope --server 127.0.0.1 --port 8114 > /tmp/ui-badges-base.log 2>&1 +kill $MOCK +sed 's/[0-9.]\+/N/g' /tmp/ui-badges-base.log > .superpowers/sdd/ui-badges-baseline-udps.txt +grep -c 'Gtk-CRITICAL' /tmp/ui-badges-base.log; grep 'gtk warnings' /tmp/ui-badges-base.log +``` +Expected: `0` Gtk-CRITICAL, `gtk warnings: 0`. Also keep `/tmp/ui-badges-base.log` un-normalised. Every later task diffs its own normalised run against this file and must **explain every differing line** from that task's "allowed baseline changes" list; any other difference is a defect. + +- [ ] **Step 2: Wrap the plot in `plot_overlay` in `src/ui/main-window.ui`** + +In `left_column`, replace the first `` (the bare `plot_box`) with: + +```xml + + + + 1 + 1 + + + ... (the existing plot_box, unchanged) ... + + + + +``` +Update the file's header comment: the plot column is `plot_overlay` > `plot_box`. + +- [ ] **Step 3: Add the CSS to `install_css()` (`src/ui_widgets.cpp`)** + +Append to `kCss` before the final `menubutton.udpscope-hint` rules (keep the string a single literal, each rule on its own `"...\n"` line): + +```cpp + // The slide-over panel over the plot's right edge: opaque (it must hide the plot under it), + // with an edge and a shadow so it reads as above the plot rather than beside it. + ".udpscope-slideover { background-color: @window_bg_color; border-left: 1px solid @borders;" + " box-shadow: -3px 0 10px rgba(0, 0, 0, 0.35); }\n" + ".udpscope-slideover-title { font-weight: bold; }\n" +``` + +- [ ] **Step 4: Write `src/ui_slideover.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// The slide-over panel: one panel at a time over the plot's right edge, a page per setup surface. +// See docs/superpowers/specs/2026-11-02-ui-badges-dock-design.md. + +#include "ui_app.hpp" + +#include + +#include +#include + +namespace udpscope { +namespace ui_impl { + +namespace { + +void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast(user)); } + +void on_source_switch(GObject* o, GParamSpec*, gpointer user) { + App* app = static_cast(user); + if (app->slideover.syncing) return; + const char* name = adw_toggle_group_get_active_name(ADW_TOGGLE_GROUP(o)); + if (name != nullptr && app->slideover.page != name) slideover_open(*app, name); +} + +/// Observes every press on the plot overlay, in the capture phase, and never claims the sequence: the +/// plot's own pan/zoom/drop gestures see the press exactly as they did before. +void on_press_capture(GtkGestureClick*, int, double x, double y, gpointer user) { + slideover_click_away(*static_cast(user), x, y); +} + +bool is_source_page(const std::string& id) { return id.rfind("source-", 0) == 0; } + +} // namespace + +void build_slideover(App& app) { + SlideOver& s = app.slideover; + s.overlay = app.plot_overlay; + if (s.overlay == nullptr) return; + + GtkWidget* panel = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_widget_add_css_class(panel, "udpscope-slideover"); + gtk_widget_set_size_request(panel, kSlideOverWidth, -1); + gtk_widget_set_hexpand(panel, FALSE); + + GtkWidget* head = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + gtk_widget_set_margin_start(head, 12); + gtk_widget_set_margin_end(head, 6); + gtk_widget_set_margin_top(head, 6); + gtk_widget_set_margin_bottom(head, 6); + s.title = gtk_label_new(""); + gtk_widget_add_css_class(s.title, "udpscope-slideover-title"); + gtk_label_set_xalign(GTK_LABEL(s.title), 0.0f); + gtk_widget_set_hexpand(s.title, TRUE); + gtk_label_set_ellipsize(GTK_LABEL(s.title), PANGO_ELLIPSIZE_END); + GtkWidget* close = gtk_button_new_with_label("Close"); + gtk_widget_add_css_class(close, "flat"); + g_signal_connect(close, "clicked", G_CALLBACK(on_close_clicked), &app); + gtk_box_append(GTK_BOX(head), s.title); + gtk_box_append(GTK_BOX(head), close); + gtk_box_append(GTK_BOX(panel), head); + + // A source with several panels (the E-1608: Analog out, Digital I/O) gets a segmented switch under + // the header; it is hidden unless the open page is a source page and there are two or more. + s.source_switch = adw_toggle_group_new(); + gtk_widget_set_margin_start(s.source_switch, 12); + gtk_widget_set_margin_end(s.source_switch, 12); + gtk_widget_set_margin_bottom(s.source_switch, 6); + gtk_widget_set_visible(s.source_switch, FALSE); + g_signal_connect(s.source_switch, "notify::active-name", G_CALLBACK(on_source_switch), &app); + gtk_box_append(GTK_BOX(panel), s.source_switch); + gtk_box_append(GTK_BOX(panel), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + + s.stack = gtk_stack_new(); + gtk_widget_set_vexpand(s.stack, TRUE); + gtk_box_append(GTK_BOX(panel), s.stack); + + s.revealer = gtk_revealer_new(); + gtk_revealer_set_transition_type(GTK_REVEALER(s.revealer), GTK_REVEALER_TRANSITION_TYPE_SLIDE_LEFT); + gtk_revealer_set_transition_duration(GTK_REVEALER(s.revealer), 120); + gtk_revealer_set_child(GTK_REVEALER(s.revealer), panel); + gtk_widget_set_halign(s.revealer, GTK_ALIGN_END); + gtk_widget_set_valign(s.revealer, GTK_ALIGN_FILL); + // Closed, it must not be a pick target for the frames of its slide-out. + gtk_widget_set_can_target(s.revealer, FALSE); + gtk_overlay_add_overlay(GTK_OVERLAY(s.overlay), s.revealer); + gtk_overlay_set_clip_overlay(GTK_OVERLAY(s.overlay), s.revealer, TRUE); + + GtkGesture* click = gtk_gesture_click_new(); + gtk_event_controller_set_propagation_phase(GTK_EVENT_CONTROLLER(click), GTK_PHASE_CAPTURE); + g_signal_connect(click, "pressed", G_CALLBACK(on_press_capture), &app); + gtk_widget_add_controller(s.overlay, GTK_EVENT_CONTROLLER(click)); +} + +bool slideover_add_page(App& app, const std::string& id, const std::string& title, GtkWidget* content) { + SlideOver& s = app.slideover; + if (s.stack == nullptr || content == nullptr || id.empty() || slideover_has_page(app, id)) { + return false; + } + // A scroller per page: pages differ in height, and one shared scroller would leave a page scrolled + // to somewhere it never asked for. It does not propagate the content's height, so the panel's + // height is the plot's and never the page's. + GtkWidget* scroller = gtk_scrolled_window_new(); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scroller), GTK_POLICY_NEVER, GTK_POLICY_AUTOMATIC); + gtk_scrolled_window_set_child(GTK_SCROLLED_WINDOW(scroller), content); + gtk_stack_add_titled(GTK_STACK(s.stack), scroller, id.c_str(), title.c_str()); + if (is_source_page(id)) { + s.source_pages.push_back(id); + AdwToggle* t = adw_toggle_new(); + adw_toggle_set_name(t, id.c_str()); + adw_toggle_set_label(t, title.c_str()); + adw_toggle_group_add(ADW_TOGGLE_GROUP(s.source_switch), t); // takes the reference + } + return true; +} + +bool slideover_has_page(const App& app, const std::string& id) { + return app.slideover.stack != nullptr && + gtk_stack_get_child_by_name(GTK_STACK(app.slideover.stack), id.c_str()) != nullptr; +} + +bool slideover_open(App& app, const std::string& id) { + SlideOver& s = app.slideover; + if (!slideover_has_page(app, id)) return false; + GtkWidget* child = gtk_stack_get_child_by_name(GTK_STACK(s.stack), id.c_str()); + gtk_stack_set_visible_child(GTK_STACK(s.stack), child); + s.page = id; + GtkStackPage* sp = gtk_stack_get_page(GTK_STACK(s.stack), child); + const char* title = gtk_stack_page_get_title(sp); + gtk_label_set_text(GTK_LABEL(s.title), title != nullptr ? title : ""); + const bool several = is_source_page(id) && s.source_pages.size() >= 2; + gtk_widget_set_visible(s.source_switch, several); + if (several) { + s.syncing = true; + adw_toggle_group_set_active_name(ADW_TOGGLE_GROUP(s.source_switch), id.c_str()); + s.syncing = false; + } + gtk_widget_set_can_target(s.revealer, TRUE); + gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), TRUE); + sync_badge_bar(app); + return true; +} + +void slideover_close(App& app) { + SlideOver& s = app.slideover; + if (s.revealer == nullptr || s.page.empty()) return; + s.page.clear(); + gtk_widget_set_can_target(s.revealer, FALSE); + gtk_revealer_set_reveal_child(GTK_REVEALER(s.revealer), FALSE); + sync_badge_bar(app); +} + +bool slideover_toggle(App& app, const std::string& id) { + if (!slideover_has_page(app, id)) return false; + if (app.slideover.page == id) { + slideover_close(app); + } else { + slideover_open(app, id); + } + return true; +} + +const std::string& slideover_page(const App& app) { return app.slideover.page; } + +bool slideover_escape(App& app) { + if (app.slideover.page.empty()) return false; + slideover_close(app); + return true; +} + +bool slideover_click_away(App& app, double x, double y) { + SlideOver& s = app.slideover; + if (s.page.empty() || s.overlay == nullptr || s.revealer == nullptr) return false; + graphene_point_t in = GRAPHENE_POINT_INIT(static_cast(x), static_cast(y)); + graphene_point_t out; + if (gtk_widget_compute_point(s.overlay, s.revealer, &in, &out) && + gtk_widget_contains(s.revealer, out.x, out.y)) { + return false; + } + slideover_close(app); + return true; +} + +// --- the probe --------------------------------------------------------------------------------- + +namespace { + +struct ProbeState { + App* app; + int plot_w_before = 0; +}; + +bool under(GtkWidget* w, GtkWidget* ancestor) { + return w != nullptr && ancestor != nullptr && (w == ancestor || gtk_widget_is_ancestor(w, ancestor)); +} + +gboolean slideover_probe_read(gpointer user) { + ProbeState* ps = static_cast(user); + App& a = *ps->app; + SlideOver& s = a.slideover; + const int ow = gtk_widget_get_width(s.overlay); + const int oh = gtk_widget_get_height(s.overlay); + graphene_rect_t r; + const bool have = gtk_widget_compute_bounds(s.revealer, s.overlay, &r); + const int rw = have ? static_cast(r.size.width + 0.5f) : -1; + const int rx = have ? static_cast(r.origin.x + 0.5f) : -1; + const int rh = have ? static_cast(r.size.height + 0.5f) : -1; + // Outside the panel, a quarter in: the plot must be what answers. Inside it, the panel. + GtkWidget* outside = gtk_widget_pick(s.overlay, ow / 4.0, oh / 2.0, GTK_PICK_DEFAULT); + GtkWidget* inside = gtk_widget_pick(s.overlay, ow - kSlideOverWidth / 2.0, oh / 2.0, GTK_PICK_DEFAULT); + const int plot_w_open = gtk_widget_get_width(a.plot_box); + std::fprintf(stderr, + "[slideover] open page=%s panel=%dx%d at x=%d overlay=%dx%d edge_flush=%d full_height=%d " + "outside_hits_plot=%d inside_hits_panel=%d plot_w before=%d open=%d plot_unchanged=%d\n", + s.page.c_str(), rw, rh, rx, ow, oh, rx + rw == ow ? 1 : 0, rh == oh ? 1 : 0, + under(outside, a.plot_box) ? 1 : 0, under(inside, s.revealer) ? 1 : 0, ps->plot_w_before, + plot_w_open, ps->plot_w_before == plot_w_open ? 1 : 0); + // Click-away from outside the panel closes; a press inside does not. + const bool inside_closed = slideover_click_away(a, ow - kSlideOverWidth / 2.0, oh / 2.0); + const bool open_after_inside = !s.page.empty(); + const bool outside_closed = slideover_click_away(a, ow / 4.0, oh / 2.0); + slideover_open(a, "probe"); + const bool esc_closed = slideover_escape(a); + std::fprintf(stderr, + "[slideover] click_inside_closes=%d still_open=%d click_outside_closes=%d esc_closes=%d " + "closed=%d\n", + inside_closed ? 1 : 0, open_after_inside ? 1 : 0, outside_closed ? 1 : 0, esc_closed ? 1 : 0, + s.page.empty() ? 1 : 0); + delete ps; + return G_SOURCE_REMOVE; +} + +} // namespace + +gboolean slideover_probe(gpointer user) { + App* a = static_cast(user); + if (a->slideover.stack == nullptr) return G_SOURCE_REMOVE; + // A page of the probe's own: the real pages arrive in later tasks, and this one has an entry so the + // panel has something that takes focus and keys. + GtkWidget* box = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_box_append(GTK_BOX(box), gtk_label_new("slide-over probe page")); + gtk_box_append(GTK_BOX(box), gtk_entry_new()); + slideover_add_page(*a, "probe", "Probe", box); + ProbeState* ps = new ProbeState{a, gtk_widget_get_width(a->plot_box)}; + slideover_open(*a, "probe"); + // The revealer's slide is 120 ms; the reading is taken well after it has settled. + g_timeout_add(500, slideover_probe_read, ps); + return G_SOURCE_REMOVE; +} + +} // namespace ui_impl +} // namespace udpscope +``` +`sync_badge_bar(App&)` does not exist until task 2: in this task add to `ui_app.hpp` a declaration and, in `ui_slideover.cpp`'s neighbour, nothing -- instead **temporarily omit** the two `sync_badge_bar(app);` calls and add them in task 2 step 4 (they are marked `// task 2` if you prefer a comment). Check every GTK/libadwaita signature against the installed headers (`/usr/include/gtk-4.0`, `/usr/include/libadwaita-1`); fix names rather than guessing. + +- [ ] **Step 5: Declare, wire, register** + + - `ui_app.hpp`: add the `SlideOver` struct, `kSlideOverWidth`, the function declarations from **Interfaces**, and on `App`: `SlideOver slideover; GtkWidget* plot_overlay = nullptr;`. + - `ui.cpp` `on_activate`: next to the other `gtk_builder_get_object` lookups add `app->plot_overlay = GTK_WIDGET(gtk_builder_get_object(app->window_ui, "plot_overlay"));`, add it to the null guard, and call `build_slideover(*app);` right after `app->plot_split` is resolved and the tour overlay is built (the plot overlay exists from the builder, so any point before `make_area` works). + - `ui.cpp` probe scheduling (next to line ~5906 `side_tab_probe`): `if (const char* env = std::getenv("UDPSCOPE_PROBE_SLIDEOVER_MS")) g_timeout_add(static_cast(std::atoi(env)), slideover_probe, &app);` + - `CMakeLists.txt`: add `src/ui_slideover.cpp` after `src/ui_source.cpp`. + - `docs/claude/verification.md`: add an entry for `UDPSCOPE_PROBE_SLIDEOVER_MS=2000` (with `UDPSCOPE_EXIT_AFTER_MS=5000`): the two `[slideover]` lines and what each field must read (below). + +- [ ] **Step 6: Build and run the probe** + +```sh +cmake --build build -j 2>&1 | grep -E 'warning|error' ; echo "build rc=$?" +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a env GDK_BACKEND=x11 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 \ + ./build/udpscope --server 127.0.0.1 --port 8114 2>&1 | grep -E '\[slideover\]|Gtk-|gtk warnings' +kill $MOCK +``` +Expected: the build has zero warnings (incremental) and `[slideover]` line 1 reads `panel=360x ... edge_flush=1 full_height=1 outside_hits_plot=1 inside_hits_panel=1 ... plot_unchanged=1`; line 2 reads `click_inside_closes=0 still_open=1 click_outside_closes=1 esc_closes=1 closed=1`; `gtk warnings: 0`. If `outside_hits_plot=0` the panel has an allocation wider than 360 or a transparent parent -- stop and fix, do not go on. + +- [ ] **Step 7: Confirm the compositing by eye (the risk the spec names)** + +```sh +python3 tools/mock_udpstreamer.py --port 8114 --rate 10000 --batch 100 --duration 30 & MOCK=$! +sleep 1.5 +xvfb-run -a -s "-screen 0 1400x900x24" sh -c 'GDK_BACKEND=x11 GSK_RENDERER=cairo UDPSCOPE_EXIT_AFTER_MS=7000 \ + UDPSCOPE_ENABLE_ALL_MS=500 UDPSCOPE_PROBE_SLIDEOVER_MS=1500 ./build/udpscope --server 127.0.0.1 --port 8114 \ + >/tmp/so.log 2>&1 & sleep 2.4; import -window root /tmp/slideover.png; wait' +kill $MOCK +``` +The probe closes the page at ~2.0 s, so for the screenshot only: temporarily leave it open by setting `UDPSCOPE_PROBE_SLIDEOVER_HOLD=1`, which makes `slideover_probe_read` return before the click/esc part (add that early `if (std::getenv("UDPSCOPE_PROBE_SLIDEOVER_HOLD")) { delete ps; return G_SOURCE_REMOVE; }` after the first `fprintf`). Read `/tmp/slideover.png` with the Read tool and confirm: the panel is drawn **over** the traces on the plot's right edge, opaque, with its header, "Close" button, entry; the plot to its left is intact. Record the result in the task report. If the GL surface draws over the panel or the panel is transparent, stop: the design's premise failed, report BLOCKED. + +- [ ] **Step 8: Baseline diff and commit** + +Re-run Step 1's command into `/tmp/ui-badges-t1.log`, normalise, `diff` against `.superpowers/sdd/ui-badges-baseline-udps.txt`. +Allowed baseline changes: none (a layout line that prints the plot area's size may differ by the panel not being open -- it must not). + +```bash +git add src/ui_slideover.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp CMakeLists.txt docs/claude/verification.md +git commit -m "ui: a slide-over panel over the plot's right edge, with its probe" +``` + +--- + +### Task 2: BadgeBar with the Trigger badge, and the trigger form into the slide-over + +**Files:** +- Create: `src/ui_badges.cpp` +- Modify: `src/ui/main-window.ui`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_widgets.cpp`, `src/ui_slideover.cpp`, `src/ui_probe.cpp`, `CMakeLists.txt`, `docs/claude/verification.md` + +**Interfaces:** +- Consumes: Task 1's `slideover_add_page`, `slideover_toggle`, `slideover_page`, `slideover_open`. +- Produces: + +```cpp +/// One badge: a button with a name and a state line. `page` is the slide-over page it opens. +struct Badge { + GtkWidget* button = nullptr; + GtkWidget* name = nullptr; + GtkWidget* state = nullptr; + std::string page; +}; +struct BadgeBar { + GtkWidget* bar = nullptr; ///< main-window.ui `badge_bar`, fixed height + GtkWidget* signals_slot = nullptr; ///< left; empty in step 1, step 2 fills it + GtkWidget* system_slot = nullptr; ///< right: Trigger, Source + Badge trigger; + Badge source; ///< task 3 +}; +/// The Trigger badge's state word. Pure, so the probe can assert the whole table. +const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event); +void build_badge_bar(App& app); +void sync_badge_bar(App& app); // per tick and on every slide-over open/close; only writes what changed +gboolean badges_probe(gpointer user); // UDPSCOPE_PROBE_BADGES_MS +``` + `App` gains `BadgeBar badges;` and, via the builder, `badge_bar`. `trigger_badge_state` truth table (enabled, armed, pending, has_event) -> word: `!enabled` -> `off`; `has_event && !pending` -> `triggered`; `pending` -> `capturing`; `armed` -> `armed`; else `disarmed`. + +- [ ] **Step 1: Add the badge bar row to `main-window.ui`** + +In `left_column`, between `plot_overlay` and `status_row`: + +```xml + + + + horizontal + 6 + 36 + 6 + 6 + + + horizontal + 6 + 1 + + + + + horizontal + 6 + end + + + + +``` +Add the three ids to the guard in `on_activate`. + +- [ ] **Step 2: CSS (`install_css`)** + +```cpp + // The badge bar: a badge is a pill carrying a name and a state; the one whose page is open is + // marked, as a bench scope marks the open menu's button. + ".udpscope-badge { padding: 2px 12px; min-height: 0; border-radius: 14px; }\n" + ".udpscope-badge-open { background-color: alpha(@accent_bg_color, 0.28); }\n" + ".udpscope-badge-state { opacity: 0.7; }\n" +``` + +- [ ] **Step 3: Write `src/ui_badges.cpp`** + +```cpp +// SPDX-License-Identifier: MIT +// SPDX-FileCopyrightText: 2026 Martino Ferrari + +// The badge bar under the plot: setup is reached by the object it configures. + +#include "ui_app.hpp" + +#include +#include + +namespace udpscope { +namespace ui_impl { + +const char* trigger_badge_state(bool enabled, bool armed, bool pending, bool has_event) { + if (!enabled) return "off"; + if (has_event && !pending) return "triggered"; + if (pending) return "capturing"; + return armed ? "armed" : "disarmed"; +} + +namespace { + +void set_text_if_changed(GtkWidget* label, const char* text) { + if (std::strcmp(gtk_label_get_text(GTK_LABEL(label)), text) != 0) gtk_label_set_text(GTK_LABEL(label), text); +} + +void set_open(GtkWidget* button, bool open) { + if ((gtk_widget_has_css_class(button, "udpscope-badge-open") != FALSE) == open) return; + if (open) { + gtk_widget_add_css_class(button, "udpscope-badge-open"); + } else { + gtk_widget_remove_css_class(button, "udpscope-badge-open"); + } +} + +void on_badge_clicked(GtkButton*, gpointer user) { + Badge* b = static_cast(user); + // The App is reached through the badge's button: the badges live inside App, so the pointer is stable. + App* app = static_cast(g_object_get_data(G_OBJECT(b->button), "udpscope-app")); + if (app != nullptr) slideover_toggle(*app, b->page); +} + +/// A badge: a flat pill, name on the left, state dimmed beside it. Words only. +void make_badge(App& app, Badge& b, GtkWidget* parent, const char* name, const std::string& page) { + b.page = page; + b.button = gtk_button_new(); + gtk_widget_add_css_class(b.button, "flat"); + gtk_widget_add_css_class(b.button, "udpscope-badge"); + GtkWidget* row = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 8); + b.name = gtk_label_new(name); + b.state = gtk_label_new(""); + gtk_widget_add_css_class(b.state, "udpscope-badge-state"); + gtk_box_append(GTK_BOX(row), b.name); + gtk_box_append(GTK_BOX(row), b.state); + gtk_button_set_child(GTK_BUTTON(b.button), row); + g_object_set_data(G_OBJECT(b.button), "udpscope-app", &app); + g_signal_connect(b.button, "clicked", G_CALLBACK(on_badge_clicked), &b); + gtk_box_append(GTK_BOX(parent), b.button); +} + +} // namespace + +void build_badge_bar(App& app) { + BadgeBar& bb = app.badges; + bb.bar = app.badge_bar; + bb.signals_slot = app.badge_signals; + bb.system_slot = app.badge_system; + make_badge(app, bb.trigger, bb.system_slot, "Trigger", "trigger"); + sync_badge_bar(app); +} + +void sync_badge_bar(App& app) { + BadgeBar& bb = app.badges; + if (bb.trigger.button == nullptr) return; + const bool enabled = app.trigger_toggle != nullptr && + gtk_toggle_button_get_active(GTK_TOGGLE_BUTTON(app.trigger_toggle)) != FALSE; + const bool armed = app.model->trigger_armed().load(std::memory_order_relaxed); + const bool pending = app.model->trigger_pending(); + const bool has_event = app.model->event() != nullptr; + set_text_if_changed(bb.trigger.state, trigger_badge_state(enabled, armed, pending, has_event)); + set_open(bb.trigger.button, slideover_page(app) == bb.trigger.page); +} + +gboolean badges_probe(gpointer user) { + App* a = static_cast(user); + // The whole table of the pure function: 16 inputs, 5 words. + struct Row { bool e, a, p, v; const char* want; }; + static const Row kRows[] = { + {false, false, false, false, "off"}, {false, true, true, true, "off"}, + {true, false, false, true, "triggered"}, {true, true, false, true, "triggered"}, + {true, true, true, false, "capturing"}, {true, false, true, true, "capturing"}, + {true, true, false, false, "armed"}, {true, false, false, false, "disarmed"}, + }; + int bad = 0; + for (const Row& r : kRows) { + if (std::strcmp(trigger_badge_state(r.e, r.a, r.p, r.v), r.want) != 0) ++bad; + } + // The geometry: fixed height whatever the state, and the open mark follows the slide-over. + const int h_closed = gtk_widget_get_height(a->badges.bar); + slideover_toggle(*a, "trigger"); + const bool marked_open = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") != FALSE; + const bool page_open = slideover_page(*a) == "trigger"; + slideover_toggle(*a, "trigger"); + const bool marked_closed = gtk_widget_has_css_class(a->badges.trigger.button, "udpscope-badge-open") == FALSE; + const int h_after = gtk_widget_get_height(a->badges.bar); + std::fprintf(stderr, + "[badges] table_bad=%d trigger_state=%s bar_h=%d bar_h_after=%d fixed=%d open_marked=%d " + "page_open=%d closed_unmarked=%d\n", + bad, gtk_label_get_text(GTK_LABEL(a->badges.trigger.state)), h_closed, h_after, + h_closed == h_after && h_closed >= 36 ? 1 : 0, marked_open ? 1 : 0, page_open ? 1 : 0, + marked_closed ? 1 : 0); + return G_SOURCE_REMOVE; +} + +} // namespace ui_impl +} // namespace udpscope +``` +(The probe checks `trigger_badge_state` against the full table the declaration gives. Add the 8 remaining combinations if you want all 16; the 8 above cover every word and every branch.) + +- [ ] **Step 4: Wire it** + + - `ui_app.hpp`: `Badge`, `BadgeBar`, declarations; on `App`: `BadgeBar badges; GtkWidget* badge_bar = nullptr; GtkWidget* badge_signals = nullptr; GtkWidget* badge_system = nullptr;`. + - `ui_slideover.cpp`: restore the two `sync_badge_bar(app);` calls from task 1 (end of `slideover_open` and `slideover_close`). + - `ui.cpp` `on_activate`: resolve the three ids, then `build_badge_bar(*app);` **after** `app->trigger_toggle` exists (it is created in `build_toolbar`, which runs before) and after `build_slideover`. + - `ui.cpp` `tick()`: call `sync_badge_bar(*app);` next to `sync_source_panels(*app);`. + - `ui.cpp`: probe scheduling `UDPSCOPE_PROBE_BADGES_MS` -> `badges_probe`. + - `CMakeLists.txt`: add `src/ui_badges.cpp`. + +- [ ] **Step 5: Move the trigger form into the slide-over's `trigger` page** + +In `on_activate` (currently ~ui.cpp:5187): + +```cpp + // Signals: the trigger picks a channel from the list beside it. + app->trigger_panel = build_trigger_panel(*app); + gtk_box_append(GTK_BOX(signals_page), app->trigger_panel); + gtk_box_append(GTK_BOX(signals_page), gtk_separator_new(GTK_ORIENTATION_HORIZONTAL)); + app->signal_panel = build_signal_panel(*app); + gtk_box_append(GTK_BOX(signals_page), app->signal_panel); +``` +becomes +```cpp + // The trigger form lives in the slide-over (reached by the Trigger badge); the signal list stays in the + // sidebar until the dock replaces it. + app->trigger_panel = build_trigger_panel(*app); + slideover_add_page(*app, "trigger", "Trigger", app->trigger_panel); + app->signal_panel = build_signal_panel(*app); + gtk_box_append(GTK_BOX(signals_page), app->signal_panel); +``` +Then deal with everything that assumed the trigger was on the Signals page **and mapped**: `grep -n "trigger_panel\|trigger_body\|trigger_head\|trigger_summary\|dbg_trigger_\|dbg_signal_page_current" src/*.cpp`. Specifically `exit_probe_step3` (`ui_probe.cpp` ~100-206) reads `gtk_widget_get_width/height(a->trigger_panel)`, `on_signals && gtk_widget_get_sensitive(a->trigger_body)`; any probe that reads a trigger widget's *allocation* must call `slideover_open(*a, "trigger")` and wait one settled frame (the reveal is 120 ms) before reading, and close it after. Reads of sensitivity/visibility/value need no open page. `dbg_signal_page_current` / `dbg_trigger_sensitive` keep their meaning but `on_signals` is now about the sidebar tab only for the signal list: `dbg_trigger_sensitive = gtk_widget_get_sensitive(a->trigger_body)`. + The trigger form's header toggle mirroring (`trigger_switch` <-> `trigger_toggle`), `sync_trigger_rows` and the locked summary view are all widget-state, not page-state; they must be unaffected -- the existing trigger probes are the proof (Step 6). + +- [ ] **Step 6: Verify** + +Build (0 warnings). Then, serially, with a mock each: + 1. `[badges]` probe: `UDPSCOPE_PROBE_BADGES_MS=2000 UDPSCOPE_EXIT_AFTER_MS=5000 UDPSCOPE_ENABLE_ALL_MS=500` against the UDPS mock -> `table_bad=0 fixed=1 open_marked=1 page_open=1 closed_unmarked=1`, `gtk warnings: 0`. + 2. Every existing trigger probe in `docs/claude/verification.md` (grep `UDPSCOPE_PROBE_.*TRIGGER\|FORCE\|ARM\|PERSIST\|DENSITY\|CONDITION\|LOCK\|SUMMARY`; run those whose names mention the trigger panel, arming, locking, the summary or the panel switch) and compare against the reading each entry in `docs/claude/probe-readings.md` states. A probe that now reads zero because its widget is unallocated is the fix described in Step 5, not a regression to hide. + 3. Screenshot with `UDPSCOPE_PROBE_BADGES_HOLD`-style early exit is not needed: add `slideover_open(*a,"trigger")` to nothing; instead take one screenshot (Task 1 step 7's command with `UDPSCOPE_PROBE_BADGES_MS` replaced by a one-off env `UDPSCOPE_OPEN_PAGE=trigger` read in `on_activate`'s tail -- remove it after) and view it: badge bar under the plot, Trigger badge pressed, the trigger form in the panel. Do not commit the temporary env. + 4. Baseline diff (normalised) vs `.superpowers/sdd/ui-badges-baseline-udps.txt`. +Allowed baseline changes: the `side`/`trigger` size lines (`trigger_panel` is read through the slide-over), plus the new `[badges]` lines. + +- [ ] **Step 7: Commit** + +```bash +git add src/ui_badges.cpp src/ui/main-window.ui src/ui_app.hpp src/ui.cpp src/ui_widgets.cpp src/ui_slideover.cpp src/ui_probe.cpp CMakeLists.txt docs/claude/verification.md +git commit -m "ui: a badge bar with a Trigger badge, and the trigger form in the slide-over" +``` + +--- + +### Task 3: The Source badge and the source panels in the slide-over + +**Files:** +- Modify: `src/ui_source.cpp`, `src/ui_badges.cpp`, `src/ui_app.hpp`, `src/ui.cpp`, `src/ui_probe.cpp`, `docs/claude/verification.md` + +**Interfaces:** +- Consumes: `slideover_add_page`, `slideover.source_pages`, `Badge`/`BadgeBar::source`. +- Produces: `build_source_panels(App&)` now registers `source-` slide-over pages (id and `SourcePanelPage::name` unchanged); `sync_badge_bar` also writes the Source badge. `SourcePanelPage` is unchanged. + +- [ ] **Step 1: `build_source_panels` registers slide-over pages** + +In `src/ui_source.cpp`, replace the scroller + `adw_view_stack_add_titled` block with: + +```cpp + const std::string name = "source-" + p.id; + // The panel is a slide-over page now: the slide-over wraps it in its own scroller, and with two or + // more source pages it draws the segmented switch (the E-1608: Analog out, Digital I/O). + if (!slideover_add_page(app, name, p.title, p.widget)) continue; + app.source_panels.push_back({name, p.title, p.widget}); +``` +and change the guard at the top of the function from `app.side_stack == nullptr` to `app.slideover.stack == nullptr`. `apply_source_panels`/`sync_source_panels` (sensitivity from the connection flag) are unchanged. + Leave `keep_tabs_icon_free` in place for now (the sidebar tabs still exist until task 4); it must not be called with source pages any more -- it only hides images, so it is harmless. + +- [ ] **Step 2: The Source badge** + +In `build_badge_bar`, after the Trigger badge: + +```cpp + // The first source page is where the badge goes; a source with none (UDPS) has nothing to open, so its + // badge is a read-out: not a target, not focusable, but still on screen. + const std::string first = app.source_panels.empty() ? std::string() : app.source_panels.front().name; + make_badge(app, bb.source, bb.system_slot, "Source", first); + if (first.empty()) { + gtk_widget_set_can_target(bb.source.button, FALSE); + gtk_widget_set_focusable(bb.source.button, FALSE); + } +``` +`build_badge_bar` must therefore run **after** `build_source_panels(*app)` -- move its call there. (Order in `on_activate`: `build_slideover`, trigger page, `build_source_panels`, `build_badge_bar`.) For a source's open mark, the Source badge is "open" for any `source-*` page: in `sync_badge_bar`, `set_open(bb.source.button, slideover_page(app).rfind("source-", 0) == 0)`. + State text (written in `sync_badge_bar`): the name label is the source kind's `display_name()` (`find_kind(app.source_kind)`, include `source/registry.hpp`; fall back to `"Source"`), the state is `connected` / `disconnected` from `app.model->stats().connected.load(std::memory_order_relaxed)`; in file mode (`app.replaying` or `!app.model->config().open_waveform.empty()`) the name is `"File"` and the state is empty. Set the name label with `set_text_if_changed` too. + The badge click on a multi-page source opens the **first** page; the segmented switch inside the panel changes page (Task 1's `on_source_switch`). If the open page is already a source page, a click closes it (that is `slideover_toggle`'s same-page rule, which the badge's `page` = first page gives for the first page; for any other open source page make `on_badge_clicked` close the panel when `b == &app->badges.source` and the open page starts with `source-`). + +- [ ] **Step 3: Re-point the probes that read the source pages** + + - `ui_probe.cpp` ~1958 and ~2400: `adw_view_stack_get_child_by_name(side_stack, name) != nullptr` -> `slideover_has_page(*a, name)`. + - `ui_probe.cpp` ~2495: `adw_view_stack_set_visible_child_name(side_stack, "source-ao")` -> `slideover_open(*a, "source-ao")`; the probe reads panel widgets' values and sensitivity, and any read of an allocation needs the page open and one settled frame (>= 150 ms) -- keep the page open for the probe's reads and close it at the end. + - Add to the dump (or a `UDPSCOPE_PROBE_BADGES_MS` extension) a `[badges] source name=<...> state=<...> pages= switch_visible=<0|1>` line: after `slideover_open(*a, )`, `switch_visible` must read `1` for the E-1608 (two pages) and `0` for the probe source (one page); then select the second page via `adw_toggle_group_set_active_name` and read back `slideover_page(*a)`. + +- [ ] **Step 4: Verify (serial; kill each mock)** + + 1. Probe source: `UDPSCOPE_PROBE_SOURCE_KINDS=3000 UDPSCOPE_EXIT_AFTER_MS=7000 UDPSCOPE_PROBE_BADGES_MS=2500 ./build/udpscope --source probe --source-opt rate=500` -> `[badges] source name=... pages=1 switch_visible=0`; `[source-kinds]` lines unchanged. + 2. E-1608: `python3 tools/mock_e1608.py` (default port 54211), run the app with `--source e1608` the way `docs/claude/verification.md` states, with the E-1608 panel probes (`UDPSCOPE_PROBE_E1608_*`) and `UDPSCOPE_PROBE_BADGES_MS` -> `pages=2 switch_visible=1`, the panel probes' readings unchanged, `gtk warnings: 0`. + 3. UDPS mock baseline diff. Allowed baseline changes: none beyond Task 2's; the Source badge on UDPS is the not-a-target read-out. + 4. Screenshot of the E-1608 run with the Source page open (cairo, as Task 1 step 7), view it: the segmented switch under the header, the Analog out page below it. + +- [ ] **Step 5: Commit** + +```bash +git add src/ui_source.cpp src/ui_badges.cpp src/ui_app.hpp src/ui.cpp src/ui_probe.cpp docs/claude/verification.md +git commit -m "ui: the Source badge, and the source panels as slide-over pages" +``` + +--- + +### Task 4: Replace the sidebar tabs with the temporary dock; session and keyboard + +**Files:** +- Modify: `src/ui/main-window.ui`, `src/ui.cpp`, `src/ui_source.cpp`, `src/ui_app.hpp`, `src/ui_layout.cpp`, `src/layout_store.hpp`, `src/layout_store.cpp`, `src/shortcuts.hpp`, `src/shortcuts.cpp`, `src/ui/shortcuts.ui` (if it lists the two actions), `src/ui_probe.cpp`, `docs/claude/*` entries touched by their probes +- Read first: `docs/claude/invariants-session-and-export.md`, `docs/claude/invariants-plot-and-layout.md` + +**Interfaces:** +- Produces: `App::dock_page` (the dock's `GtkBox`), `void dock_show_section(App&, const char* section)` (`"signals"` or `"measure"`; scrolls the dock to that section; unknown name is a no-op), `AppAction::ShowMeasure` / `AppAction::ShowSignals` (renamed from `SideTabMeasure` / `SideTabSignals`; same keys `m` and `g`, labels "Go to Measure" / "Go to Signals"), `SessionState::dock_shown` (bool, default true). **Removed:** `App::side_stack`, `App::side_switcher`, `App::side_tab`, `select_side_tab`, `on_side_tab_changed`, `keep_tabs_icon_free`, `SessionState::side_tab`, `App::dbg_switcher_*`, `dbg_signal_page_current`'s tab meaning. + +- [ ] **Step 1: The dock in `main-window.ui`** + +Replace the `side_column` subtree (switcher + stack + the two page scrollers) with: + +```xml + + + vertical + 300 + + + 1 + never + automatic + + + vertical + + + + + +``` +Update the file's header comment (the sidebar is the dock; the panels appended to `dock_page`). + +- [ ] **Step 2: Fill the dock in `on_activate` and delete the tab code** + + - Resolve `dock_page`, `dock_scroller` instead of `side_stack`, `side_switcher`, `measure_page`, `signals_page`; guard them; `app->side_box = side;` stays (the tour and the layout probes measure it). Add `GtkWidget* dock_page; GtkWidget* dock_scroller;` to `App`. + - Order in the dock, top to bottom: `signal_panel`, separator, `cursors_panel`, separator, `build_measures_panel(*app)`. The signal list is first because it is what is dragged onto the plot. + - Delete: the `adw_view_stack_set_visible_child_name(side_stack, "signals")` block and its `notify::visible-child-name` connection, `keep_tabs_icon_free(*app)` and its definition/declaration in `ui_source.cpp`/`ui_app.hpp` (with `hide_tab_icons`, `on_tab_pages_changed`), `on_side_tab_changed`, `select_side_tab` (`ui.cpp` ~2867-2882), `App::side_stack/side_switcher/side_tab`, and the `dbg_switcher_*` members. + - Add in `ui.cpp`: + +```cpp +void dock_show_section(App& app, const char* section) { + if (app.dock_scroller == nullptr || app.dock_page == nullptr || section == nullptr) return; + GtkWidget* target = std::strcmp(section, "measure") == 0 ? app.cursors_panel + : std::strcmp(section, "signals") == 0 ? app.signal_panel + : nullptr; + if (target == nullptr) return; + graphene_point_t zero = GRAPHENE_POINT_INIT(0.f, 0.f); + graphene_point_t at; + if (!gtk_widget_compute_point(target, app.dock_page, &zero, &at)) return; + GtkAdjustment* adj = gtk_scrolled_window_get_vadjustment(GTK_SCROLLED_WINDOW(app.dock_scroller)); + gtk_adjustment_set_value(adj, at.y); +} +``` + - `run_app_action`: `case AppAction::ShowMeasure: dock_show_section(app, "measure"); break;` / `ShowSignals` likewise. Rename in `shortcuts.hpp`, `shortcuts.cpp` (labels "Go to Measure" / "Go to Signals", same group "Panes and editors"), `shortcuts.ui` if present, and the keyboard probe (`ui_probe.cpp` ~6278-6305: it compared `side_tab` before/after; compare the dock scroller's `vadjustment` value instead -- `m` must move it to the cursors panel's y, `g` back to 0). + - Add `case Escape`: in `on_app_key_pressed`, directly after the tour's Escape branch and before the editable check: `if (keyval == GDK_KEY_Escape && slideover_escape(*app)) return TRUE;`. + +- [ ] **Step 3: Dock visibility -- shown, insensitive until signals** + +`update_side_visibility` (`ui.cpp` ~2887) and the per-frame block in `tick()` (~4029-4047) both compute `want = have_signals && sidebar_wanted`. Change both to `want = sidebar_wanted`, and state the dock's availability instead: + +```cpp + // "Disable, do not hide": the dock is on screen from the start and does nothing until the stream has signals. + gtk_widget_set_sensitive(app.side_box, have_signals); + gtk_widget_set_sensitive(app.sidebar_toggle, TRUE); +``` +(`trigger_toggle`'s visibility from `have_signals` is unchanged; `update_side_visibility` keeps the early-return guard on `app.side_box == nullptr` instead of `side_stack`.) Update the comments above both blocks. + +- [ ] **Step 4: Session -- `side_tab` out, `dock_shown` in** + + - `layout_store.hpp`: remove `side_tab`; add `bool dock_shown = true;` documented as "the dock's shown state (the user's last word on the toolbar's Dock button)". + - `layout_store.cpp`: writer (~743) emits `dock = true|false` in the same top section where `side_tab` was written (keep key order per the session invariants); parser: replace the `key == "side_tab"` branch with `key == "dock"` -> `st.dock_shown = value == "true"`; an old file's `side_tab = ...` falls through to the existing unknown-key handling (check the final `else` of that chain ignores it -- read it; do not add a branch to swallow it if it already does). Bump nothing else. + - `ui_layout.cpp:67` -> `st.dock_shown = app.sidebar_wanted;`; `:430` -> `app.sidebar_wanted = st.dock_shown;` then `update_side_visibility(app);`. + - A change of the user's wish must schedule a save: in `on_sidebar_toggled`, after recording `sidebar_wanted` when `!sidebar_syncing`, `if (!app->updating_panels) schedule_layout_save(*app);`. + - If there are session round-trip probes (`grep -n "side_tab" src/ui_probe.cpp docs/claude/*.md`), extend them: save with the dock hidden, reload, read `sidebar_wanted == false`; and a file containing `side_tab = "measure"` loads with no error and `dock_shown` default. + +- [ ] **Step 5: Re-point the layout/sidebar probes** + +`ui_probe.cpp`: `exit_probe_step3` (~100-206), `side_tab_probe*` (~1800-1875), `sidebar_probe` (~3532-3567), the `select_side_tab(*a, "measure")` calls at ~4519 and ~4678, and the tour-surface probes (done in Task 5). Rules: + - `dbg_switcher_*` and the `switcher:` dump line (ui.cpp ~2998, 3055-3090, and the printing at ~6224-6436) are deleted; replace with one `dock: shown=<0|1> sensitive=<0|1> w= scroller_pages=1` line and document it. + - `side_tab_probe` keeps its job (measurements keep accumulating while the Measure section is scrolled out of view) with `dock_show_section(*a, "measure")` / `"signals"` in place of `select_side_tab`; rename it `dock_section_probe`, env `UDPSCOPE_PROBE_SIDE_TAB_MS` is kept as is (do not break scripts); its `advanced=4` assertion is unchanged. + - `dbg_signal_page_current` becomes "the signal list has rows" (no tab), `dbg_trigger_sensitive` is read directly. + +- [ ] **Step 6: Verify** + +Build (0 warnings incremental; clean build has exactly one). Serially: UDPS mock baseline diff; `UDPSCOPE_PROBE_SIDE_TAB_MS=3000 UDPSCOPE_EXIT_AFTER_MS=11000` (`advanced=4`); the sidebar/collapse probe; the keyboard probe; the session round-trip probe; the pane-drag probe (`dbg_drag_hid_sidebar`/`restored` both 1: dragging a signal from an overlaid dock still hides it for the drag); one run with **no signals** (UDPS mock with `--source` that has none, or a run before CONFIG) reading `dock: shown=1 sensitive=0`; E-1608 and probe-source runs from Task 3 again. Screenshot at 1280x800: plot, badge bar, dock with the list, cursors, measurements. +Allowed baseline changes: the `switcher:` line goes; a `dock:` line appears; `tab`/`page` readings that named `signals`/`measure` become the section readings; `[sidebar]` lines may differ only by `shown` when no signals existed. + +- [ ] **Step 7: Commit** + +```bash +git add -A src docs/claude +git commit -m "ui: the sidebar's tabs become one dock; Measure and Signals by keyboard section; session keeps the dock's state" +``` + +--- + +### Task 5: Tour, contextual help and the help figures + +**Files:** +- Modify: `src/ui_tour.cpp`, `src/ui_context_help.cpp`, `src/ui_app.hpp`, `src/ui_probe.cpp`; read `tools/check_help.sh`, `tools/make-help-shots.sh` + +**Interfaces:** +- Produces: `TourView::on_page` -- `void (*on_page)(void* user, const char* page) = nullptr; void* page_user = nullptr;`. When set it replaces the `page_stack` mechanism for that view: `select_view_page` calls `on_page(page_user, stop->page)` (page may be `nullptr`, meaning "no page needed"). The chooser keeps `page_stack`. `TourStep::page` for the main window now names a slide-over page (`"trigger"`) or is `nullptr`. + +- [ ] **Step 1: The page hook in the shared tour machinery (`ui_tour.cpp` / `ui_app.hpp`)** + + - Add the two members to `TourView`. + - `select_view_page`: first line `if (view.on_page != nullptr) { view.on_page(view.page_user, page); return; }`. + - `stop_is_walkable`: `if (stop.page != nullptr && view.on_page != nullptr) return true;` before the `page_stack` rule -- a slide-over page can always be opened, whatever the dock is doing. + - `start_tour`: replace `app.tour.page_stack = app.side_stack;` with `app.tour.on_page = +[](void* u, const char* page) { App& a = *static_cast(u); if (page != nullptr) slideover_open(a, page); else slideover_close(a); }; app.tour.page_user = &app;`. A stop with `page == nullptr` closes the panel, so the card never walks to the status line over an open panel. On `tour_end`, close the panel too (find where the tour tears down and call `slideover_close` through the same hook with `nullptr`). + +- [ ] **Step 2: Rewrite the main window's stops** + +`kTourSteps` becomes (titles/bodies in plain words; keep the existing style): + +| id | page | target | text (replace with your own wording of this meaning) | +| --- | --- | --- | --- | +| `plot` | nullptr | `&App::area_overlay` | unchanged | +| `badges` | nullptr | `&App::badge_bar` | "Setup is reached by the thing it configures. The Trigger badge opens the trigger form; the Source badge opens the source's own controls. Click it again, press Esc or click the plot to close the panel." | +| `trigger` | `"trigger"` | `&App::trigger_body` | existing trigger text (describe an event, arm it) | +| `side` | nullptr | `&App::side_box` | "The dock beside the plot: the list of signals, then the cursors and the measurements. The buttons in the bar above show and hide it." | +| `signals` | nullptr | `&App::signal_list` | unchanged | +| `status` | nullptr | `&App::status_row` | unchanged | + +Add `GtkWidget* App::badge_bar` to the table (it is already an `App` member from Task 2). Order matters for the walk: the trigger stop opens the panel, the `side` stop closes it. Update `kFigureSurfaces` (`ui_tour.cpp` ~484): drop `side_switcher` and `side_stack`; add `{"badge_bar", &App::badge_bar}` and `{"dock", &App::side_box}`. If `tools/check_help.sh`, `tools/make-help-shots.sh` or any `docs/help/**` figure data names `side_switcher`/`side_stack`, grep for them (`grep -rn "side_switcher\|side_stack" tools docs src data 2>/dev/null`): keep `side_stack` as a **second name for the dock** in `kFigureSurfaces` until the manual's figures are redone in step 4 (record that in the ledger), and drop `side_switcher` only if nothing names it. + +- [ ] **Step 3: Contextual help (`ui_context_help.cpp`)** + +The hint table (~line 92 `{"signals", "The signals", ...}`) anchors "(i)" marks to panel hosts. Read the whole table. Hints whose host is now in the slide-over (the trigger's) are on an unmapped widget while the panel is closed: confirm the code that places them tolerates an unmapped host (hints are overlay children of the host; they should appear with the panel) and that `context_help_count` / the `hints=N ids={...}` reading is the same set of ids as before. Add a hint for the badge bar only if the table's pattern is "one hint per panel"; otherwise do not. + +- [ ] **Step 4: Re-point the tour probes** + +`ui_probe.cpp` ~6565-6875 (`[tour]`, `[toursurface]`): `side_tab=` becomes `slideover=` printed from `slideover_page(a)`; `select_side_tab(*a, "measure")` + `mapped(a->side_stack)` become `slideover_open(*a, "trigger")` and `mapped(a->side_box)`; the "collapsed" leg (dock collapsed) must show that the trigger stop is still walkable (it is the new behaviour: the slide-over does not depend on the dock) and the signals stop is dropped. Read `docs/claude/probe-readings.md` for the `[toursurface]` entry and update it in Task 6. + +- [ ] **Step 5: Verify** + +Tour probes (`grep -n "TOUR\|HELPUI\|CONTEXT" docs/claude/verification.md` for their env names) -> the walk lists the stops above in order, `slideover=trigger` at the trigger stop and `slideover=none` at the next, spotlight rectangles contained in the overlay, `hints=` ids as before; `tools/check_help.sh` passes. UDPS baseline diff; allowed changes: tour step count and stop ids/titles, `side_tab=` fields. + +- [ ] **Step 6: Commit** + +```bash +git add src/ui_tour.cpp src/ui_context_help.cpp src/ui_app.hpp src/ui_probe.cpp +git commit -m "ui: the tour and the contextual help follow the badge bar, the slide-over and the dock" +``` + +--- + +### Task 6: Documentation for step 1, and the final check + +**Files:** +- Modify: `README.md`, `docs/claude/architecture.md`, `docs/claude/verification.md`, `docs/claude/probe-readings.md`, `docs/claude/invariants-plot-and-layout.md`, `docs/claude/invariants-gtk-and-chrome.md`, `docs/claude/build-and-run.md` (only if it names a changed file), `CLAUDE.md` (only its table if a row's text is now wrong) +- Not in this plan (spec step 4): `USER-GUIDE.md`, the Mallard manual's text and figures. + +- [ ] **Step 1: Sweep the references** + +`grep -rn "side_switcher\|side_stack\|side_tab\|switcher:\|keep_tabs_icon_free\|SideTab\|Measure tab\|Signals tab\|tab switcher\|sidebar tab" README.md CLAUDE.md docs/claude src tools` -- every hit outside `docs/superpowers/` is rewritten or deleted. Specifically: + - `README.md`: the layout description (badge bar, slide-over, dock), the keyboard table (`m`/`g` are "Go to Measure / Signals"), the session file's keys, the deviations list (the three step-1 deviations above, as known limitations of the in-progress redesign). + - `docs/claude/architecture.md`: the three new units and the rule that the slide-over/badge bar are built in `on_activate`, per-tick state in `sync_badge_bar`. + - `docs/claude/invariants-plot-and-layout.md`: the slide-over as the second thing placed over the plot (after the layout editor's buttons), with the pick rule; the badge bar's fixed height. + - `docs/claude/invariants-gtk-and-chrome.md`: delete the tab-icon paragraph and `keep_tabs_icon_free` (the switcher is gone); add: a capture-phase observer gesture that never claims the sequence is how click-away coexists with the plot's gestures; `AdwToggleGroup` is the segmented switch. + - `docs/claude/probe-readings.md` and `verification.md`: the new lines `[slideover]`, `[badges]`, `dock:`, the removal of `switcher:`; the new baseline statement for the UDPS diff ("unchanged except ..." now relative to the commit before this series, with the allowed list); the renamed keyboard actions. + - `docs/claude/verification.md` header sentence about the UDPS baseline: say a self-check taken **before** `c572ec2` is no longer the baseline for these lines. + +- [ ] **Step 2: The final run, serially** + +Clean build count: `touch src/icons/udpscope.gresource.xml && cmake --build build -j 2>&1 | grep -c warning` -> `1`; incremental -> `0`. UDPS mock full self-check: `gtk warnings: 0`, no `Gtk-CRITICAL`, `buffer allocated:` lines 2-3. Each probe touched in Tasks 1-5 once more, on the final tree. A last screenshot at 1280x800 with the trigger page open and one with a source page open (E-1608 mock) for the report. Kill every mock. + +- [ ] **Step 3: Commit** + +```bash +git add README.md CLAUDE.md docs/claude +git commit -m "docs: the badge bar, the slide-over and the dock (step 1 of the redesign)" +``` + +--- + +## Self-review (against the spec) + +- **Spec step 1 -- "plot overlay, badge bar with Trigger and Source, slide-over holding the existing trigger form and source pages, sidebar tabs removed, existing Measure and Signals in a temporary dock":** Task 1 (overlay + slide-over), Task 2 (badge bar, Trigger, trigger form), Task 3 (Source, source pages), Task 4 (tabs removed, temporary dock). +- **Risks the spec assigns to step 1:** overlay above GL -> Task 1 steps 6-7 before anything else; reparenting the trigger form -> Task 2 step 6 (existing trigger probes). "Many signals" is step 2. +- **Header:** the Events toggle removal and the Dock toggle rename are *not* step 1 (the events pane stays until step 4); the sidebar toggle already is the dock toggle in behaviour -- its tooltip/label text, if it says "sidebar", is renamed "Dock" in Task 4 step 3 (look at `header-bar.ui`). +- **Keyboard / session / probes / tour / docs:** Tasks 4, 4, 4-5, 5, 6. +- **Left to later steps (by the spec's rollout):** signal badges and drag (2), compact readouts and the 280 px dock (3), events section and deleting the left pane (4), USER-GUIDE and manual figures (4). +- **Names used across tasks:** `slideover_add_page/has_page/open/close/toggle/page/escape/click_away`, `SlideOver::source_pages`, `build_badge_bar`, `sync_badge_bar`, `trigger_badge_state`, `BadgeBar::{trigger,source}`, `dock_show_section`, `ShowMeasure/ShowSignals`, `SessionState::dock_shown`, `TourView::on_page/page_user` are used with one spelling throughout.