Dark mode was persistent in `AppearanceSettings` but never reached any
paint code: every call site in the shell read `colors::INK` etc. as a
`pub const u32`, so toggling `ThemeMode::Dark` mutated state nothing
sampled. Root-cause fix is to invert the contract — the design-system
exports functions that resolve through a thread-local `Mode`, and the
GPUI render impl sets that mode each frame.
What lands:
- `ely_design_system::colors::Mode` + thread-local + `set_mode` /
`mode` accessors. Every ink shade, glass surface, stroke, divider,
hairline, canvas, success / error chip now picks the warm-dark
counterpart when the active mode is `Mode::Dark`.
- `ElyShell::render` resolves `ThemeMode::System` against
`Window::appearance()` and pushes the mode before traversing the
tree, so widgets lower down read the right shade without owning a
`Mode` parameter.
- `render_wallpaper` + `panel_bg` now branch on `colors::mode()` so
the gradient base, panel tint, and overlay highlights flip to
warm-graphite when dark mode is active.
- Mechanical conversion across 687 call sites in 55 files from
`colors::FOO` constants to `colors::foo()` accessors. The
`Theme` / `ELY_THEME` const surface (unused outside the design
system) is removed; the function surface is the new contract.
Servo already publishes the live page title in every `LiveFrameReport`
but the renderer was dropping it on the floor — tabs that navigated
away from `ely://new-tab` kept showing "New Tab" forever, and there
was no favicon visible anywhere in the sidebar.
Add `BrowserCore::set_tab_title` and switch `set_tab_favicon_key` to
return `Ok(true)` only when the value actually changed; both methods
mirror the new value into the matching history entry so the History
page stays in lockstep. Derive the canonical `/favicon.ico` URL from
the loaded URL on `UrlText` and store it as the tab's `favicon_key`.
In the surface layer, every Ready frame now emits a
`WebSurfacePageMetadata` change alongside any `WebSurfaceUrlChange`,
and the controller applies title + favicon URL together. Render the
sidebar tab row's favicon via GPUI's HTTP image loader (falling
through to the URL-derived glyph for `ely://` pages, file URLs, and
hosts without a /favicon.ico endpoint).
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.
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.
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.
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 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.
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.
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.
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.
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.
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).
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.
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.
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.
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.