The `sed`-based extractions in the preceding three split commits each
left one extra blank line at the cut boundary, which `cargo fmt --all
--check` (a CI gate) rejects. Whitespace only — no code change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`web_surface_live_site_tests.rs` (648, behind the `live-site-smoke`
feature) was the last file over the audit ceiling. Its helpers split
cleanly one-directionally: drivers/waiters call the leaf
validators/builders, never the reverse.
Keep imports, consts, the 6 tests, `run_isolated_live_site_test`, the
`assert_*` drivers, `render_web_surface_frame`, and the `wait_for_*`
helpers in the parent; move the leaf validators + pure fixtures
(`ExpectedCssViewport`, `validate_prd_frame*`, `log_prd_frame`,
`require*`, `*_bounds`, `live_scroll_point`, `web_tab`, `normalized_url`)
into `web_surface_live_site_support.rs` as `pub(super)` items, opened
with `use super::*;`. Parent pulls them back via `use ...support::*`.
470 / 184 lines. `cargo build/clippy -p ely_app --tests --features
live-site-smoke -- -D warnings` clean. scripts/audit_source_lines.sh now
exits 0 (every source file <= 500).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`gpui_harness_tests.rs` was the repo's largest file (1062 lines) and the
worst violator of the 500-line / no-god-component audit. Its 13
`#[gpui::test]`s each build their own local fixtures and share only a
pair of type aliases, an `impl super::ElyShell` test helper, and two free
fns (`active_tab_overlay_state`, `example_url`).
Keep those shared items plus tests 1-4 in the root module; move tests 5-8
to `gpui_harness_tests_b.rs` and tests 9-13 to `gpui_harness_tests_c.rs`,
each opening with `use super::*;` so they inherit the parent's imports
and shared items with no per-item churn. Declared via `#[path]` mod, the
established sibling-test pattern in this crate.
475 / 319 / 276 lines. No tests added, removed, or renamed (paths gain a
`gpui_harness_tests_{b,c}::` segment). clippy --all-targets -D warnings
clean; `cargo test -p ely_app` 167 passed / 0 failed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`scripts/audit_source_lines.sh` (a CI gate) flagged `tests/commands.rs`
(527) and `tests/splits.rs` (524) over the 500-line ceiling. Both are
flat lists of independent `#[test]` fns with no shared helpers, so each
splits cleanly into a sibling integration-test binary (the crate already
uses a topical file-per-concern layout under `tests/`).
- commands.rs (29 tests) -> commands.rs (20: tab/space/profile/search)
+ command_pages.rs (9: internal-page-opening commands). 527 -> 389.
- splits.rs (25 tests) -> splits.rs (13: layout/axis/detach mechanics)
+ saved_split_lifecycle.rs (12: close/archive/restore/group). 524 -> 266.
No tests added or removed; each new file carries only the imports it
uses. `cargo clippy -p ely_browser_core --tests -- -D warnings` clean;
all ely_browser_core tests pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`runtime.rs` had grown past the 500-line ceiling enforced by
`scripts/audit_source_lines.sh` once the grid-pref comment landed (522
lines). Peel the repaint/present pair — `paint_without_readback`,
`paint_without_readback_with_completion`, the private `paint_webview`,
and `paint_with_readback` — into a sibling `runtime_paint.rs`, exactly
the `paint.rs` boundary the embedding architecture doc prescribes.
`runtime_paint` is a child module of `runtime` (declared via `#[path]`,
mirroring `runtime_context`), so it keeps access to the private
`SoftwareServoHost` fields and the `webview()` / `wait_for_paint_completion`
/ `read_rendered_frame` helpers without widening any visibility. No
behaviour change: public API and call sites are identical.
runtime.rs 522 -> 469 lines. Build + clippy clean; full workspace test
suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`Preferences::default()` is Servo's conservative library default and
ships `layout_grid_enabled: false`. `Servo::new` forwards prefs to Stylo
(`prefs::set` -> `stylo_static_prefs::set_pref!("layout.grid.enabled")`),
so with the gate off Stylo blockifies `display: grid`: every grid
container collapses to `display: block` and grid-based page layouts
stack into a single column — the "broken" rendering reported on modern
sites.
`ely_servo_preferences()` only flipped `dom_intersection_observer_enabled`
and inherited the grid default, so ELY rendered grid pages collapsed
while Servo's own servoshell (which enables the pref) renders them
correctly. The layout path is implemented — servo-layout drives
`DisplayInside::Grid` through Taffy — so enabling the pref is the real
fix, not a workaround.
Verified with a deterministic `display: grid; grid-template-columns:
1fr 1fr 1fr` page: 9 stacked full-width bars before, a 3x3 grid after.
Wikipedia/HN/GitHub/google.com re-checked unchanged; full workspace
test suite green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Servo's \`WebView::set_history\` fires \`notify_url_changed\` on **every**
history mutation — full navigations, redirects, in-page link clicks,
and JS-driven \`history.pushState\` / \`history.replaceState\`.
Our pipeline fans that delegate signal back through
\`WebSurfaceUrlChange\` into \`tab.url\`. The next \`ensure_surface\`
observes the new tab URL, hashes a fresh ensure key, and calls
\`ServoLiveClient::ensure\` → \`apply_navigation\`. Until this commit
\`apply_navigation\` compared the request against the local
\`session.requested_url\` cache and, on mismatch, sent Servo a
\`webview.load(url)\` — even when Servo was the one who *just*
told us about that URL.
\`WebView::load\` is a hard navigation: it tells the constellation
to abort the current document, clear the surface, and refetch.
google.com's homepage \`replaceState\`s a fresh \`?zx=<timestamp>\`
roughly once a second to bust caches; with this round-trip we
were turning each of those into a full load-clear-refetch and
producing one visible white flash per second.
Use \`host.snapshot(webview_id).url()\` (which Servo keeps in lock-step
with \`set_history\`) as the source of truth. If Servo's WebView is
already at the requested URL, just sync our \`session.requested_url\`
bookkeeping and return — no \`load\` message, no surface clear, no
flash. Genuine embedder-initiated navigations (chrome URL bar typed,
in-app link click) still take the \`should_navigate\` path because
Servo's URL hasn't caught up to the requested target yet.
Servo's reference embedder (\`examples/winit_minimal.rs\`) is reactive:
- \`user_event\` only calls \`servo.spin_event_loop()\` — never paints
- \`notify_new_frame_ready\` is what flips the embedder into the
redraw path (calls \`window.request_redraw()\`)
- \`RedrawRequested\` is the **single** site that runs
\`webview.paint() + rendering_context.present()\`
Painting is therefore at-most-once-per-real-frame: a paint happens
only when Servo has actually composited new content.
Our \`ServoLiveClient::ensure\` was forcing
\`paint_without_readback_with_completion\` after every setup pass.
\`ensure\` runs on every \`ensure_surface\` invocation — viewport
debounce settle, URL redirect (google.com → / → /?zx=…), permission
update, native-surface re-attach, page-zoom change. Each invocation
called Servo's painter while it had no new composited frame ready:
WebRender's \`clear_background\` wiped the surface to the configured
shell background colour, the empty scene rendered, \`present\` swapped
that blank surface to the NSView's CALayer — and the user saw a
white flash. A chain of redirects produced a chain of flashes.
Drop the unconditional \`paint\` from \`ensure\`. Keep the
\`host.tick()\` (Servo's spin) so the navigate / viewport / input
messages reach the constellation in this turn, but defer painting to
\`poll\`, which already gates \`paint_without_readback_with_completion\`
on \`snapshot.has_pending_frame()\` — exactly mirroring the
reference embedder's reactive model. \`ServoLiveFrame\` returned from
\`ensure\` only carries snapshot metadata, so callers that depend on
the frame value (worker queue, UI state) are unaffected.
Servo's reference embedder (\`examples/winit_minimal.rs\`) handles
resize with a single call: \`webview.resize(new_size)\`. The Servo
paint pipeline behind that call:
1. early-returns if \`rendering_context.size() == new_size\`,
2. otherwise calls \`rendering_context.resize\` itself,
3. updates \`webview_renderer.rect\` so the compositor relays out
the page at the new device viewport,
4. sends \`transaction.set_document_view(...)\` so WebRender's
document viewport matches the surface,
5. flags the painter \`needs_repaint(RepaintReason::Resize)\`.
Our \`runtime.rs::resize\` called \`webview.rendering_context.resize\`
*before* \`webview.webview.resize\`. surfman accepted the new size, so
the painter saw \`rendering_context.size() == new_size\` and took the
early-return path — steps 3, 4, and 5 never ran. The compositor
kept the original (creation-time) viewport rect while we presented
a much larger surface. The page ended up laid out for a tiny
viewport and either rendered into the top-left of a sea of cleared
background (StableLance) or never painted any pipeline at all
(google.com appeared totally blank).
Drop the direct \`rendering_context.resize\` call and route through
\`WebView::resize\` exactly as the reference embedder does. The
debounce in \`record_viewport_size\` still collapses a sidebar /
window-edge animation into a single trailing-edge resize, so we
also avoid hammering Servo with per-frame surface mutations.
The web canvas is a GPUI `native_surface` element, which on macOS
attaches an `NSView` (with a Metal-backed `CALayer`) as an AppKit
overlay on top of the GPUI render layer. GPUI's `overflow: hidden`
clips its own children but cannot reach into AppKit, so the canvas
painted square corners on top of the main panel's rounded edge —
the white surface visibly overshot the panel's rounded bottom.
Thread a per-corner radius through the GPUI patch:
- `Window::sync_native_surface` and the `PlatformWindow` trait carry
a `Corners<Pixels>`.
- The macOS implementation sets `CALayer.cornerRadius` to the maximum
requested radius and `maskedCorners` to the bitmask of corners that
actually requested rounding (GPUI is Y-down while NSView is Y-up,
so a GPUI "top" maps to a CALayer "MaxY" corner — comment locks
the mapping). `setMasksToBounds: YES` lets Core Animation actually
honor the corner radius for layered content.
- The `NativeSurface` element resolves all four corner radii from its
own `StyleRefinement` (so callers attach `.rounded_bl(...)` /
`.rounded_br(...)` exactly as on any other GPUI element).
Application side wires the right radius per context:
- The main canvas asks for `RADIUS_CARD` (18 px) on its bottom corners
so it follows the surrounding panel's rounded bottom edge while
the top stays flush against the toolbar.
- A split pane's canvas asks for the pane's own `SPLIT_PANE_RADIUS`
(10 px) so the canvas hugs the rounded frame the pane already paints.
`render_failed_web_surface` doesn't need rounding wiring — the error
page is a GPUI div and is clipped normally by the surrounding
`overflow: hidden`.
Sidebar / window-edge animations emit a new viewport bounds on every
GPUI paint. The previous flow fired `runtime.ensure_tab` synchronously
on every change, which reached down into Servo's surfman backend and
destroyed + reallocated the rendering framebuffer 50+ times per second
— each cycle leaving the NSView with an empty surface until Servo's
next paint completed. The user saw the page strobe blank-then-content
on every gesture, plus a one-time Metal driver warning about an
unloadable texture (the previous framebuffer caught mid-recreate).
Track `viewport_size_changed_at` on `PerTabSurface` and propagate the
debounce on two seams:
- `record_viewport_size` returns `Buffered` for a transition that
arrives inside the 80 ms window of the previous one, so the
synchronous `flush_external_web_surface_tick` from the GPUI paint
callback skips the resize altogether. The very first measurement
and the first transition after a quiet period still return `Applied`
so a single drag step or page-load is not delayed.
- `ensure_surface` skips while the viewport is settling, but only
*after* the initial ensure has installed `last_ensure_key`. The
first ensure must fire even mid-gesture or the page never loads.
- `next_tick_delay` clamps the poll cadence to `ACTIVE_POLL_INTERVAL`
while any visible tab is settling, so the trailing-edge resize
fires within a frame of the gesture stopping instead of waiting
for an 80 ms idle tick.
The synchronous `record + ensure` pattern in
`failed_surface_ensure_waits_for_a_new_key_before_retrying` can't
advance an `Instant`, so it now calls a `#[cfg(test)]`
`clear_viewport_resize_debounce_for_test` to simulate the
trailing edge and exercise the retry-on-new-key business rule in
isolation. Adds `rapid_viewport_changes_buffer_until_gesture_settles`
to lock the new behaviour.
web_surface_runtime.rs hit 628 lines because it carried four unrelated
concerns:
- session domain types (`WebSurfaceRuntimeScope`, `WebSurfaceSession`,
`WebSurfaceUrlChange`, …) and their config-dir / `session_for_scope`
helpers — these are the runtime's input vocabulary, not its control
flow.
- wire-side glue (scroll-input field marshalling, `pending_input_kind`
classifier, the latency tracing call, and the `From<&Site...>` impl
that lowers permissions to the Servo client) — these are the runtime's
output vocabulary.
- the runtime itself, plus its tests harness.
Split into:
- `web_surface_runtime.rs` (441 lines): WebSurfaceRuntime + Drop.
- `web_surface_runtime_session.rs`: the session types + helpers, with
`pub(super)`-exposed methods so the runtime can drive them.
- `web_surface_runtime_wire.rs`: the wire helpers and the From impl.
`web_surface_runtime` keeps re-exporting the URL-change / frame /
ensure-result types so existing call sites
(`web_surface_state.rs`, `web_surface.rs`, `web_surface_controller.rs`)
need no rewiring.
`web_surface.rs` had crept to 548 lines; six unrelated input-recorder
methods (scroll delta, viewport size, native surface, hover point,
click point, typed text) dominated the file. Move them into a sibling
`web_surface_input.rs` so the store file (now 338 lines) is the
ensure / tick / lifecycle facade and the input file is a flat set of
`record_*` methods on the same struct.
`surfaces`, `keyboard_focus`, and `surface_mut` become `pub(super)` so
the sibling can reach them; everything else stays private.
tabs.rs had grown to 538 lines — past the 500-line ceiling — and was
mixing four unrelated concerns:
- `tab_navigation` (URL changes + history back/forward)
- `tab_metadata` (title, favicon, zoom, favorite/pin, sort, sync flag)
- `tab_archive_restore` (un-archive entry points + query matching)
- core CRUD (open / close / move-to-space + private helpers)
Each split file lives below 250 lines and only pulls the `ely_domain`
types and crate helpers it actually uses. `active_tab_mut` and the
`TabUrlUpdate` enum get `pub(super)` so siblings can reach them; no
behaviour change.
Lift `apply_viewport`'s three-way diff (resize / set_hidpi / set_zoom)
into a `ViewportChange` value so the decision is testable without a
running `SoftwareServoHost`. Four unit cases pin the contract:
- Retina creation (DPR=2.0): only the hidpi push fires (the bug above).
- Standard-DPI creation (DPR=1.0): nothing to push, session already
matches Servo's defaults.
- Mid-session zoom change: only `set_page_zoom` fires.
- Cross-monitor DPR change: only the hidpi push fires.
This is a regression guard for the previous commit — any future change
that re-initializes a session from request values instead of Servo's
post-build defaults will fail `fresh_retina_session_pushes_hidpi_only`.
The newly-created session was stamped with the request's hidpi factor,
but `WebViewBuilder` defaults `hidpi_scale_factor` to 1.0 and we never
override it. `apply_viewport`'s diff check then saw
`session.dpr == request.dpr` and skipped `set_hidpi_scale`, leaving Servo
at hidpi=1.0 forever. On Retina that collapses CSS pixels onto device
pixels — the page lays out for a 2× viewport and renders at half size.
Same shape applies to page zoom.
Initialize the session with Servo's actual post-build defaults so the
viewport diff is the source of truth for whether `set_hidpi_scale` and
`set_page_zoom` need to run.
`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.