@@ -153,7 +153,7 @@ impl StyleCollector {
154 fn to_css_formatted(&self, dev: bool, use_layer: bool) -> String {
154 fn to_css_formatted(&self, dev: bool, use_layer: bool) -> String {
155 let mut css = String::new();
155 let mut css = String::new();
156− let (sp, nl) = if dev { (" ", "\n") } else { ("", "") };
156+ let inner_indent = if use_layer && dev { " " } else { "" };
158 // Wrap in @layer for specificity management
158 // Wrap in @layer for specificity management
159 if use_layer {
159 if use_layer {
@@ -164,226 +164,178 @@ impl StyleCollector {
167− // Non-responsive rules
167+ // Non-responsive rules. Base and pseudo rules are merged separately by
168+ // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
169+ // replaces two identical rules. Pseudo variants are grouped per selector
170+ // suffix (`:hover` with `:hover`, etc.) to keep each pseudo's cascade
171+ // position independent of others.
172+ let mut base_pairs: Vec<(&str, &str)> = Vec::with_capacity(self.entries.len());
168 for e in &self.entries {
173 for e in &self.entries {
169 if !e.base.is_empty() {
174 if !e.base.is_empty() {
170− if use_layer && dev {
171− css.push_str(&format!(" .{}{sp}{{{}}}{nl}", e.class_name, e.base));
175+ base_pairs.push((e.class_name.as_str(), e.base.as_str()));
173− css.push_str(&format!(".{}{sp}{{{}}}{nl}", e.class_name, e.base));
178+ emit_grouped_rules(&mut css, &base_pairs, "", inner_indent, dev);
180+ // Collect pseudo rules grouped by selector suffix, preserving the order
181+ // in which suffixes first appear across entries.
182+ let mut pseudo_order: Vec<&str> = Vec::new();
183+ let mut pseudo_buckets: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
184+ for e in &self.entries {
176− for (selector, pseudo_css) in &e.pseudo {
185+ for (selector, body) in &e.pseudo {
177− if !pseudo_css.is_empty() {
186+ if body.is_empty() {
178− if use_layer && dev {
187+ continue;
179− css.push_str(&format!(
180− " .{}{}{sp}{{{}}}{nl}",
181− e.class_name, selector, pseudo_css
184− css.push_str(&format!(
185− ".{}{}{sp}{{{}}}{nl}",
186− e.class_name, selector, pseudo_css
189+ let key = selector.as_str();
190+ if !pseudo_buckets.contains_key(key) {
191+ pseudo_order.push(key);
196+ .push((e.class_name.as_str(), body.as_str()));
199+ for selector in &pseudo_order {
200+ if let Some(pairs) = pseudo_buckets.get(selector) {
201+ emit_grouped_rules(&mut css, pairs, selector, inner_indent, dev);
193 // Responsive rules grouped by breakpoint
205 // Responsive rules grouped by breakpoint
194 for &(bp_name, bp_width) in BREAKPOINTS {
206 for &(bp_name, bp_width) in BREAKPOINTS {
195− let mut bp_css = String::new();
207+ let mut bp_pairs: Vec<(&str, &str)> = Vec::new();
196 for e in &self.entries {
208 for e in &self.entries {
197 for (bp, rule_css) in &e.responsive {
209 for (bp, rule_css) in &e.responsive {
198 if bp == bp_name && !rule_css.is_empty() {
210 if bp == bp_name && !rule_css.is_empty() {
199− if dev {
211+ bp_pairs.push((e.class_name.as_str(), rule_css.as_str()));
200− bp_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, rule_css));
202− bp_css.push_str(&format!(".{}{{{}}}", e.class_name, rule_css));
207− if !bp_css.is_empty() {
215+ emit_media_block(
217+ &format!("@media (min-width: {})", bp_width),
218+ &format!("@media(min-width:{})", bp_width),
209− css.push_str(&format!(
222+ );
210− "@media (min-width: {}) {{\n{}}}\n",
214− css.push_str(&format!("@media(min-width:{}){{{}}}", bp_width, bp_css));
219 // Dark mode rules
225 // Dark mode rules
220− let mut dark_css = String::new();
226+ let dark_pairs: Vec<(&str, &str)> = self
221− for e in &self.entries {
227+ .entries
222− if !e.dark.is_empty() {
228+ .iter()
223− if dev {
229+ .filter(|e| !e.dark.is_empty())
224− dark_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.dark));
226− dark_css.push_str(&format!(".{}{{{}}}", e.class_name, e.dark));
230+ .map(|e| (e.class_name.as_str(), e.dark.as_str()))
228− }
232+ emit_media_block(
230− if !dark_css.is_empty() {
232− css.push_str(&format!(
233− "@media (prefers-color-scheme: dark) {{\n{}}}\n",
234+ "@media (prefers-color-scheme: dark)",
237− css.push_str(&format!(
238− "@media(prefers-color-scheme:dark){{{}}}",
235+ "@media(prefers-color-scheme:dark)",
239− dark_css
236+ &dark_pairs,
244 // Print rules
241 // Print rules
245− let mut print_css = String::new();
242+ let print_pairs: Vec<(&str, &str)> = self
246− for e in &self.entries {
243+ .entries
247− if !e.print.is_empty() {
244+ .iter()
248− if dev {
245+ .filter(|e| !e.print.is_empty())
249− print_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.print));
251− print_css.push_str(&format!(".{}{{{}}}", e.class_name, e.print));
246+ .map(|e| (e.class_name.as_str(), e.print.as_str()))
253− }
248+ emit_media_block(
255− if !print_css.is_empty() {
257− css.push_str(&format!("@media print {{\n{}}}\n", print_css));
250+ "@media print",
259− css.push_str(&format!("@media print{{{}}}", print_css));
251+ "@media print",
263 // Motion safe rules
257 // Motion safe rules
264− let mut motion_safe_css = String::new();
258+ let motion_safe_pairs: Vec<(&str, &str)> = self
265− for e in &self.entries {
259+ .entries
266− if !e.motion_safe.is_empty() {
260+ .iter()
269− .push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_safe));
261+ .filter(|e| !e.motion_safe.is_empty())
271− motion_safe_css.push_str(&format!(".{}{{{}}}", e.class_name, e.motion_safe));
262+ .map(|e| (e.class_name.as_str(), e.motion_safe.as_str()))
273− }
264+ emit_media_block(
275− if !motion_safe_css.is_empty() {
277− css.push_str(&format!(
278− "@media (prefers-reduced-motion: no-preference) {{\n{}}}\n",
266+ "@media (prefers-reduced-motion: no-preference)",
282− css.push_str(&format!(
283− "@media(prefers-reduced-motion:no-preference){{{}}}",
267+ "@media(prefers-reduced-motion:no-preference)",
284− motion_safe_css
268+ &motion_safe_pairs,
289 // Motion reduce rules
273 // Motion reduce rules
290− let mut motion_reduce_css = String::new();
274+ let motion_reduce_pairs: Vec<(&str, &str)> = self
291− for e in &self.entries {
275+ .entries
292− if !e.motion_reduce.is_empty() {
276+ .iter()
295− .push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_reduce));
277+ .filter(|e| !e.motion_reduce.is_empty())
298− .push_str(&format!(".{}{{{}}}", e.class_name, e.motion_reduce));
278+ .map(|e| (e.class_name.as_str(), e.motion_reduce.as_str()))
300− }
280+ emit_media_block(
302− if !motion_reduce_css.is_empty() {
304− css.push_str(&format!(
305− "@media (prefers-reduced-motion: reduce) {{\n{}}}\n",
282+ "@media (prefers-reduced-motion: reduce)",
309− css.push_str(&format!(
310− "@media(prefers-reduced-motion:reduce){{{}}}",
283+ "@media(prefers-reduced-motion:reduce)",
311− motion_reduce_css
284+ &motion_reduce_pairs,
316 // Landscape rules
289 // Landscape rules
317− let mut landscape_css = String::new();
290+ let landscape_pairs: Vec<(&str, &str)> = self
318− for e in &self.entries {
291+ .entries
319− if !e.landscape.is_empty() {
292+ .iter()
320− if dev {
293+ .filter(|e| !e.landscape.is_empty())
321− landscape_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.landscape));
323− landscape_css.push_str(&format!(".{}{{{}}}", e.class_name, e.landscape));
294+ .map(|e| (e.class_name.as_str(), e.landscape.as_str()))
325− }
296+ emit_media_block(
327− if !landscape_css.is_empty() {
329− css.push_str(&format!(
330− "@media (orientation: landscape) {{\n{}}}\n",
298+ "@media (orientation: landscape)",
334− css.push_str(&format!(
335− "@media(orientation:landscape){{{}}}",
299+ "@media(orientation:landscape)",
336− landscape_css
300+ &landscape_pairs,
341 // Portrait rules
305 // Portrait rules
342− let mut portrait_css = String::new();
306+ let portrait_pairs: Vec<(&str, &str)> = self
343− for e in &self.entries {
307+ .entries
344− if !e.portrait.is_empty() {
308+ .iter()
345− if dev {
309+ .filter(|e| !e.portrait.is_empty())
346− portrait_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.portrait));
348− portrait_css.push_str(&format!(".{}{{{}}}", e.class_name, e.portrait));
310+ .map(|e| (e.class_name.as_str(), e.portrait.as_str()))
350− }
312+ emit_media_block(
352− if !portrait_css.is_empty() {
354− css.push_str(&format!(
355− "@media (orientation: portrait) {{\n{}}}\n",
314+ "@media (orientation: portrait)",
359− css.push_str(&format!("@media(orientation:portrait){{{}}}", portrait_css));
315+ "@media(orientation:portrait)",
360− }
316+ &portrait_pairs,
363 // Container query rules grouped by breakpoint
321 // Container query rules grouped by breakpoint
364 for &(bp_name, bp_width) in BREAKPOINTS {
322 for &(bp_name, bp_width) in BREAKPOINTS {
365− let mut cq_css = String::new();
323+ let mut cq_pairs: Vec<(&str, &str)> = Vec::new();
366 for e in &self.entries {
324 for e in &self.entries {
367 for (bp, rule_css) in &e.container {
325 for (bp, rule_css) in &e.container {
368 if bp == bp_name && !rule_css.is_empty() {
326 if bp == bp_name && !rule_css.is_empty() {
369− if dev {
327+ cq_pairs.push((e.class_name.as_str(), rule_css.as_str()));
370− cq_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, rule_css));
372− cq_css.push_str(&format!(".{}{{{}}}", e.class_name, rule_css));
377− if !cq_css.is_empty() {
331+ emit_media_block(
333+ &format!("@container (min-width: {})", bp_width),
334+ &format!("@container(min-width:{})", bp_width),
379− css.push_str(&format!(
338+ );
380− "@container (min-width: {}) {{\n{}}}\n",
384− css.push_str(&format!("@container(min-width:{}){{{}}}", bp_width, cq_css));
389 // Close @layer
341 // Close @layer
@@ -399,6 +351,80 @@ impl StyleCollector {
354+/// Emit CSS rules from `(class_name, body)` pairs, merging identical bodies
355+/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
356+/// of each distinct body determines ordering, so output stays deterministic
357+/// across runs. `selector_suffix` is appended to each class (e.g. `":hover"`,
358+/// or `""` for plain class rules). `indent` is prepended to each rule line.
359+fn emit_grouped_rules(
361+ pairs: &[(&str, &str)],
362+ selector_suffix: &str,
366+ if pairs.is_empty() {
369+ // Group in first-occurrence order.
370+ let mut order: Vec<&str> = Vec::new();
371+ let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
372+ for &(name, body) in pairs {
373+ if body.is_empty() {
376+ if !groups.contains_key(body) {
379+ groups.entry(body).or_default().push(name);
381+ let sp = if dev { " " } else { "" };
382+ let nl = if dev { "\n" } else { "" };
383+ for body in &order {
384+ let names = &groups[body];
385+ out.push_str(indent);
386+ for (i, n) in names.iter().enumerate() {
392+ out.push_str(selector_suffix);
402+/// Emit an `@media` / `@container` block containing grouped class rules.
403+/// Skips the block entirely if no non-empty bodies are present.
408+ pairs: &[(&str, &str)],
409+ selector_suffix: &str,
412+ if pairs.iter().all(|(_, body)| body.is_empty()) {
415+ let mut inner = String::new();
416+ let inner_indent = if dev { " " } else { "" };
417+ emit_grouped_rules(&mut inner, pairs, selector_suffix, inner_indent, dev);
418+ if inner.is_empty() {
422+ out.push_str(&format!("{} {{\n{}}}\n", header_dev, inner));
424+ out.push_str(&format!("{}{{{}}}", header_min, inner));
402struct GenContext {
428struct GenContext {
403 dev: bool,
429 dev: bool,
404 depth: usize,
430 depth: usize,
@@ -747,51 +773,60 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
748 let mut element_css = String::new();
774 let mut element_css = String::new();
750− // CSS custom properties (includes @theme runtime tokens)
776+ // Collect all CSS custom properties (explicit `@let --name` / `@theme`
777+ // tokens, plus any auto-extracted repeats) so they can be emitted in a
778+ // single `:root` block below.
779+ let mut root_vars: Vec<(String, String)> = Vec::new();
780+ for (name, value) in &doc.css_vars {
781+ root_vars.push((name.clone(), value.clone()));
751− let has_theme_tokens = !doc.theme_tokens.is_empty();
783+ for (name, value) in &doc.theme_tokens {
752− if !doc.css_vars.is_empty() || has_theme_tokens {
784+ let css_name = format!("--{}", name);
785+ if !root_vars.iter().any(|(n, _)| *n == css_name) {
786+ root_vars.push((css_name, value.clone()));
790+ let has_custom_css = !doc.custom_css.is_empty();
791+ let styles_css = styles.to_css_formatted(dev, !has_custom_css);
792+ // Fold literal values declared via `@theme` / `@let --name` back into
793+ // `var(--name)` references so the generated CSS actually uses the
794+ // custom properties emitted in `:root`. Saves bytes and makes runtime
795+ // theming take effect.
796+ let styles_css = substitute_css_vars(&styles_css, &root_vars);
797+ // Further compress the CSS by auto-extracting any remaining literal
798+ // values that appear often enough for `var(--hN)` references to come out
799+ // shorter overall. Disabled in dev mode to keep the generated CSS
801+ let styles_css = if dev {
804+ let (new_css, auto_vars) = auto_extract_repeats(&styles_css, &root_vars);
805+ root_vars.extend(auto_vars);
809+ // Emit the (possibly extended) :root block first so the cascade picks up
810+ // the custom properties before the class rules consume them.
811+ if !root_vars.is_empty() {
754 element_css.push_str(":root {\n");
813 element_css.push_str(":root {\n");
755− for (name, value) in &doc.css_vars {
814+ for (name, value) in &root_vars {
756 element_css.push_str(&format!(" {}: {};\n", name, value));
815 element_css.push_str(&format!(" {}: {};\n", name, value));
758− // @theme tokens as runtime CSS custom properties
759− for (name, value) in &doc.theme_tokens {
760− let css_name = format!("--{}", name);
761− // Only emit if not already in css_vars
762− if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
763− element_css.push_str(&format!(" {}: {};\n", css_name, value));
766 element_css.push_str("}\n");
817 element_css.push_str("}\n");
768 element_css.push_str(":root{");
819 element_css.push_str(":root{");
769− for (name, value) in &doc.css_vars {
820+ for (name, value) in &root_vars {
770 element_css.push_str(name);
821 element_css.push_str(name);
771 element_css.push(':');
822 element_css.push(':');
772 element_css.push_str(value);
823 element_css.push_str(value);
773 element_css.push(';');
824 element_css.push(';');
775− for (name, value) in &doc.theme_tokens {
776− let css_name = format!("--{}", name);
777− if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
778− element_css.push_str(&css_name);
779− element_css.push(':');
780− element_css.push_str(value);
781− element_css.push(';');
784 element_css.push('}');
826 element_css.push('}');
788− let has_custom_css = !doc.custom_css.is_empty();
789− let styles_css = styles.to_css_formatted(dev, !has_custom_css);
790− // Fold literal values declared via `@theme` / `@let --name` back into
791− // `var(--name)` references so the generated CSS actually uses the
792− // custom properties emitted in `:root`. Saves bytes and makes runtime
793− // theming take effect.
794− let styles_css = substitute_css_vars(&styles_css, &doc.css_vars);
795 element_css.push_str(&styles_css);
830 element_css.push_str(&styles_css);
797 // @keyframes
832 // @keyframes
@@ -1266,43 +1301,46 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1266 let has_custom_css = !doc.custom_css.is_empty();
1301 let has_custom_css = !doc.custom_css.is_empty();
1267 let mut element_css = String::new();
1302 let mut element_css = String::new();
1269− // CSS custom properties
1304+ let mut root_vars: Vec<(String, String)> = Vec::new();
1305+ for (name, value) in &doc.css_vars {
1306+ root_vars.push((name.clone(), value.clone()));
1270− if !doc.css_vars.is_empty() || !doc.theme_tokens.is_empty() {
1308+ for (name, value) in &doc.theme_tokens {
1309+ let css_name = format!("--{}", name);
1310+ if !root_vars.iter().any(|(n, _)| *n == css_name) {
1311+ root_vars.push((css_name, value.clone()));
1315+ let styles_css = styles.to_css_formatted(dev, !has_custom_css);
1316+ let styles_css = substitute_css_vars(&styles_css, &root_vars);
1317+ let styles_css = if dev {
1320+ let (new_css, auto_vars) = auto_extract_repeats(&styles_css, &root_vars);
1321+ root_vars.extend(auto_vars);
1325+ if !root_vars.is_empty() {
1271 if dev {
1326 if dev {
1272 element_css.push_str(":root {\n");
1327 element_css.push_str(":root {\n");
1273− for (name, value) in &doc.css_vars {
1328+ for (name, value) in &root_vars {
1274 element_css.push_str(&format!(" {}: {};\n", name, value));
1329 element_css.push_str(&format!(" {}: {};\n", name, value));
1276− for (name, value) in &doc.theme_tokens {
1277− let css_name = format!("--{}", name);
1278− if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
1279− element_css.push_str(&format!(" {}: {};\n", css_name, value));
1282 element_css.push_str("}\n");
1331 element_css.push_str("}\n");
1283 } else {
1332 } else {
1284 element_css.push_str(":root{");
1333 element_css.push_str(":root{");
1285− for (name, value) in &doc.css_vars {
1334+ for (name, value) in &root_vars {
1286 element_css.push_str(name);
1335 element_css.push_str(name);
1287 element_css.push(':');
1336 element_css.push(':');
1288 element_css.push_str(value);
1337 element_css.push_str(value);
1289 element_css.push(';');
1338 element_css.push(';');
1291− for (name, value) in &doc.theme_tokens {
1292− let css_name = format!("--{}", name);
1293− if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
1294− element_css.push_str(&css_name);
1295− element_css.push(':');
1296− element_css.push_str(value);
1297− element_css.push(';');
1300 element_css.push('}');
1340 element_css.push('}');
1304− let styles_css = styles.to_css_formatted(dev, !has_custom_css);
1305− let styles_css = substitute_css_vars(&styles_css, &doc.css_vars);
1306 element_css.push_str(&styles_css);
1344 element_css.push_str(&styles_css);
1308 for (name, kf_body) in &doc.keyframes {
1346 for (name, kf_body) in &doc.keyframes {
@@ -2458,6 +2496,19 @@ fn is_prefixed_attr(key: &str) -> bool {
2458 || key.starts_with("has(")
2496 || key.starts_with("has(")
2499+/// `display:flex;flex-direction:column;` — base layout for `@el` and every
2500+/// semantic wrapper that behaves like a column.
2501+const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
2503+/// Font stack shared by `@pre`, `@code`, `@kbd`.
2504+const MONOSPACE_STACK: &str = "font-family:ui-monospace,monospace;";
2506+/// Declarations common to `@badge` and `@tag`. They differ only in
2507+/// `border-radius` (and `@badge` additionally centers content / pins
2508+/// line-height:1), so keeping the shared base here avoids drift.
2509+const PILL_BASE: &str =
2510+ "display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;";
2461fn attrs_to_css(
2512fn attrs_to_css(
2462 attrs: &[Attribute],
2513 attrs: &[Attribute],
2463 state_prefix: &str,
2514 state_prefix: &str,
@@ -2470,43 +2521,61 @@ fn attrs_to_css(
2470 if state_prefix.is_empty() {
2521 if state_prefix.is_empty() {
2471 match kind {
2522 match kind {
2472 ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2523 ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2473− ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
2524+ ElementKind::Column
2474− ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
2525+ | ElementKind::El
2475− ElementKind::Paragraph => css.push_str("margin:0;"),
2476 // Semantic elements get flex column layout like @el
2526 // Semantic elements get flex column layout like @el
2477− ElementKind::Nav | ElementKind::Header | ElementKind::Footer
2528+ | ElementKind::Header
2529+ | ElementKind::Footer
2530+ | ElementKind::Main
2478− | ElementKind::Main | ElementKind::Section | ElementKind::Article
2531+ | ElementKind::Section
2532+ | ElementKind::Article
2479− | ElementKind::Aside => css.push_str("display:flex;flex-direction:column;"),
2533+ | ElementKind::Aside
2534+ | ElementKind::ListItem
2535+ | ElementKind::Form
2536+ | ElementKind::Details
2537+ | ElementKind::Dialog => css.push_str(FLEX_COLUMN),
2538+ ElementKind::Paragraph => css.push_str("margin:0;"),
2480 // Lists: reset browser defaults
2539 // Lists: reset browser defaults
2481 ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
2540 ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
2482− ElementKind::ListItem => css.push_str("display:flex;flex-direction:column;"),
2483− // Form: flex column like @el
2484− ElementKind::Form => css.push_str("display:flex;flex-direction:column;"),
2485− // Details: flex column
2486− ElementKind::Details => css.push_str("display:flex;flex-direction:column;"),
2487− // Figure: flex column
2488− ElementKind::Figure => css.push_str("display:flex;flex-direction:column;margin:0;"),
2489− // Blockquote: flex column, reset browser margin
2541+ // Figure / Blockquote: flex column with browser margin reset
2490− ElementKind::Blockquote => css.push_str("display:flex;flex-direction:column;margin:0;"),
2542+ ElementKind::Figure | ElementKind::Blockquote => {
2543+ css.push_str(FLEX_COLUMN);
2544+ css.push_str("margin:0;");
2491 // Pre: preserve whitespace
2546 // Pre: preserve whitespace
2547+ ElementKind::Pre => {
2492− ElementKind::Pre => css.push_str("margin:0;white-space:pre;font-family:ui-monospace,monospace;"),
2548+ css.push_str("margin:0;white-space:pre;");
2549+ css.push_str(MONOSPACE_STACK);
2493− // Code: monospace font
2551+ // Code / Kbd: monospace font
2494− ElementKind::Code => css.push_str("font-family:ui-monospace,monospace;"),
2552+ ElementKind::Code | ElementKind::Kbd => css.push_str(MONOSPACE_STACK),
2496− ElementKind::Dialog => css.push_str("display:flex;flex-direction:column;"),
2497 ElementKind::DefinitionList => css.push_str("margin:0;"),
2553 ElementKind::DefinitionList => css.push_str("margin:0;"),
2498− ElementKind::DefinitionDescription => css.push_str("margin:0;display:flex;flex-direction:column;"),
2554+ ElementKind::DefinitionDescription => {
2555+ css.push_str("margin:0;");
2556+ css.push_str(FLEX_COLUMN);
2558+ ElementKind::Fieldset => {
2559+ css.push_str(FLEX_COLUMN);
2560+ // Align padding to the px-based scale used by Badge/Tag/Chip
2561+ // rather than the old 0.5em (which depended on font-size).
2499− ElementKind::Fieldset => css.push_str("display:flex;flex-direction:column;border:1px solid currentColor;padding:0.5em;margin:0;"),
2562+ css.push_str("border:1px solid currentColor;padding:8px;margin:0;");
2500− ElementKind::Kbd => css.push_str("font-family:ui-monospace,monospace;"),
2563+ }
2501 ElementKind::Grid => css.push_str("display:grid;"),
2564 ElementKind::Grid => css.push_str("display:grid;"),
2502 ElementKind::Stack => css.push_str("position:relative;"),
2565 ElementKind::Stack => css.push_str("position:relative;"),
2566+ ElementKind::Badge => {
2567+ css.push_str(PILL_BASE);
2503− ElementKind::Badge => css.push_str("display:inline-flex;align-items:center;justify-content:center;padding:2px 8px;border-radius:9999px;font-size:0.75rem;font-weight:600;line-height:1;"),
2568+ css.push_str("justify-content:center;border-radius:9999px;line-height:1;");
2570+ ElementKind::Tag => {
2571+ css.push_str(PILL_BASE);
2572+ css.push_str("border-radius:4px;");
2504 ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
2574 ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
2505 ElementKind::Spacer => css.push_str("flex:1;"),
2575 ElementKind::Spacer => css.push_str("flex:1;"),
2506 ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
2576 ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
2507 ElementKind::Carousel => css.push_str("display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;"),
2577 ElementKind::Carousel => css.push_str("display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;"),
2508 ElementKind::Chip => css.push_str("display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;"),
2578 ElementKind::Chip => css.push_str("display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;"),
2509− ElementKind::Tag => css.push_str("display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;"),
2512 // Children of @carousel get scroll-snap-align and flex-shrink
2581 // Children of @carousel get scroll-snap-align and flex-shrink
@@ -2545,14 +2614,15 @@ fn attrs_to_css(
2546 "padding-x" => {
2615 "padding-x" => {
2547 if let Some(v) = val {
2616 if let Some(v) = val {
2548− push_css(&mut css, "padding-left", &css_px(v));
2617+ // Logical property covers both inline sides in one
2549− push_css(&mut css, "padding-right", &css_px(v));
2618+ // declaration; for symmetric values this is visually
2619+ // identical to padding-left/right in LTR and RTL.
2620+ push_css(&mut css, "padding-inline", &css_px(v));
2552 "padding-y" => {
2623 "padding-y" => {
2553 if let Some(v) = val {
2624 if let Some(v) = val {
2554− push_css(&mut css, "padding-top", &css_px(v));
2625+ push_css(&mut css, "padding-block", &css_px(v));
2555− push_css(&mut css, "padding-bottom", &css_px(v));
@@ -3035,14 +3105,12 @@ fn attrs_to_css(
3036 "margin-x" => {
3106 "margin-x" => {
3037 if let Some(v) = val {
3107 if let Some(v) = val {
3038− push_css(&mut css, "margin-left", &css_px(v));
3108+ push_css(&mut css, "margin-inline", &css_px(v));
3039− push_css(&mut css, "margin-right", &css_px(v));
3042 "margin-y" => {
3111 "margin-y" => {
3043 if let Some(v) = val {
3112 if let Some(v) = val {
3044− push_css(&mut css, "margin-top", &css_px(v));
3113+ push_css(&mut css, "margin-block", &css_px(v));
3045− push_css(&mut css, "margin-bottom", &css_px(v));
@@ -3719,6 +3787,129 @@ fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
3790+/// Scan generated CSS for literal property values that repeat often enough
3791+/// that substituting them with an auto-named custom property would reduce
3792+/// total byte count. Returns the rewritten CSS and any new vars that should
3793+/// be appended to `:root`.
3795+/// The algorithm only promotes a value if it strictly saves bytes after
3796+/// accounting for the `--xN:value;` declaration overhead plus the
3797+/// `var(--xN)` reference cost at each call site. Values that are already
3798+/// `var(...)` references or contain `var(` calls are skipped so we never
3799+/// nest references. Names avoid collisions with `existing_vars`.
3800+fn auto_extract_repeats(
3802+ existing_vars: &[(String, String)],
3803+) -> (String, Vec<(String, String)>) {
3804+ if css.is_empty() {
3805+ return (css.to_string(), Vec::new());
3807+ // Count occurrences of each distinct property value (the text between
3808+ // `:` and the next `;` or `}` at the top level of a declaration block,
3809+ // ignoring balanced parentheses so that e.g. `rgba(...)` stays intact).
3810+ let bytes = css.as_bytes();
3811+ let mut counts: HashMap<String, usize> = HashMap::new();
3812+ let mut depth: i32 = 0;
3814+ while i < bytes.len() {
3830+ if depth > 0 && bytes[i] == b':' {
3831+ // Skip past `:` then walk to the terminator.
3833+ let mut end = start;
3834+ let mut pdepth = 0i32;
3835+ while end < bytes.len() {
3837+ b'(' => pdepth += 1,
3843+ b';' | b'}' if pdepth == 0 => break,
3844+ b'{' if pdepth == 0 => break,
3849+ let val = css[start..end].trim();
3850+ if !val.is_empty() && !val.starts_with("var(") && !val.contains("var(") {
3851+ *counts.entry(val.to_string()).or_default() += 1;
3859+ // Decide which values are worth extracting. A value of length L appearing
3860+ // N times costs N*L bytes inline; promoting it costs (L + 6) for the
3861+ // `--hK:V;` declaration plus N * ref_len for the call sites. We estimate
3862+ // ref_len optimistically as `var(--h0)` (9 bytes) and fall back to 10 for
3863+ // two-digit indices, which only affects very large extraction counts.
3864+ let existing: std::collections::HashSet<&str> =
3865+ existing_vars.iter().map(|(n, _)| n.as_str()).collect();
3866+ let mut candidates: Vec<(String, usize)> = counts
3868+ .filter(|(_, n)| *n >= 2)
3870+ // Stable, deterministic ordering — longest values first, tiebreak by text.
3871+ candidates.sort_by(|a, b| {
3874+ .then_with(|| a.0.cmp(&b.0))
3877+ let mut extracted: Vec<(String, String)> = Vec::new();
3878+ let mut next_idx: usize = 0;
3879+ for (value, n) in candidates {
3880+ let l = value.len();
3881+ // Allocate the next unused `--hN` name.
3882+ let (name, ref_len) = loop {
3883+ let candidate = format!("--h{}", next_idx);
3885+ let rl = candidate.len() + 6; // `var(` + name + `)`
3886+ if !existing.contains(candidate.as_str())
3887+ && !extracted.iter().any(|(n, _)| *n == candidate)
3889+ break (candidate, rl);
3892+ // Skip if promotion would not strictly reduce byte count.
3893+ // Before: n * l bytes. After: (l + name.len() + 2) for the :root
3894+ // declaration (`<name>:<value>;`) plus n * ref_len for the sites.
3895+ let decl_overhead = l + name.len() + 2;
3896+ let before = n * l;
3897+ let after = decl_overhead + n * ref_len;
3898+ if after < before {
3899+ extracted.push((name, value));
3901+ // Rewind the index so the next value can reuse this slot.
3906+ if extracted.is_empty() {
3907+ return (css.to_string(), Vec::new());
3909+ let new_css = substitute_css_vars(css, &extracted);
3910+ (new_css, extracted)
3722/// Format a `line-height` value. CSS accepts either a unitless multiplier
3913/// Format a `line-height` value. CSS accepts either a unitless multiplier
3723/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
3914/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
3724/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
3915/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which