# 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.