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>
This commit is contained in:
Engineer
2026-09-10 20:58:20 -04:00
co-authored by Architect Jeff Hedlund
parent 43b07d5b5a
commit 297bd36525
5 changed files with 317 additions and 8 deletions
@@ -21,7 +21,9 @@
<div id="diff-header"> <div id="diff-header">
<h1 id="diff-watch-title">{{ watch_a.label }}</h1> {# The title is one centred line and ellipsizes only when it is wider than the
whole bar, so keep the full text reachable on hover. #}
<h1 id="diff-watch-title" title="{{ watch_a.label }}">{{ watch_a.label }}</h1>
<div id="settings"> <div id="settings">
<form class="pure-form " action="{{ url_for("ui.ui_diff.diff_history_page", uuid=uuid) }}" method="GET" id="diff-form"> <form class="pure-form " action="{{ url_for("ui.ui_diff.diff_history_page", uuid=uuid) }}" method="GET" id="diff-form">
<fieldset class="diff-fieldset"> <fieldset class="diff-fieldset">
@@ -51,6 +53,10 @@
{#<button type="submit" class="pure-button pure-button-primary reset-margin">Go</button>#} {#<button type="submit" class="pure-button pure-button-primary reset-margin">Go</button>#}
{% endif %} {% endif %}
</fieldset> </fieldset>
{# Collapses the diff options below into a popover. Hidden until
diff-overview.js takes over the fieldset, so with scripting off the
options stay inline and reachable. #}
<button type="button" id="diff-filters-toggle" class="pure-button" aria-expanded="false" aria-controls="diff-style">{{ _('Filters') }} &#x25be;</button>
<fieldset id="diff-style"> <fieldset id="diff-style">
<span> <span>
<label for="diffWords" class="pure-checkbox"> <label for="diffWords" class="pure-checkbox">
@@ -89,9 +95,12 @@
</fieldset> </fieldset>
{%- if versions|length >= 2 -%} {%- if versions|length >= 2 -%}
<div id="keyboard-nav"> <div id="keyboard-nav">
{# In the sticky bar the prompt and the two words are hidden and
only the arrows show, so each link carries the full wording as
its accessible name and its tooltip. #}
<strong>{{ _('Keyboard:') }} </strong> <strong>{{ _('Keyboard:') }} </strong>
<a href="" class="pure-button pure-button-primary" id="btn-previous"> &larr; {{ _('Previous') }}</a> <a href="" class="pure-button pure-button-primary" id="btn-previous" title="{{ _('Previous version') }}" aria-label="{{ _('Previous version') }}"> &larr; <span class="keyboard-nav-label">{{ _('Previous') }}</span></a>
&nbsp; <a class="pure-button pure-button-primary" id="btn-next" href=""> &rarr; {{ _('Next') }}</a> <a class="pure-button pure-button-primary" id="btn-next" href="" title="{{ _('Next version') }}" aria-label="{{ _('Next version') }}"> &rarr; <span class="keyboard-nav-label">{{ _('Next') }}</span></a>
</div> </div>
{%- endif -%} {%- endif -%}
</form> </form>
@@ -71,6 +71,86 @@ function setupDiffNavigation() {
}, false); }, false);
} }
// The seven diff options collapse behind the 'Filters' button so the sticky bar
// stays one toolbar row. Wired up here rather than purely in CSS: until this
// runs the fieldset is inline and the toggle hidden, so with scripting off the
// options are still reachable and still submit with the form.
function setupDiffFilters() {
var header = document.getElementById('diff-header');
var toggle = document.getElementById('diff-filters-toggle');
var panel = document.getElementById('diff-style');
if (!header || !toggle || !panel) {
return;
}
header.classList.add('diff-filters-js');
function isOpen() {
return header.classList.contains('diff-filters-open');
}
// The panel is position: fixed - #diff-header's overflow: auto would clip an
// absolutely positioned one - so it has to be parked under the button by
// hand, and kept inside the viewport on a narrow screen.
function place() {
var button = toggle.getBoundingClientRect();
var available = document.documentElement.clientWidth - panel.offsetWidth - 8;
panel.style.top = (button.bottom + 4) + 'px';
panel.style.left = Math.max(8, Math.min(button.left, available)) + 'px';
}
function open() {
header.classList.add('diff-filters-open');
toggle.setAttribute('aria-expanded', 'true');
place();
}
function close(restoreFocus) {
if (!isOpen()) {
return;
}
header.classList.remove('diff-filters-open');
toggle.setAttribute('aria-expanded', 'false');
if (restoreFocus) {
toggle.focus();
}
}
toggle.addEventListener('click', function (event) {
event.preventDefault();
if (isOpen()) {
close(false);
} else {
open();
}
});
document.addEventListener('click', function (event) {
if (isOpen() && !panel.contains(event.target) && !toggle.contains(event.target)) {
close(false);
}
});
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
close(true);
}
});
// The bar is sticky directly under the top menu, so the button keeps its
// viewport position as the page scrolls and only a resize can move it out
// from under the panel. A tab switch hides #settings, and the panel with it,
// so drop the open state rather than leave aria-expanded lying.
window.addEventListener('resize', function () {
if (isOpen()) {
place();
}
});
window.addEventListener('hashchange', function () {
close(false);
});
}
$(document).ready(function () { $(document).ready(function () {
$('.needs-localtime').each(function () { $('.needs-localtime').each(function () {
for (var option of this.options) { for (var option of this.options) {
@@ -87,6 +167,8 @@ $(document).ready(function () {
setupDiffNavigation(); setupDiffNavigation();
} }
setupDiffFilters();
// Load it when the #screenshot tab is in use, so we dont give a slow experience when waiting for the text diff to load // Load it when the #screenshot tab is in use, so we dont give a slow experience when waiting for the text diff to load
window.addEventListener('hashchange', function (e) { window.addEventListener('hashchange', function (e) {
toggle(location.hash); toggle(location.hash);
File diff suppressed because one or more lines are too long
+174 -4
View File
@@ -92,6 +92,13 @@ body.difference-page {
#pure-menu-horizontal-spinner { #pure-menu-horizontal-spinner {
z-index: 29; z-index: 29;
} }
// The bar is the page's whole chrome now, so trim the tabs' vertical padding
// (0.7em on all four sides) to half. Saves ~11px of every scroll position and
// leaves the horizontal padding, and so the tab widths, as they are.
.tabs ul li a {
padding: 0.35em 0.7em;
}
} }
#diff-header { #diff-header {
@@ -104,9 +111,12 @@ body.difference-page {
box-sizing: border-box; box-sizing: border-box;
// Center children at their intrinsic width, the same way section.content does // Center children at their intrinsic width, the same way section.content does
// for the non-sticky siblings it still lays out (align-items: center). // for the non-sticky siblings it still lays out (align-items: center).
// Three rows: title, the toolbar, the tabs.
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
gap: 0.25rem;
padding: 0.25rem 1rem;
// Keep the diff readable even with wrapped controls, zoom, or a short viewport. // 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 // 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. // it - otherwise the two bars together take 64% of a 390px-tall landscape phone.
@@ -124,17 +134,123 @@ body.difference-page {
max-width: 100%; max-width: 100%;
} }
// The title owns the full width of the bar on its own row above the controls,
// so it truncates only when it is wider than the whole viewport - the common
// long title fits in full. One line rather than a two-line clamp, matching the
// watch URL's existing truncation and keeping the bar's height constant. The
// template carries the untruncated text in the title attribute for hover.
#diff-watch-title { #diff-watch-title {
width: 100%;
margin: 0; margin: 0;
padding: 0.25rem 1rem; padding: 0;
font-size: 1.25rem; font-size: 0.9rem;
overflow-wrap: anywhere; font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
text-align: center; text-align: center;
color: #fff; // match #diff-form's white-on-gradient text color: #fff; // match #diff-form's white-on-gradient text
} }
#settings {
min-width: 0;
display: flex;
justify-content: center;
}
// One toolbar row: From/To chips and their selects, the version arrows, and
// the Filters toggle.
#diff-form { #diff-form {
margin-bottom: 0.5rem; 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 <label> out of the accessibility tree and the selects lose their
// accessible name entirely, and two identically formatted datetime selects
// side by side need the red/green cue sighted users already know. The
// shipped width: 4rem is what makes them full-size plates. "From" is wider
// than "To", which left-ragged the selects once they stack, so the floor is
// wide enough for the longer word - it costs width only, never height.
.from-to-label {
width: auto;
min-width: 2.4rem;
text-align: center;
font-size: 0.75rem;
padding: 0.1rem 0.35rem;
border-radius: 3px;
line-height: 1.5;
}
}
#keyboard-nav {
display: flex;
align-items: center;
gap: 0.3rem;
// The arrows speak for themselves at this size; the links keep the full
// wording as aria-label and title (see diff.html).
strong,
.keyboard-nav-label {
display: none;
}
}
// Seven set-once options are a popover, not a permanent row of the bar. The
// swap happens only once diff-overview.js has wired the toggle up, so with
// scripting off the fieldset stays inline and submits as it does today.
#diff-filters-toggle {
display: none;
font-size: 0.85rem;
padding: 0.3em 0.8em;
}
&.diff-filters-js {
#diff-filters-toggle {
display: inline-block;
}
#diff-style {
display: none;
}
}
&.diff-filters-js.diff-filters-open {
#diff-style {
// fixed, not absolute: #diff-header carries overflow: auto to enforce the
// 50dvh cap above, which clips an absolutely positioned descendant at the
// bar's bottom edge. The bar is sticky with no transform/filter/contain,
// so it is not a containing block for fixed and the panel escapes the
// clip. diff-overview.js parks it under the toggle.
display: block;
position: fixed;
z-index: 25;
min-width: 14rem;
padding: 0.5rem 0.75rem;
border-radius: 8px;
box-shadow: 0 4px 14px rgba(0, 0, 0, .28);
text-align: left;
// The bar repaints the page's own backdrop; so must anything that has to
// be opaque over the scrolling diff.
@include page-surface-gradient;
}
// Out-specifies #diff-form #diff-style > span's inline-block above.
#diff-form #diff-style > span {
display: block;
padding: 0.2em 0;
}
} }
.diff-fieldset { .diff-fieldset {
@@ -147,6 +263,7 @@ body.difference-page {
> span { > span {
max-width: 100%; max-width: 100%;
min-width: 0; min-width: 0;
gap: 0.25rem;
// Size to content so From/To stay side-by-side on desktop (as they were // Size to content so From/To stay side-by-side on desktop (as they were
// before the header existed); shrink and wrap only when the row can't fit. // before the header existed); shrink and wrap only when the row can't fit.
flex: 0 1 auto; flex: 0 1 auto;
@@ -171,6 +288,59 @@ body.difference-page {
} }
} }
// The one narrow-viewport breakpoint for the whole bar - title, fit and
// gutters all hang off it rather than letting magic numbers multiply.
//
// Every selector below carries an ID on purpose. The rules it has to beat are
// written above as `#diff-header .diff-fieldset > span` and friends, and a
// single ID outranks any number of classes, so a class-only override loses
// silently and the chain goes on shrink-wrapping. Please don't tidy the IDs
// away.
@media (max-width: 700px) {
// Once the From/To 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. Shrinking the font alone
// cannot fix that: #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 and the headroom stays pinned
// at zero at every font size. Pin the chain to the row first; only then does
// the smaller font buy anything.
#diff-header > #settings {
width: 100%;
}
#diff-header #diff-form {
width: 100%;
box-sizing: border-box;
// 16px of bar gutter plus 11.2px of form gutter a side is 54px of a 390px
// row spent on nothing.
padding-left: 0.2rem;
padding-right: 0.2rem;
}
#diff-header {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
#diff-header #diff-form > .diff-fieldset {
flex: 1 1 100%;
}
#diff-header .diff-fieldset > span {
flex: 1 1 100%;
}
// With the chain pinned the select holds its width, so this is what actually
// buys the room. The worst realistic English value - a two-digit day, the
// longest month and a two-digit hour - still clips at 0.8rem.
#diff-header .diff-fieldset select {
font-size: 0.75rem;
padding-left: 0.3em;
padding-right: 0.3em;
}
}
#diff-ui { #diff-ui {
background: var(--color-background); background: var(--color-background);
@@ -27,6 +27,54 @@ def test_diff_header_watch_label(client, title, page_title, expected):
assert page.select_one('#diff-header .tabs') is not None assert page.select_one('#diff-header .tabs') is not None
assert page.select_one('#diff-header #difference') is None assert page.select_one('#diff-header #difference') is None
assert 'Second release' in page.select_one('#difference').get_text() 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): def test_difference_page_class_scopes_sticky_header(client):