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.
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.
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.
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.
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).
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.