ui: one pane at a time on phones, sized for touch
Below 760px the sidebar becomes a top bar and the list and thread trade
places: a selection marks .app.sel, which hides the list, and a back
arrow in the thread head returns to it. The gutter flows under the body,
long unbroken text wraps, and the dialog and toast fit the screen.
On touch devices (pointer: coarse) every control grows to a 44px hit
area, and fields stay at 16px so iOS Safari doesn't zoom on focus.
Taller options broke the menus' fixed 260px flip test, so a menu now
opens upward, measures itself, and flips down if it would clip above
the reader.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
diff --git a/assets/docket.css b/assets/docket.css
index 03cf243..e88d7a9 100644
--- a/assets/docket.css
+++ b/assets/docket.css
@@ -11,8 +11,9 @@ button { font: inherit; color: inherit; }
.count { font-size: var(--gl-step--2); color: var(--gl-color-text-secondary); }
/* Three panes on the fill: bare mailboxes, the list on a card, the thread
- as a stack of cards. */
-.app { height: 100vh; display: grid; grid-template-columns: 200px 420px minmax(0, 1fr); gap: var(--gl-space-2xs); padding: var(--gl-space-2xs) var(--gl-space-2xs) var(--gl-space-s); background: var(--gl-color-surface-fill); }
+ as a stack of cards. dvh over vh where it exists: mobile URL bars
+ shouldn't shrink the panes twice. */
+.app { height: 100vh; height: 100dvh; display: grid; grid-template-columns: 200px 420px minmax(0, 1fr); gap: var(--gl-space-2xs); padding: var(--gl-space-2xs) var(--gl-space-2xs) var(--gl-space-s); background: var(--gl-color-surface-fill); }
/* Gloss Input: a field on a rule. */
.field { box-sizing: border-box; width: 100%; border: none; border-bottom: var(--gl-border-w) solid var(--gl-color-border-default); border-radius: 0; background: transparent; padding: 8px 2px; font: var(--gl-step--1) var(--gl-font-text); color: var(--gl-color-text-primary); outline: none; }
@@ -88,7 +89,7 @@ button { font: inherit; color: inherit; }
.item .from { cursor: pointer; }
.item .addr, .item .copies { color: var(--gl-color-text-tertiary); }
.item .copies { font-size: var(--gl-step--2); }
-.item .text { line-height: 1.6; white-space: pre-line; }
+.item .text { line-height: 1.6; white-space: pre-line; overflow-wrap: anywhere; }
.item .text.closed { cursor: pointer; color: var(--gl-color-text-tertiary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* The gutter: date, then the email's values, right-aligned. */
@@ -118,13 +119,16 @@ button { font: inherit; color: inherit; }
.opt:hover, .opt.cur { background: var(--gl-color-surface-fill); }
.check { font: 500 var(--gl-step--2)/1 var(--gl-font-mono); }
+/* The mobile back link; the desktop two-pane never shows it. */
+.back { display: none; color: var(--gl-color-text-secondary); }
+
/* Deleting is the one destructive value: quiet until hovered. */
.del-btn { all: unset; box-sizing: border-box; cursor: pointer; padding: 4px 6px; border-radius: var(--gl-radius); color: var(--gl-color-text-tertiary); font: var(--gl-step--2) var(--gl-font-mono); transition: background var(--gl-dur-fast) var(--gl-ease); }
.del-btn:hover { background: var(--gl-color-surface-fill); color: var(--gl-color-text-primary); }
.del-btn:focus-visible { outline: 2px solid var(--gl-color-accent); outline-offset: 1px; }
/* The trash confirm: a small centered modal over a dimmed page. */
-dialog { margin: auto; border: none; border-radius: var(--gl-radius); padding: var(--gl-space-m); background: var(--gl-color-surface-card); color: var(--gl-color-text-primary); box-shadow: var(--gl-shadow-float); max-width: 380px; }
+dialog { margin: auto; border: none; border-radius: var(--gl-radius); padding: var(--gl-space-m); background: var(--gl-color-surface-card); color: var(--gl-color-text-primary); box-shadow: var(--gl-shadow-float); max-width: min(380px, calc(100vw - 2 * var(--gl-space-m))); }
dialog::backdrop { background: rgb(0 0 0 / .4); }
dialog h3 { font: 500 var(--gl-step-0)/1.3 var(--gl-font-text); margin: 0 0 var(--gl-space-2xs) 0; }
dialog p { margin: 0 0 var(--gl-space-s) 0; color: var(--gl-color-text-secondary); }
@@ -138,3 +142,44 @@ dialog form { display: flex; justify-content: flex-end; gap: var(--gl-space-xs);
.toast form { display: contents; }
.toast-act { all: unset; cursor: pointer; text-transform: uppercase; letter-spacing: .09em; text-decoration: underline; text-underline-offset: 3px; }
.toast-x { all: unset; cursor: pointer; opacity: .6; font-size: 16px; line-height: 1; }
+
+/* ── phones: one pane at a time ──
+ The shell drops to a top bar over either the list or the thread —
+ which one rides on .app.sel, the same URL state the desktop two-pane
+ reads. Opening a thread hides the list; the back link returns. */
+@media (max-width: 760px) {
+ .app { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); padding: var(--gl-space-3xs) var(--gl-space-3xs) var(--gl-space-2xs); }
+ .side { flex-direction: row; align-items: center; flex-wrap: wrap; gap: var(--gl-space-3xs) var(--gl-space-xs); padding: 0 var(--gl-space-3xs); overflow-y: visible; }
+ .brand-row { gap: var(--gl-space-xs); }
+ .nav { flex-direction: row; flex-wrap: wrap; gap: 2px; order: 3; width: 100%; }
+ .nav-item { margin: 0; padding: 8px 10px; }
+ .grow { display: none; }
+ .who { order: 2; margin-left: auto; }
+ .who-btn { margin: 0; padding: 8px 10px; }
+ .who-login { display: none; }
+ .version { display: none; }
+ .list { padding: var(--gl-space-xs) var(--gl-space-2xs) 0; }
+ .thread { padding: var(--gl-space-xs) var(--gl-space-2xs) var(--gl-space-m); }
+ .thread-head { gap: var(--gl-space-3xs); }
+ .thread-head .back { display: inline-flex; align-items: center; padding: 6px; margin-left: -6px; border-radius: var(--gl-radius); color: var(--gl-color-text-secondary); }
+ .thread-head .back:hover { background: var(--gl-color-surface-fill); }
+ .app.sel .list { display: none; }
+ .app:not(.sel) .reader { display: none; }
+ .item { grid-template-columns: minmax(0, 1fr); gap: var(--gl-space-2xs); }
+ .item .gutter { flex-direction: row; align-items: center; flex-wrap: wrap; gap: var(--gl-space-3xs) var(--gl-space-2xs); text-align: left; }
+ .comment .gutter { justify-content: flex-start; }
+ .toast { left: var(--gl-space-2xs); right: var(--gl-space-2xs); transform: none; min-width: 0; }
+}
+
+/* ── touch: fingers, not cursors ──
+ Every control grows to a 44px hit area, and fields stay at 16px or
+ more, below which iOS Safari zooms the page on focus. */
+@media (pointer: coarse) {
+ .field { font-size: max(16px, var(--gl-step--1)); }
+ .ctrl-btn, .del-btn, .opt, .nav-item, .who-btn { box-sizing: border-box; min-height: 44px; align-items: center; }
+ .who-btn { align-items: stretch; justify-content: center; }
+ .icon-btn, .back, .search-btn { min-width: 44px; min-height: 44px; justify-content: center; align-items: center; }
+ .thread-head .back { margin-left: -14px; }
+ .search-btn { margin: -14px 0; }
+ .toast-act, .toast-x { padding: 12px 8px; margin: -12px 0; }
+}
diff --git a/src/views.rs b/src/views.rs
index a2dc084..3a04a4e 100644
--- a/src/views.rs
+++ b/src/views.rs
@@ -117,6 +117,10 @@ const SEARCH_ICON: &str = r#"<svg width="15" height="15" viewBox="0 0 24 24" fil
/// Lucide's external-link, at Gloss's icon weight.
const EXTERNAL_ICON: &str = r#"<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 3h6v6"/><path d="M10 14 21 3"/><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/></svg>"#;
+/// Lucide's arrow-left, at Gloss's icon weight: the mobile way back to
+/// the list; the desktop two-pane has no use for it.
+const BACK_ICON: &str = r#"<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m12 19-7-7 7-7"/><path d="M19 12H5"/></svg>"#;
+
pub fn page(p: &Page<'_>) -> Markup {
html! {
(DOCTYPE)
@@ -130,7 +134,7 @@ pub fn page(p: &Page<'_>) -> Markup {
script defer src="/assets/alpine.js" {}
}
body {
- div.app {
+ div.app.sel[p.selected.is_some()] {
(sidebar(p))
(list(p))
(reader(p))
@@ -305,6 +309,7 @@ fn thread(p: &Page<'_>, t: &ThreadView, selected: &str) -> Markup {
html! {
article.thread {
header.thread-head {
+ a.back href=(view_path(p.view)) aria-label="Back to the list" { (PreEscaped(BACK_ICON)) }
h2 { (t.thread.subject) }
@if t.account.read_only {
span.type-label { "Read-only" }
@@ -393,13 +398,14 @@ fn message(p: &Page<'_>, m: &Message, open: bool, selected: bool, account: &Acco
}
/// A value that opens its menu when clicked. Menus open upward, as in the
-/// design, unless that would run off the top of the pane.
+/// design, unless that would run off the top of the pane — measured once
+/// shown, since touch layouts make the options taller.
fn menu(label: Markup, action: String, here: &str, options: Markup) -> Markup {
html! {
div.ctrl x-data="{ open: false, up: true }" "@click.outside"="open = false" "@keydown.escape"="open = false" {
button.ctrl-btn type="button" ":class"="open && 'on'"
- "@click"="up = $el.getBoundingClientRect().top > 260; open = !open" { (label) }
- form.menu method="post" action=(action) x-show="open" x-cloak ":class"="up ? 'up' : 'down'" {
+ "@click"="up = true; open = !open; $nextTick(() => { up = $refs.menu.getBoundingClientRect().top >= $el.closest('.reader').getBoundingClientRect().top })" { (label) }
+ form.menu x-ref="menu" method="post" action=(action) x-show="open" x-cloak ":class"="up ? 'up' : 'down'" {
(back(here))
(options)
}
diff --git a/tests/api.rs b/tests/api.rs
index b7f5523..00cc4c4 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -182,8 +182,16 @@ async fn lanes_and_search() {
#[tokio::test]
async fn thread_view_shows_the_chain_with_values_in_the_gutter() {
let addr = spawn().await;
+ // An unselected page renders the plain shell; a selection marks it,
+ // which is what the phone layout swaps panes on, and the thread
+ // head carries the mobile back link.
+ let (_, body) = get(addr, SAM, "/").await;
+ assert!(body.contains(r#"<div class="app">"#));
+ assert!(!body.contains("Back to the list"));
let (status, body) = get(addr, SAM, &format!("/?m={}", mid(4))).await;
assert_eq!(status, StatusCode::OK);
+ assert!(body.contains(r#"<div class="app sel">"#));
+ assert!(body.contains(r#"href="/" aria-label="Back to the list""#));
assert!(body.contains("<h2>Gutter repair estimate</h2>"));
// Only the selected email floats.
assert!(body.contains(&format!(r#"class="item card sel" id="m{}""#, mid(4))));