Files
changedetection.io/changedetectionio/tests/test_diff_header.py
T
43b07d5b5a Keep history diff title and controls visible while scrolling
The watch label, version selectors, diff options and tabs move into a
sticky #diff-header, and the top menu above it (watch URL, EDIT, theme
and GitHub links) becomes sticky too, so the whole header stays on screen
while the diff scrolls. The minimap and anchor jumps offset by the
measured height of both bars instead of a fixed value. Heights are
measured on load and kept current with a ResizeObserver where it is
available, falling back to the window resize and hashchange events as the
restock graph and queue sparkline already do, plus one deferred measure at
load - the tab switch hides #settings and so resizes the header without a
window resize, and opening a link already at #screenshot does the same
with no hashchange to follow. The app header is stored before the diff
header is measured, since the latter's cap is derived from the former.

A sticky bar must be opaque to the content scrolling beneath it, but the
page's own backdrop is a fixed full-viewport gradient layer over the page
colour, so a flat --color-background panel reads as a white band cutting
across it. Instead each bar repaints that same backdrop via a shared
page-surface-gradient mixin: the three gradient stops pre-composited
against --color-background-page with color-mix() at the layer's 0.91
opacity, with background-attachment: fixed so they stay registered with
body::after. Children of #diff-header are centred with a flex column,
mirroring the align-items: center that section.content applies to the
non-sticky siblings, so the controls panel and tabs keep their intrinsic
width and standard colours.

The top menu is made sticky only under body.difference-page. Upstream
already wants this globally (see the @todo in parts/_top_menu.scss) but
held off because the bar has no background of its own; scoping it here
keeps that decision separate. Sticky makes .header a stacking context, so
its mobile drawer is capped at the bar's z-index of 30 - high enough to
cover #diff-header, low enough that the action rail's hover flyout and
toast notifications still paint over the bar as before. The activity
strip is fixed on body in the root stacking context, so it would have
painted through the capped drawer; on this page it drops just below the
bar rather than raising the bar past the rail.

Dropping the 40px section.content padding and tightening the title's own
padding closes the gap between the two bars, and #diff-header's
half-viewport cap now excludes the top menu so the sticky stack stays
within the same budget on short viewports. .app-main's 0.55rem gap goes
too on this page: it sits between the two sticky bars, so the diff header
would otherwise start 8.8px below the top menu and slide up to meet it
over the first 8.8px of scroll. That gap cannot survive sticking - it is
outside both bars, so the diff would scroll through it - and the bars are
flush at every scroll position instead.

Co-authored-by: Engineer <engineer@agents.matrixsi.com>
Co-authored-by: Jeff Hedlund <jhedlund@gmail.com>
Signed-off-by: Jeff Hedlund <jhedlund@gmail.com>
2026-09-09 15:59:39 -04:00

48 lines
2.3 KiB
Python

from flask import url_for
from bs4 import BeautifulSoup
import pytest
@pytest.mark.parametrize('title,page_title,expected', [
('Release notes <script>alert(1)</script>', 'Fetched title', 'Release notes <script>alert(1)</script>'),
('', 'Fetched title', 'Fetched title'),
('', '', 'https://example.com/releases'),
])
def test_diff_header_watch_label(client, title, page_title, expected):
datastore = client.application.config['DATASTORE']
uuid = datastore.add_watch(url='https://example.com/releases', extras={
'title': title, 'page_title': page_title, 'paused': True,
})
watch = datastore.data['watching'][uuid]
watch.save_history_blob('First release', 1700000000, 'first')
watch.save_history_blob('Second release', 1700000060, 'second')
response = client.get(url_for('ui.ui_diff.diff_history_page', uuid=uuid))
assert response.status_code == 200
page = BeautifulSoup(response.data, 'html.parser')
heading = page.select_one('#diff-header #diff-watch-title')
assert heading.get_text() == expected
assert heading.find('script') is None
assert page.select_one('#diff-header #diff-form') is not None
assert page.select_one('#diff-header .tabs') is not None
assert page.select_one('#diff-header #difference') is None
assert 'Second release' in page.select_one('#difference').get_text()
def test_difference_page_class_scopes_sticky_header(client):
"""The sticky top menu is styled off body.difference-page, so only the diff
page may carry that class - the extract page shares the same blueprint."""
datastore = client.application.config['DATASTORE']
uuid = datastore.add_watch(url='https://example.com/releases', extras={'paused': True})
watch = datastore.data['watching'][uuid]
watch.save_history_blob('First release', 1700000000, 'first')
watch.save_history_blob('Second release', 1700000060, 'second')
diff = BeautifulSoup(client.get(url_for('ui.ui_diff.diff_history_page', uuid=uuid)).data,
'html.parser')
assert 'difference-page' in diff.select_one('body').get('class')
extract = client.get(url_for('ui.ui_diff.diff_history_page_extract_GET', uuid=uuid))
assert extract.status_code == 200
assert 'difference-page' not in BeautifulSoup(extract.data, 'html.parser').select_one('body').get('class')