Files
changedetection.io/changedetectionio/tests/test_diff_header.py
T
297bd36525 Condense the history diff header into a compact toolbar
The sticky bar the previous commit introduced takes 297px of a 1000px
desktop viewport and 422px of a 390x844 phone - at that width its own
50dvh cap is already clipping it, so the shipped page scrolls a header
inside itself before the diff has moved at all. Compact it to three
rows and that becomes 154px and 228px, without hiding any control.

The watch title takes the full width of the bar on its own row above
the controls, centred, 0.9rem, one line with an ellipsis. It truncates
only when it is wider than the whole viewport - a 184-character title
shows in full at 1440px and ellipsizes at 768 and 390 - and the bar
keeps exactly the same height either way, which a two-line clamp would
not. The full text goes in the title attribute so hover reveals what
the ellipsis hides, the same bargain the watch URL above it already
makes.

The From/To labels shrink to chips rather than hiding. display: none
takes a <label> out of the accessibility tree: verified through
Chromium's accessibility tree, the selects report "From" and "To" with
the chips and "" without them. Sighted users need them too, since two
identically formatted datetime selects side by side have only their
order to tell them apart, and on a phone they stack so even that stops
helping. The chips cost horizontal space and no height at all. Dropping
the shipped width: 4rem sizes them to their text, and "From" is wider
than "To", so a min-width floor keeps the stacked selects flush.

The seven diff options move behind a Filters button. The fieldset stays
inline, inside the form, until diff-overview.js swaps it for the
popover, so with scripting off the options are exactly as reachable as
they are today. The panel is position: fixed, not absolute: #diff-header
carries overflow: auto to enforce its 50dvh cap, which clips an
absolutely positioned descendant at the bar's bottom edge. The bar is
sticky with no transform, filter or contain, so it is not a containing
block for fixed and the panel escapes the clip; the script parks it
under the button and keeps it inside the viewport, with aria-expanded,
Escape, outside-click and a reposition on resize.

The keyboard-nav prompt and the two button words are hidden, leaving
the arrows; both links carry the full wording as aria-label and title
so the accessible name survives the CSS. The tabs' vertical padding
halves on this page, 11px off every scroll position, leaving their
horizontal padding and so their widths alone.

Narrow viewports need one more thing, and font-size alone cannot give
it: once the chip takes inline width the select clips its own displayed
value, and two snapshots from the same day differ only by the meridiem,
so they read identically while closed. #settings, #diff-form,
.diff-fieldset and the span are all sized to max-content, which is the
select's text, so the container shrinks by exactly as much as the text
does - headroom measured 0.0px at every size from 0.9rem to 0.7rem. The
chain is pinned to the row first and only then does 0.75rem buy room:
+19.3px on the demo pair and +6.3px against the longest realistic
English value, which still clips at 0.8rem. All of it hangs off the one
700px breakpoint, and every selector in it carries an ID because the
rules it overrides are written with one and a class-only override would
lose silently.

Co-authored-by: Architect <architect@agents.matrixsi.com>
Co-authored-by: Jeff Hedlund <jhedlund@gmail.com>
Signed-off-by: Jeff Hedlund <jhedlund@gmail.com>
2026-09-10 20:58:20 -04:00

96 lines
4.4 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()
# The title is one line and ellipsizes when it outgrows the bar, so the
# untruncated text has to stay reachable on hover.
assert heading.get('title') == expected
def _seeded_diff_page(client, **extras):
datastore = client.application.config['DATASTORE']
extras.setdefault('paused', True)
uuid = datastore.add_watch(url='https://example.com/releases', extras=extras)
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
return BeautifulSoup(response.data, 'html.parser')
def test_diff_filters_toggle_degrades_without_javascript(client):
"""The diff options collapse into a popover, but only once diff-overview.js
has taken them over: the toggle ships hidden and the fieldset ships inline
and inside the form, so with scripting off the options are still reachable
and still submit."""
page = _seeded_diff_page(client)
toggle = page.select_one('#diff-form #diff-filters-toggle')
assert toggle is not None
# Inside a form, anything but type=button submits it.
assert toggle.get('type') == 'button'
assert toggle.get('aria-expanded') == 'false'
assert toggle.get('aria-controls') == 'diff-style'
options = page.select_one('#diff-form #diff-style')
assert options is not None
assert options.get('hidden') is None
assert page.select_one('#diff-form #diff-style #ignoreWhitespace') is not None
def test_diff_version_arrows_are_named(client):
"""Only the arrow glyphs are visible in the compact bar, so each link has to
carry the wording itself rather than lean on text the CSS hides."""
page = _seeded_diff_page(client)
for element_id in ('btn-previous', 'btn-next'):
link = page.select_one(f'#keyboard-nav #{element_id}')
assert link is not None
assert link.get('aria-label')
assert link.get('title') == link.get('aria-label')
assert link.select_one('.keyboard-nav-label') is not None
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')