Skip to content

Commit 4adace8

Browse files
fix(ui): mobile breadcrumb truncation + initial FAB lift on deep links
## Breadcrumb (MastheadRule) At 375px the masthead showed `~/anyplot.ai · bar-tornado-sensitivi…` — ellipsis ate the language and library segments, i.e. exactly the parts the user was currently looking at. Two reasons: 1. `gridTemplateColumns` switched to `1fr auto 1fr` at `sm`, but the center comment is `display: none` until `md`. The right `1fr` therefore claimed ~half the bar at sm just for the theme toggle, leaving the breadcrumb ~290px when it needed ~360px. 2. Language (`python`) + library (`plotnine`) were always rendered in full, competing with a long spec-id for the limited room. Fix: - Grid stays `1fr auto auto` until `md`, so xs+sm give the breadcrumb the full remaining width (toggle still hugs the right edge). - Language and library carry a `short` label (`py` / `p9` via the existing `LANG_EXT` and `LIB_ABBREV` maps already used 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/SR users. - 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 masthead becomes a pure breadcrumb on mobile. Result at 375px: before: `~/anyplot.ai · bar-tornado-sensitivi…` after: `bar-tornado-sensitivity · py · p9` ## FAB lift (FeedbackWidget) On a direct deep link to a spec page the FAB appeared elevated for ~hundreds of ms before settling at the corner. The lift effect uses `footer.getBoundingClientRect().top` to detect viewport overlap, and only recomputes on `scroll` / `resize` — neither fires while React hydrates and the spec data + images stream in. During that window the footer sits high in the layout and the FAB lifts dramatically, then drops once content loads. Watch the body with `ResizeObserver` (in addition to scroll/resize) and re-run the same RAF-batched update — content growth now triggers a recompute, so the FAB stays anchored at the corner during initial layout. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e10219b commit 4adace8

2 files changed

Lines changed: 71 additions & 30 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: 58 additions & 25 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;
@@ -143,9 +148,12 @@ export function MastheadRule() {
143148
return (
144149
<Box sx={{
145150
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' },
151+
// xs+sm: left takes all remaining room, toggle hugs the right edge —
152+
// the center comment is hidden until md, so giving it its own balanced
153+
// 1fr column at sm just wastes ~half the bar on whitespace and forces
154+
// the breadcrumb to truncate. md+: center slot appears, sides are
155+
// balanced 1fr auto 1fr.
156+
gridTemplateColumns: { xs: '1fr auto auto', md: '1fr auto 1fr' },
149157
alignItems: 'center',
150158
columnGap: { xs: 1, sm: 2 },
151159
py: 1.25,
@@ -162,19 +170,23 @@ export function MastheadRule() {
162170
overflow: 'hidden',
163171
textOverflow: 'ellipsis',
164172
}}>
165-
{/* Always-visible root marker */}
173+
{/* Root marker — hidden on xs (where the NavBar logo `any.plot()` below
174+
already anchors the brand) so the breadcrumb has room for the
175+
spec-id + lang + lib without truncating. */}
166176
<Box
167177
component={RouterLink}
168178
to="/"
169179
onClick={() => trackEvent('nav_click', { source: 'masthead_logo', target: '/' })}
170-
sx={linkSx}
180+
sx={{ ...linkSx, display: { xs: 'none', sm: 'inline' } }}
171181
>
172182
~/anyplot.ai
173183
</Box>
174184

175185
{isLanding ? (
176186
<>
177-
{' · '}
187+
<Box component="span" sx={{ display: { xs: 'none', sm: 'inline' } }}>
188+
{' · '}
189+
</Box>
178190
<Box
179191
component="a"
180192
href={`${REPO_URL}/tree/main`}
@@ -198,25 +210,46 @@ export function MastheadRule() {
198210
</Box>
199211
</>
200212
) : (
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}
213+
segments.map((seg, i) => {
214+
const hasShort = seg.short && seg.short !== seg.label;
215+
const labelEl = hasShort ? (
216+
<>
217+
{/* xs+sm show the shorthand (`py`, `p9`); md+ has room for the
218+
full name. Matches NavBar's md-breakpoint convention. */}
219+
<Box component="span" sx={{ display: { xs: 'inline', md: 'none' } }} title={seg.label}>
220+
{seg.short}
212221
</Box>
213-
) : (
214-
<Box component="span" sx={{ ...staticSx, color: 'var(--ink-soft)' }}>
222+
<Box component="span" sx={{ display: { xs: 'none', md: 'inline' } }}>
215223
{seg.label}
216224
</Box>
217-
)}
218-
</Box>
219-
))
225+
</>
226+
) : (
227+
seg.label
228+
);
229+
return (
230+
<Box key={`${seg.label}-${i}`} component="span">
231+
{/* First separator hides on xs because the logo is hidden too;
232+
later separators always show. */}
233+
<Box component="span" sx={i === 0 ? { display: { xs: 'none', sm: 'inline' } } : undefined}>
234+
{' · '}
235+
</Box>
236+
{seg.to ? (
237+
<Box
238+
component={RouterLink}
239+
to={seg.to}
240+
onClick={() => trackEvent('nav_click', { source: 'breadcrumb', target: seg.to })}
241+
sx={linkSx}
242+
>
243+
{labelEl}
244+
</Box>
245+
) : (
246+
<Box component="span" sx={{ ...staticSx, color: 'var(--ink-soft)' }}>
247+
{labelEl}
248+
</Box>
249+
)}
250+
</Box>
251+
);
252+
})
220253
)}
221254
</Box>
222255

0 commit comments

Comments
 (0)