Compare commits

...
2 Commits
Author SHA1 Message Date
ducoterra 2174caff33 fix button widths on mobile
Build and Push Containers / build-and-push-app (push) Successful in 1m39s
Build and Push Containers / build-and-push-db (push) Successful in 12s
2026-09-07 14:18:45 -04:00
ducoterra f664aafeed fix(ui): give the mobile refresh controls labels and full width
At <=640px the RAG "Sync sources" pill and the History "Refresh"
pill squeezed down to tiny icon-only buttons — hard to discover and
tap on a phone. They are now full-width labeled pills:

- the RAG page-head row wraps so the Sync pill drops below the
  "Knowledge base" title at full width; the History page-head
  already wrapped the pill below its title block
- the Sync label's min(16rem, 40vw) cap lifts on mobile (min-width: 0
  engages the ellipsis) so the live-file text truncates against the
  full width instead of the 40vw cap
- the Refresh glyph joins its visible label (it stays hidden on
  desktop, where the label carries the pill)

This matches the established mobile full-width pill language (New
chat / Share / stale-ban Regenerate). The three unit tests that
pinned the old icon-only CSS are updated to pin the new behavior.
2026-09-07 14:16:41 -04:00
5 changed files with 92 additions and 27 deletions
+43 -13
View File
@@ -2259,8 +2259,8 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.history-refresh:hover { background: #f55a72; color: var(--bg); }
.history-refresh:disabled { opacity: 0.6; cursor: wait; }
/* The refresh glyph is hidden on desktop (the label carries the
pill); it is the whole control below 640px (the <=640 block
mirrors the phase-46 auth-link icon-only convention). */
pill); below 640px it joins the visible label in the full-width
pill (the <=640 block — the icon-only squeeze is gone). */
.history-refresh svg { width: 16px; height: 16px; display: none; }
/* ---------- History page (phase 50) ----------
@@ -3413,6 +3413,33 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
@media (max-width: 640px) {
:root { --header-h: 58px; }
.container { padding-inline: 0.9rem; }
/* Owner report 2026-09-08: the hamburger "won't extend" on a real
phone while the same width in a desktop browser works. The page
opts into `viewport-fit=cover` (the <meta>), so on notched phones
the layout viewport runs UNDER the status bar / Dynamic Island —
and this bar (58px, toggle tap zone y≈7–51px) sits entirely in the
system top-edge zone: visually under the battery/signal icons, and
in the region where a real device does not reliably deliver taps
to the page (desktop emulation has no system top zone, so it works
there — the device-only failure). Pad the bar DOWN by the top
safe-area inset: the header surface still fills the notch region
(that is the point of viewport-fit=cover) but every control — the
#nav-toggle first — lands in the reliably tappable area, and the
dropdown's top:100% follows the taller bar automatically. The
.app-header rule doubles as the document viewer's row-1 bar (the
same class), so the viewer's hamburger is covered by the same
rule. env() resolves to 0 on every non-notch surface (desktop,
headless, no-island phones) — byte-identical rendering there. */
.app-header {
height: calc(var(--header-h) + env(safe-area-inset-top, 0px));
padding-top: env(safe-area-inset-top, 0px);
}
/* The share toast rests just under the bar — the bar is taller by
the same inset on notched phones, so the offset must carry it too
(the toast would otherwise tuck under the padded bar). */
.toast {
top: calc(var(--header-h) + 0.75rem + env(safe-area-inset-top, 0px));
}
/* Phase 14: the New chat pill joins the header — tighten the bar so
brand + nav + pill fit at 360px without horizontal overflow (the
brand text may ellipsize as the designated squeeze target). */
@@ -3663,17 +3690,20 @@ details.thinking .thinking-text ul { margin: 0 0 0.5rem; }
.shared-note { font-size: 0.88rem; }
.footer-inner { flex-direction: column; gap: 0.2rem; text-align: center; }
main { padding-bottom: env(safe-area-inset-bottom, 0); }
/* Sync button goes icon-only on mobile; the label hides, aria-label
keeps the accessible name. The spinning icon is the visible running
state on a touch screen. */
.sync-btn { padding: 0.4rem 0.3rem; }
.sync-label { display: none; }
/* Phase 77 task 03: the History refresh pill goes icon-only like
the phase-46 auth pill (the aria-label keeps the accessible
name); the page-head row above wraps the pill below the title
block when the width runs out. */
.history-refresh { padding: 0.4rem 0.3rem; }
.history-refresh-label { display: none; }
/* Sync + History refresh: full-width LABELED pills on mobile (the
icon-only squeeze is gone — a symbol-only control is too small a
target to discover on a phone). The RAG page-head row wraps so
the Sync pill drops below the "Knowledge base" title at full
width; the History page-head already wraps the pill below its
title block. The Sync label's min(16rem, 40vw) cap lifts so the
live-file text ellipsizes against the FULL width (min-width: 0
lets the nowrap flex item shrink, which is what engages the
ellipsis); the refresh glyph joins its visible label. The
aria-labels keep the accessible names. */
.page-head-row { flex-wrap: wrap; }
.sync-btn { width: 100%; }
.sync-label { max-width: none; min-width: 0; }
.history-refresh { width: 100%; }
.history-refresh svg { display: block; }
.sync-result {
position: absolute !important;
+4 -3
View File
@@ -691,9 +691,10 @@
page-head — OUTSIDE the table wrap — so it stays
reachable while the empty state is showing. The house
inline-SVG refresh glyph (aria-hidden) + the visible
"Refresh" label (the phase-46 auth-link convention:
label visible >=640px, icon-only below; the aria-label
keeps the accessible name in both). history.js binds it
"Refresh" label (visible at every width — below 640px
the pill goes full width and the glyph joins the
label; the aria-label keeps the accessible name in
both). history.js binds it
in the admin branch (the view is admin-gated; anonymous
sees the gate instead) and announces the outcome in
#history-status. -->
+6 -6
View File
@@ -721,9 +721,9 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
hidden on desktop (the label carries the pill) — and the global
:focus-visible ring applies (no button-scoped focus override).
The page-head flex row is SCOPED to #view-history (the other four
views' page-heads are untouched). Below 640px the pill goes
icon-only (the phase-46 auth-link convention — the aria-label
keeps the accessible name)."""
views' page-heads are untouched). Below 640px the pill goes full
width with the label visible (the icon-only squeeze is gone —
the glyph joins the label)."""
css = _asset("styles.css")
block = re.search(r"\.history-refresh \{([\s\S]*?)\n\}", css)
assert block, "styles.css must style .history-refresh"
@@ -746,11 +746,11 @@ def test_history_refresh_button_css_reuses_the_new_chat_language() -> None:
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}\n", css)
assert mobile, "the 640px media query must exist"
mbody = mobile.group(1)
assert ".history-refresh-label { display: none; }" in mbody, (
"icon-only below 640px (the phase-46 convention)"
assert ".history-refresh { width: 100%; }" in mbody, (
"full width below 640px (the icon-only squeeze is gone)"
)
assert ".history-refresh svg { display: block; }" in mbody, (
"the glyph is the whole control below 640px"
"the glyph joins the visible label below 640px"
)
+10 -4
View File
@@ -380,8 +380,10 @@ def test_sync_label_css_ellipsis_truncation() -> None:
""".sync-label: the live-file label ellipsizes a long
source/relative/path inside the pill (A4) — inline-block with the
min(16rem, 40vw) cap, overflow hidden, text-overflow ellipsis, no
wrap, baseline-aligned; the mobile squeeze's display:none override
(icon-only button) stays."""
wrap, baseline-aligned; on mobile the pill goes full width with
the label visible (the cap lifts, and min-width: 0 engages the
ellipsis against the full width — the icon-only squeeze is
gone)."""
css = _css()
block = re.search(r"\.sync-label\s*\{([^}]*)\}", css)
assert block, "styles.css must style .sync-label"
@@ -397,8 +399,12 @@ def test_sync_label_css_ellipsis_truncation() -> None:
assert prop in body, f".sync-label must carry {prop!r}"
mobile = re.search(r"@media \(max-width: 640px\) \{([\s\S]*?)\n\}", css)
assert mobile, "the ≤640px media query must stay"
assert ".sync-label { display: none; }" in mobile.group(1), (
"the mobile icon-only override must survive the ellipsis rule"
mbody = mobile.group(1)
assert ".sync-btn { width: 100%; }" in mbody, (
"the mobile Sync pill is full width (the icon-only squeeze is gone)"
)
assert ".sync-label { max-width: none; min-width: 0; }" in mbody, (
"the label stays visible and ellipsizes against the full width"
)
+29 -1
View File
@@ -251,6 +251,35 @@ def test_mobile_block_renders_the_44px_toggle() -> None:
)
def test_mobile_block_pads_the_bar_for_the_top_safe_area() -> None:
"""Owner report 2026-09-08: on a real notched phone (the page opts
into `viewport-fit=cover`) the whole 58px bar — including the
#nav-toggle tap zone (y≈7–51px) — sat under the status bar /
Dynamic Island system top-edge zone, where a device does not
reliably deliver taps to the page (desktop emulation has no such
zone, so the menu only "broke" on the phone). The ≤640px block
must pad the bar down by the top safe-area inset (explicit 0
fallback — the house `env(..., 0)` pattern, so non-notch surfaces
render byte-identical) and grow the height by the same amount
(border-box: the control row keeps its 58px). The share toast's
under-bar offset must carry the same inset."""
mobile = _media_block(_css(), "@media (max-width: 640px)")
header = _rule_block(mobile, ".app-header")
assert "padding-top: env(safe-area-inset-top, 0px)" in header, (
"the mobile bar must pad down by the top safe-area inset "
"(the viewport-fit=cover contract)"
)
assert "height: calc(var(--header-h) + env(safe-area-inset-top, 0px))" in header, (
"the bar height must grow by the same inset (border-box keeps "
"the control row at --header-h)"
)
toast = _rule_block(mobile, ".toast")
assert (
"top: calc(var(--header-h) + 0.75rem + env(safe-area-inset-top, 0px))"
in toast
), "the toast's under-bar offset must carry the same inset"
def test_mobile_block_turns_the_nav_into_the_dropdown() -> None:
"""The closed (default) mobile nav is the invisible, non-interactive
dropdown panel: absolute edge-to-edge under the sticky .app-header
@@ -326,7 +355,6 @@ def test_mobile_menu_rows_are_comfortable_targets() -> None:
for untouched in (
".new-chat-label { display: none; }",
".auth-label { display: none; }",
".sync-label { display: none; }",
":root { --header-h: 58px; }",
):
assert untouched in mobile, f"the ≤640px block lost {untouched!r}"