Skip to main content

tablo_core/panel/shell/
brand.rs

1//! The panel's brand: its name and logos, in the sidebar header and above the sign-in card.
2
3use topcoat::{
4    Result,
5    context::Cx,
6    view::{BoxView, ViewExt, view},
7};
8
9use super::{super::Panel, current};
10
11/// Branding for the panel's shell: the sidebar header, the login card, and the
12/// topbar below `md`, where the sidebar folds into a sheet.
13#[derive(Debug, Clone)]
14pub struct Brand {
15    /// Display name (e.g. `"Acme"`).
16    pub name: String,
17    /// Optional logo URL (e.g. `"/logo.svg"`). Rendered as an `<img>` when present.
18    pub logo: Option<String>,
19    /// Optional logo URL shown in place of [`logo`](Self::logo) while the page renders dark.
20    pub dark_logo: Option<String>,
21    /// Optional favicon URL (e.g. `"/favicon.svg"`), the `<link rel="icon">` of every page the
22    /// panel renders, its sign-in pages included.
23    pub favicon: Option<String>,
24    /// The primary color, light then dark, and the text on it.
25    palette: Palette,
26    radius: Option<Radius>,
27}
28
29/// A [`Brand`]'s colors over the shipped theme's primary tokens.
30#[derive(Debug, Clone, Default)]
31struct Palette {
32    color: Option<String>,
33    dark_color: Option<String>,
34    on_color: Option<String>,
35}
36
37/// How round a [`Brand`]'s controls, cards and badges are, over the shipped `0.5rem`.
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39#[non_exhaustive]
40pub enum Radius {
41    /// Square corners.
42    None,
43    /// `0.25rem`.
44    Small,
45    /// `0.5rem`, the shipped theme's.
46    Medium,
47    /// `0.75rem`.
48    Large,
49    /// `1rem`.
50    ExtraLarge,
51}
52
53impl Radius {
54    const fn css(self) -> &'static str {
55        match self {
56            Self::None => "0rem",
57            Self::Small => "0.25rem",
58            Self::Medium => "0.5rem",
59            Self::Large => "0.75rem",
60            Self::ExtraLarge => "1rem",
61        }
62    }
63}
64
65impl Brand {
66    /// Create a brand with the given name (surrounding whitespace is
67    /// trimmed so `" Acme "` cannot break the `flex h-16` header).
68    pub fn new(name: impl Into<String>) -> Self {
69        Self {
70            name: name.into().trim().to_string(),
71            logo: None,
72            dark_logo: None,
73            favicon: None,
74            palette: Palette::default(),
75            radius: None,
76        }
77    }
78
79    /// Paint the panel's primary color, its buttons, links, focus rings and brand mark, in
80    /// `color`: a hex color or a color function such as `oklch(0.55 0.2 260)`. It paints the dark
81    /// theme too, unless [`dark_color`](Self::dark_color) says otherwise. Mounting the panel
82    /// refuses any other spelling, which the page's stylesheet would carry as it is.
83    pub fn color(mut self, color: impl Into<String>) -> Self {
84        self.palette.color = Some(color.into().trim().to_string());
85        self
86    }
87
88    /// Paint the primary color in `color` while the page renders dark, such as a lighter shade
89    /// of [`color`](Self::color) for a dark background.
90    pub fn dark_color(mut self, color: impl Into<String>) -> Self {
91        self.palette.dark_color = Some(color.into().trim().to_string());
92        self
93    }
94
95    /// Paint the text on the primary color in `color`, over the shipped near-white: a dark
96    /// color for a light [`color`](Self::color), such as yellow.
97    pub fn on_color(mut self, color: impl Into<String>) -> Self {
98        self.palette.on_color = Some(color.into().trim().to_string());
99        self
100    }
101
102    /// Round the panel's corners to `radius`.
103    pub fn radius(mut self, radius: Radius) -> Self {
104        self.radius = Some(radius);
105        self
106    }
107
108    /// The colors mounting refuses: each that is no CSS color.
109    pub(crate) fn declaration_errors(&self) -> Vec<crate::DeclarationErrorKind> {
110        let Palette {
111            color,
112            dark_color,
113            on_color,
114        } = &self.palette;
115        [color, dark_color, on_color]
116            .into_iter()
117            .flatten()
118            .filter(|value| !crate::column::is_css_color(value))
119            .map(|value| crate::DeclarationErrorKind::BrandColor {
120                value: value.clone(),
121            })
122            .collect()
123    }
124
125    /// The style sheet that lays the brand's colors and radius over the shipped theme; `None`
126    /// when it sets neither, or a color mounting would refuse.
127    pub(crate) fn style(&self) -> Option<String> {
128        if !self.declaration_errors().is_empty() {
129            return None;
130        }
131        let Palette {
132            color,
133            dark_color,
134            on_color,
135        } = &self.palette;
136        let tokens = |primary: Option<&String>| {
137            let mut tokens = String::new();
138            if let Some(primary) = primary {
139                tokens.push_str(&format!("--primary:{primary};--ring:{primary};"));
140            }
141            if let Some(on) = on_color {
142                tokens.push_str(&format!("--primary-foreground:{on};"));
143            }
144            tokens
145        };
146        let mut light = tokens(color.as_ref());
147        if let Some(radius) = self.radius {
148            light.push_str(&format!("--radius:{};", radius.css()));
149        }
150        let dark = tokens(dark_color.as_ref().or(color.as_ref()));
151        let mut style = String::new();
152        if !light.is_empty() {
153            style.push_str(&format!(":root{{{light}}}"));
154        }
155        if !dark.is_empty() {
156            style.push_str(&format!(":root.dark{{{dark}}}"));
157        }
158        (!style.is_empty()).then_some(style)
159    }
160
161    /// Attach a logo URL (blank values are ignored so an empty
162    /// `logo("")` falls back to the name-only render instead of a
163    /// broken-image icon).
164    pub fn logo(mut self, logo: impl Into<String>) -> Self {
165        let logo = logo.into().trim().to_string();
166        if !logo.is_empty() {
167            self.logo = Some(logo);
168        }
169        self
170    }
171
172    /// Attach the logo URL shown in place of [`logo`](Self::logo) while the page renders dark,
173    /// such as a light wordmark for a dark background; a blank value is ignored. Without a
174    /// [`logo`](Self::logo) it is ignored too, and the brand shows its initial in either theme.
175    pub fn dark_logo(mut self, logo: impl Into<String>) -> Self {
176        let logo = logo.into().trim().to_string();
177        if !logo.is_empty() {
178            self.dark_logo = Some(logo);
179        }
180        self
181    }
182
183    /// Attach a favicon URL; a blank value is ignored, leaving the browser's default.
184    pub fn favicon(mut self, favicon: impl Into<String>) -> Self {
185        let favicon = favicon.into().trim().to_string();
186        if !favicon.is_empty() {
187            self.favicon = Some(favicon);
188        }
189        self
190    }
191}
192
193/// A brand's logo URLs: the one it shows, and the one it shows instead while the page renders
194/// dark.
195struct Logos {
196    light: String,
197    dark: Option<String>,
198}
199
200/// Where a brand mark renders, which sets its size.
201#[derive(Clone, Copy)]
202enum BrandSize {
203    /// The sidebar header and the narrow topbar: 28px.
204    Sidebar,
205    /// Above the card of a page outside the shell, such as sign-in: 40px.
206    Page,
207}
208
209impl Panel {
210    /// The panel's brand: its name and its logos, light then dark, or none for its initial on
211    /// the primary color.
212    fn current_brand(cx: &Cx) -> (String, Option<Logos>) {
213        match current(cx).and_then(|panel| panel.brand.as_ref()) {
214            Some(brand) => (
215                brand.name.clone(),
216                brand.logo.clone().map(|light| Logos {
217                    light,
218                    dark: brand.dark_logo.clone(),
219                }),
220            ),
221            None => ("Tablo".to_string(), None),
222        }
223    }
224
225    /// The request's panel brand name, `Tablo` without one.
226    pub(crate) fn brand_name(cx: &Cx) -> String {
227        Self::current_brand(cx).0
228    }
229
230    /// The brand's mark at `size`: its logo, or without one its initial on the primary token, so
231    /// a header keeps its mark.
232    fn brand_mark<'a>(cx: &'a Cx, name: &str, logo: Option<Logos>, size: BrandSize) -> BoxView<'a> {
233        // A page logo keeps its own width at the page mark's height.
234        let (width, height, logo_class, initial_class) = match size {
235            BrandSize::Sidebar => (
236                Some("28"),
237                "28",
238                "size-7 shrink-0 rounded-md",
239                "flex size-7 shrink-0 items-center justify-center rounded-md bg-primary text-sm \
240                 font-semibold text-primary-foreground",
241            ),
242            BrandSize::Page => (
243                None,
244                "40",
245                "h-10 w-auto shrink-0",
246                "flex size-10 shrink-0 items-center justify-center rounded-lg bg-primary text-lg \
247                 font-semibold text-primary-foreground",
248            ),
249        };
250        match logo {
251            Some(Logos { light, dark }) => {
252                // A page always shows the name beside its logo, so the logo is decoration there;
253                // the icon rail hides the sidebar's name, so the sidebar logo names the brand.
254                let alt = match size {
255                    BrandSize::Sidebar => name.to_string(),
256                    BrandSize::Page => String::new(),
257                };
258                // Both images render and the `dark` class picks one, so the theme toggle swaps
259                // the logo without a request.
260                let light_class = match dark {
261                    Some(_) => format!("{logo_class} dark:hidden"),
262                    None => logo_class.to_string(),
263                };
264                let dark_class = format!("{logo_class} hidden dark:block");
265                view! {
266                    <img
267                        src=(light)
268                        alt=(alt.clone())
269                        width=(width)
270                        height=(height)
271                        class=(light_class)
272                    >
273                    if let Some(dark_url) = dark {
274                        <img
275                            src=(dark_url)
276                            alt=(alt)
277                            width=(width)
278                            height=(height)
279                            class=(dark_class)
280                        >
281                    }
282                }
283                .boxed()
284            }
285            None => {
286                let initial = name.chars().next().map(String::from).unwrap_or_default();
287                view! { <span aria-hidden="true" class=(initial_class)>(initial)</span> }.boxed()
288            }
289        }
290    }
291
292    /// The brand as the sidebar header shows it: the mark beside the name.
293    pub(crate) async fn render_brand(cx: &Cx) -> Result<BoxView<'_>> {
294        let (name, logo) = Self::current_brand(cx);
295        let mark = Self::brand_mark(cx, &name, logo, BrandSize::Sidebar);
296        Ok(view! {
297            <div class="flex min-w-0 items-center gap-2 font-semibold text-foreground">
298                (mark)
299                <span class="truncate md:group-data-[collapsible=icon]/sidebar:hidden">
300                    (name)
301                </span>
302            </div>
303        }
304        .boxed())
305    }
306
307    /// The brand as a page outside the shell shows it, above its card: the mark over the name.
308    pub(crate) fn render_page_brand(cx: &Cx) -> BoxView<'_> {
309        let (name, logo) = Self::current_brand(cx);
310        let mark = Self::brand_mark(cx, &name, logo, BrandSize::Page);
311        view! {
312            <div class="flex flex-col items-center gap-3 text-center" data-brand="">
313                (mark)
314                <span class="text-lg font-semibold tracking-tight text-foreground">
315                    (name)
316                </span>
317            </div>
318        }
319        .boxed()
320    }
321}