Skip to content

Commit 3c2b5d5

Browse files
committed
fix(theme): support tweakcn registry json imports
1 parent 6d57db6 commit 3c2b5d5

2 files changed

Lines changed: 188 additions & 74 deletions

File tree

app/src/settings_view/import_theme_modal.rs

Lines changed: 25 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
1-
//! Import-theme modal for tweakcn CSS exports.
1+
//! Import-theme modal for tweakcn CSS and registry JSON exports.
22
//!
33
//! Opens when the user presses "Import theme…" in Appearance settings.
4-
//! The user pastes tweakcn CSS, optionally edits the theme name, and clicks
4+
//! The user pastes tweakcn CSS or registry JSON, optionally edits the theme name, and clicks
55
//! Save. The modal calls `write_imported` to write YAML(s) to disk, then
66
//! dispatches a theme-reload+select event so the new theme is immediately
77
//! active.
88
//!
99
//! ## Drag-and-drop
1010
//! The modal body is wrapped in a `FileDropZone` element (inner module) that
1111
//! intercepts `Event::DragAndDropFiles` from the OS and dispatches a
12-
//! `ImportThemeBodyAction::FileDropped` action. Only `.css` files are
12+
//! `ImportThemeBodyAction::FileDropped` action. Only `.css` and `.json` files are
1313
//! accepted; anything else is rejected with an inline error.
1414
1515
use std::any::Any;
@@ -19,7 +19,9 @@ use crate::appearance::Appearance;
1919
use crate::editor::{EditorOptions, EditorView, Event as EditorEvent, SingleLineEditorOptions};
2020
use crate::modal::Modal;
2121
use crate::themes::theme::{CustomTheme, ThemeKind};
22-
use crate::themes::tweakcn_import::{parse_blocks, write_imported, GamutPolicy, ParsedBlocks};
22+
use crate::themes::tweakcn_import::{
23+
parse_blocks_or_json, write_imported, GamutPolicy, ParsedBlocks,
24+
};
2325
#[cfg(feature = "local_fs")]
2426
use crate::user_config;
2527
use warpui::elements::Point;
@@ -243,7 +245,7 @@ impl ImportThemeBody {
243245
return;
244246
}
245247

246-
match parse_blocks(&self.css_text) {
248+
match parse_blocks_or_json(&self.css_text) {
247249
Ok(blocks) => {
248250
// Auto-fill name from CSS comment hint if the name field is still empty.
249251
if self.name.is_empty() {
@@ -339,7 +341,8 @@ impl ImportThemeBody {
339341

340342
/// Handle a file dropped onto the modal (OS DragAndDropFiles event).
341343
///
342-
/// Accepts the first `.css` file found in `paths`. Non-`.css` files (or
344+
/// Accepts the first `.css` or `.json` file found in `paths`. Unsupported
345+
/// files (or
343346
/// an empty list) show an inline error and leave the paste box untouched.
344347
///
345348
/// Gated on `local_fs` because the fallback (web) has no filesystem access
@@ -348,15 +351,17 @@ impl ImportThemeBody {
348351
pub fn on_file_dropped(&mut self, paths: Vec<String>, ctx: &mut ViewContext<Self>) {
349352
use std::path::Path;
350353

351-
let css_path = paths
352-
.iter()
353-
.map(Path::new)
354-
.find(|p| p.extension().and_then(|e| e.to_str()) == Some("css"));
354+
let import_path = paths.iter().map(Path::new).find(|p| {
355+
matches!(
356+
p.extension().and_then(|e| e.to_str()),
357+
Some("css") | Some("json")
358+
)
359+
});
355360

356-
let path = match css_path {
361+
let path = match import_path {
357362
Some(p) => p,
358363
None => {
359-
self.show_error = Some("Only .css files are supported.".to_string());
364+
self.show_error = Some("Only .css and .json files are supported.".to_string());
360365
ctx.notify();
361366
return;
362367
}
@@ -622,9 +627,13 @@ impl View for ImportThemeBody {
622627

623628
// CSS paste label + field
624629
layout.add_child(
625-
Text::new_inline("Paste tweakcn CSS", appearance.ui_font_family(), 12.)
626-
.with_color(theme.active_ui_text_color().into())
627-
.finish(),
630+
Text::new_inline(
631+
"Paste tweakcn CSS or registry JSON",
632+
appearance.ui_font_family(),
633+
12.,
634+
)
635+
.with_color(theme.active_ui_text_color().into())
636+
.finish(),
628637
);
629638
layout.add_child(css_input);
630639

@@ -654,7 +663,7 @@ impl View for ImportThemeBody {
654663
// Button row
655664
layout.add_child(button_row);
656665

657-
// Wrap the whole layout in a FileDropZone so OS-level .css file drops
666+
// Wrap the whole layout in a FileDropZone so OS-level import file drops
658667
// are captured and dispatched as ImportThemeBodyAction::FileDropped.
659668
Box::new(FileDropZone::new(layout.finish()))
660669
}

app/src/themes/tweakcn_import.rs

Lines changed: 163 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
//! Convert tweakcn CSS exports (OKLCH colors in shadcn token format) into
2-
//! CastCodes `WarpTheme` YAMLs. No new crates — Ottosson's OKLCH → linear
3-
//! sRGB formulas are short enough to vendor.
1+
//! Convert tweakcn CSS exports and shadcn registry JSON themes into CastCodes
2+
//! `WarpTheme` YAMLs. No new crates — Ottosson's OKLCH → linear sRGB formulas
3+
//! are short enough to vendor.
4+
5+
use std::collections::HashMap;
46

57
use pathfinder_color::ColorU;
8+
use serde_json::Value;
69

710
/// Convert OKLCH (L: 0..1, C: 0..0.4 typical, H: 0..360 degrees) to
811
/// 8-bit sRGB. Returns `Err((r, g, b))` if any channel was out of the
@@ -63,11 +66,117 @@ pub enum ImportError {
6366

6467
#[derive(Debug, Default, PartialEq)]
6568
pub struct ParsedBlocks {
66-
pub light: std::collections::HashMap<String, (f64, f64, f64)>, // var → (L, C, H_deg)
67-
pub dark: std::collections::HashMap<String, (f64, f64, f64)>,
69+
pub light: HashMap<String, (f64, f64, f64)>, // var → (L, C, H_deg)
70+
pub dark: HashMap<String, (f64, f64, f64)>,
6871
pub name_comment: Option<String>,
6972
}
7073

74+
pub fn parse_blocks_or_json(input: &str) -> Result<ParsedBlocks, ImportError> {
75+
if input.trim_start().starts_with('{') {
76+
parse_registry_json(input)
77+
} else {
78+
parse_blocks(input)
79+
}
80+
}
81+
82+
pub fn parse_registry_json(input: &str) -> Result<ParsedBlocks, ImportError> {
83+
let value: Value =
84+
serde_json::from_str(input).map_err(|e| ImportError::Io(format!("invalid JSON: {e}")))?;
85+
let css_vars = value
86+
.get("cssVars")
87+
.and_then(Value::as_object)
88+
.ok_or(ImportError::NoColorBlocksFound)?;
89+
90+
let mut blocks = ParsedBlocks {
91+
name_comment: value
92+
.get("name")
93+
.and_then(Value::as_str)
94+
.map(ToOwned::to_owned),
95+
..Default::default()
96+
};
97+
98+
for (key, target) in [("light", &mut blocks.light), ("dark", &mut blocks.dark)] {
99+
let Some(vars) = css_vars.get(key).and_then(Value::as_object) else {
100+
continue;
101+
};
102+
103+
for (name, value) in vars {
104+
let Some(value) = value.as_str() else {
105+
continue;
106+
};
107+
if let Some(triple) = parse_oklch_value(name, value)? {
108+
target.insert(name.clone(), triple);
109+
}
110+
}
111+
}
112+
113+
if blocks.light.is_empty() && blocks.dark.is_empty() {
114+
return Err(ImportError::NoColorBlocksFound);
115+
}
116+
117+
Ok(blocks)
118+
}
119+
120+
fn parse_oklch_value(var: &str, value: &str) -> Result<Option<(f64, f64, f64)>, ImportError> {
121+
let Some(args) = value
122+
.trim()
123+
.strip_prefix("oklch(")
124+
.and_then(|s| s.strip_suffix(')'))
125+
else {
126+
return Ok(None);
127+
};
128+
129+
let triple: Vec<&str> = args.split_whitespace().take(3).collect();
130+
if triple.len() < 3 {
131+
return Err(ImportError::InvalidOklch {
132+
var: var.to_string(),
133+
raw: value.to_string(),
134+
});
135+
}
136+
137+
let l: f64 = triple[0].trim_end_matches('%').parse().unwrap_or(f64::NAN);
138+
// tweakcn emits L as 0..1 (no `%`), but tolerate `%` style:
139+
let l = if triple[0].ends_with('%') {
140+
l / 100.0
141+
} else {
142+
l
143+
};
144+
let c: f64 = triple[1].parse().unwrap_or(f64::NAN);
145+
let h: f64 = triple[2]
146+
.trim_end_matches("deg")
147+
.parse()
148+
.unwrap_or(f64::NAN);
149+
150+
if l.is_finite() && c.is_finite() && h.is_finite() {
151+
Ok(Some((l, c, h)))
152+
} else {
153+
Err(ImportError::InvalidOklch {
154+
var: var.to_string(),
155+
raw: value.to_string(),
156+
})
157+
}
158+
}
159+
160+
fn parse_css_decls(
161+
body: &str,
162+
target: &mut HashMap<String, (f64, f64, f64)>,
163+
) -> Result<(), ImportError> {
164+
for decl in body.split(';') {
165+
let decl = decl.trim();
166+
if !decl.starts_with("--") {
167+
continue;
168+
}
169+
let Some((name, value)) = decl.split_once(':') else {
170+
continue;
171+
};
172+
let name = name.trim().trim_start_matches("--").to_string();
173+
if let Some(triple) = parse_oklch_value(&name, value)? {
174+
target.insert(name, triple);
175+
}
176+
}
177+
Ok(())
178+
}
179+
71180
/// Pull `:root { ... }` and `.dark { ... }` blocks out of a tweakcn CSS
72181
/// export. Parses each `--var: oklch(L C H);` line into a (L,C,H) triple.
73182
/// `oklch()` is the only color function supported — anything else is
@@ -132,62 +241,11 @@ pub fn parse_blocks(css: &str) -> Result<ParsedBlocks, ImportError> {
132241
Some(&haystack[body_start..end])
133242
}
134243

135-
let parse_decls = |body: &str,
136-
target: &mut std::collections::HashMap<String, (f64, f64, f64)>|
137-
-> Result<(), ImportError> {
138-
for decl in body.split(';') {
139-
let decl = decl.trim();
140-
if !decl.starts_with("--") {
141-
continue;
142-
}
143-
let Some((name, value)) = decl.split_once(':') else {
144-
continue;
145-
};
146-
let name = name.trim().trim_start_matches("--").to_string();
147-
let value = value.trim();
148-
// Only `oklch(L C H[ / a])` is supported; anything else is silently skipped.
149-
let Some(args) = value
150-
.strip_prefix("oklch(")
151-
.and_then(|s| s.strip_suffix(')'))
152-
else {
153-
continue;
154-
};
155-
let triple: Vec<&str> = args.split_whitespace().take(3).collect();
156-
if triple.len() < 3 {
157-
return Err(ImportError::InvalidOklch {
158-
var: name,
159-
raw: value.to_string(),
160-
});
161-
}
162-
let l: f64 = triple[0].trim_end_matches('%').parse().unwrap_or(f64::NAN);
163-
// tweakcn emits L as 0..1 (no `%`), but tolerate `%` style:
164-
let l = if triple[0].ends_with('%') {
165-
l / 100.0
166-
} else {
167-
l
168-
};
169-
let c: f64 = triple[1].parse().unwrap_or(f64::NAN);
170-
let h: f64 = triple[2]
171-
.trim_end_matches("deg")
172-
.parse()
173-
.unwrap_or(f64::NAN);
174-
if l.is_finite() && c.is_finite() && h.is_finite() {
175-
target.insert(name, (l, c, h));
176-
} else {
177-
return Err(ImportError::InvalidOklch {
178-
var: name,
179-
raw: value.to_string(),
180-
});
181-
}
182-
}
183-
Ok(())
184-
};
185-
186244
if let Some(body) = extract_block(&cleaned, ":root") {
187-
parse_decls(body, &mut blocks.light)?;
245+
parse_css_decls(body, &mut blocks.light)?;
188246
}
189247
if let Some(body) = extract_block(&cleaned, ".dark") {
190-
parse_decls(body, &mut blocks.dark)?;
248+
parse_css_decls(body, &mut blocks.dark)?;
191249
}
192250

193251
if blocks.light.is_empty() && blocks.dark.is_empty() {
@@ -579,6 +637,53 @@ mod parse_block_tests {
579637
assert!(blocks.light.is_empty());
580638
assert_eq!(blocks.dark.len(), 1);
581639
}
640+
641+
#[test]
642+
fn registry_json_extracts_light_and_dark_blocks() {
643+
let json = r##"{
644+
"$schema": "https://ui.shadcn.com/schema/registry-item.json",
645+
"name": "vercel",
646+
"type": "registry:style",
647+
"cssVars": {
648+
"theme": { "font-sans": "Geist, sans-serif" },
649+
"light": {
650+
"background": "oklch(0.99 0 0)",
651+
"foreground": "oklch(0 0 0)",
652+
"shadow-color": "#000000"
653+
},
654+
"dark": {
655+
"background": "oklch(0 0 0)",
656+
"foreground": "oklch(1 0 0)",
657+
"border": "oklch(1 0 0 / 10%)"
658+
}
659+
}
660+
}"##;
661+
662+
let blocks = parse_blocks_or_json(json).unwrap();
663+
664+
assert_eq!(blocks.name_comment.as_deref(), Some("vercel"));
665+
assert_eq!(blocks.light.len(), 2);
666+
assert_eq!(blocks.dark.len(), 3);
667+
assert_eq!(blocks.dark["foreground"], (1.0, 0.0, 0.0));
668+
}
669+
670+
#[test]
671+
fn registry_json_invalid_oklch_reports_the_variable() {
672+
let json = r#"{
673+
"cssVars": {
674+
"light": {
675+
"background": "oklch(nope 0 0)"
676+
}
677+
}
678+
}"#;
679+
680+
let result = parse_blocks_or_json(json);
681+
682+
assert!(matches!(
683+
result,
684+
Err(ImportError::InvalidOklch { var, .. }) if var == "background"
685+
));
686+
}
582687
}
583688

584689
#[cfg(test)]

0 commit comments

Comments
 (0)