#!/usr/bin/env python3 """ Does the header stay put? Files, Videos, Music and Photos pin three bands under the navigation bar — the tab bar (the search field, on the Search page), the application's toolbar, and the file table's column heads — so that scrolling a library never costs the controls that steer it. Two of those offsets are `calc()` over a height measured at runtime (sticky.js), which means the arrangement can be wrong in ways no stylesheet reading finds: a band an inch too low leaves a stripe of list showing through above it, a band too high hides the one over it, and a toolbar that wraps to three rows on a phone moves the column heads by 96px that nothing in the CSS knows about. So this scrolls. It renders the shipped `GroupPage` and `SearchPage` against a stub node, walks to each application, scrolls the window down, and reports the rectangle of every band before and after — at a desktop width and at a phone width, where the toolbars wrap and the measurement earns its keep. sticky_header_probe.py Prints JSON: one object per case, each with `before` and `after` maps of selector -> {top, bottom, height} in viewport coordinates, plus what the window did. Chrome by default; `--engine firefox` runs the same cases in Firefox, which is half of MeshBay's readers and has its own history with `position: sticky`. """ import argparse import http.server import json import os import socketserver import subprocess import sys import tempfile import threading import time from pathlib import Path STATIC = Path(__file__).resolve().parents[2] / "src" / "meshbay_hub" / "static" PORT = 8751 RECORDS = [] socketserver.TCPServer.allow_reuse_address = True # Set when every case has reported. Firefox needs it: it has no # "navigate and stay open" headless mode that runs a page to completion, only # `--screenshot`, which fires at the load event and exits. So each frame holds # one image open, the load event waits for that image, and the image is # answered here once the run is done. Chrome does not need it and does not get # it. FINISHED = threading.Event() # 1100 desktop; 420 is where the phone media query has been tuned; 390 is an # actual handset, and the width the first report of a band not pinning at all # came from; 360 is the small end of what is still sold, and it is where the # settings table's two switches and its two buttons have the least room. WIDTHS = [1100, 420, 390, 360] # The tab bar's buttons are the enabled apps in registry order, so Files is 1, # Videos 2, Music 3, Photos 4 (Chat is 0, Settings last). The Search page's # view toggle is files/videos/music/photos in that order. GROUP_CASES = [ ("group files", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(2)"], "ready": ".file-toolbar", "bands": [".group-tabs", ".file-toolbar", ".file-table th"], "content": ".file-row"}), # Inside a root, where the rows are the library's own folders. The report # that started this came from there, not from the top level. ("group files in a folder", {"page": "group", # The second row, which is the music root: # the first is `films`, whose folders are # short and prove nothing. "click": [".group-tabs .group-tab:nth-of-type(2)", ".file-table tbody tr:nth-of-type(2)"], "ready": ".file-toolbar", "bands": [".group-tabs", ".file-toolbar", ".file-table th"], "content": ".file-row"}), ("group videos", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(3)"], "ready": ".video-toolbar", "bands": [".group-tabs", ".video-toolbar"], "content": ".video-tile-slot"}), ("group music", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(4)"], "ready": ".video-toolbar", "bands": [".group-tabs", ".video-toolbar"], "content": ".music-tile-slot"}), ("group photos", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(5)"], "ready": ".photo-toolbar", "bands": [".group-tabs", ".photo-toolbar"], "content": ".photo-album-tile-slot"}), # An open album swaps the toolbar for its own title bar, which pins in the # same place — the one band that is not a toolbar. # The group's own Settings, as its operator sees it: the shared-directories # table is four columns of controls, none of which can be squeezed, and it # was the next thing to hang off the right of a phone. ("group settings", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(6)"], "ready": ".shared-dirs-tbl", "bands": [".group-tabs"], "content": ".shared-dirs-tbl tbody tr"}), ("group photo album", {"page": "group", "click": [".group-tabs .group-tab:nth-of-type(5)", ".photo-album-card"], "ready": ".photo-album-bar", "bands": [".group-tabs", ".photo-album-bar"], "content": ".photo-tile-slot"}), # Search's Files view opens on the list of groups, one folder per group — # the rows are inside it. ("search files", {"page": "search", "click": [".view-toggle button:nth-of-type(1)", ".file-row.dir-row"], "ready": ".file-toolbar", "bands": [".search-bar", ".file-toolbar", ".file-table th"], "content": ".file-row"}), ("search videos", {"page": "search", "click": [".view-toggle button:nth-of-type(2)"], "ready": ".video-toolbar", "bands": [".search-bar", ".video-toolbar"], "content": ".video-tile-slot"}), ("search music", {"page": "search", "click": [".view-toggle button:nth-of-type(3)"], "ready": ".video-toolbar", "bands": [".search-bar", ".video-toolbar"], "content": ".music-tile-slot"}), # Photos on the Search page has no toolbar of its own: its only control is # the filter, and the search field above it already is one. ("search photos", {"page": "search", "click": [".view-toggle button:nth-of-type(4)"], "ready": ".photo-album-grid", "bands": [".search-bar"], "content": ".photo-album-tile-slot"}), ] CASES = [(f"{name} @{w}", dict(spec, width=w, label=name)) for w in WIDTHS for name, spec in GROUP_CASES] # The shell around the page under test: the real navigation bar (which is what # every band pins beneath), the real sidebar, the real main column. Measuring a # page mounted on a bare body would put every band at the top of the window and # prove nothing about the offset. SHELL = """