@use "settings" as *; // Paint the page's own background (page colour + the fixed 130deg gradient at // 0.91, pre-composited with color-mix) so a sticky bar stays opaque to content // scrolling under it while looking identical to the page behind it. // // styles.scss paints that backdrop as a real fixed layer: body::after is a // `position: fixed` box at the viewport's top-left corner, viewport-wide and // `height: 100vh` tall. Anything that has to disappear into it must show the // slice of that box it covers. background-attachment: fixed asks the engine for // exactly that, and iOS Safari does not honour it - it anchors the layer to the // document instead, so the bar sweeps the whole ramp (cyan to purple over a // long page) while the gutters beside it hold still. Chromium does honour it, // which is why only a device caught this. // // So derive the alignment from geometry and ask for no feature at all. Each bar // keeps a constant viewport offset - .header at the top, #diff-header directly // below it - and both are flush with the viewport's right edge at every width: // the action rail insets them from the *left* on a wide screen and widens again // on hover, but nothing moves their right edge. Anchor a viewport-sized copy of // the gradient to that edge and push it up by the bar's own offset, and the bar // paints body::after's slice by construction, on any engine. // // $y-offset: the bar's distance from the top of the viewport. @mixin page-surface-gradient($y-offset: 0px) { background: var(--color-background-gradient-second); // fallback, no color-mix // Longhands from here down, and no second `background:` shorthand after them - // one would silently reset size/position/origin back to their initial values. background-image: linear-gradient(130deg, color-mix(in srgb, var(--color-background-gradient-first) 91%, var(--color-background-page)), color-mix(in srgb, var(--color-background-gradient-second) 91%, var(--color-background-page)) 41.07%, color-mix(in srgb, var(--color-background-gradient-third) 91%, var(--color-background-page)) 84.05%); // 100vh to match body::after's own height: 100vh - same unit, so the two // resolve to the same number on iOS, where it means the large (toolbar // collapsed) viewport and therefore holds still as the toolbar moves. // 100vw for the width: the bars are as wide as the viewport only on a narrow // screen, so 100% would compress the ramp into the rail's leftovers on a // desktop one. background-size: 100vw 100vh; background-repeat: no-repeat; // border-box, or the bar's own padding would offset the copy by that much. background-origin: border-box; background-position: right 0 top calc(-1 * #{$y-offset}); } #diff-form { background: rgba(0, 0, 0, .05); padding: 1em; border-radius: 10px; margin-bottom: 1em; color: #fff; font-size: 0.9rem; text-align: center; label.from-to-label { width: 4rem; text-decoration: none; padding: 0.5rem; &#change-from { color: #b30000; background: #fadad7 } &#change-to { background: #eaf2c2; color: #406619; } } #diff-style { >span { display: inline-block; padding: 0.3em; label { font-weight: normal; } } } * { vertical-align: middle; } } body.difference-page { // Keep the top menu (watch URL, EDIT, theme/GitHub icons) on screen while the // diff scrolls, stacked above #diff-header. Scoped to the diff page on // purpose - upstream's @todo in parts/_top_menu.scss wants this globally, but // that is a separate change. // // Sticky makes .header a stacking context, so its mobile drawer / overlay // (z-index 10000 / 9999) are now capped by this value. 30 clears #diff-header // below and still leaves toasts (10000, fixed) painting over the bar. .header { position: sticky; top: 0; z-index: 30; @include page-surface-gradient(0px); } // The top line now carries the watch's name, so give it back the room the two // stacked left insets were taking. Its text starts $common-gap twice in: the // menu row's own padding, which every item on the row needs to clear the edge, // plus the link's own left margin, which on the row's first item only repeats // that padding. Drop the repeat and keep the row's padding: 8.8px back, and // the line still sits on the same left edge as the diff below it rather than // hard against the viewport. The right side of both is untouched - the link's // right margin still separates it from the menu icons. .current-diff-url { margin-left: 0; } // Wherever the hamburger is (max-width: $desktop-wide-breakpoint, set in // parts/_hamburger_menu.scss), the menu row is at its tightest and every item // on it competes with the watch name for the same line. The heart is the one // item on that row that is neither navigation nor status, so it is what gives // way - on this page only, and only under that width. Desktop diff pages and // every other page keep it. // // CSS rather than a {% if %} in base.html: the heart's own reason to exist is // that it is always there, and a template branch would take it off the page // entirely rather than fold it away with the rest of the row. @media only screen and (max-width: $desktop-wide-breakpoint) { #heart-us { display: none; } } // .app-main's 0.55rem gap sits between the two sticky bars, so #diff-header // starts 8.8px below the top menu and then slides up to meet it over the // first 8.8px of scroll - a visible twitch right as scrolling begins. The gap // can't be preserved once stuck (it is outside both bars, so the diff would // scroll through it), so close it here and let them sit flush at every scroll // position. Only the .header/section.content pair is affected; .app-main has // no other children. .app-main { gap: 0; } .tab-pane-inner { scroll-margin-top: calc(var(--app-header-height, 0px) + var(--diff-header-height, 0px) + 1rem); } // The 2px activity strip is fixed on body, so it lives in the root stacking // context and would paint over the mobile drawer now that the drawer is capped // inside .header's context above. Drop it below .header rather than raising // .header past the action rail (60), which would hide the rail's hover flyout. // Still above #diff-header (20) and the minimap (10); on this page it now also // passes under the rail's flyout, which is 2px of imperceptible overlap. #pure-menu-horizontal-spinner { z-index: 29; } // The thin blue line under the active tab. Nothing at that boundary has a // border - the active tab and #diff-ui are both var(--color-background) - so // it can only be the page gradient showing through a device-pixel row where // neither white paints. Whether such a row exists comes down to one number, // the bar's bottom edge minus the card's top edge, and measured across 14 // viewports at three device pixel ratios that number is 0.813, 0.203 or // exactly 0.000 and never a whole pixel. At 0.000 the outcome belongs // entirely to the engine's sub-pixel rounding - which is why a DPR 3 phone // can show a line a DPR 1 desktop does not, and why 390x844 on the Screenshot // tab, the exact case reported, measures 0.000. // // So take the margin out of the engine's hands instead of hoping two white // edges land on the same device row. A pixel of overlap covers the gradient // on any grid; measured the same way afterwards, the worst case over those 42 // readings becomes exactly 1.000px. // // The other candidate was a white skirt under the active tab // (box-shadow: 0 1px 0) which would have touched nothing else, and it does not // work: the shadow hangs below the bar's last child and #diff-header's // overflow: auto - the thing enforcing its height cap - clips it. Measured // with the card held 3px away so the skirt and the card are separable, it // painted 0 device rows of the 3 it needs at 844x390 and 926x428, and 0 to 2 // in the sticky modes. Recorded here so it is not proposed again. // // What the overlap costs, stated rather than discovered later: where the bar // is sticky it is invisible, and a device-pixel diff of the seam band at // 390x844 and DPR 3 says so exactly - zero differing pixels, because the bar // is opaque and paints over the card. Below the short-viewport breakpoint the // bar has no background of its own, so the card's white also reaches the // bottom pixel of the *inactive* tabs and the 5px gaps between them: 4374 // device pixels at 844x390, gradient turning white. The tab row ends up // sitting flush on the card rather than floating a pixel above it, which is // how tabs normally meet their panel and is the same continuity the active // tab was asked for. // // Scoped to the diff page deliberately. diff.css is NOT diff-page-only - it // is also served to the preview page (blueprint/ui/preview.py) and the // extract page (processors/extract.py), and both have a #diff-ui of their own // with no bar above it to absorb the pixel. Only difference.py sets the // difference-page class. #diff-ui { margin-top: -1px; } } #diff-header { position: sticky; // Sits directly under the now-sticky top menu. The 0 fallback (JS off) just // slides this under the bar - the same degradation the minimap already takes. top: var(--app-header-height, 0px); z-index: 20; // section.content insets its children by $common-gap so they clear the left // rail, while the top menu above stays flush against it. A bar that paints // the page's own backdrop therefore leaves an 8.8px strip of real page // background down each side, and on a device those strips read as mismatched // borders. Cancel the inset rather than try to blend across it: with no // gutter there is nothing left to keep aligned, and the flush right edge is // what page-surface-gradient anchors its copy of the page gradient to. The // controls do not move - the bar grows outwards by exactly the margin it // takes back. // // The width has to come from align-self, not width: 100%. .content-main // centres its children, so an item is fit-content unless it stretches, and a // percentage width resolves against the parent without the negative margins - // it would leave the bar short by 17.6px. align-self: stretch; margin-left: -$common-gap; margin-right: -$common-gap; box-sizing: border-box; // Center children at their intrinsic width, the same way section.content does // for the non-sticky siblings it still lays out (align-items: center). // Two rows: the toolbar and the tabs. display: flex; flex-direction: column; align-items: center; gap: 0.25rem; // No bottom padding: the tab row is the bar's last child, and the active tab // and #diff-ui below it are both var(--color-background). 4px of gradient // between them reads as a seam across an otherwise continuous white surface - // the same tabs on the extract page, which are not in a bar, measure 0. The // top padding stays; nothing sits above the toolbar. padding: 0.25rem 1rem 0; // Keep the diff readable even with wrapped controls, zoom, or a short viewport. // The budget is for the whole sticky stack, so the top menu above comes out of // it - otherwise the two bars together take 64% of a 390px-tall landscape phone. // // svh, not dvh. dvh tracks the dynamic viewport, which on iOS Safari grows and // shrinks as the browser's own toolbar collapses and expands while you scroll: // the cap would then breathe mid-scroll, changing the header's height and // shifting everything below it, and each transition also fires resize and // re-runs the sticky measurement behind it. svh is defined as the // toolbar-expanded minimum and holds still for the life of the page, so the // budget is a constant. It is the smaller of the two, which is the safe side to // be wrong on for a cap. The plain vh line stays as the fallback for engines // without the new units. max-height: calc(50vh - var(--app-header-height, 0px)); max-height: calc(50svh - var(--app-header-height, 0px)); overflow: auto; // Same offset as `top` above: the bar's constant distance from the top of the // viewport. With JS off the var is absent and the copy is misaligned by the // top menu's height - the same tier of degradation `top` itself already takes. @include page-surface-gradient(var(--app-header-height, 0px)); > * { // A centered flex item is sized to fit-content, which has a min-content // floor (the version s enough intrinsic width // that From and To each take their own row, and From + To + Filters + tabs is // then taller than 50svh minus the top menu. With overflow: auto on the bar // itself the row that leaves the visible box is the last one - the tabs, which // are the page's navigation and have no scrollbar to advertise themselves. Measured in Chromium with no iOS emulation at 736x414, 667x375, // 780x360 and 844x330: the tab row sits outside #diff-header's own rect. // // So spend the budget on the row that can afford it. #settings is the only // child allowed to shrink, and it is what scrolls; the tab row never does and // is therefore always inside the bar at any viewport height. The shrink needs // min-height: 0 - a flex item's automatic minimum size is its content, which // is what would otherwise push the tabs back out. #settings { min-width: 0; display: flex; justify-content: center; flex: 0 1 auto; min-height: 0; overflow-y: auto; // Same reason the Filters popover carries it: a nested scroller inside a // sticky bar that chains its overscroll to the diff behind is worse than no // scroller at all on a touch screen. overscroll-behavior: contain; } // One toolbar row: From/To chips and their selects, the version arrows, and // the Filters toggle. #diff-form { display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 0.25rem 0.9rem; padding: 0.2rem 0.7rem; margin-bottom: 0; fieldset { margin: 0; padding: 0; border: 0; } // The From/To labels shrink to chips rather than hiding: display:none takes // the