Commit Graph
100 Commits
Author SHA1 Message Date
ZacharyZhang-NY 7b2b6daee9 Land a red TDD guard for the rendered-canvas click pipeline (T7)
After 840255f put the input overlay on-screen and a80d039 dropped
the file-system pixel pipe, the two harness baselines confirm:

  * `.occlude() + capture_any_mouse_up` works in TestAppContext
    (baseline_overlay_div_receives_simulated_click)
  * `input_overlay`'s exact listener combo works in isolation
    (baseline_overlay_with_full_listener_combo_receives_click)

…and the layout regression guard confirms the overlay is now drawn
inside the visible window. Yet running a real ElyShell, navigating
to https://example.com/, and dispatching a real MouseDown/MouseUp at
the geometric center of the measured viewport STILL leaves
WebSurfaceStore.click_point as None. The capture phase listener is
being eaten somewhere strictly inside the real ElyShell widget tree.

`user_click_in_rendered_web_canvas_reaches_input_pipeline` encodes
this contract in user terms — "click on the rendered page and the
input pipeline records it" — without naming a GPUI mechanism. The
test is marked `#[ignore]` so the rest of the suite stays green; the
attribute carries the full reproduction note so a reader picking the
ticket up later doesn't have to rediscover what we already know
(layout + pixel-pipe both clean, listener combo clean, suspicion now
on sibling z-order / ancestor stop_propagation / overflow_hidden
content_mask clipping the overlay's hitbox).

The fix commit must DELETE the attribute outright; toggling the
ignore reason is a broken contract. Running
`cargo test -- --ignored user_click_in_rendered_web_canvas_reaches_input_pipeline`
today reproduces the failure with click_at = (1106, 567) inside
viewport_bounds (309, 71, 1594, 992).

cargo test --bin ely_app: 112 passed, 0 failed, 1 ignored.
cargo test --bin ely_app -- --ignored: 1 failed (expected RED).
2026-05-10 19:13:46 -04:00
ZacharyZhang-NY a80d0393e9 Drop the file system from the live frame pixel pipe
Every live frame was round-tripping through the local file system:
the sidecar called `fs::write(rgba_out, frame.rgba_bytes())` in
`poll_frame`, the JSON response carried `rgba_path`, and the main
process turned around and called `fs::read(rgba_path)` to lift the
bytes back into a `Vec<u8>`. At 1080p that is 8 MB of syscall +
memcpy + page cache traffic per frame; at 60 fps it dwarfs every
other cost in the pipeline and shows up as scroll/zoom jank the user
can feel before any other bottleneck.

Replace it with a same-pipe binary protocol. The sidecar writes the
JSON `LiveResponse` line as before, then writes the raw RGBA frame
bytes on the same stdout immediately after the trailing `\n`. The
client `read_line`s the JSON, parses `rgba_byte_count` from the
header, and `read_exact`s exactly that many bytes from the same
`BufReader<ChildStdout>` (the buffered reader drains its own buffer
before pulling from the child). No tmpfs directory, no
`fs::remove_dir_all` on drop, no `rgba_path` field, no per-frame
filename plumbing.

Boundary defence on the client side: the header's `rgba_byte_count`
is cross-checked against `width * height * 4` before any allocation
or `read_exact`. A buggy or compromised sidecar can no longer ask
the GPUI process to allocate an arbitrarily large buffer or park on
`read_exact` for a payload that will never arrive.

The sidecar process boundary stays exactly where it was; only the
pixel transport between the two processes changes. The `one-shot`
sidecar binary path (used by `tests/sidecar.rs` and PRD smoke tests)
still writes to its CLI-supplied `--rgba-out` path — those tests
were untouched and continue to pass 9/9.

cargo test --bin ely_app: 112 passed.
cargo test -p ely_servo_host --features servo-engine --test sidecar: 9 passed.

Follow-ups deferred to the profile step (T9):
  * the host still copies its rendered buffer to a transient
    `Vec<u8>` via `frame.rgba_bytes().to_vec()` before write_all;
    expose `&[u8]` straight to `write_all` once the profile shows
    that allocation in the top five.
  * `poll_frame` calls `snapshot` twice per iteration; harmless
    under the software renderer but worth folding into a single
    snapshot once we have numbers.
  * raw memcpy bandwidth is still ~480 MB/s at 60 fps 1080p; the
    GPU-side fix (T10: OffscreenRenderingContext + IOSurface
    zero-copy) is the next material change.
2026-05-10 19:04:34 -04:00
ZacharyZhang-NY 840255f88c Lift the web canvas out of in-flow so the input overlay lands on screen
A GPUI harness boots a real ElyShell, navigates to an external URL, and
asks the input_overlay's sibling canvas tracker where it laid out. On
main before this change the canvas reports

    Bounds { origin: (309, window_height - 17), size: (W - 326, content_h) }

i.e. the overlay's top edge sits at the very bottom of the visible
window. Every user click in the visible area lands above (or beside)
the overlay; the on_mouse_down + capture_any_mouse_up listeners never
even see the event because the hitbox is off-screen. Twelve commits
chased focus/coords/outcome enums on the sidecar side while every click
in the live shell hit empty space.

Root cause: in render_web_surface the rendered web image (img / loading
div / error page) was a non-absolute child of a `.relative().size_full()`
wrapper. The non-absolute child claims `size_full` block-flow height
inside that wrapper, which made the wrapper's intrinsic height
content_height + content_height. The two `.absolute().size_full()`
siblings (viewport_tracker, input_overlay) then sized against that
inflated parent and were positioned in the bottom half — exactly
content_height below where they were supposed to be.

Fix: keep the relative wrapper as the layout owner of the panel slot
(size_full, overflow_hidden, min_w_0) and put the rendered image into
an absolute `inset_0` child of its own. viewport_tracker and
input_overlay stay as absolute siblings. With the image out of in-flow
the wrapper sizes to its parent and the overlay's hitbox lands at
y = top of content area (71 in a 1080-tall window) instead of
y = window_height - 17.

GPUI test harness (`gpui_harness_tests.rs`) is the holdout set:
  - `baseline_overlay_div_receives_simulated_click` proves GPUI's
    occlude + capture_any_mouse_up primitive works under TestAppContext.
  - `baseline_overlay_with_full_listener_combo_receives_click` proves
    the exact listener combo render_input_overlay uses works in
    isolation.
  - `ely_shell_external_canvas_lays_out_inside_window` boots a real
    ElyShell, navigates, and asserts the overlay's measured bounds fit
    inside the visible window. Without the fix above, this test trips
    on bounds extending below the window bottom.

The three new store-layer tests in web_surface_tests.rs pin per-tab
isolation, zero-delta short-circuit, and resize-mid-drain decoupling
invariants the harness work flushed out.

ely_app picks up gpui's test-support feature as a dev-dependency so the
harness can use VisualTestContext + simulate_mouse_*.

cargo test --bin ely_app: 112 passed (was 108 + 4 new harness/store tests).

Remaining work (not in this commit): even with the layout fixed, the
harness shows MouseUp's capture_any_mouse_up still doesn't fire on the
ElyShell tree, while MouseDown's bubble does. Some sibling/ancestor
listener in the live shell is eating the MouseUp capture phase that
the standalone listener-combo baseline does not. Tracked separately.
2026-05-10 17:38:10 -04:00
ZacharyZhang-NY 6314e1f777 Make WebView input invariant a property of the dispatch path
Servo's hit-test silently absorbs notify_input_event on a hidden or
unfocused WebView. Today show()+focus() are called at creation and on
the first-navigate rebuild, but every later sibling-WebView creation
also calls focus() — silently stealing focus from the foreground tab.
load() (later navigates), resize, set_page_zoom, and paint never
re-focus, so a click on the visible tab can land on an unreachable
WebView and disappear.

Move the invariant from a state spread across creation/navigation/
tab-switching into a property of the dispatch path itself: a private
webview_for_input(id) helper re-asserts show()+focus() and returns
the WebView; click/hover/drag/touch_tap/scroll/type_text all go
through it. The cosmetic show/focus calls in create_webview_in_context
and the navigate-rebuild branch stay (first-frame paint), now annotated
to point to webview_for_input as the input-path owner.

Not a complete fix on its own. focus() goes through constellation_proxy
asynchronously (servo crate webview.rs:352), so debug_assert!(focused())
right after focus() would race the constellation — doc comment is the
only guard. Sidecar integration tests pass 9/9 but only exercise
single-WebView sessions; multi-tab focus stealing on Google / YouTube /
Twitter still needs human verification in the live shell. Linus's
critique of the GPUI-side data structure (PerTabSurface Option-as-queue
+ Ensure bundling config+input+frame) is a separate layer untouched
by this change.

Pre-existing tests/software_host.rs::manages_real_servo_webview_lifecycle
already times out on https://servo.org/ with state=Complete but
has_pending_frame=false on b8795bf without this change, so unrelated.
2026-05-10 13:59:04 -04:00
ZacharyZhang-NY b8795bf903 Name every silent input rejection with WebSurfaceInputOutcome
bool returns on the five record_* surface inputs collapsed nine real
outcomes into one bit. The cascade we found in this round — silent
click drop because viewport_bounds wasn't measured yet, then
keyboard_focus stays None, then typing also "fails" — looked like
three independent symptoms but was one root cause hiding inside
that bit. Replace the bool with a #[must_use] WebSurfaceInputOutcome
enum so each rejection names itself at the call site.

Variants map 1:1 to real return points in web_surface.rs:
  Applied / NoChange / Buffered — three distinct success-ish states
  the controller already needed to disambiguate (only Applied notifies)
  DroppedInvalidBounds — geometry rejected zero/NaN viewport
  DroppedNoViewportBounds — input arrived before the viewport tracker
  DroppedOutOfBounds — window position outside the viewport rect
  DroppedZeroDelta — wheel rounded to zero device px
  DroppedEmptyText — empty record_typed_text
  DroppedNoKeyboardFocus — type without a prior click
  DroppedFocusMismatch — focus belongs to another tab/url

Behavior preserved: Applied is the only notify trigger, matching the
old `true` semantics. Three new negative-path tests (no_viewport_bounds,
zero_delta, no_keyboard_focus) lock the named drops so a future
regression surfaces as a wrong variant in tests instead of a missing
repaint. cargo test ely_app --bin ely_app web_surface: 17 passed.
2026-05-10 02:00:52 -04:00
ZacharyZhang-NY c20daf36b9 Multiply input coords by window scale_factor so Retina clicks land
GPUI delivers logical (CSS) pixels but Servo expects device pixels.
On a 2x Retina display the page rendered at half resolution and every
click landed in roughly the upper-left quadrant of the page — the
second independent root cause that surfaces immediately after T1's
show()/focus() lets input reach Servo at all.

Single conversion boundary inside web_surface_geometry.rs:
viewport_dimension, scroll_dimension, and click_coordinate each
multiply once by window.scale_factor() before truncating to integer
device pixels. positive_scale_or_one() guards against a zero/negative/
NaN scale_factor reaching the arithmetic — falling back to 1.0 keeps
coordinates valid even if the platform reports nonsense (system
boundary validation per CLAUDE.md, not internal trust).

scale_factor is plumbed through controller and view layer, sourced
from window.scale_factor() at every record_* boundary so all four
inputs (click, scroll, hover, viewport) stay in sync. Existing tests
pin the 1.0 path; new retina_scale_factor_doubles_every_input_coordinate
locks the 2.0 path against a future regression.

cargo test ely_app --bin ely_app: 104 passed (was 103 + new test).
2026-05-10 01:53:51 -04:00
ZacharyZhang-NY 22ba8517d1 Show + focus the Servo WebView so it stops dropping every input event
Root cause: Servo's WebView is hidden+unfocused by default. notify_input_event
on a hidden WebView runs paint() hit-test, which returns no hit, and Servo
silently absorbs the event as "already handled". Eleven prior commits all
patched the GPUI side of input forwarding while every event landed in
exactly that black hole.

Fix: webview.show() + webview.focus() immediately after WebViewBuilder::build
in create_webview_in_context, and again in navigate() only on the
should_create_initial_document branch (the load() branch keeps existing
visibility+focus, otherwise a background tab finishing navigation would
steal focus from the foreground tab — Linus correctness ask).

Also unblocks the sidecar binary build, which had been frozen at the
May 9 13:53 stale binary because servo-engine feature was broken on two
fronts:

- ServoHost trait was missing the hover() method that the SoftwareServoHost
  impl declared (regression from "Plug three holes" commit eb58ce7).
- Servo SDK renamed Key::Enter / Backspace / Tab / Escape / Delete /
  Arrow{Up,Down,Left,Right} / Home / End / Page{Up,Down} to
  Key::Named(NamedKey::*). keyboard.rs updated to match.

Sidecar binary rebuilt: 335 MB at May 10 01:44. The earlier 11 commits
were never reaching users because they couldn't recompile the sidecar
without these two upstream-API repairs.
2026-05-10 01:46:39 -04:00
ZacharyZhang-NY 25ff653d83 Polish vertical tab lifecycle: hover ≠ active, close as a coin
Active and hover used the same ACTIVE_NAV_BG, so a selected row gave
no feedback when the cursor crossed it — the contrast ladder was a
single rung. Replace it with a four-state ladder shared by the home
anchor, launcher rows, and tab rows:

  rest   → transparent   (HOVER_NAV_BG hover)
  active → ACTIVE_NAV_BG (ACTIVE_NAV_BG_HOVER hover)

The choice lives in nav_row_palette(active) so the ladder cannot
silently disagree across the three callers. Close (×) becomes an
18 px circular coin (was a 16 px square) with the warm-dark hover
wash from CLOSE_HOVER_BG, shared by both row variants via a single
render_row_close_button recipe — the button now reads as something
to press, not a glyph in a square.

Hoist sidebar_chrome.rs alongside sidebar.rs to host the shared
constants/helpers and keep both files under the 500-line ceiling
(444 / 198). cargo test ely_app --bin ely_app: 103 passed.
2026-05-10 01:28:39 -04:00
ZacharyZhang-NY 7c27f7308c Lock the input invariants with web_surface store regressions
Five PerTabSurface tests pin the input contract so future refactors
can't silently regress it:

- typed_text_enters_pending_input_after_clicked_viewport: typing
  reaches the sidecar once a click establishes keyboard focus.
- scroll_delta_enters_pending_input_after_wheel: scroll deltas
  combine across multiple wheel events.
- viewport_size_changes_after_stable_second_measurement: viewport
  resize is debounced behind a "same size twice" guard.
- scroll_after_click_keeps_keyboard_focus_and_typed_text: the bug
  fixed last commit cycle — scroll drops the buffered click point
  but must keep keyboard_focus and buffered keystrokes.
- typing_without_a_prior_click_is_rejected: typing without focus
  returns false, so a future refactor can't accept stray keystrokes.

No instrumentation eprintln/log added in production paths
(per CLAUDE.md NO LOGGING). cargo test ely_app --bin ely_app: 102
passed.
2026-05-10 01:22:30 -04:00
ZacharyZhang-NY 1e85dad291 Solve workspace popover anchor from runtime sidebar width
DISCLOSURE_TOP_PX, DISCLOSURE_LEFT_PX, and DISCLOSURE_WIDTH_PX
were three hardcoded numbers that drifted the moment anyone touched
the sidebar header layout: SHELL_INSET, header padding, picker row
geometry, or the workspace tile size. Replace them with
WorkspaceDisclosureAnchor::solve(sidebar_width) — a pure function
whose algebra is the geometric inverse of render_sidebar_header,
expressed over named layout constants (HEADER_PT, PICKER_BUTTON_SIZE,
etc.) the renderer already applies.

Picker resize now flows: snapshot → sidebar_width → solve → anchor →
render_workspace_disclosure. Three regression tests lock the default
(66, 98, 180), invariance of left/top under resize, and zero-width
clamp. cargo test ely_app --bin ely_app: 101 passed.
2026-05-10 01:12:45 -04:00
ZacharyZhang-NY dc06d5590d Fold WebSurfaceStore into a single PerTabSurface owner
Eleven parallel BTreeMaps (click_points, hover_points,
pending_scroll_deltas, scroll_offsets, typed_texts, viewport_bounds,
viewport_sizes, pending_viewport_sizes, states, etc.) made every
input bug a coordination problem across 11 disjoint maps with no
compile-time guarantee they stayed in sync. Collapse them into
BTreeMap<TabId, PerTabSurface>: one owner per tab, one lookup per
input event. keyboard_focus stays at the store level because only
one tab in the window can hold focus at a time.

Public API and behavior are unchanged; the previously-fixed
"scroll keeps keyboard_focus" semantics are preserved. cargo test
ely_browser_core + cargo test ely_app --bin ely_app green.
2026-05-10 01:08:03 -04:00
ZacharyZhang-NY 21da001bf5 Make topbar moon button actually toggle theme
The Moon icon previously navigated to ely://settings/appearance,
which is misleading for a button visually framed as a one-tap theme
control. Add cycle_theme_mode (System → Light → Dark → System) and
swap the icon between Sun and Moon to mirror the active state.
2026-05-10 00:57:59 -04:00
ZacharyZhang-NY f2799e89c9 Sharpen sidebar polish: profile chip chevron + close-hover bg
The profile chip in the sidebar footer used a chevron-down icon, which
universally signals "this opens an inline popover," but its handler
just navigates to ely://settings/profiles. Swap to chevron-right so
the icon honors what the click actually does.

The per-row tab close (×) button hovered with rgba(0x281e1414) — 8%
alpha — which read as no hover at all on the cream panel. Bump to
~30% alpha (CLOSE_HOVER_BG) so the hit target snaps in like Arc/Dia.
2026-05-10 00:56:25 -04:00
ZacharyZhang-NY eb58ce75da Plug three holes in Servo input forwarding
1. Scroll no longer wipes keyboard focus. Servo holds DOM focus across
   wheel events; the shell was clearing keyboard_focus and typed_texts
   on every scroll, so a focused input went deaf the moment the user
   scrolled. Scroll still drops the buffered click point because that
   coordinate is captured against the pre-scroll viewport.

2. Mouse-down hands focus to the shell's root focus handle (in
   addition to mouse-up's existing click forwarding). The user can now
   start typing the moment they press the page, instead of having to
   first complete a click round-trip to escape the omnibar's focus.

3. Sidecar hover() honors the requesting webview_id instead of
   defaulting to the first webview in the map, so multi-tab sidecars
   no longer pipe every hover into tab #1.
2026-05-10 00:51:37 -04:00
ZacharyZhang-NY 84ec12e471 Make vertical tab close (×) buttons reliably hittable
Two issues kept the close button unusable:
- The launcher-row close button lacked flex_shrink_0, so on narrow
  sidebars the title swallowed the 16 px hit target before flex laid
  it out.
- The handler did select_tab(close_id) → close_active_tab(); if the
  newly-selected tab routed through split-view close logic the call
  silently no-op'd against the user's intent.

Add flex_shrink_0 on both launcher and tab close buttons, and route
the click through a new close_tab_by_id helper that calls
BrowserCore::close_tab(tab_id) directly.
2026-05-10 00:47:31 -04:00
ZacharyZhang-NY 7c33381063 Lift sidebar resize handle outside the rounded clip so it's hittable
The handle was a child of the rounded sidebar panel, which set
overflow_hidden — so the 6 px strip pinned at right(-2) was clipped to
the rounded edge and never reached the cursor. Split the panel into an
outer positioning wrapper plus an inner rounded panel, and place the
handle on the wrapper. Widen the strip to 8 px straddling the edge and
warm the hover tint so the affordance is visible during drag.
2026-05-10 00:46:44 -04:00
ZacharyZhang-NY 5287fccdec Fix Newsreader serif resolution by matching its real family name
The bundled Newsreader.ttf is the 16pt optical-size cut: its TrueType
name-id 1 reads "Newsreader 16pt", and "Newsreader" only appears in
name-id 16. GPUI/cosmic-text matches by name-id 1, so every
.font_family(SERIF_FAMILY) call site (hero headline, settings titles,
plugin pages, recap) was silently falling back to the default sans.
Setting SERIF_FAMILY to "Newsreader 16pt" pins the right font.
2026-05-10 00:45:54 -04:00
ZacharyZhang-NY 2352d4658d Persist settings nav across every settings sub-page
Each ely://settings/* route used to render its content alone, with no
nav column — so clicking a sidebar item replaced the entire page and
read to users as a brand-new tab opening. Move the nav column into a
shared render_settings_shell wrapper and route every settings/* URL
through it. ely://sync/status reuses the sync route highlight.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

No behavior change. cargo clippy -p ely_app: clean.
cargo test --workspace: 440 passed, 0 failed.
2026-05-09 22:22:36 -04:00
ZacharyZhang-NY cd4bac8e22 Stop propagation on workspace popover dismiss click
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.
2026-05-09 22:20:58 -04:00
ZacharyZhang-NY 2ceb00a95a Drop the fake reload glyph behavior on split-pane headers
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.
2026-05-09 21:59:50 -04:00
ZacharyZhang-NY 432879b27f Mark topbar nav arrows as visibly disabled until history wires up
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.
2026-05-09 21:58:26 -04:00
ZacharyZhang-NY 546c3f616d Add hover-reveal close glyph to every regular tab row
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.
2026-05-09 21:57:15 -04:00
ZacharyZhang-NY 4a29e2d23c Suppress workspace popover when sidebar isn't expanded
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.
2026-05-09 21:53:46 -04:00
ZacharyZhang-NY adb278b3bb Stop propagation on tab close so the row doesn't re-select
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.
2026-05-09 21:45:03 -04:00
ZacharyZhang-NY 41b5208006 Move workspace popover to window root + dismiss on outside click
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.
2026-05-09 21:42:37 -04:00
ZacharyZhang-NY 85d1e09916 Add a draggable sidebar resize handle
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.
2026-05-09 21:16:23 -04:00
ZacharyZhang-NY 573eaae4a8 Bundle Geist as the body sans
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.
2026-05-09 21:12:56 -04:00
ZacharyZhang-NY ed2d8ffac2 Float workspace picker disclosure instead of pushing tabs
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.
2026-05-09 21:06:52 -04:00
ZacharyZhang-NY 8ef638af6f Inline sidebar title with macOS traffic lights
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.
2026-05-09 21:04:40 -04:00
ZacharyZhang-NY 2d484a7c50 Fix dead-pane regression: drop the absolute highlight overlay
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.
2026-05-09 21:02:48 -04:00
ZacharyZhang-NY dbba9fedd0 Use design's omnibar placeholder copy
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.
2026-05-09 20:47:23 -04:00
ZacharyZhang-NY e7d7a9cc90 Use real Notion brand glyph on the home Open Notion pill
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.
2026-05-09 20:46:11 -04:00
ZacharyZhang-NY 573bf4bdf6 Diversify plugin cover gradients
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.
2026-05-09 20:45:18 -04:00
ZacharyZhang-NY 21b2135188 Use design's ArrowRight glyph on the home activity link
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.
2026-05-09 20:43:28 -04:00
ZacharyZhang-NY c27776ca42 Tint home greeting glyph by time of day
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.
2026-05-09 20:39:26 -04:00
ZacharyZhang-NY c85023170c Lift split panes onto two-layer drop shadow
Match the design's `0 1px 0 rgba(0,0,0,0.05), 0 12px 30px -16px
rgba(0,0,0,0.18)` shadow stack on every split pane card and tighten
inter-pane gap from 12 px (`gap_3`) to the design's 10 px. Without the
ambient shadow the panes read as flat seams against the wallpaper;
with it they sit on the canvas the way the design renders them.

Grid axis now also gets a 10 px row gap so the second row doesn't
butt up against the first.

cargo test --workspace: 440 passed, 0 failed.
2026-05-09 20:35:47 -04:00
ZacharyZhang-NY cbcc45bd7b Match split-pane header to design (accent dot + tab title)
The design's split.jsx Pane header leads with an 8 px brand-accent dot,
the lock indicator, host name in INK, then the tab title in INK_4.
The previous header rendered a 24 px brand glyph plus the URL path —
visually heavier and less informative once both panes share most of
their URL prefix.

* brand_glyph: `brand_accent_color(brand)` and `accent_color_for_host`
  collapse each Brand to one design-matching dot color (Figma 7c6cf7,
  Linear 5e6ad2, etc.).
* split_pane: 32 → 30 px header, glyph → 8 px dot, URL path → tab
  title in INK_4. `pane_path_label` deleted; nothing else used it.

cargo test --workspace: 440 passed, 0 failed.
2026-05-09 20:34:06 -04:00
ZacharyZhang-NY a92f478e21 Composite glass: inset highlights, blink, fade, panel tint
Closes the three "platform-impossible" gaps from the design audit
without resorting to hacks or shaders.

* chrome::glass — `render_inner_highlight(radius)` paints the design's
  `box-shadow: inset 0 0 0 1px` ring as an absolute overlay div with a
  1 px top-edge highlight sliver. GPUI 0.2.2's BoxShadow has no inset
  flag, so the composite is the only way to keep the panel content
  unclipped while the ring rides on top.
* chrome::animations — `blink` and `fade_in` wrap GPUI's first-class
  `with_animation` driver. Square-wave caret in the command header,
  180 ms opacity ramp on the command backdrop and workspace
  disclosure list.
* chrome::sidebar — panel_bg now tints toward the active wallpaper
  theme so the wallpaper bleeds through every glass surface, the
  closest honest substitute for backdrop-filter without a shader pass.
* command_overlay split: row helpers move to chrome::command_rows so
  command_overlay drops from 504 → 232 lines, well under the 500-line
  ceiling. No behavior change.

cargo test --workspace: 440 passed, 0 failed.
2026-05-09 20:29:08 -04:00
ZacharyZhang-NY e392db1cd7 Match home section spacing to design (48 + 16, not uniform 40)
The design uses asymmetric spacing — 48 px between the hero and the
favorites grid, and only 16 px between favorites and the Continue +
Activity recap row. The previous uniform gap(40) flattened both gaps
to the same value. Wrap each section in a div with the correct margin
so the lower row hugs the favorites strip the way the design does.
2026-05-09 20:17:28 -04:00
ZacharyZhang-NY b438bb2349 Use real reading-list count on the home cover badge
The reading-list cover badge previously read "Reading List · 1" when a
featured entry existed and "Reading List · 0" otherwise — a binary
flag stamped onto a count format. The user's actual reading list can
hold many entries, and the design clearly intends the badge to show
the total ("Reading List · 5" in the mock).

Pass snapshot.reading_list.len() through render_reading_list_cover so
the badge mirrors the real count.
2026-05-09 20:16:30 -04:00
ZacharyZhang-NY 211b1351af Cap command overlay results height at design's 440 px
Design overflow scoping uses maxHeight:440 on the inner results region,
not the whole panel. The previous max_h(540) on the panel capped the
overall card height (header + results + footer) and let the results
expand to fill, which clipped the footer when many sections were
visible.

Now the panel grows to fit content while the results section caps at
440 px and overflows internally — matches the design's
panel-with-scrollable-middle layout exactly.
2026-05-09 20:15:52 -04:00
ZacharyZhang-NY 89f4541d91 Match topbar nav arrows + omnibar padding to design
- Nav-back / nav-forward now render lucide ArrowLeft / ArrowRight to
  match the design's TopBar exactly. Chevron arrows read as accordion
  toggles in this design language; arrow heads read as page navigation.
- Omnibar uses uniform px(14) horizontal padding instead of asymmetric
  pl(14) pr(8), so the inner chips inherit the design's symmetric breathing
  room. The flex/gap layout already keeps the chips at the right edge.
2026-05-09 20:15:13 -04:00
ZacharyZhang-NY 49e4816bce Wire ↑↓↵ keyboard navigation in the command overlay
The footer chips already advertised ↑↓ to navigate and ↵ to open;
they now match reality.

- chrome::command_match exposes COMMAND_ACTIONS / matching_actions
  alongside the tab/history/bookmark match helpers and a
  CommandSelection enum + visible_command_rows that returns the flat
  ordered list of activatable rows. command_overlay drops its private
  copies of the action const and matcher and consumes them from the
  shared module so render and key handling share one source of truth.

- ElyShell tracks command_selected_index with command_select_next /
  command_select_prev (cyclic, no notify when index doesn't change)
  and activate_selected_command which dispatches the right shell call
  for the currently selected CommandSelection variant and dismisses
  the overlay. Dismissing command mode resets the index to 0.

- The shell root captures key_down via on_command_overlay_key_down.
  When the live snapshot's command_query starts with '>', up / down
  / enter run the matching helper and the event stops propagating so
  the omnibar input doesn't move its caret.

- Each rendered row receives a `selected` flag. The selected row gets
  the design's tinted bg + accent-bar on the left edge so the active
  result is unambiguous at any keyboard step.
2026-05-09 20:12:31 -04:00
ZacharyZhang-NY 4403eacf15 Auto-reveal hidden sidebar on cursor reach
Cursor-reach reveal now fulfils the "Slide in on cursor reach"
description on the design's Hidden-on-hover layout card.

- BrowserCore::active_space_sidebar_width() returns the current
  sidebar tier without cloning a full BrowserSnapshot, so the
  mouse_move hot path stays cheap.
- on_window_mouse_move on the shell root hits-tests the cursor x
  against REVEAL_THRESHOLD_PX (24 px from the left edge). Outside
  the reveal/collapse zones it returns immediately, so 99 % of
  mouse moves never even read the snapshot.
- When in HIDDEN mode and the cursor crosses the reveal threshold,
  expand_hidden_sidebar fires (its early-return on already-expanded
  state prevents notify spam). When the cursor passes
  COLLAPSE_THRESHOLD_PX (shell inset + default sidebar width + 24 px
  buffer), collapse_hidden_sidebar fires.
- Click-to-expand on the rail and click-on-backdrop-to-collapse
  remain as predictable fallbacks.
2026-05-09 20:06:11 -04:00
ZacharyZhang-NY 9a2f9f2d74 Add hidden-on-hover sidebar mode
Domain ships HIDDEN_SIDEBAR_WIDTH_PX = 8 alongside the existing
collapsed/default tiers. ElyShell tracks sidebar_hover_expanded with
expand_hidden_sidebar / collapse_hidden_sidebar helpers; switching
back to a non-hidden width via the layout cards or core API resets
the flag automatically so the sidebar can never be both hidden and
expanded after a mode change.

Renderer:
- render_sidebar takes a sidebar_hidden flag and routes to a thin
  8 px clickable rail (hover bg + click expands) when the active
  space's width is at HIDDEN.
- While the rail is expanded, render_browser overlays a transparent
  backdrop + the full default-width sidebar absolutely positioned in
  the shell inset, so the main pane content never reflows.
- collapsed_sidebar_active still drives the COLLAPSED-tier compact
  sidebar; the hidden tier is opted out of that path.

Appearance form:
- Layout cards section gains the design's third "Hidden on hover"
  card with a 6 px sliver preview that mutates the active space to
  HIDDEN_SIDEBAR_WIDTH_PX. LayoutMode now derives id/width/preview
  from a small enum so adding a fourth mode would be one match arm.

The hover-expanded state never persists into the domain; once the
user switches modes or clicks the backdrop, it collapses cleanly.
2026-05-09 20:01:31 -04:00
ZacharyZhang-NY c98ca18613 Add workspace switcher disclosure list
Clicking the picker pill now opens an in-flow space-list disclosure
beneath the workspace row instead of cycling to the next space. The
list shows every space's emoji glyph + name, highlights the active one
with the accent check, and on row click switches to that space and
closes the disclosure.

Sidebar disclosure rules:
- The chevron flips ChevronDown → ChevronUp when open so the affordance
  reads at a glance.
- A "Manage spaces" footer routes to ely://settings/spaces and closes
  the picker.
- ElyShell carries a workspace_picker_open: bool with toggle / close /
  select_space_from_picker helpers; render_sidebar_header receives a
  &ElyShell so it can read the open flag without leaking shell internals
  to free helpers.

The existing SelectNextSpace shortcut still routes through
cycle_to_next_space, so the keyboard cycle behaviour is unchanged.
2026-05-09 19:49:37 -04:00
ZacharyZhang-NY 797560d71c Replace translucency presets with a real continuous slider
ElyShell now hosts a translucency_slider: Entity<SliderState> bound to
0..=100 step 1, defaulting to DEFAULT_TRANSLUCENCY_PCT. A subscription
on SliderEvent::Change writes the rounded value into the core via
set_translucency_pct, so dragging the thumb mutates the persisted
appearance setting in real time.

The appearance form swaps the static track-and-thumb visual for the
gpui_component Slider (160 wide) plus a live percentage readout. The
three preset chips move below the row as fast-set buttons that go
through a new set_translucency_pct_from_preset helper which writes
both core and the SliderState so the thumb tracks the chip choice.

reset_appearance now resets the slider to DEFAULT_TRANSLUCENCY_PCT
alongside resetting the core, keeping every UI source of truth in
lock-step.
2026-05-09 19:46:43 -04:00
ZacharyZhang-NY 3108f3265b Add translucency setting + apply to runtime panel alpha
Domain:
- AppearanceSettings gains translucency_pct (u8, 0..=100, default 40)
  with a clamping setter and serde round-trip coverage.
- DEFAULT_TRANSLUCENCY_PCT and MAX_TRANSLUCENCY_PCT exported for the
  shell.

Core:
- BrowserCore::set_translucency_pct delegates to the appearance struct;
  the existing reset_appearance covers the reset path.
- Integration test covers persistence into snapshot.appearance.

Render:
- chrome::sidebar::panel_bg(snapshot) replaces the static PANEL_BG
  constant, mapping the user's translucency_pct linearly into the alpha
  byte 0xff..0xb3. Sidebar (expanded + compact) and main pane consume
  the helper so changing the setting at runtime updates every glass
  surface in lock-step.

Form:
- Translucency row in chrome::appearance_form mirrors the design's
  static track + thumb visual driven by the persisted percentage, plus
  three preset chips (Solid 0 / Default 40 / Glassy 75) that mutate the
  setting through shell.set_translucency_pct.

Strict UX rule preserved: alpha never drops below 0xb3 so panels stay
readable without backdrop blur (which GPUI 0.2.2 doesn't expose).
2026-05-09 19:39:11 -04:00
ZacharyZhang-NY 22186bb8a4 Match Continue card column ratio + add Cmd+K omnibar focus
- Continue card: switch from grid_cols(2) (50/50) to flex with a
  flex-1 history column and a fixed 240 px reading-list cover, matching
  the design's grid-template-columns: 1fr 240px.
- Cmd+K (Ctrl+K on Windows/Linux) now aliases FocusAddressBar so the
  ⌘K affordance shown in Arc/Dia/Linear-style designs lands on the
  omnibar without disturbing the existing Cmd+L mapping.
2026-05-09 19:35:46 -04:00
ZacharyZhang-NY 70b50f9852 Wire plugin marketplace search to filter the grid
Replaces the static "Install plugin · N active" hero button with the
design's pair: a glass search input + a primary Install action. The
input is bound to a new InputState on ElyShell (plugin_search_input);
on every render the catalog reads its current value and filters the
plugin grid by case-insensitive contains on name, author, and
description. An empty-needle render shows every installed plugin so
the page still works at rest.

When the search yields no matches, the grid swaps to an empty-state
copy that quotes the user's needle. Install action moves into a
trailing dark chip that calls choose_plugin_package, preserving the
existing file-picker flow.
2026-05-09 19:30:17 -04:00
ZacharyZhang-NY 6419609eba Add Bookmarks section to command overlay
Design's command switcher exposes Open tabs / History / Bookmarks /
Actions. The implementation now includes a Bookmarks section sourced
from snapshot.bookmarks: filtered by lowercase needle on title and
URL, capped at RESULT_LIMIT, rendered with brand glyphs, opening the
URL on click.

Match helpers (matching_tabs / matching_history / matching_bookmarks)
move into chrome::command_match so command_overlay.rs stays under the
500-line ceiling. Ask ELY remains skipped — no AI surface in domain
yet, no fabrication.
2026-05-09 19:28:18 -04:00
ZacharyZhang-NY 3903004ede Surface unread counts on sidebar launcher rows
The design's Slack/Linear/Gmail rows show numeric badges (12, 3) that
real apps publish in their tab title prefix — "(12) Slack | …",
"(3) Inbox — Linear", "(99+) Gmail". Add a pure parser
ely_domain::parse_title_unread_count that recognises the leading
"(N)" pattern and surfaces it through BrowserTab::unread_count(); the
sidebar launcher row renders a glass pill badge whenever the count
is non-zero, capped at "99+" for very high counts.

No domain field, no fabrication: the badge appears only when a real
website publishes its own unread count via the title. Tests cover
canonical formats, non-prefixed titles, leading whitespace, and
non-numeric / overflow inputs.
2026-05-09 19:25:31 -04:00
ZacharyZhang-NY 3856c2ea70 Resolve clippy warnings introduced this round
- AppearanceSettings derives Default instead of carrying a manual impl
  that's identical to the derived one.
- topbar::render_lock_or_search collapses the duplicated Search arms
  into a single fallback so clippy stops flagging identical blocks.
- command_overlay row helpers bundle id/title/hint/keys into a small
  CommandRowContent struct so render_row, render_row_with_glyph, and
  render_row_inner stay under the 7-arg threshold without losing any
  call-site clarity.
2026-05-09 19:08:19 -04:00
ZacharyZhang-NY 68e784daf8 Reveal close glyph on sidebar launcher hover
Each sidebar launcher row now ships with a 16 px close glyph that fades
in via group_hover. Clicking the glyph selects the row's tab then
closes the active tab — the same flow the split pane close uses.
Matches the design's .ely-nav-item .close opacity-0 → 1 transition
without keeping the X visible at rest.
2026-05-09 19:05:55 -04:00
ZacharyZhang-NY bf41ec3dd1 Highlight Appearance in settings nav on /settings landing 2026-05-09 19:04:53 -04:00
ZacharyZhang-NY 20f3740089 Add Sidebar / Layout cards to appearance form
Below the appearance rows the Appearance form now exposes the design's
"Sidebar / Layout" section: serif "Layout" sub-headline plus a 2-up
preview-card grid wired to the active space's sidebar_width — Single
column lands on DEFAULT_SIDEBAR_WIDTH_PX, Compact lands on
COLLAPSED_SIDEBAR_WIDTH_PX. Each card draws a miniature sidebar +
canvas preview so the choice reads at a glance.

The shell exposes a new set_active_sidebar_width helper alongside the
existing toggle so the cards mutate state without re-implementing the
toggle logic. The "Hidden on hover" preset from the design is omitted
until that mode actually ships — no placeholder option.

The cards live in chrome::appearance_layout_cards so appearance_form
stays under 500 lines.
2026-05-09 19:04:14 -04:00
ZacharyZhang-NY a84db2a152 Layer reading-list cover with pink + blue overlays
The Continue card's reading-list cover was a flat 2-stop gradient. The
design layers a cream base with a pink upper-right glow and a blue
lower-left glow. Add two diagonal overlay layers that fade to
transparent so the cover feels more atmospheric without changing the
clickable surface. Adds purposeful hover/active feedback so the cover
acts visibly clickable.
2026-05-09 19:02:20 -04:00
ZacharyZhang-NY b0b4ac3791 Redesign plugin detail page in design's 320 + 1fr grid
Plugin detail now opens with the design's marketplace card: a
4/3 brand-gradient cover plus install/secondary buttons plus a
permissions list with risk badges on the left, and a category
overline plus serif Newsreader title plus description plus a
4-up real-data stat grid (permissions, high-risk, contributes,
signature) plus a "What it adds to ELY" contributions list on
the right. Every value comes from PluginManifest — no fabricated
ratings, install counts, or feature copy.

Internal: chrome::plugin_detail_view holds the layout, and
chrome::plugin_labels owns the permission scope labels and
contribution copy so plugin_detail_view stays under 500 lines.
The internal_pages/plugin_details.rs shim only handles route
parsing and the missing-plugin fallback.
2026-05-09 19:01:39 -04:00
ZacharyZhang-NY ed3150f9ed Bundle Newsreader serif and apply to hero text
Embeds Newsreader (variable opsz/wght, OFL-licensed) into the
ely_app binary via include_bytes!, registers it through GPUI's
text_system at startup (chrome::typography::register_serif_fonts),
and exposes the SERIF_FAMILY constant for downstream surfaces.

Apply the family to every page hero where the design uses
ely-serif: home headline, settings/appearance headline, plugin
marketplace headline, sync headline, plugin detail name, and the
home reading-list cover headline. The OFL license file ships
alongside the .ttf assets to satisfy the font's redistribution
clause.
2026-05-09 18:57:40 -04:00
ZacharyZhang-NY 273ac07ceb Make /settings landing render the appearance form
Per the design, ely://settings opens directly on the appearance form
(serif "Appearance" headline + wallpaper grid + theme/accent/motion
rows), not a route summary. The settings layout's right column now
delegates to render_appearance_form so /settings and /settings/appearance
share the same canonical form rendering.

The metric cards / "Jump in" list helpers and the METRIC_BG constant
are deleted — they were only referenced by the previous summary right
column. Left nav still highlights Appearance as active when the user
lands on /settings.
2026-05-09 18:54:31 -04:00
ZacharyZhang-NY 3c2d16d870 Untrack stray claude-goal embedded repo 2026-05-09 18:50:38 -04:00
ZacharyZhang-NY a35e0f7872 Rebuild /settings/appearance as the design form
The appearance route now opens with the design layout: serif "GENERAL"
overline + "Appearance" headline + intro paragraph, a four-tile
wallpaper picker (Dawn/Violet/Mint/Slate with active outline + check
glyph), a theme-mode segmented control, an accent swatch row, a
reduce-motion toggle, and a reset row. Every control mutates real state
through new shell methods (set_wallpaper_theme, set_theme_mode,
toggle_reduce_motion, reset_appearance) which delegate to the core.

The chrome lives in chrome::appearance_form so the page module is a
six-line shim. Light/Dark theme buttons currently switch the persisted
mode; rendering swaps will land when light/dark token sheets ship —
keeping the persistence so the eventual flip is one place.
2026-05-09 18:50:33 -04:00
ZacharyZhang-NY 9780fdbc8c Route domain WallpaperTheme through render_wallpaper
The shell now reads snapshot.appearance.wallpaper and feeds the domain
WallpaperTheme directly into render_wallpaper. The chrome enum is
deleted; the four design themes (Dawn / Violet / Mint / Slate) all map
to base + upper-blob + lower-blob HSLA palettes that approximate the
design's radial gradients with GPUI's 2-stop linear gradient pair.

Switching themes via core.set_wallpaper_theme now propagates through
snapshots into the next render.
2026-05-09 18:48:54 -04:00
ZacharyZhang-NY 53dc40986a Wire AppearanceSettings into BrowserCore + snapshot
BrowserCore now owns an AppearanceSettings, exposes it through
BrowserSnapshot.appearance, and offers set_wallpaper_theme,
set_theme_mode, set_reduce_motion, reset_appearance mutators. Defaults
match the domain defaults (Dawn / System / motion-on) so existing
behaviour is unchanged for callers who don't touch appearance.

Integration tests cover initial snapshot defaults, mutation persistence
across snapshots, and reset behaviour.
2026-05-09 18:48:10 -04:00
ZacharyZhang-NY 71088bccb0 Add AppearanceSettings to ely_domain
Introduces a small persistent appearance contract: WallpaperTheme
(Dawn/Violet/Mint/Slate), ThemeMode (System/Light/Dark), reduce_motion.
Defaults to Dawn + System + motion-on. Field accessors and setters live
on the struct so the core can mutate without exposing internals; serde
support uses kebab-case so the on-disk form matches the design CSS
naming.

Tests cover defaults, mutation independence, and a JSON round-trip with
the kebab-case names. serde_json is added as a dev-dependency only —
the production crate does not depend on it.
2026-05-09 18:46:58 -04:00
ZacharyZhang-NY db70ca142c Extract command footer into chrome::command_footer
command_overlay.rs grew past 500 lines after wiring brand glyphs.
Move the footer renderer, the keyboard hint chunks, and the kbd chip
helper into their own module so the overlay file stays under budget
without changing any rendered output.
2026-05-09 18:44:30 -04:00
ZacharyZhang-NY 60400a78d1 Normalize hover/active feedback across chrome surfaces
Brings hover/active opacity feedback to a consistent 0.92/0.82 baseline
across the omnibar content, the home search pill, and the workspace
picker tile + add button. The home search pill and the omnibar styled
URL display previously had no hover state at all, so the click target
felt invisible. The motion design framework wants every interactive
element to acknowledge hover and click; this change closes the gap
without introducing new keyframe animations (GPUI 0.2.2 has no
transition layer to drive them).
2026-05-09 18:43:03 -04:00
ZacharyZhang-NY a8deebcf1a Render omnibar host bold + path muted when not editing
The design omnibar shows the host in ink-1 weight 500 and the path in
ink-3, switching to a plain editable address only while the user types.
The shell now compares the input value to the active tab URL: when they
match (and the URL isn't ely://new-tab) the omnibar renders the styled
host/path display; clicking it routes through focus_address_bar which
re-mounts the Input via the same listener that already drove typing.

Internal: render_lock_or_search picks Search vs Globe based on the URL
scheme so insecure pages get a different leading glyph in display mode.
2026-05-09 18:42:15 -04:00
ZacharyZhang-NY c62954344a Brand glyph + reload control in split pane header
Replace the colored accent dot in each split pane header with the
host's brand glyph, and add the design's reload control next to the
close glyph. The header height bumps from 30 to 32 px so the 16 px
glyph sits cleanly between the 11 px lock and host text.
2026-05-09 18:40:51 -04:00
ZacharyZhang-NY 057079886d Brand glyphs in command overlay tab + history rows
Open tabs and History results in the command switcher now lead with the
brand glyph for their host (Notion, GitHub, Figma, Linear, etc.) instead
of a generic Globe / Undo2. The action rows still use IconName glyphs
because they map to internal navigation, not external sites.

Internal: split row rendering into render_row_inner so both the icon
and glyph variants share the same hover/click body.
2026-05-09 18:40:02 -04:00
ZacharyZhang-NY 967e5cb3d9 Brand glyphs in home Continue + Recent activity rows
Replace the rounded-square initial in the Continue history rows and the
generic Globe icon in the Recent activity rows with brand glyphs derived
from each entry's URL host. Unknown hosts still use the gradient initial
fallback so unbranded sites stay legible. Adds a hover opacity feedback
to both rows so the click affordance matches the rest of the home tiles.
2026-05-09 18:39:19 -04:00
ZacharyZhang-NY cd6d8dea32 Rebuild sidebar nav to match design's launcher + tabs structure
The previous sidebar listed FAVORITES / PINNED / SPACES / TABS / ARCHIVE
sections side-by-side. The design opens with a single Home anchor row,
then promotes favorites and pinned tabs as Arc-style launcher rows
(brand glyph + label, no section label between Home and the launchers),
then a TABS · {count} section with a + New Tab row at the end.

Spaces are no longer listed as nav rows because the workspace picker
already cycles through them; archived tabs move out of the sidebar (the
ely://archive route still exposes them). The footer is split into a
Settings row + a profile row that opens ely://settings/profiles. Every
launcher tab now resolves its icon through brand_glyph::glyph_for_host,
so figma.com/github.com/etc surfaces show the design's stylized glyphs.
2026-05-09 18:38:24 -04:00
ZacharyZhang-NY 7762d500c9 Add brand glyph module and use it on the home favorites grid
The design distinguishes apps by stylized brand glyphs (Notion N,
YouTube ▶, Dribbble dot, X 𝕏, Vercel ▲, Behance Bē, Figma F,
Slack #, GitHub ◯, Linear L, plus reading and news clusters). Add
chrome::brand_glyph with a host → Brand dispatcher so any place that
shows an app icon can route through one helper instead of inventing
fallback initials.

Wire it into the home favorites tile first — tabs whose host matches a
known brand now show the proper glyph; unknown hosts still get the
gradient initial fallback. Tests cover canonical hosts, www/subdomain
normalization, the news/reading clusters, and unknown rejection.
2026-05-09 18:35:58 -04:00
ZacharyZhang-NY 445276aff4 Extract editor's pick into its own module
plugin_catalog.rs was 516 lines after the marketplace redesign. Move
the editor's pick card, the featured plugin selector, and the shared
action_button helper into plugin_editors_pick.rs so the catalog file
stays under the 500-line per-module budget.
2026-05-09 18:21:48 -04:00
ZacharyZhang-NY ab8a08a754 Split pane chrome out of splits.rs into chrome::split_pane
splits.rs had grown to 551 lines after adding the design's pane header.
Move the header renderer, the close glyph, the URL helpers, and the
compact-canvas placeholder into chrome::split_pane so splits.rs is back
to a 422-line action + render orchestrator and the new helpers stay
under their own 150-line file.

Behaviour preserved: the pane click still selects the tab, the close
glyph still selects+closes, and pane_host/path/secure still derive from
the same UrlText fields.
2026-05-09 18:20:14 -04:00
ZacharyZhang-NY b5d9a8bbfc Recompose plugin marketplace as hero + grid + editor's pick
The marketplace landing now matches the design's two-row hero (serif
"Quiet tools. Everyday magic." headline + native-to-ELY tagline on the
left, an Editor's Pick card on the right that promotes the first
enabled plugin or, when none are installed, an empty-state CTA), a row
of category chips with sandbox audit count, and a 4-column card grid.

Each plugin card uses real manifest data (name, author, description,
permission counts, sandbox flag) and routes to ely://plugin/{id} on
click. Empty state surfaces an honest "drop a signed .rplug" message
plus the install action. Nothing on the page is fabricated catalog data.
2026-05-09 18:18:20 -04:00
ZacharyZhang-NY 42f67b794e Recompose sync page in design's marketing layout
Sync now opens with the design's two-column structure: a serif headline
and Cloudflare Sync status pill on the left with a local-queue card
showing pending/failed counts and a reset action, and a "What syncs"
card on the right that lists every SyncObjectKind with a state dot,
local count, and a working policy toggle.

The connection label is honest: until Better Auth integration ships, it
reads "Local-only · sign-in coming soon" rather than displaying a fake
sign-in form. All toggles dispatch real shell.set_sync_object_policy
calls so the state changes are persisted through the existing core API.
2026-05-09 18:16:41 -04:00
ZacharyZhang-NY cc43c1ed01 Restructure settings landing as design's nav + content split
Settings now opens with the design's two-column layout: a 232 wide nav
column on the left with grouped sections (General, Account, Power,
About) and a content column on the right that leads with a serif
headline, snapshot metric cards, and a "Jump in" list of every settings
route. Each nav entry and jump row deep-links to the existing internal
page so the destinations remain real.

The previous settings.rs is now a thin shim that calls into the chrome
module — keeps the module under 500 lines and lets future
sub-page redesigns share the same shell layout.
2026-05-09 18:14:59 -04:00
ZacharyZhang-NY 7f1870295d Add command-mode floating switcher overlay
Whenever the command query starts with the existing '>' prefix the shell
now overlays a 640-wide floating panel — backdrop dim, glass card, three
ranked sections (Open tabs from snapshot.tabs, History from
snapshot.history_entries, and a fixed action set for workspace + key
internal routes), and a footer with the design's keyboard hints. Each
result is a real navigation hop; selecting a row dispatches the
appropriate shell action and clears the command query so the overlay
dismisses without leaving stale chrome state.

The overlay reads from the live BrowserSnapshot rather than fabricating
results, and matches case-insensitively against title and URL. Empty
queries surface the most recent items so the panel stays useful even
before the user types.
2026-05-09 18:12:49 -04:00
ZacharyZhang-NY 02760ffd10 Match split pane chrome to design
Each split pane now opens with the design's titlebar layout: an accent
status dot, a lock/globe glyph keyed off the URL scheme, the host in
ink-1 + the path in ink-4, and a close action on the right that selects
and closes the pane's tab. The body sits below a 1px hairline on the
panel cream so the panes feel like miniature browser windows instead of
borderless cards.

The pane click target stays on the outer container so clicking anywhere
in a pane focuses it without intercepting the close glyph.
2026-05-09 18:10:29 -04:00
ZacharyZhang-NY f136ac55c1 Move expanded sidebar list out of render.rs
render.rs was 525 lines because it carried both the shell orchestrator
and every sidebar row helper. The expanded sidebar (favorites, pinned,
spaces, tabs, archive sections plus the profile footer and the row
helpers) now lives in chrome::sidebar, where panel/soft shadow tokens
also moved. render.rs is back to a 150-line orchestrator that only
composes wallpaper, sidebar, topbar, and content area.

Behaviour preserved: render_tab_row stays available for tab_groups via
pub(crate) on impl ElyShell, and all existing on_action listeners and
keyboard captures remain wired in render_browser.
2026-05-09 18:08:42 -04:00
ZacharyZhang-NY 4d44057367 Add Continue + Recent activity recap to home
Home now ends with the design's two-card recap row: a "Continue where
you left off" card on the left (history list + reading-list cover that
opens the latest item, falling back to the reading list page when the
list is empty) and a "Recent activity" card on the right that shows
visited entries with relative time labels and a "View all history" link.

The previous open-tabs list is dropped from home — the sidebar already
exposes tabs and the design home doesn't repeat them. Source data is
real history_entries / reading_list off the snapshot; nothing is
fabricated. The home page is split into chrome/home/{hero, favorites,
recap, section, style, time}.rs so each file stays under 500 lines, and
internal_pages/new_tab.rs is now a thin shim that calls into it.
2026-05-09 18:06:18 -04:00
ZacharyZhang-NY 76eff3cf6e Restructure home hero with greeting and design suggestion pills
The home page now opens with the design's three-stage hero — a sun-icon
greeting row that picks morning/afternoon/evening from the system clock
and addresses the active profile by name, the serif headline, then a
54-tall search pill with an arrow chip and three contextual pills below
(Search Tabs, Switch Workspace, Open History).

Greeting derives "morning/afternoon/evening" from a UTC hour bucket,
which matches the design's 1500x940 dawn artboard well enough without
pulling in a tz-aware crate. Pure helper covered by unit tests so the
phase boundaries don't drift unintentionally.

The favourites grid is widened to 7 columns to match the design and
gains a plus-tile that routes to ely://bookmarks. Tabs section keeps the
existing data flow with a chevron section label to match the design's
collapsible affordance.
2026-05-09 18:01:37 -04:00
ZacharyZhang-NY 599ed51ce7 Restructure topbar to match design's omnibar + right action cluster
Move topbar rendering into chrome::topbar so it does not bloat render.rs.
The omnibar now ends in two glass chips (filters + favorite star) inline
with the address input, matching the design's pill layout. The toolbar
right cluster is rebuilt as copy-url, downloads, theme, and menu icons:

- Copy chip writes the active tab's URL to the system clipboard.
- Downloads opens ely://downloads (existing internal route).
- Theme chip routes to ely://settings/appearance for now (a Light/Dark
  toggle still has to live in appearance settings before it can be a
  one-shot).
- Menu opens ely://settings.

The pin/new-tab buttons are removed; they were not part of the design's
topbar. Pinning still works through the favorite chip + sidebar
selection state, and Cmd+T continues to create tabs.
2026-05-09 17:59:45 -04:00
ZacharyZhang-NY 7508767014 Replace sidebar header with workspace picker row
The design's sidebar opens with an ELY title row plus a three-part
workspace picker: a layout-grid tile (opens spaces settings), a glass
pill showing the active space's emoji + name (cycles to next space on
click), and a plus button (opens spaces settings). The previous header
was a static title + space-name label that did not match the design.

Cycle behaviour reuses the existing select_next_space core API to avoid
introducing a new state flow; the existing SelectNextSpace shortcut
delegates through the same helper.
2026-05-09 17:55:35 -04:00
ZacharyZhang-NY dcd4bb4c48 Add layered dawn wallpaper to shell background
Designs call for a radial-gradient dawn (cream base + pink upper-right
glow + blue lower-left glow). GPUI 0.2.2 only supports linear gradients,
so the wallpaper is composed of a cream base layer with two diagonal
linear gradients that fade to transparent at ~60% — the perceived effect
matches the design's softness without needing radial-gradient support.

Wallpaper is isolated to chrome::wallpaper so future appearance themes
(violet, mint, slate) extend the enum without touching the shell.
2026-05-09 17:52:56 -04:00
ZacharyZhang-NY 7dfefac569 Add gradient wallpaper and panel shadows for glass depth
The flat white background is the root visual difference from the
design reference. Replace it with a warm-to-cool diagonal linear
gradient (pink-beige to lavender-blue). Add BoxShadow to both
sidebar and main pane panels for floating depth. Use warmer panel
background (88% white) instead of 62% transparent. Add soft shadows
to active nav items and workspace tiles. Avatar uses gradient
matching the design reference.
2026-05-09 17:30:38 -04:00
ZacharyZhang-NY 3caf207129 update 2026-05-09 16:27:40 -04:00
ZacharyZhang-NY 86f558c65f Match new tab page to design reference dimensions
Hero heading at 64px, search bar at 54px height with 14px radius
and card-like background (85% white), favorites grid at 7 columns
with 16px card radius matching the design spec.
2026-05-09 16:25:19 -04:00