Join the tab row to the frozen date row

The tab row was painted in the page ground while the day bar above it is a
card, and the two sat as separate bars with the day bar's rounded corners
cutting between them — so the frozen top of the screen read as two mismatched
strips rather than one thing.

It takes the same card now: surface colour, radius and shadow. Once the log
buttons have scrolled away and the two bars meet, they lose the seam and share
one frame — the day bar squares its bottom corners and gives up its shadow, the
tab row squares its top, and the pair casts a single shadow below. Scrolled back
to the top they are two cards again, which is right: the log buttons genuinely
sit between them there.

CSS has no way to ask whether a sticky element is currently stuck, so a class is
toggled from JS on the simplest available test — whether the two are touching.
It rides the rAF-throttled scroll handler that already existed for the timer
pill, so it adds no listener, and it is re-checked on a tab switch (a shorter
tab can leave the page too short to stay scrolled) and when the day bar changes
height.

The tab row's sticky offset is now a pixel less than the day bar's height. The
safe-area inset is free to be fractional while the measured height is a whole
number, and that shortfall would show as a hairline of page ground between the
two; the overlap it costs is invisible, since the day bar paints on top in the
same colour.
This commit is contained in:
Alexander Heldt
2026-09-07 20:14:24 +00:00
parent de1e18e394
commit 39343ff2a4
3 changed files with 54 additions and 8 deletions
+28 -6
View File
@@ -4766,8 +4766,10 @@
}
}
// The pill's visibility depends on whether the big card is on screen, and
// the card only exists on one tab.
// the card only exists on one tab. A shorter tab can also leave the page
// too short to stay scrolled, unsticking the bars, so re-check that too.
updateBarClockMode();
updateTabsMerged();
}
for (const [i, btn] of tabButtons.entries()) {
@@ -4794,13 +4796,28 @@
if (!dayBarEl) return;
document.documentElement.style.setProperty("--day-bar-h", `${dayBarEl.offsetHeight}px`);
}
const onDayBarResize = () => { measureDayBar(); updateTabsMerged(); };
if (dayBarEl && typeof ResizeObserver === "function") {
new ResizeObserver(measureDayBar).observe(dayBarEl);
new ResizeObserver(onDayBarResize).observe(dayBarEl);
} else {
addEventListener("resize", measureDayBar);
addEventListener("resize", onDayBarResize);
}
measureDayBar();
// Once the log buttons have scrolled away the two frozen bars meet, and they
// should look like one card rather than two stacked ones — see .merged in the
// stylesheet. CSS cannot ask whether a sticky element is currently stuck, so
// the test is simply whether they are touching.
const tabsEl = document.querySelector(".tabs");
function updateTabsMerged() {
if (!dayBarEl || !tabsEl) return;
const gap = tabsEl.getBoundingClientRect().top - dayBarEl.getBoundingClientRect().bottom;
const merged = gap <= 1; // they overlap by a pixel by design
dayBarEl.classList.toggle("merged", merged);
tabsEl.classList.toggle("merged", merged);
}
updateTabsMerged();
showTab(loadTab());
// ---------- wiring ----------
@@ -4836,14 +4853,19 @@
});
});
// Swap the timer pill in/out of the frozen bar as the big card scrolls
// past. rAF-throttled: scroll events fire far more often than we can paint.
// Swap the timer pill in/out of the frozen bar as the big card scrolls past,
// and join the two frozen bars up once they meet. rAF-throttled: scroll
// events fire far more often than we can paint.
{
let queued = false;
const onScroll = () => {
if (queued) return;
queued = true;
requestAnimationFrame(() => { queued = false; updateBarClockMode(); });
requestAnimationFrame(() => {
queued = false;
updateBarClockMode();
updateTabsMerged();
});
};
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll, { passive: true });