Commit Graph
100 Commits
Author SHA1 Message Date
ZacharyZhang-NY 1c72622db8 Resolve favicons through Google's s2 service
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.
2026-05-15 21:59:30 -04:00
ZacharyZhang-NY 1e480a9f87 Update OTP test to match the new browser@elydora.com sender
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.
2026-05-15 21:45:29 -04:00
ZacharyZhang-NY 74b3de54ed Add email + OTP sign-in flow for cloud sync
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.
2026-05-15 21:32:50 -04:00
ZacharyZhang-NY 80cff6dad3 Stop the web surface from strobing on transient errors
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.
2026-05-15 21:21:14 -04:00
ZacharyZhang-NY 838f80006a Remove broken sidebar Layout cards from appearance settings
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.
2026-05-15 21:18:10 -04:00
ZacharyZhang-NY 81a75f6489 Inline the macOS traffic lights with the workspace picker
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.
2026-05-15 21:16:10 -04:00
ZacharyZhang-NY 39d10bf3d7 Convert chrome-local color constants to theme-aware accessors
The previous theme refactor only flipped `colors::*` tokens; the
~50 chrome-local `const FOO: u32 = 0xffffff…;` overlays kept their
white-glass values, so dark mode still painted a light omnibar pill,
light search field, light sidebar tab card, light command-overlay
backdrop, light sync page chips, etc.

Promote `colors::pick(light, dark)` to `pub` so component-internal
constants can reach the same thread-local mode the global palette
uses, then convert every white-glass (`0xffffff??`) and warm-dark
wash (`0x281e14??`) constant in:

- chrome/topbar (omnibar pill, hover chip)
- chrome/sidebar_chrome + chrome/sidebar (rail nav row hover/active,
  highlight border, close-button hover, unread badge)
- chrome/sidebar_header (workspace picker, disclosure card)
- chrome/command_overlay + chrome/command_rows + chrome/command_footer
- chrome/settings_layout, chrome/appearance_form,
  chrome/appearance_layout_cards, chrome/plugin_detail_view
- chrome/home/style (search field, arrow chip, pill, card, add-tile)
- internal_pages/sync (status pill, metrics card, button row)
- internal_pages/plugin_catalog
- shell/render (main pane highlight border)

Each constant becomes `fn name() -> u32 { colors::pick(<light>, <dark>) }`
where the dark counterpart preserves the original alpha but swaps the
warm-white prefix `ffffff` for warm-graphite `1f1d1b` (or the inverse:
`281e14` warm-dark wash → `f2efe9` warm-white wash). Brand accents
(`0xc96442??`, `0xffaa7733`) and the global slate backdrop keep their
single value across modes.
2026-05-15 20:22:51 -04:00
ZacharyZhang-NY 467dcb1f87 Reflect real sync state on the Sync settings page
`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`.
2026-05-15 19:58:42 -04:00
ZacharyZhang-NY 6b2578c3a8 Make every shell color token theme-aware
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.
2026-05-15 19:50:38 -04:00
ZacharyZhang-NY 5548427df3 Land 125 Hz Servo polling and eased shell transitions
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.
2026-05-15 17:19:38 -04:00
ZacharyZhang-NY 6bacb3faa8 Add Sync now button that uploads a bookmarks snapshot
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.
2026-05-15 17:15:07 -04:00
ZacharyZhang-NY a47fbdc09c Wire SyncEngine into BrowserCore
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.
2026-05-15 17:10:30 -04:00
ZacharyZhang-NY 2081c6bd88 Update Cargo.lock for ely_sync_client deps 2026-05-15 17:05:14 -04:00
ZacharyZhang-NY 7366a0dffe Add ely_sync_client crate
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.
2026-05-15 17:05:10 -04:00
ZacharyZhang-NY 3d2c3ed1bf Propagate page title and favicon into the active tab
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).
2026-05-15 16:53:32 -04:00
ZacharyZhang-NY 00a8ff1fef Apply cargo fmt across chrome and internal pages 2026-05-15 16:42:00 -04:00
ZacharyZhang-NY 90c029eddb Move Servo IPC off UI thread
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.
2026-05-15 16:41:40 -04:00
ZacharyZhang-NY f4c650c4d8 Fix Servo IOSurface orientation and resize identity 2026-05-13 10:21:50 -04:00
ZacharyZhang-NY 9212b0be24 Flip BGRA surface texture sampling 2026-05-13 09:51:59 -04:00
ZacharyZhang-NY 8238526cc1 Wait for complete live-site smoke frames 2026-05-13 04:40:14 -04:00
ZacharyZhang-NY 2302a53b39 Cover web surface live scrolling 2026-05-13 04:31:58 -04:00
ZacharyZhang-NY d4046ca4e7 Default Servo live rendering to hardware on macOS 2026-05-13 04:14:18 -04:00
ZacharyZhang-NY a743435ea1 Transfer Servo IOSurface ports over Mach 2026-05-13 04:05:07 -04:00
ZacharyZhang-NY 72f7b9761e Report Servo live IOSurface import failures 2026-05-13 03:30:55 -04:00
ZacharyZhang-NY b91f61c201 Fix live perf bench scroll page URL 2026-05-13 02:59:15 -04:00
ZacharyZhang-NY ac84a80d9e Close Servo live sessions for removed tabs 2026-05-13 02:41:54 -04:00
ZacharyZhang-NY 74731d4faa Keep loading until live content arrives 2026-05-13 02:18:48 -04:00
ZacharyZhang-NY 8fb1277ced Default Servo live rendering to software 2026-05-13 02:13:51 -04:00
ZacharyZhang-NY e7b62e2daf Scope Servo live runtimes by profile 2026-05-13 02:07:01 -04:00
ZacharyZhang-NY 05ce4afba4 Poll only visible web surfaces 2026-05-13 01:54:51 -04:00
ZacharyZhang-NY 6ce5df83a8 Skip hardware live readback 2026-05-13 01:41:58 -04:00
ZacharyZhang-NY c19e6923c3 Clean gpui harness clippy debt 2026-05-13 01:26:52 -04:00
ZacharyZhang-NY 5bdb2871ad Invert wheel deltas for Servo scroll 2026-05-13 01:20:10 -04:00
ZacharyZhang-NY 1325f043b4 Commit Servo loaded URLs to tabs 2026-05-13 01:09:39 -04:00
ZacharyZhang-NY 4cb7ff089e Wire topbar tab history navigation 2026-05-13 00:49:52 -04:00
ZacharyZhang-NY 8cd5e969dc Guard hardware surface selectors 2026-05-13 00:43:13 -04:00
ZacharyZhang-NY fec5a9b475 Move idle archiving into tab lifecycle 2026-05-13 00:38:04 -04:00
ZacharyZhang-NY 8308eae6d2 Split sidecar args tests 2026-05-13 00:34:03 -04:00
ZacharyZhang-NY 777df012e3 Validate Servo hardware frame surfaces 2026-05-13 00:29:26 -04:00
ZacharyZhang-NY 15d773b28e Prefer Cargo hardware sidecar only for workspace builds 2026-05-13 00:25:48 -04:00
ZacharyZhang-NY 9313c76743 Default Servo live rendering to hardware on macOS 2026-05-13 00:24:39 -04:00
ZacharyZhang-NY 05a7a0d67f Present Servo BGRA hardware surfaces 2026-05-13 00:22:00 -04:00
ZacharyZhang-NY bf1ebfb6fe Hold hardware live mode until BGRA presentation 2026-05-12 23:42:52 -04:00
ZacharyZhang-NY 316bf6f9a4 Split Servo live rendering modules 2026-05-12 23:38:13 -04:00
ZacharyZhang-NY 0db1caad69 Route web surface scrolls to Servo hit point 2026-05-12 23:29:40 -04:00
ZacharyZhang-NY 6f279bf3e6 T17: pixel-content smoke tests — red/blue data URLs render their actual colour through Servo 2026-05-11 01:03:52 -04:00
ZacharyZhang-NY 42b4e8c87b T15: paint barrier — pump event loop until framebuffer is consistent
`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.
2026-05-11 00:57:35 -04:00
ZacharyZhang-NY 10d77135d9 T16: screencapture verify_render script for post-T11/T12/T13 sanity
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.
2026-05-11 00:55:22 -04:00
ZacharyZhang-NY 5269853ec8 T14: disable T10.5 surface() path; GPUI 0.2.2 Surface asserts NV12 YUV 2026-05-11 00:53:18 -04:00
ZacharyZhang-NY b70fa71a9f T13: push display scale factor into Servo's hidpi so Retina pages lay out at logical CSS dimensions 2026-05-11 00:46:32 -04:00
ZacharyZhang-NY 60a3b9b050 T12: disarm visible-content gate on software path after first real frame 2026-05-11 00:35:11 -04:00
ZacharyZhang-NY 5d8734aefc T11: swap R↔B between Servo RGBA and GPUI BGRA so colours stop inverting 2026-05-11 00:27:09 -04:00
ZacharyZhang-NY d570b84c9c T10.7: bypass visible-content gate on hardware path (196ms → 6ms per frame) 2026-05-11 00:05:04 -04:00
ZacharyZhang-NY 6e2bd20a9b T10.6: drop RGBA payload from the wire when the hardware path publishes a surface 2026-05-10 23:52:12 -04:00
ZacharyZhang-NY a447d52262 T10.5: render IOSurface via gpui::surface(CVPixelBuffer) 2026-05-10 23:37:00 -04:00
ZacharyZhang-NY c4a6ea3c57 T10.4: import sidecar IOSurface mach port into a cached MTLTexture 2026-05-10 23:27:56 -04:00
ZacharyZhang-NY 07b9c9da01 T10.3: publish IOSurfaceHandle once per surface, current_surface_id per frame 2026-05-10 23:17:05 -04:00
ZacharyZhang-NY bb0bd0032f T10.2: extract IOSurface mach port from the hardware surface on macOS 2026-05-10 23:01:34 -04:00
ZacharyZhang-NY 1e38ace997 Add manual live_perf_bench driver for software vs hardware comparison 2026-05-10 22:56:06 -04:00
ZacharyZhang-NY 71ddadb482 Wire HardwareOffscreenContext::connection() so Servo's painter constructs 2026-05-10 22:56:03 -04:00
ZacharyZhang-NY 320da3ddb5 Make frame_perf total a real end-to-end timing and right-size buckets 2026-05-10 22:38:12 -04:00
ZacharyZhang-NY f7027e6ea5 Profile frame stages via sidecar histogram → ely::servo::perf 2026-05-10 22:30:57 -04:00
ZacharyZhang-NY 18fd20b577 Plumb the rendering context kind from ely_app env var to the sidecar
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.
2026-05-10 21:27:12 -04:00
ZacharyZhang-NY a7d3e896bb Wire the vendored hardware context into SoftwareServoHost
`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.
2026-05-10 20:55:35 -04:00
ZacharyZhang-NY 048c5dfecd Vendor a headless hardware RenderingContext for Servo
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).
2026-05-10 20:50:59 -04:00
ZacharyZhang-NY 3f184ee941 Swap SipHash13 → AHash for the live-frame dedup key + record T10 plan
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.
2026-05-10 20:25:01 -04:00
ZacharyZhang-NY 9711167560 T13 diagnostic: T7 red is a TestAppContext hit_test quirk, not a prod bug
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).
2026-05-10 20:13:19 -04:00
ZacharyZhang-NY e02c0fd502 Drop the per-frame to_vec() clone in the sidecar pixel writer
`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.
2026-05-10 19:54:14 -04:00
ZacharyZhang-NY 94194e933e Document T13 bisect probes: capture phase bug is in ElyShell::new
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.
2026-05-10 19:49:51 -04:00
ZacharyZhang-NY 7f3b8b42b3 Dedup identical RGBA payloads against the last frame's Arc<RenderImage>
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.
2026-05-10 19:36:08 -04:00
ZacharyZhang-NY 414ba3d158 Land a red TDD guard for live-frame texture re-upload (T10)
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).
2026-05-10 19:32:43 -04:00
ZacharyZhang-NY 7b2b6daee9 Land a red TDD guard for the rendered-canvas click pipeline (T7)
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).
2026-05-10 19:13:46 -04:00
ZacharyZhang-NY a80d0393e9 Drop the file system from the live frame pixel pipe
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.
2026-05-10 19:04:34 -04:00
ZacharyZhang-NY 840255f88c Lift the web canvas out of in-flow so the input overlay lands on screen
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.
2026-05-10 17:38:10 -04:00
ZacharyZhang-NY 6314e1f777 Make WebView input invariant a property of the dispatch path
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.
2026-05-10 13:59:04 -04:00
ZacharyZhang-NY b8795bf903 Name every silent input rejection with WebSurfaceInputOutcome
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.
2026-05-10 02:00:52 -04:00
ZacharyZhang-NY c20daf36b9 Multiply input coords by window scale_factor so Retina clicks land
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).
2026-05-10 01:53:51 -04:00
ZacharyZhang-NY 22ba8517d1 Show + focus the Servo WebView so it stops dropping every input event
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.
2026-05-10 01:46:39 -04:00
ZacharyZhang-NY 25ff653d83 Polish vertical tab lifecycle: hover ≠ active, close as a coin
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.
2026-05-10 01:28:39 -04:00
ZacharyZhang-NY 7c27f7308c Lock the input invariants with web_surface store regressions
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.
2026-05-10 01:22:30 -04:00
ZacharyZhang-NY 1e85dad291 Solve workspace popover anchor from runtime sidebar width
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.
2026-05-10 01:12:45 -04:00
ZacharyZhang-NY dc06d5590d Fold WebSurfaceStore into a single PerTabSurface owner
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.
2026-05-10 01:08:03 -04:00
ZacharyZhang-NY 21da001bf5 Make topbar moon button actually toggle theme
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.
2026-05-10 00:57:59 -04:00
ZacharyZhang-NY f2799e89c9 Sharpen sidebar polish: profile chip chevron + close-hover bg
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.
2026-05-10 00:56:25 -04:00
ZacharyZhang-NY eb58ce75da Plug three holes in Servo input forwarding
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.
2026-05-10 00:51:37 -04:00
ZacharyZhang-NY 84ec12e471 Make vertical tab close (×) buttons reliably hittable
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.
2026-05-10 00:47:31 -04:00
ZacharyZhang-NY 7c33381063 Lift sidebar resize handle outside the rounded clip so it's hittable
The handle was a child of the rounded sidebar panel, which set
overflow_hidden — so the 6 px strip pinned at right(-2) was clipped to
the rounded edge and never reached the cursor. Split the panel into an
outer positioning wrapper plus an inner rounded panel, and place the
handle on the wrapper. Widen the strip to 8 px straddling the edge and
warm the hover tint so the affordance is visible during drag.
2026-05-10 00:46:44 -04:00
ZacharyZhang-NY 5287fccdec Fix Newsreader serif resolution by matching its real family name
The bundled Newsreader.ttf is the 16pt optical-size cut: its TrueType
name-id 1 reads "Newsreader 16pt", and "Newsreader" only appears in
name-id 16. GPUI/cosmic-text matches by name-id 1, so every
.font_family(SERIF_FAMILY) call site (hero headline, settings titles,
plugin pages, recap) was silently falling back to the default sans.
Setting SERIF_FAMILY to "Newsreader 16pt" pins the right font.
2026-05-10 00:45:54 -04:00
ZacharyZhang-NY 2352d4658d Persist settings nav across every settings sub-page
Each ely://settings/* route used to render its content alone, with no
nav column — so clicking a sidebar item replaced the entire page and
read to users as a brand-new tab opening. Move the nav column into a
shared render_settings_shell wrapper and route every settings/* URL
through it. ely://sync/status reuses the sync route highlight.

Also stop in-place navigation from stealing focus to the omnibar so
the destination page keeps focus for scroll and interaction.
2026-05-10 00:45:21 -04:00
ZacharyZhang-NY fcac3268ad Stop hijacking omnibar typing when an external web tab is active
Real bug found by reading capture_key_down semantics: the web
keyboard handler was registered as `capture_key_down` on the root
div, which fires from root → focused element. So when the user is
on an external tab (https://google.com) and clicks the omnibar to
type a new URL, every keystroke was intercepted at the root, the
text was forwarded to Servo, and `cx.stop_propagation()` killed the
event before it could reach the focused Input. The omnibar appeared
dead.

Gate the handler on `self.focus_handle.is_focused(window)`. The
shell's root handle is only focused when nothing deeper is — clicks
on the web viewport call `focus_handle.focus(window)`, which makes
the root focused; clicks on any Input transfer focus to the Input's
handle and `is_focused` returns false on the root. Now keystrokes
reach the Input untouched while still flowing to Servo when the
user is interacting with the page itself.

cargo test --workspace: 440 passed, 0 failed.
2026-05-10 00:23:55 -04:00
ZacharyZhang-NY 4d82023a68 Servo sidecar: extend post-input frame wait to 250 ms
The live-mode `poll_frame` budgeted 60 ms for Servo to paint after
each `Ensure` request. That value was set when the only thing being
applied was navigation, where the GPUI tick timer (16 ms cadence) would
backfill missed frames quickly. Once we started forwarding clicks and
typing through the same `Ensure`, 60 ms was tighter than the
software renderer needs to handle MouseDown + MouseUp + layout +
paint on a real page like google.com — so the response carried the
pre-click frame and the user saw no visible reaction.

Bump the budget to 250 ms. That covers the click → focus-ring paint
cycle on the software backend without making nav slower (`apply_layout`
exits early on stable size, so unchanged ensures still return on the
first poll).
2026-05-10 00:21:33 -04:00
ZacharyZhang-NY c95e3665c6 Omnibar Enter navigates the current tab instead of spawning a new one
`submit_command` matched every browser's "Enter to navigate" intent
to `open_tab(url)`, which always inserts a new `BrowserTab`. So
typing `google.com` and hitting Enter on a new-tab page would leave
both the new-tab and a fresh google.com tab in the sidebar.

Mirror the same in-place navigation rule the shell-level code now
uses: `navigate_active_tab(url)` for the Navigate and Search
intents, with `open_tab` as the fallback when there's no active tab
yet. Tab count only goes up when the user explicitly hits + New Tab.

cargo test --workspace: 440 passed, 0 failed.
2026-05-09 22:50:01 -04:00
ZacharyZhang-NY a0ac0de765 Override gpui-component theme font so the Input picks up Geist
Found the actual root cause of "fonts still wrong" by reading
gpui-component source. Input, tooltip, context menu, search popover,
notification, and inspector all set `font_family(cx.theme().font_family)`
on their root div directly — they don't inherit from the parent's
text style. Default theme value is `.SystemUIFont` (SF Pro on macOS),
so the omnibar Input the user actually types into was rendering in
SF Pro while every chrome surface around it rendered Geist.

After fonts register, mutate the theme global so its `font_family`
is `"Geist"`. Now every gpui-component sub-element uses the same
sans as the rest of the app.

cargo test --workspace: 440 passed, 0 failed.
2026-05-09 22:45:17 -04:00
ZacharyZhang-NY f8e05604ed Navigate the active tab in place instead of spawning a new tab
Root cause of "settings opens new tab for every click": every
internal navigation went through `open_internal_tab → open_url →
core.open_tab(url)`, and `open_tab` unconditionally inserts a new
`BrowserTab`. So three settings sub-page clicks left four tabs in
the sidebar, which is the screenshot the user keeps sending.

Real browsers navigate the active tab in place for in-app links and
spawn new tabs only on `+ New Tab` (or Cmd-click). Wire it through:

* `BrowserTab::set_url(url)` mutates the tab's URL and bumps
  `last_active_at`. Title stays put — the page renderer can refresh
  it from the new URL.
* `BrowserCore::navigate_active_tab(url)` finds the active tab,
  calls `set_url`, marks it Ready, records the history entry, and
  bumps activity. Returns `TabNotFound` if there's no active tab.
* `ElyShell::navigate_active_tab` calls the core method and falls
  back to `open_tab` if there's no active tab to navigate. The
  shell's `open_internal_tab` (used by settings nav, home pills,
  sidebar Settings + Profile rows, command-overlay routes, etc.)
  now routes through this in-place path.
* `open_url` keeps the explicit "spawn a new tab" semantics for
  `+ New Tab` and the deep-link router.

Settings, plugin marketplace, history, profile picker — every
sidebar nav now stays in one tab.

cargo test --workspace: 440 passed, 0 failed.
2026-05-09 22:42:25 -04:00
ZacharyZhang-NY 2852ab63ee Mirror omnibar value into the home search shortcut
The home page hero search showed a static "Search the web or ELY"
placeholder regardless of what the user had typed in the omnibar.
Click the home search → focus the omnibar → start typing → omnibar
shows the text but the home search still says "Search the web or
ELY". The user reasonably reads that as "my typing went nowhere."

Read `command_input.value()` from the home search renderer and echo
it (in INK when set, INK_4 placeholder when empty). Single Input
still owns the actual state — the home search is a click-to-focus
shortcut, this is a read-only echo so the user sees their typing
reflected here too. Truncate so long URLs don't blow the row.

Plumb `&ElyShell` through `render_home_page → render_hero →
render_search_bar`.
2026-05-09 22:30:34 -04:00
ZacharyZhang-NY 6cf2defca9 Stop propagation on split-pane close so the pane doesn't reselect
Same fix as `adb278b` but for the split-pane close glyph: clicking ×
fires the pane wrapper's `on_click(select_tab)` after the close runs.
Add `cx.stop_propagation()` so the close ends at the glyph.
2026-05-09 22:28:41 -04:00
ZacharyZhang-NY a6e08f6267 Drop unwired shortcut hints from command overlay footer
Footer advertised `⌘↵ open in split` and `⇥ filter`, but neither is
plumbed through `on_command_overlay_key_down` — the dispatch only
handles up / down / enter. Per the no-fake-handlers rule a hint that
doesn't fire is worse than no hint, so trim the footer to just the
two shortcuts that actually work. The split/filter hints can come
back the moment the dispatch grows to handle them.
2026-05-09 22:27:45 -04:00
ZacharyZhang-NY cea5f650de Route sidebar toggle through set_active_sidebar_width
`toggle_sidebar_width` set the new width via core directly, bypassing
the picker-dismiss + hover-expand reset that lives in
`set_active_sidebar_width`. So the user could open the workspace
popover, hit toggle, see the sidebar collapse, and the popover would
reappear on re-expand because the flag never cleared.

Forward toggle through the same entry point. One sidebar-resize code
path for the keyboard, the toggle button, and the drag handle.
2026-05-09 22:23:37 -04:00
ZacharyZhang-NY cb8038f60c Auto-dismiss workspace popover when sidebar collapses
Picker pill is only painted in the expanded sidebar, but
workspace_picker_open lived independently of sidebar width. If a user
opened the picker, then collapsed the sidebar via the toggle or by
dragging the resize handle below the COLLAPSED threshold, the popover
state stayed `true` — re-expanding the sidebar would surprise them
with a stranded popover from before.

Drop the picker_open flag inside set_active_sidebar_width whenever the
new width is at or below COLLAPSED_SIDEBAR_WIDTH_PX, so the popover
state stays in sync with the trigger's visibility.
2026-05-09 22:23:08 -04:00
ZacharyZhang-NY 69aee5bc68 Resolve clippy warnings in this round's changes
Three nits clippy flagged on the round 13 + 14 commits. Fix each:
* `RangeInclusive::contains` for the sidebar reveal-threshold guard.
* Reword the popover-anchor doc-comments so the `+ tile` lines don't
  get parsed as Markdown list items.
* Collapse the `if let Some(text)` + nested `if` in the external web
  keyboard handler into a single `let-and-and` chain.

No behavior change. cargo clippy -p ely_app: clean.
cargo test --workspace: 440 passed, 0 failed.
2026-05-09 22:22:36 -04:00