From 3c2b5d5112845e7ac4110abdb83351c241ac62df Mon Sep 17 00:00:00 2001 From: Val Alexander <68980965+BunsDev@users.noreply.github.com> Date: Thu, 21 May 2026 12:44:31 -0500 Subject: [PATCH] fix(theme): support tweakcn registry json imports --- app/src/settings_view/import_theme_modal.rs | 41 ++-- app/src/themes/tweakcn_import.rs | 221 +++++++++++++++----- 2 files changed, 188 insertions(+), 74 deletions(-) diff --git a/app/src/settings_view/import_theme_modal.rs b/app/src/settings_view/import_theme_modal.rs index 42a4f6f2..60507bdf 100644 --- a/app/src/settings_view/import_theme_modal.rs +++ b/app/src/settings_view/import_theme_modal.rs @@ -1,7 +1,7 @@ -//! Import-theme modal for tweakcn CSS exports. +//! Import-theme modal for tweakcn CSS and registry JSON exports. //! //! Opens when the user presses "Import theme…" in Appearance settings. -//! The user pastes tweakcn CSS, optionally edits the theme name, and clicks +//! The user pastes tweakcn CSS or registry JSON, optionally edits the theme name, and clicks //! Save. The modal calls `write_imported` to write YAML(s) to disk, then //! dispatches a theme-reload+select event so the new theme is immediately //! active. @@ -9,7 +9,7 @@ //! ## Drag-and-drop //! The modal body is wrapped in a `FileDropZone` element (inner module) that //! intercepts `Event::DragAndDropFiles` from the OS and dispatches a -//! `ImportThemeBodyAction::FileDropped` action. Only `.css` files are +//! `ImportThemeBodyAction::FileDropped` action. Only `.css` and `.json` files are //! accepted; anything else is rejected with an inline error. use std::any::Any; @@ -19,7 +19,9 @@ use crate::appearance::Appearance; use crate::editor::{EditorOptions, EditorView, Event as EditorEvent, SingleLineEditorOptions}; use crate::modal::Modal; use crate::themes::theme::{CustomTheme, ThemeKind}; -use crate::themes::tweakcn_import::{parse_blocks, write_imported, GamutPolicy, ParsedBlocks}; +use crate::themes::tweakcn_import::{ + parse_blocks_or_json, write_imported, GamutPolicy, ParsedBlocks, +}; #[cfg(feature = "local_fs")] use crate::user_config; use warpui::elements::Point; @@ -243,7 +245,7 @@ impl ImportThemeBody { return; } - match parse_blocks(&self.css_text) { + match parse_blocks_or_json(&self.css_text) { Ok(blocks) => { // Auto-fill name from CSS comment hint if the name field is still empty. if self.name.is_empty() { @@ -339,7 +341,8 @@ impl ImportThemeBody { /// Handle a file dropped onto the modal (OS DragAndDropFiles event). /// - /// Accepts the first `.css` file found in `paths`. Non-`.css` files (or + /// Accepts the first `.css` or `.json` file found in `paths`. Unsupported + /// files (or /// an empty list) show an inline error and leave the paste box untouched. /// /// Gated on `local_fs` because the fallback (web) has no filesystem access @@ -348,15 +351,17 @@ impl ImportThemeBody { pub fn on_file_dropped(&mut self, paths: Vec, ctx: &mut ViewContext) { use std::path::Path; - let css_path = paths - .iter() - .map(Path::new) - .find(|p| p.extension().and_then(|e| e.to_str()) == Some("css")); + let import_path = paths.iter().map(Path::new).find(|p| { + matches!( + p.extension().and_then(|e| e.to_str()), + Some("css") | Some("json") + ) + }); - let path = match css_path { + let path = match import_path { Some(p) => p, None => { - self.show_error = Some("Only .css files are supported.".to_string()); + self.show_error = Some("Only .css and .json files are supported.".to_string()); ctx.notify(); return; } @@ -622,9 +627,13 @@ impl View for ImportThemeBody { // CSS paste label + field layout.add_child( - Text::new_inline("Paste tweakcn CSS", appearance.ui_font_family(), 12.) - .with_color(theme.active_ui_text_color().into()) - .finish(), + Text::new_inline( + "Paste tweakcn CSS or registry JSON", + appearance.ui_font_family(), + 12., + ) + .with_color(theme.active_ui_text_color().into()) + .finish(), ); layout.add_child(css_input); @@ -654,7 +663,7 @@ impl View for ImportThemeBody { // Button row layout.add_child(button_row); - // Wrap the whole layout in a FileDropZone so OS-level .css file drops + // Wrap the whole layout in a FileDropZone so OS-level import file drops // are captured and dispatched as ImportThemeBodyAction::FileDropped. Box::new(FileDropZone::new(layout.finish())) } diff --git a/app/src/themes/tweakcn_import.rs b/app/src/themes/tweakcn_import.rs index 67ce6df7..cc3a4a4b 100644 --- a/app/src/themes/tweakcn_import.rs +++ b/app/src/themes/tweakcn_import.rs @@ -1,8 +1,11 @@ -//! Convert tweakcn CSS exports (OKLCH colors in shadcn token format) into -//! CastCodes `WarpTheme` YAMLs. No new crates — Ottosson's OKLCH → linear -//! sRGB formulas are short enough to vendor. +//! Convert tweakcn CSS exports and shadcn registry JSON themes into CastCodes +//! `WarpTheme` YAMLs. No new crates — Ottosson's OKLCH → linear sRGB formulas +//! are short enough to vendor. + +use std::collections::HashMap; use pathfinder_color::ColorU; +use serde_json::Value; /// Convert OKLCH (L: 0..1, C: 0..0.4 typical, H: 0..360 degrees) to /// 8-bit sRGB. Returns `Err((r, g, b))` if any channel was out of the @@ -63,11 +66,117 @@ pub enum ImportError { #[derive(Debug, Default, PartialEq)] pub struct ParsedBlocks { - pub light: std::collections::HashMap, // var → (L, C, H_deg) - pub dark: std::collections::HashMap, + pub light: HashMap, // var → (L, C, H_deg) + pub dark: HashMap, pub name_comment: Option, } +pub fn parse_blocks_or_json(input: &str) -> Result { + if input.trim_start().starts_with('{') { + parse_registry_json(input) + } else { + parse_blocks(input) + } +} + +pub fn parse_registry_json(input: &str) -> Result { + let value: Value = + serde_json::from_str(input).map_err(|e| ImportError::Io(format!("invalid JSON: {e}")))?; + let css_vars = value + .get("cssVars") + .and_then(Value::as_object) + .ok_or(ImportError::NoColorBlocksFound)?; + + let mut blocks = ParsedBlocks { + name_comment: value + .get("name") + .and_then(Value::as_str) + .map(ToOwned::to_owned), + ..Default::default() + }; + + for (key, target) in [("light", &mut blocks.light), ("dark", &mut blocks.dark)] { + let Some(vars) = css_vars.get(key).and_then(Value::as_object) else { + continue; + }; + + for (name, value) in vars { + let Some(value) = value.as_str() else { + continue; + }; + if let Some(triple) = parse_oklch_value(name, value)? { + target.insert(name.clone(), triple); + } + } + } + + if blocks.light.is_empty() && blocks.dark.is_empty() { + return Err(ImportError::NoColorBlocksFound); + } + + Ok(blocks) +} + +fn parse_oklch_value(var: &str, value: &str) -> Result, ImportError> { + let Some(args) = value + .trim() + .strip_prefix("oklch(") + .and_then(|s| s.strip_suffix(')')) + else { + return Ok(None); + }; + + let triple: Vec<&str> = args.split_whitespace().take(3).collect(); + if triple.len() < 3 { + return Err(ImportError::InvalidOklch { + var: var.to_string(), + raw: value.to_string(), + }); + } + + let l: f64 = triple[0].trim_end_matches('%').parse().unwrap_or(f64::NAN); + // tweakcn emits L as 0..1 (no `%`), but tolerate `%` style: + let l = if triple[0].ends_with('%') { + l / 100.0 + } else { + l + }; + let c: f64 = triple[1].parse().unwrap_or(f64::NAN); + let h: f64 = triple[2] + .trim_end_matches("deg") + .parse() + .unwrap_or(f64::NAN); + + if l.is_finite() && c.is_finite() && h.is_finite() { + Ok(Some((l, c, h))) + } else { + Err(ImportError::InvalidOklch { + var: var.to_string(), + raw: value.to_string(), + }) + } +} + +fn parse_css_decls( + body: &str, + target: &mut HashMap, +) -> Result<(), ImportError> { + for decl in body.split(';') { + let decl = decl.trim(); + if !decl.starts_with("--") { + continue; + } + let Some((name, value)) = decl.split_once(':') else { + continue; + }; + let name = name.trim().trim_start_matches("--").to_string(); + if let Some(triple) = parse_oklch_value(&name, value)? { + target.insert(name, triple); + } + } + Ok(()) +} + /// Pull `:root { ... }` and `.dark { ... }` blocks out of a tweakcn CSS /// export. Parses each `--var: oklch(L C H);` line into a (L,C,H) triple. /// `oklch()` is the only color function supported — anything else is @@ -132,62 +241,11 @@ pub fn parse_blocks(css: &str) -> Result { Some(&haystack[body_start..end]) } - let parse_decls = |body: &str, - target: &mut std::collections::HashMap| - -> Result<(), ImportError> { - for decl in body.split(';') { - let decl = decl.trim(); - if !decl.starts_with("--") { - continue; - } - let Some((name, value)) = decl.split_once(':') else { - continue; - }; - let name = name.trim().trim_start_matches("--").to_string(); - let value = value.trim(); - // Only `oklch(L C H[ / a])` is supported; anything else is silently skipped. - let Some(args) = value - .strip_prefix("oklch(") - .and_then(|s| s.strip_suffix(')')) - else { - continue; - }; - let triple: Vec<&str> = args.split_whitespace().take(3).collect(); - if triple.len() < 3 { - return Err(ImportError::InvalidOklch { - var: name, - raw: value.to_string(), - }); - } - let l: f64 = triple[0].trim_end_matches('%').parse().unwrap_or(f64::NAN); - // tweakcn emits L as 0..1 (no `%`), but tolerate `%` style: - let l = if triple[0].ends_with('%') { - l / 100.0 - } else { - l - }; - let c: f64 = triple[1].parse().unwrap_or(f64::NAN); - let h: f64 = triple[2] - .trim_end_matches("deg") - .parse() - .unwrap_or(f64::NAN); - if l.is_finite() && c.is_finite() && h.is_finite() { - target.insert(name, (l, c, h)); - } else { - return Err(ImportError::InvalidOklch { - var: name, - raw: value.to_string(), - }); - } - } - Ok(()) - }; - if let Some(body) = extract_block(&cleaned, ":root") { - parse_decls(body, &mut blocks.light)?; + parse_css_decls(body, &mut blocks.light)?; } if let Some(body) = extract_block(&cleaned, ".dark") { - parse_decls(body, &mut blocks.dark)?; + parse_css_decls(body, &mut blocks.dark)?; } if blocks.light.is_empty() && blocks.dark.is_empty() { @@ -579,6 +637,53 @@ mod parse_block_tests { assert!(blocks.light.is_empty()); assert_eq!(blocks.dark.len(), 1); } + + #[test] + fn registry_json_extracts_light_and_dark_blocks() { + let json = r##"{ + "$schema": "https://ui.shadcn.com/schema/registry-item.json", + "name": "vercel", + "type": "registry:style", + "cssVars": { + "theme": { "font-sans": "Geist, sans-serif" }, + "light": { + "background": "oklch(0.99 0 0)", + "foreground": "oklch(0 0 0)", + "shadow-color": "#000000" + }, + "dark": { + "background": "oklch(0 0 0)", + "foreground": "oklch(1 0 0)", + "border": "oklch(1 0 0 / 10%)" + } + } +}"##; + + let blocks = parse_blocks_or_json(json).unwrap(); + + assert_eq!(blocks.name_comment.as_deref(), Some("vercel")); + assert_eq!(blocks.light.len(), 2); + assert_eq!(blocks.dark.len(), 3); + assert_eq!(blocks.dark["foreground"], (1.0, 0.0, 0.0)); + } + + #[test] + fn registry_json_invalid_oklch_reports_the_variable() { + let json = r#"{ + "cssVars": { + "light": { + "background": "oklch(nope 0 0)" + } + } +}"#; + + let result = parse_blocks_or_json(json); + + assert!(matches!( + result, + Err(ImportError::InvalidOklch { var, .. }) if var == "background" + )); + } } #[cfg(test)]