# 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. - **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 the **bottom row** of the figure carries one, every other row 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_`, 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`.