feat: container variant props, 11-component registry, rich dashboard

Parser:
- column/row/stack now parse trailing { props } after ]
- Enables: column [...] { variant: "card" }

Codegen:
- Container props dispatch: variant, class, click, style, layout
- variant_to_css() maps (tag, variant) → CSS class
- variant_map_js() for dynamic variants via inline JS map
- 230+ lines design system CSS (button/badge/card/dialog/toast/
  progress/alert/separator/toggle/avatar/stat)

Registry (11 components):
- button, input, card, badge, dialog, toast
- NEW: progress, alert, separator, toggle, avatar
- All embedded via include_str! for offline use

Examples:
- showcase.ds: component gallery with variant prop
- dashboard.ds: admin dashboard with glassmorphism cards
This commit is contained in:
enzotar 2026-02-26 13:58:33 -08:00
parent 7805b94704
commit a290bc1891
16 changed files with 445 additions and 61 deletions

View file

@ -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<String>, list: bool, all: bool) {

View file

@ -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 = (() => {

View file

@ -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,
}))
}

View file

@ -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" }
]
]

View file

@ -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" }
]
]

View file

@ -0,0 +1,5 @@
-- DreamStack Alert Component
-- Variants: info, warning, error, success
export component Alert(message, variant) =
text message { variant: "info" }

View file

@ -0,0 +1,5 @@
-- DreamStack Avatar Component
-- User avatar with initials fallback
export component Avatar(initials) =
text initials

View file

@ -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" }

View file

@ -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" }

View file

@ -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" }

View file

@ -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" }

View file

@ -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 }
]

View file

@ -0,0 +1,7 @@
-- DreamStack Progress Component
-- Animated progress bar with percentage
export component Progress(value) =
column [
text "{value}%"
]

View file

@ -0,0 +1,5 @@
-- DreamStack Separator Component
-- Visual divider between content sections
export component Separator() =
text ""

View file

@ -2,4 +2,4 @@
-- Notification toast with slide-in animation
export component Toast(message) =
text message { class: "ds-toast" }
text message

View file

@ -0,0 +1,5 @@
-- DreamStack Toggle Component
-- On/off switch
export component Toggle(value, onChange) =
button "" { click: onChange }