Files
udpscope/docs/superpowers/plans/2026-11-02-ui-badges-dock-shell.md

62 KiB

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:<name>, signals, measure, cursors, events, source-<id>. (Step 1 implements trigger and source-<id> 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):
/// 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<std::string> 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:

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 <child> (the bare plot_box) with:

                <child>
                  <!-- The plot, and the slide-over panel that opens over its right edge. The panel is
                       one opaque overlay child placed by `halign=end` and a 360 px width -- never a
                       full-area transparent child, which would be picked wherever its allocation is
                       and swallow the plot's pan, drop and hover (docs/claude/invariants-plot-and-layout.md).
                       The overlay is the only parent change: `plot_box` keeps its id and its content. -->
                  <object class="GtkOverlay" id="plot_overlay">
                    <property name="hexpand">1</property>
                    <property name="vexpand">1</property>
                    <child>
                      <object class="GtkBox" id="plot_box">
                        ... (the existing plot_box, unchanged) ...
                      </object>
                    </child>
                  </object>
                </child>

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):

      // 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
// 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 <adwaita.h>

#include <cstdio>
#include <cstring>

namespace udpscope {
namespace ui_impl {

namespace {

void on_close_clicked(GtkButton*, gpointer user) { slideover_close(*static_cast<App*>(user)); }

void on_source_switch(GObject* o, GParamSpec*, gpointer user) {
  App* app = static_cast<App*>(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<App*>(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<float>(x), static_cast<float>(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<ProbeState*>(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<int>(r.size.width + 0.5f) : -1;
  const int rx = have ? static_cast<int>(r.origin.x + 0.5f) : -1;
  const int rh = have ? static_cast<int>(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<App*>(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<guint>(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

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<overlay height> ... 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)
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).

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:
/// 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:

                <child>
                  <!-- Setup is reached by the object it configures: a badge per signal on the left
                       (step 2), the system badges (Trigger, Source) on the right. A fixed height, so
                       the plot above it never moves when badges come and go. -->
                  <object class="GtkBox" id="badge_bar">
                    <property name="orientation">horizontal</property>
                    <property name="spacing">6</property>
                    <property name="height-request">36</property>
                    <property name="margin-start">6</property>
                    <property name="margin-end">6</property>
                    <child>
                      <object class="GtkBox" id="badge_signals">
                        <property name="orientation">horizontal</property>
                        <property name="spacing">6</property>
                        <property name="hexpand">1</property>
                      </object>
                    </child>
                    <child>
                      <object class="GtkBox" id="badge_system">
                        <property name="orientation">horizontal</property>
                        <property name="spacing">6</property>
                        <property name="halign">end</property>
                      </object>
                    </child>
                  </object>
                </child>

Add the three ids to the guard in on_activate.

  • Step 2: CSS (install_css)
      // 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
// 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 <cstdio>
#include <cstring>

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<Badge*>(user);
  // The App is reached through the badge's button: the badges live inside App, so the pointer is stable.
  App* app = static_cast<App*>(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<App*>(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):

  // 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

  // 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
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-<id> 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:

    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:

  // 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=<n> switch_visible=<0|1> line: after slideover_open(*a, <first source page>), 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

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:

          <!-- The dock: one scroller holding the signal list, then the cursors and the measurements.
               It replaces the tab switcher (four text tabs did not fit 320 px and a source can add
               more); it is the temporary home of Measure and Signals until steps 2 and 3 turn them
               into the badge bar's signals and the compact readouts. -->
          <object class="GtkBox" id="side_column">
            <property name="orientation">vertical</property>
            <property name="width-request">300</property>
            <child>
              <object class="GtkScrolledWindow" id="dock_scroller">
                <property name="vexpand">1</property>
                <property name="hscrollbar-policy">never</property>
                <property name="vscrollbar-policy">automatic</property>
                <property name="child">
                  <object class="GtkBox" id="dock_page">
                    <property name="orientation">vertical</property>
                  </object>
                </property>
              </object>
            </child>
          </object>

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:
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:

  // "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=<px> 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
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<App*>(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=<page|none> 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
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
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.