diff --git a/compiler/ds-cli/src/main.rs b/compiler/ds-cli/src/main.rs index 661702d..0765a0a 100644 --- a/compiler/ds-cli/src/main.rs +++ b/compiler/ds-cli/src/main.rs @@ -1205,6 +1205,36 @@ const REGISTRY: &[RegistryItem] = &[ source: include_str!("../../../registry/components/toast.ds"), deps: &[], }, + RegistryItem { + name: "progress", + description: "Animated progress bar with percentage", + source: include_str!("../../../registry/components/progress.ds"), + deps: &[], + }, + RegistryItem { + name: "alert", + description: "Alert banner with info/warning/error/success variants", + source: include_str!("../../../registry/components/alert.ds"), + deps: &[], + }, + RegistryItem { + name: "separator", + description: "Visual divider between content sections", + source: include_str!("../../../registry/components/separator.ds"), + deps: &[], + }, + RegistryItem { + name: "toggle", + description: "On/off switch toggle", + source: include_str!("../../../registry/components/toggle.ds"), + deps: &[], + }, + RegistryItem { + name: "avatar", + description: "User avatar with initials fallback", + source: include_str!("../../../registry/components/avatar.ds"), + deps: &[], + }, ]; fn cmd_add(name: Option, list: bool, all: bool) { diff --git a/compiler/ds-codegen/src/js_emitter.rs b/compiler/ds-codegen/src/js_emitter.rs index c810c8c..8a2eb3d 100644 --- a/compiler/ds-codegen/src/js_emitter.rs +++ b/compiler/ds-codegen/src/js_emitter.rs @@ -510,16 +510,63 @@ impl JsEmitter { self.emit_line(&format!("{}.className = '{}';", node_var, class)); } - // Handle container props + // Handle container props (variant, class, events, style, layout) + let container_tag = match &container.kind { + ContainerKind::Column => "column", + ContainerKind::Row => "row", + ContainerKind::Stack => "stack", + ContainerKind::Panel => "panel", + _ => "column", + }; for (key, val) in &container.props { - let js_val = self.emit_expr(val); - if graph.name_to_id.contains_key(&js_val) || self.is_signal_ref(&js_val) { - self.emit_line(&format!( - "DS.effect(() => {{ {}.style.{} = {}.value + 'px'; }});", - node_var, key, js_val - )); - } else { - self.emit_line(&format!("{}.style.{} = {};", node_var, key, js_val)); + match key.as_str() { + "variant" => { + match val { + Expr::StringLit(s) if s.segments.len() == 1 => { + if let Some(ds_parser::StringSegment::Literal(v)) = s.segments.first() { + let css_class = variant_to_css(container_tag, v); + self.emit_line(&format!( + "{}.className += ' {}';", + node_var, css_class + )); + } + } + _ => { + let js = self.emit_expr(val); + let map_entries = variant_map_js(container_tag); + self.emit_line(&format!( + "DS.effect(() => {{ const v = {js}; const cls = ({map_entries})[typeof v === 'object' ? v.value : v] || ''; {node_var}.className = 'ds-{container_tag} ' + cls; }});" + )); + } + } + } + "class" => { + let js = self.emit_expr(val); + self.emit_line(&format!("{}.className += ' ' + {};", node_var, js)); + } + "click" | "submit" => { + let handler_js = self.emit_event_handler_expr(val); + self.emit_line(&format!( + "{}.addEventListener('{}', (e) => {{ {}; DS.flush(); }});", + node_var, key, handler_js + )); + } + "style" => { + let js = self.emit_expr(val); + self.emit_line(&format!("{}.style.cssText = {};", node_var, js)); + } + _ => { + // Layout props (x, y, width, height) or arbitrary style + let js_val = self.emit_expr(val); + if graph.name_to_id.contains_key(&js_val) || self.is_signal_ref(&js_val) { + self.emit_line(&format!( + "DS.effect(() => {{ {}.style.{} = {}.value + 'px'; }});", + node_var, key, js_val + )); + } else { + self.emit_line(&format!("{}.style.{} = {};", node_var, key, js_val)); + } + } } } @@ -656,6 +703,31 @@ impl JsEmitter { node_var, js )); } + "variant" => { + // Map variant prop to CSS class based on element tag + let tag = element.tag.as_str(); + match val { + Expr::StringLit(s) if s.segments.len() == 1 => { + // Static variant: emit class directly + if let Some(ds_parser::StringSegment::Literal(v)) = s.segments.first() { + let css_class = variant_to_css(tag, v); + self.emit_line(&format!( + "{}.className += ' {}';", + node_var, css_class + )); + } + } + _ => { + // Dynamic variant: reactive class via inline lookup + let js = self.emit_expr(val); + let tag = element.tag.as_str(); + let map_entries = variant_map_js(tag); + self.emit_line(&format!( + "DS.effect(() => {{ const v = {js}; const cls = ({map_entries})[typeof v === 'object' ? v.value : v] || ''; {node_var}.className = 'ds-{tag} ' + cls; }});" + )); + } + } + } _ => { let js = self.emit_expr(val); self.emit_line(&format!("{}.setAttribute('{}', {});", node_var, key, js)); @@ -1621,29 +1693,29 @@ body { transform: translateY(0); } /* ── Button Variants ── */ -.ds-btn-primary { +button.ds-btn-primary, .ds-button.ds-btn-primary { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 100%); box-shadow: 0 4px 15px rgba(99, 102, 241, 0.3); } -.ds-btn-primary:hover { box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4); } -.ds-btn-secondary { +button.ds-btn-primary:hover { box-shadow: 0 6px 20px rgba(99, 102, 241, 0.4); } +button.ds-btn-secondary, .ds-button.ds-btn-secondary { background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.15); color: #e2e8f0; box-shadow: none; } -.ds-btn-secondary:hover { background: rgba(255, 255, 255, 0.12); } -.ds-btn-ghost { +button.ds-btn-secondary:hover { background: rgba(255, 255, 255, 0.12); } +button.ds-btn-ghost, .ds-button.ds-btn-ghost { background: transparent; box-shadow: none; color: #a5b4fc; } -.ds-btn-ghost:hover { background: rgba(99, 102, 241, 0.1); } -.ds-btn-destructive { +button.ds-btn-ghost:hover { background: rgba(99, 102, 241, 0.1); } +button.ds-btn-destructive, .ds-button.ds-btn-destructive { background: linear-gradient(135deg, #ef4444 0%, #dc2626 100%); box-shadow: 0 4px 15px rgba(239, 68, 68, 0.3); } -.ds-btn-destructive:hover { box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); } +button.ds-btn-destructive:hover { box-shadow: 0 6px 20px rgba(239, 68, 68, 0.4); } /* ── Card ── */ .ds-card { background: rgba(255, 255, 255, 0.04); @@ -1779,8 +1851,166 @@ body { .ds-fade-in { animation: ds-fade-in 0.3s ease-out; } +/* ── Progress ── */ +.ds-progress-track { + width: 100%; + height: 8px; + background: rgba(255, 255, 255, 0.08); + border-radius: 9999px; + overflow: hidden; +} +.ds-progress-fill { + height: 100%; + border-radius: 9999px; + background: linear-gradient(90deg, #6366f1 0%, #8b5cf6 100%); + transition: width 0.4s ease; +} +/* ── Avatar ── */ +.ds-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: linear-gradient(135deg, #6366f1 0%, #a855f7 100%); + display: flex; + align-items: center; + justify-content: center; + color: white; + font-weight: 700; + font-size: 1rem; + flex-shrink: 0; +} +.ds-avatar-lg { width: 56px; height: 56px; font-size: 1.25rem; } +.ds-avatar-sm { width: 28px; height: 28px; font-size: 0.75rem; } +/* ── Separator ── */ +.ds-separator { + width: 100%; + height: 1px; + background: rgba(255, 255, 255, 0.08); + border: none; + margin: 0.5rem 0; +} +/* ── Alert ── */ +.ds-alert { + padding: 1rem 1.25rem; + border-radius: 12px; + font-size: 0.875rem; + line-height: 1.5; +} +.ds-alert-info { + background: rgba(56, 189, 248, 0.08); + border: 1px solid rgba(56, 189, 248, 0.2); + color: #7dd3fc; +} +.ds-alert-warning { + background: rgba(234, 179, 8, 0.08); + border: 1px solid rgba(234, 179, 8, 0.2); + color: #fde047; +} +.ds-alert-error { + background: rgba(239, 68, 68, 0.08); + border: 1px solid rgba(239, 68, 68, 0.2); + color: #fca5a5; +} +.ds-alert-success { + background: rgba(34, 197, 94, 0.08); + border: 1px solid rgba(34, 197, 94, 0.2); + color: #86efac; +} +/* ── Toggle ── */ +.ds-toggle { + width: 44px; + height: 24px; + border-radius: 12px; + background: rgba(255, 255, 255, 0.1); + position: relative; + cursor: pointer; + transition: background 0.2s; + border: none; + padding: 0; +} +.ds-toggle-on { + background: #6366f1; +} +.ds-toggle::after { + content: ''; + position: absolute; + width: 18px; + height: 18px; + border-radius: 50%; + background: white; + top: 3px; + left: 3px; + transition: transform 0.2s; +} +.ds-toggle-on::after { + transform: translateX(20px); +} +/* ── Stat ── */ +.ds-stat-value { + font-size: 2rem; + font-weight: 800; + color: #f1f5f9; + line-height: 1; +} +.ds-stat-delta-up { + font-size: 0.75rem; + font-weight: 600; + color: #4ade80; +} +.ds-stat-delta-down { + font-size: 0.75rem; + font-weight: 600; + color: #f87171; +} "#; +/// Map a DreamStack variant prop to CSS class(es) based on element tag. +fn variant_to_css(tag: &str, variant: &str) -> String { + match tag { + "button" => match variant { + "primary" => "ds-btn-primary".to_string(), + "secondary" => "ds-btn-secondary".to_string(), + "ghost" => "ds-btn-ghost".to_string(), + "destructive" => "ds-btn-destructive".to_string(), + _ => format!("ds-btn-{}", variant), + }, + "text" => match variant { + "success" | "warning" | "error" | "info" | "default" => + format!("ds-badge ds-badge-{}", variant), + "title" => "ds-card-title".to_string(), + "subtitle" | "muted" => "ds-card-subtitle".to_string(), + "label" => "ds-input-label".to_string(), + _ => format!("ds-text-{}", variant), + }, + "column" => match variant { + "card" => "ds-card".to_string(), + "dialog" => "ds-dialog-content".to_string(), + "overlay" => "ds-dialog-overlay".to_string(), + _ => format!("ds-column-{}", variant), + }, + "row" => match variant { + "card" => "ds-card".to_string(), + _ => format!("ds-row-{}", variant), + }, + "input" => match variant { + "error" => "ds-input-has-error".to_string(), + _ => format!("ds-input-{}", variant), + }, + _ => format!("ds-{}-{}", tag, variant), + } +} + +/// Generate a JS object literal mapping variant names to CSS classes for dynamic variant. +fn variant_map_js(tag: &str) -> String { + match tag { + "button" => "{'primary':'ds-btn-primary','secondary':'ds-btn-secondary','ghost':'ds-btn-ghost','destructive':'ds-btn-destructive'}".to_string(), + "text" => "{'success':'ds-badge ds-badge-success','warning':'ds-badge ds-badge-warning','error':'ds-badge ds-badge-error','info':'ds-badge ds-badge-info','default':'ds-badge ds-badge-default','title':'ds-card-title','subtitle':'ds-card-subtitle','label':'ds-input-label'}".to_string(), + "column" => "{'card':'ds-card','dialog':'ds-dialog-content','overlay':'ds-dialog-overlay'}".to_string(), + "input" => "{'error':'ds-input-has-error'}".to_string(), + _ => "{}".to_string(), + } +} + /// The DreamStack client-side reactive runtime (~3KB). const RUNTIME_JS: &str = r#" const DS = (() => { diff --git a/compiler/ds-parser/src/parser.rs b/compiler/ds-parser/src/parser.rs index 3f379e4..2efb207 100644 --- a/compiler/ds-parser/src/parser.rs +++ b/compiler/ds-parser/src/parser.rs @@ -1309,10 +1309,31 @@ impl Parser { } self.expect(&TokenKind::RBracket)?; + + // Optional trailing props: `column [ ... ] { variant: "card" }` + let mut props = Vec::new(); + if self.check(&TokenKind::LBrace) { + self.advance(); + self.skip_newlines(); + while !self.check(&TokenKind::RBrace) && !self.is_at_end() { + self.skip_newlines(); + let key = self.expect_ident()?; + self.expect(&TokenKind::Colon)?; + let val = self.parse_expr()?; + props.push((key, val)); + self.skip_newlines(); + if self.check(&TokenKind::Comma) { + self.advance(); + } + self.skip_newlines(); + } + self.expect(&TokenKind::RBrace)?; + } + Ok(Expr::Container(Container { kind, children, - props: Vec::new(), + props, })) } diff --git a/examples/dashboard.ds b/examples/dashboard.ds index 39bfe2e..22a95ea 100644 --- a/examples/dashboard.ds +++ b/examples/dashboard.ds @@ -1,19 +1,95 @@ -let title = "Dashboard" -let active = "Analytics" +-- DreamStack Dashboard +-- Rich admin dashboard using container variant props -layout dashboard { - sidebar.x == 0 - sidebar.y == 0 - sidebar.width == 250 - sidebar.height == parent.height - main.x == sidebar.width - main.y == 0 - main.width == parent.width - sidebar.width - main.height == parent.height -} +let visitors = 12847 +let revenue = 48290 +let orders = 384 +let conversion = 3.2 +let search = "" view main = column [ - text title - text "Welcome to DreamStack Dashboard" - text active + + -- Header + row [ + text "Dashboard" { variant: "title" } + input { bind: search, placeholder: "Search..." } + ] + text "Welcome back — here's what's happening today." { variant: "subtitle" } + + -- Stat cards row + row [ + column [ + text "Total Visitors" { variant: "subtitle" } + text "{visitors}" { variant: "title" } + text "↑ 12% from last month" { variant: "success" } + ] { variant: "card" } + + column [ + text "Revenue" { variant: "subtitle" } + text "${revenue}" { variant: "title" } + text "↑ 8.2% from last month" { variant: "success" } + ] { variant: "card" } + + column [ + text "Orders" { variant: "subtitle" } + text "{orders}" { variant: "title" } + text "↓ 2.1% from last month" { variant: "error" } + ] { variant: "card" } + + column [ + text "Conversion" { variant: "subtitle" } + text "{conversion}%" { variant: "title" } + text "↑ 0.5% from last month" { variant: "success" } + ] { variant: "card" } + ] + + -- Alert + text "⚡ Your trial ends in 3 days. Upgrade to keep all features." { variant: "info" } + + -- Recent Orders + column [ + text "Recent Orders" { variant: "title" } + + row [ + text "#" { variant: "label" } + text "Customer" { variant: "label" } + text "Status" { variant: "label" } + text "Amount" { variant: "label" } + ] + + row [ + text "3842" + text "Alice Johnson" + text "Delivered" { variant: "success" } + text "$249.00" + ] + + row [ + text "3841" + text "Bob Smith" + text "Processing" { variant: "warning" } + text "$149.00" + ] + + row [ + text "3840" + text "Carol White" + text "Shipped" { variant: "info" } + text "$399.00" + ] + + row [ + text "3839" + text "Dave Brown" + text "Cancelled" { variant: "error" } + text "$89.00" + ] + ] { variant: "card" } + + -- Actions + row [ + button "View All Orders" { variant: "primary" } + button "Export CSV" { variant: "secondary" } + button "Settings" { variant: "ghost" } + ] ] diff --git a/examples/showcase.ds b/examples/showcase.ds index ed18553..6c417ca 100644 --- a/examples/showcase.ds +++ b/examples/showcase.ds @@ -1,5 +1,5 @@ -- DreamStack Component Showcase --- Demonstrates all component styles +-- Demonstrates all component styles via variant prop -- State let name = "" @@ -8,38 +8,38 @@ let count = 0 -- Main view view main = column [ - text "🧩 DreamStack Components" { class: "ds-card-title" } - text "shadcn-inspired component registry" { class: "ds-card-subtitle" } + text "🧩 DreamStack Components" { variant: "title" } + text "shadcn-inspired component registry" { variant: "subtitle" } -- Button Variants - text "Button Variants" { class: "ds-card-title" } + text "Button Variants" { variant: "title" } row [ - button "Primary" { class: "ds-btn-primary" } - button "Secondary" { class: "ds-btn-secondary" } - button "Ghost" { class: "ds-btn-ghost" } - button "Destructive" { class: "ds-btn-destructive" } + button "Primary" { variant: "primary" } + button "Secondary" { variant: "secondary" } + button "Ghost" { variant: "ghost" } + button "Destructive" { variant: "destructive" } ] -- Badge Variants - text "Badge Variants" { class: "ds-card-title" } + text "Badge Variants" { variant: "title" } row [ - text "SUCCESS" { class: "ds-badge ds-badge-success" } - text "WARNING" { class: "ds-badge ds-badge-warning" } - text "ERROR" { class: "ds-badge ds-badge-error" } - text "INFO" { class: "ds-badge ds-badge-info" } - text "DEFAULT" { class: "ds-badge ds-badge-default" } + text "SUCCESS" { variant: "success" } + text "WARNING" { variant: "warning" } + text "ERROR" { variant: "error" } + text "INFO" { variant: "info" } + text "DEFAULT" { variant: "default" } ] -- Input with live binding - text "Input Component" { class: "ds-card-title" } - text "Name" { class: "ds-input-label" } + text "Input Component" { variant: "title" } + text "Name" { variant: "label" } input { bind: name, placeholder: "Type your name..." } text "Hello, {name}!" -- Interactive counter - text "Interactive Counter" { class: "ds-card-title" } + text "Interactive Counter" { variant: "title" } row [ - button "Count: {count}" { click: count += 1, class: "ds-btn-primary" } - button "Reset" { click: count = 0, class: "ds-btn-ghost" } + button "Count: {count}" { click: count += 1, variant: "primary" } + button "Reset" { click: count = 0, variant: "ghost" } ] ] diff --git a/registry/components/alert.ds b/registry/components/alert.ds new file mode 100644 index 0000000..c078d1e --- /dev/null +++ b/registry/components/alert.ds @@ -0,0 +1,5 @@ +-- DreamStack Alert Component +-- Variants: info, warning, error, success + +export component Alert(message, variant) = + text message { variant: "info" } diff --git a/registry/components/avatar.ds b/registry/components/avatar.ds new file mode 100644 index 0000000..a846e47 --- /dev/null +++ b/registry/components/avatar.ds @@ -0,0 +1,5 @@ +-- DreamStack Avatar Component +-- User avatar with initials fallback + +export component Avatar(initials) = + text initials diff --git a/registry/components/badge.ds b/registry/components/badge.ds index 1a39bbc..0222fc2 100644 --- a/registry/components/badge.ds +++ b/registry/components/badge.ds @@ -2,4 +2,4 @@ -- Variants: success, warning, error, info, default export component Badge(label, variant) = - text label { class: "ds-badge ds-badge-default" } + text label { variant: "default" } diff --git a/registry/components/button.ds b/registry/components/button.ds index 7afd2bf..3e547bd 100644 --- a/registry/components/button.ds +++ b/registry/components/button.ds @@ -2,4 +2,4 @@ -- Variants: primary (default), secondary, ghost, destructive export component Button(label, variant, onClick) = - button label { click: onClick, class: "ds-btn-primary" } + button label { click: onClick, variant: "primary" } diff --git a/registry/components/card.ds b/registry/components/card.ds index 2d8dfc3..d94ef80 100644 --- a/registry/components/card.ds +++ b/registry/components/card.ds @@ -3,6 +3,6 @@ export component Card(title, subtitle) = column [ - text title { class: "ds-card-title" } - text subtitle { class: "ds-card-subtitle" } - ] { class: "ds-card" } + text title { variant: "title" } + text subtitle { variant: "subtitle" } + ] { variant: "card" } diff --git a/registry/components/dialog.ds b/registry/components/dialog.ds index d41cd20..9b2e40c 100644 --- a/registry/components/dialog.ds +++ b/registry/components/dialog.ds @@ -5,6 +5,6 @@ import { Button } from "./button" export component Dialog(title, open, onClose) = column [ - text title { class: "ds-dialog-title" } + text title { variant: "title" } Button { label: "Close", onClick: onClose } - ] { class: "ds-dialog-content" } + ] { variant: "dialog" } diff --git a/registry/components/input.ds b/registry/components/input.ds index 7668f38..dc29501 100644 --- a/registry/components/input.ds +++ b/registry/components/input.ds @@ -3,6 +3,6 @@ export component Input(value, placeholder, label) = column [ - text label { class: "ds-input-label" } + text label { variant: "label" } input { bind: value, placeholder: placeholder } ] diff --git a/registry/components/progress.ds b/registry/components/progress.ds new file mode 100644 index 0000000..fbadd14 --- /dev/null +++ b/registry/components/progress.ds @@ -0,0 +1,7 @@ +-- DreamStack Progress Component +-- Animated progress bar with percentage + +export component Progress(value) = + column [ + text "{value}%" + ] diff --git a/registry/components/separator.ds b/registry/components/separator.ds new file mode 100644 index 0000000..c3b707e --- /dev/null +++ b/registry/components/separator.ds @@ -0,0 +1,5 @@ +-- DreamStack Separator Component +-- Visual divider between content sections + +export component Separator() = + text "" diff --git a/registry/components/toast.ds b/registry/components/toast.ds index aa02610..957155f 100644 --- a/registry/components/toast.ds +++ b/registry/components/toast.ds @@ -2,4 +2,4 @@ -- Notification toast with slide-in animation export component Toast(message) = - text message { class: "ds-toast" } + text message diff --git a/registry/components/toggle.ds b/registry/components/toggle.ds new file mode 100644 index 0000000..d0f00aa --- /dev/null +++ b/registry/components/toggle.ds @@ -0,0 +1,5 @@ +-- DreamStack Toggle Component +-- On/off switch + +export component Toggle(value, onChange) = + button "" { click: onChange }