# Invariants: the time axis and the window control What may change the time scale (one function, one caller), how the x labels pick their form, and what a fit restores. Part of [CLAUDE.md](../../CLAUDE.md). These are rules that cost a defect to learn; read the ones touching the area you are editing before you edit it. - **The window control's label is width-locked and ellipsized** (`kWindowLabelChars`), for the same reason as the status label: it sits in the header bar above the plot, so a label that grew from `1 s` to `123.456 ms` would widen the bar and relayout everything under it on a mere change of window. That is also why the window is typed into a popover rather than shown in the bar. - **No plot interaction changes the time scale; only the window control does.** The *time scale* is more than the axis: `RowSet::window_s_` also feeds `ScopeModel::set_event_span()` (the trigger's pre-trigger share is a share of *this* window), `ScopeModel::set_target_span()` (which may re-plan the partition, move the decimation and **drop the history**), the window control's readout and the session file. A **zoom or a pan** does not touch it by construction — the controller writes the *member* axis and `clamp_to_data()` caps the span at `window_s_`, so nothing has to be written to keep it that way. **Neither do the two fits**: a fit is the *undo of a zoom*, so `RowSet::fit_x()` restores the window that is already set and reads `window_s_` without ever writing it. Both fits used to frame the *data* instead and move `window_s_` alone, which was wrong twice over — a gesture-triggered re-plan of the rings, and an event span left on the *old* window until the next decade step or unit change applied the fitted value for real (one action, two changes of time scale). - **`apply_time_window()` is the only place the time scale changes**, and the window control is its only caller. It clamps to `window_limit()` and moves `set_target_span()`, `set_event_span()`, `RowSet::set_time_window()`, the control and the session save together — the partition **before** the axis, so the axis is framed over the rings that will exist. A fit must not come here for two reasons: it is not entitled to the scale at all, and `set_time_window()` unpins and re-arms the live follow, which would throw a held event or a paused view onto a live edge it has nothing bound to. Paths that change the span for their own reasons (an event pin) call `sync_window_display()` alone, which only restates the control and never moves the plot — calling `apply_time_window()` there would clamp a legitimately short capture up to the requested window. - **With nothing bound, `set_time_window()` frames `{-window, 0}` and never `{0, window}`.** Under a following x group a range states two things — its span, and *through its maximum* where the window sits behind the live edge — so a maximum **at** the edge is what makes the offset zero. When there is a live edge to read that is `{newest - window, newest}`; when there is not, the edge the live view would put at x = 0 is 0, so the equivalent statement is `{-window, 0}`. Asking for `{0, window}` there is an offset of a *whole window*. This is not a rare fallback: "no samples bound" is the state every structural change leaves behind, and `apply_time_window()` re-plans the rings and *then* calls this, so it is the axis a mode change is drawn with until the first sample lands. With `{0, window}` the two or three frames after every event or waveform open drew the time base one screen to the left of where the following frames drew it — a trace that jumps a screen sideways and comes back, which is what "opening events or even waveform sometime result in data flickering (like time base) and is unusable" was. The reading is the dump's traceless paint counts: `mode=live` 38 → 0 and `mode=waiting` 2 → 40, the latter being the point — a waiting view now paints its empty axis in the *same* place every frame instead of twitching into a framed one. - **The x labels go relative on the tick *step against the label resolution at the axis' magnitude*, never on the span.** `RowSet::update_time_labels()`'s question is only ever "can this step be written at this magnitude": `TickFormat::Time` carries three significant figures below a minute, **whole seconds** through `m:ss`/`h:mm:ss` up to a day, and three figures of days above — so the resolution is a step function of the magnitude, which `time_label_resolution()` states and nothing else may re-derive. The old test was `span < 1.0` and it was wrong in both directions: a 2 s window at twenty minutes of uptime reads `20:00` on every tick and stayed absolute, and a **triggered** sub-second window — whose axis is already `[-25ms, +25ms]` about the edge — went relative and so re-anchored its ticks *away from the trigger*, with a `t0` caption that had nothing to explain. Three details hold it up. The step is read back from the majors the **last layout** produced, which is not a feedback loop (a tick's value depends on the range and the axis' pixel width, never on the label form) but *is* absent before the first layout — and with no ticks the form must be **left alone**, because a step guessed from the span would be latched by the hysteresis below. The hysteresis is a 1.05 band on *leaving* the relative form. And *entering* it needs a ULP of slack (`1 - 1e-9`): a step differenced from two tick values and a resolution from `pow(10, n)` differ in their last bits when they are the same number, and `step == res` is a readable axis (`1.73s`, `1.74s`, `1.75s` — one in the last digit is exactly enough), so the bare comparison picked a different form from one frame to the next. The guard is `[timelabels-probe]`'s `distinct=1`, which asserts what the form is *for* rather than which form a given window gets. - **The `t0 = …` caption is chrome, never an annotation.** It is `Plot::set_x_note()` (added to `deps/plotter` for this), drawn by `Renderer::draw_x_note()` on the legend's own `draw_readout_panel()` backplate in the bottom-left corner of the axes, in **pixels**. It was an `Annotation` first, and an annotation's anchor is a *data* coordinate: the caption therefore crawled across the plot under a pan and drifted under a zoom, which is what "[t0=...s] move slightly around when user zoom/pan" was. Three things follow. The **text is the host's**, because the caption has to name the anchor precisely enough that label + caption is the exact absolute time and `format_time()` would round it to three figures (`"t0 = %.6g s"`); only a plot whose x strip draws labels (`plot::x_strip_has_labels(x_strip_mode())`: the bottom row, and any row above it whose columns differ from the row below's) carries one, every other plot being set to `""`; and the renderer measures it rather than `Plot::layout()` — it reserves no space and nothing can be picked on it, so there is no rect to read back, and a caption explaining the x labels must not be able to move the layout those labels were measured into. The guards are the library's `the_x_note_is_chrome_that_a_pan_or_a_zoom_does_not_move` (the footprint is the pixels a frame with the caption differs from one without, and a pan plus a 64× zoom must give the same box to the pixel) and `[timelabels-probe]`'s `note=`, which reads `Plot::x_note()` back. - **`fit_x()` restores the *whole of what the view may show*, which is not always `window_s_`.** Live, that is the newest `window_s_` at the live edge (`set_time_window(window_s_)`, the same path a resume takes). Pinned — a held event, a filling capture or a paused view — it is the **pin's** full range, which is the snapshot's own stamps and may be legitimately *narrower* than the requested window. It must write the per-row **member** axes as well as the link group: `hold_pinned_window()` prefers a member that differs from `pinned_stated_` (it scans **every** row's member, in order, since a labelled strip on a row other than the first takes x gestures too), which is how it tells a real gesture from its own echo, so writing the group alone is reverted to the zoomed range on the next frame. `fit_all()` is the y fit plus this, shared rather than duplicated so the two cannot drift apart. - **Relative time is decided by the newest statement, not by the default.** `Config::relative_time_specified` is set by `--relative-time` and by the preferences — which the source chooser can reach before the run is planned, so that is the one place a preference still changes *this* run. Both the list's own copy (`apply_source()`) and a source's stored configuration (`apply_stored_settings()`) only fill in when nothing newer was said. Without the marker, clicking a source row after applying the preference silently reverted it — `[sources] picked ... relative=0`, with the dialog having just written `1`.