git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit3b09eeb71463f73d8c1ef9d858d6d245d5c8875d
parent2e573c35b6
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 21:40
feat: every symbol the toolkit draws is a cce-icons glyph

The DE's one icon source is cce-icons/svg, but the toolkit drew its own
symbols as characters in whatever face the font fell back to (the
dropdown's ▼, menubar ▼ and ✓, spreadsheet ▲▼, spinbox - and +, the
font picker's "Aa", the breadcrumb's …, markdown's ↳, menu marks and
page chevrons, and a copy button that fell back to an emoji).

- PaintCtx::icon(name, rect, color): a glyph tinted like the text beside
  it, with upload_icon_tinted / icon_tint / icon_pixels underneath and a
  RenderTarget::icon for the legacy target.
- Context menus draw a leading "✓ ", "● " or "○ " in a row label as
  the check / circle / circle-outline glyph, and their page and back
  marks as chevrons. Labels keep the mark as text, so hosts that match
  their own labels are unaffected.
- The menu popup can draw images: it keeps its own copies of the glyphs
  (icon_source + VkRenderer::upload_rgba_now). It also stops draining the
  shared upload queue, which let an upload meant for the window land in
  the popup's image table.
- Vertical ButtonStrip / Paginator take glyphs by name (with_icons) in
  place of a label's first character.

Tests: menu_marks_and_chevrons_are_glyphs, and
every_glyph_the_toolkit_names_is_in_the_icon_set, which fails on a glyph
name with no file (a missing glyph draws nothing, silently).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 CLAUDE.md                             |  46 +++++++
 src/backend/menu_popup.rs             |  38 +++++-
 src/backend/window_runner.rs          |   8 ++
 src/draw/images.rs                    |   7 +
 src/layout.rs                         |   3 +
 src/lib.rs                            |  78 ++++++++++--
 src/scene/paint.rs                    |  39 ++++++
 src/vk/image.rs                       |  29 +++++
 src/vk/renderer.rs                    |  23 ++++
 src/widget/container/breadcrumb.rs    |  22 +++-
 src/widget/container/menu.rs          |  46 +++++--
 src/widget/container/paginator.rs     |  38 +++---
 src/widget/container/parameters_bg.rs |   5 +-
 src/widget/container/spreadsheet.rs   |  26 +++-
 src/widget/container/treelist.rs      |  17 +--
 src/widget/core.rs                    | 233 ++++++++++++++++++++++++++++++----
 src/widget/input/button.rs            |   5 +-
 src/widget/input/button_strip.rs      |  82 ++++++------
 src/widget/input/dropdown.rs          |  74 +++++------
 src/widget/input/font_selector.rs     |  27 ++--
 src/widget/input/spinbox.rs           |  15 ++-
 src/widget/markdown.rs                |  24 +++-
 22 files changed, 685 insertions(+), 200 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 8aa0fc5..f8189df 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1763,6 +1763,52 @@ System fonts are loaded only when `$CCE_LOAD_SYSTEM_FONTS` is set (or via
 `create_font_system_with_system_fonts()`, used by the font picker). Configured custom font
 families are validated at startup with a warning if missing.
 
+### Every symbol is a cce-icons glyph (since 2026-10-05)
+
+The DE's one icon source is `cce-icons/svg`, and the toolkit draws a symbol
+— a chevron, a check, a mark, a + or a − — ONLY as one of those glyphs, never
+as a character (`▼`, `✓`, `●`, `›`, `+`, an emoji) in whatever face the font
+falls back to, and never built from primitives. Apps hold to the same rule.
+
+- **`PaintCtx::icon(name, rect, color)`** draws a glyph tinted like the text
+  beside it (raw sRGB, as a text colour is; its alpha the image's), rasterized
+  at twice the rect so it is crisp on a 2x output. `icon_untinted` is for the
+  `weather-*` family, the one set that carries its own colours.
+  `RenderTarget::icon` is the same call for the legacy target (a
+  `PopoverCollector` draws nothing). Underneath, `upload_icon_tinted` /
+  `icon_tint` / `icon_pixels`: the artwork is white, so multiplying is
+  tinting.
+- **A context-menu row MARK is a glyph**: a label beginning `MARK_CHECK`
+  (`"✓ "`), `MARK_ON` (`"● "`) or `MARK_OFF` (`"○ "`) draws `check`, `circle`
+  or `circle-outline` at its left and the label without it. The text stays the
+  row's identity, so hosts that match their own labels still match. A page
+  row's `›` and the back band's `‹` are `chevron-right` / `chevron-left`.
+- **The menu popup has images now.** Its renderer used to pass none
+  (`images: &[]`). It keeps its OWN copies (`menu_icon_ids`): a glyph painted
+  by the window renderer's id is looked up with `icon_source` and uploaded
+  once into the popup's table (`VkRenderer::upload_rgba_now`). And it no
+  longer drains the shared upload queue (`set_shared_uploads(false)`) — it
+  did, so an upload queued between the window's frame and the popup's landed
+  in the popup's table and never drew in the window.
+- **A vertical `ButtonStrip` / `Paginator` takes glyphs by name**
+  (`with_icons`). It took the label's first character when that was a word of
+  its own ("📁 Browse") and drew it as text.
+- Converted: dropdown arrow (`arrow_rect`, `ARROW_SIDE`), the params pane's
+  picker face (it drew "▾" AND the arrow), menubar title arrow and checked
+  items, spreadsheet sort marks (`chevron-up` / `chevron-down`), spinbox −/+,
+  font selector ("Aa" → `font`), breadcrumb overflow (`more-horizontal`),
+  markdown's unrenderable embed (`Draw::Icon`, `link`), the tree list's
+  missing-icon fallback (now an empty slot) and the copy button's fallback
+  ("📋" → "Copy").
+- **Kept as shapes**, being indicators and not symbols: `StatusDot`'s LED
+  disc and the plate dock's corner dot.
+
+`every_glyph_the_toolkit_names_is_in_the_icon_set` scans the source for glyph
+names and fails on one with no file — a missing glyph draws NOTHING, silently
+(skipped where the icon set is not checked out). `menu_marks_and_chevrons_are_glyphs`
+holds the menu convention. **A shadow session needs `CCE_ICONS_DIR`**: its
+HOME is isolated, so the default path finds no icons and every glyph is blank.
+
 ## Debug environment variables
 
 All opt-in, all read once, all quiet when unset — set one and run any client.
diff --git a/src/backend/menu_popup.rs b/src/backend/menu_popup.rs
index fb2798f..7b42bfb 100644
--- a/src/backend/menu_popup.rs
+++ b/src/backend/menu_popup.rs
@@ -279,11 +279,34 @@ impl<A: Application> EngineState<A> {
 
         let mut items = Vec::new();
         collect_dl_text(self.font_system.as_mut().unwrap(), &dl, &mut items);
-        let (verts, dl_batches, _images, plate_features) = tessellate_display_list(&dl, w, h, scale);
+        let (verts, dl_batches, dl_images, plate_features) = tessellate_display_list(&dl, w, h, scale);
         let bounds = TextBounds { left: 0, top: 0, right: pw as i32, bottom: ph as i32 };
         let spans = dl_text_spans(&items, scale, bounds, &[]);
         renderer.prepare_text(self.font_system.as_mut().unwrap(), &mut self.swash_cache, &spans);
         let batches = dl_batches_2d(&dl_batches, scale);
+        // The menu's glyphs (its marks, its page and back chevrons) were
+        // painted with the WINDOW renderer's ids; this renderer keeps images
+        // of its own, so each is drawn by its copy here, uploaded once.
+        let icon_ids = &mut self.menu_icon_ids;
+        let images: Vec<crate::vk::ImageQuad> = dl_images
+            .iter()
+            .filter_map(|di| {
+                let own = *icon_ids.entry(di.image).or_insert_with(|| {
+                    let (name, px, tint) = crate::icon_source(di.image)?;
+                    let (rgba, iw, ih) = crate::icon_pixels(&name, px, tint)?;
+                    Some(renderer.upload_rgba_now(&rgba, iw, ih))
+                });
+                Some(crate::vk::ImageQuad {
+                    image: own?,
+                    rect: (di.rect.x * scale, di.rect.y * scale, di.rect.width * scale, di.rect.height * scale),
+                    alpha: di.alpha,
+                    z_before: di.at,
+                    clip: di.clip.map(|c| {
+                        ((c.x * scale).max(0.0) as u32, (c.y * scale).max(0.0) as u32, (c.width * scale) as u32, (c.height * scale) as u32)
+                    }),
+                })
+            })
+            .collect();
 
         let e = renderer.pending_extent();
         if e.width != pw || e.height != ph {
@@ -297,7 +320,7 @@ impl<A: Application> EngineState<A> {
             verts: &verts,
             batches: &batches,
             overlay_verts: &[],
-            images: &[],
+            images: &images,
             plate_features: &plate_features,
             clear_color: [0.0; 4],
             damage: None,
@@ -318,7 +341,7 @@ impl<A: Application> PopupHandler for EngineState<A> {
         let (x, y) = (config.position.0 as f32 / f, config.position.1 as f32 / f);
         let (w, h) = (config.width.max(1) as f32 / f, config.height.max(1) as f32 / f);
         let (scale_factor, display) = (self.scale_factor, self.display_ptr);
-        let (mp, slot) = (self.menu_popup.as_mut(), &mut self.menu_renderer);
+        let (mp, slot, icon_ids) = (self.menu_popup.as_mut(), &mut self.menu_renderer, &mut self.menu_icon_ids);
         let Some(mp) = mp else { return };
         mp.placed = Some((x, y, w, h));
         let handoff = std::mem::take(&mut mp.handoff);
@@ -342,7 +365,14 @@ impl<A: Application> PopupHandler for EngineState<A> {
                 let t = std::time::Instant::now();
                 let made = unsafe { VkRenderer::try_new(display_ptr, surface_ptr, pw, ph, 0.0) };
                 log::debug!("[menu_popup] renderer created in {:?}", t.elapsed());
-                made.map(|r| *slot = Some(r))
+                // A new renderer holds none of the old one's copies.
+                icon_ids.clear();
+                made.map(|mut r| {
+                    // Its images are its own (the menu's glyphs, copied in
+                    // `render_menu_popup`); the shared queue is the window's.
+                    r.set_shared_uploads(false);
+                    *slot = Some(r)
+                })
             }
         };
         // A lost surface is the connection dying under the menu; the window's
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index c8b4c64..2372973 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -123,6 +123,13 @@ pub struct EngineState<A: Application> {
     /// surface to the next: a renderer costs a device and every pipeline
     /// (tens of ms), a re-attach costs one swapchain.
     pub menu_renderer: Option<VkRenderer>,
+    /// The popup renderer's copies of the bundled glyphs the menu draws,
+    /// by the window renderer's id for the same glyph (see
+    /// `crate::icon_source`). The popup keeps images of its own — it does
+    /// not take the shared upload queue — so a glyph the menu paints by the
+    /// window's id is uploaded again into the popup's table, once. Cleared
+    /// with the renderer it names.
+    pub menu_icon_ids: std::collections::HashMap<u32, Option<u32>>,
     /// The `wl_display` the renderers were made from, as an address.
     pub display_ptr: usize,
 
@@ -1881,6 +1888,7 @@ fn run_session<'l, A: Application>(
         sent_popover_region: None,
         menu_popup: None,
         menu_renderer: None,
+        menu_icon_ids: std::collections::HashMap::new(),
         display_ptr: 0,
         exit: false,
         redraw: false,
diff --git a/src/draw/images.rs b/src/draw/images.rs
index 96f4ed3..f123c74 100644
--- a/src/draw/images.rs
+++ b/src/draw/images.rs
@@ -47,6 +47,13 @@ pub enum Pending {
 
 static PENDING: Mutex<Vec<Pending>> = Mutex::new(Vec::new());
 static NEXT_ID: AtomicU32 = AtomicU32::new(1);
+
+/// A fresh image id from the process-wide counter, for a renderer that
+/// uploads into its own table directly (`ImageStage::upload_now`) — never
+/// one a queued upload also holds.
+pub(crate) fn next_image_id() -> u32 {
+    NEXT_ID.fetch_add(1, Ordering::Relaxed)
+}
 /// How many image tables have been built in this process. See
 /// [`renderer_epoch`].
 static STAGES_BUILT: AtomicU32 = AtomicU32::new(0);
diff --git a/src/layout.rs b/src/layout.rs
index 93b11ce..34b59e4 100644
--- a/src/layout.rs
+++ b/src/layout.rs
@@ -4112,6 +4112,9 @@ pub trait RenderTarget {
     }
     fn push_clip_rect(&mut self, _x: f32, _y: f32, _w: f32, _h: f32) {}
     fn pop_clip_rect(&mut self) {}
+    /// A bundled cce-icons glyph — see `PaintCtx::icon`. A target that
+    /// cannot draw images (the legacy `PopoverCollector`) draws nothing.
+    fn icon(&mut self, _name: &str, _rect: crate::scene::layout::Rect, _color: [f32; 4]) {}
     /// A flush inset control plate ([`PaintCtx::inset_plate`]) — the raised
     /// control surface (groove ring down, beveled lip back up). Lets a popover
     /// draw the ACTUAL widget surface expanded (the Dropdown's grown trigger).
diff --git a/src/lib.rs b/src/lib.rs
index 6a92977..c98e1dc 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -114,13 +114,76 @@ pub fn icons_dir() -> String {
 ///
 /// [`Button::new_icon`]: widget::Button::new_icon
 pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
+    upload_icon_as(name, px, None)
+}
+
+/// [`upload_icon`] in a colour: the glyph's pixels multiplied by `rgb`
+/// (raw sRGB, as a [`TextLabel`](widget::display::TextLabel)'s colour is,
+/// so a glyph tinted with a label's colour matches the label). The artwork
+/// is white, so multiplying IS tinting, and what a glyph shades darker (a
+/// knocked-out mark) stays proportionally darker. Cached per
+/// `(name, px, rgb)` like [`upload_icon`], and re-uploaded after a renderer
+/// rebuild the same way.
+///
+/// A `PaintCtx::image` carries alpha and no colour, which is why a colour
+/// has to be baked into the texture; [`icon_tint`] is the colour a
+/// toolkit `[f32; 4]` becomes. The `weather-*` glyphs carry colours of
+/// their own and are drawn with [`upload_icon`].
+pub fn upload_icon_tinted(name: &str, px: u32, rgb: [u8; 3]) -> Option<(u32, u32, u32)> {
+    upload_icon_as(name, px, Some(rgb))
+}
+
+/// The `[u8; 3]` tint a toolkit colour becomes — the conversion a
+/// `TextLabel` applies to its own colour, so glyph and text match.
+pub fn icon_tint(color: [f32; 4]) -> [u8; 3] {
+    [
+        (color[0] * 255.0).round().clamp(0.0, 255.0) as u8,
+        (color[1] * 255.0).round().clamp(0.0, 255.0) as u8,
+        (color[2] * 255.0).round().clamp(0.0, 255.0) as u8,
+    ]
+}
+
+/// A bundled glyph rasterized at `px`, tinted when `tint` is given: the
+/// pixels [`upload_icon`] and [`upload_icon_tinted`] upload, for a caller
+/// that uploads them itself (a renderer with images of its own).
+pub fn icon_pixels(name: &str, px: u32, tint: Option<[u8; 3]>) -> Option<(Vec<u8>, u32, u32)> {
+    let path = format!("{}/{name}.svg", icons_dir());
+    let data = std::fs::read(&path).ok()?;
+    let (mut rgba, w, h) = rasterize_svg(&data, px)?;
+    if let Some(rgb) = tint {
+        for p in rgba.chunks_exact_mut(4) {
+            for (c, &t) in p[..3].iter_mut().zip(rgb.iter()) {
+                *c = ((*c as u16 * t as u16 + 127) / 255) as u8;
+            }
+        }
+    }
+    Some((rgba, w, h))
+}
+
+/// What a bundled glyph's image id holds: `(name, px, tint)`.
+type IconSource = (String, u32, Option<[u8; 3]>);
+
+/// Every id [`upload_icon`] and [`upload_icon_tinted`] have handed out, and
+/// the glyph it holds — see [`icon_source`].
+static ICON_SOURCES: std::sync::Mutex<Option<std::collections::HashMap<u32, IconSource>>> =
+    std::sync::Mutex::new(None);
+
+/// The glyph an image id from [`upload_icon`] / [`upload_icon_tinted`]
+/// holds, or `None` for any other image. What lets a renderer that keeps
+/// images of its own (the context menu's popup) draw the same glyph from a
+/// display list painted with the window's ids.
+pub fn icon_source(id: u32) -> Option<IconSource> {
+    ICON_SOURCES.lock().unwrap().as_ref()?.get(&id).cloned()
+}
+
+fn upload_icon_as(name: &str, px: u32, tint: Option<[u8; 3]>) -> Option<(u32, u32, u32)> {
     use std::collections::HashMap;
     use std::sync::Mutex;
     /// The cached ids, and the renderer epoch they were uploaded to.
-    static CACHE: Mutex<Option<(u32, HashMap<(String, u32), Option<(u32, u32, u32)>>)>> =
+    static CACHE: Mutex<Option<(u32, HashMap<IconSource, Option<(u32, u32, u32)>>)>> =
         Mutex::new(None);
     let epoch = crate::draw::renderer_epoch();
-    let key = (name.to_string(), px);
+    let key = (name.to_string(), px, tint);
     let mut guard = CACHE.lock().unwrap();
     let (cached_epoch, cache) = guard.get_or_insert_with(|| (epoch, HashMap::new()));
     if *cached_epoch != epoch {
@@ -132,12 +195,11 @@ pub fn upload_icon(name: &str, px: u32) -> Option<(u32, u32, u32)> {
     if let Some(hit) = cache.get(&key) {
         return *hit;
     }
-    let loaded = (|| {
-        let path = format!("{}/{name}.svg", icons_dir());
-        let data = std::fs::read(&path).ok()?;
-        let (rgba, w, h) = rasterize_svg(&data, px)?;
-        Some((crate::draw::upload_rgba(rgba, w, h), w, h))
-    })();
+    let loaded = icon_pixels(name, px, tint).map(|(rgba, w, h)| {
+        let id = crate::draw::upload_rgba(rgba, w, h);
+        ICON_SOURCES.lock().unwrap().get_or_insert_with(HashMap::new).insert(id, key.clone());
+        (id, w, h)
+    });
     cache.insert(key, loaded);
     loaded
 }
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index e6136a1..3615abd 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -1170,6 +1170,41 @@ impl PaintCtx {
         self.push(Prim::Image { image, rect, alpha });
     }
 
+    /// A bundled cce-icons glyph (`cce-icons/svg/<name>.svg`) drawn at
+    /// `rect`, tinted `color` — given as a text colour is, raw sRGB, so a
+    /// glyph and the label beside it match — with the colour's alpha as the
+    /// image's. Rasterized at twice the rect's longer side so it stays crisp
+    /// on a 2x output, and cached (see [`crate::upload_icon_tinted`]).
+    /// `false`, and nothing drawn, when the glyph is missing.
+    ///
+    /// The ONE way the toolkit draws a symbol: a chevron, a mark, a + or a
+    /// − is this, never a character in whatever face the font falls back to.
+    /// A `weather-*` glyph carries its own colours; draw it with
+    /// [`icon_untinted`](Self::icon_untinted).
+    pub fn icon(&mut self, name: &str, rect: Rect, color: [f32; 4]) -> bool {
+        let px = (rect.width.max(rect.height) * 2.0).ceil().max(1.0) as u32;
+        match crate::upload_icon_tinted(name, px, crate::icon_tint(color)) {
+            Some((id, _, _)) => {
+                self.image(id, rect, color[3]);
+                true
+            }
+            None => false,
+        }
+    }
+
+    /// [`icon`](Self::icon) for a glyph that carries its own colours (the
+    /// `weather-*` family): drawn as it is, at `alpha`.
+    pub fn icon_untinted(&mut self, name: &str, rect: Rect, alpha: f32) -> bool {
+        let px = (rect.width.max(rect.height) * 2.0).ceil().max(1.0) as u32;
+        match crate::upload_icon(name, px) {
+            Some((id, _, _)) => {
+                self.image(id, rect, alpha);
+                true
+            }
+            None => false,
+        }
+    }
+
     pub fn rounded_rect(&mut self, rect: Rect, radius: f32, corners: (bool, bool, bool, bool), color: [f32; 4]) {
         let rect = self.apply_offset(rect);
         self.push(Prim::RoundedRect { rect, radius, corners, color });
@@ -1980,6 +2015,10 @@ impl PaintCtx {
 /// plates, rounded rects, bounded text — instead of the flattened
 /// `PopoverCollector` view (which stays for legacy tuple hosts).
 impl crate::layout::RenderTarget for PaintCtx {
+    fn icon(&mut self, name: &str, rect: Rect, color: [f32; 4]) {
+        PaintCtx::icon(self, name, rect, color);
+    }
+
     fn rect(&mut self, color: [f32; 4], x: f32, y: f32, w: f32, h: f32) {
         self.quad(Rect { x, y, width: w, height: h }, color);
     }
diff --git a/src/vk/image.rs b/src/vk/image.rs
index 10178be..369b66e 100644
--- a/src/vk/image.rs
+++ b/src/vk/image.rs
@@ -131,6 +131,12 @@ pub(crate) struct ImageStage {
     staging_idle: u32,
     /// Per frame in flight: this frame's quad vertices (6 per ImageQuad).
     frame_buffers: Vec<AllocatedBuffer>,
+    /// Whether this table takes the process-wide upload queue. True for a
+    /// window's renderer; false for a renderer that keeps images of its own
+    /// (the context menu's popup), which must not drain the queue — an
+    /// upload meant for the window, queued between the window's frame and
+    /// the popup's, would land in the popup's table and never be drawn.
+    pub(crate) shared_uploads: bool,
 }
 
 impl ImageStage {
@@ -325,6 +331,7 @@ impl ImageStage {
                 staging: None,
                 staging_idle: 0,
                 frame_buffers,
+                shared_uploads: true,
             }
         }
     }
@@ -338,6 +345,9 @@ impl ImageStage {
         queue: vk::Queue,
         command_pool: vk::CommandPool,
     ) {
+        if !self.shared_uploads {
+            return;
+        }
         let pending: Vec<Pending> = take_pending();
         if pending.is_empty() {
             self.staging_idle = self.staging_idle.saturating_add(1);
@@ -439,6 +449,25 @@ impl ImageStage {
     }
 
     #[allow(clippy::too_many_arguments)]
+    /// Upload RGBA8 pixels into THIS table now, rather than queueing them for
+    /// whichever renderer drains the shared queue next — for a renderer with
+    /// images of its own (see [`Self::shared_uploads`]). The id comes from
+    /// the process-wide counter, so it never collides with a queued one.
+    pub(crate) fn upload_now(
+        &mut self,
+        device: &ash::Device,
+        allocator: &mut Allocator,
+        queue: vk::Queue,
+        command_pool: vk::CommandPool,
+        pixels: &[u8],
+        width: u32,
+        height: u32,
+    ) -> u32 {
+        let id = crate::draw::images::next_image_id();
+        self.upload(device, allocator, queue, command_pool, id, pixels, width, height, PixelFormat::Rgba, false);
+        id
+    }
+
     fn upload(
         &mut self,
         device: &ash::Device,
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index df2f2f2..131ec2b 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -1686,6 +1686,29 @@ impl VkRenderer {
         self.surface != vk::SurfaceKHR::null()
     }
 
+    /// Whether this renderer takes the process-wide image upload queue
+    /// ([`crate::vk::upload_rgba`] and kin) — true by default. A renderer
+    /// that keeps images of its own, uploaded with
+    /// [`upload_rgba_now`](Self::upload_rgba_now), turns it off so it never
+    /// takes an upload the window's renderer was meant to draw.
+    pub fn set_shared_uploads(&mut self, on: bool) {
+        self.image.shared_uploads = on;
+    }
+
+    /// Upload RGBA8 pixels into this renderer's image table now, returning
+    /// the id to draw them by in this renderer's frames.
+    pub fn upload_rgba_now(&mut self, pixels: &[u8], width: u32, height: u32) -> u32 {
+        self.image.upload_now(
+            &self.core.device,
+            self.core.allocator.as_mut().unwrap(),
+            self.core.queue,
+            self.core.command_pool,
+            pixels,
+            width,
+            height,
+        )
+    }
+
     /// The extent the next `draw_frame` will render at: the pending size when a
     /// swapchain rebuild is queued, otherwise the live one.
     pub fn pending_extent(&self) -> vk::Extent2D {
diff --git a/src/widget/container/breadcrumb.rs b/src/widget/container/breadcrumb.rs
index 19448cb..c229780 100644
--- a/src/widget/container/breadcrumb.rs
+++ b/src/widget/container/breadcrumb.rs
@@ -125,6 +125,11 @@ impl Breadcrumb {
         (family, size.unwrap_or(BREADCRUMB_FONT_SIZE))
     }
 
+    /// The side of the folded-segments marker glyph at font size `size`.
+    fn marker_side(size: f32) -> f32 {
+        (size * 0.9).round()
+    }
+
     /// The segments to actually paint, each with its BUTTON BOX left edge/width and its
     /// *logical* index (position in [`virtual_segs`]; `None` marks the leading "…" ellipsis).
     /// This is the one source for hit-testing, the hover overlay, the plates, and the text
@@ -143,8 +148,12 @@ impl Breadcrumb {
         let avail = (rect.width - 2.0 * Self::SEG_INSET).max(0.0);
 
         let (font, size) = Self::font_and_size();
-        let box_w =
-            |s: &str| crate::widget::display::measure_text_width(s, &font, size) + 2.0 * SEG_PAD_X;
+        // The "…" marker is drawn as the `more-horizontal` glyph, so its box
+        // is the glyph's width, not the character's.
+        let box_w = |s: &str| {
+            let inner = if s == "…" { Self::marker_side(size) } else { crate::widget::display::measure_text_width(s, &font, size) };
+            inner + 2.0 * SEG_PAD_X
+        };
 
         // Lay a list of (text, logical index) out left-to-right from the widget's left edge,
         // one button box per segment.
@@ -440,6 +449,15 @@ impl Paint for Breadcrumb {
         let (_, size) = Self::font_and_size();
         let last_logical = self.virtual_segs().len().saturating_sub(1);
         for vs in segs {
+            if vs.logical.is_none() {
+                // The marker for the segments folded away: the
+                // `more-horizontal` glyph in the dimmed colour, centred in
+                // its box.
+                let side = Self::marker_side(size);
+                let g = Rect { x: vs.x + SEG_PAD_X, y: rect.y + 0.5 * (rect.height - side), width: side, height: side };
+                ctx.icon("more-horizontal", g, [0x88 as f32 / 255.0, 0x88 as f32 / 255.0, 0x99 as f32 / 255.0, 1.0]);
+                continue;
+            }
             let color =
                 if vs.logical == Some(last_logical) { [0xcc, 0xcc, 0xd4] } else { [0x88, 0x88, 0x99] };
             // `visible_segs` already drops segments behind a "…" to make the
diff --git a/src/widget/container/menu.rs b/src/widget/container/menu.rs
index 807b4c7..a9fa30d 100644
--- a/src/widget/container/menu.rs
+++ b/src/widget/container/menu.rs
@@ -586,6 +586,15 @@ impl Paint for MenuBar {
             (srgb[1] * 255.0) as u8,
             (srgb[2] * 255.0) as u8,
         ];
+        // The title's context arrow: the `chevron-down` glyph in the title's
+        // colour, standing where the " ▼" it replaced stood (the title's
+        // width is still measured with it, so nothing moves). A label at
+        // (x, y) sized `fs` puts it there.
+        let arrow_tint = [srgb[0], srgb[1], srgb[2], 1.0];
+        let arrow_at = |ctx: &mut PaintCtx, x: f32, y: f32, fs: f32| {
+            let side = (fs * 0.6).round();
+            ctx.icon("chevron-down", Rect { x, y: y + 0.5 * (fs - side), width: side, height: side }, arrow_tint);
+        };
         let padding_x = crate::layout::paginator_tab_padding_x();
 
         if let Some(ref label) = self.label {
@@ -637,6 +646,10 @@ impl Paint for MenuBar {
                     font_size,
                     text_color,
                 ) {
+                    if l.text == "▼" {
+                        arrow_at(ctx, l.x, l.y, l.font_size);
+                        continue;
+                    }
                     ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, None,
                         Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
                 }
@@ -656,6 +669,10 @@ impl Paint for MenuBar {
                 for (i, c) in self.display_title().chars().enumerate() {
                     let char_str = c.to_string();
                     let y_pos = start_y + i as f32 * line_height;
+                    if c == '▼' {
+                        arrow_at(ctx, x_pos, y_pos, font_size);
+                        continue;
+                    }
                     ctx.text_with(char_str, x_pos, y_pos, font_size, text_color, None,
                         Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
                 }
@@ -681,8 +698,13 @@ impl Paint for MenuBar {
             } else {
                 rect.x + start_x
             };
-            ctx.text_with(display_title, x_pos, text_y, font_size, text_color, None,
+            ctx.text_with(self.title.clone(), x_pos, text_y, font_size, text_color, None,
                 Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]));
+            if !self.context_options.is_empty() {
+                let lead = format!("{} ", self.title);
+                let ax = x_pos + crate::widget::display::measure_text_width(&lead, &font_fam, font_size);
+                arrow_at(ctx, ax, text_y, font_size);
+            }
         }
 
         // Every word this widget draws is bounded by the widget. A menu's
@@ -759,18 +781,20 @@ impl Paint for MenuBar {
                         let checked = self.menu_dropdown_checked.get(menu_idx)
                             .and_then(|menu| menu.get(i))
                             .and_then(|&v| v);
-                        let prefix = match checked {
-                            Some(true) => "✓ ",
-                            Some(false) => "  ",
-                            None => "",
-                        };
-                        let text = format!("{}{}", prefix, option);
+                        // A checkable item keeps a mark's room before its
+                        // text; a checked one wears the `check` glyph in it.
+                        const MARK: f32 = 9.0;
+                        let indent = if checked.is_some() { MARK + 6.0 } else { 0.0 };
                         let color = item_color(self.hovered_dropdown_item == Some(i), checked == Some(true));
                         let iy = crate::layout::align_text_y(dy + i as f32 * DROPDOWN_ITEM_H, DROPDOWN_ITEM_H, 12.0, 0.0);
+                        if checked == Some(true) {
+                            let my = dy + i as f32 * DROPDOWN_ITEM_H + 0.5 * (DROPDOWN_ITEM_H - MARK);
+                            pc.icon("check", Rect { x: dx + 8.0, y: my, width: MARK, height: MARK }, color);
+                        }
                         if let Some(ref f) = font {
-                            pc.text_with_font_and_bounds(&text, dx + 8.0, iy, 12.0, color, f, bounds);
+                            pc.text_with_font_and_bounds(option, dx + 8.0 + indent, iy, 12.0, color, f, bounds);
                         } else {
-                            pc.text_with_bounds(&text, dx + 8.0, iy, 12.0, color, bounds);
+                            pc.text_with_bounds(option, dx + 8.0 + indent, iy, 12.0, color, bounds);
                         }
                     }
                 }
@@ -1134,8 +1158,10 @@ impl MenuController for MenuBar {
                         let checked = self.menu_dropdown_checked.get(i)
                             .and_then(|menu| menu.get(item_idx))
                             .and_then(|&v| v);
+                        // `context_menu`'s mark convention: a checked item
+                        // is drawn with the `check` glyph.
                         let prefix = match checked {
-                            Some(true) => "✓ ",
+                            Some(true) => crate::widget::context_menu::MARK_CHECK,
                             Some(false) => "  ",
                             None => "",
                         };
diff --git a/src/widget/container/paginator.rs b/src/widget/container/paginator.rs
index 845640b..770cf9f 100644
--- a/src/widget/container/paginator.rs
+++ b/src/widget/container/paginator.rs
@@ -68,6 +68,18 @@ impl Paginator {
 }
 
 impl Adapted<Paginator> {
+    /// A cce-icons glyph per page, worn at the top of its sidebar tab — see
+    /// [`ButtonStrip::icons`].
+    pub fn with_icons(mut self, icons: Vec<Option<String>>) -> Self {
+        let p = self.inner_mut();
+        p.sidebar_menu.inner_mut().set_icons(icons);
+        p.sidebar_w = p.sidebar_w();
+        let w = p.sidebar_w;
+        let (x, y, _, h) = p.sidebar_menu.rect();
+        p.sidebar_menu.set_rect(x, y, w, h);
+        self
+    }
+
     pub fn with_sidebar_mode(self, _enabled: bool) -> Self {
         self
     }
@@ -122,26 +134,12 @@ impl PageSelector for Paginator {
         let font_size = font_info.1;
         let padding = crate::layout::button_padding();
 
-        let mut max_w = 0.0;
-        for label in &self.page_labels {
-            let trimmed = label.trim();
-            let space_idx = trimmed.find(' ');
-            let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
-            let content_w = if has_icon {
-                let space_idx = space_idx.unwrap();
-                let (icon, _) = trimmed.split_at(space_idx);
-                let icon = icon.trim();
-                let icon_font_size = 14.0;
-                let est_icon_w = crate::widget::display::measure_text_width(icon, &font_fam, icon_font_size);
-                est_icon_w.max(font_size)
-            } else {
-                font_size
-            };
-            let w = content_w + 2.0 * padding;
-            if w > max_w {
-                max_w = w;
-            }
-        }
+        let _ = font_fam;
+        // A page with a glyph (`with_icons`) needs the glyph's width; one
+        // without, a line of its rotated label.
+        let has_icons = self.sidebar_menu.inner().icons.iter().any(|i| i.is_some());
+        let content_w = if has_icons { ButtonStrip::ICON_SIDE.max(font_size) } else { font_size };
+        let max_w = content_w + 2.0 * padding;
         max_w.max(1.0)
     }
 }
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 8b0f325..1bafbc7 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -3663,7 +3663,7 @@ impl ParamController for ParametersBg {
                         // in the box's recess, not a bare glyph beside it.
                         Some(
                             Dropdown::new(options, 0)
-                                .with_custom_display_text("\u{25be}")
+                                .with_custom_display_text("")
                                 .with_raised(true),
                         )
                     }
@@ -3923,7 +3923,8 @@ mod tests {
         ]);
         let arrow = |i: usize| {
             let d = p.choices[i].as_ref().unwrap();
-            d.own_text_labels().into_iter().find(|l| l.text == "▼").expect("an arrow").x
+            let (x, y, w, h) = d.rect();
+            d.inner().arrow_rect(crate::scene::layout::Rect { x, y, width: w, height: h }).x
         };
         assert!((arrow(0) - arrow(1)).abs() < 1e-3, "picker {} vs dropdown {}", arrow(0), arrow(1));
     }
diff --git a/src/widget/container/spreadsheet.rs b/src/widget/container/spreadsheet.rs
index fc62711..4535ffe 100644
--- a/src/widget/container/spreadsheet.rs
+++ b/src/widget/container/spreadsheet.rs
@@ -516,14 +516,28 @@ impl Paint for Spreadsheet {
                 continue;
             }
             let cx = xoff + col_w * i as f32 + 8.0;
-            let (label, color) = match self.sort {
+            match self.sort {
                 Some((col, ascending)) if col == i => {
-                    let mark = if ascending { '\u{25b2}' } else { '\u{25bc}' };
-                    (format!("{header} {mark}"), [0xff, 0xff, 0xff])
+                    // The sorted column: its name, then the `chevron-up` or
+                    // `chevron-down` glyph a cell after it — the room of
+                    // the " ▲" it was until 2026-10-05, taken out of the
+                    // name's budget.
+                    let b = budget.saturating_sub(2);
+                    let shown: String = if b < 3 || header.chars().count() <= b {
+                        header.clone()
+                    } else {
+                        let mut out: String = header.chars().take(b - 1).collect();
+                        out.push('\u{2026}');
+                        out
+                    };
+                    let tx = cx + shown.chars().count() as f32 * char_w + 0.5 * char_w;
+                    ctx.text_with(shown, cx, y + 6.0, 12.0, [0xff, 0xff, 0xff], None, col_bounds(i, y, HEADER_H));
+                    const SIDE: f32 = 8.0;
+                    let r = Rect { x: tx, y: y + 0.5 * (HEADER_H - SIDE), width: SIDE, height: SIDE };
+                    ctx.icon(if ascending { "chevron-up" } else { "chevron-down" }, r, [1.0, 1.0, 1.0, 1.0]);
                 }
-                _ => (header.clone(), [0xdd, 0xdd, 0xee]),
-            };
-            ctx.text_with(fit(label), cx, y + 6.0, 12.0, color, None, col_bounds(i, y, HEADER_H));
+                _ => ctx.text_with(fit(header.clone()), cx, y + 6.0, 12.0, [0xdd, 0xdd, 0xee], None, col_bounds(i, y, HEADER_H)),
+            }
         }
         // A row half scrolled under the header or off the bottom draws its
         // text CUT at the body band, as its zebra fill is. Until 2026-09-30
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 5d6ef1b..1b365d6 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -1642,7 +1642,7 @@ impl TreeList {
     /// Type cells clip at their separators so text can't bleed into the next
     /// column; sections span the whole row.
     /// The section-row chevron (cce-icons), cached per size by `upload_icon`;
-    /// `None` when the icon set is missing (rows fall back to text triangles).
+    /// `None` when the icon set is missing (the slot is left empty).
     fn chevron_icon(collapsed: bool) -> Option<(u32, u32, u32)> {
         crate::upload_icon(if collapsed { "chevron-right" } else { "chevron-down" }, 32)
     }
@@ -1697,17 +1697,14 @@ impl TreeList {
             }
 
             match item {
-                TreeElement::Section { name, indent, collapsed, .. } => {
+                TreeElement::Section { name, indent, .. } => {
                     if self.editing_key_idx != Some(i) {
                         let sx = list_left + 8.0 + *indent as f32 * 12.0;
-                        // Chevron icons (cce-icons) replace the text triangles
-                        // when available — paint() draws the image in the slot
-                        // this leaves open. Text triangles are the fallback.
-                        let (text, tx) = if Self::chevron_icon(*collapsed).is_some() {
-                            (name.clone(), sx + tree_font_size + 6.0)
-                        } else {
-                            (format!("{} {}", if *collapsed { "▶" } else { "▼" }, name), sx)
-                        };
+                        // The chevron glyph (cce-icons) stands in the slot
+                        // this leaves open — paint() draws it. A machine
+                        // without the icon set shows the slot empty: no
+                        // symbol is drawn as a character.
+                        let (text, tx) = (name.clone(), sx + tree_font_size + 6.0);
                         labels.push((TextLabel {
                             text,
                             x: tx,
diff --git a/src/widget/core.rs b/src/widget/core.rs
index a2d998e..09d6bcb 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -507,12 +507,68 @@ pub mod context_menu {
         }
     }
 
-    /// The mark a row that leads to a PAGE carries at its right end.
+    /// The mark a row that leads to a PAGE carries at its right end. Drawn
+    /// as the `chevron-right` glyph; the character is what a host that lays
+    /// out its own rows (the designer's dialog) may reserve room by.
     pub const PAGE_MARK: &str = "›";
     /// What a page's back band leads with, before the title of the plate it
-    /// goes back to.
+    /// goes back to. Drawn as the `chevron-left` glyph.
     pub const BACK_MARK: &str = "‹";
 
+    /// A row whose label BEGINS with one of these wears the mark as a
+    /// cce-icons glyph at its left, and the label is drawn without it: a
+    /// checked item (`check`), a switch or radio that is on (`circle`), one
+    /// that is off (`circle-outline`). The text stays the row's identity —
+    /// a host matching its own labels still matches `"✓ Show Grid"` — and
+    /// the toolkit owns how a mark looks, so no menu draws one as a
+    /// character in whatever face its font falls back to.
+    pub const MARK_CHECK: &str = "✓ ";
+    /// See [`MARK_CHECK`]: a switch or radio row that is on.
+    pub const MARK_ON: &str = "● ";
+    /// See [`MARK_CHECK`]: a switch or radio row that is off.
+    pub const MARK_OFF: &str = "○ ";
+
+    /// The glyph a label's leading mark names, and the label without it.
+    pub fn split_mark(label: &str) -> (Option<&'static str>, &str) {
+        for (mark, glyph) in [(MARK_CHECK, "check"), (MARK_ON, "circle"), (MARK_OFF, "circle-outline")] {
+            if let Some(rest) = label.strip_prefix(mark) {
+                return (Some(glyph), rest);
+            }
+        }
+        (None, label)
+    }
+
+    /// How wide a row mark is drawn, at menu font size `size`, and the gap
+    /// after it: the glyph box at the font size, so the mark (a disc fills
+    /// three quarters of its box) stands about as tall as a capital.
+    fn mark_size(size: f32) -> f32 {
+        (size * 0.95).round()
+    }
+    const MARK_GAP: f32 = 6.0;
+    /// The page and back chevrons, smaller still: they point, they do not
+    /// label.
+    fn chevron_size(size: f32) -> f32 {
+        (size * 0.8).round()
+    }
+
+    /// A glyph a menu draws: name, top-left, side, colour.
+    #[derive(Debug, Clone)]
+    pub(crate) struct MenuGlyph {
+        pub(crate) name: &'static str,
+        x: f32,
+        y: f32,
+        side: f32,
+        color: [f32; 4],
+    }
+
+    /// Draw `g` (shifted by `dx`, at `alpha`): the glyph tinted to the
+    /// label colour beside it — `PaintCtx::icon`.
+    fn paint_glyph(ctx: &mut crate::scene::paint::PaintCtx, g: &MenuGlyph, dx: f32, alpha: f32) {
+        let r = crate::scene::layout::Rect { x: g.x + dx, y: g.y, width: g.side, height: g.side };
+        let [cr, cg, cb, ca] = g.color;
+        ctx.icon(g.name, r, [cr, cg, cb, ca * alpha]);
+    }
+
     /// A page turn the menu has been asked for, drained by the host with
     /// [`take_turn`] (a swipe) or read with [`turn_at`] (a press). The menu
     /// does not turn by itself: what a row leads to may be another list of
@@ -721,6 +777,8 @@ pub mod context_menu {
                 .options
                 .iter()
                 .map(|s| {
+                    let (mark, s) = split_mark(s);
+                    let mark_w = if mark.is_some() { mark_size(size) + MARK_GAP } else { 0.0 };
                     let inked = crate::widget::display::measure_text_width(s, &family, size);
                     let shaped = crate::geometry_font_system()
                         .lock()
@@ -731,7 +789,7 @@ pub mod context_menu {
                                 .map(|&(_, total)| total)
                         })
                         .unwrap_or(0.0);
-                    inked.max(shaped)
+                    mark_w + inked.max(shaped)
                 })
                 .fold(0.0f32, f32::max);
             self.w = (widest + 2.0 * PAD).max(120.0);
@@ -759,7 +817,9 @@ pub mod context_menu {
             }
             let (family, size) = label_font();
             let measure = |t: &str| crate::widget::display::measure_text_width(t, &family, size);
-            let need = PAD + measure(&self.options[idx]) + SLIDER_GAP + measure(PAGE_MARK) + PAD;
+            let (mark, label) = split_mark(&self.options[idx]);
+            let mark_w = if mark.is_some() { mark_size(size) + MARK_GAP } else { 0.0 };
+            let need = PAD + mark_w + measure(label) + SLIDER_GAP + chevron_size(size) + PAD;
             self.w = self.w.max(need);
             self.pages[idx] = true;
         }
@@ -796,9 +856,8 @@ pub mod context_menu {
                 dir: if back.is_some() { 1.0 } else { -1.0 },
             });
             if let Some(title) = back {
-                let label = format!("{BACK_MARK} {title}");
                 let (family, size) = label_font();
-                let need = PAD + crate::widget::display::measure_text_width(&label, &family, size) + PAD;
+                let need = PAD + chevron_size(size) + MARK_GAP + crate::widget::display::measure_text_width(title, &family, size) + PAD;
                 self.w = self.w.max(need);
                 self.back = Some(title.to_string());
                 self.content_h += ROW_H;
@@ -1474,10 +1533,15 @@ pub mod context_menu {
                 let bounds = Some([rect.x, rect.y, rect.x + rect.width, rect.y + rect.height]);
                 let (lo, hi) = (-t.dir * e * TURN_SLIDE, t.dir * (1.0 - e) * TURN_SLIDE);
                 let (out, into) = turn_fades(e);
-                for (labels, dx, alpha) in [(t.from.labels(), lo, out), (self.labels(), hi, into)] {
-                    for label in labels {
+                for (rows, dx, alpha) in [(&*t.from, lo, out), (self, hi, into)] {
+                    for label in rows.labels() {
                         ctx.text_faded(label.text, label.x + dx, label.y, label.font_size, label.color, alpha, Some(family.clone()), bounds);
                     }
+                    ctx.clip(rect, |ctx| {
+                        for g in rows.glyphs() {
+                            paint_glyph(ctx, &g, dx, alpha);
+                        }
+                    });
                 }
                 return;
             }
@@ -1496,6 +1560,12 @@ pub mod context_menu {
                     bounds,
                 );
             }
+            let plate = crate::scene::layout::Rect { x: self.x, y: self.y, width: self.w, height: self.h };
+            ctx.clip(plate, |ctx| {
+                for g in self.glyphs() {
+                    paint_glyph(ctx, &g, 0.0, 1.0);
+                }
+            });
         }
 
         pub fn text_labels(&self) -> Vec<TextLabel> {
@@ -1506,14 +1576,14 @@ pub mod context_menu {
         }
 
         /// The labels as the rows stand, shown or not.
-        fn labels(&self) -> Vec<TextLabel> {
+        pub(crate) fn labels(&self) -> Vec<TextLabel> {
             let mut labels = Vec::new();
 
             if let Some(title) = &self.back {
                 let (_, label_size) = label_font();
                 labels.push(TextLabel {
-                    text: format!("{BACK_MARK} {title}"),
-                    x: self.x + PAD,
+                    text: title.clone(),
+                    x: self.x + PAD + chevron_size(label_size) + MARK_GAP,
                     y: self.back_band_y() + (ROW_H - label_size) / 2.0,
                     font_size: label_size,
                     color: rgb8(if self.back_hovered { crate::color::TEXT_HEADER } else { crate::color::TEXT_DIM }),
@@ -1544,25 +1614,17 @@ pub mod context_menu {
                     rgb8(crate::color::TEXT_FG)
                 };
 
+                // A leading mark is drawn as a glyph (see `glyphs`); the
+                // label follows it.
+                let (mark, text) = split_mark(opt);
+                let mark_w = if mark.is_some() { mark_size(label_size) + MARK_GAP } else { 0.0 };
                 labels.push(TextLabel {
-                    text: opt.clone(),
-                    x: self.x + PAD,
+                    text: text.to_string(),
+                    x: self.x + PAD + mark_w,
                     y: iy,
                     font_size: label_size,
                     color: text_color,
                 });
-                // A row that leads to a page says so at its right end.
-                if self.leads_to_page(idx) {
-                    let (family, _) = label_font();
-                    let tw = crate::widget::display::measure_text_width(PAGE_MARK, &family, label_size);
-                    labels.push(TextLabel {
-                        text: PAGE_MARK.to_string(),
-                        x: self.x + self.w - PAD - tw.max(label_size * 0.4),
-                        y: iy,
-                        font_size: label_size,
-                        color: text_color,
-                    });
-                }
                 // A slider row's readout, right-aligned against its band.
                 if let Some(s) = self.slider(idx) {
                     let (family, _) = label_font();
@@ -1579,6 +1641,54 @@ pub mod context_menu {
             }
             labels
         }
+
+        /// The glyphs the rows wear, as they stand: each row's leading mark
+        /// (see [`MARK_CHECK`]), the chevron at the right end of a row that
+        /// leads to a page, and the back band's chevron. Each takes its
+        /// row's text colour.
+        pub(crate) fn glyphs(&self) -> Vec<MenuGlyph> {
+            let mut glyphs = Vec::new();
+            let (_, size) = label_font();
+            let (ms, cs) = (mark_size(size), chevron_size(size));
+            if self.back.is_some() {
+                glyphs.push(MenuGlyph {
+                    name: "chevron-left",
+                    x: self.x + PAD,
+                    y: self.back_band_y() + (ROW_H - cs) / 2.0,
+                    side: cs,
+                    color: if self.back_hovered { crate::color::TEXT_HEADER } else { crate::color::TEXT_DIM },
+                });
+            }
+            for (idx, opt) in self.options.iter().enumerate() {
+                if opt == "-" {
+                    continue;
+                }
+                let top = self.row_y(idx);
+                if top + ROW_H < self.y || top > self.y + self.h {
+                    continue;
+                }
+                let color = if idx < self.header_count {
+                    crate::color::TEXT_DIM
+                } else if self.hovered_item == Some(idx) {
+                    crate::color::TEXT_HEADER
+                } else {
+                    crate::color::TEXT_FG
+                };
+                if let (Some(name), _) = split_mark(opt) {
+                    glyphs.push(MenuGlyph { name, x: self.x + PAD, y: top + (ROW_H - ms) / 2.0, side: ms, color });
+                }
+                if self.leads_to_page(idx) {
+                    glyphs.push(MenuGlyph {
+                        name: "chevron-right",
+                        x: self.x + self.w - PAD - cs,
+                        y: top + (ROW_H - cs) / 2.0,
+                        side: cs,
+                        color,
+                    });
+                }
+            }
+            glyphs
+        }
     }
 
     /// The menu PLATE alone, over `rect`: [`Material::menu`] on a rounded
@@ -2180,7 +2290,7 @@ mod context_menu_slider_tests {
 
 #[cfg(test)]
 mod context_menu_padding_tests {
-    use super::context_menu::{self, ContextMenuState, PAD, ROW_H};
+    use super::context_menu::{self, split_mark, ContextMenuState, PAD, ROW_H};
     use crate::widget::WidgetId;
 
     /// The shared menu is a popover for the window-drag question too: a
@@ -2200,6 +2310,77 @@ mod context_menu_padding_tests {
         assert!(ctx.drag_allowed_at(110.0, 200.0 + PAD + ROW_H * 0.5), "hidden, it vetoes nothing");
     }
 
+    /// A row's leading mark is a glyph, not a character: the label is drawn
+    /// without it and after the glyph's room, the glyph is the mark's
+    /// (`check`, `circle`, `circle-outline`), and a page row's chevron and a
+    /// page's back chevron are glyphs too — no row draws "✓", "●", "○", "›"
+    /// or "‹" as text.
+    #[test]
+    fn menu_marks_and_chevrons_are_glyphs() {
+        let mut m = ContextMenuState::new();
+        m.show(0.0, 0.0, vec!["✓ Show Grid".into(), "● On".into(), "○ Off".into(), "Plain".into(), "Add Tab".into()], 0, WidgetId(1));
+        m.set_row_page(4);
+        let labels = m.labels();
+        let texts: Vec<&str> = labels.iter().map(|l| l.text.as_str()).collect();
+        assert_eq!(texts, ["Show Grid", "On", "Off", "Plain", "Add Tab"]);
+        assert!(labels[0].x > labels[3].x, "a marked label stands after its mark's room");
+        let names: Vec<&str> = m.glyphs().iter().map(|g| g.name).collect();
+        assert_eq!(names, ["check", "circle", "circle-outline", "chevron-right"]);
+        m.show_page(0.0, 0.0, Some("View"), vec!["○ Wireframe".into()], 0, WidgetId(1));
+        assert_eq!(m.labels()[0].text, "View", "the back band reads its title, its chevron a glyph");
+        assert_eq!(m.glyphs().iter().map(|g| g.name).collect::<Vec<_>>(), ["chevron-left", "circle-outline"]);
+        for l in m.labels() {
+            assert!(!l.text.contains(['✓', '●', '○', '›', '‹']), "{:?} draws a mark as text", l.text);
+        }
+        assert_eq!(split_mark("✓ Collapse controls"), (Some("check"), "Collapse controls"));
+        assert_eq!(split_mark("Collapse controls"), (None, "Collapse controls"));
+    }
+
+    /// Every glyph the toolkit draws by name is in the icon set: a name
+    /// with no file draws NOTHING (a missing icon is not an error at paint),
+    /// so the miss is caught here. Skipped where the icon set is not checked
+    /// out beside the crate.
+    #[test]
+    fn every_glyph_the_toolkit_names_is_in_the_icon_set() {
+        let dir = std::path::Path::new(&crate::icons_dir()).to_path_buf();
+        if !dir.is_dir() {
+            eprintln!("skipped: no icon set at {}", dir.display());
+            return;
+        }
+        let mut names = std::collections::BTreeSet::new();
+        let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
+        let mut stack = vec![root];
+        while let Some(d) = stack.pop() {
+            for e in std::fs::read_dir(&d).unwrap().flatten() {
+                let p = e.path();
+                if p.is_dir() {
+                    stack.push(p);
+                    continue;
+                }
+                if p.extension().is_none_or(|x| x != "rs") {
+                    continue;
+                }
+                let src = std::fs::read_to_string(&p).unwrap();
+                for call in [".icon(\"", "upload_icon(\"", "upload_icon_tinted(\"", "with_icon_name(\"", "new_icon(\""] {
+                    for (i, _) in src.match_indices(call) {
+                        let rest = &src[i + call.len()..];
+                        if let Some(end) = rest.find('"') {
+                            let n = &rest[..end];
+                            if !n.is_empty() && n.chars().all(|c| c.is_ascii_lowercase() || c == '-') {
+                                names.insert(n.to_string());
+                            }
+                        }
+                    }
+                }
+            }
+        }
+        for g in ["check", "circle", "circle-outline", "chevron-left", "chevron-right", "chevron-up", "chevron-down"] {
+            names.insert(g.to_string());
+        }
+        let missing: Vec<_> = names.iter().filter(|n| !dir.join(format!("{n}.svg")).is_file()).collect();
+        assert!(missing.is_empty(), "named but not in {}: {missing:?}", dir.display());
+    }
+
     /// The plate pads its rows evenly: the height is the rows plus a pad
     /// above and below, the labels sit one pad in from the left with the
     /// widest one a pad from the right, and the padding is plate — a pointer
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index c1c632f..a831b44 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -153,7 +153,7 @@ impl Button {
     }
 
     pub fn new_copy_icon(x: f32, y: f32, w: f32, h: f32) -> Adapted<Button> {
-        Button::new_icon("copy", "📋", x, y, w, h)
+        Button::new_icon("copy", "Copy", x, y, w, h)
     }
 
     /// A plateless icon button faced with the bundled cce-icons glyph
@@ -258,9 +258,6 @@ impl Button {
     /// that holds this lock across a widget call (the flat host, around
     /// `prepare_text`) never reaches a Button's measure — keep it that way.
     fn label_width(&self, label: &str) -> f32 {
-        if label == "📋" {
-            return 12.0;
-        }
         let (family, size) = self.font();
         let font = Paint::widget_font(self);
         crate::geometry_font_system()
diff --git a/src/widget/input/button_strip.rs b/src/widget/input/button_strip.rs
index a2fc20c..a85d17e 100644
--- a/src/widget/input/button_strip.rs
+++ b/src/widget/input/button_strip.rs
@@ -9,6 +9,12 @@ pub struct ButtonStrip {
     w: f32,
     h: f32,
     pub buttons: Vec<String>,
+    /// A bundled cce-icons glyph per button (`None` for one without): in a
+    /// VERTICAL strip it stands at the top of its button, above the
+    /// rotated label. Until 2026-10-05 the glyph was the label's first
+    /// character when that was a word of its own ("📁 Browse"), drawn as
+    /// text — an emoji or a symbol in whatever face the font fell back to.
+    pub icons: Vec<Option<String>>,
     pub selected: Option<usize>,
     pub vertical: bool,
     pub just_clicked: Option<usize>,
@@ -49,6 +55,7 @@ impl ButtonStrip {
             w,
             h,
             buttons: Vec::new(),
+            icons: Vec::new(),
             selected: None,
             vertical: false,
             just_clicked: None,
@@ -109,6 +116,27 @@ impl ButtonStrip {
         self
     }
 
+    /// The glyph each button wears in a vertical strip — see [`Self::icons`].
+    pub fn with_icons(mut self, icons: Vec<Option<String>>) -> Self {
+        self.set_icons(icons);
+        self
+    }
+
+    /// See [`Self::with_icons`].
+    pub fn set_icons(&mut self, icons: Vec<Option<String>>) {
+        self.icons = icons;
+        self.tab_quads_cache.clear();
+        self.generate_rotated_labels();
+    }
+
+    /// Button `i`'s glyph, if it has one.
+    pub fn icon_of(&self, i: usize) -> Option<&str> {
+        self.icons.get(i).and_then(|n| n.as_deref())
+    }
+
+    /// The side of a vertical button's glyph.
+    pub const ICON_SIDE: f32 = 14.0;
+
     pub fn with_selected(mut self, selected: Option<usize>) -> Self {
         self.selected = selected;
         self.generate_rotated_labels();
@@ -180,13 +208,8 @@ impl ButtonStrip {
             let hex_color = format!("#{:02X}{:02X}{:02X}", color[0], color[1], color[2]);
 
             let trimmed = page_name.trim();
-            let space_idx = trimmed.find(' ');
-            let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
-            let label_text = if has_icon {
-                trimmed.split_at(space_idx.unwrap()).1.trim()
-            } else {
-                trimmed
-            };
+            let has_icon = self.icon_of(i).is_some();
+            let label_text = trimmed;
 
             let r = self.item_rect(i);
             let padding_y = crate::layout::button_padding();
@@ -272,13 +295,8 @@ impl ButtonStrip {
             let font_size = font_info.1;
             let padding = crate::layout::button_padding();
             if self.vertical {
-                let space_idx = trimmed.find(' ');
-                let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
-                let label_text = if has_icon {
-                    trimmed.split_at(space_idx.unwrap()).1.trim()
-                } else {
-                    trimmed
-                };
+                let has_icon = self.icon_of(i).is_some();
+                let label_text = trimmed;
                 let text_w = crate::widget::display::measure_text_width(label_text, &font_fam, font_size);
                 if has_icon {
                     (text_w + 12.0 + 3.0 * padding).max(1.0)
@@ -418,12 +436,15 @@ impl crate::widget::Paint for ButtonStrip {
                 if i < self.tab_text_quads.len() {
                     let min_y = self.y;
                     let max_y = self.y + self.h;
-                    let page_name = &self.buttons[i];
-                    let trimmed = page_name.trim();
-                    let space_idx = trimmed.find(' ');
-                    let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
+                    let has_icon = self.icon_of(i).is_some();
                     let padding_y = crate::layout::button_padding();
                     let y_offset = if has_icon { padding_y + 12.0 } else { 0.0 };
+                    if let Some(name) = self.icon_of(i) {
+                        let c = if Some(i) == self.selected { [0xf0, 0xf0, 0xf5] } else { [0xa8, 0xa8, 0xb3] };
+                        let side = Self::ICON_SIDE;
+                        let g = Rect { x: r.0 + 0.5 * (r.2 - side), y: r.1 + (padding_y - 2.0).max(0.0), width: side, height: side };
+                        pc.icon(name, g, [c[0] as f32 / 255.0, c[1] as f32 / 255.0, c[2] as f32 / 255.0, 1.0]);
+                    }
 
                     for &(qx, qy, qw, qh, qc) in &self.tab_text_quads[i] {
                         let absolute_x = r.0 + qx;
@@ -440,7 +461,7 @@ impl crate::widget::Paint for ButtonStrip {
             }
         }
 
-        // Own labels (horizontal button text / vertical icon glyphs).
+        // Own labels (horizontal button text).
         for tl in self.own_labels() {
             pc.text(tl.text, tl.x, tl.y, tl.font_size, tl.color);
         }
@@ -582,27 +603,8 @@ impl ButtonStrip {
                 [0xa8, 0xa8, 0xb3]
             };
             if self.vertical {
-                let trimmed = btn_label.trim();
-                let space_idx = trimmed.find(' ');
-                let has_icon = space_idx.map(|idx| trimmed.split_at(idx).0.trim().chars().count() == 1).unwrap_or(false);
-                if has_icon {
-                    let space_idx = space_idx.unwrap();
-                    let (icon, _) = trimmed.split_at(space_idx);
-                    let icon = icon.trim();
-                    if !icon.is_empty() {
-                        let icon_font_size = 14.0;
-                        let est_icon_w = crate::widget::display::measure_text_width(icon, &font_fam, icon_font_size);
-                        let padding_y = crate::layout::button_padding();
-                        let icon_y = r.1 + (padding_y - 2.0).max(0.0);
-                        labels.push(TextLabel {
-                            text: icon.to_string(),
-                            x: r.0 + (r.2 - est_icon_w) / 2.0,
-                            y: icon_y,
-                            font_size: icon_font_size,
-                            color,
-                        });
-                    }
-                }
+                // A vertical button's label is the rotated texture and its
+                // glyph an icon, both drawn in `paint`.
             } else {
                 let est_w = crate::widget::display::measure_text_width(btn_label, &font_fam, font_size);
                 labels.push(TextLabel {
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index d770b50..ae171a7 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -50,7 +50,14 @@ fn monospace_cell_width(font_family: &str, font_size: f32) -> f32 {
     ((w_m20 - w_m10) / 10.0).max(1.0)
 }
 
-/// Width of the ARROW SLOT, a trigger's right end in which its ▼ is
+/// The side of a trigger's arrow, the `chevron-down` glyph: the size the
+/// "▼" it replaced drew at in the 10px face it was set in.
+pub(crate) const ARROW_SIDE: f32 = 8.0;
+/// The arrow's colour: the grey the "▼" was drawn in (raw sRGB, as a text
+/// colour is).
+const ARROW_COLOR: [f32; 4] = [0x83 as f32 / 255.0, 0x83 as f32 / 255.0, 0x8a as f32 / 255.0, 1.0];
+
+/// Width of the ARROW SLOT, a trigger's right end in which its arrow is
 /// centred: `ARROW_SLOT` and a relief wall at this band height. A textpick
 /// picker (`ParametersBg`) is exactly this wide with its arrow in its middle,
 /// so its arrow lines up with every other trigger's in a column of rows.
@@ -161,7 +168,7 @@ pub struct Dropdown {
     /// parameter pane's textpick picker button nested in its TextBox), where
     /// the menu should span the whole field, not the button sliver.
     pub popover_anchor: Option<Rect>,
-    /// The ▼ stands in the MIDDLE of the trigger instead of in the arrow
+    /// The arrow stands in the MIDDLE of the trigger instead of in the arrow
     /// slot at its right end — for a trigger that carries nothing else
     /// (the textpick picker), whose padding about the arrow should be even.
     pub center_arrow: bool,
@@ -203,7 +210,7 @@ pub struct Dropdown {
     /// Keyboard focus (FocusIn / FocusOut): lights the trigger plate's rim.
     focused: bool,
     /// The open menu REPLACES the trigger instead of growing out of it: no
-    /// trigger band (display text + ▼) in the open surface, the rows alone,
+    /// trigger band (display text + arrow) in the open surface, the rows alone,
     /// with the menu's edge anchored where the trigger's was (its bottom for
     /// an upward menu, its top for a downward one) so the rows occupy the
     /// trigger's slot. The trigger itself stops painting once the revealed
@@ -296,7 +303,7 @@ impl Dropdown {
 
     /// Horizontal inset added to a measured label to get a dropdown width the label fits inside
     /// without tripping `paint_text`'s right-edge fade: an 8px left pad plus the 28px right
-    /// reservation (`right_limit = w - 28`, room for the 10px gap and the ▼ arrow) = 36px, plus a
+    /// reservation (`right_limit = w - 28`, room for the 10px gap and the arrow) = 36px, plus a
     /// 2px cushion for the small gap between the ink-`measure_text_width` used here and the M-dummy
     /// advance the paint pass measures with. Shared by `content_width` (widest option) and
     /// `display_width` (collapsed display text) so the two can't drift.
@@ -751,18 +758,23 @@ impl Dropdown {
         }
     }
 
-    /// Where the ▼ is drawn on a trigger band: centred in the arrow slot
-    /// at its right end ([`arrow_slot`]), or in the whole band under
-    /// `center_arrow`. Measured at the size the glyph is drawn at — the
-    /// configured font's, which the runner reads off the font string.
+    /// Where the arrow is drawn on a trigger band: the `chevron-down` glyph,
+    /// [`ARROW_SIDE`] square, centred in the arrow slot at the band's right
+    /// end ([`arrow_slot`]), or in the whole band under `center_arrow`.
     /// Until 2026-10-02 it stood 18 px in from the right end, and the
-    /// picker's centred arrow stood two pixels left of every other.
-    fn arrow_x(&self, band: Rect, font_family: &str, font_size: f32) -> f32 {
+    /// picker's centred arrow stood two pixels left of every other; until
+    /// 2026-10-05 it was a "▼" in the control font.
+    pub(crate) fn arrow_rect(&self, band: Rect) -> Rect {
         let slot = if self.center_arrow { band.width } else { arrow_slot(band.height).min(band.width) };
-        band.x + band.width - 0.5 * (slot + text_advance("▼", font_family, font_size))
+        Rect {
+            x: band.x + band.width - 0.5 * (slot + ARROW_SIDE),
+            y: band.y + 0.5 * (band.height - ARROW_SIDE),
+            width: ARROW_SIDE,
+            height: ARROW_SIDE,
+        }
     }
 
-    /// Emit the selected-text (per-character fade against the right edge) and the ▼ arrow —
+    /// Emit the selected-text (per-character fade against the right edge) and the arrow glyph —
     /// the legacy `text_labels` body minus the control label (the adapter's base-label
     /// machinery draws that, with the +4px `detached_label_inset`).
     fn paint_text(&self, content: Rect, ctx: &mut PaintCtx) {
@@ -852,16 +864,8 @@ impl Dropdown {
         // fight rather than help.
         // The glyph is drawn at the configured font's size (the runner
         // reads it off the font string), so that is the size it is measured at.
-        let arrow_x = self.arrow_x(content, &font_family, font_size);
-        ctx.text_with(
-            "▼",
-            arrow_x,
-            crate::layout::center_text_y(content.y, content.height, 10.0),
-            10.0,
-            [0x83, 0x83, 0x8a],
-            Some(crate::layout::control_label_font_detached()),
-            Some([content.x, content.y, content.x + content.width, content.y + content.height]),
-        );
+        let _ = (&font_family, font_size);
+        ctx.icon("chevron-down", self.arrow_rect(content), ARROW_COLOR);
     }
 
     /// Port of the legacy `keyboard_input` body.
@@ -1132,7 +1136,7 @@ impl Paint for Dropdown {
         }
 
         // Trigger content redrawn over its band (the box covers the widget-pass
-        // trigger paint) — display text left, ▼ right, the paint_text palette.
+        // trigger paint) — display text left, arrow right, the paint_text palette.
         // A menu that replaces the trigger has no band to redraw on.
         if !self.menu_replaces_trigger {
             let (tx, ty) = (rect.x, rect.y);
@@ -1149,16 +1153,7 @@ impl Paint for Dropdown {
                 &font,
                 band_bounds,
             );
-            let (family, size) = crate::layout::control_label_font_detached_parsed();
-            pc.text_with_font_and_bounds(
-                "▼",
-                self.arrow_x(rect, &family, size),
-                crate::layout::center_text_y(ty, th, 10.0),
-                10.0,
-                [0x83 as f32 / 255.0, 0x83 as f32 / 255.0, 0x8a as f32 / 255.0, 1.0],
-                &font,
-                band_bounds,
-            );
+            pc.icon("chevron-down", self.arrow_rect(rect), ARROW_COLOR);
         }
 
         if let Some(h_idx) = self.hovered_item {
@@ -1456,7 +1451,7 @@ mod tests {
             .items
             .into_iter()
             .filter_map(|item| match item.prim {
-                crate::scene::paint::Prim::Text { text, x, .. } if text != "▼" => Some((text, x)),
+                crate::scene::paint::Prim::Text { text, x, .. } => Some((text, x)),
                 _ => None,
             })
             .collect();
@@ -1668,11 +1663,11 @@ mod tests {
         dd.set_rect(10.0, 10.0, 100.0, 24.0); // very narrow dropdown
 
         let labels = dd.own_text_labels();
-        // Labels are individual characters of selected_text, then the ▼ arrow (prim order).
+        // Labels are individual characters of selected_text (the arrow is a glyph, not text).
         assert!(labels.len() > 2);
 
-        // The last character label (excluding the arrow) should be faded (i.e. not the default color)
-        let last_char_idx = labels.len() - 2;
+        // The last character label should be faded (i.e. not the default color)
+        let last_char_idx = labels.len() - 1;
         let first_char = &labels[0];
         let last_char = &labels[last_char_idx];
 
@@ -1694,10 +1689,9 @@ mod tests {
             let mut dd = Dropdown::new(vec!["a".to_string()], 0).with_custom_display_text("");
             dd.inner_mut().center_arrow = center;
             dd.set_rect(10.0, 10.0, 30.0, 24.0);
-            dd.own_text_labels().into_iter().find(|l| l.text == "▼").expect("the arrow").x
+            dd.inner().arrow_rect(Rect { x: 10.0, y: 10.0, width: 30.0, height: 24.0 }).x
         };
-        let (family, size) = crate::layout::control_label_font_detached_parsed();
-        let adv = super::text_advance("▼", &family, size);
+        let adv = super::ARROW_SIDE;
         let x = arrow_x(true);
         assert!((x + 0.5 * adv - 25.0).abs() < 1e-3, "centred on the trigger's middle: {x} + {adv}/2");
         let slot = super::arrow_slot(24.0);
diff --git a/src/widget/input/font_selector.rs b/src/widget/input/font_selector.rs
index f9fec27..b5c6fa1 100644
--- a/src/widget/input/font_selector.rs
+++ b/src/widget/input/font_selector.rs
@@ -116,17 +116,6 @@ impl FontSelector {
             }
         }
 
-        // The picker glyph: "Aa", the font-picker convention, in the dropdown arrow's
-        // grey — a text glyph every face has (the emoji this drew rendered as tofu
-        // wherever no emoji font was installed).
-        labels.push(TextLabel {
-            text: "Aa".to_string(),
-            x: rect.x + rect.width - 24.0,
-            y: crate::layout::align_text_y(rect.y, rect.height, 11.0, 0.0),
-            font_size: 11.0,
-            color: [0x83, 0x83, 0x8a],
-        });
-
         labels
     }
 }
@@ -196,18 +185,20 @@ impl Paint for FontSelector {
 }
 
 impl FontSelector {
-    /// Labels: family text clipped short of the picker glyph (the legacy per-label
-    /// bounds), glyph unclipped.
+    /// Labels: family text clipped short of the picker glyph, then the glyph
+    /// itself — the `font` icon in the dropdown arrow's grey, at the right
+    /// end (it was the text "Aa" until 2026-10-05, and an emoji that
+    /// rendered as tofu before that).
     fn paint_labels(&self, rect: Rect, ctx: &mut PaintCtx) {
         let font = self.widget_font();
         let clip_right = rect.x + rect.width - 24.0;
         let bounds = Some([rect.x, rect.y, clip_right, rect.y + rect.height]);
-        let labels = self.field_labels(rect);
-        let count = labels.len();
-        for (idx, l) in labels.into_iter().enumerate() {
-            let b = if idx < count - 1 { bounds } else { None };
-            ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, font.clone(), b);
+        for l in self.field_labels(rect) {
+            ctx.text_with(l.text, l.x, l.y, l.font_size, l.color, font.clone(), bounds);
         }
+        const SIDE: f32 = 12.0;
+        let g = Rect { x: rect.x + rect.width - 24.0 + 0.5 * (24.0 - SIDE) - 2.0, y: rect.y + 0.5 * (rect.height - SIDE), width: SIDE, height: SIDE };
+        ctx.icon("font", g, [0x83 as f32 / 255.0, 0x83 as f32 / 255.0, 0x8a as f32 / 255.0, 1.0]);
     }
 }
 
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index 874b028..98b86b0 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -477,7 +477,7 @@ impl Paint for Spinbox {
             }
         }
 
-        // Value, unit, and -/+ glyphs.
+        // Value, unit, and the minus/plus glyphs.
         let tc = colors::spinbox_text_color();
         let text_color = [(tc[0] * 255.0) as u8, (tc[1] * 255.0) as u8, (tc[2] * 255.0) as u8];
         // The value and its unit live in the FIELD, which ends where the -/+
@@ -492,11 +492,14 @@ impl Paint for Spinbox {
         if g.btn_w > 0.0 {
             let dec_center_x = g.dec_c;
             let inc_center_x = g.inc_c;
-            let ty = crate::layout::align_text_y(g.y, g.h, 12.0, 0.0);
-            let dec_box = Some([g.run_x, g.y, g.seam_x, g.y + g.h]);
-            let inc_box = Some([g.seam_x, g.y, g.run_end, g.y + g.h]);
-            ctx.text_with("-".to_string(), dec_center_x - 4.0, ty, 12.0, text_color, None, dec_box);
-            ctx.text_with("+".to_string(), inc_center_x - 4.0, ty, 12.0, text_color, None, inc_box);
+            // The `minus` and `plus` glyphs, in the value's colour, centred
+            // on their buttons (they were "-" and "+" in the 12px face).
+            const SIDE: f32 = 8.0;
+            let gy = g.y + 0.5 * (g.h - SIDE);
+            let tint = [tc[0], tc[1], tc[2], 1.0];
+            let glyph = |cx: f32| crate::scene::layout::Rect { x: cx - 0.5 * SIDE, y: gy, width: SIDE, height: SIDE };
+            ctx.icon("minus", glyph(dec_center_x), tint);
+            ctx.icon("plus", glyph(inc_center_x), tint);
         }
     }
 }
diff --git a/src/widget/markdown.rs b/src/widget/markdown.rs
index 1746a6f..60f4d2e 100644
--- a/src/widget/markdown.rs
+++ b/src/widget/markdown.rs
@@ -83,13 +83,15 @@ pub enum Draw {
     Dot { cx: f32, cy: f32, r: f32, color: [f32; 4] },
     /// An embedded image, by its link text; its id is asked for at paint.
     Image { target: String, rect: Rect },
+    /// A bundled cce-icons glyph, tinted `color` (sRGB, as a text colour).
+    Icon { name: &'static str, rect: Rect, color: [f32; 4] },
 }
 
 impl Draw {
     fn top_bottom(&self) -> (f32, f32) {
         match self {
             Draw::Text { y, size, .. } => (*y, y + size * 1.3),
-            Draw::Quad { rect, .. } | Draw::Round { rect, .. } | Draw::Image { rect, .. } => (rect.y, rect.y + rect.height),
+            Draw::Quad { rect, .. } | Draw::Round { rect, .. } | Draw::Image { rect, .. } | Draw::Icon { rect, .. } => (rect.y, rect.y + rect.height),
             Draw::Line { y1, y2, width, .. } => (y1.min(*y2) - width, y1.max(*y2) + width),
             Draw::Check { cy, r, .. } | Draw::Dot { cy, r, .. } => (cy - r, cy + r),
         }
@@ -178,6 +180,9 @@ impl Layout {
                     Some(img) => pc.image(img.id, rect(r), 1.0),
                     None => pc.rounded_rect(rect(r), 4.0 * k, (true, true, true, true), CODE_BG),
                 },
+                Draw::Icon { name, rect: r, color } => {
+                    pc.icon(name, rect(r), *color);
+                }
             }
         }
     }
@@ -290,16 +295,27 @@ impl<'a> Layouter<'a> {
                 y + ih
             }
             Block::Embed { target, subpath, .. } => {
+                // An embed that cannot be shown inline (a note, a missing
+                // image) is a link to it, led by the `link` glyph in the
+                // link colour (it was "↳ " until 2026-10-05).
                 let shown = match subpath {
-                    Some(s) => format!("↳ {target}#{s}"),
-                    None => format!("↳ {target}"),
+                    Some(s) => format!("{target}#{s}"),
+                    None => target.clone(),
                 };
+                let size = self.theme.size;
+                let side = (size * 0.85).round();
+                let gap = (size * 0.4).round();
+                self.out.draws.push(Draw::Icon {
+                    name: "link",
+                    rect: Rect { x, y: y + 0.5 * (size * 1.3 - side), width: side, height: side },
+                    color: LINK,
+                });
                 let span = Span {
                     text: shown,
                     style: Style::default(),
                     link: Some(SpanLink::Note { target: target.clone(), subpath: subpath.clone() }),
                 };
-                self.inline(std::slice::from_ref(&span), x, w, y, self.plain_style())
+                self.inline(std::slice::from_ref(&span), x + side + gap, (w - side - gap).max(0.0), y, self.plain_style())
             }
             Block::List { start, items, .. } => self.list(*start, items, x, w, y),
             Block::Quote { callout, blocks, .. } => self.quote(callout.as_ref(), blocks, x, w, y),