831831 on. The reference styles the glyph rather than the button, so the button stays
832832 flat when pressed and only the mark changes. */
833833
834- .traj-controls {
834+ /* One sticky band for both filter axes.
835+
836+ The type row and the 异常 row decide the same question — which records the ledger shows —
837+ so they are pinned as a single block rather than one of them following the other out of
838+ view. Only the type row claimed `position: sticky` before, and it did not survive: with
839+ the ledger below it and both rows scrolled away, a session filtered down to a few hundred
840+ rows gave no sign that anything was filtered at all. The band carries its own background
841+ because it now sits over the ledger, which is opaque underneath it.
842+
843+ Everything pinned below this measures its offset from it rather than assuming it — see
844+ syncStickyOffsets(). The anomaly row wraps at narrow widths and the note under it comes
845+ and goes, so its height is not a number anyone can write down once. */
846+ .filter-sticky {
835847 position : sticky;
836848 top : 0 ;
837- z-index : 5 ;
849+ z-index : 6 ;
850+ background : var (--mcode-bg );
851+ border-bottom : 1px solid var (--mcode-border );
852+ }
853+
854+ .traj-controls {
838855 display : flex;
839856 flex : none;
840857 align-items : center;
841858 padding : 6px 16px ;
842859 background : var (--mcode-bg );
843- border-bottom : 1px solid var (--mcode-border );
844860 }
845861
846862 .traj-controls-actions {
941957
942958 .overview {
943959 position : sticky;
944- top : 32px ;
960+ top : var ( --sticky-band-h , 32px ) ;
945961 z-index : 4 ;
946962 border-bottom : 1px solid var (--mcode-border );
947963 user-select : none;
12071223
12081224 .turn-head {
12091225 position : sticky;
1210- top : 0 ;
1226+ /* Below every band above it, measured. It used to pin at 0 with z-index 2 and simply got
1227+ drawn over — its turn title and its kind summary landed on the type row's buttons and on
1228+ the overview's tick labels, and neither could be read. */
1229+ top : calc (var (--sticky-band-h , 32px ) + var (--sticky-overview-h , 51px )
1230+ + var (--sticky-window-h , 45px ));
12111231 z-index : 2 ;
12121232 display : flex;
12131233 align-items : center;
15861606
15871607 .window-bar {
15881608 position : sticky;
1589- /* Sticky offsets stack here: the type row owns 0–32 and the overview strip owns
1590- 32–83 (32 + the 50px plot + its 1px border), so a bar pinned at 0 would simply
1591- hide behind both of them. z-index 3 keeps it under the overview (4) and over the
1592- turn head (2). */
1593- top : 83px ;
1609+ /* Sticky offsets stack here, and every one of them below the filter band is measured
1610+ rather than written down: the band is the two filter rows (whose height changes when
1611+ 异常 wraps or when its note appears) plus the overview strip. The old numbers — 0–32 and
1612+ 32–83 — were correct for a layout where the 异常 row was not in the band, and they are
1613+ exactly the kind of constant that goes stale silently. syncStickyOffsets() owns them. */
1614+ top : calc (var (--sticky-band-h , 32px ) + var (--sticky-overview-h , 51px ));
15941615 z-index : 3 ;
15951616 padding : 8px 16px ;
15961617 background : var (--mcode-bg );
@@ -2058,7 +2079,8 @@ <h2 class="section-title" id="kpi-heading">关键指标</h2>
20582079 < div class ="kpi-grid " id ="kpi-grid "> </ div >
20592080 </ section >
20602081
2061- < div class ="traj-controls " id ="kind-controls " role ="toolbar " aria-label ="按类型筛选轨迹 " aria-controls ="overview " data-filter-scope >
2082+ < div class ="filter-sticky " id ="filter-sticky ">
2083+ < div class ="traj-controls " id ="kind-controls " role ="toolbar " aria-label ="按类型筛选轨迹 " aria-controls ="overview " data-filter-scope >
20622084 < nav class ="traj-controls-actions " aria-label ="按类型筛选轨迹 ">
20632085 < button type ="button " class ="tbtn tbtn-action tbtn-all " id ="kind-all " aria-pressed ="true "
20642086 aria-label ="当前显示全部类型 · 点击只看空白 " title ="当前显示全部类型 · 点击只看空白 ">
@@ -2136,6 +2158,7 @@ <h2 class="section-title" id="kpi-heading">关键指标</h2>
21362158 < div class ="anomaly-quick " id ="anomaly-controls " role ="group " aria-labelledby ="anomaly-label "> </ div >
21372159 < span class ="anomaly-bar-note " id ="anomaly-mode-note " hidden > </ span >
21382160 </ div >
2161+ </ div >
21392162
21402163 < section class ="overview " id ="overview " aria-labelledby ="overview-heading ">
21412164 < h2 class ="sr-only " id ="overview-heading "> 轨迹总览</ h2 >
@@ -3124,6 +3147,7 @@ <h2 class="section-title" id="inspector-heading">检视</h2>
31243147 dom . staleBar = document . getElementById ( 'stale-bar' ) ;
31253148 dom . toolbar = document . getElementById ( 'toolbar' ) ;
31263149 dom . kindControls = document . getElementById ( 'kind-controls' ) ;
3150+ dom . filterSticky = document . getElementById ( 'filter-sticky' ) ;
31273151 dom . sessionSelect = document . getElementById ( 'session-select' ) ;
31283152 dom . generationField = document . getElementById ( 'generation-field' ) ;
31293153 dom . generationSelect = document . getElementById ( 'generation-select' ) ;
@@ -3139,6 +3163,7 @@ <h2 class="section-title" id="inspector-heading">检视</h2>
31393163 dom . anomalyNote = document . getElementById ( 'anomaly-mode-note' ) ;
31403164 dom . anomalyMulti = document . getElementById ( 'anomaly-multi' ) ;
31413165 dom . windowBar = document . getElementById ( 'window-bar' ) ;
3166+ dom . overview = document . getElementById ( 'overview' ) ;
31423167 dom . headerToggle = document . getElementById ( 'header-toggle' ) ;
31433168 dom . kpiToggle = document . getElementById ( 'kpi-toggle' ) ;
31443169 dom . kpiGrid = document . getElementById ( 'kpi-grid' ) ;
@@ -6091,6 +6116,28 @@ <h2 class="section-title" id="inspector-heading">检视</h2>
60916116
60926117 /* ---------------- top-level render ---------------- */
60936118
6119+ /**
6120+ * Publishes the heights of the pinned bands so everything below them can be positioned from
6121+ * a measurement instead of a number written down once.
6122+ *
6123+ * The alternative was three constants — 32 for the type row, 51 for the overview strip, 45
6124+ * for the window bar — and they were already wrong the moment the 异常 row joined the band:
6125+ * that row wraps at narrow widths and grows a line when its note appears, so no constant can
6126+ * stay right. This runs at the end of `render()`, after the note has been written and the
6127+ * buttons re-counted, which is the only moment the band's height is final. `offsetHeight`
6128+ * rather than `getBoundingClientRect().height`, so a fractional device-pixel ratio cannot
6129+ * make the bands overlap by a third of a pixel per poll.
6130+ */
6131+ function syncStickyOffsets ( ) {
6132+ if ( ! dom . filterSticky || ! dom . overview || ! dom . windowBar ) return ;
6133+ var root = document . documentElement . style ;
6134+ var band = dom . filterSticky . offsetHeight ;
6135+ var overview = dom . overview . offsetHeight ;
6136+ root . setProperty ( '--sticky-band-h' , band + 'px' ) ;
6137+ root . setProperty ( '--sticky-overview-h' , overview + 'px' ) ;
6138+ root . setProperty ( '--sticky-window-h' , dom . windowBar . offsetHeight + 'px' ) ;
6139+ }
6140+
60946141 function render ( ) {
60956142 renderBars ( ) ;
60966143 renderGeneration ( ) ;
@@ -6123,6 +6170,10 @@ <h2 class="section-title" id="inspector-heading">检视</h2>
61236170 renderInspector ( ) ;
61246171 renderFooter ( ) ;
61256172 updateHandoffButton ( ) ;
6173+ // Last: it reads the bands the lines above just changed. The 异常 note appears and
6174+ // disappears here, so measuring before this point would pin the bands to yesterday's
6175+ // height and leave the window bar overlapping the strip above it.
6176+ syncStickyOffsets ( ) ;
61266177 }
61276178
61286179 function renderGeneration ( ) {
0 commit comments