atproto blogging
1//! Full theme editor component with all 16 colours, fonts, spacing.
2
3use dioxus::prelude::*;
4use weaver_renderer::themes::{BUILTIN_CODE_THEMES, BUILTIN_COLOUR_SCHEMES};
5
6use crate::components::HexColourInput;
7use crate::components::button::{Button, ButtonVariant};
8use crate::components::inline_theme_editor::InlineThemeValues;
9use crate::components::select::{Select, SelectList, SelectOption, SelectTrigger, SelectValue};
10use crate::components::theme_preview::ThemePreview;
11use crate::components::toggle_group::{ToggleGroup, ToggleItem};
12
13const THEME_EDITOR_CSS: Asset = asset!("/assets/styling/theme-editor.css");
14
15/// Full theme values for the theme editor.
16#[derive(Debug, Clone, PartialEq, Default)]
17pub struct ThemeEditorValues {
18 // Light scheme colours (all 16).
19 pub light: ColourSchemeValues,
20 // Dark scheme colours (all 16).
21 pub dark: ColourSchemeValues,
22 // Fonts.
23 pub font_body: String,
24 pub font_heading: String,
25 pub font_mono: String,
26 // Spacing.
27 pub spacing_base: String,
28 pub spacing_line_height: String,
29 pub spacing_scale: String,
30 // Code themes.
31 pub light_code_theme: String,
32 pub dark_code_theme: String,
33 // Default mode.
34 pub default_mode: String,
35}
36
37/// All 16 colour values for a single scheme.
38#[derive(Debug, Clone, PartialEq, Default)]
39pub struct ColourSchemeValues {
40 pub base: String,
41 pub surface: String,
42 pub overlay: String,
43 pub text: String,
44 pub muted: String,
45 pub subtle: String,
46 pub emphasis: String,
47 pub primary: String,
48 pub secondary: String,
49 pub tertiary: String,
50 pub error: String,
51 pub warning: String,
52 pub success: String,
53 pub border: String,
54 pub link: String,
55 pub highlight: String,
56}
57
58impl ThemeEditorValues {
59 /// Convert to InlineThemeValues for preview (uses primary 4 colours from light scheme).
60 pub fn to_inline_values(&self) -> InlineThemeValues {
61 InlineThemeValues {
62 background: self.light.base.clone(),
63 text: self.light.text.clone(),
64 primary: self.light.primary.clone(),
65 link: self.light.link.clone(),
66 light_background: Some(self.light.base.clone()),
67 light_text: Some(self.light.text.clone()),
68 light_primary: Some(self.light.primary.clone()),
69 light_link: Some(self.light.link.clone()),
70 dark_background: Some(self.dark.base.clone()),
71 dark_text: Some(self.dark.text.clone()),
72 dark_primary: Some(self.dark.primary.clone()),
73 dark_link: Some(self.dark.link.clone()),
74 light_code_theme: self.light_code_theme.clone(),
75 dark_code_theme: self.dark_code_theme.clone(),
76 default_mode: self.default_mode.clone(),
77 }
78 }
79
80 /// Create from a preset colour scheme.
81 pub fn from_preset(scheme_id: &str) -> Option<Self> {
82 let scheme = BUILTIN_COLOUR_SCHEMES.iter().find(|s| s.id == scheme_id)?;
83 let colours = &scheme.colours;
84
85 let scheme_values = ColourSchemeValues {
86 base: strip_hex_prefix(&colours.base),
87 surface: strip_hex_prefix(&colours.surface),
88 overlay: strip_hex_prefix(&colours.overlay),
89 text: strip_hex_prefix(&colours.text),
90 muted: strip_hex_prefix(&colours.muted),
91 subtle: strip_hex_prefix(&colours.subtle),
92 emphasis: strip_hex_prefix(&colours.emphasis),
93 primary: strip_hex_prefix(&colours.primary),
94 secondary: strip_hex_prefix(&colours.secondary),
95 tertiary: strip_hex_prefix(&colours.tertiary),
96 error: strip_hex_prefix(&colours.error),
97 warning: strip_hex_prefix(&colours.warning),
98 success: strip_hex_prefix(&colours.success),
99 border: strip_hex_prefix(&colours.border),
100 link: strip_hex_prefix(&colours.link),
101 highlight: strip_hex_prefix(&colours.highlight),
102 };
103
104 // Find counterpart scheme for dark/light.
105 let is_dark = scheme.variant == "dark";
106 let counterpart_id = if is_dark {
107 "rose-pine-dawn"
108 } else {
109 "rose-pine"
110 };
111 let counterpart = BUILTIN_COLOUR_SCHEMES
112 .iter()
113 .find(|s| s.id == counterpart_id);
114
115 let counterpart_values = counterpart
116 .map(|c| {
117 let colours = &c.colours;
118 ColourSchemeValues {
119 base: strip_hex_prefix(&colours.base),
120 surface: strip_hex_prefix(&colours.surface),
121 overlay: strip_hex_prefix(&colours.overlay),
122 text: strip_hex_prefix(&colours.text),
123 muted: strip_hex_prefix(&colours.muted),
124 subtle: strip_hex_prefix(&colours.subtle),
125 emphasis: strip_hex_prefix(&colours.emphasis),
126 primary: strip_hex_prefix(&colours.primary),
127 secondary: strip_hex_prefix(&colours.secondary),
128 tertiary: strip_hex_prefix(&colours.tertiary),
129 error: strip_hex_prefix(&colours.error),
130 warning: strip_hex_prefix(&colours.warning),
131 success: strip_hex_prefix(&colours.success),
132 border: strip_hex_prefix(&colours.border),
133 link: strip_hex_prefix(&colours.link),
134 highlight: strip_hex_prefix(&colours.highlight),
135 }
136 })
137 .unwrap_or_default();
138
139 let (light, dark) = if is_dark {
140 (counterpart_values, scheme_values)
141 } else {
142 (scheme_values, counterpart_values)
143 };
144
145 Some(Self {
146 light,
147 dark,
148 font_body: String::new(),
149 font_heading: String::new(),
150 font_mono: String::new(),
151 spacing_base: "16".to_string(),
152 spacing_line_height: "1.6".to_string(),
153 spacing_scale: "1.25".to_string(),
154 light_code_theme: "rose-pine-dawn".to_string(),
155 dark_code_theme: "rose-pine".to_string(),
156 default_mode: scheme.variant.to_string(),
157 })
158 }
159}
160
161fn strip_hex_prefix(s: &str) -> String {
162 s.trim_start_matches('#')
163 .trim_start_matches("0x")
164 .trim_start_matches("0X")
165 .to_uppercase()
166}
167
168fn mode_to_index(mode: &str) -> std::collections::HashSet<usize> {
169 let idx = match mode {
170 "light" => 1,
171 "dark" => 2,
172 _ => 0,
173 };
174 std::collections::HashSet::from([idx])
175}
176
177fn index_to_mode(idx: usize) -> &'static str {
178 match idx {
179 1 => "light",
180 2 => "dark",
181 _ => "auto",
182 }
183}
184
185/// Props for ThemeEditor.
186#[derive(Props, Clone, PartialEq)]
187pub struct ThemeEditorProps {
188 /// Current theme values (signal for reactivity).
189 pub values: Signal<ThemeEditorValues>,
190 /// Callback on save.
191 pub on_save: EventHandler<ThemeEditorValues>,
192 /// Callback on cancel.
193 pub on_cancel: EventHandler<()>,
194 /// Whether save is in progress.
195 #[props(default = false)]
196 pub saving: bool,
197}
198
199/// Full theme editor with all 16 colours, fonts, spacing.
200#[component]
201pub fn ThemeEditor(props: ThemeEditorProps) -> Element {
202 let mut values = props.values;
203
204 // Preview values derived from editor values.
205 let mut preview_values = use_signal(|| values().to_inline_values());
206
207 // Sync preview when values change.
208 use_effect(move || {
209 let v = values();
210 preview_values.set(v.to_inline_values());
211 });
212
213 // Which variant is being edited (for preview).
214 let mut editing_dark = use_signal(|| false);
215
216 // Derived signals for light/dark schemes.
217 let light_scheme = use_memo(move || values().light.clone());
218 let dark_scheme = use_memo(move || values().dark.clone());
219
220 rsx! {
221 document::Stylesheet { href: THEME_EDITOR_CSS }
222
223 div { class: "theme-editor-page",
224 // Left column: controls.
225 div { class: "theme-editor-controls",
226 // Mode toggle.
227 ModeSection { values }
228
229 // Fonts section.
230 FontsSection { values }
231
232 // Spacing section.
233 SpacingSection { values }
234
235 // Code themes.
236 CodeThemesSection { values }
237
238 // Light scheme colours.
239 ColourSchemeSection {
240 title: "Light scheme",
241 variant: "light",
242 scheme: light_scheme,
243 on_change: move |new_scheme: ColourSchemeValues| {
244 values.write().light = new_scheme;
245 },
246 on_focus: move |_| editing_dark.set(false),
247 }
248
249 // Dark scheme colours.
250 ColourSchemeSection {
251 title: "Dark scheme",
252 variant: "dark",
253 scheme: dark_scheme,
254 on_change: move |new_scheme: ColourSchemeValues| {
255 values.write().dark = new_scheme;
256 },
257 on_focus: move |_| editing_dark.set(true),
258 }
259
260 // Actions.
261 div { class: "theme-editor-actions",
262 Button {
263 variant: ButtonVariant::Primary,
264 onclick: move |_| {
265 props.on_save.call(values());
266 },
267 disabled: props.saving,
268 if props.saving { "Saving..." } else { "Save" }
269 }
270 Button {
271 variant: ButtonVariant::Ghost,
272 onclick: move |_| {
273 props.on_cancel.call(());
274 },
275 disabled: props.saving,
276 "Cancel"
277 }
278 }
279 }
280
281 // Right column: preview.
282 div { class: "theme-editor-preview",
283 div { class: "theme-editor-preview-header",
284 h3 { "Preview" }
285 ToggleGroup {
286 horizontal: true,
287 default_pressed: std::collections::HashSet::from([0usize]),
288 on_pressed_change: move |pressed: std::collections::HashSet<usize>| {
289 if let Some(&idx) = pressed.iter().next() {
290 editing_dark.set(idx == 1);
291 }
292 },
293 ToggleItem { index: 0usize, "Light" }
294 ToggleItem { index: 1usize, "Dark" }
295 }
296 }
297 ThemePreview {
298 values: preview_values,
299 dark: editing_dark(),
300 }
301 }
302 }
303 }
304}
305
306/// Colour scheme section with all 16 colours and its own preset selector.
307#[component]
308fn ColourSchemeSection(
309 title: &'static str,
310 variant: &'static str,
311 scheme: ReadSignal<ColourSchemeValues>,
312 on_change: EventHandler<ColourSchemeValues>,
313 on_focus: EventHandler<FocusEvent>,
314) -> Element {
315 // Helper to create colour input with change handler.
316 let colour_input =
317 move |label: &'static str, value: String, setter: fn(&mut ColourSchemeValues, String)| {
318 let on_change = on_change.clone();
319 let on_focus = on_focus.clone();
320 let scheme = scheme.clone();
321 rsx! {
322 HexColourInput {
323 label: Some(label.to_string()),
324 value: value,
325 onchange: move |val: String| {
326 let mut new_scheme = scheme();
327 setter(&mut new_scheme, val);
328 on_change.call(new_scheme);
329 },
330 onfocus: move |e| on_focus.call(e),
331 }
332 }
333 };
334
335 let s = scheme();
336
337 // Filter presets by variant.
338 let presets: Vec<_> = BUILTIN_COLOUR_SCHEMES
339 .iter()
340 .filter(|p| p.variant == variant)
341 .collect();
342
343 rsx! {
344 div { class: "theme-editor-section",
345 div { class: "theme-editor-section-header",
346 h3 { "{title}" }
347 Select::<String> {
348 placeholder: "Custom",
349 on_value_change: move |val: Option<String>| {
350 let Some(val) = val else { return };
351 let Some(preset) = BUILTIN_COLOUR_SCHEMES.iter().find(|p| p.id == val) else { return };
352 let colours = &preset.colours;
353 on_change.call(ColourSchemeValues {
354 base: strip_hex_prefix(&colours.base),
355 surface: strip_hex_prefix(&colours.surface),
356 overlay: strip_hex_prefix(&colours.overlay),
357 text: strip_hex_prefix(&colours.text),
358 muted: strip_hex_prefix(&colours.muted),
359 subtle: strip_hex_prefix(&colours.subtle),
360 emphasis: strip_hex_prefix(&colours.emphasis),
361 primary: strip_hex_prefix(&colours.primary),
362 secondary: strip_hex_prefix(&colours.secondary),
363 tertiary: strip_hex_prefix(&colours.tertiary),
364 error: strip_hex_prefix(&colours.error),
365 warning: strip_hex_prefix(&colours.warning),
366 success: strip_hex_prefix(&colours.success),
367 border: strip_hex_prefix(&colours.border),
368 link: strip_hex_prefix(&colours.link),
369 highlight: strip_hex_prefix(&colours.highlight),
370 });
371 },
372 SelectTrigger {
373 SelectValue {}
374 }
375 SelectList {
376 for (idx, preset) in presets.iter().enumerate() {
377 SelectOption::<String> {
378 index: idx,
379 value: preset.id.to_string(),
380 text_value: preset.name.to_string(),
381 "{preset.name}"
382 }
383 }
384 }
385 }
386 }
387
388 // Background colours.
389 div { class: "theme-editor-colour-group",
390 span { class: "theme-editor-colour-group-label", "Background" }
391 div { class: "theme-editor-colour-group-items",
392 {colour_input("Base", s.base.clone(), |s, v| s.base = v)}
393 {colour_input("Surface", s.surface.clone(), |s, v| s.surface = v)}
394 {colour_input("Overlay", s.overlay.clone(), |s, v| s.overlay = v)}
395 }
396 }
397
398 // Text colours.
399 div { class: "theme-editor-colour-group",
400 span { class: "theme-editor-colour-group-label", "Text" }
401 div { class: "theme-editor-colour-group-items",
402 {colour_input("Text", s.text.clone(), |s, v| s.text = v)}
403 {colour_input("Muted", s.muted.clone(), |s, v| s.muted = v)}
404 {colour_input("Subtle", s.subtle.clone(), |s, v| s.subtle = v)}
405 {colour_input("Emphasis", s.emphasis.clone(), |s, v| s.emphasis = v)}
406 }
407 }
408
409 // Accent colours.
410 div { class: "theme-editor-colour-group",
411 span { class: "theme-editor-colour-group-label", "Accents" }
412 div { class: "theme-editor-colour-group-items",
413 {colour_input("Primary", s.primary.clone(), |s, v| s.primary = v)}
414 {colour_input("Secondary", s.secondary.clone(), |s, v| s.secondary = v)}
415 {colour_input("Tertiary", s.tertiary.clone(), |s, v| s.tertiary = v)}
416 {colour_input("Link", s.link.clone(), |s, v| s.link = v)}
417 }
418 }
419
420 // Status colours.
421 div { class: "theme-editor-colour-group",
422 span { class: "theme-editor-colour-group-label", "Status" }
423 div { class: "theme-editor-colour-group-items",
424 {colour_input("Error", s.error.clone(), |s, v| s.error = v)}
425 {colour_input("Warning", s.warning.clone(), |s, v| s.warning = v)}
426 {colour_input("Success", s.success.clone(), |s, v| s.success = v)}
427 }
428 }
429
430 // UI colours.
431 div { class: "theme-editor-colour-group",
432 span { class: "theme-editor-colour-group-label", "UI" }
433 div { class: "theme-editor-colour-group-items",
434 {colour_input("Border", s.border.clone(), |s, v| s.border = v)}
435 {colour_input("Highlight", s.highlight.clone(), |s, v| s.highlight = v)}
436 }
437 }
438 }
439 }
440}
441
442/// Fonts section.
443#[component]
444fn FontsSection(values: Signal<ThemeEditorValues>) -> Element {
445 rsx! {
446 div { class: "theme-editor-section",
447 h3 { "Fonts" }
448 div { class: "theme-editor-fonts",
449 div { class: "theme-editor-font-field",
450 label { "Body" }
451 input {
452 r#type: "text",
453 value: "{values().font_body}",
454 placeholder: "IBM Plex Serif",
455 oninput: move |e| values.write().font_body = e.value(),
456 }
457 }
458 div { class: "theme-editor-font-field",
459 label { "Heading" }
460 input {
461 r#type: "text",
462 value: "{values().font_heading}",
463 placeholder: "IBM Plex Sans",
464 oninput: move |e| values.write().font_heading = e.value(),
465 }
466 }
467 div { class: "theme-editor-font-field",
468 label { "Monospace" }
469 input {
470 r#type: "text",
471 value: "{values().font_mono}",
472 placeholder: "IBM Plex Mono",
473 oninput: move |e| values.write().font_mono = e.value(),
474 }
475 }
476 }
477 }
478 }
479}
480
481/// Spacing section.
482#[component]
483fn SpacingSection(values: Signal<ThemeEditorValues>) -> Element {
484 rsx! {
485 div { class: "theme-editor-section",
486 h3 { "Spacing" }
487 div { class: "theme-editor-spacing",
488 div { class: "theme-editor-spacing-field",
489 label { "Base size (px)" }
490 input {
491 r#type: "number",
492 value: "{values().spacing_base}",
493 oninput: move |e| values.write().spacing_base = e.value(),
494 }
495 }
496 div { class: "theme-editor-spacing-field",
497 label { "Line height" }
498 input {
499 r#type: "text",
500 value: "{values().spacing_line_height}",
501 oninput: move |e| values.write().spacing_line_height = e.value(),
502 }
503 }
504 div { class: "theme-editor-spacing-field",
505 label { "Scale" }
506 input {
507 r#type: "text",
508 value: "{values().spacing_scale}",
509 oninput: move |e| values.write().spacing_scale = e.value(),
510 }
511 }
512 }
513 }
514 }
515}
516
517/// Code themes section.
518#[component]
519fn CodeThemesSection(values: Signal<ThemeEditorValues>) -> Element {
520 let light_themes: Vec<_> = BUILTIN_CODE_THEMES
521 .iter()
522 .filter(|t| t.variant == "light")
523 .collect();
524 let dark_themes: Vec<_> = BUILTIN_CODE_THEMES
525 .iter()
526 .filter(|t| t.variant == "dark")
527 .collect();
528
529 rsx! {
530 div { class: "theme-editor-section",
531 h3 { "Code themes" }
532 div { class: "theme-editor-code-themes",
533 div { class: "theme-editor-code-theme",
534 label { "Light" }
535 Select::<String> {
536 value: Some(values().light_code_theme.clone()),
537 on_value_change: move |val: Option<String>| {
538 if let Some(val) = val {
539 values.write().light_code_theme = val;
540 }
541 },
542 SelectTrigger {
543 SelectValue {}
544 }
545 SelectList {
546 for (idx, theme) in light_themes.iter().enumerate() {
547 SelectOption::<String> {
548 index: idx,
549 value: theme.id.to_string(),
550 text_value: theme.name.to_string(),
551 "{theme.name}"
552 }
553 }
554 }
555 }
556 }
557 div { class: "theme-editor-code-theme",
558 label { "Dark" }
559 Select::<String> {
560 value: Some(values().dark_code_theme.clone()),
561 on_value_change: move |val: Option<String>| {
562 if let Some(val) = val {
563 values.write().dark_code_theme = val;
564 }
565 },
566 SelectTrigger {
567 SelectValue {}
568 }
569 SelectList {
570 for (idx, theme) in dark_themes.iter().enumerate() {
571 SelectOption::<String> {
572 index: idx,
573 value: theme.id.to_string(),
574 text_value: theme.name.to_string(),
575 "{theme.name}"
576 }
577 }
578 }
579 }
580 }
581 }
582 }
583 }
584}
585
586/// Mode section.
587#[component]
588fn ModeSection(values: Signal<ThemeEditorValues>) -> Element {
589 rsx! {
590 div { class: "theme-editor-section",
591 div { class: "theme-editor-mode",
592 label { "Default mode:" }
593 ToggleGroup {
594 horizontal: true,
595 default_pressed: mode_to_index(&values().default_mode),
596 on_pressed_change: move |pressed: std::collections::HashSet<usize>| {
597 if let Some(&idx) = pressed.iter().next() {
598 values.write().default_mode = index_to_mode(idx).to_string();
599 }
600 },
601 ToggleItem { index: 0usize, "Auto" }
602 ToggleItem { index: 1usize, "Light" }
603 ToggleItem { index: 2usize, "Dark" }
604 }
605 }
606 }
607 }
608}