829176248ac162526a5db31f771d9d339283cdd9
34
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
829176248a |
help: a tinier (i), hints beside their controls, and the tour starting in the chooser
The contextual help becomes the affordance the ask names: a plain (i) (`dialog-information-symbolic`) at **16x16**, and one that sits beside the control it explains rather than only in a pane's heading. - The mark: `GtkMenuButton` ignores `min-width` on the widget and a size request, so the size is made in `install_css()` on all three nodes (outer `menubutton`, its `button` subnode, the glyph). Measured 16x16 against the default 34x34; the dump prints it as `hint=WxH`, measured with `gtk_widget_compute_bounds()` on the first *visible* hint. - More sites: `events_record` (the "record events" switch in the trigger panel), `trigger_advanced` (the pre-trigger share row), `signals_config` (the signals pane's hint line, wrapped in a box so a hint can join it). Hints join as siblings, so none can cover a switch, pencil or dropdown. - The source chooser gets its own layer -- `chooser_sources` (a new heading above the saved-source list) and `chooser_waveform` (the waveforms action row) -- built with the one `context_help_button()` both windows use and re-synced by its Help menu's toggle. The form's `AdwEntryRow`s cannot host a sibling hint, so none is invented for the host/port fields. - The tour is one implementation over two windows: `ui_impl::TourView` plus a list of `TourStop`s; each window keeps its own table. A walk that passes its last stop calls `on_done`, and the chooser's sets `Config::tour_pending`, so a first run is one tour that crosses from the chooser into the app. The chooser's tour auto-starts from `settings.tour_seen` exactly as the window's does, and its "Guided tour" row now starts *it* instead of only requesting the window's. - Probes: `UDPSCOPE_PROBE_CHOOSER_TOUR=1` / `UDPSCOPE_PROBE_CHOOSER_TOUR_MS`, the hint size in the `help layer:`/`[helpui] contextual on:` lines, and the chooser's hints in its `chooser ui:` line. Docs updated in invariants-gtk-and-chrome.md, probe-readings.md and verification.md. |
||
|
|
d8a79b96ba |
help: default the contextual layer off, and carry the help into the chooser
The contextual help was on by default, so a first run met a window with a "?" beside every surface it had not asked to have explained; `contextual_help` now defaults to false and the layer is asked for from the Help menu, from the chooser's new help menu, or with Ctrl+H. The source chooser runs before `App` exists, which makes it the window a user who does not know how to start is looking at, so it now carries the same three help affordances the window's Help view does: Documentation (the manual is another process and needs nothing of the app), Enable contextual help (a row whose check *is* the setting, written to the file the app reads a moment later), and Guided tour. The tour is drawn on the app's own window, so the chooser can only record the wish: it sets `Config::tour_pending` -- the value `main()` owns and hands to both `choose_source()` and `run_ui()` -- and `on_activate()` starts it once the window is up and laid out, through its own callback so the readings can tell a request from the first-run auto-start. The rows are the window's own `menu_button()`/`menu_toggle()`; `install_css()` therefore runs from `choose_source()` (it is idempotent now) so the popover gets the `.udpscope-menu` rules before it is shown. Opening the manual could also fail with a message that blamed the handler whatever was missing. `help_installed_path()` now resolves `help:<id>` the way the specification does and the failure alert says which of the two it is: with the set installed it names the viewer, without it it names the identifier, the expected installed path and the install command. `show_help()` gained an optional reporter, which `UDPSCOPE_PROBE_HELP_MS` uses for a new reading -- `[help] launch attempted= done= ok= error='' installed=` -- since a successful launch leaves no window of ours to observe. Tested: clean --clean-first build, three warnings as documented; test-ui 13/13 and test-help pass; no Gtk-CRITICAL in the help, helpui and chooser runs. |
||
|
|
6fe1b22524 |
chore: drop the dead help window's .ui with the window it built
`help_window.cpp`'s paged window went with the manual ( |
||
|
|
1baa58b8fd |
feat: the help is a submenu, with a contextual-help layer and a guided tour
The hamburger menu's help rows were three flat entries and a window. They are a **`Help` submenu** now, which is where every GNOME menu keeps them, with the four things a first run cannot find: - Keyboard shortcuts Ctrl+/ (the existing overview, `show_shortcuts()`) - Documentation F1 (the existing manual, `show_help()`) - Guided tour Ctrl+T (new) - Enable contextual help (new, a stateful row: its check *is* the setting) The submenu *replaces* the menu's rows rather than floating beside them, the same shape and the same reason as the plot's right-click menu: a submenu torn out of the menu it belongs to is a second thing to dismiss, and the `GtkMenuButton` that owned one toggled itself out of sync the moment anything else closed its popover. It is the pages of a `GtkStack`, not a rebuild of the box and not a swap of the popover's child: the main view holds `app.layout_toggle`, which the tick's `sync_layout_editor()` and the `L` accel read, so rebuilding would leave that pointer on a destroyed widget, and a swapped child would leave the view that is not showing without an owner. **The contextual help is one table and a "?" per surface** (`src/ui_context_help.cpp`): the plot, the trigger, the cursors, the measurements, the math channels, the signals, the event history, the time window, the status line, and the math editor's identity row. Each row names the *heading box* its button joins -- a sibling of the title, never an overlay child on a corner, which is what would cover the switch or the [edit] button already there -- so adding a surface is a row and a widget pointer. Off means *gone*, not greyed: the buttons are removed, and they are found by a data key rather than a list of pointers because the math editor's heading is built afresh on every opening and a list would go stale one dialog later. `contextual_help` and `tour_seen` are keys of `settings.toml`, written through the same `Settings`/`load_settings()`/`save_settings()` the Preferences dialog uses, whose own rows start from `load_settings()` and so carry them through untouched. **The guided tour is five steps on a `GtkOverlay`** above the window's content: the plot, the side panel, the trigger, the signals and the status line. There is no popover in it and therefore no pointer grab to leak -- four dim rectangles around the hole are what make it modal -- and the probe asserts that as a reading (`controllers=` against the count taken before it opened) rather than as a claim. Everything is placed from `gtk_widget_compute_bounds()` re-read on every frame, because a widget's allocation is 0 on the frame it is first shown and a window resize moves it under a one-shot measurement; the hole is kept from the last frame that could measure the target, because a frame that lands between two layout passes would otherwise empty it and make the spotlight jump. `Skip`, `Finish` and `Escape` all hide every child and leave nothing behind. A first run gets the tour once, decided by `tour_seen` and not by "the settings file did not exist" (a user who changed the theme has a file and has still never seen it), and the flag is written when the tour *starts* rather than when it finishes: a user who skips it in the first second has still seen it. `UDPSCOPE_PROBE_HELPUI_MS` drives all of it -- the auto-start, the menu's seven rows and the submenu's five, the toggle's effect read as a count of affordances on either side of it, and the tour started, stepped forwards and back and dismissed -- and the exit dump gains `help layer:` and `help figures:`, the latter being the ten surfaces' window-coordinate rectangles that the manual's annotated screenshots crop their boxes from. Two of them are what the D-Bus-free headless run can still not decide, and both are limitations rather than defects: export carries no "?" (it is a menu action with no surface of its own, and a popover inside the menu's popover is the nested-popover trap the invariants already warn about), and the tour card cannot miss its target when the target is the whole content -- which is what the plot is at the window's 756 px minimum, where the split view overlays the side panel instead of putting it beside the plot. The probe prints both rectangles rather than asserting a placement, so that case reads as the geometry it is. Also fixed while it was found: `gtk_widget_measure()` includes the widget's own margins (measured: a 280 px box with `margin-start 453` reports 733), so a measure that is used to *place* the widget is a feedback loop -- that is what made the tour card walk between the corner and the target from frame to frame. |
||
|
|
c1460a6977 |
feat: the manual is a Mallard set opened in Yelp, not a GResource in the binary
The help was 14 Pango-markup `.txt` bodies in a second GResource, drawn into a paged `GtkWindow` built in `help_window.cpp`. That made F1 a viewer of ours with no search index, no cross-references and no translation, and it kept the manual in the *binary* when every other desktop application's is a document on disk. It is now a real Mallard set: help/C/org.udpscope.app/ index.page + 14 topic pages + figures/*.png and `show_help()` hands `help:org.udpscope.app` to whatever the desktop registered for the `help:` scheme -- Yelp on GNOME -- through a `GtkUriLauncher`, reporting a launch failure with a `GtkAlertDialog` rather than a click that silently does nothing. The paged window's builder, the page table and `help_self_check()`'s markup validation are gone with the resource. The document identifier is the desktop file's own basename (its desktop-file id), derived in CMakeLists from `data/org.udpscope.app.desktop.in`, so the same string is the `.desktop` name, the GApplication id and the install directory at once. The set is installed by a new `install()` rule to `<datadir>/help/C/org.udpscope.app/` -- where the freedesktop Help System Specification's `help:` URI resolves `help:<id>` -- so the manual is read from a datadir by another process even though the binary has no install step. `UDPSCOPE_HELP_NAME` and `UDPSCOPE_HELP_DIR` are compiled in from that one place. The 14 pages were migrated fact for fact, but restructured the Mallard way: a `<desc>` per page (what a search result shows), `<steps>` for procedures, `<terms>` for option lists, `<gui>`/`<key>`/`<keyseq>` for interface words and keys, `<note>` for the asides, and a topic graph via `<link type="guide" xref="index#main"/>`. The figures are annotated screenshots produced by `tools/make-help-shots.sh`, which drives the app headlessly against `tools/mock_udpstreamer.py` and draws the boxes with ImageMagick; the side panel's box is measured from the app's own `side=NNNx` dump reading, the header and status bands come from a documented table, and a crop whose standard deviation is 0 fails the script. `cmake --build build --target test-help` checks the set the way `test-ui` checks the `.ui` files: `yelp-check`'s offline subcommands (links, media, orphans, ids) and a `yelp-build html` render. `yelp-check validate` is deliberately absent -- it fetches the Mallard RelaxNG schema over the network and fails offline for that reason, not for a fault in the pages. The `[help]` probe reading changes with the resource it was written for. `parsed == pages` over in-binary markup cannot exist now, so `help_self_check()` scans `UDPSCOPE_HELP_DIR` and the line reads `topics=14 index=1 figures=3`; `topics=0 index=0` is the manual missing from the tree, which is the finding the old count named. Verified: clean build with the documented three warnings (the help bundle's `-Woverlength-strings` is gone, so the baseline moves from four to three); test-ui 14/14; test-help clean (the four subcommands and 15 pages rendered); a temp-prefix install lands 15 `.page` files and 3 figures at `share/help/C/org.udpscope.app/`; the headless run reads `[help] topics=14 index=1 figures=3 dir=...` with 0 Gtk-CRITICAL. Yelp's own window could not be exercised on this box -- it starts under Xvfb, activates its portals and never maps, the same WebKit/portal limitation that stops a grab -- so its rendering and its search index are unverified here; the content pipeline was (`yelp-build` renders the whole set). Docs: the manual's build/install/open story in `build-and-run.md` and README; the warning baseline in `verification.md`/CLAUDE.md; the help bullets in `invariants-gtk-and-chrome.md`; the `[help]` line in `probe-readings.md`. |
||
|
|
d8705a369f |
ui: Arm is disabled with trigger mode off, and the redundant Go-live button is gone
**`Arm` is now disabled while trigger mode is off**, and labelled `Arm` again: it is the one control in that pane that is *runtime* rather than setting, so with no engine running there is nothing to arm. It is also the only thing in the body that stays disabled — the settings themselves stay editable, which is the point of the previous commit. `arm sensitive with the trigger off=0` is the reading. **`Go live (trigger off)` is deleted.** Its handler was `trigger_toggle → FALSE`, i.e. exactly the toolbar's trigger switch and the pane's own switch, which is what it was for: "the way out of trigger mode, from inside the panel that froze the view: it is the toolbar's trigger switch, so there is exactly one piece of state." With that switch now in the pane's own heading, the button was the same control drawn a third time. The status line that told users about it says "or switch the trigger off to stream" instead. The events pane's own `events_live` button is a different button on a different path — the file view restore — and is untouched. **And one silent regression this work uncovered.** The `trigger panel:` frame reading had been walking the *wrapper* box the app puts around the `.ui`'s panel (heading row + locked summary + panel) ever since the heading moved out of the file in the lock commit, so it reported `boxes=2 … search_inside=0/4` against a panel that was entirely correct. It reads `trigger_body` — the file's own panel — again, and now says so in its comment, because a reading that can silently start describing the wrong widget is the failure it exists to catch. Verified: `trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4` (the documented shape, restored); `arm sensitive with the trigger off=0`; `trigger panel sensitive …=1`; `panes:`/`panel switches:`/`pane edits:` unchanged; `make test-ui` 14 files; zero `Gtk-CRITICAL`. |
||
|
|
4f4851f4e3 |
fix: the [edit] buttons' icons, and the locked pane says four lines not one
Two things the lock shipped wrong. **The "done" icon did not exist.** `emblem-ok-symbolic` is a breeze icon; Adwaita — which is what this app runs under — does not have it, so the button that leaves configuration mode drew a missing-image placeholder, i.e. nothing. Both names are now Adwaita's own: `document-edit-symbolic` (the pencil: edit) and `object-select-symbolic` (the check: done). This is exactly the silent failure `toolbar icons:` exists for, so the two `[edit]` buttons get the same treatment: `pane edits: locked=…(ok) config=…(ok)` is read by clicking into configuration mode and back, so one line carries both names and `gtk_icon_theme_has_icon()`'s verdict for each. The invariants now carry the rule, including the trap that a name found under `breeze` is not an answer. **The locked pane was too compact.** One line could not hold what a user opens the pane for, so it is now a small definition list: `signal`, `condition`, `level`, `mode` on four lines, and after that only the figures that are *not* at their defaults — `noise band`, `HF reject`, `pre-trigger`, `hold-off` — because a line reading "band 0" is a line that says nothing. The channel is resolved from the acquired channels and the condition quotes the figures its own shape reads (a width and its comparison, two levels, or a timeout), never one it ignores. The pane is ~192 px tall locked against 580 in configuration mode. One self-inflicted critical on the way: the probe read the summary with `gtk_label_get_text()`, and the container is a grid now — `Gtk-CRITICAL: assertion 'GTK_IS_LABEL (self)' failed`. The text comes from the sync that writes it instead. Verified: `pane edits: locked=document-edit-symbolic(ok) config=object-select-symbolic(ok)`; `panes: trigger=locked signals=locked body_visible=0 summary='signal: square | condition: window, falling | level: 0.3 .. 0.6 | mode: single | noise band: 0.2 | pre-trigger: 20 % | hold-off: 1000 ms'`; `make test-ui` 14 files; zero `Gtk-CRITICAL` and no missing icon. |
||
|
|
121abbefae |
feat: locked panes for a returning user, and the trigger is persisted
Two panes, two modes. **Locked** is what a returning user gets: the trigger pane shows one line saying
what it is set to, the signal list shows only the signals that are on, and neither offers a control that
changes the configuration — each heading has an `[edit]` button (the Adwaita `emblem-system-symbolic`,
swapping to `emblem-ok-symbolic` in configuration mode) that flips its pane. A **first run** — no session
file for that source — never reaches `apply_layout()` and comes up in configuration mode, because
configuring the host is what a first run is for. The mode is a mode and not a setting, so it is
deliberately not persisted.
The locked signal list drops the rows of every signal that is off, which is the whole of "show active
signals": the list already filters on nothing, so the lock is one `continue`. With the rows go the acquire
tick, the overflow menu (Information, Calibrate…, Delete, rename) and the `MATH` divider's `+` — and the
colour chip stops being clickable, because choosing a colour is configuration too. Dragging a signal onto
a plot stays: that is which view shows what, not what the scope is set up to do.
**The trigger is persisted, settings and state both** — the settings *even while it is off*, which is the
point: a trigger configured once and switched off is still the trigger you come back to. `LayoutState`
gains fifteen flat `trigger_*` keys; the channel is stored by *name* (every rebuild renumbers the
channels, the model's own rule) and the two enumerations as **the words the panel's dropdowns show**,
because an index ties the file to a declaration order — the lesson `math_op_at()` already carries. The
words are shared with the panel now (`trigger_type_text()` and friends build the dropdown arrays), so the
two cannot drift.
Two ordering traps, both found by running it:
- **The channel name resolves twice over.** `rebuild_trigger_signals()` picks up
`App::pending_trigger_channel` when the stream arrives *after* the load, and `apply_trigger_layout()`
resolves it itself when the channels are already there — without the second path the name stayed
pending past the one rebuild that would have used it, and the trigger silently sat on channel 0
(`summary='pulse width · sine …'` where the session said `square`).
- **The trigger's state is applied by pressing the control, not by setting a flag**: the toggle is set
*outside* the `updating_panels` window `apply_layout()` uses, because a toggle set inside it emits
`toggled` into a handler that returns at once and nothing would happen — no arming, no events button,
no sensitivity.
Verified, in both modes and both directions:
panes: trigger=locked signals=locked body_visible=0 summary='window · square · 0.3…0.6 · 20 % pre'
panes: trigger=config signals=config body_visible=1 summary=''
signals panel: labels=2 list=1 rows=7 signals=2 math=4 divider=1
The locked trigger pane is 42 px tall against 580 unlocked. A hand-written session round-trips: every
value it named came back in the panel's own controls, `trigger=1/1` (the state arrived and the pane's
switch followed it), and the file the app wrote back carried all fifteen keys with the untouched ones at
their defaults. `[side-tab-probe] … advanced=4` on its documented recipe; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon.
Docs: the `panes:` reading with what each field means, the corrected `labels=` note (the panel's frame is
counted above the list now, because the heading sits in a heading row), the invariants for both the
persistence and the lock, the README's deviation note, and the removal of its claim that the trigger has
never been persisted — it is now, and the locked view is built from exactly that.
|
||
|
|
cc8335c514 |
ui: each pane carries its own switch, the same one the header bar has
The trigger pane and the cursors pane now have a `GtkSwitch` in their heading, and it is *one control in
two places* with the header bar's button: both run the same action (`run_app_action()`), and
`sync_panel_switches()` re-reads each pair from the toolbar's state after any change, so clicking either
end moves both. A pane whose feature is off is disabled, which is exactly the state you need the switch
in, so it belongs *in* the pane rather than only in the toolbar.
Three things it needed:
- **The sensitive target moved to the pane's body.** A control inside an insensitive parent cannot be
clicked, so a switch that greyed out with its own pane could never turn it back on. `on_trigger_toggled`
and `on_cursors_toggled` now disable `trigger_body` / `cursors_body`, and so does the startup default;
the headings, with their switches, stay live.
- **The trigger's heading moved out of `trigger-panel.ui`** and is built in `build_trigger_panel()`,
which returns the heading row and the panel together. It has to be a sibling of the body for the switch
to stay usable, and keeping it in the file would have meant the file owning a widget the code must
arrange. The file's own panel is unchanged otherwise, so the `[trigger panel:]` counts still read.
- **A guard on the sync.** Setting a `GtkSwitch` emits `notify::active` for a programmatic write just as
it does for a click, so without `App::panel_switch_sync` the program's own write would come back as a
click and flip the feature again.
Readings, both directions in one line:
trigger panel sensitive before the probe toggled it: 0; … ; pane switches live while their panes are off=1
panel switches: trigger=1/1 cursors=1/1 drove_toolbar=1 (pane switch / header bar button)
`pane switches live …=1` is the design's point and is read before anything turns either pane on; `0` would
be a switch drawn, greyed and useless. The trigger pair is moved from the *toolbar* end and the cursors
pair from the *pane* end, so the line carries both halves of the sync, and `drove_toolbar=1` is the pane's
own switch having turned the cursors on with the button following. `trigger panel sensitive …=0` is the
body, not the panel.
Verified: the two lines above; `[side-tab-probe] … advanced=4`; `make test-ui` 14 files; zero
`Gtk-CRITICAL` and no missing icon. Docs: the new reading in `probe-readings.md` with what a `0` means,
the invariants bullet for the one-control-in-two-places rule and its two traps, the sidebar bullet's
sensitive target, and the README note on why the switch is in the pane.
|
||
|
|
e615465366 |
ui: each tab's glyph is a pair, not one icon
The Measure tab is now `udpscope-tab-measure-symbolic` — the two cursors standing across a ruler — and the Signals tab is `udpscope-tab-signals-symbolic` — the trigger's rising edge over the channel list it acts on. Each is a 16px composite rather than one of the icons the toolbar carries: a glyph the toolbar already uses for one thing means two things the moment a tab shows it too, which is why the ruler alone and the trigger alone were both wrong here. The plain ruler stays exactly where it was. `udpscope-measure-symbolic` is the Preferences window's Plot page icon and is untouched; the tab's pair is a new file beside it, so nothing that already meant "the measurement ruler" changed meaning. Seven action icons bundled, nine with the two app icons. Both composites are at the limit of what 16px holds — four features each — so each was drawn and then *measured*: rasterized and read back as an ink map, since the shapes have to be checked as pixels and the house range is 25-36% ink (trigger 25, cursors 27, ruler 36). The Signals list is two rows and not three, so the edge and the list get half the cell each; its bars are all one length, because unequal ones read as a bar chart and a chart is not a list. The Measure ruler's ends are open on purpose: a closed frame puts four more pixels of ink at the left and right edges, which is what turns a ruler into a rectangle at this size. First drafts came out at 39% and 44% ink and were rebalanced to 32% and 36%. Verified: `page icons: measure=udpscope-tab-measure-symbolic(ok) signals=udpscope-tab-signals-symbolic(ok)`; `switcher: policy=narrow pills=2 labels=0 names=2`; `[side-tab-probe] … advanced=4` (switching by page name still works); the Preferences dialog opened in the same run with no "not present"; `make test-ui` 14 files; zero `Gtk-CRITICAL`. The icons' GResource blob is now 11309 bytes, still over C99's 4095-byte string-literal limit, so the clean-build warning count is still four. |
||
|
|
0b146f1d53 |
ui: the tabs are icons, and the pills keep their names
Each sidebar page gets its own glyph and no visible text. The Measure tab reuses the ruler
(`udpscope-measure-symbolic`, the icon its deleted toolbar toggle used and the Plot preferences page
still carries); the Signals tab gets a new `udpscope-signals-symbolic` — three rows of a colour chip
and a name, which is what that tab mostly is — rather than a second copy of the trigger glyph, since
one shape meaning two things in one window is worse than a new one. Its bars are all one length: a
set of unequal ones reads as a bar chart, and a chart is not a list.
`AdwViewSwitcherPolicy.narrow` alone does **not** give an icon-only bar. Measured on libadwaita 1.10
by walking the switcher's tree: with `narrow` and a title, each pill holds a `GtkImage` *and* a
`GtkLabel` — 48px of "Measure" inside a 54px pill. So the pages' `title` is **empty** (their `name` is
what identifies them to `select_side_tab()` and to the session file, and both are unchanged) and the
words are re-attached in code: `name_side_tabs()` runs on the first tick — the pills are
`AdwViewSwitcher`'s own and exist only once it is mapped — and gives each pill a **tooltip and an
accessible name** from the page's name, then hides the empty label that would otherwise leave the
icon off-centre. An icon-only pill is still a named control for a screen reader, and the page icon
that does not resolve leaves a *blank* pill, which is why it is read like the toolbar's icons are.
Two readings, because the claim is two claims:
switcher: policy=narrow pills=2 labels=0 names=2
page icons: measure=udpscope-measure-symbolic(ok) signals=udpscope-signals-symbolic(ok)
`labels` counts *mapped labels with text* — a plain mapped count reads 2 either way, because the
narrow pill keeps an empty label in its layout — and `pills=2` is the guard that makes it mean
something: with nothing mapped the rest of the line is void rather than a pass.
Docs: the icon census (six action icons, eight bundled, and what each of the two new users is), the
switcher bullet in `invariants-gtk-and-chrome.md` with the measurement above, the two new dump lines
in `probe-readings.md`, the re-measured GResource figures in `verification.md` (icons 8613 -> 9842
bytes, help 514660 after this session's help rewrite; the count stays four), and the README recording
that the design spec had the switcher showing titles and neither page an icon.
Verified: `make test-ui` 14 files; `[side-tab-probe] … advanced=4` (tab switching by name still
works with empty titles); `signals panel: … divider=1`; `[math-probe] 3 math channels`; zero
`Gtk-CRITICAL` and no missing icon.
|
||
|
|
6492bb910f |
test: a probe reads the hidden tab's accumulator, and the docs follow the tabs
`UDPSCOPE_PROBE_SIDE_TAB_MS` is the one reading for the design's §5 rule -- the statistics accumulator keeps running on the hidden page -- which nothing tested before. It switches to Measure, forces one pass so there is a baseline, switches away to Signals and reads again two seconds later: `advanced=4` is the assertion, and `advanced=0` is the defect where the accumulation is gated on the visible tab, which would leave a gap in min/max/avg for exactly as long as the trigger was on screen. Two things the probe has to do for itself, and getting either wrong makes `advanced=0` a lie about the tab: - it **enables its own signal**, because a signal starts disabled (enabling is what starts acquisition) and a probe that waited for one to be on would be reading another probe's side effects -- and `UDPSCOPE_ENABLE_ALL_MS` is not part of this run; - it **assigns that channel to a plot**, because a channel with no trace leaves the live window *still*, and a still window is precisely the state `update_measures()` is supposed to skip. Measured the hard way: without the assignment the probe read `advanced=0 min_n=0` with the view pinned and only four passes in twelve seconds, none of them the tab's doing. Docs: `probe-readings.md` gains the probe's entry and the signals panel's new shape (`rows == signals + 1 + math`, `divider=0` a list that lost its `+`), loses the whole `math panel:` entry, and follows `pane_sensitive=`, the gone `revealed=` field, the six toolbar controls and the tab-reading `m`/`g` key pairs. `verification.md` gains the probe's bullet and the re-measured warning figures. The invariants record the sidebar as a switcher above a stack with a scroller per page, the hidden-page trap that replaces the unrevealed-revealer one for it, that a feature's on/off is sensitivity rather than visibility, that a math channel is a row of the signal list, and that a cursors-off readout blanks to `--` with the panel left on screen. `shortcuts.ui` and the help page's window and sidebar bullets stop describing five revealers. The README records the deviation from `SPECS.md` that names the panes as separate collapsible sections. Verified: `[side-tab-probe] page=signals rows=4 accums=4 advanced=4 min_n=16`; `signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1`; `panels: … (page_current=1) (sensitive=0) switcher=1`; `toolbar icons:` six controls with every name `(ok)`, with the Preferences dialog opened in the same run; zero `Gtk-CRITICAL` and no missing icon. |
||
|
|
a493737ca1 |
ui: one channel list, and the math pane is gone
`rebuild_signal_list()` is handed `model.signals()`, and `append_math_signals_locked()` already appends one synthetic entry per math channel -- "they go through the ordinary signal path on purpose: a derived channel is a channel" -- so **every math channel was already a row of the signal list**, with its tick, chip, name, unit and menu. The math pane listed the same channels a second time, so this is mostly a deletion: `build_math_panel()`, `rebuild_math_pane()`, `src/ui/math-panel.ui` and its resource entry, `App::math_list`, and the `math panel:` dump line. What the one list gains: - a non-selectable `MATH` divider before the first math row, carrying the `+` that creates a channel -- appended even when there are no math channels at all, because a `+` conditional on one existing could never create the first one; - a math row's own menu: *Edit graph…*, *Default colour*, *Delete*. No Information and no Calibrate (calibration is applied at ingest, and a derived channel has no stream to describe), and no rename, because the node editor owns a channel's name and unit -- a second place to change them would be a second answer to the same question. An item dismisses the row's popover before opening or destroying anything from it; - `App::math` mirrored from a math row's tick: `enable_locked()` mirrors it into the model, but `App::math` is the copy the session file is written from. The mirror sits *before* the `apply_math_channels()` in the `!on` path, which would otherwise push a stale `enabled = true` straight back into the model; - a tick visible in a file view too: a derived channel is computed, and a file view has no acquisition to start, which is the other tick's question. `rebuild_signal_list()` also publishes the list's own shape -- `rows`, `signals`, `math` -- and the dump prints `divider=1` when `rows == signals + 1 + math`: the reading for a list that lost its `+`. Verified: `make test-ui` 14 files (was 15); `signals panel: labels=2 list=1 rows=9 signals=4 math=4 divider=1`; `[math-probe] 3 math channels`, every other line unchanged; no `math panel:` line, and zero `Gtk-CRITICAL`. |
||
|
|
619b5e356c |
fix: five review findings on the tabbed-sidebar commit
Finding 2 — four builder ids had no null check. `side_stack`,
`side_switcher`, `measure_page` and `signals_page` were resolved at
lines 4709-4712, well past the skeleton null-guard. A typo in the `.ui`
would have been a silent segfault. The four resolutions are moved to
the guard block and added to its condition, in the same form the guard
already uses.
Finding 3 — the 'editable focused: m refused' leg of keys_probe was
deleted instead of ported. It is restored, adapted for the new
behaviour: 'm' now selects the Measure tab instead of toggling a pane,
so the refusal is observed as the side_tab name being unchanged across
the synthesised key, not as a toggle that did not flip. The dump line
keeps its existing name and `refused=` field so probe-readings.md:601
still describes it.
Finding 4 — `exit_probe_step3` assigned `sidebar_shown` to both
`dbg_side_visible` and `dbg_side_column_after_hide`, and the panels:
dump line read the wrong one (dbg_side_visible) for its `(visible=)`
field. Two readings that cannot disagree diagnose nothing. Resolved
by making `panels:` read `dbg_side_column_after_hide` and removing the
redundant `dbg_side_visible` field.
Finding 5 — the three-line rationale comment above `reload_file_view`
in `apply_math_channels` was removed by the commit under review.
Nothing it said became untrue. Restored verbatim from
|
||
|
|
44d2fd35dc |
ui: the sidebar is two tabs, and a toggle disables its pane
Replace the sidebar's five-panel scrolling column with an AdwViewStack carrying two pages — Measure (cursors + measurements) and Signals (trigger + math + channel list) — with an AdwViewSwitcher above it. The trigger and cursors toggles change from "reveal/hide my pane" to "enable/disable my feature, leaving the pane insensitive", following the disable-not-hide principle. The keys probe loses a duplicate registration that was toggling the sidebar back on before the exit probe captured its state; the exit-probe captures now read adw_overlay_split_view_get_show_ sidebar() instead of gtk_widget_get_visible(), which AdwOverlaySplitView never changes. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> |
||
|
|
50a1ddca62 | added loading screen | ||
|
|
1583741e03 |
ui: the trigger panel offers a noise band and an HF reject
Two label-only rows in trigger-panel.ui (band_row, hf_row, after holdoff_row and before
mode_row) and the two spins the code appends into them: the band on the level spins' scale
in signal units, the HF reject at 0-1e7 Hz with 0 off. Both are published with the other
fields -- read before the model lock is taken, assigned inside it -- and both join the
value-changed loop, so editing either republishes the condition.
The rows are shown per type: the band for every type but Dropout, which is an absence a
band cannot describe, and the HF reject for all of them, since it sits before every
comparison. `sync_trigger_rows()` resolves a row as its spin's parent rather than keeping a
second handle to it: the row comes from the file and holds one child.
The exit dump counts the panel's controls, so it reads controls_inside=15/15 now -- and the
probe confirms it, along with both search readings and no Gtk-CRITICAL:
[udpscope] trigger panel: boxes=15 controls_inside=15/15 search_widgets=4/4 search_inside=4/4
|
||
|
|
c3d588f03d |
fix: the math editor's body was a child of the window, not of the box above it
Moving the editor's frame into math-editor.ui left `math_body` and `math_actions` as <child> elements of the *window* rather than of `math_outer`, because those edits were anchored on the file's tail and closed the outer box two elements early. A GtkWindow takes exactly one default <child>, so nothing failed loudly: the body -- with the canvas and the parameter form inside it -- was left parentless, visible but never mapped, 0x0, drawing nothing. Two symptoms, one cause: the canvas is blank, and the window is sized around the head row and the buttons alone. Measured against the pre-move revision in a worktree, same probe, same environment: before this fix `canvas=0x0 mapped=0 window=756x940`; after it `canvas=420x836 mapped=1 window=938x940`, which is the old revision's own reading exactly. It survived because that slice's probe read the editor's *values* (nodes, gain, output, name, unit) and nothing about its structure. Every other panel moved that day has a structural reading -- `controls_inside=13/13`, `nested=1`, `labels=2 list=1` -- and none of them was affected. `[math-probe] editor frame:` is that reading now: the canvas's allocation, its mapped state, and whether the canvas and the name entry are inside the dialog at all, delayed 500 ms because an allocation only exists after a layout. In the broken build it read `0x0 mapped=0 in_dialog=0`. check_ui passes both files: the XML was valid and every property type-checked. |
||
|
|
28d80f2e90 |
ui: the header bar's frame is a .ui file, closing item 10's last row
src/ui/header-bar.ui holds the bar: the time-window control's slot, the trigger, pause and events group with the separator that marks where the view's controls end, and the five at the far end. What stays in code is everything that acts on the model -- the overflow menu's rows, the window control itself, and every handler. The order is the file's, which is the part the code used to explain in three comments: a <child type="end"> is added leftwards-from-the-right in document order, so the screen reads the reverse of it -- cursors, measurements, math, sidebar, menu. The first start child is a *slot* and not the control, and that is the one thing this move could not do naively: gtk_header_bar_pack_start() appends after whatever a file has already packed, so the control -- which is code, its menu's rows coming from the buffer's limits -- would have landed to the right of the whole group instead of at its head. The probe is the check, and every header reading is byte-identical to before the move: 12 lines of `header:` (window_button_w=130 bar_w=756), `window control:`, `panels:`, both width-probe frames and all seven layout-probe lines. Only `badge frames:` differs, and it is a frame counter. check_ui: 14 ok, 0 failed. |
||
|
|
148d1168d6 |
ui: the events pane's frame is a .ui file
src/ui/events-pane.ui holds the pane: the GtkRevealer that slides it in, the header with the way back to the live stream, the Compare and Accumulate actions, and the list inside its scroller. The rows stay in code -- one per latched event, rebuilt whenever the cache changes -- and their sensitivity stays with update_group_buttons(), which is why the file only starts the two actions insensitive. Two statements moved into the file along with the widgets, because they are about what the pane is for rather than about a default: multiple selection, and activation on *double* click. With single-click activation the plain click that builds a selection would replay the event instead. Read back as `events pane: compare=1 accumulate=1 list=1 revealer=1 nested=1`, where `nested` is the list being inside the revealer -- the one thing a lost id cannot show. The [events] probe is the other half and drives the pane end to end: recording, trigger, the history button, a row click and the replay, then Compare and Accumulate, so the file's buttons are read by being clicked. check_ui: 13 ok, 0 failed. |
||
|
|
c86d2117fb |
ui: the math editor's action row is the file's, closing that plan row
The row is the file's -- the box and its two buttons, the second carrying the suggested-action class -- and what is code is what they do and what the second one says: "Save" edits an existing channel and "Add" makes a new one, which the file cannot know. With it the editor's frame is whole: the window, the identity row, the body with the canvas in it, and the buttons that end it. The parameter form and the palette stay in code, and for the reason the canvas's *drawing* does -- the form follows which node is selected and the palette is the operation table itself. The probe clicks the apply button rather than calling its handler, so `dialog build: ... name='math3' unit=''; saved nodes=2 output=1` is that button having worked from the file, and `after close: dialog=cleared` / `reopened: dialog=ok, nodes=2` are the lifetime around it. check_ui: 12 ok, 0 failed. |
||
|
|
1a02946656 |
ui: the math editor's window, identity row and canvas are a .ui file
The second half of this plan row, and the half of that dialog which is template. src/ui/math-editor.ui holds the window (its size and modality), the box around it, the identity row -- the name and the unit -- and the body with the canvas in it. What stays in code is the drawing: the canvas is a GtkDrawingArea the file *declares* and the code draws, with the gestures that make it a graph editor, and the parameter form follows which node is selected. The palette and the buttons go next. The builder is held on `App` and cleared on the *next* opening rather than with the window: the window belongs to the builder's object list, so freeing the builder under a live window frees the window with it. That is the source form's pattern, and the probe's close-then-reopen path is what exercises it -- `reopened: dialog=ok, nodes=2`. The identity row is read back too, since a `.ui` can hold the widget but not the value: `dialog build: ... name='math3' unit='' ...`, the entries having been filled from the channel being edited. check_ui is at 12 ok, 0 failed. |
||
|
|
90a17eb186 |
ui: the math panel's frame is a .ui file
The first half of this plan row. src/ui/math-panel.ui holds the panel: the box and its margins, the heading row with the title and the add button, and the list the channel rows go in. The rows stay in code -- each is a channel with its name, unit, expression, colour and its own edit and remove buttons -- so the file holds the frame, as in the measurements and signals panels beside it. Read back by a dump line that also reads the file's own text: `math panel: head=2 buttons=1 list=1 title='Math channels'`, where buttons=0 is a lost `+` and title='-' a lost label. `[math-probe]` still drives the add and opens the editor, so the rows landing in the file's list is read as well as the frame. The other half is the node editor's chrome, which is where least of that file is template: the canvas is a GtkDrawingArea that is drawn rather than declared, and the gestures, the parameter fields and the palette are code. That is its own change. |
||
|
|
c37fbdde21 |
ui: the signals panel's frame is a .ui file
src/ui/signals-panel.ui holds the panel's shape: the box and its margins, the heading, the hint that says what a signal is for, and the list. The rows stay in code -- one per signal, carrying that signal's switch, colour, calibration and drop targets, rebuilt whenever the stream's signals change -- so the file holds the frame, which is the shape the plan gives this slice. The list's width-only size request moved with it, comment and all: a height floor there made the list occupy 120 px whatever it held, which hid the compact rows' height behind it. Read back by a new dump line, `signals panel: labels=2 list=1`, where a lost id reads as `labels=1` or `list=0` instead of a panel quietly missing its hint; `panels: … row_h=26` still reads the list's geometry and the height a row takes. check_ui is at 10 ok. |
||
|
|
1ccc70c5ac |
ui: the trigger panel's frame is a .ui file
The panel's shape -- the heading, one labelled row per setting, the two buttons that run it, and the search block -- now lives in src/ui/trigger-panel.ui. Every control that carries a value stays in code and is appended into the file's row: a dropdown's model and a spin's range are the application's, a label is text a translator owns, so that is where the line runs. `row_for()` is `labeled_box()`'s arrangement with the labels moved into the file; the thirteen old calls to `labeled_box()` here are gone and its eleven uses elsewhere are untouched. `sync_trigger_rows()` now guards the rows it shows and hides, because a row that comes from the file can be missing rather than merely misnamed. The frame is read back by a new dump line: trigger panel: boxes=13 controls_inside=13/13 search_widgets=4/4 search_inside=4/4 where `controls_inside` is what a drifted id breaks -- a control built and never placed leaves the panel with a field missing and nothing else complaining -- and `[defaults] trigger: 0.001 0.002 0 0.001 0 50 0.01` still reads the seven spins in the same tree order, which is the check that the restructure kept the panel's contents. Two days of grief came out of one GTK fact, now in the invariants: an expander's child is not in the widget-parent chain (`gtk_widget_get_parent()` is NULL for it even after the window is presented, while `gtk_expander_get_child()` returns it), so the probe walk had to start there instead of at the panel. The throwaway program that settled it is what stopped the workaround -- a second root in the file plus a code attach -- from being the answer. |
||
|
|
d344361165 |
ui: the measurements panel's frame is a .ui file
The panel's shape -- the title row with its add button, the one statistics switch the whole panel shares, and the box the readings go into -- now lives in src/ui/measures-panel.ui. The readings stay in code: each row is a measurement a user placed, rebuilt whenever the set or the mode changes. A faithful move, not a redesign: the same margins, spacing, labels, tooltips, flat classes and insensitive Reset button, the same two lambdas on the same two signals. The frame is read back through the dump line, which grew the two fields that can disagree with the code that drives them -- `measures pane: n=4 revealed=0 marks_shown=6 pane_rows=4 pane_w=0 pane_h=0 switch_on=1 reset_sensitive=1`. In that reading pane_rows=4 is the four readout rows having landed in the frame's `measures_list`, and switch_on/reset_sensitive are the switch widget and the Reset button following measure_stats_on, which the probe set through the same signal a user would. `check_ui` type-checks the file (8 ok, 0 failed) and the build stays at four warnings. |
||
|
|
0a64e9e854 |
ui: the source chooser's window is a .ui file, and its pages an AdwViewStack
The last hand-built UI outside ui.cpp. The window, its header bar, its two pages and the four buttons that are about the chooser rather than about a source now live in src/ui/source-chooser.ui; the rows stay in code because a source row and a waveform row are data, rebuilt whenever the known hosts or the recent files change. The pages were a GtkNotebook and are an AdwViewStack under an AdwViewSwitcherBar, which is what current libadwaita asks for and the same information without a tab strip. The three actions moved from a button row at the foot into the header bar, the shape source-form.ui already uses -- and deliberately without receives-default, since Enter must keep activating the selected row. Nothing carries a value: the tooltips and labels moved as they were, the handlers are the same functions, and the switch is by page *name* rather than by index. The probe reads the frame back -- `[sources] chooser ui: page='sources' title='Sources' icon=yes page='waveforms' … switcher_wired=1 buttons=[prefs=1 new_host=1 open=1 cancel=1]` -- so a .ui that lost an id is named instead of opening a window with a hole in it, and `check_ui` type-checks the file against the GIR (7 ok, 0 failed). Two things the checks caught while doing it: the new frame dropped the call that fills the waveforms page (the unused-function warning), and `AdwViewStackPage` is a GObject that *wraps* a child rather than being one, so reading the pages needs adw_view_stack_get_page(). |
||
|
|
f00d161900 |
fix: row titles mean their mnemonics instead of drawing the underscore
Fifteen display strings in the two preference forms were written as GTK mnemonics -- `_Directory`, `_Server`, `Multicast _group`, `_Theme` and the rest -- but the property that makes GTK read them was never set, so GTK drew the underscore literally: `_Directory` in the row, not a D with a line under it. Two switches are involved, FALSE by default in each case. `GtkLabel`/`GtkButton` take `use-underline`; the rows take `AdwPreferencesRow:use-underline`, "whether an embedded underline in the title indicates a mnemonic". The two buttons in the source form already set it (so their mnemonics worked and showed no underscore); the thirteen rows did not. All thirteen now do. The row classes that carry the property are not all related -- AdwEntryRow has its own, while AdwSpinRow, AdwComboRow and AdwSwitchRow get it through AdwActionRow -- so the probe reads it by name on a GObject (g_object_class_find_property, then g_object_get) rather than casting to one row type; a cast to a `GtkWidget*` where the type is only forward-declared does not compile at all. `_Cancel` and `_Connect` both claimed C in the same form, so Alt+C could only reach one of them: that marker was unreachable, which is worse than absent because it is in the source to be trusted. `_Connect` becomes `Conn_ect`, freeing C for Cancel. [prefs-probe] reads the count (4/4) on the real widgets; check_ui validates the property against the GIR for every object in every .ui file, inherited properties included. |
||
|
|
bb01278d88 |
fix: the sidebar keeps its own width, so a small window can no longer hide it
The sidebar's width was a GtkPaned divider position: `window - 940` in pixels. On any window narrower than 940 px the divider landed past the right edge and the sidebar came up 0 px wide, reachable only by dragging the divider back. Every attempt that kept the paned failed for that reason -- deriving the position from the paned's own width, re-parenting the column, property-form children, code-set size requests. The mechanism was the fault, so it is gone: the row is an AdwOverlaySplitView, which is what a utility sidebar is in GNOME, with min-sidebar-width = max-sidebar-width = 320 in pixels. Its width is therefore its own at any window size, and when there is no room beside the plot it *overlays* the plot (collapsed, set from the window's width in tick()) instead of being squeezed to nothing. Two things found on the way, both recorded in the invariants: - sidebar-width-fraction is a 0..1 fraction of the window whatever sidebar-width-unit says. Setting a pixel count there is a GObject critical and it falls back to 0.25, which is why the sidebar first read 560. The width is pinned with min/max instead. - the window opened at its content minimum (756 px, the plot column plus the sidebar) because the explicit gtk_window_set_default_size() was dropped when the window moved into the .ui. Restored. [sidebar-probe] reads both states and is the guard: window=756 sidebar=320 under a 2x scale, which is the case that used to read 0. |
||
|
|
ed82abd405 |
feat: the cursors panel is a UI file
src/ui/cursors-panel.ui holds the panel's shape -- the header with the mode dropdown and the reset button, and the grid the two cursor times are read in. What is not in it is the grid's contents, which follow the cursor mode and the rows and are rebuilt whenever either changes. Panels need a builder that outlives them, unlike the dialogs, which are rebuilt on every opening: a widget belongs to the builder that built it, so App::ui_builders holds each panel's for the life of the window. ui_load() is that, in ui_widgets.cpp. make test-ui also checks that udpscope-ui.gresource.xml lists every .ui file now. That was the third trap of this refactor -- a file on disk but not in the resource is a Gtk-ERROR at load, which aborts -- and it is a compile-time mistake that nothing else in the build could see. Verified by unlisting the new file and watching the check fail, then restoring it. [cursors-probe] reads as it did: all four modes, pane=1, the timings and the drag cases. |
||
|
|
8073e7b3fd |
feat: the main window's skeleton is a UI file
src/ui/main-window.ui holds the window, its two panes, the plot column with the status line under it, and the sidebar's scroller. What stays in code is what depends on the stream: the events pane, the two plot areas, the sidebar's panels and the status popover, each put into a slot by id. The header bar is deliberately not in the file. It is the window's own titlebar, every button on it is wired to an action, and an AdwApplicationWindow refuses a titlebar outright -- a fatal Adwaita-ERROR rather than a warning that leaves the window merely ugly -- so the window is a GtkApplicationWindow and the header bar stays where it was. gtk_paned_set_position() is the other thing that had to stay in code, and the reason is order rather than shape: it clamps against its children *now*, so stating it before the sidebar's panels exist moved the plot column by ~180 px. The layout, events and box-zoom probes read what they read before this change, and the header reads window_button_w=130 bar_w=756 as it always has -- checked by building the previous commit and running the same probe against it, because the layout numbers in the notes turned out to be stale from an earlier session. Five UI files now. |
||
|
|
68743a7d7f |
feat: the host form, and the help window, are UI files
src/ui/source-form.ui is the form a source is added or edited with: an AdwDialog of AdwPreferencesGroup rows (AdwEntryRow, AdwSpinRow, AdwSwitchRow) with its action in the header bar, where the label says which mode the form is in -- Connect for a new host, Save for an existing configuration. The grid of labels and bare entries it replaces had its hints in placeholders, which vanish as soon as there is a value; they are the groups' descriptions now. src/ui/help.ui is the help window's chrome: an AdwWindow with a header bar and an AdwNavigationSplitView whose sidebar is the page list. The rows stay in C++ because the fourteen pages are data, not template. Four UI files now, 653 lines of template that used to be widget construction. Two libadwaita traps cost a run each and are in docs/claude/invariants-gtk-and-chrome.md: an AdwDialog has no content property (its child is a plain <child>, unlike an AdwToolbarView), and AdwEntryRow has no subtitle (it is an AdwPreferencesRow, not an AdwActionRow). The unused form_field() helper went with them. Every probe reading is unchanged -- [sources] form:, [sources] edit form:, [help] -- which is what refactoring a shape rather than a behaviour should look like. |
||
|
|
4c56172005 |
feat: the preferences are a UI file, applying as they change
src/ui/preferences.ui is an AdwPreferencesDialog whose rows are libadwaita's own -- AdwEntryRow, AdwSpinRow, AdwSwitchRow, AdwComboRow, AdwButtonRow -- instead of a label and a bare GtkEntry in a GtkBox. There is no Apply button: a row applies as it changes, which is what those widgets are designed around, and [prefs-probe] reads the settings file back after every change. The folder chooser is cancellable now, so a picker still open when the dialog closes cannot write into a row that is gone; the cancellable is also how the probe takes the cancel path without a mouse. Two traps cost a run each and are recorded in docs/claude/invariants-gtk-and-chrome.md: an AdwDialog is not a GtkWindow (it cannot parent a GtkFileDialog, and in floating mode it adds a native surface to gtk_window_get_toplevels() that is neither a widget nor a window), and a .ui that is not listed in the GResource is a Gtk-ERROR at runtime rather than a build failure -- which is exactly what the probe opening every window is for. |
||
|
|
0e256764d5 |
udpscope: an oscilloscope view for a UDPStreamer source
GTK4 and libadwaita. It receives one UDP stream through the vendored udpsclient, buffers it in a large ring, and draws it with the vendored plotter: a trigger with a pre/post event window, a pause that freezes the window, cursors, measurements, math channels in a node editor, an event cache, named configurations, preferences, and an export of the visible window to .npy/.csv. Waveform files can be opened and analysed in place. The interface is two threads -- a producer that owns the socket and the GTK main thread that owns every widget and the GL context -- sharing one model under one mutex, with samples crossing through lock-free rings. deps/plotter is a submodule pinned to the commit this tree builds against; deps/udpsclient is vendored in place. docs/claude/ documents the rules that cost a defect to learn, and SPECS.md is the requirement document. |