mirror of
https://github.com/dgtlmoon/changedetection.io.git
synced 2026-09-18 03:16:38 +00:00
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>
96 lines
4.4 KiB
Python
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')
|