Skip to main content

app_ui/
routing.rs

1//! Pure-Rust URL-routing helpers (M-TRAY.4 / AUT-253, M-BUBBLE.0 / AUT-273).
2//!
3//! The browser-facing entry points in [`crate`] pull the query string
4//! off `window.location.search()` and hand it to [`parse_surface`]
5//! (for the in-`AppShell` surface) or [`parse_mount_point`] (for the
6//! window-level mount dispatch — `AppShell` vs `Bubble` vs `DropZone`).
7//! Splitting the parse out of the wasm-only path makes it testable
8//! on every OS (no `web_sys`, no Tauri runtime).
9
10use ui_storybook::components::shell::AppSection;
11
12/// Which Leptos tree the current page mounts — drives the top-level
13/// dispatch in [`crate::run`].
14///
15/// Distinct from [`AppSection`] (which is for navigation-rail items
16/// inside the `AppShell`). A `?mount=bubble` page renders the bubble
17/// canvas, not a `NavigationRail` surface; keeping the two enums
18/// separate prevents `AppShell`-internal navigation from accidentally
19/// reaching the bubble or vice versa.
20#[derive(Clone, Copy, Debug, PartialEq, Eq)]
21pub enum MountPoint {
22    /// `?surface=<recorder|library|editor|cursor|prefs>` —
23    /// the in-`AppShell` `NavigationRail` surface (M-TRAY.3 / AUT-252).
24    AppShell(AppSection),
25    /// `?mount=bubble` — the borderless webcam overlay window
26    /// (M-BUBBLE.0 / AUT-273).
27    Bubble,
28    /// No recognised query — falls back to the legacy drop-zone shell
29    /// preserved for `trunk serve` browser dev (M-INT.1 flow).
30    DropZone,
31}
32
33/// Parse a URL query string (with or without the leading `?`) into a
34/// [`MountPoint`]. `?surface=…` wins over `?mount=…` so the `AppShell`
35/// surface query takes precedence — matters only if both ever appear
36/// in the same URL, which they shouldn't.
37#[must_use]
38pub fn parse_mount_point(query: &str) -> MountPoint {
39    if let Some(section) = parse_surface(query) {
40        return MountPoint::AppShell(section);
41    }
42    let stripped = query.strip_prefix('?').unwrap_or(query);
43    for pair in stripped.split('&') {
44        if let Some((key, value)) = pair.split_once('=')
45            && key == "mount"
46            && value == "bubble"
47        {
48            return MountPoint::Bubble;
49        }
50    }
51    MountPoint::DropZone
52}
53
54/// Parse a URL query string (with or without the leading `?`) for a
55/// `surface=<slug>` parameter. Returns the matching [`AppSection`]
56/// or `None` if the param is missing / unrecognised.
57///
58/// Round-trips with [`surface_slug`]: parsing the output of
59/// `surface_slug(x)` returns `Some(x)` for every variant.
60#[must_use]
61pub fn parse_surface(query: &str) -> Option<AppSection> {
62    let stripped = query.strip_prefix('?').unwrap_or(query);
63    for pair in stripped.split('&') {
64        if let Some((key, value)) = pair.split_once('=')
65            && key == "surface"
66        {
67            return parse_slug(value);
68        }
69    }
70    None
71}
72
73/// Map a slug to its [`AppSection`]. Accepts both `recorder` and
74/// `record` for the Record variant (the storybook uses `record` in
75/// the kebab-case slug; the URL convention is the longer `recorder`).
76#[must_use]
77pub fn parse_slug(slug: &str) -> Option<AppSection> {
78    match slug {
79        "recorder" | "record" => Some(AppSection::Record),
80        "library" => Some(AppSection::Library),
81        "editor" => Some(AppSection::Editor),
82        "cursor" => Some(AppSection::Cursor),
83        "prefs" => Some(AppSection::Prefs),
84        _ => None,
85    }
86}
87
88/// Convert an [`AppSection`] to its canonical URL slug. Round-trips
89/// with [`parse_slug`] / [`parse_surface`].
90#[must_use]
91pub fn surface_slug(section: AppSection) -> &'static str {
92    match section {
93        AppSection::Record => "recorder",
94        AppSection::Library => "library",
95        AppSection::Editor => "editor",
96        AppSection::Cursor => "cursor",
97        AppSection::Prefs => "prefs",
98    }
99}
100
101#[cfg(test)]
102mod tests {
103    use super::*;
104
105    #[test]
106    fn round_trip_every_section() {
107        for section in [
108            AppSection::Record,
109            AppSection::Library,
110            AppSection::Editor,
111            AppSection::Cursor,
112            AppSection::Prefs,
113        ] {
114            let slug = surface_slug(section);
115            let parsed = parse_slug(slug);
116            assert_eq!(parsed, Some(section), "slug={slug}");
117        }
118    }
119
120    #[test]
121    fn parse_surface_finds_param_with_leading_question_mark() {
122        assert_eq!(parse_surface("?surface=recorder"), Some(AppSection::Record));
123    }
124
125    #[test]
126    fn parse_surface_finds_param_without_leading_question_mark() {
127        assert_eq!(parse_surface("surface=library"), Some(AppSection::Library));
128    }
129
130    #[test]
131    fn parse_surface_finds_param_among_multiple() {
132        assert_eq!(
133            parse_surface("?foo=bar&surface=editor&baz=quux"),
134            Some(AppSection::Editor)
135        );
136    }
137
138    #[test]
139    fn parse_surface_returns_none_for_unknown_slug() {
140        assert_eq!(parse_surface("?surface=mystery"), None);
141    }
142
143    #[test]
144    fn parse_surface_returns_none_for_missing_param() {
145        assert_eq!(parse_surface("?foo=bar"), None);
146        assert_eq!(parse_surface(""), None);
147    }
148
149    #[test]
150    fn parse_surface_accepts_record_alias() {
151        // The storybook slug for `Record` is `record`; the URL
152        // convention is `recorder`. We accept both.
153        assert_eq!(parse_surface("?surface=record"), Some(AppSection::Record));
154        assert_eq!(parse_surface("?surface=recorder"), Some(AppSection::Record));
155    }
156
157    #[test]
158    fn mount_point_dispatches_appshell_for_surface_query() {
159        assert_eq!(
160            parse_mount_point("?surface=recorder"),
161            MountPoint::AppShell(AppSection::Record)
162        );
163    }
164
165    #[test]
166    fn mount_point_dispatches_bubble_for_mount_query() {
167        assert_eq!(parse_mount_point("?mount=bubble"), MountPoint::Bubble);
168        assert_eq!(parse_mount_point("mount=bubble"), MountPoint::Bubble);
169    }
170
171    #[test]
172    fn mount_point_falls_back_to_drop_zone_for_unknown_query() {
173        assert_eq!(parse_mount_point(""), MountPoint::DropZone);
174        assert_eq!(parse_mount_point("?foo=bar"), MountPoint::DropZone);
175        assert_eq!(parse_mount_point("?mount=unknown"), MountPoint::DropZone);
176    }
177
178    #[test]
179    fn mount_point_appshell_wins_when_both_queries_present() {
180        // Defensive: shouldn't happen in practice, but if both are
181        // set we route to the AppShell surface (the higher-information
182        // signal).
183        assert_eq!(
184            parse_mount_point("?surface=library&mount=bubble"),
185            MountPoint::AppShell(AppSection::Library)
186        );
187    }
188}