on master help sign in
Switch to CSS classes for hover/active/focus pseudo-selector support
44eeeb3iko
DESIGN.md+14
@@ -284,6 +284,20 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
284| `underline` | Underlined text |
284| `underline` | Underlined text |
285| `size N` | Font size in px |
285| `size N` | Font size in px |
286| `font NAME` | Font family |
286| `font NAME` | Font family |
287+| `transition VALUE` | CSS transition |
288+| `cursor VALUE` | Cursor style |
289+| `opacity VALUE` | Opacity (0–1) |
290+
291+### Pseudo-states
292+
293+Prefix any style attribute with `hover:`, `active:`, or `focus:` to apply it on that state.
294+
295+```
296+@el [padding 16, background #3b82f6, hover:background #2563eb, active:background #1d4ed8, rounded 8, transition all 0.15s ease]
297+ @text [color white] Click me
298+```
299+
300+All style attributes support state prefixes: `hover:color`, `active:rounded`, `focus:border`, etc.
287
301
288### Flow
302### Flow
289
303
examples/demo.hl+5−5
@@ -2,9 +2,9 @@
2@let primary #3b82f6
2@let primary #3b82f6
3@let gap 20
3@let gap 20
4
4
5−-- reusable card component
5+-- reusable card with hover effect
6@fn card $title
6@fn card $title
7− @el [padding 20, background white, rounded 8, border 1 #e5e7eb]
7+ @el [padding 20, background white, rounded 8, border 1 #e5e7eb, hover:border 1 $primary, transition all 0.15s ease, cursor pointer]
8 @text [bold] $title
8 @text [bold] $title
9 @children
9 @children
10
10
@@ -23,7 +23,7 @@
23 {@text [bold, color $primary] static HTML}.
23 {@text [bold, color $primary] static HTML}.
24 @image [width 80, height 80, rounded 40] https://picsum.photos/80
24 @image [width 80, height 80, rounded 40] https://picsum.photos/80
25
25
26− -- cards using @fn
26+ -- cards using @fn with hover
27 @page-section [title Features]
27 @page-section [title Features]
28 @row [wrap, spacing 10]
28 @row [wrap, spacing 10]
29 @card [title Simple]
29 @card [title Simple]
@@ -33,9 +33,9 @@
33 @card [title Minimal]
33 @card [title Minimal]
34 No JavaScript required
34 No JavaScript required
35
35
36− -- footer
36+ -- footer with hover button
37 @row [spacing 10]
37 @row [spacing 10]
38− @el [padding 16, background $primary, rounded 8] > @link https://github.com
38+ @el [padding 16, background $primary, hover:background #2563eb, active:background #1d4ed8, rounded 8, transition all 0.15s ease, cursor pointer] > @link https://github.com
39 @text [color white, bold] Get Started
39 @text [color white, bold] Get Started
40 @el [width fill]
40 @el [width fill]
41 @text [color #888] © 2026
41 @text [color #888] © 2026
examples/demo.html+31−11
@@ -8,29 +8,49 @@
8*, *::before, *::after { box-sizing: border-box; }
8*, *::before, *::after { box-sizing: border-box; }
9body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }
9body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }
10img { display: block; }
10img { display: block; }
11+._0{display:flex;flex-direction:column;max-width:800px;margin-left:auto;margin-right:auto;padding:40px;gap:20px;}
12+._1{display:flex;flex-direction:row;gap:20px;}
13+._2{display:flex;flex-direction:column;flex:1;min-width:0;gap:10px;}
14+._3{font-weight:bold;font-size:32px;}
15+._4{margin:0;}
16+._5{font-weight:bold;color:#3b82f6;}
17+._6{width:80px;height:80px;border-radius:40px;}
18+._7{display:flex;flex-direction:column;gap:10px;}
19+._8{font-weight:bold;font-size:24px;color:#333;}
20+._9{display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;}
21+._10{display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;transition:all 0.15s ease;cursor:pointer;}
22+._10:hover{border:1px solid #3b82f6;}
23+._11{font-weight:bold;}
24+._12{display:flex;flex-direction:row;gap:10px;}
25+._13{display:flex;flex-direction:column;padding:16px;background:#3b82f6;border-radius:8px;transition:all 0.15s ease;cursor:pointer;}
26+._13:hover{background:#2563eb;}
27+._13:active{background:#1d4ed8;}
28+._14{color:white;font-weight:bold;}
29+._15{display:flex;flex-direction:column;flex:1;min-width:0;}
30+._16{color:#888;}
11</style>
31</style>
12</head>
32</head>
13<body>
33<body>
14−<div style="display:flex;flex-direction:column;max-width:800px;margin-left:auto;margin-right:auto;padding:40px;gap:20px;"><div style="display:flex;flex-direction:row;gap:20px;"><div style="display:flex;flex-direction:column;flex:1;min-width:0;gap:10px;"><span style="font-weight:bold;font-size:32px;">Welcome to htmlang</span>
34+<div class="_0"><div class="_1"><div class="_2"><span class="_3">Welcome to htmlang</span>
15−<p style="margin:0;">A minimalist layout language that compiles to
35+<p class="_4">A minimalist layout language that compiles to
16−<span style="font-weight:bold;color:#3b82f6;">static HTML</span>.</p>
36+<span class="_5">static HTML</span>.</p>
17</div>
37</div>
18−<img src="https://picsum.photos/80" style="width:80px;height:80px;border-radius:40px;">
38+<img src="https://picsum.photos/80" class="_6">
19</div>
39</div>
20−<div style="display:flex;flex-direction:column;gap:10px;"><span style="font-weight:bold;font-size:24px;color:#333;">Features</span>
40+<div class="_7"><span class="_8">Features</span>
21−<div style="display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;"><div style="display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;"><span style="font-weight:bold;">Simple</span>
41+<div class="_9"><div class="_10"><span class="_11">Simple</span>
22<span>Write layouts without CSS</span></div>
42<span>Write layouts without CSS</span></div>
23−<div style="display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;"><span style="font-weight:bold;">Fast</span>
43+<div class="_10"><span class="_11">Fast</span>
24<span>Compiles to a single HTML file</span></div>
44<span>Compiles to a single HTML file</span></div>
25−<div style="display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;"><span style="font-weight:bold;">Minimal</span>
45+<div class="_10"><span class="_11">Minimal</span>
26<span>No JavaScript required</span></div>
46<span>No JavaScript required</span></div>
27</div>
47</div>
28</div>
48</div>
29−<div style="display:flex;flex-direction:row;gap:10px;"><div style="display:flex;flex-direction:column;padding:16px;background:#3b82f6;border-radius:8px;"><a href="https://github.com"><span style="color:white;font-weight:bold;">Get Started</span>
49+<div class="_12"><div class="_13"><a href="https://github.com"><span class="_14">Get Started</span>
30</a>
50</a>
31</div>
51</div>
32−<div style="display:flex;flex-direction:column;flex:1;min-width:0;"></div>
52+<div class="_15"></div>
33−<span style="color:#888;">© 2026</span>
53+<span class="_16">© 2026</span>
34</div>
54</div>
35</div>
55</div>
36</body>
56</body>
src/codegen.rs+229−77
@@ -1,11 +1,94 @@
1+use std::collections::HashMap;
2+
1use crate::ast::*;
3use crate::ast::*;
2
4
5+// ---------------------------------------------------------------------------
6+// Style collector: deduplicates CSS and assigns class names
7+// ---------------------------------------------------------------------------
8+
9+struct StyleEntry {
10+ class_name: String,
11+ base: String,
12+ hover: String,
13+ active: String,
14+ focus: String,
15+}
16+
17+struct StyleCollector {
18+ entries: Vec<StyleEntry>,
19+ index: HashMap<String, usize>,
20+}
21+
22+impl StyleCollector {
23+ fn new() -> Self {
24+ StyleCollector {
25+ entries: Vec::new(),
26+ index: HashMap::new(),
27+ }
28+ }
29+
30+ /// Returns a class name for this style combination, or None if all empty.
31+ fn get_class(
32+ &mut self,
33+ base: String,
34+ hover: String,
35+ active: String,
36+ focus: String,
37+ ) -> Option<String> {
38+ if base.is_empty() && hover.is_empty() && active.is_empty() && focus.is_empty() {
39+ return None;
40+ }
41+ let key = format!("{}|{}|{}|{}", base, hover, active, focus);
42+ if let Some(&idx) = self.index.get(&key) {
43+ return Some(self.entries[idx].class_name.clone());
44+ }
45+ let name = format!("_{}", self.entries.len());
46+ let idx = self.entries.len();
47+ self.entries.push(StyleEntry {
48+ class_name: name.clone(),
49+ base,
50+ hover,
51+ active,
52+ focus,
53+ });
54+ self.index.insert(key, idx);
55+ Some(name)
56+ }
57+
58+ fn to_css(&self) -> String {
59+ let mut css = String::new();
60+ for e in &self.entries {
61+ if !e.base.is_empty() {
62+ css.push_str(&format!(".{}{{{}}}\n", e.class_name, e.base));
63+ }
64+ if !e.hover.is_empty() {
65+ css.push_str(&format!(".{}:hover{{{}}}\n", e.class_name, e.hover));
66+ }
67+ if !e.active.is_empty() {
68+ css.push_str(&format!(".{}:active{{{}}}\n", e.class_name, e.active));
69+ }
70+ if !e.focus.is_empty() {
71+ css.push_str(&format!(".{}:focus{{{}}}\n", e.class_name, e.focus));
72+ }
73+ }
74+ css
75+ }
76+}
77+
78+// ---------------------------------------------------------------------------
79+// Public API
80+// ---------------------------------------------------------------------------
81+
3pub fn generate(doc: &Document) -> String {
82pub fn generate(doc: &Document) -> String {
83+ let mut styles = StyleCollector::new();
4 let mut body = String::new();
84 let mut body = String::new();
85+
5 for node in &doc.nodes {
86 for node in &doc.nodes {
6− generate_node(node, None, &mut body);
87+ generate_node(node, None, &mut body, &mut styles);
7 }
88 }
8
89
90+ let element_css = styles.to_css();
91+
9 match &doc.page_title {
92 match &doc.page_title {
10 Some(title) => format!(
93 Some(title) => format!(
11 "\
94 "\
@@ -19,6 +102,7 @@ pub fn generate(doc: &Document) -> String {
19*, *::before, *::after {{ box-sizing: border-box; }}
102*, *::before, *::after {{ box-sizing: border-box; }}
20body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
103body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
21img {{ display: block; }}
104img {{ display: block; }}
105+{element_css}\
22</style>
106</style>
23</head>
107</head>
24<body>
108<body>
@@ -27,15 +111,31 @@ img {{ display: block; }}
27</html>
111</html>
28",
112",
29 title = html_escape(title),
113 title = html_escape(title),
114+ element_css = element_css,
30 body = body,
115 body = body,
31 ),
116 ),
32− None => body,
117+ None => {
118+ if element_css.is_empty() {
119+ body
120+ } else {
121+ format!("<style>\n{}</style>\n{}", element_css, body)
122+ }
123+ }
33 }
124 }
34}
125}
35
126
127+// ---------------------------------------------------------------------------
128+// Node generation
129+// ---------------------------------------------------------------------------
130+
131+fn generate_node(
132+ node: &Node,
36−fn generate_node(node: &Node, parent_kind: Option<&ElementKind>, out: &mut String) {
133+ parent_kind: Option<&ElementKind>,
134+ out: &mut String,
135+ styles: &mut StyleCollector,
136+) {
37 match node {
137 match node {
38− Node::Element(elem) => generate_element(elem, parent_kind, out),
138+ Node::Element(elem) => generate_element(elem, parent_kind, out, styles),
39 Node::Text(segments) => {
139 Node::Text(segments) => {
40 let needs_wrap = matches!(
140 let needs_wrap = matches!(
41 parent_kind,
141 parent_kind,
@@ -44,7 +144,7 @@ fn generate_node(node: &Node, parent_kind: Option<&ElementKind>, out: &mut Strin
44 if needs_wrap {
144 if needs_wrap {
45 out.push_str("<span>");
145 out.push_str("<span>");
46 }
146 }
47− generate_text_segments(segments, out);
147+ generate_text_segments(segments, out, styles);
48 if needs_wrap {
148 if needs_wrap {
49 out.push_str("</span>");
149 out.push_str("</span>");
50 }
150 }
@@ -56,14 +156,16 @@ fn generate_node(node: &Node, parent_kind: Option<&ElementKind>, out: &mut Strin
56 }
156 }
57}
157}
58
158
159+fn generate_element(
160+ elem: &Element,
59−fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut String) {
161+ parent_kind: Option<&ElementKind>,
60− // Image is a void element
162+ out: &mut String,
163+ styles: &mut StyleCollector,
164+) {
61 if elem.kind == ElementKind::Image {
165 if elem.kind == ElementKind::Image {
62− generate_image(elem, parent_kind, out);
166+ generate_image(elem, parent_kind, out, styles);
63 return;
167 return;
64 }
168 }
65−
66− // @children should be replaced during function expansion; skip if it leaks through
67 if elem.kind == ElementKind::Children {
169 if elem.kind == ElementKind::Children {
68 return;
170 return;
69 }
171 }
@@ -76,8 +178,9 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
76 ElementKind::Image | ElementKind::Children => unreachable!(),
178 ElementKind::Image | ElementKind::Children => unreachable!(),
77 };
179 };
78
180
181+ // Compute CSS for each state and get a class name
79− let styles = attrs_to_css(&elem.attrs, &elem.kind, parent_kind);
182+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
80− let (id, class) = extract_id_class(&elem.attrs);
183+ let (id, user_class) = extract_id_class(&elem.attrs);
81
184
82 out.push('<');
185 out.push('<');
83 out.push_str(tag);
186 out.push_str(tag);
@@ -90,21 +193,13 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
90 }
193 }
91 }
194 }
92
195
93− if !styles.is_empty() {
196+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
94− out.push_str(" style=\"");
197+
95− out.push_str(&styles);
96− out.push('"');
97− }
98 if let Some(id) = id {
198 if let Some(id) = id {
99 out.push_str(" id=\"");
199 out.push_str(" id=\"");
100 out.push_str(&html_escape(&id));
200 out.push_str(&html_escape(&id));
101 out.push('"');
201 out.push('"');
102 }
202 }
103− if let Some(class) = class {
104− out.push_str(" class=\"");
105− out.push_str(&html_escape(&class));
106− out.push('"');
107− }
108 out.push('>');
203 out.push('>');
109
204
110 // Inline text argument (@text content)
205 // Inline text argument (@text content)
@@ -117,8 +212,7 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
117 // Children
212 // Children
118 let is_paragraph = elem.kind == ElementKind::Paragraph;
213 let is_paragraph = elem.kind == ElementKind::Paragraph;
119 for (i, child) in elem.children.iter().enumerate() {
214 for (i, child) in elem.children.iter().enumerate() {
120− generate_node(child, Some(&elem.kind), out);
215+ generate_node(child, Some(&elem.kind), out, styles);
121− // In paragraphs, add newline between children so HTML renders a space
122 if is_paragraph && i < elem.children.len() - 1 {
216 if is_paragraph && i < elem.children.len() - 1 {
123 out.push('\n');
217 out.push('\n');
124 }
218 }
@@ -129,71 +223,133 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
129 out.push_str(">\n");
223 out.push_str(">\n");
130}
224}
131
225
226+fn generate_image(
227+ elem: &Element,
132−fn generate_image(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut String) {
228+ parent_kind: Option<&ElementKind>,
229+ out: &mut String,
230+ styles: &mut StyleCollector,
231+) {
133 let src = elem.argument.as_deref().unwrap_or("");
232 let src = elem.argument.as_deref().unwrap_or("");
134− let styles = attrs_to_css(&elem.attrs, &elem.kind, parent_kind);
233+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
135− let (id, class) = extract_id_class(&elem.attrs);
234+ let (id, user_class) = extract_id_class(&elem.attrs);
136
235
137 out.push_str("<img src=\"");
236 out.push_str("<img src=\"");
138 out.push_str(&html_escape(src));
237 out.push_str(&html_escape(src));
139 out.push('"');
238 out.push('"');
140
239
141− if !styles.is_empty() {
240+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
142− out.push_str(" style=\"");
241+
143− out.push_str(&styles);
144− out.push('"');
145− }
146 if let Some(id) = id {
242 if let Some(id) = id {
147 out.push_str(" id=\"");
243 out.push_str(" id=\"");
148 out.push_str(&html_escape(&id));
244 out.push_str(&html_escape(&id));
149 out.push('"');
245 out.push('"');
150 }
246 }
151− if let Some(class) = class {
152− out.push_str(" class=\"");
153− out.push_str(&html_escape(&class));
154− out.push('"');
155− }
156 out.push_str(">\n");
247 out.push_str(">\n");
157}
248}
158
249
159−fn generate_text_segments(segments: &[TextSegment], out: &mut String) {
250+fn generate_text_segments(
251+ segments: &[TextSegment],
252+ out: &mut String,
253+ styles: &mut StyleCollector,
254+) {
160 for segment in segments {
255 for segment in segments {
161 match segment {
256 match segment {
162 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
257 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
163 TextSegment::Inline(elem) => {
258 TextSegment::Inline(elem) => {
164− // Render to buffer and trim trailing newlines for inline context
165 let mut buf = String::new();
259 let mut buf = String::new();
166− generate_element(elem, None, &mut buf);
260+ generate_element(elem, None, &mut buf, styles);
167 out.push_str(buf.trim_end());
261 out.push_str(buf.trim_end());
168 }
262 }
169 }
263 }
170 }
264 }
171}
265}
172
266
267+// ---------------------------------------------------------------------------
268+// Style helpers
269+// ---------------------------------------------------------------------------
270+
271+fn compute_class(
272+ attrs: &[Attribute],
273+ kind: &ElementKind,
274+ parent_kind: Option<&ElementKind>,
275+ styles: &mut StyleCollector,
276+) -> Option<String> {
277+ let base = attrs_to_css(attrs, "", kind, parent_kind);
278+ let hover = attrs_to_css(attrs, "hover:", kind, parent_kind);
279+ let active = attrs_to_css(attrs, "active:", kind, parent_kind);
280+ let focus = attrs_to_css(attrs, "focus:", kind, parent_kind);
281+ styles.get_class(base, hover, active, focus)
282+}
283+
284+fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
285+ match (gen_class, user_class) {
286+ (Some(g), Some(u)) => {
287+ out.push_str(" class=\"");
288+ out.push_str(g);
289+ out.push(' ');
290+ out.push_str(&html_escape(u));
291+ out.push('"');
292+ }
293+ (Some(g), None) => {
294+ out.push_str(" class=\"");
295+ out.push_str(g);
296+ out.push('"');
297+ }
298+ (None, Some(u)) => {
299+ out.push_str(" class=\"");
300+ out.push_str(&html_escape(u));
301+ out.push('"');
302+ }
303+ (None, None) => {}
304+ }
305+}
306+
173// ---------------------------------------------------------------------------
307// ---------------------------------------------------------------------------
174// Attribute → CSS mapping
308// Attribute → CSS mapping
175// ---------------------------------------------------------------------------
309// ---------------------------------------------------------------------------
176
310
311+const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
312+
313+fn is_state_attr(key: &str) -> bool {
314+ STATE_PREFIXES.iter().any(|p| key.starts_with(p))
315+}
316+
177fn attrs_to_css(
317fn attrs_to_css(
178 attrs: &[Attribute],
318 attrs: &[Attribute],
319+ state_prefix: &str,
179 kind: &ElementKind,
320 kind: &ElementKind,
180 parent_kind: Option<&ElementKind>,
321 parent_kind: Option<&ElementKind>,
181) -> String {
322) -> String {
182 let mut css = String::new();
323 let mut css = String::new();
183
324
184− // Base styles from element kind
325+ // Base element styles only for the default (non-state) pass
326+ if state_prefix.is_empty() {
185− match kind {
327+ match kind {
186− ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
328+ ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
187− ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
329+ ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
188− ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
330+ ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
189− ElementKind::Paragraph => css.push_str("margin:0;"),
331+ ElementKind::Paragraph => css.push_str("margin:0;"),
190− _ => {}
332+ _ => {}
333+ }
191 }
334 }
192
335
193 for attr in attrs {
336 for attr in attrs {
337+ // Determine the effective key for this pass
338+ let effective_key = if state_prefix.is_empty() {
339+ if is_state_attr(&attr.key) {
340+ continue;
341+ }
342+ attr.key.as_str()
343+ } else {
344+ match attr.key.strip_prefix(state_prefix) {
345+ Some(k) => k,
346+ None => continue,
347+ }
348+ };
349+
194 let val = attr.value.as_deref();
350 let val = attr.value.as_deref();
195
351
196− match attr.key.as_str() {
352+ match effective_key {
197 // Layout
353 // Layout
198 "spacing" => {
354 "spacing" => {
199 if let Some(v) = val {
355 if let Some(v) = val {
@@ -246,9 +402,7 @@ fn attrs_to_css(
246 }
402 }
247 _ => push_css(&mut css, "width", "100%"),
403 _ => push_css(&mut css, "width", "100%"),
248 },
404 },
249− "shrink" => {
250− push_css(&mut css, "flex-shrink", "0");
405+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
251− }
252 _ => push_css(&mut css, "width", &format!("{}px", v)),
406 _ => push_css(&mut css, "width", &format!("{}px", v)),
253 }
407 }
254 }
408 }
@@ -263,9 +417,7 @@ fn attrs_to_css(
263 }
417 }
264 _ => push_css(&mut css, "height", "100%"),
418 _ => push_css(&mut css, "height", "100%"),
265 },
419 },
266− "shrink" => {
267− push_css(&mut css, "flex-shrink", "0");
420+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
268− }
269 _ => push_css(&mut css, "height", &format!("{}px", v)),
421 _ => push_css(&mut css, "height", &format!("{}px", v)),
270 }
422 }
271 }
423 }
@@ -302,9 +454,7 @@ fn attrs_to_css(
302 }
454 }
303 },
455 },
304 "center-y" => match parent_kind {
456 "center-y" => match parent_kind {
305− Some(ElementKind::Row) => {
306− push_css(&mut css, "align-self", "center");
457+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
307− }
308 _ => {
458 _ => {
309 push_css(&mut css, "margin-top", "auto");
459 push_css(&mut css, "margin-top", "auto");
310 push_css(&mut css, "margin-bottom", "auto");
460 push_css(&mut css, "margin-bottom", "auto");
@@ -314,33 +464,21 @@ fn attrs_to_css(
314 Some(ElementKind::Column) | Some(ElementKind::El) => {
464 Some(ElementKind::Column) | Some(ElementKind::El) => {
315 push_css(&mut css, "align-self", "flex-start");
465 push_css(&mut css, "align-self", "flex-start");
316 }
466 }
317− _ => {
318− push_css(&mut css, "margin-right", "auto");
467+ _ => push_css(&mut css, "margin-right", "auto"),
319− }
320 },
468 },
321 "align-right" => match parent_kind {
469 "align-right" => match parent_kind {
322 Some(ElementKind::Column) | Some(ElementKind::El) => {
470 Some(ElementKind::Column) | Some(ElementKind::El) => {
323 push_css(&mut css, "align-self", "flex-end");
471 push_css(&mut css, "align-self", "flex-end");
324 }
472 }
325− _ => {
326− push_css(&mut css, "margin-left", "auto");
473+ _ => push_css(&mut css, "margin-left", "auto"),
327− }
328 },
474 },
329 "align-top" => match parent_kind {
475 "align-top" => match parent_kind {
330− Some(ElementKind::Row) => {
331− push_css(&mut css, "align-self", "flex-start");
476+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
332− }
333− _ => {
334− push_css(&mut css, "margin-bottom", "auto");
477+ _ => push_css(&mut css, "margin-bottom", "auto"),
335− }
336 },
478 },
337 "align-bottom" => match parent_kind {
479 "align-bottom" => match parent_kind {
338− Some(ElementKind::Row) => {
339− push_css(&mut css, "align-self", "flex-end");
480+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
340− }
341− _ => {
342− push_css(&mut css, "margin-top", "auto");
481+ _ => push_css(&mut css, "margin-top", "auto"),
343− }
344 },
482 },
345
483
346 // Style
484 // Style
@@ -390,14 +528,28 @@ fn attrs_to_css(
390 push_css(&mut css, "font-family", v);
528 push_css(&mut css, "font-family", v);
391 }
529 }
392 }
530 }
531+ "transition" => {
532+ if let Some(v) = val {
533+ push_css(&mut css, "transition", v);
534+ }
535+ }
536+ "cursor" => {
537+ if let Some(v) = val {
538+ push_css(&mut css, "cursor", v);
539+ }
540+ }
541+ "opacity" => {
542+ if let Some(v) = val {
543+ push_css(&mut css, "opacity", v);
544+ }
545+ }
393
546
394 // Flow
547 // Flow
395 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
548 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
396
549
397− // Identity — handled separately, not CSS
550+ // Identity — not CSS
398 "id" | "class" => {}
551 "id" | "class" => {}
399
552
400− // Unknown — ignore
401 _ => {}
553 _ => {}
402 }
554 }
403 }
555 }