git.lucas.co / cce-browser
web browser (Servo)
git clone https://git.lucas.co/cce-browser.git

commit99c50a2e0cc9abec9adfcf08dba9b21fb3f40f62
parentcbf345e703
authorLucas Galante <[email protected]>
date2026-09-09 14:36
feat: favorites — a strip of one-click pills in the bar, apart from bookmarks

The star is the archive; favorites are the handful of places worth a
permanent spot. They show as a row of label pills between the tab row and
the controls row whenever there are any (no empty row — the bar stays two
rows otherwise, so controls_y is now measured from the bar's bottom edge).
Click loads in the active tab and folds the bar; middle-click opens a new
tab and leaves it out. Pills take their label's width up to FAV_MAX_W and
the strip stops at the bar's edge rather than scrolling.

Add/remove: Ctrl+Shift+D, the right-click menu's "Add to Favorites", or the
new `favorite` link on a bookmark row (which keeps the starred title).
cce://favorites (about:favorites, Ctrl+Shift+B) lists, reorders (up/down),
renames (a GET form per row) and removes; the store is favorites.tsv beside
bookmarks.tsv, sharing Entry and the TSV helpers. Labels default to the
title, else the host without www., else the file name; internal pages are
refused.

Both hosts carry the store and expose favorites()/active_favorited()/
toggle_favorite(); the chrome keeps a snapshot refreshed with the page state,
which is how the page's edits reach the strip. The Servo backend is mirrored
by analogy and not built.

Verified in a scale-2 shadow: add via route, pill hover, click-to-load and
fold, chord toggle both ways, context-menu entry, rename form, bookmarks
cross-reference. 19 tests pass (3 new for the store).

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md       |  46 +++++++--
 src/main.rs     | 177 ++++++++++++++++++++++++++++++--
 src/pages.rs    | 308 ++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 src/webview.rs  |  29 +++++-
 src/wpe/host.rs |  25 +++++
 5 files changed, 554 insertions(+), 31 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 91cea97..38fe1c2 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -21,7 +21,7 @@ Eight files, ~3k lines:
 | `src/instance.rs` | single-instance forwarding: a later launch hands its argument to the running instance's socket and exits |
 | `src/bin/open.rs` | `cce-browser-open`, the desktop entry's `Exec` target: a ~500KB forwarder linking only libc (~4ms vs ~22ms through the full binary), exec'ing `cce-browser` when no instance answers |
 | `src/webview.rs` | `ServoHost` — Servo boot, the delegate, one `WebView` per tab, the frame pipeline |
-| `src/pages.rs` | the `cce:` protocol handler and its History / Bookmarks stores |
+| `src/pages.rs` | the `cce:` protocol handler and its History / Bookmarks / Favorites stores |
 | `src/downloads.rs` | the chrome-side download pipeline (Servo has none) |
 | `src/session.rs` | open-tab persistence: the tab set survives a restart |
 | `src/settings.rs` | the per-app KDL config |
@@ -166,9 +166,34 @@ are choices, not accidents:
 There are **no `cce-ui` widgets in this app**. The whole utility bar is emitted as
 `PaintCtx` primitives in `display_list` (`display_list_text()` returns `true`), and
 every hit test in `handle_mouse_input` re-derives the same rects from the same
-`bar_rect`/`tab_rect`/`btn_rect`/`url_rect` helpers. **Draw and hit-test are two
+`bar_rect`/`tab_rect`/`btn_rect`/`url_rect`/`fav_rects` helpers. **Draw and hit-test are two
 readings of one geometry** — change a rect helper, not one call site.
 
+### Favorites are not bookmarks
+
+Two stores, two meanings. The **star** (`Ctrl+D`, `cce://bookmarks`) is the
+archive: everything worth finding again, newest first. **Favorites**
+(`Ctrl+Shift+D`, the right-click menu's "Add to Favorites", or the
+`favorite` link on a bookmark row; managed at `cce://favorites` /
+`about:favorites`, `Ctrl+Shift+B`) are the handful of places worth a
+permanent one-click spot: a **strip of label pills inside the bar**, between
+the tab row and the controls row. Click loads the favorite in the active tab
+and folds the bar (a menu pick); middle-click opens it in a new tab and
+leaves the bar out. Insertion order is strip order; the page reorders
+(▲/▼), renames (a GET form per row — form submissions reach the `cce:`
+handler like any other navigation) and removes.
+
+Geometry points that are choices: the bar has **no empty row** — with no
+favorites it is the two-row bar it always was (`bar_h(favorites)`), so
+`controls_y` is measured from the bar's *bottom* edge rather than counted
+down from the top. The strip does not scroll or wrap: pills take their
+label's width up to `FAV_MAX_W`, and `fav_rects` simply stops at the bar's
+edge, so a too-long strip loses its tail. The chrome keeps a snapshot
+(`favs`) refreshed with the rest of the page state, which is also how edits
+made on the `cce://favorites` page — on the way into a navigation — reach
+the strip. A label defaults to the page title, else the host (`www.`
+stripped), else the file name; internal pages are refused.
+
 ### The bar is a circle menu
 
 The chrome's persistent element is the **DE's corner control** —
@@ -230,10 +255,11 @@ applies the inverted delta itself.
 ## `cce://` pages
 
 `CceProtocol` registers the `cce` scheme with Servo's `ProtocolRegistry`, so
-`cce://history`, `cce://bookmarks`, `cce://downloads` and `cce://cookies` are **real
-pages fetched through Servo's network stack** and rendered like any other. That is why
-every mutating action is an ordinary link (`cce://history/clear`,
-`cce://bookmarks/remove?url=…`) — no chrome plumbing needed.
+`cce://history`, `cce://bookmarks`, `cce://favorites`, `cce://downloads` and
+`cce://cookies` are **real pages fetched through Servo's network stack** and rendered
+like any other. That is why every mutating action is an ordinary link
+(`cce://history/clear`, `cce://bookmarks/remove?url=…`,
+`cce://favorites/up?url=…`) — no chrome plumbing needed.
 
 ### Servo leaks a document per load — the biggest live hazard
 
@@ -262,9 +288,11 @@ therefore *cannot reach Servo itself*: `cce://cookies/clear` sets an `AtomicBool
 the next `pump` acts on via `site_data_manager()`. Anything else needing engine access
 from a page has to take the same route.
 
-History and bookmarks are TSV under `~/.local/state/cce/browser/`; `sanitize()` strips
-tabs and newlines because the format has no escaping. All four pages share the `page()`
-skeleton — restyle there, not per page.
+History, bookmarks and favorites are TSV under `~/.local/state/cce/browser/`;
+`sanitize()` strips tabs and newlines because the format has no escaping. All the
+pages share the `page()` skeleton — restyle there, not per page. A page's own
+`<style>` goes in through `head_extra`, which lands *before* the skeleton's, so
+an override has to out-specify it (`.e .w`, not `.w`).
 
 Clearing cookies is a **confirm-then-act page**, and Ctrl+Shift+Delete opens it rather
 than clearing outright: sessions persist now, so an accidental chord would sign the
diff --git a/src/main.rs b/src/main.rs
index 1f19595..9fbcadb 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -4,8 +4,8 @@
 //! finished frame is read back and uploaded to cce-ui's image registry,
 //! then drawn as a single quad under the chrome: the DE's circular corner
 //! control (`cce_ui::widget::plate_dock`), which here toggles the utility
-//! bar (tabs, back / forward / reload, URL field) that unfolds from under
-//! it. Input over the page area is translated into Servo input events; the
+//! bar (tabs, the favorites strip, back / forward / reload, URL field) that
+//! unfolds from under it. Input over the page area is translated into Servo input events; the
 //! URL bar is a small hand-rolled line editor.
 
 mod downloads;
@@ -53,8 +53,13 @@ pub enum EditingCommand {
 }
 
 const BAR_MARGIN: f32 = 10.0;
-/// Two rows: tab strip on top, nav controls + URL field below.
-const BAR_H: f32 = BAR_PAD + TAB_H + ROW_GAP + BTN_H + BAR_PAD;
+/// Two rows — tab strip on top, nav controls + URL field below — with the
+/// favorites strip between them whenever there is one to show. The bar
+/// does not carry an empty row: no favorites, no strip, two-row bar.
+fn bar_h(favorites: bool) -> f32 {
+    let favs = if favorites { FAV_H + ROW_GAP } else { 0.0 };
+    BAR_PAD + TAB_H + ROW_GAP + favs + BTN_H + BAR_PAD
+}
 const BAR_RADIUS: f32 = 10.0;
 const BAR_PAD: f32 = 7.0;
 /// Seconds for the bar to unfold from the corner control (and back).
@@ -79,6 +84,14 @@ const TAB_CLOSE_MIN_W: f32 = 72.0;
 const TAB_CLOSE_W: f32 = 18.0;
 const PLUS_W: f32 = 26.0;
 const ROW_GAP: f32 = 6.0;
+/// The favorites strip: a row of pills, each one page. Pills take their
+/// label's width up to `FAV_MAX_W`, and the strip simply stops at the bar's
+/// edge — favorites are a handful by design, not a scrolling list.
+const FAV_H: f32 = 22.0;
+const FAV_GAP: f32 = 4.0;
+const FAV_MAX_W: f32 = 150.0;
+const FAV_PAD_X: f32 = 9.0;
+const FAV_FONT: f32 = 12.0;
 /// Utility-bar fill; the negative alpha marks the plate as blur-behind.
 /// The blurred page is the base and this color tints it at |alpha|
 /// opacity — keep |alpha| low so the frosted content shows through.
@@ -221,6 +234,8 @@ enum CtxAction {
     /// Fetch through WebKit's download pipeline.
     Download(String),
     OpenExternal,
+    /// Add the page to the favorites strip, or take it out.
+    ToggleFavorite,
 }
 
 #[cfg(feature = "wpe")]
@@ -309,6 +324,13 @@ struct BrowserApp {
     font_system: cce_ui::cosmic_text::FontSystem,
     /// The open-tab set, persisted across restarts (see `session.rs`).
     session: session::Session,
+    /// The favorites store, shared with the host (and so with the
+    /// `cce://favorites` page, which edits it); `favs` is the strip as last
+    /// read from it, refreshed with the rest of the page state.
+    favorites: std::sync::Arc<pages::Favorites>,
+    favs: Vec<pages::Favorite>,
+    /// Hovered pill in the favorites strip — a repaint, like the dot.
+    fav_hover: Option<usize>,
 }
 
 fn hit(r: &Rect, x: f32, y: f32) -> bool {
@@ -320,16 +342,17 @@ fn hit(r: &Rect, x: f32, y: f32) -> bool {
 /// way, so nothing but the chrome geometry depends on this. Every other
 /// bar-relative rect below is derived from this one — never from
 /// `BAR_MARGIN` directly, or it would stay pinned to the top.
-fn bar_rect(win: (f32, f32), position: settings::BarPosition) -> Rect {
+fn bar_rect(win: (f32, f32), position: settings::BarPosition, favorites: bool) -> Rect {
+    let h = bar_h(favorites);
     let y = match position {
         settings::BarPosition::Top => BAR_MARGIN,
-        settings::BarPosition::Bottom => (win.1 - BAR_MARGIN - BAR_H).max(BAR_MARGIN),
+        settings::BarPosition::Bottom => (win.1 - BAR_MARGIN - h).max(BAR_MARGIN),
     };
     Rect {
         x: BAR_MARGIN,
         y,
         width: (win.0 - 2.0 * BAR_MARGIN).max(120.0),
-        height: BAR_H,
+        height: h,
     }
 }
 
@@ -338,11 +361,39 @@ fn tabs_y(bar: &Rect) -> f32 {
     bar.y + BAR_PAD
 }
 
-/// Y of the nav-controls row.
-fn controls_y(bar: &Rect) -> f32 {
+/// Y of the favorites strip — under the tabs, where it only exists when
+/// the bar was sized for it.
+fn favs_y(bar: &Rect) -> f32 {
     bar.y + BAR_PAD + TAB_H + ROW_GAP
 }
 
+/// Y of the nav-controls row: the bar's bottom row, whether or not the
+/// favorites strip sits above it, so it is measured from the bottom edge.
+fn controls_y(bar: &Rect) -> f32 {
+    bar.y + bar.height - BAR_PAD - BTN_H
+}
+
+/// The favorites strip's pills, one rect per favorite that fits, in strip
+/// order (index into the strip = index into the result). Widths follow the
+/// labels, which is why this takes the font: draw and hit-test both read it
+/// with the same font and get the same rects.
+fn fav_rects(bar: &Rect, favs: &[pages::Favorite], sans: &str) -> Vec<Rect> {
+    let mut rects = Vec::with_capacity(favs.len());
+    let right = bar.x + bar.width - BAR_PAD;
+    let mut x = bar.x + BAR_PAD;
+    for f in favs {
+        let w = (measure_text_width(&f.label, sans, FAV_FONT) + 2.0 * FAV_PAD_X)
+            .min(FAV_MAX_W)
+            .max(FAV_H);
+        if x + w > right {
+            break;
+        }
+        rects.push(Rect { x, y: favs_y(bar), width: w, height: FAV_H });
+        x += w + FAV_GAP;
+    }
+    rects
+}
+
 fn plus_rect(bar: &Rect, position: settings::BarPosition) -> Rect {
     Rect {
         x: bar.x + bar.width - BAR_PAD - dot_col(position, true) - PLUS_W,
@@ -417,6 +468,9 @@ fn parse_url_input(input: &str, search_prefix: &str) -> Option<Url> {
     if s.eq_ignore_ascii_case("about:bookmarks") {
         return Url::parse("cce://bookmarks").ok();
     }
+    if s.eq_ignore_ascii_case("about:favorites") {
+        return Url::parse("cce://favorites").ok();
+    }
     if s.eq_ignore_ascii_case("about:downloads") {
         return Url::parse("cce://downloads").ok();
     }
@@ -467,7 +521,30 @@ impl BrowserApp {
     /// The utility bar's rect for the current window size and configured
     /// edge — the single source every chrome hit-test and draw reads.
     fn bar(&self) -> Rect {
-        bar_rect(self.win, self.settings.bar_position)
+        bar_rect(self.win, self.settings.bar_position, !self.favs.is_empty())
+    }
+
+    /// The favorites strip's pills for the current bar.
+    fn fav_rects(&self, bar: &Rect) -> Vec<Rect> {
+        let (sans, ..) = cce_ui::layout::read_preferred_fonts();
+        fav_rects(bar, &self.favs, &sans)
+    }
+
+    /// Re-read the strip from the store. Called with the rest of the page
+    /// state, which is also when the `cce://favorites` page's edits — made
+    /// on the way into a navigation — become visible.
+    fn refresh_favorites(&mut self) {
+        let favs = self.favorites.snapshot();
+        if favs != self.favs {
+            self.favs = favs;
+            self.fav_hover = None;
+        }
+    }
+
+    /// Add or remove the active page from the favorites strip.
+    fn toggle_favorite(&mut self) {
+        self.host.toggle_favorite();
+        self.refresh_favorites();
     }
 
     /// Centre of the corner control: the bar's corner nearest the window
@@ -545,6 +622,7 @@ impl BrowserApp {
 
     /// Pull delegate-observed page state into the chrome.
     fn sync_page_state(&mut self) {
+        self.refresh_favorites();
         self.loading = self.host.loading();
         self.title = self.host.title().filter(|t| !t.is_empty());
         if !self.url_focused {
@@ -656,6 +734,16 @@ impl BrowserApp {
         items.push(item("Back", CtxAction::Back, self.host.can_go_back()));
         items.push(item("Forward", CtxAction::Forward, self.host.can_go_forward()));
         items.push(item("Reload", CtxAction::Reload, true));
+        let favorited = self.host.active_favorited();
+        let on_page = self
+            .host
+            .url()
+            .is_some_and(|u| !matches!(u.scheme(), "cce" | "about"));
+        items.push(item(
+            if favorited { "Remove from Favorites" } else { "Add to Favorites" },
+            CtxAction::ToggleFavorite,
+            favorited || on_page,
+        ));
         items.push(item("Open in Other Browser", CtxAction::OpenExternal, true));
 
         let h = CTX_PAD * 2.0 + items.len() as f32 * CTX_ROW_H;
@@ -690,6 +778,7 @@ impl BrowserApp {
             }
             CtxAction::Download(uri) => self.host.download_uri(uri),
             CtxAction::OpenExternal => self.open_external(),
+            CtxAction::ToggleFavorite => self.toggle_favorite(),
         }
     }
 
@@ -1068,6 +1157,8 @@ impl Application for BrowserApp {
         host.set_color_scheme_dark(settings.color_scheme.is_dark());
         #[cfg(feature = "wpe")]
         host.set_force_dark(settings.color_scheme.forces_dark());
+        let favorites = host.favorites();
+        let favs = favorites.snapshot();
         Self {
             host,
             settings,
@@ -1089,6 +1180,9 @@ impl Application for BrowserApp {
             sender,
             font_system: cce_ui::create_font_system(),
             session,
+            favorites,
+            favs,
+            fav_hover: None,
         }
     }
 
@@ -1248,6 +1342,16 @@ impl Application for BrowserApp {
             self.dot_hover = over_dot;
             *_needs_rebuild = true;
         }
+        let over_fav = if self.chrome_open && !self.favs.is_empty() {
+            let bar = self.bar();
+            self.fav_rects(&bar).iter().position(|r| hit(r, pos.x, pos.y))
+        } else {
+            None
+        };
+        if over_fav != self.fav_hover {
+            self.fav_hover = over_fav;
+            *_needs_rebuild = true;
+        }
         if !self.chrome_hit(pos.x, pos.y) {
             let s = self.scale as f32;
             self.host.mouse_move(pos.x * s, pos.y * s);
@@ -1345,6 +1449,24 @@ impl Application for BrowserApp {
                 self.close_chrome();
                 return None;
             }
+            // Favorites strip: a pill is a menu pick — load it here and fold
+            // — or, middle-clicked, a new tab, with the bar left out so
+            // several can be opened in a row.
+            if let Some(i) = self.fav_rects(&bar).iter().position(|r| hit(r, pos.x, pos.y)) {
+                let Ok(url) = Url::parse(&self.favs[i].url) else { return None };
+                if button == MouseButton::Middle {
+                    self.host.open_tab(url);
+                    self.url_focused = false;
+                    self.sync_page_state();
+                    self.persist_session();
+                } else {
+                    self.host.load(url);
+                    self.loading = true;
+                    self.close_chrome();
+                    self.sync_page_state();
+                }
+                return None;
+            }
             if button != MouseButton::Left {
                 return None;
             }
@@ -1485,6 +1607,19 @@ impl Application for BrowserApp {
                     *needs_rebuild = true;
                     return None;
                 }
+                // The favorites pair sits a Shift above the bookmarks pair:
+                // Ctrl+Shift+D toggles the page in the strip, Ctrl+Shift+B
+                // opens the page that manages it.
+                Key::Character(c) if event.shift && c.eq_ignore_ascii_case("d") => {
+                    self.toggle_favorite();
+                    *needs_rebuild = true;
+                    return None;
+                }
+                Key::Character(c) if event.shift && c.eq_ignore_ascii_case("b") => {
+                    self.open_internal_page("cce://favorites");
+                    *needs_rebuild = true;
+                    return None;
+                }
                 Key::Character(c) if c == "h" || c == "b" || c == "j" => {
                     let page = match c.as_str() {
                         "h" => "cce://history",
@@ -1677,6 +1812,28 @@ impl Application for BrowserApp {
                 TEXT,
             );
 
+            // Favorites strip: label pills, the hovered one lifted like an
+            // active tab. Labels are cut to the pill, never the other way.
+            let fav_rects = self.fav_rects(&bar);
+            for (i, r) in fav_rects.iter().enumerate() {
+                let hovered = self.fav_hover == Some(i);
+                pc.rounded_rect(
+                    *r,
+                    7.0,
+                    (true, true, true, true),
+                    if hovered { TAB_ACTIVE_BG } else { TAB_BG },
+                );
+                let label =
+                    Self::fit_text(&self.favs[i].label, &sans, FAV_FONT, r.width - 2.0 * FAV_PAD_X);
+                pc.text(
+                    label,
+                    r.x + FAV_PAD_X,
+                    cce_ui::layout::align_text_y(r.y, r.height, FAV_FONT, 0.0),
+                    FAV_FONT,
+                    if hovered { TEXT } else { TEXT_DIM },
+                );
+            }
+
             let labels = ["<", ">", "R"];
             let enabled = [self.host.can_go_back(), self.host.can_go_forward(), true];
             for (i, label) in labels.iter().enumerate() {
diff --git a/src/pages.rs b/src/pages.rs
index abdd286..7a790e1 100644
--- a/src/pages.rs
+++ b/src/pages.rs
@@ -1,7 +1,7 @@
 //! Internal `cce:` pages and their backing stores.
 //!
-//! History and bookmarks live as TSV files under the XDG state dir
-//! (`~/.local/state/cce/browser/`), with in-memory copies for rendering.
+//! History, bookmarks and favorites live as TSV files under the XDG state
+//! dir (`~/.local/state/cce/browser/`), with in-memory copies for rendering.
 //! The `cce:` protocol handler serves them back as real pages —
 //! `cce://history` and `cce://bookmarks` are fetched through Servo's
 //! network stack and rendered like any other page, so entries are
@@ -112,6 +112,7 @@ pub(crate) fn page(title: &str, meta: &str, body: &str, head_extra: &str) -> Str
              text-overflow:ellipsis;flex:1}}\
          .e a.rm{{color:#6f7177;font-size:12px;max-width:none}}\
          .e a.rm:hover{{color:#d49b9b}}\
+         .e .tag{{color:#7fa3d4;font-size:12px}}\
          .empty{{color:#8a8c92}}\
          </style></head><body>\
          <h1>{title}</h1>\
@@ -228,15 +229,33 @@ impl Bookmarks {
         write_tsv(&self.path, &entries);
     }
 
-    fn html(&self) -> String {
+    /// The title a bookmark was saved with, for promoting it to a favorite
+    /// from the bookmarks page without re-fetching anything.
+    pub fn title_of(&self, url: &str) -> Option<String> {
+        self.entries
+            .lock()
+            .unwrap()
+            .iter()
+            .find(|e| e.url == url)
+            .map(|e| e.title.clone())
+    }
+
+    fn html(&self, favorites: &Favorites) -> String {
         let entries = self.entries.lock().unwrap();
         let mut rows = String::new();
         for e in entries.iter().rev() {
             let title = if e.title.trim().is_empty() { &e.url } else { &e.title };
-            let enc: String = url::form_urlencoded::byte_serialize(e.url.as_bytes()).collect();
+            let enc = url_encode(&e.url);
+            // A bookmark that is already a favorite says so instead of
+            // offering to add it twice.
+            let fav = if favorites.contains(&e.url) {
+                "<span class=tag>favorite</span>".to_string()
+            } else {
+                format!("<a class=rm href=\"cce://favorites/add?url={}\">favorite</a>", html_escape(&enc))
+            };
             rows.push_str(&format!(
                 "<div class=e><span class=w data-ts=\"{}\"></span>\
-                 <a href=\"{}\">{}</a><span class=u>{}</span>\
+                 <a href=\"{}\">{}</a><span class=u>{}</span>{fav}\
                  <a class=rm href=\"cce://bookmarks/remove?url={}\">remove</a></div>\n",
                 e.ts,
                 html_escape(&e.url),
@@ -245,7 +264,10 @@ impl Bookmarks {
                 html_escape(&enc),
             ));
         }
-        let meta = format!("{} bookmarks", entries.len());
+        let meta = format!(
+            "{} bookmarks<a href=\"cce://favorites\">favorites</a>",
+            entries.len()
+        );
         let body = if entries.is_empty() {
             "<p class=empty>No bookmarks yet. Star a page or press Ctrl+D.</p>".to_string()
         } else {
@@ -255,10 +277,187 @@ impl Bookmarks {
     }
 }
 
+fn url_encode(s: &str) -> String {
+    url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
+}
+
+/// A favorite as the chrome shows it: the pill's label and where it goes.
+#[derive(Clone, Debug, PartialEq)]
+pub struct Favorite {
+    pub url: String,
+    pub label: String,
+}
+
+/// The label a favorite gets when it is added: the page title, or — for an
+/// untitled page — the host with any `www.` shorn off (the file name, for
+/// a `file:` URL, which has no host), so a pill never reads as a full URL.
+fn default_label(url: &str, title: &str) -> String {
+    let title = title.trim();
+    if !title.is_empty() {
+        return title.to_string();
+    }
+    let Ok(u) = url::Url::parse(url) else { return url.to_string() };
+    let host = u
+        .host_str()
+        .map(|h| h.trim_start_matches("www.").to_string())
+        .filter(|h| !h.is_empty());
+    let file = u
+        .path_segments()
+        .and_then(|mut segs| segs.next_back().map(str::to_string))
+        .filter(|f| !f.is_empty());
+    host.or(file).unwrap_or_else(|| url.to_string())
+}
+
+/// The favorites: a short, ordered, hand-curated list of places, shown as a
+/// row of pills in the utility bar. Deliberately not the bookmarks — the
+/// star is an archive of everything worth finding again; this is the
+/// handful of sites worth a permanent one-click spot. Insertion order is
+/// strip order, and the `cce://favorites` page reorders, renames and
+/// removes.
+pub struct Favorites {
+    entries: Mutex<Vec<Entry>>,
+    path: PathBuf,
+}
+
+impl Favorites {
+    pub fn load() -> Self {
+        let path = state_dir().join("favorites.tsv");
+        Self { entries: Mutex::new(read_tsv(&path)), path }
+    }
+
+    /// The strip, in order.
+    pub fn snapshot(&self) -> Vec<Favorite> {
+        self.entries
+            .lock()
+            .unwrap()
+            .iter()
+            .map(|e| Favorite { url: e.url.clone(), label: default_label(&e.url, &e.title) })
+            .collect()
+    }
+
+    pub fn contains(&self, url: &str) -> bool {
+        self.entries.lock().unwrap().iter().any(|e| e.url == url)
+    }
+
+    /// Add `url` to the end of the strip, or do nothing if it is there.
+    /// Internal pages are refused — a favorite pointing at a blank tab
+    /// helps nobody.
+    pub fn add(&self, url: &str, title: &str) {
+        if url.starts_with("cce:") || url == "about:blank" {
+            return;
+        }
+        let mut entries = self.entries.lock().unwrap();
+        if entries.iter().any(|e| e.url == url) {
+            return;
+        }
+        entries.push(Entry {
+            ts: now(),
+            url: sanitize(url),
+            title: sanitize(&default_label(url, title)),
+        });
+        write_tsv(&self.path, &entries);
+    }
+
+    /// Add or remove `url`; returns true when it is now a favorite.
+    pub fn toggle(&self, url: &str, title: &str) -> bool {
+        if self.contains(url) {
+            self.remove(url);
+            false
+        } else {
+            self.add(url, title);
+            self.contains(url)
+        }
+    }
+
+    pub fn remove(&self, url: &str) {
+        let mut entries = self.entries.lock().unwrap();
+        entries.retain(|e| e.url != url);
+        write_tsv(&self.path, &entries);
+    }
+
+    pub fn rename(&self, url: &str, title: &str) {
+        let mut entries = self.entries.lock().unwrap();
+        if let Some(e) = entries.iter_mut().find(|e| e.url == url) {
+            e.title = sanitize(&default_label(url, title));
+            write_tsv(&self.path, &entries);
+        }
+    }
+
+    /// Move `url` one place toward the front (`-1`) or the back (`1`).
+    pub fn shift(&self, url: &str, delta: isize) {
+        let mut entries = self.entries.lock().unwrap();
+        let Some(i) = entries.iter().position(|e| e.url == url) else { return };
+        let j = i as isize + delta;
+        if j < 0 || j >= entries.len() as isize {
+            return;
+        }
+        entries.swap(i, j as usize);
+        write_tsv(&self.path, &entries);
+    }
+
+    fn html(&self) -> String {
+        let entries = self.entries.lock().unwrap();
+        let mut rows = String::new();
+        let last = entries.len().saturating_sub(1);
+        for (i, e) in entries.iter().enumerate() {
+            let enc = url_encode(&e.url);
+            let label = default_label(&e.url, &e.title);
+            // Ordering links; the end pill has nowhere further to go.
+            let up = if i > 0 {
+                format!("<a class=rm href=\"cce://favorites/up?url={}\">&#9650;</a>", html_escape(&enc))
+            } else {
+                "<span class=rm>&#9650;</span>".to_string()
+            };
+            let down = if i < last {
+                format!("<a class=rm href=\"cce://favorites/down?url={}\">&#9660;</a>", html_escape(&enc))
+            } else {
+                "<span class=rm>&#9660;</span>".to_string()
+            };
+            rows.push_str(&format!(
+                "<div class=e><span class=w>{up} {down}</span>\
+                 <a href=\"{url}\">{label}</a><span class=u>{url}</span>\
+                 <form action=\"cce://favorites/rename\">\
+                 <input type=hidden name=url value=\"{url}\">\
+                 <input name=title value=\"{label}\" size=18>\
+                 <button>rename</button></form>\
+                 <a class=rm href=\"cce://favorites/remove?url={enc}\">remove</a></div>\n",
+                url = html_escape(&e.url),
+                label = html_escape(&label),
+                enc = html_escape(&enc),
+            ));
+        }
+        let meta = format!(
+            "{} favorites<a href=\"cce://bookmarks\">bookmarks</a>",
+            entries.len()
+        );
+        let body = if entries.is_empty() {
+            "<p class=empty>No favorites yet. Press Ctrl+Shift+D on a page, pick \
+             \"Add to Favorites\" from its right-click menu, or promote a bookmark.</p>"
+                .to_string()
+        } else {
+            rows
+        };
+        page("Favorites", &meta, &body, FAVORITES_CSS)
+    }
+}
+
+/// The rename form's styling, on top of the shared skeleton.
+const FAVORITES_CSS: &str = "<style>\
+    .e .w{min-width:3em}\
+    .e form{display:flex;gap:6px;margin:0}\
+    .e input{background:#111214;color:#dcdce1;border:1px solid #2c2d31;border-radius:5px;\
+             padding:2px 6px;font-size:12px;width:9em}\
+    .e button{background:#232427;color:#8a8c92;border:1px solid #2c2d31;border-radius:5px;\
+              padding:2px 8px;font-size:12px;cursor:pointer}\
+    .e button:hover{color:#dcdce1}\
+    .w a{margin-right:4px}\
+    </style>";
+
 /// `cce:` scheme: internal pages served straight out of the app.
 pub struct CceProtocol {
     pub history: Arc<History>,
     pub bookmarks: Arc<Bookmarks>,
+    pub favorites: Arc<Favorites>,
     pub downloads: Arc<crate::downloads::Downloads>,
     /// Raised by cce://cookies/clear. The handler runs on fetch threads and
     /// cannot reach Servo, so it flags the request and the app's next pump
@@ -297,20 +496,55 @@ impl CceProtocol {
     pub(crate) fn route(&self, url: &str) -> Option<String> {
         let full = url.trim_start_matches("cce://");
         let (path, query) = full.split_once('?').unwrap_or((full, ""));
+        let param = |key: &str| -> Option<String> {
+            url::form_urlencoded::parse(query.as_bytes())
+                .find(|(k, _)| k == key)
+                .map(|(_, v)| v.into_owned())
+        };
         match path.trim_end_matches('/') {
             "history" => Some(self.history.html()),
             "history/clear" => {
                 self.history.clear();
                 Some(self.history.html())
             }
-            "bookmarks" => Some(self.bookmarks.html()),
+            "bookmarks" => Some(self.bookmarks.html(&self.favorites)),
             "bookmarks/remove" => {
-                if let Some((_, target)) =
-                    url::form_urlencoded::parse(query.as_bytes()).find(|(k, _)| k == "url")
-                {
+                if let Some(target) = param("url") {
                     self.bookmarks.remove(&target);
                 }
-                Some(self.bookmarks.html())
+                Some(self.bookmarks.html(&self.favorites))
+            }
+            "favorites" => Some(self.favorites.html()),
+            // Adding lands on the favorites page so the new pill's place in
+            // the strip is visible right away. A bookmark promoted without a
+            // title in the query keeps the title it was starred with.
+            "favorites/add" => {
+                if let Some(target) = param("url") {
+                    let title = param("title")
+                        .or_else(|| self.bookmarks.title_of(&target))
+                        .unwrap_or_default();
+                    self.favorites.add(&target, &title);
+                }
+                Some(self.favorites.html())
+            }
+            "favorites/remove" => {
+                if let Some(target) = param("url") {
+                    self.favorites.remove(&target);
+                }
+                Some(self.favorites.html())
+            }
+            "favorites/up" | "favorites/down" => {
+                if let Some(target) = param("url") {
+                    let delta = if path.ends_with("up") { -1 } else { 1 };
+                    self.favorites.shift(&target, delta);
+                }
+                Some(self.favorites.html())
+            }
+            "favorites/rename" => {
+                if let Some(target) = param("url") {
+                    self.favorites.rename(&target, &param("title").unwrap_or_default());
+                }
+                Some(self.favorites.html())
             }
             "downloads" => Some(self.downloads.html()),
             "downloads/clear" => {
@@ -327,6 +561,58 @@ impl CceProtocol {
     }
 }
 
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    fn store() -> Favorites {
+        let dir = std::env::temp_dir().join(format!("cce-browser-favs-{}", std::process::id()));
+        let _ = fs::remove_dir_all(&dir);
+        Favorites { entries: Mutex::new(Vec::new()), path: dir.join("favorites.tsv") }
+    }
+
+    #[test]
+    fn labels_fall_back_to_host_then_file_name() {
+        assert_eq!(default_label("https://www.example.com/a", "Example"), "Example");
+        assert_eq!(default_label("https://www.example.com/a", "  "), "example.com");
+        assert_eq!(default_label("file:///home/me/page.html", ""), "page.html");
+        assert_eq!(default_label("about:blank", ""), "about:blank");
+    }
+
+    #[test]
+    fn strip_order_is_insertion_order_and_shifts_move_one_place() {
+        let f = store();
+        f.add("https://a.example/", "A");
+        f.add("https://b.example/", "B");
+        f.add("https://c.example/", "C");
+        f.add("https://b.example/", "again"); // already there: no duplicate
+        let labels = |f: &Favorites| f.snapshot().iter().map(|x| x.label.clone()).collect::<Vec<_>>();
+        assert_eq!(labels(&f), ["A", "B", "C"]);
+        f.shift("https://c.example/", -1);
+        assert_eq!(labels(&f), ["A", "C", "B"]);
+        f.shift("https://a.example/", -1); // already first: stays
+        assert_eq!(labels(&f), ["A", "C", "B"]);
+        f.rename("https://c.example/", "Sea");
+        assert_eq!(labels(&f), ["A", "Sea", "B"]);
+        assert!(!f.toggle("https://a.example/", "A"));
+        assert!(f.toggle("https://d.example/", "D"));
+        assert_eq!(labels(&f), ["Sea", "B", "D"]);
+
+        // Round-trips through the file.
+        let back = Favorites { entries: Mutex::new(read_tsv(&f.path)), path: f.path.clone() };
+        assert_eq!(back.snapshot(), f.snapshot());
+        let _ = fs::remove_dir_all(f.path.parent().unwrap());
+    }
+
+    #[test]
+    fn internal_pages_are_refused() {
+        let f = store();
+        f.add("cce://history", "History");
+        f.add("about:blank", "");
+        assert!(f.snapshot().is_empty());
+    }
+}
+
 #[cfg(feature = "servo")]
 impl ProtocolHandler for CceProtocol {
     fn load(
diff --git a/src/webview.rs b/src/webview.rs
index 2a359e4..7ee03d4 100644
--- a/src/webview.rs
+++ b/src/webview.rs
@@ -30,7 +30,7 @@ use servo::protocol_handler::ProtocolRegistry;
 use url::Url;
 
 use crate::downloads::{is_download_url, Downloads};
-use crate::pages::{Bookmarks, CceProtocol, History};
+use crate::pages::{Bookmarks, CceProtocol, Favorites, History};
 use crate::Message;
 
 /// Delegate-observed signals for one webview, polled by the app after each
@@ -225,6 +225,7 @@ pub struct ServoHost {
     delegate: Rc<Delegate>,
     history: std::sync::Arc<History>,
     bookmarks: std::sync::Arc<Bookmarks>,
+    favorites: std::sync::Arc<Favorites>,
     tabs: Vec<Tab>,
     active: usize,
     size_px: (u32, u32),
@@ -317,12 +318,14 @@ impl ServoHost {
 
         let history = std::sync::Arc::new(History::load());
         let bookmarks = std::sync::Arc::new(Bookmarks::load());
+        let favorites = std::sync::Arc::new(Favorites::load());
         let downloads = std::sync::Arc::new(Downloads::default());
         let clear_cookies = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
         let mut protocols = ProtocolRegistry::default();
         let handler = CceProtocol {
             history: history.clone(),
             bookmarks: bookmarks.clone(),
+            favorites: favorites.clone(),
             downloads: downloads.clone(),
             clear_cookies: clear_cookies.clone(),
         };
@@ -390,6 +393,7 @@ impl ServoHost {
             delegate,
             history,
             bookmarks,
+            favorites,
             tabs: Vec::new(),
             // Sentinel so the first open_tab's activate() does the full
             // show/focus/resize dance instead of early-returning on 0 == 0.
@@ -647,6 +651,29 @@ impl ServoHost {
         }
     }
 
+    /// The favorites store, shared with the `cce://favorites` page; the
+    /// chrome reads the strip from it.
+    pub fn favorites(&self) -> std::sync::Arc<Favorites> {
+        self.favorites.clone()
+    }
+
+    /// Whether the active tab's page is in the favorites strip.
+    pub fn active_favorited(&self) -> bool {
+        self.active_tab()
+            .url
+            .as_ref()
+            .is_some_and(|u| self.favorites.contains(u.as_str()))
+    }
+
+    /// Toggle the favorite for the active tab's page.
+    pub fn toggle_favorite(&self) {
+        let tab = self.active_tab();
+        if let Some(url) = &tab.url {
+            self.favorites
+                .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
+        }
+    }
+
     pub fn can_go_back(&self) -> bool {
         self.active_tab().webview.can_go_back()
     }
diff --git a/src/wpe/host.rs b/src/wpe/host.rs
index b96901b..00f3368 100644
--- a/src/wpe/host.rs
+++ b/src/wpe/host.rs
@@ -130,6 +130,7 @@ pub struct WebKitHost {
     /// the backend swap unchanged.
     history: std::sync::Arc<crate::pages::History>,
     bookmarks: std::sync::Arc<crate::pages::Bookmarks>,
+    favorites: std::sync::Arc<crate::pages::Favorites>,
     history_enabled: bool,
     force_dark: bool,
     /// Serves the `cce:` pages. Boxed and leaked into the scheme callback,
@@ -203,12 +204,14 @@ impl WebKitHost {
 
             let history = std::sync::Arc::new(crate::pages::History::load());
             let bookmarks = std::sync::Arc::new(crate::pages::Bookmarks::load());
+            let favorites = std::sync::Arc::new(crate::pages::Favorites::load());
             let downloads = std::sync::Arc::new(crate::downloads::Downloads::default());
             let clear_cookies =
                 std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
             let protocol = Rc::new(crate::pages::CceProtocol {
                 history: history.clone(),
                 bookmarks: bookmarks.clone(),
+                favorites: favorites.clone(),
                 downloads: downloads.clone(),
                 clear_cookies: clear_cookies.clone(),
             });
@@ -277,6 +280,7 @@ impl WebKitHost {
                     .ok(),
                 history: history.clone(),
                 bookmarks: bookmarks.clone(),
+                favorites,
                 history_enabled: true,
                 force_dark: false,
                 protocol,
@@ -676,6 +680,27 @@ impl WebKitHost {
         }
     }
 
+    /// The favorites store, shared with the `cce://favorites` page; the
+    /// chrome reads the strip from it.
+    pub fn favorites(&self) -> std::sync::Arc<crate::pages::Favorites> {
+        self.favorites.clone()
+    }
+
+    pub fn active_favorited(&self) -> bool {
+        self.active_tab()
+            .url
+            .as_ref()
+            .is_some_and(|u| self.favorites.contains(u.as_str()))
+    }
+
+    pub fn toggle_favorite(&self) {
+        let tab = self.active_tab();
+        if let Some(url) = &tab.url {
+            self.favorites
+                .toggle(url.as_str(), tab.title.as_deref().unwrap_or(""));
+        }
+    }
+
     /// Clipboard on the page. WebKit takes these as named editing commands,
     /// so unlike the Servo backend there is no separate clipboard delegate to
     /// implement — it goes through the platform clipboard itself.