Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,14 @@ All notable changes to Allthing are recorded here. The format follows [Keep a Ch
- Videos up to 250 MB upload in 8 MB chunks. Retry continues from the last acknowledged chunk; Remove cancels the pending upload. Preparation and finishing have visible states, and the cast stays disabled until its attachments are ready.
- Compatible MP4 and MOV videos are prepared for HLS from bounded metadata and file slices, preserving their frames and sound without copying the entire video into WebAssembly. Existing image limits and storage safeguards remain.

### Fixed

- After Escape closes a hover card, layout changes cannot reopen it under a stationary pointer. Moving away and back still opens it normally.

### Development

- Browser checks wait for app handover after measuring the first painted card, compare Back with the position at the actual click, and await exact page-load counts. Local media checks run database commands asynchronously so CI's Node 22 client can retire closed connections, and retain bounded Worker diagnostics on failure. Existing timing limits, request counts and upload assertions remain.

## [0.5.1] - 2026-10-23

### Changed
Expand Down
55 changes: 51 additions & 4 deletions crates/allthing-web/src/components/popover.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ pub fn Popover(
) -> impl IntoView {
let state = RwSignal::new(State::Closed);
let timer = StoredValue::new(None::<TimeoutHandle>);
// Layout can send trusted leave/enter events to a stationary pointer
// after Escape's closing animation. Only a new pointer excursion or
// an explicit activation may arm that hover again.
let hover_dismissed = RwSignal::new(false);
let pointer_at = StoredValue::new(None::<(i32, i32)>);
let dismissed_at = StoredValue::new(None::<(i32, i32)>);
let root = NodeRef::<leptos::html::Span>::new();
let button = NodeRef::<leptos::html::Button>::new();
let max_px = max_px.unwrap_or(MAX_PANEL_PX);
Expand Down Expand Up @@ -258,6 +264,7 @@ pub fn Popover(
let open_by = move |press: bool| {
cancel();
place();
hover_dismissed.set(false);
pressed_open.set_value(press);
state.set(State::Open);
};
Expand All @@ -282,6 +289,13 @@ pub fn Popover(
);
}
};
let dismiss_hover = move || {
if state.try_get_untracked() == Some(State::Open) {
dismissed_at.set_value(pointer_at.get_value());
hover_dismissed.set(true);
close();
}
};
if let Some(shut) = shut {
Effect::new(move |seen: Option<u32>| {
let now = shut.get();
Expand Down Expand Up @@ -315,9 +329,10 @@ pub fn Popover(
warm.run(());
}
};
let enter = move |_: ev::MouseEvent| {
let enter = move |ev: ev::MouseEvent| {
pointer_at.set_value(Some((ev.client_x(), ev.client_y())));
warm();
if hovering() {
if hovering() && !hover_dismissed.get_untracked() {
match state.get_untracked() {
State::Open => cancel(),
State::Closed => schedule(open_delay, Box::new(open)),
Expand All @@ -328,6 +343,11 @@ pub fn Popover(
}
}
};
let pointer_move = move |ev: ev::PointerEvent| {
if ev.pointer_type() == "mouse" || ev.pointer_type() == "pen" && can_hover() {
pointer_at.set_value(Some((ev.client_x(), ev.client_y())));
}
};
let leave = move |_: ev::MouseEvent| {
if hovering() {
if state.get_untracked() != State::Open {
Expand Down Expand Up @@ -431,7 +451,7 @@ pub fn Popover(
pointer.set_value(String::new());
if ev.key() == "Escape" && state.get_untracked() == State::Open {
ev.stop_propagation();
close();
dismiss_hover();
if links {
// Back to the link focus rested on, or else the first one.
let back = focused_link.get_value().filter(|link| link.is_connected()).or_else(|| {
Expand Down Expand Up @@ -463,6 +483,7 @@ pub fn Popover(
let outside_click = StoredValue::new(None::<WindowListenerHandle>);
let escape_key = StoredValue::new(None::<WindowListenerHandle>);
let pointer_up = StoredValue::new(None::<WindowListenerHandle>);
let hover_rearm = StoredValue::new(None::<WindowListenerHandle>);
// The window's capture listeners: light dismissal's, and a fixed panel's.
let captures = StoredValue::new_local(Vec::<Capture>::new());
let remove_listeners = move || {
Expand All @@ -484,6 +505,26 @@ pub fn Popover(
});
!inside_root(target) && !over
};
Effect::new(move |_| {
hover_rearm.update_value(|current| {
if let Some(handle) = current.take() {
handle.remove();
}
});
if !hover_dismissed.get() {
return;
}
hover_rearm.set_value(Some(window_event_listener(ev::pointermove, move |ev| {
let moved = dismissed_at.get_value().is_none_or(|point| point != (ev.client_x(), ev.client_y()));
let outside = ev
.target()
.and_then(|target| target.dyn_into::<web_sys::Node>().ok())
.is_some_and(|target| !inside_root(&target));
if (ev.pointer_type() == "mouse" || ev.pointer_type() == "pen" && can_hover()) && moved && outside {
let _ = hover_dismissed.try_set(false);
}
})));
});
Effect::new(move |_| {
remove_listeners();
if state.get() != State::Open {
Expand Down Expand Up @@ -520,7 +561,7 @@ pub fn Popover(
}
escape_key.set_value(Some(window_event_listener(ev::keydown, move |ev| {
if ev.key() == "Escape" {
close();
dismiss_hover();
}
})));
pointer_up.set_value(Some(window_event_listener(ev::pointerup, move |ev| {
Expand Down Expand Up @@ -595,6 +636,11 @@ pub fn Popover(
on_cleanup(move || {
cancel();
remove_listeners();
hover_rearm.update_value(|current| {
if let Some(handle) = current.take() {
handle.remove();
}
});
});

let id = format!("pop-{}", next_id());
Expand Down Expand Up @@ -653,6 +699,7 @@ pub fn Popover(
on:focusout=focus_out
on:keydown=keydown
on:pointerdown=pointer_down
on:pointermove=pointer_move
on:click=link_click
>
{lead.map(|lead| lead.run())}
Expand Down
32 changes: 27 additions & 5 deletions e2e/allthing.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,9 @@ test.describe("front page", () => {
// The static copy builds the page in the browser (several upstream round trips, no edge cache).
expect(elapsed, `first casts after ${elapsed} ms`).toBeLessThan(PAGES ? 6000 : LIVE ? 3000 : 5000);

// First paint above keeps its original timing budget. Interactive
// branding is checked after the app replaces its early boot header.
await appCame(page);
// Branding: the wordmark only, no logo mark.
await expect(page.locator("header .wordmark")).toHaveText("Allthing");
await expect(page.locator(".site-header img, .wordmark svg, .logo-mark")).toHaveCount(0);
Expand Down Expand Up @@ -789,6 +792,7 @@ test.describe("threads and navigation", () => {

test("opening a post from the feed renders at once, and back restores the scroll position", async ({ page }) => {
await page.goto(at("/"));
await appCame(page);
await expect(firstCard(page)).toBeVisible();
await page.evaluate(() => window.scrollTo(0, 1600));
await page.waitForTimeout(300);
Expand All @@ -804,30 +808,48 @@ test.describe("threads and navigation", () => {
// estimated height, and images above it load: measure once the card has
// stopped moving.
await page.waitForLoadState("networkidle").catch(() => {});
let before = await top();
let settledTop = await top();
await expect
.poll(async () => {
const [was, now] = [before, (before = await top())];
const [was, now] = [settledTop, (settledTop = await top())];
return was === now;
}, { intervals: [250] })
.toBe(true);
// Measure in the page: from the click to the post's text in the DOM.
await page.evaluate(() => {
// Playwright may scroll again while making the click actionable. As
// in kept-front.fixture.spec.ts, Back must restore the actual departure
// position, not the earlier, possibly off-screen layout measurement.
// The capture-phase point is also when nav.rs saves the reader's place.
// Measure from this same real click to the post's text in the DOM.
await page.evaluate((hash) => {
(window as any).__openedIn = null;
(window as any).__openedFrom = null;
document.addEventListener("click", () => {
const t0 = performance.now();
const card = document.querySelector(`.cast-card[data-hash="${hash}"]`);
(window as any).__openedFrom = {
hash, path: location.pathname + location.search,
top: card ? Math.round(card.getBoundingClientRect().top) : null,
y: Math.round(scrollY), viewport: innerHeight,
};
const check = () => document.querySelector(".post .post-text, .post .mini-app-note")
? ((window as any).__openedIn = performance.now() - t0)
: requestAnimationFrame(check);
requestAnimationFrame(check);
}, { capture: true, once: true });
});
}, hash);
await card.locator(".cast-text").click({ position: { x: 4, y: 8 } });
await expect(page).toHaveURL(new RegExp(`/p/${hash}$`));
await expect.poll(() => page.evaluate(() => (window as any).__openedIn)).not.toBeNull();
const openedIn = await page.evaluate(() => (window as any).__openedIn as number);
console.log(`post opened in ${openedIn.toFixed(0)} ms`);
expect(openedIn, "post renders from feed data").toBeLessThan(100);
const departed = await page.evaluate(() => (window as any).__openedFrom as {
hash: string; path: string; top: number | null; y: number; viewport: number;
} | null);
expect(departed?.hash, "the real click's source card").toBe(hash);
expect(departed?.path, "the real click leaves Home").toBe(at("/"));
expect(departed?.top, "a laid-out source card at departure").not.toBeNull();
const before = departed!.top!;

await page.goBack();
await expect(left).toBeVisible();
Expand Down
62 changes: 59 additions & 3 deletions e2e/hover-cards.fixture.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ async function watchDismissal(page: Page) {
const listeners = new Set<EventListenerOrEventListenerObject>();
const describe = (target: EventTarget | null) => target instanceof Element
? `${target.tagName.toLowerCase()}.${target.className}`.slice(0, 120) : null;
const record = (action: string, target: EventTarget | null = null) => {
const record = (action: string, target: EventTarget | null = null, event?: Event) => {
events.push({
at: Math.round(performance.now()), action, target: describe(target),
point: event instanceof MouseEvent ? [event.clientX, event.clientY] : null,
focus: describe(document.activeElement), windowKeys: listeners.size,
panels: [...document.querySelectorAll(".popover-panel")].slice(0, 8)
.map((panel) => ({ id: panel.id, class: panel.className })),
Expand All @@ -108,9 +109,9 @@ async function watchDismissal(page: Page) {
for (const type of ["keydown", "keyup", "mouseenter", "mouseleave", "focusin", "focusout"]) {
add(type, ((event: Event) => {
if (event instanceof KeyboardEvent) {
if (event.key === "Escape") record(`${type}:${event.key}:${event.isTrusted}`, event.target);
if (event.key === "Escape") record(`${type}:${event.key}:${event.isTrusted}`, event.target, event);
} else if (event.target instanceof Element && event.target.matches(".popover, .popover-panel, .user-link, .author, .display-name")) {
record(type, event.target);
record(type, event.target, event);
}
}) as EventListener, true);
}
Expand Down Expand Up @@ -237,6 +238,61 @@ test("Escape stays dismissed when the pointer re-enters during the closing anima
await expect(profile).toBeVisible();
});

test("Escape stays dismissed across stationary-pointer layout changes after closing", async ({ page }) => {
await serve(page);
await watchDismissal(page);
await page.goto("/");
await appCame(page);
const picture = chipLink(feedCard(page), "user-link");
const chip = picture.locator("..");
const profile = profileOf(page, "Alice", "alice");
await picture.hover();
await expect(profile).toBeVisible();
await page.keyboard.press("Escape");
await expect(profile).toHaveCount(0);

// The pointer stays still. Moving its trigger away and back creates
// real layout-driven boundary events after Closing has already ended.
// Those trusted events must not undo the reader's Escape dismissal.
type Boundary = { action: string; target: string | null; point: [number, number] | null };
const boundaries = () => page.evaluate(() =>
((window as Window & { popoverDismissalEvents?: Boundary[] }).popoverDismissalEvents ?? [])
.filter((event) => ["mouseenter", "mouseleave"].includes(event.action) && event.target === "span.popover user-chip"));
const before = await boundaries();
const entered = before.filter((event) => event.action === "mouseenter").length;
const left = before.filter((event) => event.action === "mouseleave").length;
const parked = before.at(-1)!.point;
await chip.evaluate((element) => { (element as HTMLElement).style.transform = "translateX(400px)"; });
await expect.poll(async () => (await boundaries()).filter((event) => event.action === "mouseleave").length).toBe(left + 1);
await chip.evaluate((element) => { (element as HTMLElement).style.transform = ""; });
await expect.poll(async () => (await boundaries()).filter((event) => event.action === "mouseenter").length).toBe(entered + 1);
expect((await boundaries()).slice(-2).map((event) => event.point)).toEqual([parked, parked]);
await page.waitForTimeout(300);
try {
await expect(profile).toHaveCount(0, { timeout: 500 });
} catch (error) {
const events = await page.evaluate(() => (window as Window & { popoverDismissalEvents?: unknown[] }).popoverDismissalEvents);
await test.info().attach("popover-dismissal-events", { body: JSON.stringify(events, null, 2), contentType: "application/json" });
throw error;
}

// Deliberate movement out and back still starts a fresh hover.
await page.mouse.move(5, 5);
await picture.hover();
await expect(profile).toBeVisible();

// A hover-capable pen follows the same path; touch keeps its explicit
// tap behavior. Chromium delivers real pen pointer events through CDP.
await page.keyboard.press("Escape");
await expect(profile).toHaveCount(0);
const box = (await picture.boundingBox())!;
const input = await page.context().newCDPSession(page);
await input.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: 5, y: 5, pointerType: "pen" });
await input.send("Input.dispatchMouseEvent", { type: "mouseMoved", x: box.x + box.width / 2, y: box.y + box.height / 2, pointerType: "pen" });
await expect(profile).toBeVisible({ timeout: 500 });
await input.detach();
});

test("a card's picture shows the small copy the feed loaded until its own has loaded", async ({ page }) => {
await serve(page);
// Alice has a picture. The feed's small copy comes at once; the card's
Expand Down
Loading
Loading