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.
Every `WebSurfaceFrame::from_live_frame` today calls
`Arc::new(RenderImage::new([image::Frame::new(image_buffer)]))`
unconditionally — even when the underlying RGBA bytes are
byte-for-byte identical to the previous frame. At 60 fps on a 1080p
canvas that is roughly 960 MB/s of host-side cloning + a fresh
GPUI texture upload, and the roundtable agreed it is the next
material bottleneck after the file-system pipe (a80d039).
The contract this test pins is the cheapest invariant we can hold
against today's `SoftwareRenderingContext`: two `ServoLiveFrame`
inputs whose `rgba_bytes` are bit-identical must produce the same
underlying `Arc<RenderImage>` instance. Today they do not; the
ignored run confirms two distinct pointer values for back-to-back
identical inputs.
The fix has two recognised shapes. The interim shape lives entirely
in `WebSurfaceFrame::from_parts`: remember the previous frame's
bytes (hash or pointer-eq) and reuse the existing `Arc<RenderImage>`
when they match. The endgame shape removes the host-side image step
entirely — `OffscreenRenderingContext` + IOSurface — at which point
the assertion becomes meaningless and is replaced by a frame-time
budget. Whichever lands first, the fix commit MUST delete the
`#[ignore]` attribute outright; toggling its reason is a broken
contract.
To call `WebSurfaceFrame::from_live_frame` from a unit test without
spawning a real sidecar process, `ServoLiveFrame` gains a
`#[cfg(test)] pub(crate) fn for_test(...)` constructor that wraps
the existing private `from_parts` with realistic defaults. No
production path uses it.
cargo test --bin ely_app: 112 passed, 0 failed, 2 ignored.
cargo test --bin ely_app -- --ignored: 2 failed (expected RED:
T7 click pipeline + T10 texture re-upload).
After 840255f put the input overlay on-screen and a80d039 dropped
the file-system pixel pipe, the two harness baselines confirm:
* `.occlude() + capture_any_mouse_up` works in TestAppContext
(baseline_overlay_div_receives_simulated_click)
* `input_overlay`'s exact listener combo works in isolation
(baseline_overlay_with_full_listener_combo_receives_click)
…and the layout regression guard confirms the overlay is now drawn
inside the visible window. Yet running a real ElyShell, navigating
to https://example.com/, and dispatching a real MouseDown/MouseUp at
the geometric center of the measured viewport STILL leaves
WebSurfaceStore.click_point as None. The capture phase listener is
being eaten somewhere strictly inside the real ElyShell widget tree.
`user_click_in_rendered_web_canvas_reaches_input_pipeline` encodes
this contract in user terms — "click on the rendered page and the
input pipeline records it" — without naming a GPUI mechanism. The
test is marked `#[ignore]` so the rest of the suite stays green; the
attribute carries the full reproduction note so a reader picking the
ticket up later doesn't have to rediscover what we already know
(layout + pixel-pipe both clean, listener combo clean, suspicion now
on sibling z-order / ancestor stop_propagation / overflow_hidden
content_mask clipping the overlay's hitbox).
The fix commit must DELETE the attribute outright; toggling the
ignore reason is a broken contract. Running
`cargo test -- --ignored user_click_in_rendered_web_canvas_reaches_input_pipeline`
today reproduces the failure with click_at = (1106, 567) inside
viewport_bounds (309, 71, 1594, 992).
cargo test --bin ely_app: 112 passed, 0 failed, 1 ignored.
cargo test --bin ely_app -- --ignored: 1 failed (expected RED).
Every live frame was round-tripping through the local file system:
the sidecar called `fs::write(rgba_out, frame.rgba_bytes())` in
`poll_frame`, the JSON response carried `rgba_path`, and the main
process turned around and called `fs::read(rgba_path)` to lift the
bytes back into a `Vec<u8>`. At 1080p that is 8 MB of syscall +
memcpy + page cache traffic per frame; at 60 fps it dwarfs every
other cost in the pipeline and shows up as scroll/zoom jank the user
can feel before any other bottleneck.
Replace it with a same-pipe binary protocol. The sidecar writes the
JSON `LiveResponse` line as before, then writes the raw RGBA frame
bytes on the same stdout immediately after the trailing `\n`. The
client `read_line`s the JSON, parses `rgba_byte_count` from the
header, and `read_exact`s exactly that many bytes from the same
`BufReader<ChildStdout>` (the buffered reader drains its own buffer
before pulling from the child). No tmpfs directory, no
`fs::remove_dir_all` on drop, no `rgba_path` field, no per-frame
filename plumbing.
Boundary defence on the client side: the header's `rgba_byte_count`
is cross-checked against `width * height * 4` before any allocation
or `read_exact`. A buggy or compromised sidecar can no longer ask
the GPUI process to allocate an arbitrarily large buffer or park on
`read_exact` for a payload that will never arrive.
The sidecar process boundary stays exactly where it was; only the
pixel transport between the two processes changes. The `one-shot`
sidecar binary path (used by `tests/sidecar.rs` and PRD smoke tests)
still writes to its CLI-supplied `--rgba-out` path — those tests
were untouched and continue to pass 9/9.
cargo test --bin ely_app: 112 passed.
cargo test -p ely_servo_host --features servo-engine --test sidecar: 9 passed.
Follow-ups deferred to the profile step (T9):
* the host still copies its rendered buffer to a transient
`Vec<u8>` via `frame.rgba_bytes().to_vec()` before write_all;
expose `&[u8]` straight to `write_all` once the profile shows
that allocation in the top five.
* `poll_frame` calls `snapshot` twice per iteration; harmless
under the software renderer but worth folding into a single
snapshot once we have numbers.
* raw memcpy bandwidth is still ~480 MB/s at 60 fps 1080p; the
GPU-side fix (T10: OffscreenRenderingContext + IOSurface
zero-copy) is the next material change.
A GPUI harness boots a real ElyShell, navigates to an external URL, and
asks the input_overlay's sibling canvas tracker where it laid out. On
main before this change the canvas reports
Bounds { origin: (309, window_height - 17), size: (W - 326, content_h) }
i.e. the overlay's top edge sits at the very bottom of the visible
window. Every user click in the visible area lands above (or beside)
the overlay; the on_mouse_down + capture_any_mouse_up listeners never
even see the event because the hitbox is off-screen. Twelve commits
chased focus/coords/outcome enums on the sidecar side while every click
in the live shell hit empty space.
Root cause: in render_web_surface the rendered web image (img / loading
div / error page) was a non-absolute child of a `.relative().size_full()`
wrapper. The non-absolute child claims `size_full` block-flow height
inside that wrapper, which made the wrapper's intrinsic height
content_height + content_height. The two `.absolute().size_full()`
siblings (viewport_tracker, input_overlay) then sized against that
inflated parent and were positioned in the bottom half — exactly
content_height below where they were supposed to be.
Fix: keep the relative wrapper as the layout owner of the panel slot
(size_full, overflow_hidden, min_w_0) and put the rendered image into
an absolute `inset_0` child of its own. viewport_tracker and
input_overlay stay as absolute siblings. With the image out of in-flow
the wrapper sizes to its parent and the overlay's hitbox lands at
y = top of content area (71 in a 1080-tall window) instead of
y = window_height - 17.
GPUI test harness (`gpui_harness_tests.rs`) is the holdout set:
- `baseline_overlay_div_receives_simulated_click` proves GPUI's
occlude + capture_any_mouse_up primitive works under TestAppContext.
- `baseline_overlay_with_full_listener_combo_receives_click` proves
the exact listener combo render_input_overlay uses works in
isolation.
- `ely_shell_external_canvas_lays_out_inside_window` boots a real
ElyShell, navigates, and asserts the overlay's measured bounds fit
inside the visible window. Without the fix above, this test trips
on bounds extending below the window bottom.
The three new store-layer tests in web_surface_tests.rs pin per-tab
isolation, zero-delta short-circuit, and resize-mid-drain decoupling
invariants the harness work flushed out.
ely_app picks up gpui's test-support feature as a dev-dependency so the
harness can use VisualTestContext + simulate_mouse_*.
cargo test --bin ely_app: 112 passed (was 108 + 4 new harness/store tests).
Remaining work (not in this commit): even with the layout fixed, the
harness shows MouseUp's capture_any_mouse_up still doesn't fire on the
ElyShell tree, while MouseDown's bubble does. Some sibling/ancestor
listener in the live shell is eating the MouseUp capture phase that
the standalone listener-combo baseline does not. Tracked separately.
bool returns on the five record_* surface inputs collapsed nine real
outcomes into one bit. The cascade we found in this round — silent
click drop because viewport_bounds wasn't measured yet, then
keyboard_focus stays None, then typing also "fails" — looked like
three independent symptoms but was one root cause hiding inside
that bit. Replace the bool with a #[must_use] WebSurfaceInputOutcome
enum so each rejection names itself at the call site.
Variants map 1:1 to real return points in web_surface.rs:
Applied / NoChange / Buffered — three distinct success-ish states
the controller already needed to disambiguate (only Applied notifies)
DroppedInvalidBounds — geometry rejected zero/NaN viewport
DroppedNoViewportBounds — input arrived before the viewport tracker
DroppedOutOfBounds — window position outside the viewport rect
DroppedZeroDelta — wheel rounded to zero device px
DroppedEmptyText — empty record_typed_text
DroppedNoKeyboardFocus — type without a prior click
DroppedFocusMismatch — focus belongs to another tab/url
Behavior preserved: Applied is the only notify trigger, matching the
old `true` semantics. Three new negative-path tests (no_viewport_bounds,
zero_delta, no_keyboard_focus) lock the named drops so a future
regression surfaces as a wrong variant in tests instead of a missing
repaint. cargo test ely_app --bin ely_app web_surface: 17 passed.
GPUI delivers logical (CSS) pixels but Servo expects device pixels.
On a 2x Retina display the page rendered at half resolution and every
click landed in roughly the upper-left quadrant of the page — the
second independent root cause that surfaces immediately after T1's
show()/focus() lets input reach Servo at all.
Single conversion boundary inside web_surface_geometry.rs:
viewport_dimension, scroll_dimension, and click_coordinate each
multiply once by window.scale_factor() before truncating to integer
device pixels. positive_scale_or_one() guards against a zero/negative/
NaN scale_factor reaching the arithmetic — falling back to 1.0 keeps
coordinates valid even if the platform reports nonsense (system
boundary validation per CLAUDE.md, not internal trust).
scale_factor is plumbed through controller and view layer, sourced
from window.scale_factor() at every record_* boundary so all four
inputs (click, scroll, hover, viewport) stay in sync. Existing tests
pin the 1.0 path; new retina_scale_factor_doubles_every_input_coordinate
locks the 2.0 path against a future regression.
cargo test ely_app --bin ely_app: 104 passed (was 103 + new test).
Active and hover used the same ACTIVE_NAV_BG, so a selected row gave
no feedback when the cursor crossed it — the contrast ladder was a
single rung. Replace it with a four-state ladder shared by the home
anchor, launcher rows, and tab rows:
rest → transparent (HOVER_NAV_BG hover)
active → ACTIVE_NAV_BG (ACTIVE_NAV_BG_HOVER hover)
The choice lives in nav_row_palette(active) so the ladder cannot
silently disagree across the three callers. Close (×) becomes an
18 px circular coin (was a 16 px square) with the warm-dark hover
wash from CLOSE_HOVER_BG, shared by both row variants via a single
render_row_close_button recipe — the button now reads as something
to press, not a glyph in a square.
Hoist sidebar_chrome.rs alongside sidebar.rs to host the shared
constants/helpers and keep both files under the 500-line ceiling
(444 / 198). cargo test ely_app --bin ely_app: 103 passed.
Five PerTabSurface tests pin the input contract so future refactors
can't silently regress it:
- typed_text_enters_pending_input_after_clicked_viewport: typing
reaches the sidecar once a click establishes keyboard focus.
- scroll_delta_enters_pending_input_after_wheel: scroll deltas
combine across multiple wheel events.
- viewport_size_changes_after_stable_second_measurement: viewport
resize is debounced behind a "same size twice" guard.
- scroll_after_click_keeps_keyboard_focus_and_typed_text: the bug
fixed last commit cycle — scroll drops the buffered click point
but must keep keyboard_focus and buffered keystrokes.
- typing_without_a_prior_click_is_rejected: typing without focus
returns false, so a future refactor can't accept stray keystrokes.
No instrumentation eprintln/log added in production paths
(per CLAUDE.md NO LOGGING). cargo test ely_app --bin ely_app: 102
passed.
DISCLOSURE_TOP_PX, DISCLOSURE_LEFT_PX, and DISCLOSURE_WIDTH_PX
were three hardcoded numbers that drifted the moment anyone touched
the sidebar header layout: SHELL_INSET, header padding, picker row
geometry, or the workspace tile size. Replace them with
WorkspaceDisclosureAnchor::solve(sidebar_width) — a pure function
whose algebra is the geometric inverse of render_sidebar_header,
expressed over named layout constants (HEADER_PT, PICKER_BUTTON_SIZE,
etc.) the renderer already applies.
Picker resize now flows: snapshot → sidebar_width → solve → anchor →
render_workspace_disclosure. Three regression tests lock the default
(66, 98, 180), invariance of left/top under resize, and zero-width
clamp. cargo test ely_app --bin ely_app: 101 passed.
Eleven parallel BTreeMaps (click_points, hover_points,
pending_scroll_deltas, scroll_offsets, typed_texts, viewport_bounds,
viewport_sizes, pending_viewport_sizes, states, etc.) made every
input bug a coordination problem across 11 disjoint maps with no
compile-time guarantee they stayed in sync. Collapse them into
BTreeMap<TabId, PerTabSurface>: one owner per tab, one lookup per
input event. keyboard_focus stays at the store level because only
one tab in the window can hold focus at a time.
Public API and behavior are unchanged; the previously-fixed
"scroll keeps keyboard_focus" semantics are preserved. cargo test
ely_browser_core + cargo test ely_app --bin ely_app green.
The Moon icon previously navigated to ely://settings/appearance,
which is misleading for a button visually framed as a one-tap theme
control. Add cycle_theme_mode (System → Light → Dark → System) and
swap the icon between Sun and Moon to mirror the active state.
The profile chip in the sidebar footer used a chevron-down icon, which
universally signals "this opens an inline popover," but its handler
just navigates to ely://settings/profiles. Swap to chevron-right so
the icon honors what the click actually does.
The per-row tab close (×) button hovered with rgba(0x281e1414) — 8%
alpha — which read as no hover at all on the cream panel. Bump to
~30% alpha (CLOSE_HOVER_BG) so the hit target snaps in like Arc/Dia.
1. Scroll no longer wipes keyboard focus. Servo holds DOM focus across
wheel events; the shell was clearing keyboard_focus and typed_texts
on every scroll, so a focused input went deaf the moment the user
scrolled. Scroll still drops the buffered click point because that
coordinate is captured against the pre-scroll viewport.
2. Mouse-down hands focus to the shell's root focus handle (in
addition to mouse-up's existing click forwarding). The user can now
start typing the moment they press the page, instead of having to
first complete a click round-trip to escape the omnibar's focus.
3. Sidecar hover() honors the requesting webview_id instead of
defaulting to the first webview in the map, so multi-tab sidecars
no longer pipe every hover into tab #1.
Two issues kept the close button unusable:
- The launcher-row close button lacked flex_shrink_0, so on narrow
sidebars the title swallowed the 16 px hit target before flex laid
it out.
- The handler did select_tab(close_id) → close_active_tab(); if the
newly-selected tab routed through split-view close logic the call
silently no-op'd against the user's intent.
Add flex_shrink_0 on both launcher and tab close buttons, and route
the click through a new close_tab_by_id helper that calls
BrowserCore::close_tab(tab_id) directly.
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.
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.
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.
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.
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.
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.
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`.
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.
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.
`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.
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.
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.
The backdrop closed the picker on mouse_down but didn't consume the
event. The same press would propagate to the top div and the same
click sequence's mouse_up could then land on whatever element ended
up under the cursor (a tab, a button) once the backdrop unmounted on
the next render — dismissing the popover would inadvertently fire a
second action.
Add `cx.stop_propagation()` after the close so the dismiss click ends
at the backdrop. Standard popover semantics: outside-click closes,
consumed.
The split-pane reload glyph had `cursor_pointer` + an `on_click` that
called `select_tab` — clicking the reload button just re-selected the
already-active tab. That's a fake handler.
Make the glyph visibly disabled (INK_5, no cursor, no click) until a
real per-tab reload action lands in BrowserCore. `refresh_tab` exists
but is `pub(super)` and only flips the discard state; surfacing it as
"reload" would mislead.
The back/forward arrows in the topbar were styled as live buttons
(cursor_pointer, hover swap) but they had no on_click — they were
clickable affordances that did nothing. Per the "no fake handlers"
rule that's worse than no button at all: it lies to the user.
Pull the cursor and hover off, drop the text color to INK_5 so they
read as the design's `disabled` state (`var(--ely-ink-5)`), and
leave them untouched until real per-tab history lands in
BrowserCore. When the navigation API arrives the call site can
swap to a live variant.
This is the actual root cause of "vertical tabs not closeable" —
`render_tab_row` (the rich title+URL rows used in the TABS section)
never rendered a close glyph. Only `render_launcher_row` (favorites
and pinned) had one. So every regular tab in the sidebar was
permanently un-closeable from the sidebar UI; the only path to close
was Cmd+W or the menu.
Restructure render_tab_row from a flex_col (title above URL) to a
flex row: title + URL stack on the left under flex_1+min_w_0, close
glyph on the right with the same group_hover opacity-0 → 1 pattern
the launcher rows use, plus stop_propagation so the row's own
on_click doesn't re-select the just-closed tab.
The two text rows now use `truncate` instead of `overflow_hidden` so
long titles ellipsize cleanly inside the constrained width.
The disclosure anchors to fixed window coords sized for the picker pill
in the expanded sidebar. If the sidebar is collapsed or hidden when
`workspace_picker_open` is true, the popover would float disconnected
from any visible trigger. Gate the render on
`!sidebar_collapsed && !sidebar_hidden` so the popover only shows when
the pill is actually painted.
The launcher row's `on_click` selects the tab. Its child close glyph
also has its own `on_click` that selects + closes. After the close,
the click bubbled up to the row, which then tried to re-select the
tab we just removed — usually a no-op but a wasted state churn that
can race with the close in BrowserCore.
Add `cx.stop_propagation()` after the close handler so the click
ends at the close glyph.
Previously the disclosure was an absolute child of the picker row,
clipped by the sidebar's overflow_hidden, with no way for an outside
click to close it. Real popovers (Arc, Linear, Raycast) close the
moment you click anywhere else, and they spill past their host
container so a long workspace list isn't cut at the sidebar edge.
Lift the disclosure to the render_browser tree:
* `render_workspace_disclosure_backdrop` paints a fullscreen
transparent layer that closes the picker on mouse-down — no
on_click so the close fires immediately, before any synthetic click.
* `render_workspace_disclosure` paints next, pinned to fixed
window-relative anchor coords (top 98, left 66, width 180) that
match the picker pill's row in the default sidebar layout.
The picker row inside the sidebar header drops the inline disclosure
child entirely.
cargo test --workspace: 440 passed, 0 failed.
User reports the sidebar can't be resized. Until now sidebar width
was only mutable through the COLLAPSED/DEFAULT toggle and through
typed settings. Real browsers (Arc, Dia, Zen) all let you drag the
right edge of the sidebar to size it live.
Wire it through the existing space.sidebar_width_px:
* `ElyShell.sidebar_resize_origin: Option<(f32, u16)>` records the
cursor-x and width-px at the moment the handle is grabbed.
* `begin_sidebar_resize` / `end_sidebar_resize` set and clear it.
* Window-level `on_mouse_move` consults the origin first; while held,
it forwards the delta to `set_active_sidebar_width` clamped to
220–480 px so you can't accidentally annihilate either pane.
* Window-level `on_mouse_up` releases the drag.
* The handle itself is a 6 px transparent strip pinned to the right
edge of the expanded sidebar, `cursor_col_resize`, with a soft white
hover. mouse-down captures the origin; the rest is window events.
cargo test --workspace: 440 passed, 0 failed.
The design's `--ely-font-sans` is Geist; we were registering only
Newsreader. Body text everywhere fell through to GPUI's
`.SystemUIFont` (SF Pro on macOS) — too humanist for the geometric,
calm tech aesthetic the design wants.
Bundle Geist-Regular (SIL OFL 1.1, 126 KB), register it alongside
Newsreader, and set the root window's `.font_family(SANS_FAMILY)`.
SERIF_FAMILY callsites continue to override per element so headlines
keep using Newsreader.
Only Regular is bundled — GPUI synthesizes weights from the metrics,
so 500/600 still read correctly. Bold/italic file additions are a
later polish.
Picking a workspace previously expanded the sidebar header inline:
the disclosure list was a normal flex child, so opening it shoved
the home anchor row, every launcher, and the tab list down by ~200
px. User correctly flagged this as a popover, not an accordion.
Move the disclosure under the picker row as an absolute overlay
(`.relative()` on the picker row + `.absolute().top_full()` on the
disclosure) with the existing fade_in animation. Picker row height
stays constant whether the disclosure is open or closed; the list
floats over whatever lives beneath it inside the sidebar's clip.
Picking a space still calls `close_workspace_picker`, and toggling
the pill still hides it. Outside-click dismiss is the next polish
pass.
Round 12 left the sidebar header at `pt(36)` so the title row landed
two rows below the macOS traffic lights — the screenshot shows them
stacked vertically instead of sharing a row. The design renders the
dots and the workspace title side-by-side.
Tighten the header's top inset to 8 px and reserve the first 68 px of
the title row for the traffic lights via `TRAFFIC_LIGHT_RESERVE`. The
title now sits inline with the dots and the workspace picker row
follows underneath at the design's gap.
Round 12 painted the inner highlight ring through an absolutely
positioned overlay that covered every glass panel. User reports the
right pane was unclickable, the search bar wouldn't take input, and
sidebar tab close buttons never appeared on hover. Even though the
overlay div had no listeners, in this layout it was racing the
parent's hit-test for the same pixels — the close glyph in
`render_launcher_row` is `opacity(0)` until `group_hover` fires, and
the overlay was preventing that hover from registering.
Move the highlight onto each panel's own `.border_1()` so the ring is
part of the panel paint, not a separate overlay. Painted, never
hit-tested. The four wired callers (expanded sidebar, compact
sidebar, main pane, command overlay panel) now each carry their
inner border directly. `chrome::glass` deletes; nothing else used it.
The 1 px brighter top-edge specular sliver from the design is gone —
GPUI 0.2.2 has no asymmetric border colors and live clicks beat that
single-pixel polish.
cargo test --workspace: 440 passed, 0 failed.
The design's TopBar.url placeholder reads `Search ELY or type a
command…`. We were shipping `Search or enter address`, the generic
URL-bar string from when the omnibar didn't yet host commands.
Match the design copy so the empty-state on `ely://new-tab` matches
home.jsx and command.jsx exactly.
home.jsx renders the Open Notion pill with `<Brand.Notion s={12}/>` —
the actual N-in-a-rounded-square mark. We were drawing a generic
BookOpen icon there, so the pill read as just another quick action
instead of a branded shortcut.
Split `render_pill` so it accepts an `AnyElement` leading slot; the
two icon-only pills go through `render_pill_icon`, the Notion pill
hands in `render_glyph_for(Some("notion.so"), …, 14.0)` directly.
cargo test --workspace: 440 passed, 0 failed.
Match plugins.jsx, where every card cover gets a different pastel→bold
gradient (Pink+Blue, Mint+Violet, Cream+Coral, etc). Previously every
card painted the same Pink→Blue ramp, making the marketplace grid feel
uniform.
`plugin_cover_gradient(name)` hashes the plugin name into the design's
8-stop palette so the same plugin always lands on the same ramp without
needing any new manifest fields.
cargo test --workspace: 440 passed, 0 failed.
Match home.jsx's `View all history <I.ArrowRight size={11}/>` rather
than the unicode arrow we shipped earlier — same gap-4 lockup, real
icon. Trivial visual swap; no behavior change.
The design's home hero pairs the greeting with a Sunrise glyph in the
warm horizon orange (#e89a6e). Previously we showed `IconName::Sun`
in `ACCENT_LIGHT` regardless of phase, so an evening user saw a noon
sun next to "Good evening, Alex".
Promote the day phase to a `DayPhase` enum so hero.rs can pick the
glyph + tint per phase: warm orange Sun in the morning, amber Sun in
the afternoon, cool violet Moon in the evening. Tests now compare
against the enum instead of the former `&'static str` wrapper.
Bonus: third quick-launch pill swaps from "Open History" (Undo2) to
"Open Notion" (BookOpen → notion.so) to match the design's third pill.
cargo test --workspace: 440 passed, 0 failed.