use std::collections::HashSet;
use crate::{
built_in_recipe_kinds, built_in_token_presets, sanitize_str, sanitize_title, AppRecipeKind,
ComponentRole, ComponentSize, DesignTokenSet, PlatformStyle, WidgetControlKind, WidgetState,
WidgetVariant,
};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
pub const WIDGET_CATALOG_MAX_WIDGETS: usize = 256;
pub const WIDGET_DESCRIPTOR_MAX_EXAMPLES: usize = 16;
pub const WIDGET_CATALOG_QUERY_MAX_RESULTS: usize = 128;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetCategory {
Action,
Input,
Selection,
Navigation,
Feedback,
DataDisplay,
Layout,
Overlay,
Ai,
Code,
ProAudio,
Media,
Shell,
}
impl WidgetCategory {
pub fn label(self) -> &'static str {
match self {
Self::Action => "action",
Self::Input => "input",
Self::Selection => "selection",
Self::Navigation => "navigation",
Self::Feedback => "feedback",
Self::DataDisplay => "data_display",
Self::Layout => "layout",
Self::Overlay => "overlay",
Self::Ai => "ai",
Self::Code => "code",
Self::ProAudio => "pro_audio",
Self::Media => "media",
Self::Shell => "shell",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetComplexity {
Primitive,
Stateful,
Composite,
Surface,
Workflow,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
pub enum WidgetUseCase {
GeneralDesktop,
AiWorkspace,
DeveloperTools,
AudioProduction,
Dashboard,
DocumentEditing,
BrowserShell,
CreativeTools,
}
impl WidgetUseCase {
pub fn label(self) -> &'static str {
match self {
Self::GeneralDesktop => "general_desktop",
Self::AiWorkspace => "ai_workspace",
Self::DeveloperTools => "developer_tools",
Self::AudioProduction => "audio_production",
Self::Dashboard => "dashboard",
Self::DocumentEditing => "document_editing",
Self::BrowserShell => "browser_shell",
Self::CreativeTools => "creative_tools",
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetStateSet {
pub base: bool,
pub hovered: bool,
pub pressed: bool,
pub focused: bool,
pub disabled: bool,
pub selected: bool,
pub checked: bool,
pub loading: bool,
pub error: bool,
}
impl WidgetStateSet {
pub const fn passive() -> Self {
Self {
base: true,
hovered: false,
pressed: false,
focused: false,
disabled: false,
selected: false,
checked: false,
loading: false,
error: false,
}
}
pub const fn interactive() -> Self {
Self {
base: true,
hovered: true,
pressed: true,
focused: true,
disabled: true,
selected: false,
checked: false,
loading: false,
error: false,
}
}
pub const fn selectable() -> Self {
Self {
selected: true,
checked: true,
..Self::interactive()
}
}
pub const fn editable() -> Self {
Self {
error: true,
..Self::interactive()
}
}
pub const fn async_surface() -> Self {
Self {
loading: true,
error: true,
selected: true,
..Self::interactive()
}
}
pub fn state_count(self) -> usize {
[
self.base,
self.hovered,
self.pressed,
self.focused,
self.disabled,
self.selected,
self.checked,
self.loading,
self.error,
]
.into_iter()
.filter(|enabled| *enabled)
.count()
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetExample {
pub id: String,
pub title: String,
pub description: String,
pub state: WidgetState,
pub variant: WidgetVariant,
pub size: ComponentSize,
pub role: ComponentRole,
}
impl WidgetExample {
pub fn new(
id: impl Into<String>,
title: impl Into<String>,
description: impl Into<String>,
) -> Self {
Self {
id: sanitize_catalog_id(&id.into(), "example"),
title: sanitize_title(&title.into(), 96),
description: sanitize_title(&description.into(), 220),
state: WidgetState::new(),
variant: WidgetVariant::Filled,
size: ComponentSize::Regular,
role: ComponentRole::Primary,
}
}
pub fn with_state(mut self, state: WidgetState) -> Self {
self.state = state;
self
}
pub fn with_variant(mut self, variant: WidgetVariant) -> Self {
self.variant = variant;
self
}
pub fn with_size(mut self, size: ComponentSize) -> Self {
self.size = size;
self
}
pub fn with_role(mut self, role: ComponentRole) -> Self {
self.role = role;
self
}
pub fn sanitized(mut self) -> Self {
self.id = sanitize_catalog_id(&self.id, "example");
self.title = sanitize_title(&self.title, 96);
self.description = sanitize_title(&self.description, 220);
self
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetDescriptor {
pub id: String,
pub name: String,
pub category: WidgetCategory,
pub summary: String,
pub control_kind: Option<WidgetControlKind>,
pub complexity: WidgetComplexity,
pub variants: Vec<WidgetVariant>,
pub states: WidgetStateSet,
pub sizes: Vec<ComponentSize>,
pub roles: Vec<ComponentRole>,
pub use_cases: Vec<WidgetUseCase>,
pub examples: Vec<WidgetExample>,
pub token_refs: Vec<String>,
pub renderer_notes: String,
}
impl WidgetDescriptor {
pub fn supports_use_case(&self, use_case: WidgetUseCase) -> bool {
self.use_cases.contains(&use_case)
}
pub fn matches_query_text(&self, text: &str) -> bool {
let text = sanitize_query_text(text);
if text.is_empty() {
return true;
}
let haystack = self.search_text();
text.split_whitespace()
.all(|token| haystack.contains(token))
}
pub fn sanitized(mut self) -> Self {
self.id = sanitize_catalog_id(&self.id, "widget");
self.name = sanitize_title(&self.name, 96);
self.summary = sanitize_title(&self.summary, 260);
self.examples.truncate(WIDGET_DESCRIPTOR_MAX_EXAMPLES);
self.examples
.iter_mut()
.for_each(|example| *example = example.clone().sanitized());
self.token_refs = self
.token_refs
.into_iter()
.map(|token| sanitize_catalog_id(&token, "token"))
.filter(|token| !token.is_empty())
.collect();
self.renderer_notes = sanitize_title(&self.renderer_notes, 260);
if self.variants.is_empty() {
self.variants.push(WidgetVariant::Filled);
}
if self.sizes.is_empty() {
self.sizes.push(ComponentSize::Regular);
}
if self.roles.is_empty() {
self.roles.push(ComponentRole::Neutral);
}
self
}
fn search_text(&self) -> String {
let mut text = format!(
"{} {} {} {} {:?}",
self.id,
self.name,
self.summary,
self.category.label(),
self.control_kind
)
.to_ascii_lowercase();
for use_case in &self.use_cases {
text.push(' ');
text.push_str(use_case.label());
}
for example in &self.examples {
text.push(' ');
text.push_str(&example.title.to_ascii_lowercase());
text.push(' ');
text.push_str(&example.description.to_ascii_lowercase());
}
text
}
}
#[derive(Clone, Debug, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetCatalogQuery {
pub text: String,
pub category: Option<WidgetCategory>,
pub use_case: Option<WidgetUseCase>,
pub complexity: Option<WidgetComplexity>,
pub control_kind: Option<WidgetControlKind>,
pub role: Option<ComponentRole>,
pub variant: Option<WidgetVariant>,
pub limit: usize,
}
impl WidgetCatalogQuery {
pub fn new() -> Self {
Self {
limit: WIDGET_CATALOG_QUERY_MAX_RESULTS,
..Self::default()
}
}
pub fn text(mut self, text: impl AsRef<str>) -> Self {
self.text = sanitize_query_text(text.as_ref());
self
}
pub fn category(mut self, category: WidgetCategory) -> Self {
self.category = Some(category);
self
}
pub fn use_case(mut self, use_case: WidgetUseCase) -> Self {
self.use_case = Some(use_case);
self
}
pub fn complexity(mut self, complexity: WidgetComplexity) -> Self {
self.complexity = Some(complexity);
self
}
pub fn control_kind(mut self, control_kind: WidgetControlKind) -> Self {
self.control_kind = Some(control_kind);
self
}
pub fn role(mut self, role: ComponentRole) -> Self {
self.role = Some(role);
self
}
pub fn variant(mut self, variant: WidgetVariant) -> Self {
self.variant = Some(variant);
self
}
pub fn limit(mut self, limit: usize) -> Self {
self.limit = limit.min(WIDGET_CATALOG_QUERY_MAX_RESULTS);
self
}
pub fn sanitized(mut self) -> Self {
self.text = sanitize_query_text(&self.text);
self.limit = if self.limit == 0 {
WIDGET_CATALOG_QUERY_MAX_RESULTS
} else {
self.limit.min(WIDGET_CATALOG_QUERY_MAX_RESULTS)
};
self
}
fn matches(&self, widget: &WidgetDescriptor) -> bool {
self.category
.map_or(true, |category| widget.category == category)
&& self
.use_case
.map_or(true, |use_case| widget.supports_use_case(use_case))
&& self
.complexity
.map_or(true, |complexity| widget.complexity == complexity)
&& self.control_kind.map_or(true, |control_kind| {
widget.control_kind == Some(control_kind)
})
&& self.role.map_or(true, |role| widget.roles.contains(&role))
&& self
.variant
.map_or(true, |variant| widget.variants.contains(&variant))
&& widget.matches_query_text(&self.text)
}
}
#[derive(Clone, Debug, PartialEq)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct WidgetCatalog {
pub name: String,
pub version: String,
pub token_presets: Vec<DesignTokenSet>,
pub platform_styles: Vec<PlatformStyle>,
pub recipe_kinds: Vec<AppRecipeKind>,
pub widgets: Vec<WidgetDescriptor>,
}
impl WidgetCatalog {
pub fn find(&self, id: &str) -> Option<&WidgetDescriptor> {
let id = sanitize_catalog_id(id, "widget");
self.widgets.iter().find(|widget| widget.id == id)
}
pub fn by_category(&self, category: WidgetCategory) -> Vec<&WidgetDescriptor> {
self.widgets
.iter()
.filter(|widget| widget.category == category)
.collect()
}
pub fn by_use_case(&self, use_case: WidgetUseCase) -> Vec<&WidgetDescriptor> {
self.widgets
.iter()
.filter(|widget| widget.supports_use_case(use_case))
.collect()
}
pub fn query(&self, query: WidgetCatalogQuery) -> Vec<&WidgetDescriptor> {
let query = query.sanitized();
self.widgets
.iter()
.filter(|widget| query.matches(widget))
.take(query.limit)
.collect()
}
pub fn query_ids(&self, query: WidgetCatalogQuery) -> Vec<&str> {
self.query(query)
.into_iter()
.map(|widget| widget.id.as_str())
.collect()
}
pub fn token_preset(&self, id: &str) -> Option<&DesignTokenSet> {
let id = sanitize_catalog_id(id, "token");
self.token_presets.iter().find(|preset| preset.id == id)
}
pub fn validate(&self) -> Result<(), CatalogValidationError> {
if self.widgets.is_empty() {
return Err(CatalogValidationError::new("catalog has no widgets"));
}
if self.token_presets.is_empty() {
return Err(CatalogValidationError::new("catalog has no token presets"));
}
if self.recipe_kinds.is_empty() {
return Err(CatalogValidationError::new("catalog has no layout recipes"));
}
let mut widget_ids = HashSet::new();
for widget in &self.widgets {
if widget.id.is_empty() {
return Err(CatalogValidationError::new("widget has empty id"));
}
if !widget_ids.insert(widget.id.as_str()) {
return Err(CatalogValidationError::new(format!(
"duplicate widget id {}",
widget.id
)));
}
if widget.name.is_empty() || widget.summary.is_empty() {
return Err(CatalogValidationError::new(format!(
"widget {} has empty display text",
widget.id
)));
}
if widget.examples.is_empty() {
return Err(CatalogValidationError::new(format!(
"widget {} has no examples",
widget.id
)));
}
if !widget.states.base {
return Err(CatalogValidationError::new(format!(
"widget {} does not include a base state",
widget.id
)));
}
}
let mut token_ids = HashSet::new();
for preset in &self.token_presets {
if !token_ids.insert(preset.id.as_str()) {
return Err(CatalogValidationError::new(format!(
"duplicate token id {}",
preset.id
)));
}
}
Ok(())
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct CatalogValidationError {
pub message: String,
}
impl CatalogValidationError {
pub fn new(message: impl Into<String>) -> Self {
Self {
message: sanitize_title(&message.into(), 240),
}
}
}
impl std::fmt::Display for CatalogValidationError {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter.write_str(&self.message)
}
}
impl std::error::Error for CatalogValidationError {}
pub fn built_in_widget_catalog() -> WidgetCatalog {
let catalog = WidgetCatalog {
name: "rae professional desktop widget catalog".to_string(),
version: env!("CARGO_PKG_VERSION").to_string(),
token_presets: built_in_token_presets(),
platform_styles: vec![
PlatformStyle::CrossPlatform,
PlatformStyle::Macos,
PlatformStyle::Windows,
PlatformStyle::Linux,
PlatformStyle::Web,
],
recipe_kinds: built_in_recipe_kinds(),
widgets: widget_descriptors()
.into_iter()
.take(WIDGET_CATALOG_MAX_WIDGETS)
.map(WidgetDescriptor::sanitized)
.collect(),
};
debug_assert!(catalog.validate().is_ok());
catalog
}
fn widget_descriptors() -> Vec<WidgetDescriptor> {
vec![
descriptor(
"button",
"Button",
WidgetCategory::Action,
"Primary command surface for committing user intent.",
Some(WidgetControlKind::Button),
WidgetComplexity::Primitive,
WidgetStateSet::interactive(),
&[WidgetUseCase::GeneralDesktop],
),
descriptor(
"icon_button",
"Icon Button",
WidgetCategory::Action,
"Compact glyph-first command with tooltip and focus-ring states.",
Some(WidgetControlKind::IconButton),
WidgetComplexity::Primitive,
WidgetStateSet::interactive(),
&[WidgetUseCase::GeneralDesktop, WidgetUseCase::CreativeTools],
),
descriptor(
"toggle",
"Toggle",
WidgetCategory::Selection,
"Binary switch for persisted or immediate settings.",
Some(WidgetControlKind::Toggle),
WidgetComplexity::Stateful,
WidgetStateSet::selectable(),
&[
WidgetUseCase::GeneralDesktop,
WidgetUseCase::AudioProduction,
],
),
descriptor(
"checkbox",
"Checkbox",
WidgetCategory::Selection,
"Checkable row control with checked, focused, disabled, and error variants.",
Some(WidgetControlKind::Checkbox),
WidgetComplexity::Stateful,
WidgetStateSet::selectable(),
&[
WidgetUseCase::GeneralDesktop,
WidgetUseCase::DocumentEditing,
],
),
descriptor(
"text_input",
"Text Input",
WidgetCategory::Input,
"Editable single-line field backed by sanitized prompt/text behavior.",
Some(WidgetControlKind::TextInput),
WidgetComplexity::Stateful,
WidgetStateSet::editable(),
&[WidgetUseCase::GeneralDesktop, WidgetUseCase::DeveloperTools],
),
descriptor(
"slider",
"Slider",
WidgetCategory::Input,
"Normalized scalar input for levels, intensity, position, or thresholds.",
Some(WidgetControlKind::Slider),
WidgetComplexity::Stateful,
WidgetStateSet::interactive(),
&[
WidgetUseCase::GeneralDesktop,
WidgetUseCase::AudioProduction,
WidgetUseCase::CreativeTools,
],
),
descriptor(
"select",
"Select",
WidgetCategory::Selection,
"Choice-list trigger with capped sanitized options and disabled-item skipping.",
Some(WidgetControlKind::Select),
WidgetComplexity::Stateful,
WidgetStateSet::interactive(),
&[
WidgetUseCase::GeneralDesktop,
WidgetUseCase::DocumentEditing,
],
),
descriptor(
"list_item",
"List Item",
WidgetCategory::Selection,
"Selectable collection row for menus, trees, palettes, and result lists.",
Some(WidgetControlKind::ListItem),
WidgetComplexity::Stateful,
WidgetStateSet::selectable(),
&[WidgetUseCase::GeneralDesktop, WidgetUseCase::DeveloperTools],
),
descriptor(
"label",
"Label",
WidgetCategory::DataDisplay,
"Text label with muted, strong, helper, and diagnostic roles.",
Some(WidgetControlKind::Label),
WidgetComplexity::Primitive,
WidgetStateSet::passive(),
&[WidgetUseCase::GeneralDesktop],
),
descriptor(
"divider",
"Divider",
WidgetCategory::Layout,
"Hairline or semantic separator with token-driven spacing.",
Some(WidgetControlKind::Divider),
WidgetComplexity::Primitive,
WidgetStateSet::passive(),
&[WidgetUseCase::GeneralDesktop],
),
descriptor(
"card",
"Card",
WidgetCategory::Layout,
"Container surface with filled, tonal, outline, ghost, or glass material.",
Some(WidgetControlKind::Card),
WidgetComplexity::Surface,
WidgetStateSet::interactive(),
&[WidgetUseCase::GeneralDesktop, WidgetUseCase::Dashboard],
),
descriptor(
"tab_bar",
"Tab Bar",
WidgetCategory::Navigation,
"Keyboard-friendly tab model with capped entries and disabled-tab skipping.",
None,
WidgetComplexity::Composite,
WidgetStateSet::selectable(),
&[WidgetUseCase::GeneralDesktop, WidgetUseCase::BrowserShell],
),
descriptor(
"scroll_area",
"Scroll Area",
WidgetCategory::Layout,
"Viewport model with visible ranges, scroll anchoring, and scrollbar geometry.",
None,
WidgetComplexity::Composite,
WidgetStateSet::interactive(),
&[WidgetUseCase::GeneralDesktop, WidgetUseCase::DeveloperTools],
),
descriptor(
"pane",
"Pane",
WidgetCategory::Shell,
"Desktop pane chrome for focused, split, grid, and stacked work surfaces.",
None,
WidgetComplexity::Surface,
WidgetStateSet::interactive(),
&[WidgetUseCase::GeneralDesktop, WidgetUseCase::DeveloperTools],
),
descriptor(
"overlay",
"Overlay",
WidgetCategory::Overlay,
"Z-ordered popover, palette, menu, tooltip, or modal layer model.",
None,
WidgetComplexity::Surface,
WidgetStateSet::async_surface(),
&[WidgetUseCase::GeneralDesktop],
),
descriptor(
"prompt",
"Prompt",
WidgetCategory::Ai,
"AI/developer composer model with sanitized cursor and selection behavior.",
None,
WidgetComplexity::Stateful,
WidgetStateSet::editable(),
&[WidgetUseCase::AiWorkspace, WidgetUseCase::DeveloperTools],
),
descriptor(
"transcript",
"Transcript",
WidgetCategory::Ai,
"Role-aware conversation output rows with bounded viewport materialization.",
None,
WidgetComplexity::Composite,
WidgetStateSet::async_surface(),
&[WidgetUseCase::AiWorkspace],
),
descriptor(
"command_palette",
"Command Palette",
WidgetCategory::Overlay,
"Searchable action surface with bounded matching and keyboard selection.",
None,
WidgetComplexity::Composite,
WidgetStateSet::interactive(),
&[WidgetUseCase::GeneralDesktop, WidgetUseCase::DeveloperTools],
),
descriptor(
"code_block",
"Code Block",
WidgetCategory::Code,
"Sanitized fenced-code renderer model with token and summary analysis.",
None,
WidgetComplexity::Composite,
WidgetStateSet::passive(),
&[WidgetUseCase::DeveloperTools, WidgetUseCase::AiWorkspace],
),
descriptor(
"code_editor_shell",
"Code Editor Shell",
WidgetCategory::Code,
"Recipe-level editor pane with files, diagnostics, output, and assistant surfaces.",
None,
WidgetComplexity::Workflow,
WidgetStateSet::async_surface(),
&[WidgetUseCase::DeveloperTools],
),
descriptor(
"metric_card",
"Metric Card",
WidgetCategory::DataDisplay,
"Dashboard KPI surface with trend, status, and semantic role coloring.",
None,
WidgetComplexity::Composite,
WidgetStateSet::passive(),
&[WidgetUseCase::Dashboard],
),
descriptor(
"data_table",
"Data Table",
WidgetCategory::DataDisplay,
"Dense desktop table metadata for rows, columns, selection, and scroll regions.",
None,
WidgetComplexity::Composite,
WidgetStateSet::selectable(),
&[WidgetUseCase::Dashboard, WidgetUseCase::GeneralDesktop],
),
descriptor(
"chart_panel",
"Chart Panel",
WidgetCategory::DataDisplay,
"Token-aware chart container for retained or immediate renderer plots.",
None,
WidgetComplexity::Surface,
WidgetStateSet::passive(),
&[WidgetUseCase::Dashboard],
),
descriptor(
"audio_meter",
"Audio Meter",
WidgetCategory::ProAudio,
"Peak, RMS, or loudness meter model with standard green/yellow/red segments.",
None,
WidgetComplexity::Stateful,
WidgetStateSet::passive(),
&[WidgetUseCase::AudioProduction],
),
descriptor(
"audio_fader",
"Audio Fader",
WidgetCategory::ProAudio,
"Decibel fader model with min/max/unity values and mute/solo/arm state.",
None,
WidgetComplexity::Stateful,
WidgetStateSet::interactive(),
&[WidgetUseCase::AudioProduction],
),
descriptor(
"audio_knob",
"Audio Knob",
WidgetCategory::ProAudio,
"Continuous or bipolar rotary control for gain, pan, frequency, and tone.",
None,
WidgetComplexity::Stateful,
WidgetStateSet::interactive(),
&[WidgetUseCase::AudioProduction],
),
descriptor(
"audio_waveform",
"Audio Waveform",
WidgetCategory::ProAudio,
"Capped sample waveform model for clips, previews, loopers, and editors.",
None,
WidgetComplexity::Composite,
WidgetStateSet::passive(),
&[WidgetUseCase::AudioProduction, WidgetUseCase::CreativeTools],
),
descriptor(
"audio_spectrum",
"Audio Spectrum",
WidgetCategory::ProAudio,
"Bounded spectral-band data for analyzers and EQ visualizations.",
None,
WidgetComplexity::Composite,
WidgetStateSet::passive(),
&[WidgetUseCase::AudioProduction],
),
descriptor(
"audio_transport",
"Audio Transport",
WidgetCategory::ProAudio,
"Playback/record/loop transport state with BPM, sample-rate, and timeline data.",
None,
WidgetComplexity::Composite,
WidgetStateSet::interactive(),
&[WidgetUseCase::AudioProduction],
),
descriptor(
"audio_plugin_panel",
"Audio Plugin Panel",
WidgetCategory::ProAudio,
"Plugin macro-control surface with bypass, latency, and automation metadata.",
None,
WidgetComplexity::Surface,
WidgetStateSet::interactive(),
&[WidgetUseCase::AudioProduction],
),
descriptor(
"audio_mixer_strip",
"Audio Mixer Strip",
WidgetCategory::ProAudio,
"Channel strip model composed from meter, fader, pan, and plugin controls.",
None,
WidgetComplexity::Composite,
WidgetStateSet::interactive(),
&[WidgetUseCase::AudioProduction],
),
descriptor(
"media_preview",
"Media Preview",
WidgetCategory::Media,
"Preview surface for images, video frames, waveforms, or shader thumbnails.",
None,
WidgetComplexity::Surface,
WidgetStateSet::async_surface(),
&[WidgetUseCase::CreativeTools],
),
descriptor(
"app_shell",
"App Shell",
WidgetCategory::Shell,
"High-level shell surface coordinated with reusable layout recipes.",
None,
WidgetComplexity::Workflow,
WidgetStateSet::interactive(),
&[WidgetUseCase::GeneralDesktop, WidgetUseCase::AiWorkspace],
),
]
}
#[allow(clippy::too_many_arguments)]
fn descriptor(
id: &str,
name: &str,
category: WidgetCategory,
summary: &str,
control_kind: Option<WidgetControlKind>,
complexity: WidgetComplexity,
states: WidgetStateSet,
use_cases: &[WidgetUseCase],
) -> WidgetDescriptor {
let variants = match category {
WidgetCategory::Layout | WidgetCategory::Shell | WidgetCategory::Overlay => {
vec![
WidgetVariant::Filled,
WidgetVariant::Tonal,
WidgetVariant::Glass,
]
}
WidgetCategory::ProAudio => vec![
WidgetVariant::Tonal,
WidgetVariant::Outline,
WidgetVariant::Glass,
],
WidgetCategory::DataDisplay | WidgetCategory::Code | WidgetCategory::Ai => {
vec![
WidgetVariant::Tonal,
WidgetVariant::Outline,
WidgetVariant::Ghost,
]
}
_ => vec![
WidgetVariant::Filled,
WidgetVariant::Tonal,
WidgetVariant::Outline,
WidgetVariant::Ghost,
],
};
let roles = match category {
WidgetCategory::ProAudio => vec![
ComponentRole::Neutral,
ComponentRole::Success,
ComponentRole::Warning,
ComponentRole::Danger,
],
WidgetCategory::Feedback => vec![
ComponentRole::Success,
ComponentRole::Warning,
ComponentRole::Danger,
],
_ => vec![
ComponentRole::Neutral,
ComponentRole::Primary,
ComponentRole::Accent,
],
};
WidgetDescriptor {
id: id.to_string(),
name: name.to_string(),
category,
summary: summary.to_string(),
control_kind,
complexity,
variants,
states,
sizes: vec![ComponentSize::Small, ComponentSize::Regular, ComponentSize::Large],
roles,
use_cases: if use_cases.is_empty() {
vec![WidgetUseCase::GeneralDesktop]
} else {
use_cases.to_vec()
},
examples: descriptor_examples(id, states),
token_refs: vec!["colors".to_string(), "spacing".to_string(), "radius".to_string(), "motion".to_string()],
renderer_notes: "Renderer owns drawing, hit dispatch, font shaping, and GPU resources; rae owns reusable model metadata.".to_string(),
}
}
fn descriptor_examples(id: &str, states: WidgetStateSet) -> Vec<WidgetExample> {
let mut examples = vec![WidgetExample::new(
format!("{id}.base"),
"Base",
"Default renderer-neutral model state.",
)];
if states.hovered {
examples.push(
WidgetExample::new(format!("{id}.hover"), "Hover", "Pointer hover affordance.")
.with_state(WidgetState::new().hovered(true))
.with_variant(WidgetVariant::Tonal),
);
}
if states.focused {
examples.push(
WidgetExample::new(format!("{id}.focus"), "Focus", "Keyboard focus-ring state.")
.with_state(WidgetState::new().focused(true))
.with_role(ComponentRole::Info),
);
}
if states.selected || states.checked {
examples.push(
WidgetExample::new(
format!("{id}.selected"),
"Selected",
"Selected or checked state.",
)
.with_state(WidgetState::new().selected(true))
.with_variant(WidgetVariant::Filled)
.with_role(ComponentRole::Accent),
);
}
if states.error {
examples.push(
WidgetExample::new(
format!("{id}.error"),
"Error",
"Validation or failed async state.",
)
.with_state(WidgetState::new().focused(true))
.with_role(ComponentRole::Danger),
);
}
examples
}
fn sanitize_catalog_id(input: &str, fallback: &str) -> String {
let id = sanitize_str(input, 96).trim().replace(' ', "_");
if id.is_empty() {
fallback.to_string()
} else {
id
}
}
fn sanitize_query_text(input: &str) -> String {
sanitize_str(input, 160).trim().to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn built_in_catalog_is_complete_and_valid() {
let catalog = built_in_widget_catalog();
assert!(catalog.validate().is_ok());
assert!(catalog.widgets.len() >= 30);
assert!(catalog.token_presets.len() >= 8);
assert!(catalog.recipe_kinds.len() >= 10);
assert!(catalog.by_category(WidgetCategory::ProAudio).len() >= 8);
assert!(catalog.by_use_case(WidgetUseCase::AudioProduction).len() >= 8);
assert!(catalog.find("prompt").is_some());
assert!(catalog.token_preset("audio_console_dark").is_some());
}
#[test]
fn widget_ids_are_unique_and_descriptors_have_examples() {
let catalog = built_in_widget_catalog();
let mut ids = HashSet::new();
for widget in catalog.widgets {
assert!(ids.insert(widget.id.clone()), "{}", widget.id);
assert!(!widget.examples.is_empty(), "{}", widget.id);
assert!(widget.states.state_count() >= 1, "{}", widget.id);
assert!(!widget.variants.is_empty(), "{}", widget.id);
assert!(!widget.sizes.is_empty(), "{}", widget.id);
assert!(!widget.roles.is_empty(), "{}", widget.id);
}
}
#[test]
fn validation_reports_duplicate_widget_ids() {
let mut catalog = built_in_widget_catalog();
let duplicate = catalog.widgets[0].clone();
catalog.widgets.push(duplicate);
let error = catalog.validate().unwrap_err();
assert!(error.message.contains("duplicate widget id"));
}
#[test]
fn catalog_search_uses_sanitized_ids() {
let catalog = built_in_widget_catalog();
assert_eq!(
catalog
.find("prompt\u{200f}")
.map(|widget| widget.id.as_str()),
Some("prompt")
);
assert_eq!(catalog.by_category(WidgetCategory::Code).len(), 2);
}
#[test]
fn catalog_query_filters_text_and_metadata_with_limits() {
let catalog = built_in_widget_catalog();
let ids = catalog.query_ids(
WidgetCatalogQuery::new()
.text("audio meter")
.use_case(WidgetUseCase::AudioProduction)
.category(WidgetCategory::ProAudio)
.limit(4),
);
assert!(ids.contains(&"audio_meter"));
assert!(ids.len() <= 4);
assert!(ids.iter().all(|id| id.starts_with("audio_")));
}
#[test]
fn catalog_query_sanitizes_public_text_and_zero_limits() {
let catalog = built_in_widget_catalog();
let ids = catalog.query_ids(WidgetCatalogQuery {
text: "Prompt\u{200f}\x1b[31m focus".to_string(),
category: Some(WidgetCategory::Ai),
limit: 0,
..WidgetCatalogQuery::default()
});
assert_eq!(ids, ["prompt"]);
}
#[cfg(feature = "serde")]
#[test]
fn widget_catalog_round_trips_through_serde() {
let catalog = built_in_widget_catalog();
let encoded = serde_json::to_string(&catalog).unwrap();
let decoded: WidgetCatalog = serde_json::from_str(&encoded).unwrap();
assert_eq!(decoded.widgets.len(), catalog.widgets.len());
assert!(decoded.validate().is_ok());
}
#[cfg(feature = "serde")]
#[test]
fn widget_catalog_queries_round_trip_through_serde() {
let query = WidgetCatalogQuery::new()
.text("audio")
.category(WidgetCategory::ProAudio)
.use_case(WidgetUseCase::AudioProduction)
.variant(WidgetVariant::Glass)
.limit(3);
let encoded = serde_json::to_string(&query).unwrap();
let decoded: WidgetCatalogQuery = serde_json::from_str(&encoded).unwrap();
let decoded = decoded.sanitized();
assert_eq!(decoded.limit, 3);
assert_eq!(decoded.category, Some(WidgetCategory::ProAudio));
}
}