From 88c60d8fe0f1520be83c04f237ea5ef397fb2d8d Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Sun, 27 Sep 2026 00:59:04 +0200 Subject: [PATCH] feat(pointer): add an outline tone with a transparent fill and white edge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue #344 point 5: pointer only had filled tones (light/dark), but a walkthrough over a busy screenshot or mockup needs an arrow that marks a spot without covering it — a white outline over a transparent fill. Chose a third PointerTone variant (Outline) over a separate `fill` field. `tone` already exists precisely to let a scene pick one coherent word instead of juggling matched fill/outline hex pairs (see its own doc comment), and `colors()` already dispatches fill+outline from it in one match. A `fill` field would need its own semantics for what "no fill" means, interact awkwardly with `color`/`outline_color`, and reintroduce the two-hex-values problem `tone` was built to avoid. A third arm keeps the existing override mechanism (`color`/`outline_color`) working unchanged: a scenario can still ask for a translucent fill under an outline pointer by setting `color` explicitly. The click ring used to default to the arrow's fill color. For Outline, fill is "transparent" (parses to rgb (0,0,0), alpha only from ring draw time), so with the old default this would render a black ring under a white outline: no hardcoded bug, but not "coherent with the outline style" either. Added `ring_fallback_color`, which threads through the *already resolved* fill/outline strings instead of adding a second hardcoded white: outline tone falls back to `outline`, the two filled tones keep falling back to `fill` exactly as before (so existing light/dark scenarios render byte-identically — pinned by `filled_tones_still_paint_pointer_colour_in_both_interior_and_edge`). Tests measure actual pixels, not field values: an interior point (found via Path::contains with a margin, not hand-picked) must stay background-colored for an outline pointer since its fill paint has alpha 0, while an edge point sitting on the stroke must be opaque and pointer-colored. Confirmed the interior/edge test fails before the fix (temporarily reusing the filled arm's colors for Outline): assertion `left == right` failed, got `(255, 255, 255, 255)` where `(0, 128, 0, 255)` (the background) was expected. A second pixel test pins the click ring default: temporarily hardcoding the fallback to `fill` reproduces a (9, 9, 9, 255) near-black ring against a white outline, which the test catches and which `ring_fallback_color` fixes. Extended pointer-walkthrough.md with the new tone and the ring-default rule. SKILL.md doesn't enumerate PointerTone's values anywhere, so it needs no update for this change. --- crates/rustmotion-components/src/pointer.rs | 182 +++++++++++++++++- .../skills/rules/pointer-walkthrough.md | 8 +- 2 files changed, 188 insertions(+), 2 deletions(-) diff --git a/crates/rustmotion-components/src/pointer.rs b/crates/rustmotion-components/src/pointer.rs index a68c98fa..c1d663e2 100644 --- a/crates/rustmotion-components/src/pointer.rs +++ b/crates/rustmotion-components/src/pointer.rs @@ -21,6 +21,9 @@ pub enum PointerTone { Light, /// Dark arrow, light outline — for light frames. Dark, + /// Transparent fill, white outline — reads on top of any background, + /// dark or light, without a filled shape competing with what it points at. + Outline, } /// How loud the click ring is. @@ -134,6 +137,7 @@ impl Pointer { let (fill, outline) = match self.tone { PointerTone::Light => ("#FFFFFF", "#111827"), PointerTone::Dark => ("#111827", "#FFFFFF"), + PointerTone::Outline => ("transparent", "#FFFFFF"), }; ( self.color.clone().unwrap_or_else(|| fill.to_string()), @@ -143,6 +147,13 @@ impl Pointer { ) } + fn ring_fallback_color<'a>(&self, fill: &'a str, outline: &'a str) -> &'a str { + match self.tone { + PointerTone::Outline => outline, + PointerTone::Light | PointerTone::Dark => fill, + } + } + fn arrow_path(size: f32) -> Path { const OUTLINE: [(f32, f32); 7] = [ (0.0, 0.0), @@ -187,7 +198,8 @@ impl Painter for Pointer { canvas.translate((dx, dy)); if let (Some(p), Some((stroke_f, travel_f))) = (click, self.click_ring.metrics()) { - let (r, g, b, _) = parse_hex_color(self.ring_color.as_deref().unwrap_or(&fill)); + let ring_fallback = self.ring_fallback_color(&fill, &outline); + let (r, g, b, _) = parse_hex_color(self.ring_color.as_deref().unwrap_or(ring_fallback)); let alpha = ((1.0 - p) * 200.0) as u8; if alpha > 0 { let mut ring = Paint::default(); @@ -312,4 +324,172 @@ mod tests { "the click itself still runs — the arrow still dips" ); } + + fn paint_ctx(time: f64, video_width: u32, video_height: u32) -> PaintCtx { + PaintCtx { + time, + scenario_time: time, + scene_duration: 1.0, + frame_index: 0, + fps: 30, + video_width, + video_height, + stagger_offset: 0.0, + } + } + + fn render( + p: &Pointer, + w: i32, + h: i32, + time: f64, + background: skia_safe::Color, + ) -> skia_safe::Surface { + let mut surface = skia_safe::surfaces::raster_n32_premul((w, h)).expect("raster surface"); + { + let canvas = surface.canvas(); + canvas.clear(background); + p.paint_content( + canvas, + &BoxLayout::default(), + &AnimatedProperties::default(), + &paint_ctx(time, w as u32, h as u32), + ); + } + surface + } + + fn pixel(surface: &mut skia_safe::Surface, w: i32, h: i32, x: f32, y: f32) -> (u8, u8, u8, u8) { + let snapshot = surface.image_snapshot(); + let info = skia_safe::ImageInfo::new( + (w, h), + skia_safe::ColorType::RGBA8888, + skia_safe::AlphaType::Unpremul, + None, + ); + let mut buf = vec![0u8; (w * h * 4) as usize]; + let ok = snapshot.read_pixels( + &info, + &mut buf, + (w * 4) as usize, + skia_safe::IPoint::new(0, 0), + skia_safe::image::CachingHint::Disallow, + ); + assert!(ok, "pixel read should succeed"); + let ix = x.round() as i32; + let iy = y.round() as i32; + let idx = ((iy * w + ix) * 4) as usize; + (buf[idx], buf[idx + 1], buf[idx + 2], buf[idx + 3]) + } + + const PROBE_SIZE: f32 = 200.0; + + fn deep_interior_point() -> (f32, f32) { + (PROBE_SIZE * 0.1946, PROBE_SIZE * 0.4390) + } + + fn left_edge_point() -> (f32, f32) { + let stroke_width = (PROBE_SIZE * 0.07f32).max(1.0); + (stroke_width * 0.3, PROBE_SIZE * 0.36) + } + + #[test] + fn deep_interior_point_is_well_clear_of_the_outline_stroke() { + let path = Pointer::arrow_path(PROBE_SIZE); + let margin = (PROBE_SIZE * 0.07).max(1.0) + 3.0; + let (cx, cy) = deep_interior_point(); + assert!( + path.contains((cx, cy)), + "probe point must be inside the arrow" + ); + for (dx, dy) in [(-margin, 0.0), (margin, 0.0), (0.0, -margin), (0.0, margin)] { + assert!( + path.contains((cx + dx, cy + dy)), + "probe point at ({cx}, {cy}) is too close to an edge in direction ({dx}, {dy})" + ); + } + } + + #[test] + fn outline_tone_leaves_the_interior_transparent_and_paints_a_pointer_coloured_edge() { + let p = pointer(serde_json::json!({ "tone": "outline", "size": PROBE_SIZE })); + let background = skia_safe::Color::from_argb(255, 0, 128, 0); + const W: i32 = 300; + const H: i32 = 300; + let mut surface = render(&p, W, H, 0.0, background); + + let (ix, iy) = deep_interior_point(); + let interior = pixel(&mut surface, W, H, ix, iy); + assert_eq!( + interior, + (0, 128, 0, 255), + "an outline pointer must let the background show through its interior, got {interior:?}" + ); + + let (ex, ey) = left_edge_point(); + let edge = pixel(&mut surface, W, H, ex, ey); + assert!( + edge.0 > 200 && edge.1 > 200 && edge.2 > 200 && edge.3 == 255, + "the outline itself must still paint a solid, pointer-coloured edge, got {edge:?}" + ); + } + + #[test] + fn filled_tones_still_paint_pointer_colour_in_both_interior_and_edge() { + let background = skia_safe::Color::from_argb(255, 0, 128, 0); + const W: i32 = 300; + const H: i32 = 300; + let (ix, iy) = deep_interior_point(); + let (ex, ey) = left_edge_point(); + + let light = pointer(serde_json::json!({ "tone": "light", "size": PROBE_SIZE })); + let mut light_surface = render(&light, W, H, 0.0, background); + assert_eq!( + pixel(&mut light_surface, W, H, ix, iy), + (255, 255, 255, 255), + "light tone interior must stay pinned to its white fill" + ); + assert_eq!( + pixel(&mut light_surface, W, H, ex, ey), + (0x11, 0x18, 0x27, 255), + "light tone edge must stay pinned to its dark outline" + ); + + let dark = pointer(serde_json::json!({ "tone": "dark", "size": PROBE_SIZE })); + let mut dark_surface = render(&dark, W, H, 0.0, background); + assert_eq!( + pixel(&mut dark_surface, W, H, ix, iy), + (0x11, 0x18, 0x27, 255), + "dark tone interior must stay pinned to its dark fill" + ); + assert_eq!( + pixel(&mut dark_surface, W, H, ex, ey), + (255, 255, 255, 255), + "dark tone edge must stay pinned to its white outline" + ); + } + + #[test] + fn outline_tone_keeps_the_click_ring_visible_and_matched_to_the_white_outline() { + let p = pointer(serde_json::json!({ + "tone": "outline", + "size": 120.0, + "click_ring": "bold", + "click_duration": 0.5, + "path": [{ "time": 0.0, "x": 150.0, "y": 150.0 }] + })); + const W: i32 = 300; + const H: i32 = 300; + let background = skia_safe::Color::from_argb(255, 20, 20, 20); + let mut surface = render(&p, W, H, 0.15, background); + + let radius = 0.3 * 1.25 * 120.0; + let offset = radius * std::f32::consts::FRAC_1_SQRT_2; + let (rx, ry) = (150.0 - offset, 150.0 - offset); + let ring = pixel(&mut surface, W, H, rx, ry); + assert!( + ring.0 > 100 && ring.1 > 100 && ring.2 > 100, + "the click ring on an outline pointer must default to the outline's white, not a hard-coded or transparent-derived colour, got {ring:?}" + ); + } } diff --git a/crates/rustmotion/skills/rules/pointer-walkthrough.md b/crates/rustmotion/skills/rules/pointer-walkthrough.md index f769e6ef..cbda3821 100644 --- a/crates/rustmotion/skills/rules/pointer-walkthrough.md +++ b/crates/rustmotion/skills/rules/pointer-walkthrough.md @@ -26,7 +26,7 @@ For a product demo or an agent walkthrough — the arrow that moves to a control | Field | Role | |---|---| | `size` | Height of the arrow in px. The click ring scales with it. | -| `tone` | `light` (white arrow, dark outline) or `dark` | +| `tone` | `light` (white arrow, dark outline), `dark`, or `outline` (transparent fill, white outline) | | `color` / `outline_color` | Override `tone` | | `click_ring` | `subtle` / `standard` / `bold` / `none` | | `path` | Waypoints `{time, x, y}` — the pointer **clicks on arrival** at each one | @@ -47,3 +47,9 @@ Corollary: `pointer` is **exempt from the viewport overflow check**, like `marqu ## The move pauses on the click Between two waypoints, the pointer doesn't set off again until the click animation is done (`click_duration`). That's what makes the gesture read: arrive, click, leave. A `click_duration` close to the gap between two waypoints barely leaves time for the travel — leave at least double. + +## `outline` tone: a pointer that doesn't fight the thing it's pointing at + +`light` and `dark` are both filled arrows — a solid shape that sits on top of whatever's underneath. `outline` is a third tone: a transparent fill with a white outline, so the arrow reads as a mark rather than a shape competing for attention with the control it's pointing at. Reach for it over a busy screenshot or a mockup where a filled arrow would cover detail you want to keep visible. + +The click ring follows the same rule as `color`/`outline_color`: it defaults to whichever colour is actually visible for the tone in use — the fill for `light`/`dark`, the outline for `outline` — rather than a colour hard-coded independently of `tone`. `ring_color` still overrides it directly, same as on the filled tones.