1use std::collections::HashMap;
24use std::sync::OnceLock;
25
26use schemars::schema_for;
27use serde_json::{to_value, Value};
28
29use crate::component::{
30 ActionCardProps, AlertProps, AvatarProps, BadgeProps, BreadcrumbProps, ButtonGroupProps,
31 ButtonProps, CalendarCellProps, CardProps, CheckboxListProps, CheckboxProps, ChecklistProps,
32 CollapsibleProps, DataTableProps, DescriptionListProps, DetailPageProps, DropdownMenuProps,
33 EmptyStateProps, FormProps, FormSectionProps, GridProps, HeaderProps, ImageProps, InputProps,
34 KanbanBoardProps, MediaCardGridProps, ModalProps, NotificationDropdownProps, PageHeaderProps,
35 PaginationProps, ProductTileProps, ProgressProps, RawHtmlProps, SelectProps, SeparatorProps,
36 SidebarProps, SkeletonProps, StatCardProps, SwitchProps, TableProps, TabsProps, TextProps,
37 ToastProps,
38};
39
40pub struct ComponentSpec {
47 pub name: String,
49 pub description: String,
51 pub props_schema: Value,
53 pub is_plugin: bool,
55 pub slot_fields: Vec<String>,
60}
61
62pub struct Catalog {
68 pub(crate) components: HashMap<String, ComponentSpec>,
70 pub(crate) plugin_components: HashMap<String, ComponentSpec>,
72 pub(crate) full_schema: Value,
74 pub(crate) per_component_schemas: HashMap<String, Value>,
76 pub(crate) validator: jsonschema::Validator,
78}
79
80#[derive(Debug, thiserror::Error)]
82pub enum CatalogError {
83 #[error("unknown component type '{type_name}' at element '{element_id}'")]
85 UnknownType {
86 element_id: String,
88 type_name: String,
90 },
91 #[error("props invalid for '{type_name}' at element '{element_id}': {errors:?}")]
93 PropsInvalid {
94 element_id: String,
96 type_name: String,
98 errors: Vec<String>,
100 },
101 #[error("spec invalid: {errors:?}")]
103 SpecInvalid {
104 errors: Vec<String>,
106 },
107 #[error("catalog build failed: {0}")]
109 BuildFailed(String),
110 #[error("schema serialization error: {0}")]
112 SchemaSerialization(#[from] serde_json::Error),
113}
114
115type SchemaFn = fn() -> Value;
118
119static BUILTIN_SPECS: &[(&str, &str, SchemaFn, &[&str])] = &[
125 (
127 "Text",
128 "Semantic text element (p / h1 / h2 / h3 / span / div / section).",
129 || to_value(schema_for!(TextProps)).unwrap(),
130 &[],
131 ),
132 (
133 "Button",
134 "Interactive button with variant, size, optional icon, and disabled state.",
135 || to_value(schema_for!(ButtonProps)).unwrap(),
136 &[],
137 ),
138 (
139 "Badge",
140 "Small variant-styled label.",
141 || to_value(schema_for!(BadgeProps)).unwrap(),
142 &[],
143 ),
144 (
145 "Alert",
146 "Inline notice with info / success / warning / error variants.",
147 || to_value(schema_for!(AlertProps)).unwrap(),
148 &[],
149 ),
150 (
151 "Separator",
152 "Horizontal or vertical divider between content sections.",
153 || to_value(schema_for!(SeparatorProps)).unwrap(),
154 &[],
155 ),
156 (
157 "Progress",
158 "Progress bar with 0–100 percentage value and optional label.",
159 || to_value(schema_for!(ProgressProps)).unwrap(),
160 &[],
161 ),
162 (
163 "Avatar",
164 "Circular user image with fallback initials and size variants.",
165 || to_value(schema_for!(AvatarProps)).unwrap(),
166 &[],
167 ),
168 (
169 "Image",
170 "Image with optional aspect ratio and skeleton fallback on load error.",
171 || to_value(schema_for!(ImageProps)).unwrap(),
172 &[],
173 ),
174 (
175 "Skeleton",
176 "Loading placeholder with configurable width / height / rounding.",
177 || to_value(schema_for!(SkeletonProps)).unwrap(),
178 &[],
179 ),
180 (
181 "Breadcrumb",
182 "Navigation trail of label + optional URL items.",
183 || to_value(schema_for!(BreadcrumbProps)).unwrap(),
184 &[],
185 ),
186 (
187 "Pagination",
188 "Page navigation for paginated data (current / per_page / total).",
189 || to_value(schema_for!(PaginationProps)).unwrap(),
190 &[],
191 ),
192 (
193 "DescriptionList",
194 "Key-value pairs displayed as a description list with optional format.",
195 || to_value(schema_for!(DescriptionListProps)).unwrap(),
196 &[],
197 ),
198 (
199 "EmptyState",
200 "Standardized empty view with title, description, and optional CTA.",
201 || to_value(schema_for!(EmptyStateProps)).unwrap(),
202 &[],
203 ),
204 (
205 "StatCard",
206 "Live-updatable metric card with label, value, icon, SSE target.",
207 || to_value(schema_for!(StatCardProps)).unwrap(),
208 &[],
209 ),
210 (
211 "Checklist",
212 "Onboarding-style checklist with dismissal and server-side state.",
213 || to_value(schema_for!(ChecklistProps)).unwrap(),
214 &[],
215 ),
216 (
217 "Toast",
218 "Declarative notification intent consumed by the runtime JS via data attributes.",
219 || to_value(schema_for!(ToastProps)).unwrap(),
220 &[],
221 ),
222 (
223 "NotificationDropdown",
224 "Dropdown listing notification items with icons, timestamps, read state.",
225 || to_value(schema_for!(NotificationDropdownProps)).unwrap(),
226 &[],
227 ),
228 (
229 "Sidebar",
230 "Dashboard sidebar with fixed top / bottom items and collapsible nav groups.",
231 || to_value(schema_for!(SidebarProps)).unwrap(),
232 &[],
233 ),
234 (
235 "Header",
236 "Dashboard top bar with business name, notification badge, user menu.",
237 || to_value(schema_for!(HeaderProps)).unwrap(),
238 &[],
239 ),
240 (
241 "DropdownMenu",
242 "Trigger button with an absolutely-positioned kebab-style action panel.",
243 || to_value(schema_for!(DropdownMenuProps)).unwrap(),
244 &[],
245 ),
246 (
247 "CalendarCell",
248 "Single day in a month grid with today highlight, out-of-month muting, event dots.",
249 || to_value(schema_for!(CalendarCellProps)).unwrap(),
250 &[],
251 ),
252 (
253 "ActionCard",
254 "Clickable row with icon, title, description, chevron, and variant-colored border.",
255 || to_value(schema_for!(ActionCardProps)).unwrap(),
256 &[],
257 ),
258 (
259 "ProductTile",
260 "Touch-friendly POS tile with name, price, and +/- quantity controls.",
261 || to_value(schema_for!(ProductTileProps)).unwrap(),
262 &[],
263 ),
264 (
265 "RawHtml",
266 "Server-injected HTML island. CONSUMER is responsible for sanitization — see docs/src/json-ui/plugins.md.",
267 || to_value(schema_for!(RawHtmlProps)).unwrap(),
268 &[],
269 ),
270 (
272 "Card",
273 "Content container with title, description, optional badge and subtitle, body children, and optional footer slot.",
274 || to_value(schema_for!(CardProps)).unwrap(),
275 &["footer"],
276 ),
277 (
278 "Modal",
279 "Dialog overlay with title, description, body children, and optional footer slot.",
280 || to_value(schema_for!(ModalProps)).unwrap(),
281 &["footer"],
282 ),
283 (
284 "Tabs",
285 "Tabbed content; per-tab children live in TabsProps.tabs[i].children.",
286 || to_value(schema_for!(TabsProps)).unwrap(),
287 &[],
288 ),
289 (
290 "KanbanBoard",
291 "Horizontally scrollable kanban columns on desktop, tab-switched on mobile.",
292 || to_value(schema_for!(KanbanBoardProps)).unwrap(),
293 &[],
294 ),
295 (
296 "PageHeader",
297 "Page title with optional breadcrumb and action button slot.",
298 || to_value(schema_for!(PageHeaderProps)).unwrap(),
299 &["actions"],
300 ),
301 (
302 "DetailPage",
303 "Canonical resource-detail skeleton: PageHeader chrome, optional info Card slot, and stacked body sections from Element.children.",
304 || to_value(schema_for!(DetailPageProps)).unwrap(),
305 &["actions", "info"],
306 ),
307 (
308 "Grid",
309 "Responsive multi-column grid with configurable breakpoint columns, gap, scroll.",
310 || to_value(schema_for!(GridProps)).unwrap(),
311 &[],
312 ),
313 (
314 "Collapsible",
315 "Expandable <details> / <summary> section.",
316 || to_value(schema_for!(CollapsibleProps)).unwrap(),
317 &[],
318 ),
319 (
320 "FormSection",
321 "Visual grouping within a form with title, description, and layout variant.",
322 || to_value(schema_for!(FormSectionProps)).unwrap(),
323 &[],
324 ),
325 (
326 "ButtonGroup",
327 "Horizontal button row with configurable gap.",
328 || to_value(schema_for!(ButtonGroupProps)).unwrap(),
329 &[],
330 ),
331 (
333 "Form",
334 "Form container with action binding and field components.",
335 || to_value(schema_for!(FormProps)).unwrap(),
336 &[],
337 ),
338 (
339 "Input",
340 "Text input with type variants, validation error, data_path pre-fill.",
341 || to_value(schema_for!(InputProps)).unwrap(),
342 &[],
343 ),
344 (
345 "Select",
346 "Dropdown select with options, error, data_path pre-fill.",
347 || to_value(schema_for!(SelectProps)).unwrap(),
348 &[],
349 ),
350 (
351 "Checkbox",
352 "Boolean checkbox with label, description, data binding.",
353 || to_value(schema_for!(CheckboxProps)).unwrap(),
354 &[],
355 ),
356 (
357 "Switch",
358 "Toggle switch (visual alternative to Checkbox); auto-submit when `action` set.",
359 || to_value(schema_for!(SwitchProps)).unwrap(),
360 &[],
361 ),
362 (
363 "CheckboxList",
364 "Multi-select checkbox group from static options or data-driven array. \
365 Each checked option submits as field=value.",
366 || to_value(schema_for!(CheckboxListProps)).unwrap(),
367 &[],
368 ),
369 (
370 "CheckboxGroup",
371 "Multi-select checkbox group (alias for CheckboxList). Each checked option \
372 submits as field=value with array-submit semantics. Identical props to \
373 CheckboxList; see that entry for full schema.",
374 || to_value(schema_for!(CheckboxListProps)).unwrap(),
375 &[],
376 ),
377 (
379 "Table",
380 "Data table with columns, row_actions, sorting, empty_message.",
381 || to_value(schema_for!(TableProps)).unwrap(),
382 &[],
383 ),
384 (
385 "DataTable",
386 "Stripe-style alternating-row table with per-row DropdownMenu and mobile card fallback.",
387 || to_value(schema_for!(DataTableProps)).unwrap(),
388 &[],
389 ),
390 (
391 "MediaCardGrid",
392 "Responsive card grid backed by a data array. Each card shows an optional screenshot image, title, description, status badge, and per-row dropdown actions.",
393 || to_value(schema_for!(MediaCardGridProps)).unwrap(),
394 &[],
395 ),
396];
397
398fn sanitize_schema(mut schema: Value) -> Value {
406 fn walk(v: &mut Value) {
407 if let Some(obj) = v.as_object_mut() {
408 if let Some(defs) = obj.remove("definitions") {
409 obj.entry("$defs".to_string()).or_insert(defs);
410 }
411 if let Some(Value::String(ref_str)) = obj.get_mut("$ref") {
412 if let Some(suffix) = ref_str.strip_prefix("#/definitions/") {
413 *ref_str = format!("#/$defs/{suffix}");
414 }
415 }
416 let keys: Vec<String> = obj.keys().cloned().collect();
418 for k in keys {
419 if let Some(child) = obj.get_mut(&k) {
420 walk(child);
421 }
422 }
423 } else if let Some(arr) = v.as_array_mut() {
424 for item in arr.iter_mut() {
425 walk(item);
426 }
427 }
428 }
429 walk(&mut schema);
430 schema
431}
432
433fn hoist_defs(schema: &mut Value, shared_defs: &mut serde_json::Map<String, Value>) {
442 if let Some(obj) = schema.as_object_mut() {
443 if let Some(Value::Object(defs)) = obj.remove("$defs") {
444 for (k, v) in defs {
445 shared_defs.entry(k).or_insert(v);
446 }
447 }
448 }
449}
450
451fn assemble_full_schema(per_component: &HashMap<String, Value>) -> Result<Value, CatalogError> {
463 let mut action_schema = sanitize_schema(to_value(schema_for!(crate::action::Action))?);
465 let mut visibility_schema =
466 sanitize_schema(to_value(schema_for!(crate::visibility::Visibility))?);
467
468 let mut shared_defs: serde_json::Map<String, Value> = serde_json::Map::new();
470 hoist_defs(&mut action_schema, &mut shared_defs);
471 hoist_defs(&mut visibility_schema, &mut shared_defs);
472
473 let mut names: Vec<&String> = per_component.keys().collect();
477 names.sort();
478 let one_of: Vec<Value> = names
479 .into_iter()
480 .map(|name| {
481 let mut props_schema = per_component[name].clone();
482 hoist_defs(&mut props_schema, &mut shared_defs);
484 serde_json::json!({
485 "allOf": [
486 {
487 "type": "object",
488 "required": ["type"],
489 "properties": {
490 "type": { "const": name }
491 }
492 },
493 {
494 "type": "object",
495 "properties": {
496 "props": props_schema,
497 "children": { "type": "array", "items": { "type": "string" } },
498 "action": { "$ref": "#/$defs/Action" },
499 "visible": { "$ref": "#/$defs/Visibility" }
500 }
501 }
502 ]
503 })
504 })
505 .collect();
506
507 shared_defs
510 .entry("Action".to_string())
511 .or_insert(action_schema);
512 shared_defs
513 .entry("Visibility".to_string())
514 .or_insert(visibility_schema);
515 shared_defs.insert(
517 "Element".to_string(),
518 serde_json::json!({ "oneOf": one_of }),
519 );
520
521 Ok(serde_json::json!({
522 "$schema": "https://json-schema.org/draft/2020-12/schema",
523 "$id": "ferro-json-ui/v2",
524 "type": "object",
525 "required": ["$schema", "root", "elements"],
526 "properties": {
527 "$schema": { "const": "ferro-json-ui/v2" },
528 "root": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_-]{0,127}$" },
529 "elements": {
530 "type": "object",
531 "additionalProperties": { "$ref": "#/$defs/Element" }
532 },
533 "title": { "type": ["string", "null"] },
534 "layout": { "type": ["string", "null"] },
535 "data": true
536 },
537 "$defs": shared_defs
538 }))
539}
540
541impl Catalog {
544 pub fn build() -> Result<Self, CatalogError> {
555 if BUILTIN_SPECS.len() != crate::render::BUILTIN_TYPES.len() {
559 return Err(CatalogError::BuildFailed(format!(
560 "BUILTIN_SPECS has {} entries but BUILTIN_TYPES has {} — \
561 add an entry to BUILTIN_SPECS or remove from BUILTIN_TYPES",
562 BUILTIN_SPECS.len(),
563 crate::render::BUILTIN_TYPES.len(),
564 )));
565 }
566
567 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
569 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len() * 2);
570 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
571 let raw = schema_fn();
572 let schema = sanitize_schema(raw);
573 per_component_schemas.insert((*name).to_string(), schema.clone());
574 components.insert(
575 (*name).to_string(),
576 ComponentSpec {
577 name: (*name).to_string(),
578 description: (*desc).to_string(),
579 props_schema: schema,
580 is_plugin: false,
581 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
582 },
583 );
584 }
585
586 let mut plugin_components = HashMap::new();
592 for plugin_type in crate::plugin::registered_plugin_types() {
593 if components.contains_key(&plugin_type) {
595 continue;
596 }
597 let raw = crate::plugin::with_plugin(&plugin_type, |p| p.props_schema())
598 .unwrap_or(Value::Null);
599 let schema = sanitize_schema(raw);
600 if jsonschema::validator_for(&schema).is_err() {
602 return Err(CatalogError::BuildFailed(format!(
603 "plugin '{plugin_type}' returned an invalid JSON Schema"
604 )));
605 }
606 per_component_schemas.insert(plugin_type.clone(), schema.clone());
607 plugin_components.insert(
608 plugin_type.clone(),
609 ComponentSpec {
610 name: plugin_type,
611 description: String::from("Plugin component."),
612 props_schema: schema,
613 is_plugin: true,
614 slot_fields: Vec::new(),
615 },
616 );
617 }
618
619 let full_schema = assemble_full_schema(&per_component_schemas)?;
621
622 let validator = jsonschema::validator_for(&full_schema)
624 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
625
626 Ok(Catalog {
627 components,
628 plugin_components,
629 full_schema,
630 per_component_schemas,
631 validator,
632 })
633 }
634
635 pub fn json_schema(&self) -> &Value {
642 &self.full_schema
643 }
644
645 pub fn validate(&self, spec: &crate::spec::Spec) -> Result<(), Vec<CatalogError>> {
665 let mut errors: Vec<CatalogError> = Vec::new();
666
667 for (id, el) in &spec.elements {
669 let known = self.components.contains_key(&el.type_name)
670 || self.plugin_components.contains_key(&el.type_name);
671 if !known {
672 errors.push(CatalogError::UnknownType {
673 element_id: id.clone(),
674 type_name: el.type_name.clone(),
675 });
676 }
677 }
678 if !errors.is_empty() {
683 return Err(errors);
684 }
685
686 for (id, el) in &spec.elements {
688 if let Some(schema) = self.per_component_schemas.get(&el.type_name) {
689 if el.props.is_null() {
694 continue;
695 }
696 let v = match jsonschema::validator_for(schema) {
700 Ok(v) => v,
701 Err(e) => {
702 errors.push(CatalogError::BuildFailed(format!(
703 "compiling per-component schema for '{}': {e}",
704 el.type_name
705 )));
706 continue;
707 }
708 };
709 let validation_props = strip_expr_objects(&el.props);
715 let mut per_elem_errs: Vec<String> = Vec::new();
716 for err in v.iter_errors(&validation_props) {
717 per_elem_errs.push(format!("{}: {}", err.instance_path(), err));
718 }
719 if !per_elem_errs.is_empty() {
720 errors.push(CatalogError::PropsInvalid {
721 element_id: id.clone(),
722 type_name: el.type_name.clone(),
723 errors: per_elem_errs,
724 });
725 }
726 }
727 }
728
729 let spec_value = match serde_json::to_value(spec) {
731 Ok(v) => v,
732 Err(e) => {
733 errors.push(CatalogError::SchemaSerialization(e));
734 return Err(errors);
735 }
736 };
737 let stripped_spec_value = strip_expr_objects(&spec_value);
739 let mut envelope_errs: Vec<String> = Vec::new();
740 for err in self.validator.iter_errors(&stripped_spec_value) {
741 envelope_errs.push(format!("{}: {}", err.instance_path(), err));
742 }
743 if !envelope_errs.is_empty() {
744 errors.push(CatalogError::SpecInvalid {
745 errors: envelope_errs,
746 });
747 }
748
749 if errors.is_empty() {
750 Ok(())
751 } else {
752 Err(errors)
753 }
754 }
755
756 pub fn component_schema(&self, type_name: &str) -> Option<&Value> {
770 self.per_component_schemas.get(type_name)
771 }
772
773 pub fn components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
780 let mut entries: Vec<&ComponentSpec> = self.components.values().collect();
781 entries.sort_by(|a, b| a.name.cmp(&b.name));
782 entries.into_iter()
783 }
784
785 pub fn plugin_components_sorted(&self) -> impl Iterator<Item = &ComponentSpec> {
791 let mut entries: Vec<&ComponentSpec> = self.plugin_components.values().collect();
792 entries.sort_by(|a, b| a.name.cmp(&b.name));
793 entries.into_iter()
794 }
795
796 pub fn prompt(&self) -> String {
812 let mut out = String::with_capacity(8 * 1024);
813 out.push_str("## Component Catalog\n\n");
814 out.push_str("Slot fields are Vec<String> of element IDs; body children come from Element.children.\n\n");
815 for spec in self.components_sorted() {
816 render_component_section(&mut out, spec);
817 }
818 if self.plugin_components.is_empty() {
819 return out;
820 }
821 out.push_str("## Plugin Components\n\n");
822 for spec in self.plugin_components_sorted() {
823 render_component_section(&mut out, spec);
824 }
825 out
826 }
827}
828
829fn render_component_section(out: &mut String, spec: &ComponentSpec) {
846 out.push_str("### ");
847 out.push_str(&spec.name);
848 out.push('\n');
849 out.push_str(&spec.description);
850 out.push('\n');
851
852 let props_line = render_props_line(&spec.props_schema);
853 if !props_line.is_empty() {
854 out.push_str("Props: ");
855 out.push_str(&props_line);
856 out.push('\n');
857 }
858 if !spec.slot_fields.is_empty() {
859 out.push_str("Slots: ");
860 out.push_str(&spec.slot_fields.join(", "));
861 out.push('\n');
862 }
863 out.push('\n');
864}
865
866fn render_props_line(schema: &Value) -> String {
877 let Some(obj) = schema.as_object() else {
878 return String::new();
879 };
880 let Some(props) = obj.get("properties").and_then(|v| v.as_object()) else {
881 return String::new();
882 };
883 let required: std::collections::HashSet<&str> = obj
884 .get("required")
885 .and_then(|v| v.as_array())
886 .map(|arr| {
887 arr.iter()
888 .filter_map(|v| v.as_str())
889 .collect::<std::collections::HashSet<_>>()
890 })
891 .unwrap_or_default();
892
893 let parts: Vec<String> = props
894 .iter()
895 .map(|(name, field_schema)| {
896 let ty = render_field_type(field_schema, required.contains(name.as_str()));
897 format!("{name} ({ty})")
898 })
899 .collect();
900 parts.join(", ")
901}
902
903fn render_field_type(schema: &Value, is_required: bool) -> String {
905 if let Some(variants) = schema.get("enum").and_then(|v| v.as_array()) {
907 let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
908 let inner = render_enum_inline(&names);
909 return wrap_optional(inner, is_required);
910 }
911 for key in ["anyOf", "oneOf"] {
913 if let Some(arr) = schema.get(key).and_then(|v| v.as_array()) {
914 let has_null = arr
915 .iter()
916 .any(|v| v.get("type").and_then(|t| t.as_str()) == Some("null"));
917 let non_null: Vec<&Value> = arr
918 .iter()
919 .filter(|v| v.get("type").and_then(|t| t.as_str()) != Some("null"))
920 .collect();
921 if has_null && non_null.len() == 1 {
922 let inner = render_field_type(non_null[0], true);
923 return format!("Option<{inner}>");
924 }
925 }
926 }
927 if let Some(types) = schema.get("type").and_then(|v| v.as_array()) {
929 let non_null: Vec<&str> = types
930 .iter()
931 .filter_map(|v| v.as_str())
932 .filter(|s| *s != "null")
933 .collect();
934 let has_null = types.iter().any(|v| v.as_str() == Some("null"));
935 if has_null && non_null.len() == 1 {
936 return format!("Option<{}>", rust_for_json_type(non_null[0], schema));
937 }
938 }
939 if let Some(t) = schema.get("type").and_then(|v| v.as_str()) {
941 let inner = rust_for_json_type(t, schema);
942 return wrap_optional(inner, is_required);
943 }
944 wrap_optional("<see schema>".to_string(), is_required)
946}
947
948fn rust_for_json_type(t: &str, schema: &Value) -> String {
950 match t {
951 "string" => "String".to_string(),
952 "integer" => "i64".to_string(),
953 "number" => "f64".to_string(),
954 "boolean" => "bool".to_string(),
955 "array" => {
956 if let Some(items) = schema.get("items") {
957 let inner = render_field_type(items, true);
958 format!("Vec<{inner}>")
959 } else {
960 "Vec<Value>".to_string()
961 }
962 }
963 "object" => "Object".to_string(),
964 other => other.to_string(),
965 }
966}
967
968fn render_enum_inline(variants: &[&str]) -> String {
970 if variants.len() <= 8 {
971 variants.join("|")
972 } else {
973 format!("one of {} — see schema", variants.len())
974 }
975}
976
977fn wrap_optional(inner: String, is_required: bool) -> String {
979 if is_required {
980 inner
981 } else {
982 format!("Option<{inner}>")
983 }
984}
985
986fn strip_expr_objects(val: &Value) -> Value {
995 match val {
996 Value::Object(map) => {
997 if map.len() == 1 && (map.contains_key("$data") || map.contains_key("$template")) {
998 Value::String(String::new())
999 } else {
1000 Value::Object(
1001 map.iter()
1002 .map(|(k, v)| (k.clone(), strip_expr_objects(v)))
1003 .collect(),
1004 )
1005 }
1006 }
1007 Value::Array(arr) => Value::Array(arr.iter().map(strip_expr_objects).collect()),
1008 other => other.clone(),
1009 }
1010}
1011
1012pub fn global_catalog() -> &'static Catalog {
1025 static GLOBAL_CATALOG: OnceLock<Catalog> = OnceLock::new();
1026 GLOBAL_CATALOG.get_or_init(|| {
1027 Catalog::build().expect("catalog build failed — see CatalogError for details")
1028 })
1029}
1030
1031#[cfg(test)]
1034impl Catalog {
1035 pub(crate) fn build_builtins_only() -> Result<Self, CatalogError> {
1040 let mut components = HashMap::with_capacity(BUILTIN_SPECS.len());
1041 let mut per_component_schemas = HashMap::with_capacity(BUILTIN_SPECS.len());
1042 for (name, desc, schema_fn, slots) in BUILTIN_SPECS {
1043 let raw = schema_fn();
1044 let schema = sanitize_schema(raw);
1045 per_component_schemas.insert((*name).to_string(), schema.clone());
1046 components.insert(
1047 (*name).to_string(),
1048 ComponentSpec {
1049 name: (*name).to_string(),
1050 description: (*desc).to_string(),
1051 props_schema: schema,
1052 is_plugin: false,
1053 slot_fields: slots.iter().map(|s| (*s).to_string()).collect(),
1054 },
1055 );
1056 }
1057 let full_schema = assemble_full_schema(&per_component_schemas)?;
1058 let validator = jsonschema::validator_for(&full_schema)
1059 .map_err(|e| CatalogError::BuildFailed(format!("compiling full spec schema: {e}")))?;
1060 Ok(Catalog {
1061 components,
1062 plugin_components: HashMap::new(),
1063 full_schema,
1064 per_component_schemas,
1065 validator,
1066 })
1067 }
1068}
1069
1070#[cfg(test)]
1071mod tests {
1072 use super::*;
1073
1074 #[test]
1075 fn builtin_types_count_is_39() {
1076 assert_eq!(crate::render::BUILTIN_TYPES.len(), 44);
1083 }
1084
1085 #[test]
1086 fn builtin_specs_len_matches_dispatch() {
1087 assert_eq!(BUILTIN_SPECS.len(), crate::render::BUILTIN_TYPES.len());
1088 assert_eq!(BUILTIN_SPECS.len(), 44);
1089 }
1090
1091 #[test]
1092 fn builtin_specs_names_match_dispatch() {
1093 use std::collections::HashSet;
1094 let specs: HashSet<&str> = BUILTIN_SPECS.iter().map(|(n, ..)| *n).collect();
1095 let types: HashSet<&str> = crate::render::BUILTIN_TYPES.iter().copied().collect();
1096 assert_eq!(specs, types, "BUILTIN_SPECS names must match BUILTIN_TYPES");
1097 }
1098
1099 #[test]
1100 fn build_populates_all_builtins() {
1101 let cat = Catalog::build_builtins_only().expect("build succeeds");
1103 for name in crate::render::BUILTIN_TYPES.iter() {
1104 assert!(
1105 cat.components.contains_key(*name),
1106 "built-in '{name}' missing from catalog.components"
1107 );
1108 let spec = &cat.components[*name];
1109 assert_eq!(spec.name, *name);
1110 assert!(
1111 !spec.description.is_empty(),
1112 "'{name}' has empty description"
1113 );
1114 assert!(
1115 spec.props_schema.is_object(),
1116 "'{name}' props_schema is not a JSON object"
1117 );
1118 assert!(!spec.is_plugin);
1119 }
1120 }
1121
1122 #[test]
1123 fn build_card_has_footer_slot() {
1124 let cat = Catalog::build_builtins_only().expect("build succeeds");
1126 let card = &cat.components["Card"];
1127 assert_eq!(card.slot_fields, vec!["footer"]);
1128 }
1129
1130 #[test]
1131 fn build_modal_has_footer_slot() {
1132 let cat = Catalog::build_builtins_only().expect("build succeeds");
1134 let modal = &cat.components["Modal"];
1135 assert_eq!(modal.slot_fields, vec!["footer"]);
1136 }
1137
1138 #[test]
1139 fn build_pageheader_has_actions_slot() {
1140 let cat = Catalog::build_builtins_only().expect("build succeeds");
1142 let ph = &cat.components["PageHeader"];
1143 assert_eq!(ph.slot_fields, vec!["actions"]);
1144 }
1145
1146 #[test]
1147 fn build_text_has_no_slots() {
1148 let cat = Catalog::build_builtins_only().expect("build succeeds");
1150 assert!(cat.components["Text"].slot_fields.is_empty());
1151 }
1152
1153 #[test]
1154 fn build_populates_per_component_schemas() {
1155 let cat = Catalog::build_builtins_only().expect("build succeeds");
1157 assert_eq!(
1158 cat.per_component_schemas.len(),
1159 BUILTIN_SPECS.len() + cat.plugin_components.len()
1160 );
1161 }
1162
1163 #[test]
1164 fn sanitize_schema_rewrites_definitions_to_dollar_defs() {
1165 let raw = serde_json::json!({
1166 "type": "object",
1167 "definitions": { "Foo": { "type": "string" } },
1168 "properties": {
1169 "x": { "$ref": "#/definitions/Foo" }
1170 }
1171 });
1172 let out = sanitize_schema(raw);
1173 assert!(out.get("definitions").is_none());
1174 assert!(out.get("$defs").is_some());
1175 assert_eq!(
1176 out["properties"]["x"]["$ref"].as_str().unwrap(),
1177 "#/$defs/Foo"
1178 );
1179 }
1180
1181 #[test]
1182 fn sanitize_schema_is_idempotent() {
1183 let raw = serde_json::json!({
1184 "type": "object",
1185 "$defs": { "Foo": { "type": "string" } },
1186 "properties": {
1187 "x": { "$ref": "#/$defs/Foo" }
1188 }
1189 });
1190 let once = sanitize_schema(raw.clone());
1191 let twice = sanitize_schema(once.clone());
1192 assert_eq!(once, twice);
1193 assert!(twice.get("definitions").is_none());
1195 assert!(twice.get("$defs").is_some());
1196 }
1197
1198 #[test]
1199 fn json_schema_has_spec_envelope_shape() {
1200 let cat = Catalog::build_builtins_only().expect("build");
1203 let schema = cat.json_schema();
1204 assert_eq!(schema["$id"], "ferro-json-ui/v2");
1205 assert_eq!(schema["type"], "object");
1206 let required: Vec<&str> = schema["required"]
1207 .as_array()
1208 .unwrap()
1209 .iter()
1210 .map(|v| v.as_str().unwrap())
1211 .collect();
1212 assert!(required.contains(&"$schema"));
1213 assert!(required.contains(&"root"));
1214 assert!(required.contains(&"elements"));
1215 }
1216
1217 #[test]
1218 fn json_schema_has_action_and_visibility_defs() {
1219 let cat = Catalog::build_builtins_only().expect("build");
1220 let schema = cat.json_schema();
1221 assert!(
1222 schema["$defs"]["Action"].is_object(),
1223 "$defs/Action missing"
1224 );
1225 assert!(
1226 schema["$defs"]["Visibility"].is_object(),
1227 "$defs/Visibility missing"
1228 );
1229 assert!(
1230 schema["$defs"]["Element"].is_object(),
1231 "$defs/Element missing"
1232 );
1233 }
1234
1235 #[test]
1236 fn json_schema_oneof_covers_all_builtins() {
1237 let cat = Catalog::build_builtins_only().expect("build");
1238 let schema = cat.json_schema();
1239 let one_of = schema["$defs"]["Element"]["oneOf"]
1241 .as_array()
1242 .expect("Element.oneOf is an array");
1243
1244 let mut discriminators: std::collections::HashSet<String> =
1246 std::collections::HashSet::new();
1247 for variant in one_of {
1248 let c = variant["allOf"][0]["properties"]["type"]["const"]
1249 .as_str()
1250 .expect("every variant pins a type const");
1251 discriminators.insert(c.to_string());
1252 }
1253
1254 for name in crate::render::BUILTIN_TYPES.iter() {
1255 assert!(
1256 discriminators.contains(*name),
1257 "oneOf is missing discriminator for '{name}'"
1258 );
1259 }
1260
1261 assert_eq!(
1263 discriminators.len(),
1264 crate::render::BUILTIN_TYPES.len(),
1265 "oneOf variant count mismatch"
1266 );
1267 }
1268
1269 #[test]
1270 fn json_schema_is_valid() {
1271 use jsonschema::draft202012;
1272 let cat = Catalog::build_builtins_only().expect("build");
1273 let schema = cat.json_schema();
1274 assert!(
1275 draft202012::meta::is_valid(schema),
1276 "assembled full_schema did not meta-validate as Draft 2020-12"
1277 );
1278 }
1279
1280 #[test]
1281 fn validator_is_compiled_once_and_usable() {
1282 let cat = Catalog::build_builtins_only().expect("build");
1283 let minimal_valid = serde_json::json!({
1287 "$schema": "ferro-json-ui/v2",
1288 "root": "r",
1289 "elements": {
1290 "r": { "type": "Text", "props": { "content": "hi" } }
1291 }
1292 });
1293 assert!(cat.validator.is_valid(&minimal_valid));
1295 }
1296
1297 #[test]
1298 fn validator_rejects_wrong_schema_version() {
1299 let cat = Catalog::build_builtins_only().expect("build");
1300 let wrong_version = serde_json::json!({
1301 "$schema": "ferro-json-ui/v99-wrong",
1302 "root": "r",
1303 "elements": {
1304 "r": { "type": "Text", "props": { "content": "hi" } }
1305 }
1306 });
1307 assert!(
1308 !cat.validator.is_valid(&wrong_version),
1309 "validator should reject unknown $schema version via const"
1310 );
1311 }
1312
1313 #[test]
1314 fn oneof_variants_are_deterministic_sorted() {
1315 let cat1 = Catalog::build_builtins_only().expect("build 1");
1316 let cat2 = Catalog::build_builtins_only().expect("build 2");
1317 assert_eq!(
1319 serde_json::to_string(cat1.json_schema()).unwrap(),
1320 serde_json::to_string(cat2.json_schema()).unwrap()
1321 );
1322 }
1323
1324 fn test_spec_with(type_name: &str, props: Value) -> crate::spec::Spec {
1328 use crate::spec::{Element, Spec};
1329 use std::collections::HashMap;
1330 let mut elements = HashMap::new();
1331 elements.insert(
1332 "r".to_string(),
1333 Element {
1334 type_name: type_name.to_string(),
1335 props,
1336 children: Vec::new(),
1337 action: None,
1338 visible: None,
1339 each: None,
1340 if_: None,
1341 },
1342 );
1343 Spec {
1344 schema: crate::spec::SCHEMA_VERSION.to_string(),
1345 root: "r".to_string(),
1346 elements,
1347 title: None,
1348 layout: None,
1349 data: Value::Null,
1350 }
1351 }
1352
1353 #[test]
1354 fn validate_positive_per_type() {
1355 let cat = Catalog::build_builtins_only().expect("build");
1358 let cases: Vec<(&str, Value)> = vec![
1359 ("Text", serde_json::json!({ "content": "hi" })),
1360 ("Button", serde_json::json!({ "label": "Save" })),
1361 ("Badge", serde_json::json!({ "label": "New" })),
1362 ("Separator", serde_json::json!({})),
1363 ];
1364 for (ty, props) in cases {
1365 let spec = test_spec_with(ty, props.clone());
1366 match cat.validate(&spec) {
1367 Ok(()) => {}
1368 Err(errs) => panic!("validate({ty}) failed: {errs:?}"),
1369 }
1370 }
1371 }
1372
1373 #[test]
1374 fn validate_unknown_type() {
1375 let cat = Catalog::build_builtins_only().expect("build");
1376 let spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1377 let errs = cat.validate(&spec).expect_err("should fail");
1378 assert!(
1379 errs.iter().any(|e| matches!(
1380 e,
1381 CatalogError::UnknownType { type_name, .. } if type_name == "NotARealComponent"
1382 )),
1383 "expected UnknownType for NotARealComponent; got {errs:?}"
1384 );
1385 }
1386
1387 #[test]
1388 fn validate_missing_required_prop() {
1389 let cat = Catalog::build_builtins_only().expect("build");
1392 let spec = test_spec_with("Card", serde_json::json!({}));
1393 let errs = cat.validate(&spec).expect_err("should fail");
1394 assert!(
1395 errs.iter().any(|e| matches!(
1396 e,
1397 CatalogError::PropsInvalid { type_name, .. } if type_name == "Card"
1398 )),
1399 "expected PropsInvalid for missing required 'title'; got {errs:?}"
1400 );
1401 }
1402
1403 #[test]
1404 fn validate_bad_schema_version() {
1405 let cat = Catalog::build_builtins_only().expect("build");
1406 let mut spec = test_spec_with("Text", serde_json::json!({ "content": "hi" }));
1407 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1408 let errs = cat.validate(&spec).expect_err("should fail");
1409 assert!(
1410 errs.iter()
1411 .any(|e| matches!(e, CatalogError::SpecInvalid { .. })),
1412 "expected SpecInvalid for wrong $schema version; got {errs:?}"
1413 );
1414 }
1415
1416 #[test]
1417 fn validate_pre_dispatch_short_circuits() {
1418 let cat = Catalog::build_builtins_only().expect("build");
1421 let mut spec = test_spec_with("NotARealComponent", serde_json::json!({}));
1422 spec.schema = "ferro-json-ui/v99-wrong".to_string();
1423 let errs = cat.validate(&spec).expect_err("should fail");
1424
1425 let has_unknown = errs
1426 .iter()
1427 .any(|e| matches!(e, CatalogError::UnknownType { .. }));
1428 let has_spec_invalid = errs
1429 .iter()
1430 .any(|e| matches!(e, CatalogError::SpecInvalid { .. }));
1431 let has_props_invalid = errs
1432 .iter()
1433 .any(|e| matches!(e, CatalogError::PropsInvalid { .. }));
1434
1435 assert!(has_unknown, "expected UnknownType");
1436 assert!(
1437 !has_spec_invalid,
1438 "Stage 3 ran despite Stage 1 failing: {errs:?}"
1439 );
1440 assert!(
1441 !has_props_invalid,
1442 "Stage 2 ran despite Stage 1 failing: {errs:?}"
1443 );
1444 }
1445
1446 #[test]
1447 fn validator_is_cached_not_recompiled() {
1448 let cat = Catalog::build_builtins_only().expect("build");
1452 for _ in 0..100 {
1453 let spec = test_spec_with("Text", serde_json::json!({ "content": "x" }));
1454 assert!(cat.validate(&spec).is_ok());
1455 }
1456 }
1457
1458 #[test]
1459 fn validate_accumulates_multiple_errors_across_elements() {
1460 use crate::spec::{Element, Spec};
1462 use std::collections::HashMap;
1463 let cat = Catalog::build_builtins_only().expect("build");
1464 let mut elements = HashMap::new();
1465 elements.insert(
1466 "a".to_string(),
1467 Element {
1468 type_name: "Card".to_string(),
1469 props: serde_json::json!({}), children: Vec::new(),
1471 action: None,
1472 visible: None,
1473 each: None,
1474 if_: None,
1475 },
1476 );
1477 elements.insert(
1478 "b".to_string(),
1479 Element {
1480 type_name: "Button".to_string(),
1481 props: serde_json::json!({}), children: Vec::new(),
1483 action: None,
1484 visible: None,
1485 each: None,
1486 if_: None,
1487 },
1488 );
1489 let spec = Spec {
1490 schema: crate::spec::SCHEMA_VERSION.to_string(),
1491 root: "a".to_string(),
1492 elements,
1493 title: None,
1494 layout: None,
1495 data: Value::Null,
1496 };
1497 let errs = cat.validate(&spec).expect_err("should fail");
1498 let props_invalid_count = errs
1499 .iter()
1500 .filter(|e| matches!(e, CatalogError::PropsInvalid { .. }))
1501 .count();
1502 assert!(
1503 props_invalid_count >= 2,
1504 "expected at least 2 PropsInvalid errors; got {errs:?}"
1505 );
1506 }
1507
1508 #[test]
1514 fn build_discovers_plugins_and_rejects_invalid_schema() {
1515 use crate::plugin::{register_plugin, Asset, JsonUiPlugin};
1516
1517 struct GoodPlugin;
1518 impl JsonUiPlugin for GoodPlugin {
1519 fn component_type(&self) -> &str {
1520 "GoodPlugin_117"
1521 }
1522 fn props_schema(&self) -> Value {
1523 serde_json::json!({ "type": "object" })
1524 }
1525 fn render(&self, _: &Value, _: &Value) -> String {
1526 String::new()
1527 }
1528 fn css_assets(&self) -> Vec<Asset> {
1529 vec![]
1530 }
1531 fn js_assets(&self) -> Vec<Asset> {
1532 vec![]
1533 }
1534 fn init_script(&self) -> Option<String> {
1535 None
1536 }
1537 }
1538
1539 register_plugin(GoodPlugin);
1540
1541 let cat = Catalog::build().expect("build succeeds with valid plugin only");
1543 assert!(
1544 cat.plugin_components.contains_key("GoodPlugin_117"),
1545 "plugin 'GoodPlugin_117' should have been discovered"
1546 );
1547 assert!(cat.plugin_components["GoodPlugin_117"].is_plugin);
1548
1549 struct BadPlugin;
1551 impl JsonUiPlugin for BadPlugin {
1552 fn component_type(&self) -> &str {
1553 "BadPlugin_117"
1554 }
1555 fn props_schema(&self) -> Value {
1556 serde_json::json!({ "type": 42 })
1559 }
1560 fn render(&self, _: &Value, _: &Value) -> String {
1561 String::new()
1562 }
1563 fn css_assets(&self) -> Vec<Asset> {
1564 vec![]
1565 }
1566 fn js_assets(&self) -> Vec<Asset> {
1567 vec![]
1568 }
1569 fn init_script(&self) -> Option<String> {
1570 None
1571 }
1572 }
1573
1574 register_plugin(BadPlugin);
1575 match Catalog::build() {
1576 Err(CatalogError::BuildFailed(msg)) => {
1577 assert!(
1578 msg.contains("BadPlugin_117"),
1579 "error should mention plugin name, got: {msg}"
1580 );
1581 }
1582 Err(other) => panic!("expected BuildFailed mentioning BadPlugin_117, got: {other:?}"),
1583 Ok(_) => panic!("expected build to fail due to invalid plugin schema"),
1584 }
1585 }
1586
1587 #[test]
1590 fn component_schema_returns_props_only() {
1591 let cat = Catalog::build_builtins_only().expect("build");
1595 let schema = cat
1596 .component_schema("Card")
1597 .expect("Card is a built-in component");
1598
1599 let obj = schema
1603 .as_object()
1604 .expect("Card props schema is a JSON object");
1605
1606 assert!(
1608 obj.contains_key("type") || obj.contains_key("oneOf") || obj.contains_key("anyOf"),
1609 "CardProps schema should be a structural object schema; got {obj:?}"
1610 );
1611
1612 if let Some(props) = obj.get("properties").and_then(|v| v.as_object()) {
1614 assert!(
1615 props.contains_key("title"),
1616 "CardProps schema.properties should include 'title'; got keys: {:?}",
1617 props.keys().collect::<Vec<_>>()
1618 );
1619 } else {
1620 panic!(
1621 "CardProps schema missing top-level 'properties' map — \
1622 sanitizer or Plan 02 may be wrong. Got: {}",
1623 serde_json::to_string_pretty(schema).unwrap_or_default()
1624 );
1625 }
1626
1627 let is_element_wrapper = obj
1630 .get("properties")
1631 .and_then(|v| v.as_object())
1632 .map(|p| p.contains_key("children") && p.contains_key("props"))
1633 .unwrap_or(false);
1634 assert!(
1635 !is_element_wrapper,
1636 "component_schema('Card') returned an Element wrapper; must be Props-only (CONTEXT D-19)"
1637 );
1638 }
1639
1640 #[test]
1641 fn component_schema_none_for_unknown() {
1642 let cat = Catalog::build_builtins_only().expect("build");
1643 assert!(
1644 cat.component_schema("NotARealComponent_117_05").is_none(),
1645 "unknown component must return None"
1646 );
1647 assert!(cat.component_schema("").is_none());
1649 }
1650
1651 #[test]
1652 fn component_schema_resolves_every_builtin() {
1653 let cat = Catalog::build_builtins_only().expect("build");
1657 for name in crate::render::BUILTIN_TYPES.iter() {
1658 assert!(
1659 cat.component_schema(name).is_some(),
1660 "built-in '{name}' has no per-component schema"
1661 );
1662 }
1663 }
1664
1665 #[test]
1666 fn components_sorted_yields_ascending_by_name() {
1667 let cat = Catalog::build_builtins_only().expect("build");
1668 let names: Vec<String> = cat
1669 .components_sorted()
1670 .map(|spec| spec.name.clone())
1671 .collect();
1672 assert_eq!(names.len(), crate::render::BUILTIN_TYPES.len());
1673 let mut sorted = names.clone();
1674 sorted.sort();
1675 assert_eq!(
1676 names, sorted,
1677 "components_sorted must yield ascending order"
1678 );
1679
1680 let plugin_names: Vec<String> = cat
1682 .plugin_components_sorted()
1683 .map(|spec| spec.name.clone())
1684 .collect();
1685 let mut plugin_sorted = plugin_names.clone();
1686 plugin_sorted.sort();
1687 assert_eq!(
1688 plugin_names, plugin_sorted,
1689 "plugin_components_sorted must yield ascending order"
1690 );
1691 }
1692
1693 #[test]
1696 fn prompt_under_size_budget() {
1697 let cat = Catalog::build_builtins_only().expect("build");
1698 let prompt = cat.prompt();
1699 let bytes = prompt.len();
1700 assert!(
1703 bytes <= 10 * 1024,
1704 "prompt() is {bytes} bytes, exceeds 10 KB budget (CONTEXT D-17)"
1705 );
1706 }
1707
1708 #[test]
1709 fn prompt_mentions_every_builtin() {
1710 let cat = Catalog::build_builtins_only().expect("build");
1711 let prompt = cat.prompt();
1712 for name in crate::render::BUILTIN_TYPES.iter() {
1713 let heading = format!("### {name}\n");
1714 assert!(
1715 prompt.contains(&heading),
1716 "prompt() missing section heading for '{name}'"
1717 );
1718 }
1719 }
1720
1721 #[test]
1722 fn prompt_is_deterministic() {
1723 let cat1 = Catalog::build_builtins_only().expect("build 1");
1724 let cat2 = Catalog::build_builtins_only().expect("build 2");
1725 assert_eq!(
1726 cat1.prompt(),
1727 cat2.prompt(),
1728 "prompt() must be deterministic"
1729 );
1730 }
1731
1732 #[test]
1733 fn prompt_documents_slot_fields() {
1734 let cat = Catalog::build_builtins_only().expect("build");
1737 let prompt = cat.prompt();
1738 let card_start = prompt.find("### Card\n").expect("Card section present");
1739 let card_slice = &prompt[card_start..];
1740 let end = card_slice[3..]
1742 .find("### ")
1743 .map(|i| i + 3)
1744 .unwrap_or(card_slice.len());
1745 let card_section = &card_slice[..end];
1746 assert!(
1747 card_section.contains("Slots: footer"),
1748 "Card section missing 'Slots: footer' line:\n{card_section}"
1749 );
1750 }
1751
1752 #[test]
1753 fn prompt_is_not_raw_json_schema() {
1754 let cat = Catalog::build_builtins_only().expect("build");
1755 let prompt = cat.prompt();
1756 assert!(
1757 prompt.starts_with("## Component Catalog"),
1758 "prompt() should start with Markdown header, not JSON"
1759 );
1760 assert!(
1761 !prompt.contains("\"$schema\""),
1762 "prompt() must not embed raw JSON Schema (ROADMAP caveat)"
1763 );
1764 }
1765
1766 #[test]
1767 fn catalog_contains_checkbox_group() {
1768 let cat = Catalog::build_builtins_only().expect("build");
1769 assert!(
1770 cat.component_schema("CheckboxGroup").is_some(),
1771 "CheckboxGroup must be registered in BUILTIN_SPECS as an alias for CheckboxList"
1772 );
1773 }
1774}