Skip to content

Commit 60e1a10

Browse files
fix(ui): mobile breadcrumb truncation + initial FAB lift (#7283)
## Summary Three small, related UI bugs the user hit on a single mobile session: 1. **Mobile breadcrumb truncated the lang/lib segments** — at 375px the masthead showed `~/anyplot.ai · bar-tornado-sensitivi…`, eating the very segments the user was currently looking at. 2. **Md+ overflow even with full breadcrumb** — at 1280px on impl pages the breadcrumb still truncated to `~/anyplot.ai · bar-tornado-sensitivity · pyt…` because the center `""" specId.library """` echo claimed ~340px from the grid. 3. **FAB started elevated on deep links** — opening a spec page directly made the quick-feedback FAB float ~hundreds of pixels above the corner for the first few hundred ms before settling. ## Changes ### `MastheadRule.tsx` - Grid stays `1fr auto auto` until `md`. The center comment slot is `display: none` until `md` anyway, so giving it its own `1fr` column at sm just wasted ~half the bar on whitespace. - Language and library segments carry a `short` label (`py` / `p9` via the existing `LANG_EXT` and `LIB_ABBREV` maps used elsewhere in compact catalog tiles). Rendered with the NavBar dual-span pattern — short on xs+sm, full on md+. `title=` carries the full name for hover and screen readers. - The `~/anyplot.ai` root marker (and its leading separator) is hidden on xs only. The NavBar logo `any.plot()` immediately below already anchors the brand. - The center `""" specId.library """` echo is now hidden when the URL has all three segments (`/:specId/:language/:library`). The breadcrumb already shows all three parts; the echo is redundant *and* it was the load-bearing cause of the md overflow. Landing, spec hub, and language hub still show their center comment. ### `FeedbackWidget.tsx` The lift effect used `footer.getBoundingClientRect().top` to detect overlap and only recomputed on `scroll` / `resize`. Neither fires while React hydrates and the spec data + images stream in — so on deep links the footer briefly sat high in the layout and the FAB lifted dramatically before content arrived and pushed the footer below the fold. Add a `ResizeObserver` on `document.body` that re-runs the same RAF-batched update on layout changes. ## Results | Viewport | Route | Before | After | | --- | --- | --- | --- | | 375 (xs) | impl page | `~/anyplot.ai · bar-tornado-sensitivi…` | `bar-tornado-sensitivity · py · p9` | | 700 (sm) | impl page | `~/anyplot.ai · bar-tornado-sensitivi…` | `~/anyplot.ai · bar-tornado-sensitivity · py · p9` | | 1280 (md) | impl page | `~/anyplot.ai · bar-tornado-sensitivity · pyt…` | `~/anyplot.ai · bar-tornado-sensitivity · python · plotnine` (center hidden) | | 1280 (md) | spec hub | unchanged | unchanged (`<!-- specId -->` still shown) | | any | landing | unchanged (xs hides logo, see below) | xs: `main · v2.3.0` / sm+: `~/anyplot.ai · main · v2.3.0` | ## Verified - `tsc --noEmit` green - Playwright + computed-style probe at 375 / 700 / 1280 — no overflow on impl pages at any breakpoint - FAB at `transform: none` when footer is below fold; `translateY(-54px)` when footer enters viewport (test scrolling to bottom at 375×812) ## Test plan - [ ] Open `/<long-spec>/python/plotnine` at iPhone width — breadcrumb shows full spec-id + `py` + `p9`, no ellipsis - [ ] Same route at tablet (~700px) — `~/anyplot.ai` reappears, still abbreviated - [ ] Same route at desktop — full breadcrumb, no center echo - [ ] Spec hub `/<spec>` at desktop — center echo `<!-- spec -->` still visible - [ ] Landing on mobile — bar reads `main · v2.x.x` without the leading separator - [ ] Hard-refresh a deep link `/<spec>/python/<lib>` on mobile — FAB stays in the corner from the first paint (no jumpy elevation) 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e10219b commit 60e1a10

2 files changed

Lines changed: 82 additions & 31 deletions

File tree

app/src/components/FeedbackWidget.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -106,20 +106,28 @@ export function FeedbackWidget() {
106106
const r = footer.getBoundingClientRect();
107107
setLift(Math.max(0, window.innerHeight - r.top));
108108
};
109-
const onScroll = () => {
109+
const schedule = () => {
110110
if (rafId) return;
111111
rafId = window.requestAnimationFrame(() => {
112112
rafId = 0;
113113
update();
114114
});
115115
};
116116
update();
117-
window.addEventListener('scroll', onScroll, { passive: true });
118-
window.addEventListener('resize', onScroll);
117+
window.addEventListener('scroll', schedule, { passive: true });
118+
window.addEventListener('resize', schedule);
119+
// On a direct deep link to a spec page the page is initially short — data
120+
// and images stream in over the next ~hundred ms — so on first paint the
121+
// footer sits high in the layout and the FAB lifts dramatically before
122+
// settling. Watch the body for size changes so the FAB drops back to the
123+
// corner once content stabilises.
124+
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(schedule) : null;
125+
ro?.observe(document.body);
119126
return () => {
120127
if (rafId) cancelAnimationFrame(rafId);
121-
window.removeEventListener('scroll', onScroll);
122-
window.removeEventListener('resize', onScroll);
128+
window.removeEventListener('scroll', schedule);
129+
window.removeEventListener('resize', schedule);
130+
ro?.disconnect();
123131
};
124132
}, []);
125133
// Default FAB center on xs is 32px from viewport bottom (bottom 12 + half of

app/src/components/MastheadRule.tsx

Lines changed: 69 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { typography, colors } from '../theme';
55
import { ThemeToggle } from './ThemeToggle';
66
import { useTheme, useLatestRelease, useAnalytics } from '../hooks';
77
import { RESERVED_TOP_LEVEL } from '../utils/paths';
8+
import { LIB_ABBREV, LANG_EXT } from '../constants';
89

910
// Symmetric block-comment delimiters used when no language context is in the URL.
1011
// One is picked on mount so each page load reveals a different classic.
@@ -51,6 +52,10 @@ const staticSx = {
5152
interface Segment {
5253
label: string;
5354
to?: string;
55+
// Shorthand label used on xs viewports — the masthead would otherwise
56+
// truncate the spec-id and cut off language/library entirely (the parts the
57+
// user is actively looking at). Falls back to `label` when absent.
58+
short?: string;
5459
}
5560

5661
/**
@@ -83,10 +88,10 @@ function pathSegments(pathname: string): Segment[] {
8388
}
8489
if (language) {
8590
if (library) {
86-
segs.push({ label: language, to: `/${specId}/${language}` });
87-
segs.push({ label: library });
91+
segs.push({ label: language, to: `/${specId}/${language}`, short: LANG_EXT[language] });
92+
segs.push({ label: library, short: LIB_ABBREV[library] });
8893
} else {
89-
segs.push({ label: language });
94+
segs.push({ label: language, short: LANG_EXT[language] });
9095
}
9196
}
9297
return segs;
@@ -126,7 +131,11 @@ export function MastheadRule() {
126131
const parts = location.pathname.split('/').filter(Boolean);
127132
const isReserved = parts.length > 0 && RESERVED_TOP_LEVEL.has(parts[0]);
128133
const isSpecRoute = parts.length > 0 && !isReserved;
129-
const centerVisible = isLanding || isSpecRoute;
134+
// On impl pages (/:specId/:language/:library) the breadcrumb already shows
135+
// all three parts; the center `""" specId.library """` echo is redundant
136+
// and steals room that pushes the breadcrumb into truncation. Hide it.
137+
const isImplPage = isSpecRoute && parts.length >= 3;
138+
const centerVisible = (isLanding || isSpecRoute) && !isImplPage;
130139

131140
let centerContent = 'the open plot catalogue';
132141
let centerDelim: { open: string; close: string } = COMMENT_POOL[randomIdx];
@@ -143,9 +152,18 @@ export function MastheadRule() {
143152
return (
144153
<Box sx={{
145154
display: 'grid',
146-
// xs: left takes all remaining room, toggle hugs the right edge.
147-
// sm+: center slot appears (auto), sides are balanced 1fr auto 1fr.
148-
gridTemplateColumns: { xs: '1fr auto auto', sm: '1fr auto 1fr' },
155+
// xs+sm: left takes all remaining room, toggle hugs the right edge —
156+
// the center comment is hidden until md, so giving it its own balanced
157+
// 1fr column at sm just wastes ~half the bar on whitespace and forces
158+
// the breadcrumb to truncate. md+: when the center comment actually
159+
// shows (landing / spec hub / lang hub), use balanced 1fr auto 1fr so
160+
// the comment sits visually centred. On impl pages the comment is
161+
// hidden — collapse the right column to `auto` so the breadcrumb can
162+
// claim the full row width instead of half.
163+
gridTemplateColumns: {
164+
xs: '1fr auto auto',
165+
md: centerVisible ? '1fr auto 1fr' : '1fr auto auto',
166+
},
149167
alignItems: 'center',
150168
columnGap: { xs: 1, sm: 2 },
151169
py: 1.25,
@@ -162,19 +180,23 @@ export function MastheadRule() {
162180
overflow: 'hidden',
163181
textOverflow: 'ellipsis',
164182
}}>
165-
{/* Always-visible root marker */}
183+
{/* Root marker — hidden on xs (where the NavBar logo `any.plot()` below
184+
already anchors the brand) so the breadcrumb has room for the
185+
spec-id + lang + lib without truncating. */}
166186
<Box
167187
component={RouterLink}
168188
to="/"
169189
onClick={() => trackEvent('nav_click', { source: 'masthead_logo', target: '/' })}
170-
sx={linkSx}
190+
sx={{ ...linkSx, display: { xs: 'none', sm: 'inline' } }}
171191
>
172192
~/anyplot.ai
173193
</Box>
174194

175195
{isLanding ? (
176196
<>
177-
{' · '}
197+
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
198+
{' · '}
199+
</Box>
178200
<Box
179201
component="a"
180202
href={`${REPO_URL}/tree/main`}
@@ -198,25 +220,46 @@ export function MastheadRule() {
198220
</Box>
199221
</>
200222
) : (
201-
segments.map((seg, i) => (
202-
<Box key={`${seg.label}-${i}`} component="span">
203-
{' · '}
204-
{seg.to ? (
205-
<Box
206-
component={RouterLink}
207-
to={seg.to}
208-
onClick={() => trackEvent('nav_click', { source: 'breadcrumb', target: seg.to })}
209-
sx={linkSx}
210-
>
211-
{seg.label}
223+
segments.map((seg, i) => {
224+
const hasShort = seg.short && seg.short !== seg.label;
225+
const labelEl = hasShort ? (
226+
<>
227+
{/* xs+sm show the shorthand (`py`, `p9`); md+ has room for the
228+
full name. Matches NavBar's md-breakpoint convention. */}
229+
<Box component="span" sx={{ display: { xs: 'inline', md: 'none' } }} title={seg.label}>
230+
{seg.short}
212231
</Box>
213-
) : (
214-
<Box component="span" sx={{ ...staticSx, color: 'var(--ink-soft)' }}>
232+
<Box component="span" sx={{ display: { xs: 'none', md: 'inline' } }}>
215233
{seg.label}
216234
</Box>
217-
)}
218-
</Box>
219-
))
235+
</>
236+
) : (
237+
seg.label
238+
);
239+
return (
240+
<Box key={`${seg.label}-${i}`} component="span">
241+
{/* First separator hides on xs because the logo is hidden too;
242+
later separators always show. */}
243+
<Box component="span" sx={i === 0 ? { display: { xs: 'none', sm: 'inline' } } : undefined}>
244+
{' · '}
245+
</Box>
246+
{seg.to ? (
247+
<Box
248+
component={RouterLink}
249+
to={seg.to}
250+
onClick={() => trackEvent('nav_click', { source: 'breadcrumb', target: seg.to })}
251+
sx={linkSx}
252+
>
253+
{labelEl}
254+
</Box>
255+
) : (
256+
<Box component="span" sx={{ ...staticSx, color: 'var(--ink-soft)' }}>
257+
{labelEl}
258+
</Box>
259+
)}
260+
</Box>
261+
);
262+
})
220263
)}
221264
</Box>
222265

0 commit comments

Comments
 (0)