Skip to main content

ui_storybook/components/cursor/
cursor_preview_canvas.rs

1//! `CursorPreviewCanvas` + `CursorAppearancePanel` (M-UI.21 / AUT-141).
2//!
3//! Preview canvas reuses the same backend pattern as the editor's
4//! `WispCanvasHost` (CSS fallback, pre-rendered Wisp asset, or
5//! runtime-unavailable banner). The appearance panel composes UI-18
6//! inspector primitives (`PropertySection` + `PropertyControlView`).
7
8use leptos::prelude::*;
9
10use crate::components::editor::{
11    InspectorTab, InspectorTabs, PropertyControlView, PropertyRowView, PropertySection,
12    PropertySectionView,
13};
14
15/// Which backend should render the preview.
16#[derive(Clone, Debug, PartialEq, Eq, Default)]
17pub enum CursorPreviewBackend {
18    /// CSS fallback drawn over the light preview card.
19    #[default]
20    CssFallback,
21    /// Pre-rendered Wisp asset (PNG).
22    WispAsset {
23        /// Relative path from the chapter to the asset.
24        asset_path: &'static str,
25    },
26    /// Runtime not available in this build.
27    RuntimeUnavailable,
28}
29
30impl CursorPreviewBackend {
31    /// Stable kebab-case slug.
32    #[must_use]
33    pub fn slug(&self) -> &'static str {
34        match self {
35            CursorPreviewBackend::CssFallback => "css-fallback",
36            CursorPreviewBackend::WispAsset { .. } => "wisp-asset",
37            CursorPreviewBackend::RuntimeUnavailable => "runtime-unavailable",
38        }
39    }
40}
41
42/// One named cursor color.
43#[derive(Clone, Copy, Debug, PartialEq, Eq)]
44pub struct CursorColor {
45    /// Display label ("White", "Yellow").
46    pub label: &'static str,
47    /// CSS color string.
48    pub css: &'static str,
49}
50
51/// Click-effect variants displayed in the appearance panel.
52#[derive(Clone, Copy, Debug, PartialEq, Eq)]
53pub enum ClickEffect {
54    /// Outline ring expanding from the click point.
55    Ring,
56    /// Soft pulse.
57    Pulse,
58    /// Spotlight cone.
59    Spotlight,
60    /// No click effect.
61    None,
62}
63
64impl ClickEffect {
65    /// Display label.
66    #[must_use]
67    pub fn label(self) -> &'static str {
68        match self {
69            ClickEffect::Ring => "Ring",
70            ClickEffect::Pulse => "Pulse",
71            ClickEffect::Spotlight => "Spotlight",
72            ClickEffect::None => "None",
73        }
74    }
75
76    /// Stable kebab-case slug.
77    #[must_use]
78    pub fn slug(self) -> &'static str {
79        match self {
80            ClickEffect::Ring => "ring",
81            ClickEffect::Pulse => "pulse",
82            ClickEffect::Spotlight => "spotlight",
83            ClickEffect::None => "none",
84        }
85    }
86}
87
88/// Coarse cursor-behavior preset.
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
90pub enum CursorBehaviorView {
91    /// Default — preserves real motion.
92    #[default]
93    Natural,
94    /// Smoothed motion path.
95    Smooth,
96    /// Snap to elements under the cursor.
97    Snap,
98}
99
100impl CursorBehaviorView {
101    /// Display label.
102    #[must_use]
103    pub fn label(self) -> &'static str {
104        match self {
105            CursorBehaviorView::Natural => "Natural",
106            CursorBehaviorView::Smooth => "Smooth",
107            CursorBehaviorView::Snap => "Snap to UI",
108        }
109    }
110}
111
112/// Appearance / motion / behavior view-model.
113#[derive(Clone, Debug, PartialEq)]
114pub struct CursorAppearanceView {
115    /// Cursor size 0..=200 (percent of default).
116    pub size_percent: u16,
117    /// Currently-selected color.
118    pub selected_color: CursorColor,
119    /// `true` to enable the halo behind the cursor.
120    pub halo_enabled: bool,
121    /// Halo strength 0..=100.
122    pub halo_strength_percent: u16,
123    /// Active click effect.
124    pub click_effect: ClickEffect,
125    /// Motion smoothing 0..=100.
126    pub smoothing_percent: u16,
127    /// `true` to render a motion trail.
128    pub trail_enabled: bool,
129    /// Behavior preset.
130    pub behavior: CursorBehaviorView,
131}
132
133impl CursorAppearanceView {
134    /// Clamp `size_percent` into `0..=200`.
135    #[must_use]
136    pub fn clamped_size(&self) -> u16 {
137        self.size_percent.min(200)
138    }
139}
140
141#[component]
142pub fn CursorPreviewCanvas(
143    /// Backend selection.
144    backend: CursorPreviewBackend,
145    /// Background CSS string for the preview card (so stories can
146    /// demo dark / light surfaces).
147    #[prop(optional, default = "linear-gradient(135deg,#f8fafc,#e5e7eb)")]
148    card_background: &'static str,
149) -> impl IntoView {
150    let style = format!("background: {card_background};");
151    let body = match backend {
152        CursorPreviewBackend::CssFallback => view! {
153            <span class="cursor-preview-arrow" aria-hidden="true">"➤"</span>
154        }
155        .into_any(),
156        CursorPreviewBackend::WispAsset { asset_path } => view! {
157            <img class="cursor-preview-asset" src=asset_path alt="Cursor preview" />
158        }
159        .into_any(),
160        CursorPreviewBackend::RuntimeUnavailable => view! {
161            <span class="cursor-preview-banner">"Runtime unavailable — CSS preview"</span>
162        }
163        .into_any(),
164    };
165    view! {
166        <div class="cursor-preview-canvas" style=style role="img" aria-label="Cursor preview">
167            <span class="cursor-preview-ring" aria-hidden="true"></span>
168            {body}
169        </div>
170    }
171}
172
173#[component]
174pub fn CursorAppearancePanel(view: CursorAppearanceView) -> impl IntoView {
175    let appearance = appearance_section(&view);
176    let click = click_effect_section(&view);
177    let motion = motion_section(&view);
178    let behavior = behavior_section(&view);
179    view! {
180        <aside class="cursor-appearance-panel" aria-label="Cursor appearance">
181            <InspectorTabs tabs=vec![InspectorTab::Cursor] active=InspectorTab::Cursor />
182            <div class="cursor-appearance-body">
183                <PropertySection section=appearance />
184                <PropertySection section=click />
185                <PropertySection section=motion />
186                <PropertySection section=behavior />
187            </div>
188            <div class="cursor-appearance-footer">
189                <button class="btn btn-outline btn-sm">"Reset"</button>
190                <button class="btn btn-default btn-sm">"Apply"</button>
191            </div>
192        </aside>
193    }
194}
195
196fn appearance_section(v: &CursorAppearanceView) -> PropertySectionView {
197    PropertySectionView {
198        title: "APPEARANCE",
199        rows: vec![
200            PropertyRowView {
201                label: "Size",
202                value: Some(leak(format!("{}%", v.clamped_size()))),
203                disabled: false,
204                control: PropertyControlView::SliderPercent {
205                    percent: pct_from_size(v.clamped_size()),
206                },
207            },
208            PropertyRowView {
209                label: "Color",
210                value: Some(v.selected_color.label),
211                disabled: false,
212                control: PropertyControlView::ColorSwatches {
213                    swatches: vec!["#fafafa", "#facc15", "#38bdf8", "#a78bfa", "#f97316"],
214                    selected: color_index(&v.selected_color),
215                },
216            },
217            PropertyRowView {
218                label: "Halo",
219                value: None,
220                disabled: false,
221                control: PropertyControlView::Toggle { on: v.halo_enabled },
222            },
223            PropertyRowView {
224                label: "Halo strength",
225                value: Some(leak(format!("{}%", v.halo_strength_percent.min(100)))),
226                disabled: !v.halo_enabled,
227                control: PropertyControlView::SliderPercent {
228                    percent: v.halo_strength_percent.min(100) as u8,
229                },
230            },
231        ],
232    }
233}
234
235fn click_effect_section(v: &CursorAppearanceView) -> PropertySectionView {
236    PropertySectionView {
237        title: "CLICK EFFECT",
238        rows: vec![PropertyRowView {
239            label: "Effect",
240            value: Some(v.click_effect.label()),
241            disabled: false,
242            control: PropertyControlView::SelectPill {
243                current_label: v.click_effect.label(),
244            },
245        }],
246    }
247}
248
249fn motion_section(v: &CursorAppearanceView) -> PropertySectionView {
250    PropertySectionView {
251        title: "MOTION",
252        rows: vec![
253            PropertyRowView {
254                label: "Smoothing",
255                value: Some(leak(format!("{}%", v.smoothing_percent.min(100)))),
256                disabled: false,
257                control: PropertyControlView::SliderPercent {
258                    percent: v.smoothing_percent.min(100) as u8,
259                },
260            },
261            PropertyRowView {
262                label: "Trail",
263                value: None,
264                disabled: false,
265                control: PropertyControlView::Toggle {
266                    on: v.trail_enabled,
267                },
268            },
269        ],
270    }
271}
272
273fn behavior_section(v: &CursorAppearanceView) -> PropertySectionView {
274    PropertySectionView {
275        title: "BEHAVIOR",
276        rows: vec![PropertyRowView {
277            label: "Preset",
278            value: Some(v.behavior.label()),
279            disabled: false,
280            control: PropertyControlView::SelectPill {
281                current_label: v.behavior.label(),
282            },
283        }],
284    }
285}
286
287fn pct_from_size(size: u16) -> u8 {
288    let pct = u32::from(size) * 100 / 200;
289    u8::try_from(pct.min(100)).expect("clamped value fits in u8")
290}
291
292fn color_index(c: &CursorColor) -> usize {
293    let palette = ["#fafafa", "#facc15", "#38bdf8", "#a78bfa", "#f97316"];
294    palette.iter().position(|p| *p == c.css).unwrap_or(0)
295}
296
297fn leak(s: String) -> &'static str {
298    Box::leak(s.into_boxed_str())
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn click_effect_slugs_unique() {
307        let slugs = [
308            ClickEffect::Ring.slug(),
309            ClickEffect::Pulse.slug(),
310            ClickEffect::Spotlight.slug(),
311            ClickEffect::None.slug(),
312        ];
313        let mut sorted = slugs.to_vec();
314        sorted.sort_unstable();
315        sorted.dedup();
316        assert_eq!(sorted.len(), slugs.len());
317    }
318
319    #[test]
320    fn cursor_appearance_clamps_size() {
321        let v = CursorAppearanceView {
322            size_percent: 350,
323            selected_color: CursorColor {
324                label: "White",
325                css: "#fafafa",
326            },
327            halo_enabled: true,
328            halo_strength_percent: 250,
329            click_effect: ClickEffect::Ring,
330            smoothing_percent: 110,
331            trail_enabled: false,
332            behavior: CursorBehaviorView::Natural,
333        };
334        assert_eq!(v.clamped_size(), 200);
335    }
336}