`move_traffic_light` was framing the close / minimize / zoom NSButtons
at `titlebar_height - traffic_light_position.y - button_h`. Once the
window opts into `NSFullSizeContentViewWindowMask`, the content layout
rectangle covers the whole frame and `titlebar_height()` returns 0 —
collapsing the origin to a large negative Y. macOS still painted the
buttons through its own caching layer at the title bar's natural
position, but the buttons' hit-test rectangle followed the frame off
screen, so clicking close / minimize / zoom did nothing.
Anchor the math against the close button's actual superview frame
height (the themeFrame, which equals the window height in Y-up
coordinates) so the visual position and the hit area stay in lockstep
no matter what mask combination the window opens with. `titlebar_height`
stays available for callers that want the legacy non-full-size
interpretation, with `#[allow(dead_code)]` and a docstring pointing at
this fix.
Drops the separate dashboard tile and the top-level "+" button so the
sidebar's first row holds a single workspace identity. "+ New
workspace" lives inside the disclosure popover next to the existing
spaces list, matching that row's geometry instead of inventing a new
square tile.
The previous 76 px traffic-light reserve only cleared the buttons
when the row also carried the tile and "+" — with the pill alone
plus its drop-shadow, it slid back behind the green button on the
default sidebar width. Widen `TRAFFIC_LIGHT_RESERVE` to 100 px and
document the derivation: pill outer left = `29 + RESERVE`, traffic-
light group right edge ≈ 112 px, so 100 leaves 17 px of breathing
room. Anchor solver and tests track the new geometry.
Pointing the renderer at the site's own `/favicon.ico` was producing
GPUI image-cache errors on every other tab — notion.com redirected
across origins, sites shipped multi-image `image/x-icon` blobs the
PNG/WebP decoder couldn't read, hosts 404'd. Each one logged a noisy
`ERROR gpui::asset_cache: Failed to load asset` line.
Switch `UrlText::favicon_url` to
`https://www.google.com/s2/favicons?domain=<host>&sz=64`. Google's
endpoint normalises every response to PNG, follows redirects on its
side, and serves a `_/` globe glyph for sites without a favicon at
all — same URL shape every browser dev-tools panel already shows
for "favicon" so the fetch is uniformly succeeding.
The wrangler.toml + better_auth.ts move from auth@ to browser@elydora.com
left this assertion behind, so `npm test` failed pre-deploy. Realign
the expected `from` field and the worker's test suite is green again.
Replace the "drop a session token in a file" workflow with a real
Chrome-style email login. The Cloudflare worker already had Better
Auth's `email-otp` plugin wired into `SEND_EMAIL`; this commit
builds the renderer-side counterpart.
Worker side:
- Move the OTP sender from `auth@elydora.com` to `browser@elydora.com`
(wrangler.toml `allowed_sender_addresses` + better_auth.ts
`EMAIL_OTP_FROM_ADDRESS`). Worker must be redeployed to pick this up.
Client side (`ely_sync_client::email_otp`):
- `send_email_otp(config, email)` POSTs `/api/auth/email-otp/send-verification-otp`
with `{ email, type: "sign-in" }`.
- `verify_email_otp(config, email, otp)` POSTs `/api/auth/sign-in/email-otp`,
reads the Better Auth session token from the JSON body's `token` field
with the `Set-Cookie: better-auth.session_token=…` header as the
documented fallback channel, and returns it as a `BearerToken`.
Shell side (`shell/auth.rs` + `shell/internal_pages/sync.rs`):
- New `AuthFlowPhase` (Idle / SendingCode / AwaitingOtp / Verifying /
Error) tracks the in-flight form. Two off-thread workers run the
HTTP exchanges so the GPUI render loop never blocks.
- Successful verify saves the bearer via `SyncEngine::install_bearer`
and triggers an immediate snapshot upload, so the user is signed in
+ initial-synced in one click.
- Sync settings page replaces the bare "Sync now" button row with an
account card: when SignedOut → email field + Send code → OTP field
+ Verify / Resend; when signed in → an account chip + Sign out.
- `trigger_cloud_sync_upload` no longer takes a `Context` param so
the post-auth path can fire it from the inbox-drain pass without
needing a window context.
Two paths in `WebSurfaceStore::tick` were downgrading a perfectly good
Ready frame back to Failed / Loading on every transient hiccup:
- `WebSurfaceRuntimeFrame::Failed` overwrote the surface state
unconditionally. Combined with the 125 Hz tick (which submits a Poll
for every visible tab and any transient ensure / poll error becomes
a Failed response), even one parse glitch wiped the rendered page.
- The `initial_display_gate_message` and `should_hold_initial_frame`
checks for incoming Ready frames hardcoded `has_previous_frame =
false`, so a stray empty paint pass after the page had already
rendered would knock the surface back to Loading or Failed.
Detect whether the surface already has a `Ready(_)` state and:
- ignore Failed responses (logging through `tracing` for diagnostics)
while a real frame is on screen;
- pass that "had ready" signal into the gate / hold checks so they only
apply to the very first paint, not later refreshes.
The page now stays put even when Servo emits a momentary error; only
the first-paint failure path can mark the surface Failed.
The three layout preset cards (Single column / Compact / Hidden on
hover) painted as bright white boxes regardless of theme, never
implemented the layouts they previewed, and duplicated the live
sidebar resize handle which already covers the same intent. Drop
the section, the supporting `appearance_layout_cards` module, and
the chrome `mod` declaration that re-exported it.
The sidebar header stacked a redundant `ELY Browser ⌄` title row above
the workspace picker, pushing the picker down a full 30 px and leaving
the traffic lights stranded over the title text. macOS already advertises
the app name in the menubar, so the in-window title is dead weight.
Drop the title row, reserve `TRAFFIC_LIGHT_RESERVE = 76 px` at the start
of the picker row instead, and recompute the disclosure anchor from the
new geometry. Traffic-light Y from main.rs (`SHELL_INSET + 22`) lines
up with the new picker row's vertical center on the first frame, so the
anchor solver sums one fewer row.
`SyncConnectionState` was a one-variant enum (`SignedOut`), so the
Sync page rendered "Local-only · sign-in coming soon" even after the
bearer token landed on disk and the upload thread completed. The
state machine now mirrors the actual lifecycle.
What lands:
- `SyncConnectionState` gains `SignedIn`, `AwaitingDeviceApproval`,
`SyncReady { last_synced_at_secs }`, `SyncError { message }`.
`SyncObjectState::Synced` joins the per-object enum so individual
rows can advertise "Synced" once a successful upload lands.
- `BrowserCore` stores the current `SyncConnectionState` and exposes
`set_sync_connection_state`. `sync_status` now propagates the live
state into the snapshot the UI reads.
- `ElyShell::probe_initial_sync_state` inspects
`<profile_data>/sync/bearer.token` synchronously at construction
so the first render of the sync page is honest about whether the
user is signed in.
- A `std::sync::mpsc` channel ferries upload outcomes from the
off-thread worker back to the shell; the existing 8 ms tick
drains it and stamps `core.set_sync_connection_state` with the
freshest result. The UI now shows "Signed in · awaiting first
sync", "Synced · last upload Xm ago", "Sync error · …", and the
worker-special "Signed in · waiting for device approval" when the
server returns `device_not_approved`.
Dark mode was persistent in `AppearanceSettings` but never reached any
paint code: every call site in the shell read `colors::INK` etc. as a
`pub const u32`, so toggling `ThemeMode::Dark` mutated state nothing
sampled. Root-cause fix is to invert the contract — the design-system
exports functions that resolve through a thread-local `Mode`, and the
GPUI render impl sets that mode each frame.
What lands:
- `ely_design_system::colors::Mode` + thread-local + `set_mode` /
`mode` accessors. Every ink shade, glass surface, stroke, divider,
hairline, canvas, success / error chip now picks the warm-dark
counterpart when the active mode is `Mode::Dark`.
- `ElyShell::render` resolves `ThemeMode::System` against
`Window::appearance()` and pushes the mode before traversing the
tree, so widgets lower down read the right shade without owning a
`Mode` parameter.
- `render_wallpaper` + `panel_bg` now branch on `colors::mode()` so
the gradient base, panel tint, and overlay highlights flip to
warm-graphite when dark mode is active.
- Mechanical conversion across 687 call sites in 55 files from
`colors::FOO` constants to `colors::foo()` accessors. The
`Theme` / `ELY_THEME` const surface (unused outside the design
system) is removed; the function surface is the new contract.
Now that `LiveRuntimeWorker` does all the blocking IPC off the UI
thread, the 16 ms shell tick is no longer the bottleneck — drop it to
8 ms (≈ 125 Hz) so a 120 Hz display can present a fresh Servo frame
between every refresh. The worker queue still coalesces, so doubling
the rate does not double the wire traffic.
Replace `fade_in`'s linear ramp with an ease-out cubic so panel /
overlay reveals decelerate the way the design tokens promise instead
of cutting in abruptly at the end. Unit-tests pin the curve shape so a
future refactor that wires in a different easing function won't
silently revert to linear.
Wire `SyncEngine::upload_bytes` to a Settings → Sync button:
- `BrowserCore::build_sync_snapshot_bytes` serialises the user's
bookmarks on the UI thread (cheap, synchronous).
- `ElyShell::trigger_cloud_sync_upload` resolves the active
profile data dir, spawns a dedicated `ely-sync-upload` thread,
and lets the engine run the blocking HTTP round-trip there so
the GPUI render loop never stalls on the network — the same
invariant the Servo IPC worker enforces.
- Outcomes go through `tracing` on the `ely::sync` target. Users
drop a Better Auth bearer token into
`<profile_data>/sync/bearer.token` to opt in; without one, the
engine reports `SignedOut` and the click is a no-op.
The Better Auth handshake + device-approval UX still need their
own UI passes; this lands the data-plane plumbing so those pieces
slot in without re-architecting the snapshot path.
Add per-profile sync orchestration to `ely_browser_core`:
- `SyncEngine::for_profile_dir` loads / generates the persistent
device identity under `<profile_data>/sync/device.json` and reads
the bearer token from `<profile_data>/sync/bearer.token`.
- `install_bearer` accepts (or clears) the Better Auth session
token; everything else stays inert until a token is on disk.
- `upload_now(&BrowserCore)` serialises the user's bookmarks into a
stable JSON snapshot, ships it via `SyncApiClient::upload_snapshot`,
and remembers the resulting snapshot id / logical clock / device
for the UI to surface.
- `BrowserCore::visible_bookmarks_for_sync` returns a read-only view
the engine can iterate without touching the in-memory state.
The shell / settings-page wiring that calls `upload_now` ships
separately so this commit stays a pure model-layer change with no
runtime behaviour difference until the UI plugs in.
Build the Rust counterpart to `ely-browser-cloud`: a Bearer-token
authenticated HTTP client with the JSON wire types for the worker's
device + snapshot routes.
What lands:
- `BearerToken` + `BearerTokenStore` so Better Auth sessions persist
per profile data dir with atomic rename writes.
- `DeviceIdentity` (UUIDv7 + Ed25519-shaped public key, persisted
alongside the token so the worker keeps the same `device_id` across
restarts).
- `SyncApiClient` with `register_device`, `list_devices`,
`upload_snapshot`, and `download_snapshot` over `ureq`, mapping the
worker's strict error envelopes onto typed `SyncClientError`s.
- `SnapshotPayload` enforces the worker's 10 MiB / SHA-256-hash
contract before the wire encode, so callers fail fast.
Out of scope for this commit: the BrowserCore integration that swaps
snapshots in and out, and the in-app Better Auth + device-approval UX.
Those land in subsequent commits — `cloudflare/src/api_controls.ts`
rejects sync from devices that aren't already approved, so first-use
also requires a one-shot D1 approval until that path exists in the UI.
Servo already publishes the live page title in every `LiveFrameReport`
but the renderer was dropping it on the floor — tabs that navigated
away from `ely://new-tab` kept showing "New Tab" forever, and there
was no favicon visible anywhere in the sidebar.
Add `BrowserCore::set_tab_title` and switch `set_tab_favicon_key` to
return `Ok(true)` only when the value actually changed; both methods
mirror the new value into the matching history entry so the History
page stays in lockstep. Derive the canonical `/favicon.ico` URL from
the loaded URL on `UrlText` and store it as the tab's `favicon_key`.
In the surface layer, every Ready frame now emits a
`WebSurfacePageMetadata` change alongside any `WebSurfaceUrlChange`,
and the controller applies title + favicon URL together. Render the
sidebar tab row's favicon via GPUI's HTTP image loader (falling
through to the URL-derived glyph for `ely://` pages, file URLs, and
hosts without a /favicon.ico endpoint).
Root cause of the post-tab lag: the GPUI 16 ms timer was calling
`WebSurfaceRuntime::ensure_tab` and `tick` on the UI thread, and each
call did a synchronous `serde_json` write plus `read_line` against the
Servo sidecar over stdin/stdout. With even one visible tab, every
frame stalled on cross-process IPC.
Introduce `web_surface_worker.rs` — a per-profile worker thread that
owns the `ServoLiveClient`, drains a coalescing request queue
(latest Ensure/Poll per tab wins, no unbounded growth), and ships
results back through a `std::sync::mpsc` channel. `WebSurfaceRuntime`
now submits work non-blockingly and drains responses in `tick`; the
UI thread never blocks on the sidecar.
Adjacent in-flight cleanup riding along: hardware IOSurface
rendering-context completion (sidecar `live_protocol`,
`hardware_rendering_context`, GPUI BGRA surface shader), CSS viewport
size + device pixel ratio plumbing into `ServoLiveFrame`, and the
Send opt-ins for `CVPixelBuffer`-bearing types so frames can cross
the thread boundary.
`webview.paint()` dispatches a render command to Servo's paint thread
asynchronously, so the subsequent `read_to_image()` raced the paint
thread and reliably returned cleared-white pixels on data: URLs (T10.8).
Clear `has_pending_frame` before dispatching paint, then spin the Servo
event loop until `notify_new_frame_ready` re-arms it or 32 ms elapse
(override via `ELY_PAINT_BARRIER_MS`). Bench: software path now ships
real RGBA bytes for 60 frames instead of all-white.
Boots release ely_app, waits 8s, screencaptures full screen, verifies:
- file >10KB, dims >100x100 via sips
- center 128x128 patch not ~white (via sips crop + stdlib PNG decode)
- stderr captured to /tmp/ely-verify-stderr.log on crash
Runs idempotently (kills stale ely_app on entry + trap cleanup on exit).
No new deps; pure bash + macOS sips + /usr/bin/python3 stdlib.
First real run: PASS, screenshot /tmp/ely-verify-20260511-005449.png,
center RGB ~(243,240,236) — app chrome paints, but web surface still
shows the GPUI welcome panel (no Servo content) — exactly what T15's
paint barrier should fix.
T10.1: with the vendored `HardwareOffscreenContext` (048c5df) and the
host-level kind dispatch (a7d3e89) in place, the sidecar binary
still ignored the rendering context kind — every spawn was wired to
the software path regardless of how the host process was built. This
commit threads the choice end-to-end:
* `ely_servo_sidecar` learns a `--rendering-context [software|
hardware]` flag on its `live` subcommand. `LiveArgs` carries
the parsed `RenderingContextKind` (defaulting to `Software` so
existing invocations stay bit-identical) and `live.rs::run_live`
routes it through to `SoftwareServoHost::new_with_config_dir_and_kind`.
Unknown values produce a typed
`SidecarArgsError::InvalidRenderingContext`; a missing value
after the flag produces the existing `MissingArgumentValue`.
* `ely_app` reads `ELY_SERVO_RENDERING_CONTEXT` (with values
`software` / `hardware`, case-insensitive) and, if set, appends
`--rendering-context VALUE` to the sidecar command line.
Unset or unrecognised values fall through to the sidecar's own
software default — a stale env var or a typo never breaks the
browser startup. The sidecar arg parser is the source of truth
for legality of explicit values; the env helper only gates
which values reach it.
* Five new unit tests in `args::tests` pin the new parse paths:
default-is-software, explicit-software, explicit-hardware,
bogus-value-rejected, missing-value-rejected. Run via
`cargo test -p ely_servo_host --features servo-engine --bin
ely_servo_sidecar` and now hit alongside the five existing
snapshot tests for 10 passes.
End-to-end perf expectation: with the sidecar binary built using
`--features servo-engine,hardware-render` and the env var set to
`hardware`, every spawned sidecar webview rasterises through the
real GPU adapter (via the vendored
`HardwareOffscreenContext`/surfman/CGL chain on macOS). The host
still reads back RGBA into a `Vec<u8>` for the existing pipe
protocol; the IOSurface zero-copy bridge that deletes that
read-back is T10.2–T10.5 in docs/t10-iosurface-plan.md and lands
in subsequent commits.
cargo test --bin ely_app: 120 passed, 0 failed, 2 ignored.
cargo test -p ely_servo_host --features servo-engine --bin ely_servo_sidecar: 10 passed.
cargo test -p ely_servo_host --features servo-engine --test sidecar: 9 passed.
cargo test -p ely_servo_host --features servo-engine,hardware-render --test hardware_rendering_context: 1 passed.
`HardwareOffscreenContext` was vendored in 048c5df but the host's
per-webview `new_rendering_context` still hard-wired
`servo::SoftwareRenderingContext`. This commit threads a
`RenderingContextKind` enum through the host so existing call sites
keep their software path, and new callers can opt into the hardware
path through `SoftwareServoHost::new_with_config_dir_and_kind(...)`.
Three changes, kept tightly scoped:
* `runtime.rs` gains a public `RenderingContextKind { Software,
Hardware }` enum and a new constructor that takes it. The
existing `new` and `new_with_config_dir` keep their signatures
and default to `Software`, so the sidecar binary and the
integration tests pick up zero behavioural change. The
private `new_rendering_context` moves from a free function to a
`&self` method so it can read `self.rendering_context_kind` and
dispatch — `Software` constructs `SoftwareRenderingContext` as
before, `Hardware` constructs the vendored
`HardwareOffscreenContext`. When the `hardware-render` feature
isn't compiled in, the `Hardware` arm returns
`ServoHostError::HardwareRenderUnavailable` instead of silently
falling back; the new constructor also rejects the request
up-front before touching the global Servo runtime flag.
* `error.rs` gains `HardwareRenderUnavailable` so the wrong-feature
path is a typed error, not a panic.
* `lib.rs` exports `RenderingContextKind` alongside
`SoftwareServoHost` so downstream code (next commit will be the
sidecar's `--rendering-context` CLI flag and the live.rs
plumbing) can name the variant directly.
This is purely an extension point — no existing call path changes,
no existing test asserts on the new enum. The next commit will add
the sidecar CLI flag and wire `live.rs::run_live` to pass the kind
through to the host so users can pick the path at startup. The
follow-up commits then extract the IOSurface from the hardware
surfman surface and bridge it across the IPC channel to GPUI's
Metal renderer, deleting the host-side `Vec<u8>` from the per-frame
hot path entirely (full plan in docs/t10-iosurface-plan.md).
cargo test -p ely_servo_host --features servo-engine --lib: 2 passed.
cargo test -p ely_servo_host --features servo-engine --test sidecar: 9 passed.
cargo test -p ely_servo_host --features servo-engine,hardware-render
--test hardware_rendering_context: 1 passed.
cargo test --bin ely_app: 120 passed, 0 failed, 2 ignored.
The first concrete step toward the T10 IOSurface zero-copy path
(plan in docs/t10-iosurface-plan.md). Before this commit the sidecar
process could only use `SoftwareRenderingContext` — CPU rasterising
plus an 8 MB RGBA readback per 1080p frame is most of where scroll
latency comes from after the file pipe (a80d039), Vec clone
(e02c0fd), texture dedup (7f3b8b4), and hash swap (3f184ee) have
all landed.
The blocker is purely architectural: `servo-paint-api 0.1` exposes
`OffscreenRenderingContext` only as a child of
`WindowRenderingContext`, which requires a `DisplayHandle +
WindowHandle`. The sidecar has no window. The underlying
`SurfmanRenderingContext` glue *can* drive a hardware adapter
against a `SurfaceType::Generic` offscreen surface, but its
constructor is private. Until the upstream PR lands, this commit
vendors the minimal slice of that glue into `ely_servo_host`:
* `HardwareOffscreenContext::new(size)` uses
`Connection::new() → create_adapter()` (real GPU, not the
software adapter) and a `SurfaceType::Generic` offscreen
surface. On macOS the surfman CGL backend backs that surface
with an `IOSurface` — exactly the thing the IOSurface bridge
in subsequent commits will reach for.
* Implements `servo::RenderingContext` so it slots into
`ServoBuilder::rendering_context` wherever the existing
`SoftwareRenderingContext` does, with no other Servo-side
knowledge.
* Scope deliberately narrow: only the methods Servo's headless
readback actually calls. `create_texture` /
`destroy_texture` / `connection` / `refresh_driver` fall
through to the trait's `None` defaults. `read_to_image` inlines
the upstream `Framebuffer::read_framebuffer_to_image` helper so
we don't reach for a private helper that may change shape.
* No `RawWindowHandle` and no `RefreshDriver` — both belong to
paths the headless sidecar doesn't take.
The whole thing is feature-gated on `hardware-render`. Default
builds compile zero new lines; the additional surfman / gleam /
glow / euclid / image / log deps are all `optional = true`. Sidecar
binary still uses `SoftwareServoHost` until a follow-up commit
threads the new context in behind a CLI flag.
A smoke test at `tests/hardware_rendering_context.rs` constructs
the context. On a host with a real GPU it returns `Ok`; on a no-GPU
CI host it logs the surfman cause and reports `ok` rather than
failing the suite — the test is guarding the wiring, not the
hardware availability. On this Mac it constructs cleanly.
The `expect_used` / `unwrap_used` workspace lints are honoured —
fallible reads return `None` instead of panicking, no `.expect()` /
`.unwrap()` survives in the vendored body. The two `unsafe` blocks
(loading GL function pointers via surfman's `get_proc_address`) are
the same blocks upstream uses, with `#[expect(unsafe_code)]` to
override the workspace `unsafe_code = "deny"` lint locally.
cargo test --bin ely_app: 120 passed.
cargo test -p ely_servo_host --features servo-engine,hardware-render
--test hardware_rendering_context: 1 passed (constructs cleanly).
Pre-existing `manages_real_servo_webview_lifecycle` failure on
servo.org is unrelated (reproduces on b8795bf without this change,
already documented in T7's commit history).
Two T10-flavoured changes in one commit, each independently ship-able
on its own:
1. `web_surface_frame::rgba_hash` switches from std's
`DefaultHasher` (SipHash13, ~1.5 GB/s) to `ahash::AHasher`
(~10 GB/s). At 1080p (8 MB per frame) the dedup key drops from
~5 ms to ~0.8 ms per cache-miss frame, returning roughly 25 % of
the 16 ms scroll budget that was being spent hashing the
newly-arrived RGBA payload. ahash was already in the dependency
graph transitively via hashbrown, so this only adds a direct
`ahash = "0.8"` line and one Cargo.lock entry.
2. `docs/t10-iosurface-plan.md` records the full architectural
roadmap for the actual zero-copy path that supersedes the
software-pipe pipeline: `OffscreenRenderingContext` against a
hardware surfman adapter, IOSurface-backed surface on macOS,
mach-port handoff to the GPUI process, MTLTexture import as an
external sampler. The document explains why each currently
shipped commit (`840255f`, `a80d039`, `e02c0fd`, `7f3b8b4`, plus
this hash swap) is a stepping stone that eventually deletes
itself once the IOSurface path lands, and names the upstream
API gap in `servo-paint-api` that blocks step 2.
cargo test --bin ely_app: 120 passed, 0 failed, 2 ignored.
Hash collision probability remains ~1 in 2^64; AHash uses the same
keyspace as the previous SipHash13.
Three new diagnostics narrow T7 to test-mode infrastructure:
1. `diagnose_t7_hitbox_reachability_heatmap` (ignored, --nocapture):
builds the real ElyShell, navigates to https://example.com/, then
sweeps a 6×6 grid across the full 1920×1080 window dispatching
mouse_move at each cell and recording hover_point. Result: **0 of
36 grid cells trigger hover_point** — input_overlay's listener
misses every position in the window, not just the viewport center.
2. Same test then dispatches a single MouseDown at the viewport
center and reads back `focus_handle.is_focused`. Result: **true**.
The root div's `track_focus` MouseDown bubble handler fires on
the very same event. GPUI dispatch IS working at the root hitbox
(`.size_full()`).
3. `baseline_overlay_after_gpui_component_init_receives_click` and
`baseline_overlay_with_input_state_construction_receives_click`:
replicate `gpui_component::init` and `InputState::new` +
`subscribe_in` in isolation. Both still pass — neither breaks
hit_test for an occlude div.
Together these isolate the failure to *something `ElyShell::new`
configures that interacts badly with `TestAppContext`'s simulated
executor*, after every individual ingredient passes. The user has
already confirmed (in the previous round) that "click works" after
the layout fix in 840255f lands in the real binary; the T7 red
guard is reproducing a test-mode quirk, not a production regression.
The red guard stays in the suite as documentation. Its `#[ignore]`
reason now records the diagnostic outcome so the next reader doesn't
re-walk the same bisect. Two paths forward (recorded in the
attribute):
(a) reproduce the bug outside TestAppContext and file upstream
(b) route web canvas input through the root div + a viewport-bounds
gate, which sidesteps hit_test for input_overlay entirely
(b) is structurally more complex than it looks because backdrops
(workspace disclosure, hidden-sidebar overlay) sit in z-order in
front of input_overlay and don't `stop_propagation` — implementing
a clean root-level fallback requires teasing apart the cases. Not
done in this commit.
cargo test --bin ely_app: 120 passed, 0 failed, 2 ignored.
cargo test --bin ely_app -- --ignored: 1 fail (T7 red) + 1 pass
(diagnostic, which just prints the heatmap and reports findings).
`LiveOutcome::frame` was carrying the rendered bytes as a fresh
`Vec<u8>` cloned out of `RenderedFrame::rgba_bytes()`. At 60 fps
on a 1080p canvas that was an extra 8 MB allocation + memcpy per
frame on top of the clone `host.last_rendered_frame()` already
paid for. `LiveOutcome` now carries the owned `RenderedFrame`
directly, and `write_outcome` writes its `rgba_bytes()` slice
straight to stdout — same single-clone cost as the host already
incurred, no second allocation.
Small Karpathy-style follow-up from the T8 review ("the host
still copies its rendered buffer to a transient Vec<u8> before
write_all; expose &[u8] straight to write_all once the profile
shows that allocation in the top five"). Profile data is still
deferred (T9 was marked vibe-benchmarking until T10 lands a real
spec), but removing the cheap clone is structurally cleaner and
makes the dispatch path one allocation lighter regardless.
cargo test --bin ely_app: 118 passed, 0 failed, 1 ignored.
cargo test -p ely_servo_host --features servo-engine --test sidecar: 9 passed.
T13 (the still-red `user_click_in_rendered_web_canvas_reaches_input_pipeline`
guard) is now backed by five bisect probes that each reproduce one
slice of the real ElyShell render tree and assert the listener combo
still receives a simulated click. Every probe passes:
* baseline_overlay_div_receives_simulated_click — GPUI primitives
* baseline_overlay_with_full_listener_combo_receives_click — exact
listener combo (on_mouse_down + capture_any_mouse_up +
on_mouse_move + on_scroll_wheel) on a single .occlude() div
* baseline_overlay_under_overflow_hidden_relative_receives_click —
the relative + overflow_hidden wrapper render_web_surface uses
* baseline_overlay_under_full_elyshell_wrapper_chain_receives_click —
root → absolute-flex container → main-pane → content-wrapper
→ surface-wrapper chain
* baseline_overlay_with_canvas_sibling_receives_click — adds the
canvas viewport_tracker sibling
* baseline_overlay_with_entity_update_in_mouse_down_receives_click —
on_mouse_down's bubble fires entity.update (auto-notify) before
MouseUp dispatches
* baseline_overlay_under_root_with_track_focus_receives_click —
the full chain wrapped in a root div with track_focus +
on_mouse_up(Left, bubble)
The red guard meanwhile reports `hover_point = None` after
`simulate_mouse_move`, meaning input_overlay's `on_mouse_move`
also never fires — so the failure mode isn't capture-specific.
ALL of the overlay's listeners share the same `hitbox.is_hovered`
check, and it returns false in the real ElyShell tree but true in
every probe. Whatever the difference is, it is in
`ElyShell::new`'s setup (BrowserCore, Entity<InputState>,
SliderState, subscriptions, `start_external_web_surface_timer`'s
detached task) or in the `sync_address_input` call that
`navigate_active_tab` runs through Input's `set_value` — none of
which the probes touch.
The probes stay in the suite as both documentation (they encode
what is *not* the bug, narrowing the search for the next round)
and as regression guards (a future change that breaks them is a
real new regression in plain layout, not in the still-elusive
ElyShell-specific bug). Tests run as 118 passed + 1 ignored;
`cargo test -- --ignored` continues to fail with the same T7 red
guard. T13 is intentionally still in progress.
WebSurfaceFrame::from_parts was unconditionally calling
Arc::new(RenderImage::new([image::Frame::new(image_buffer)])) on
every live frame, even when the underlying bytes were
byte-for-byte identical to the previous frame. At 60 fps on a 1080p
canvas that was ~960 MB/s of host-side cloning plus a fresh GPUI
texture allocation on every tick — the bottleneck Linus + Karpathy
+ Jony flagged as the next material step after dropping the
file-system pixel pipe (a80d039).
A thread_local single-slot cache in web_surface_frame.rs now keys
on a 64-bit DefaultHasher of the raw RGBA bytes. On a cache hit
the existing Arc<RenderImage> is reused; on a miss the buffer is
built once, stored, and returned. Steady-state idle pages stop
churning the GPUI texture pool entirely. Hash collisions are
1 in 2^64 — if that ever becomes a real worry, the cache key can
be widened to length + a sample of bytes before paying the full
memcmp; not worth doing today.
The T10 red guard
(identical_live_frames_share_render_image_arc) drops its
#[ignore] attribute outright per the contract it documented.
The T7 red guard (user_click_in_rendered_web_canvas_reaches_input_pipeline)
remains ignored — it's a separate diagnosis tracked under T13.
cargo test --bin ely_app: 113 passed, 0 failed, 1 ignored
(was 112+2, T10 guard went green).
cargo test --bin ely_app -- --ignored: 1 failed (only T7 click
pipeline remains red).
Follow-ups (left for the endgame T10 IOSurface path):
* a per-tab cache would prevent multi-tab switching from
thrashing the single slot; defer until a real multi-tab
scroll benchmark shows it matters.
* the endgame is OffscreenRenderingContext + IOSurface so the
GPU texture itself is the source of truth and the host-side
Vec<u8> + ImageBuffer + RenderImage allocation chain
disappears entirely.
Every `WebSurfaceFrame::from_live_frame` today calls
`Arc::new(RenderImage::new([image::Frame::new(image_buffer)]))`
unconditionally — even when the underlying RGBA bytes are
byte-for-byte identical to the previous frame. At 60 fps on a 1080p
canvas that is roughly 960 MB/s of host-side cloning + a fresh
GPUI texture upload, and the roundtable agreed it is the next
material bottleneck after the file-system pipe (a80d039).
The contract this test pins is the cheapest invariant we can hold
against today's `SoftwareRenderingContext`: two `ServoLiveFrame`
inputs whose `rgba_bytes` are bit-identical must produce the same
underlying `Arc<RenderImage>` instance. Today they do not; the
ignored run confirms two distinct pointer values for back-to-back
identical inputs.
The fix has two recognised shapes. The interim shape lives entirely
in `WebSurfaceFrame::from_parts`: remember the previous frame's
bytes (hash or pointer-eq) and reuse the existing `Arc<RenderImage>`
when they match. The endgame shape removes the host-side image step
entirely — `OffscreenRenderingContext` + IOSurface — at which point
the assertion becomes meaningless and is replaced by a frame-time
budget. Whichever lands first, the fix commit MUST delete the
`#[ignore]` attribute outright; toggling its reason is a broken
contract.
To call `WebSurfaceFrame::from_live_frame` from a unit test without
spawning a real sidecar process, `ServoLiveFrame` gains a
`#[cfg(test)] pub(crate) fn for_test(...)` constructor that wraps
the existing private `from_parts` with realistic defaults. No
production path uses it.
cargo test --bin ely_app: 112 passed, 0 failed, 2 ignored.
cargo test --bin ely_app -- --ignored: 2 failed (expected RED:
T7 click pipeline + T10 texture re-upload).
After 840255f put the input overlay on-screen and a80d039 dropped
the file-system pixel pipe, the two harness baselines confirm:
* `.occlude() + capture_any_mouse_up` works in TestAppContext
(baseline_overlay_div_receives_simulated_click)
* `input_overlay`'s exact listener combo works in isolation
(baseline_overlay_with_full_listener_combo_receives_click)
…and the layout regression guard confirms the overlay is now drawn
inside the visible window. Yet running a real ElyShell, navigating
to https://example.com/, and dispatching a real MouseDown/MouseUp at
the geometric center of the measured viewport STILL leaves
WebSurfaceStore.click_point as None. The capture phase listener is
being eaten somewhere strictly inside the real ElyShell widget tree.
`user_click_in_rendered_web_canvas_reaches_input_pipeline` encodes
this contract in user terms — "click on the rendered page and the
input pipeline records it" — without naming a GPUI mechanism. The
test is marked `#[ignore]` so the rest of the suite stays green; the
attribute carries the full reproduction note so a reader picking the
ticket up later doesn't have to rediscover what we already know
(layout + pixel-pipe both clean, listener combo clean, suspicion now
on sibling z-order / ancestor stop_propagation / overflow_hidden
content_mask clipping the overlay's hitbox).
The fix commit must DELETE the attribute outright; toggling the
ignore reason is a broken contract. Running
`cargo test -- --ignored user_click_in_rendered_web_canvas_reaches_input_pipeline`
today reproduces the failure with click_at = (1106, 567) inside
viewport_bounds (309, 71, 1594, 992).
cargo test --bin ely_app: 112 passed, 0 failed, 1 ignored.
cargo test --bin ely_app -- --ignored: 1 failed (expected RED).
Every live frame was round-tripping through the local file system:
the sidecar called `fs::write(rgba_out, frame.rgba_bytes())` in
`poll_frame`, the JSON response carried `rgba_path`, and the main
process turned around and called `fs::read(rgba_path)` to lift the
bytes back into a `Vec<u8>`. At 1080p that is 8 MB of syscall +
memcpy + page cache traffic per frame; at 60 fps it dwarfs every
other cost in the pipeline and shows up as scroll/zoom jank the user
can feel before any other bottleneck.
Replace it with a same-pipe binary protocol. The sidecar writes the
JSON `LiveResponse` line as before, then writes the raw RGBA frame
bytes on the same stdout immediately after the trailing `\n`. The
client `read_line`s the JSON, parses `rgba_byte_count` from the
header, and `read_exact`s exactly that many bytes from the same
`BufReader<ChildStdout>` (the buffered reader drains its own buffer
before pulling from the child). No tmpfs directory, no
`fs::remove_dir_all` on drop, no `rgba_path` field, no per-frame
filename plumbing.
Boundary defence on the client side: the header's `rgba_byte_count`
is cross-checked against `width * height * 4` before any allocation
or `read_exact`. A buggy or compromised sidecar can no longer ask
the GPUI process to allocate an arbitrarily large buffer or park on
`read_exact` for a payload that will never arrive.
The sidecar process boundary stays exactly where it was; only the
pixel transport between the two processes changes. The `one-shot`
sidecar binary path (used by `tests/sidecar.rs` and PRD smoke tests)
still writes to its CLI-supplied `--rgba-out` path — those tests
were untouched and continue to pass 9/9.
cargo test --bin ely_app: 112 passed.
cargo test -p ely_servo_host --features servo-engine --test sidecar: 9 passed.
Follow-ups deferred to the profile step (T9):
* the host still copies its rendered buffer to a transient
`Vec<u8>` via `frame.rgba_bytes().to_vec()` before write_all;
expose `&[u8]` straight to `write_all` once the profile shows
that allocation in the top five.
* `poll_frame` calls `snapshot` twice per iteration; harmless
under the software renderer but worth folding into a single
snapshot once we have numbers.
* raw memcpy bandwidth is still ~480 MB/s at 60 fps 1080p; the
GPU-side fix (T10: OffscreenRenderingContext + IOSurface
zero-copy) is the next material change.
A GPUI harness boots a real ElyShell, navigates to an external URL, and
asks the input_overlay's sibling canvas tracker where it laid out. On
main before this change the canvas reports
Bounds { origin: (309, window_height - 17), size: (W - 326, content_h) }
i.e. the overlay's top edge sits at the very bottom of the visible
window. Every user click in the visible area lands above (or beside)
the overlay; the on_mouse_down + capture_any_mouse_up listeners never
even see the event because the hitbox is off-screen. Twelve commits
chased focus/coords/outcome enums on the sidecar side while every click
in the live shell hit empty space.
Root cause: in render_web_surface the rendered web image (img / loading
div / error page) was a non-absolute child of a `.relative().size_full()`
wrapper. The non-absolute child claims `size_full` block-flow height
inside that wrapper, which made the wrapper's intrinsic height
content_height + content_height. The two `.absolute().size_full()`
siblings (viewport_tracker, input_overlay) then sized against that
inflated parent and were positioned in the bottom half — exactly
content_height below where they were supposed to be.
Fix: keep the relative wrapper as the layout owner of the panel slot
(size_full, overflow_hidden, min_w_0) and put the rendered image into
an absolute `inset_0` child of its own. viewport_tracker and
input_overlay stay as absolute siblings. With the image out of in-flow
the wrapper sizes to its parent and the overlay's hitbox lands at
y = top of content area (71 in a 1080-tall window) instead of
y = window_height - 17.
GPUI test harness (`gpui_harness_tests.rs`) is the holdout set:
- `baseline_overlay_div_receives_simulated_click` proves GPUI's
occlude + capture_any_mouse_up primitive works under TestAppContext.
- `baseline_overlay_with_full_listener_combo_receives_click` proves
the exact listener combo render_input_overlay uses works in
isolation.
- `ely_shell_external_canvas_lays_out_inside_window` boots a real
ElyShell, navigates, and asserts the overlay's measured bounds fit
inside the visible window. Without the fix above, this test trips
on bounds extending below the window bottom.
The three new store-layer tests in web_surface_tests.rs pin per-tab
isolation, zero-delta short-circuit, and resize-mid-drain decoupling
invariants the harness work flushed out.
ely_app picks up gpui's test-support feature as a dev-dependency so the
harness can use VisualTestContext + simulate_mouse_*.
cargo test --bin ely_app: 112 passed (was 108 + 4 new harness/store tests).
Remaining work (not in this commit): even with the layout fixed, the
harness shows MouseUp's capture_any_mouse_up still doesn't fire on the
ElyShell tree, while MouseDown's bubble does. Some sibling/ancestor
listener in the live shell is eating the MouseUp capture phase that
the standalone listener-combo baseline does not. Tracked separately.
Servo's hit-test silently absorbs notify_input_event on a hidden or
unfocused WebView. Today show()+focus() are called at creation and on
the first-navigate rebuild, but every later sibling-WebView creation
also calls focus() — silently stealing focus from the foreground tab.
load() (later navigates), resize, set_page_zoom, and paint never
re-focus, so a click on the visible tab can land on an unreachable
WebView and disappear.
Move the invariant from a state spread across creation/navigation/
tab-switching into a property of the dispatch path itself: a private
webview_for_input(id) helper re-asserts show()+focus() and returns
the WebView; click/hover/drag/touch_tap/scroll/type_text all go
through it. The cosmetic show/focus calls in create_webview_in_context
and the navigate-rebuild branch stay (first-frame paint), now annotated
to point to webview_for_input as the input-path owner.
Not a complete fix on its own. focus() goes through constellation_proxy
asynchronously (servo crate webview.rs:352), so debug_assert!(focused())
right after focus() would race the constellation — doc comment is the
only guard. Sidecar integration tests pass 9/9 but only exercise
single-WebView sessions; multi-tab focus stealing on Google / YouTube /
Twitter still needs human verification in the live shell. Linus's
critique of the GPUI-side data structure (PerTabSurface Option-as-queue
+ Ensure bundling config+input+frame) is a separate layer untouched
by this change.
Pre-existing tests/software_host.rs::manages_real_servo_webview_lifecycle
already times out on https://servo.org/ with state=Complete but
has_pending_frame=false on b8795bf without this change, so unrelated.
bool returns on the five record_* surface inputs collapsed nine real
outcomes into one bit. The cascade we found in this round — silent
click drop because viewport_bounds wasn't measured yet, then
keyboard_focus stays None, then typing also "fails" — looked like
three independent symptoms but was one root cause hiding inside
that bit. Replace the bool with a #[must_use] WebSurfaceInputOutcome
enum so each rejection names itself at the call site.
Variants map 1:1 to real return points in web_surface.rs:
Applied / NoChange / Buffered — three distinct success-ish states
the controller already needed to disambiguate (only Applied notifies)
DroppedInvalidBounds — geometry rejected zero/NaN viewport
DroppedNoViewportBounds — input arrived before the viewport tracker
DroppedOutOfBounds — window position outside the viewport rect
DroppedZeroDelta — wheel rounded to zero device px
DroppedEmptyText — empty record_typed_text
DroppedNoKeyboardFocus — type without a prior click
DroppedFocusMismatch — focus belongs to another tab/url
Behavior preserved: Applied is the only notify trigger, matching the
old `true` semantics. Three new negative-path tests (no_viewport_bounds,
zero_delta, no_keyboard_focus) lock the named drops so a future
regression surfaces as a wrong variant in tests instead of a missing
repaint. cargo test ely_app --bin ely_app web_surface: 17 passed.
GPUI delivers logical (CSS) pixels but Servo expects device pixels.
On a 2x Retina display the page rendered at half resolution and every
click landed in roughly the upper-left quadrant of the page — the
second independent root cause that surfaces immediately after T1's
show()/focus() lets input reach Servo at all.
Single conversion boundary inside web_surface_geometry.rs:
viewport_dimension, scroll_dimension, and click_coordinate each
multiply once by window.scale_factor() before truncating to integer
device pixels. positive_scale_or_one() guards against a zero/negative/
NaN scale_factor reaching the arithmetic — falling back to 1.0 keeps
coordinates valid even if the platform reports nonsense (system
boundary validation per CLAUDE.md, not internal trust).
scale_factor is plumbed through controller and view layer, sourced
from window.scale_factor() at every record_* boundary so all four
inputs (click, scroll, hover, viewport) stay in sync. Existing tests
pin the 1.0 path; new retina_scale_factor_doubles_every_input_coordinate
locks the 2.0 path against a future regression.
cargo test ely_app --bin ely_app: 104 passed (was 103 + new test).
Root cause: Servo's WebView is hidden+unfocused by default. notify_input_event
on a hidden WebView runs paint() hit-test, which returns no hit, and Servo
silently absorbs the event as "already handled". Eleven prior commits all
patched the GPUI side of input forwarding while every event landed in
exactly that black hole.
Fix: webview.show() + webview.focus() immediately after WebViewBuilder::build
in create_webview_in_context, and again in navigate() only on the
should_create_initial_document branch (the load() branch keeps existing
visibility+focus, otherwise a background tab finishing navigation would
steal focus from the foreground tab — Linus correctness ask).
Also unblocks the sidecar binary build, which had been frozen at the
May 9 13:53 stale binary because servo-engine feature was broken on two
fronts:
- ServoHost trait was missing the hover() method that the SoftwareServoHost
impl declared (regression from "Plug three holes" commit eb58ce7).
- Servo SDK renamed Key::Enter / Backspace / Tab / Escape / Delete /
Arrow{Up,Down,Left,Right} / Home / End / Page{Up,Down} to
Key::Named(NamedKey::*). keyboard.rs updated to match.
Sidecar binary rebuilt: 335 MB at May 10 01:44. The earlier 11 commits
were never reaching users because they couldn't recompile the sidecar
without these two upstream-API repairs.
Active and hover used the same ACTIVE_NAV_BG, so a selected row gave
no feedback when the cursor crossed it — the contrast ladder was a
single rung. Replace it with a four-state ladder shared by the home
anchor, launcher rows, and tab rows:
rest → transparent (HOVER_NAV_BG hover)
active → ACTIVE_NAV_BG (ACTIVE_NAV_BG_HOVER hover)
The choice lives in nav_row_palette(active) so the ladder cannot
silently disagree across the three callers. Close (×) becomes an
18 px circular coin (was a 16 px square) with the warm-dark hover
wash from CLOSE_HOVER_BG, shared by both row variants via a single
render_row_close_button recipe — the button now reads as something
to press, not a glyph in a square.
Hoist sidebar_chrome.rs alongside sidebar.rs to host the shared
constants/helpers and keep both files under the 500-line ceiling
(444 / 198). cargo test ely_app --bin ely_app: 103 passed.
Five PerTabSurface tests pin the input contract so future refactors
can't silently regress it:
- typed_text_enters_pending_input_after_clicked_viewport: typing
reaches the sidecar once a click establishes keyboard focus.
- scroll_delta_enters_pending_input_after_wheel: scroll deltas
combine across multiple wheel events.
- viewport_size_changes_after_stable_second_measurement: viewport
resize is debounced behind a "same size twice" guard.
- scroll_after_click_keeps_keyboard_focus_and_typed_text: the bug
fixed last commit cycle — scroll drops the buffered click point
but must keep keyboard_focus and buffered keystrokes.
- typing_without_a_prior_click_is_rejected: typing without focus
returns false, so a future refactor can't accept stray keystrokes.
No instrumentation eprintln/log added in production paths
(per CLAUDE.md NO LOGGING). cargo test ely_app --bin ely_app: 102
passed.
DISCLOSURE_TOP_PX, DISCLOSURE_LEFT_PX, and DISCLOSURE_WIDTH_PX
were three hardcoded numbers that drifted the moment anyone touched
the sidebar header layout: SHELL_INSET, header padding, picker row
geometry, or the workspace tile size. Replace them with
WorkspaceDisclosureAnchor::solve(sidebar_width) — a pure function
whose algebra is the geometric inverse of render_sidebar_header,
expressed over named layout constants (HEADER_PT, PICKER_BUTTON_SIZE,
etc.) the renderer already applies.
Picker resize now flows: snapshot → sidebar_width → solve → anchor →
render_workspace_disclosure. Three regression tests lock the default
(66, 98, 180), invariance of left/top under resize, and zero-width
clamp. cargo test ely_app --bin ely_app: 101 passed.
Eleven parallel BTreeMaps (click_points, hover_points,
pending_scroll_deltas, scroll_offsets, typed_texts, viewport_bounds,
viewport_sizes, pending_viewport_sizes, states, etc.) made every
input bug a coordination problem across 11 disjoint maps with no
compile-time guarantee they stayed in sync. Collapse them into
BTreeMap<TabId, PerTabSurface>: one owner per tab, one lookup per
input event. keyboard_focus stays at the store level because only
one tab in the window can hold focus at a time.
Public API and behavior are unchanged; the previously-fixed
"scroll keeps keyboard_focus" semantics are preserved. cargo test
ely_browser_core + cargo test ely_app --bin ely_app green.
The Moon icon previously navigated to ely://settings/appearance,
which is misleading for a button visually framed as a one-tap theme
control. Add cycle_theme_mode (System → Light → Dark → System) and
swap the icon between Sun and Moon to mirror the active state.
The profile chip in the sidebar footer used a chevron-down icon, which
universally signals "this opens an inline popover," but its handler
just navigates to ely://settings/profiles. Swap to chevron-right so
the icon honors what the click actually does.
The per-row tab close (×) button hovered with rgba(0x281e1414) — 8%
alpha — which read as no hover at all on the cream panel. Bump to
~30% alpha (CLOSE_HOVER_BG) so the hit target snaps in like Arc/Dia.
1. Scroll no longer wipes keyboard focus. Servo holds DOM focus across
wheel events; the shell was clearing keyboard_focus and typed_texts
on every scroll, so a focused input went deaf the moment the user
scrolled. Scroll still drops the buffered click point because that
coordinate is captured against the pre-scroll viewport.
2. Mouse-down hands focus to the shell's root focus handle (in
addition to mouse-up's existing click forwarding). The user can now
start typing the moment they press the page, instead of having to
first complete a click round-trip to escape the omnibar's focus.
3. Sidecar hover() honors the requesting webview_id instead of
defaulting to the first webview in the map, so multi-tab sidecars
no longer pipe every hover into tab #1.
Two issues kept the close button unusable:
- The launcher-row close button lacked flex_shrink_0, so on narrow
sidebars the title swallowed the 16 px hit target before flex laid
it out.
- The handler did select_tab(close_id) → close_active_tab(); if the
newly-selected tab routed through split-view close logic the call
silently no-op'd against the user's intent.
Add flex_shrink_0 on both launcher and tab close buttons, and route
the click through a new close_tab_by_id helper that calls
BrowserCore::close_tab(tab_id) directly.