fix(ui): mobile pop-out for the advice, and tables that fit a phone #16

Merged
hermes merged 2 commits from fix/mobile-advice-popout into master 2026-11-09 20:58:06 +10:00
5 changed files with 489 additions and 22 deletions
+262 -17
View File
@@ -480,6 +480,138 @@ table.data tbody tr:hover { background: var(--bg-raised-2); }
table.data .num { font-family: var(--mono); text-align: right; white-space: nowrap; } table.data .num { font-family: var(--mono); text-align: right; white-space: nowrap; }
table.data .comment { color: var(--text-muted); font-size: .84rem; } table.data .comment { color: var(--text-muted); font-size: .84rem; }
/* On a narrow screen the tables reflow into stacked cards.
A seven-column table does not fit a phone. It was left to scroll
sideways inside its wrapper, which meant the columns on the right were
off the screen and the brewer had to pan the table to see them.
Each row becomes a card: the header cell becomes a label above its
value, and the cells stack. */
@media (max-width: 720px) {
/* The header wraps onto two rows on a phone: the brand and the
sign-out on the first, the nav tabs on the second where they get
the full width and scroll sideways if they still do not fit.
They used to sit on one row with the brand and the sign-out, and
the last tab was cut off mid-word. */
.topbar__inner {
flex-wrap: wrap;
row-gap: .5rem;
}
.brand {
order: 1;
}
.topbar__logout {
order: 2;
margin-left: auto;
}
.nav {
order: 3;
flex: 1 0 100%;
overflow-x: auto;
scrollbar-width: none;
-webkit-overflow-scrolling: touch;
/* A fade at the right edge so a clipped tab reads as
scrollable rather than broken. */
-webkit-mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent 100%);
mask-image: linear-gradient(to right, #000 calc(100% - 1.5rem), transparent 100%);
}
.nav::-webkit-scrollbar {
display: none;
}
.nav__link {
flex-shrink: 0;
}
/* Tighter tabs, so all four fit on a phone without scrolling.
They still scroll if they have to. */
.nav__link {
padding: .35rem .55rem;
font-size: .8rem;
letter-spacing: .01em;
}
.table-wrap {
overflow-x: visible;
}
table.data,
table.data tbody,
table.data tr,
table.data td {
display: block;
width: auto;
}
table.data thead {
/* HIDDEN in the stacked layout. Each cell shows its column
name as a label, so the header row would be a duplicate - and
it was 230px of the first card. */
display: none;
}
table.data tr {
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: .75rem;
padding: .25rem .1rem;
background: var(--bg-raised-2);
}
table.data tbody tr:hover {
background: var(--bg-raised-2);
}
table.data th {
display: block;
border-bottom: 1px solid var(--border);
}
table.data td {
display: grid;
grid-template-columns: 8.5rem 1fr;
gap: .5rem;
align-items: baseline;
padding: .45rem .7rem;
border-bottom: 1px solid rgba(255, 255, 255, .04);
text-align: left;
}
table.data td::before {
/* The column heading, shown as the row's label. */
content: attr(data-label);
font-size: .65rem;
font-weight: 650;
letter-spacing: .07em;
text-transform: uppercase;
color: var(--text-faint);
}
table.data td.num {
text-align: left;
}
table.data td.right,
table.data td:empty {
grid-template-columns: 1fr;
}
table.data td.right::before {
content: none;
}
/* A cell with nothing in it must not leave a gap. */
table.data td:empty {
display: none;
}
}
/* ----------------------------------------------------------------- chips */ /* ----------------------------------------------------------------- chips */
.chip { .chip {
@@ -595,8 +727,18 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
.brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; } .brew-card__name { font-weight: 650; font-size: 1.02rem; margin: 0; }
.brew-card__meta { color: var(--text-muted); font-size: .82rem; font-family: var(--mono); } .brew-card__meta { color: var(--text-muted); font-size: .82rem; font-family: var(--mono); }
.brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; } .brew-card__stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: .6rem; }
/* The three stats sit side by side, so their captions line up. One
caption ("needs 2nd reading") is two lines and the others are one,
which left the baselines ragged. */
.brew-card__stat {
display: flex;
flex-direction: column;
}
.brew-card__stat .stat__label { font-size: .68rem; } .brew-card__stat .stat__label { font-size: .68rem; }
.brew-card__stat .stat__value { font-size: 1.3rem; } .brew-card__stat .stat__value { font-size: 1.3rem; }
.brew-card__stat .stat__meta { margin-top: auto; }
.brew-card__spark { height: 46px; } .brew-card__spark { height: 46px; }
/* The "start a new beer" tile — fills the last grid slot and gives the /* The "start a new beer" tile — fills the last grid slot and gives the
@@ -733,27 +875,130 @@ table.data .comment { color: var(--text-muted); font-size: .84rem; }
color: var(--danger); color: var(--danger);
} }
/* The advice in the table is a compact trigger that pops the full text
out on hover, focus or tap.
It used to be the text itself, clamped to four lines. The advice runs
to several hundred characters, and a cell that wide pushes the table
past the width of a phone, so the column ended up clipped off the
right edge and unreadable without sideways scrolling. */
.advice-cell { .advice-cell {
max-width: 32rem; position: relative;
width: 1%;
white-space: nowrap;
} }
/* In the table the advice is clamped to a few lines - the full text is .advice-trigger {
on the run detail page, where there is room to read it. */ display: inline-flex;
.advice-cell .advice { align-items: center;
margin-top: 0; gap: .35rem;
padding: .25rem .55rem;
border: 1px solid var(--amber);
border-radius: 999px;
background: var(--amber-dim);
color: var(--amber-text);
font: inherit;
font-size: .75rem; font-size: .75rem;
display: -webkit-box; line-height: 1.4;
-webkit-line-clamp: 4; cursor: pointer;
line-clamp: 4; white-space: nowrap;
-webkit-box-orient: vertical;
overflow: hidden;
} }
/* The clamped text opens up on hover, so the whole thing is readable .advice-trigger:hover,
without leaving the page. */ .advice-trigger:focus-visible {
.advice-cell:hover .advice, background: var(--amber);
.advice-cell:focus-within .advice { color: var(--bg);
-webkit-line-clamp: unset; }
line-clamp: unset;
overflow: visible; .advice-trigger--failed {
border-color: var(--danger);
background: var(--danger-dim);
color: var(--danger);
}
.advice-trigger__icon {
font-size: .85em;
}
/* The pop-out.
It is `position: fixed` and anchored to the trigger by the script,
not absolutely positioned inside the cell. The table is a scroll
container, and an absolutely positioned panel inside it was clipped
by the container's own overflow - the advice was sliced in half by
the card's edge and the bottom border was cut off.
Fixed positioning takes it out of the table entirely, so it floats
above the page and is never cut by a scroll container. */
.advice-pop {
position: fixed;
z-index: 200;
display: none;
width: min(30rem, 92vw);
max-height: 70vh;
overflow-y: auto;
padding: .8rem .9rem;
border: 1px solid var(--amber);
border-radius: 8px;
background: var(--bg-raised);
box-shadow: 0 12px 32px rgba(0, 0, 0, .55);
text-align: left;
white-space: normal;
}
.advice-pop__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: .5rem;
margin-bottom: .4rem;
}
.advice-pop__head .advice__label {
margin-bottom: 0;
}
.advice-pop__close {
border: 0;
background: none;
color: var(--text-muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
padding: 0 .15rem;
}
.advice-pop__close:hover {
color: var(--text);
}
.advice-pop__text {
margin: 0;
font-size: .8125rem;
line-height: 1.55;
color: var(--text);
}
/* The script opens and closes the panel, and places it. The CSS only
decides whether it is shown. */
.advice-pop.is-open {
display: block;
}
/* On a phone there is no hover, so the trigger is a button the script
toggles, and the panel is pinned to the middle of the screen so it is
readable whatever the table's width. */
@media (max-width: 720px) {
.advice-cell {
width: auto;
}
/* On a phone the panel is centred on the screen rather than
anchored to the trigger - there is not the room to float it
beside the cell, and the trigger may be near the edge. */
.advice-pop {
top: 50% !important;
left: 50% !important;
transform: translate(-50%, -50%);
}
} }
+176
View File
@@ -56,11 +56,187 @@
} }
} }
/**
* The brewing advice pop-out.
*
* On a desktop the panel opens on hover, which the CSS does. A
* phone has no hover, so the trigger is a button that toggles it -
* and it is a real button, so it works with a keyboard and with a
* screen reader.
*/
/**
* The brewing advice pop-out.
*
* The advice runs to several hundred characters. In a table cell it
* pushed the table past the width of a phone, so it is a compact
* trigger instead and the text is shown in a floating panel.
*
* The panel is position:fixed so it escapes the table's scroll
* container - absolutely positioned inside the cell it was clipped
* by the container and sliced in half by the card's edge.
*
* It opens on hover on a desktop, and on tap on a phone, which has
* no hover. The trigger is a real button, so it also works with a
* keyboard.
*/
function wireAdvicePopouts() {
var triggers = document.querySelectorAll(".advice-trigger");
for (var i = 0; i < triggers.length; i++) {
(function (trigger) {
var panel = trigger.parentNode.querySelector(".advice-pop");
if (!panel) { return; }
var closeTimer = null;
function isPhone() {
return window.matchMedia("(max-width: 720px)").matches;
}
function place() {
if (isPhone()) {
/* The CSS centres it on a phone - clear any
inline position left over from a desktop
resize. */
panel.style.top = "";
panel.style.left = "";
return;
}
var box = trigger.getBoundingClientRect();
var gap = 10;
var height = panel.offsetHeight;
var width = panel.offsetWidth;
var spaceBelow = window.innerHeight - box.bottom - gap;
var spaceAbove = box.top - gap;
/* Prefer below; flip above when there is not enough
room and there is more room above. */
var top;
if (height <= spaceBelow || spaceBelow >= spaceAbove) {
top = box.bottom + gap;
} else {
top = Math.max(gap, box.top - height - gap);
}
/* Keep it on screen horizontally. */
var left = Math.min(box.left, window.innerWidth - width - gap);
left = Math.max(gap, left);
panel.style.top = Math.round(top) + "px";
panel.style.left = Math.round(left) + "px";
}
function open() {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
panel.classList.add("is-open");
trigger.setAttribute("aria-expanded", "true");
place();
}
function close() {
panel.classList.remove("is-open");
trigger.setAttribute("aria-expanded", "false");
}
function closeSoon() {
/* A short delay, so the mouse can travel from the
trigger into the panel without it closing. */
if (closeTimer) { clearTimeout(closeTimer); }
closeTimer = setTimeout(function () {
closeTimer = null;
if (!panel.matches(":hover") && !trigger.matches(":hover")) {
close();
}
}, 180);
}
trigger.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
if (panel.classList.contains("is-open")) {
close();
} else {
open();
}
});
trigger.addEventListener("mouseenter", function () {
if (!isPhone()) { open(); }
});
trigger.addEventListener("mouseleave", closeSoon);
panel.addEventListener("mouseenter", function () {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
});
panel.addEventListener("mouseleave", closeSoon);
var closeButton = panel.querySelector(".advice-pop__close");
if (closeButton) {
closeButton.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
close();
trigger.focus();
});
}
document.addEventListener("keydown", function (event) {
if (event.key === "Escape" && panel.classList.contains("is-open")) {
close();
trigger.focus();
}
});
document.addEventListener("click", function (event) {
if (!panel.contains(event.target) && event.target !== trigger) {
close();
}
});
window.addEventListener("resize", function () {
if (panel.classList.contains("is-open")) { place(); }
});
window.addEventListener("scroll", function () {
if (panel.classList.contains("is-open")) { close(); }
}, true);
})(triggers[i]);
}
}
function labelTableCells() {
var tables = document.querySelectorAll("table.data");
for (var t = 0; t < tables.length; t++) {
var headerRow = tables[t].querySelector("thead tr");
if (!headerRow) { continue; }
var labels = [];
var headers = headerRow.querySelectorAll("th");
for (var h = 0; h < headers.length; h++) {
labels.push(headers[h].textContent.trim());
}
var rows = tables[t].querySelectorAll("tbody tr");
for (var r = 0; r < rows.length; r++) {
var cells = rows[r].querySelectorAll("td");
for (var c = 0; c < cells.length && c < labels.length; c++) {
if (labels[c] && !cells[c].getAttribute("data-label")) {
cells[c].setAttribute("data-label", labels[c]);
}
}
}
}
}
function init() { function init() {
focusFirstEmpty(); focusFirstEmpty();
defaultDateFields(); defaultDateFields();
wireToggles(); wireToggles();
wireConfirms(); wireConfirms();
wireAdvicePopouts();
labelTableCells();
} }
if (document.readyState === "loading") { if (document.readyState === "loading") {
+1 -1
View File
@@ -44,7 +44,7 @@
<div class="brew-card__meta"> <div class="brew-card__meta">
Run {{ run.beer_run_id }} Run {{ run.beer_run_id }}
{% if run.first_date %} &middot; since {{ run.first_date }}{% endif %} {% if run.first_date %} &middot; since {{ run.first_date }}{% endif %}
{% if run.reading_count == 1 %} &middot; 1 reading{% else %} &middot; {{ run.reading_count }} readings{% endif %} {% if run.reading_count == 1 %} &middot; 1&nbsp;reading{% else %} &middot; {{ run.reading_count }}&nbsp;readings{% endif %}
</div> </div>
</div> </div>
{% if run.tap_number is not none %} {% if run.tap_number is not none %}
+18 -3
View File
@@ -95,7 +95,7 @@
<td class="num"> <td class="num">
{% if run.min_sg is not none %} {% if run.min_sg is not none %}
<span class="mono strong">{{ run.min_sg_decimal }}</span> <span class="mono strong">{{ run.min_sg_decimal }}</span>
<div class="faint tiny">{{ run.min_sg_point_scale }} pts</div> <div class="faint tiny">{{ run.min_sg_point_scale }} points</div>
{% else %}—{% endif %} {% else %}—{% endif %}
</td> </td>
<td class="num"> <td class="num">
@@ -112,9 +112,24 @@
<span class="faint">—</span> <span class="faint">—</span>
{% endif %} {% endif %}
</td> </td>
<td class="comment advice-cell"> <td class="advice-cell">
{% if run.advice %} {% if run.advice %}
<span class="advice{% if not run.advice_ok %} advice--failed{% endif %}">{{ run.advice }}</span> <button type="button"
class="advice-trigger{% if not run.advice_ok %} advice-trigger--failed{% endif %}"
aria-expanded="false">
<span class="advice-trigger__icon" aria-hidden="true">&#9998;</span>
<span class="advice-trigger__label">
{% if run.advice_ok %}Read the advice{% else %}Advice failed{% endif %}
</span>
</button>
<div class="advice-pop" role="tooltip">
<div class="advice-pop__head">
<span class="advice__label">Brewing advice</span>
<button type="button" class="advice-pop__close"
aria-label="Close">&times;</button>
</div>
<p class="advice-pop__text">{{ run.advice }}</p>
</div>
{% else %} {% else %}
<span class="faint">Thinking…</span> <span class="faint">Thinking…</span>
{% endif %} {% endif %}
+32 -1
View File
@@ -995,6 +995,35 @@ def test_the_advice_is_shown_on_the_active_beers_page(client, repo):
assert "Brewing advice" in body assert "Brewing advice" in body
def test_the_advice_in_the_table_is_a_popout_not_the_text(client, repo):
"""The advice in the table is a trigger, and the text is in a panel.
The advice is several hundred characters. In a table cell it made
the table wider than a phone, so the column was clipped off the
right edge and the advice could not be read.
"""
repo.users.insert_one({"username": "beer", "password": "beer_pass"})
client.post("/login", data={"username": "beer", "password": "beer_pass"},
follow_redirects=True)
seed_readings(repo, [("1", "Ale", "1042", "2026-01-01", False, "")])
doc = repo.readings.find_one({"beer_run_id": "1"})
repo.set_reading_advice(str(doc["_id"]), "Warm it to 21C and wait.", ok=True)
body = client.get("/brews").get_data(as_text=True)
# The trigger is a real button, so it works with a keyboard.
assert "advice-trigger" in body
assert 'class="advice-trigger' in body
# The text lives in the panel, not in the cell.
assert "advice-pop" in body
assert "advice-pop__text" in body
# The full advice is in the page - it is just not in the cell.
assert "Warm it to 21C and wait." in body
def test_a_failed_advice_is_marked_as_such(client, repo): def test_a_failed_advice_is_marked_as_such(client, repo):
"""A generation failure reads differently from real advice.""" """A generation failure reads differently from real advice."""
repo.users.insert_one({"username": "beer", "password": "beer_pass"}) repo.users.insert_one({"username": "beer", "password": "beer_pass"})
@@ -1008,7 +1037,9 @@ def test_a_failed_advice_is_marked_as_such(client, repo):
response = client.get("/brews") response = client.get("/brews")
body = response.get_data(as_text=True) body = response.get_data(as_text=True)
assert "advice--failed" in body # A failed generation is marked differently from real advice.
assert "advice-trigger--failed" in body
assert "Advice failed" in body
def test_advice_is_not_generated_in_tests(client, repo): def test_advice_is_not_generated_in_tests(client, repo):