on master help sign in
Add @in-front and @behind directives for overlay positioning
commit6ac94398b3ca0ad02a87c7244ff96dbbcafb0e0f authoriko <mail@iko.soy> dateFri 1 May 2026, 08:27 (5 months ago) parent5f8e139
8 files changed+203−17
DESIGN.md+26 README.md+14 crates/htmlang-core/src/ast.rs+3 crates/htmlang-core/src/codegen.rs+59−17 crates/htmlang-core/src/parser.rs+8 src/bin/htmlang_lsp/completion.rs+9 src/bin/htmlang_lsp/hover.rs+6 tests/snapshots.rs+78
DESIGN.md+26
@@ -59,6 +59,8 @@ A minimalist layout language inspired by elm-ui that compiles to static HTML.
5959| `@address` | address | Contact information |
6060| `@search` | search | Search landmark (HTML5) |
6161| `@breadcrumb` | nav>ol | Semantic breadcrumb navigation |
62+| `@in-front` | div (absolute) | Overlay layer painted on top of parent |
63+| `@behind` | div (absolute) | Layer painted behind the parent's content |
6264
6365Bare lines (not starting with `@` or `[`) are text nodes.
6466
@@ -477,6 +479,30 @@ Progress bar and meter elements. Use `value`, `max`, `min` attributes.
477479@meter [value 6, min 0, max 10, low 3, high 8]
478480```
479481
482+### `@in-front` / `@behind`
483+
484+Overlay layers, inspired by elm-ui's `inFront` and `behind`. Children of these
485+directives are absolutely positioned to fill the parent's bounds; the parent
486+automatically becomes a positioning context (`position: relative` and
487+`isolation: isolate`).
488+
489+```
490+@el [width 200, height 200, background blue]
491+ @text [color white] Main content
492+ @in-front
493+ @text [color yellow] Painted on top
494+ @behind
495+ @el [background red, width fill, height fill]
496+```
497+
498+`@in-front` paints on top of the parent's content (DOM order ensures it
499+overlays static siblings). `@behind` paints behind (`z-index: -1` keeps it
500+under the siblings, while `isolation: isolate` on the parent prevents it from
501+slipping behind the parent's own background).
502+
503+If you set `position` explicitly on the parent (e.g., `position absolute` for a
504+popup), that wins over the auto-applied `position: relative`.
505+
480506## `@each` destructuring
481507
482508When `@each` has more than one variable and items contain spaces, values are destructured:
README.md+14
@@ -109,6 +109,8 @@ A VS Code extension with syntax highlighting and LSP integration is available in
109109| `@figcaption`| Caption for `@figure` |
110110| `@progress` | Progress bar |
111111| `@meter` | Meter/gauge |
112+| `@in-front` | Overlay layer on top of parent |
113+| `@behind` | Layer behind parent content |
112114
113115### Layout attributes
114116
@@ -139,6 +141,18 @@ A VS Code extension with syntax highlighting and LSP integration is available in
139141 Overlay content
140142```
141143
144+For overlays, prefer `@in-front` and `@behind` — they make the parent a
145+positioning context and stretch to its bounds automatically:
146+
147+```
148+@el [width 200, height 200, background blue]
149+ @text [color white] Main content
150+ @in-front
151+ @text [color yellow] Top layer
152+ @behind
153+ @el [background red, width fill, height fill]
154+```
155+
142156### Pseudo-states and media prefixes
143157
144158Prefix any style attribute with `hover:`, `active:`, or `focus:`:
crates/htmlang-core/src/ast.rs+3
@@ -155,6 +155,9 @@ pub enum ElementKind {
155155 Carousel,
156156 Chip,
157157 Tag,
158+ // Overlay positioning (elm-ui inspired)
159+ InFront,
160+ Behind,
158161 // HTML5 elements
159162 Script,
160163 Noscript,
crates/htmlang-core/src/codegen.rs+59−17
@@ -1571,6 +1571,18 @@ fn strip_string_quotes(val: &str) -> &str {
15711571 val
15721572}
15731573
1574+// True if any direct child is `@in-front` or `@behind`. Such children render
1575+// as absolutely positioned overlays, so the parent automatically becomes a
1576+// positioning context (position:relative + isolation:isolate).
1577+fn has_overlay_children(elem: &Element) -> bool {
1578+ elem.children.iter().any(|child| {
1579+ matches!(
1580+ child,
1581+ Node::Element(e) if matches!(e.kind, ElementKind::InFront | ElementKind::Behind)
1582+ )
1583+ })
1584+}
1585+
15741586fn generate_element(
15751587 elem: &Element,
15761588 parent_kind: Option<&ElementKind>,
@@ -1641,7 +1653,7 @@ fn generate_element(
16411653 }
16421654 // @breadcrumb generates semantic <nav aria-label="breadcrumb"><ol>...</ol></nav>
16431655 if elem.kind == ElementKind::Breadcrumb {
1644− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
1656+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
16451657 let (id, user_class) = extract_id_class(&elem.attrs);
16461658 out.push_str(&ctx.indent());
16471659 out.push_str("<nav aria-label=\"breadcrumb\"");
@@ -1696,7 +1708,9 @@ fn generate_element(
16961708 | ElementKind::Column
16971709 | ElementKind::El
16981710 | ElementKind::Grid
1699− | ElementKind::Stack => "div",
1711+ | ElementKind::Stack
1712+ | ElementKind::InFront
1713+ | ElementKind::Behind => "div",
17001714 ElementKind::Text => "span",
17011715 ElementKind::Paragraph => "p",
17021716 ElementKind::Link => "a",
@@ -1853,6 +1867,8 @@ fn generate_element(
18531867 ElementKind::Noscript => "noscript",
18541868 ElementKind::Address => "address",
18551869 ElementKind::Search => "search",
1870+ ElementKind::InFront => "in-front",
1871+ ElementKind::Behind => "behind",
18561872 ElementKind::H1 => "h1",
18571873 ElementKind::H2 => "h2",
18581874 ElementKind::H3 => "h3",
@@ -1875,7 +1891,14 @@ fn generate_element(
18751891 }
18761892
18771893 // Compute CSS for each state and get a class name
1878− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
1894+ let overlay_children = has_overlay_children(elem);
1895+ let gen_class = compute_class(
1896+ &elem.attrs,
1897+ &elem.kind,
1898+ parent_kind,
1899+ styles,
1900+ overlay_children,
1901+ );
18791902 let (id, user_class) = extract_id_class(&elem.attrs);
18801903
18811904 if ctx.dev && elem.line_num > 0 {
@@ -1953,7 +1976,7 @@ fn generate_element(
19531976 // Critical CSS: inline styles directly instead of using a class
19541977 let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
19551978 if is_critical {
1956− let inline_css = attrs_to_css(&elem.attrs, "", &elem.kind, parent_kind);
1979+ let inline_css = attrs_to_css(&elem.attrs, "", &elem.kind, parent_kind, overlay_children);
19571980 if !inline_css.is_empty() {
19581981 out.push_str(" style=\"");
19591982 out.push_str(&html_escape(&inline_css));
@@ -2059,7 +2082,7 @@ fn generate_self_closing(
20592082 styles: &mut StyleCollector,
20602083 ctx: &mut GenContext,
20612084) {
2062− let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
2085+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles, false);
20632086 let (id, user_class) = extract_id_class(&elem.attrs);
20642087
20652088 let (tag, kind_label) = match elem.kind {
@@ -2255,13 +2278,14 @@ fn compute_class(
22552278 kind: &ElementKind,
22562279 parent_kind: Option<&ElementKind>,
22572280 styles: &mut StyleCollector,
2281+ has_overlay_children: bool,
22582282) -> Option<String> {
2259− let base = attrs_to_css(attrs, "", kind, parent_kind);
2283+ let base = attrs_to_css(attrs, "", kind, parent_kind, has_overlay_children);
22602284
22612285 // Collect pseudo-state overrides
22622286 let mut pseudo = Vec::new();
22632287 for &(prefix, selector) in PSEUDO_PREFIXES {
2264− let css = attrs_to_css(attrs, prefix, kind, parent_kind);
2288+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
22652289 if !css.is_empty() {
22662290 pseudo.push((selector.to_string(), css));
22672291 }
@@ -2291,7 +2315,7 @@ fn compute_class(
22912315 for prefix in &nth_prefixes {
22922316 let expr = &prefix[4..prefix.len() - 1];
22932317 let selector = format!(":nth-child({})", expr);
2294− let css = attrs_to_css(attrs, prefix, kind, parent_kind);
2318+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
22952319 if !css.is_empty() {
22962320 pseudo.push((selector, css));
22972321 }
@@ -2312,7 +2336,7 @@ fn compute_class(
23122336 for prefix in &has_prefixes {
23132337 let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
23142338 let selector = format!(":has({})", inner);
2315− let css = attrs_to_css(attrs, prefix, kind, parent_kind);
2339+ let css = attrs_to_css(attrs, prefix, kind, parent_kind, has_overlay_children);
23162340 if !css.is_empty() {
23172341 pseudo.push((selector, css));
23182342 }
@@ -2322,7 +2346,7 @@ fn compute_class(
23222346 let mut responsive = Vec::new();
23232347 for &(bp_name, _) in BREAKPOINTS {
23242348 let prefix = format!("{}:", bp_name);
2325− let css = attrs_to_css(attrs, &prefix, kind, parent_kind);
2349+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
23262350 if !css.is_empty() {
23272351 responsive.push((bp_name.to_string(), css));
23282352 }
@@ -2332,18 +2356,19 @@ fn compute_class(
23322356 let mut container = Vec::new();
23332357 for &(bp_name, _) in BREAKPOINTS {
23342358 let prefix = format!("cq-{}:", bp_name);
2335− let css = attrs_to_css(attrs, &prefix, kind, parent_kind);
2359+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind, has_overlay_children);
23362360 if !css.is_empty() {
23372361 container.push((bp_name.to_string(), css));
23382362 }
23392363 }
23402364
2341− let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
2342− let print = attrs_to_css(attrs, "print:", kind, parent_kind);
2343− let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind);
2344− let motion_reduce = attrs_to_css(attrs, "motion-reduce:", kind, parent_kind);
2345− let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind);
2346− let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind);
2365+ let dark = attrs_to_css(attrs, "dark:", kind, parent_kind, has_overlay_children);
2366+ let print = attrs_to_css(attrs, "print:", kind, parent_kind, has_overlay_children);
2367+ let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind, has_overlay_children);
2368+ let motion_reduce =
2369+ attrs_to_css(attrs, "motion-reduce:", kind, parent_kind, has_overlay_children);
2370+ let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind, has_overlay_children);
2371+ let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind, has_overlay_children);
23472372
23482373 // Dedupe: if a property is declared twice within a single rule, keep only
23492374 // the last occurrence (element-kind defaults are written before
@@ -2545,11 +2570,18 @@ fn attrs_to_css(
25452570 state_prefix: &str,
25462571 kind: &ElementKind,
25472572 parent_kind: Option<&ElementKind>,
2573+ has_overlay_children: bool,
25482574) -> String {
25492575 let mut css = String::new();
25502576
25512577 // Base element styles only for the default (non-state) pass
25522578 if state_prefix.is_empty() {
2579+ // Elements with @in-front / @behind children become positioning
2580+ // contexts. Pushed before user attrs so an explicit `position` wins
2581+ // via dedupe (only the last declaration of a property is kept).
2582+ if has_overlay_children {
2583+ css.push_str("position:relative;isolation:isolate;");
2584+ }
25532585 match kind {
25542586 ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
25552587 ElementKind::Column
@@ -2613,6 +2645,16 @@ fn attrs_to_css(
26132645 ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
26142646 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;"),
26152647 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;"),
2648+ // Overlay layers fill the parent. Parent gets position:relative
2649+ // applied automatically (see has_overlay_children check below).
2650+ ElementKind::InFront => {
2651+ css.push_str(FLEX_COLUMN);
2652+ css.push_str("position:absolute;inset:0;");
2653+ }
2654+ ElementKind::Behind => {
2655+ css.push_str(FLEX_COLUMN);
2656+ css.push_str("position:absolute;inset:0;z-index:-1;");
2657+ }
26162658 _ => {}
26172659 }
26182660 // Children of @carousel get scroll-snap-align and flex-shrink
crates/htmlang-core/src/parser.rs+8
@@ -3321,6 +3321,8 @@ const KNOWN_ELEMENTS: &[&str] = &[
33213321 "address",
33223322 "search",
33233323 "breadcrumb",
3324+ "in-front",
3325+ "behind",
33243326];
33253327
33263328const KNOWN_DIRECTIVES: &[&str] = &[
@@ -3454,6 +3456,8 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
34543456 "address" => Ok(ElementKind::Address),
34553457 "search" => Ok(ElementKind::Search),
34563458 "breadcrumb" => Ok(ElementKind::Breadcrumb),
3459+ "in-front" => Ok(ElementKind::InFront),
3460+ "behind" => Ok(ElementKind::Behind),
34573461 _ => {
34583462 let all_known: Vec<&str> = KNOWN_ELEMENTS
34593463 .iter()
@@ -5025,6 +5029,8 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
50255029 ElementKind::Address => "@address",
50265030 ElementKind::Search => "@search",
50275031 ElementKind::Breadcrumb => "@breadcrumb",
5032+ ElementKind::InFront => "@in-front",
5033+ ElementKind::Behind => "@behind",
50285034 }
50295035}
50305036
@@ -5062,6 +5068,8 @@ fn is_container(kind: &ElementKind) -> bool {
50625068 | ElementKind::Address
50635069 | ElementKind::Search
50645070 | ElementKind::Breadcrumb
5071+ | ElementKind::InFront
5072+ | ElementKind::Behind
50655073 )
50665074}
50675075
src/bin/htmlang_lsp/completion.rs+9
@@ -236,6 +236,15 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
236236 ("@address", "Contact information (address)"),
237237 ("@search", "Search section (search)"),
238238 ("@breadcrumb", "Breadcrumb navigation (nav with aria)"),
239+ // Overlay positioning
240+ (
241+ "@in-front",
242+ "Overlay layer rendered on top of the parent (absolute, inset 0)",
243+ ),
244+ (
245+ "@behind",
246+ "Background layer rendered behind the parent's content (absolute, inset 0, z-index -1)",
247+ ),
239248 ]
240249 .iter()
241250 .map(|(name, detail)| item(name, CompletionItemKind::KEYWORD, detail, name, range))
src/bin/htmlang_lsp/hover.rs+6
@@ -908,6 +908,12 @@ fn hover_builtin(word: &str) -> Option<String> {
908908 "@stack" => {
909909 "**@stack** \u{2014} Stack container\n\nRenders as `<div>` with `position: relative`. Children can be absolutely positioned on top of each other."
910910 }
911+ "@in-front" => {
912+ "**@in-front** \u{2014} Overlay layer\n\nRenders as `<div>` with `position: absolute; inset: 0`, painted on top of the parent's content. The parent automatically becomes a positioning context (`position: relative; isolation: isolate`).\n\nUsage:\n```\n@el [width 200, height 200, background blue]\n Main content\n @in-front\n @text [color white] Overlay\n```"
913+ }
914+ "@behind" => {
915+ "**@behind** \u{2014} Background layer\n\nRenders as `<div>` with `position: absolute; inset: 0; z-index: -1`, painted behind the parent's content. The parent automatically becomes a positioning context (`position: relative; isolation: isolate`).\n\nUsage:\n```\n@el [padding 40]\n @behind\n @el [background #fef3c7, width fill, height fill]\n Foreground text\n```"
916+ }
911917 "@spacer" => {
912918 "**@spacer** \u{2014} Flexible spacer\n\nRenders as `<div>` with `flex: 1`. Pushes siblings apart in flex containers."
913919 }
tests/snapshots.rs+78
@@ -2551,6 +2551,84 @@ fn element_stack() {
25512551 );
25522552}
25532553
2554+// --- @in-front / @behind overlay layers ---
2555+
2556+#[test]
2557+fn element_in_front() {
2558+ let output = compile(
2559+ "@page T\n@el [width 200, height 200]\n Main\n @in-front\n @text Overlay",
2560+ );
2561+ assert!(
2562+ output.contains("position:absolute"),
2563+ "@in-front should have position:absolute: {}",
2564+ output
2565+ );
2566+ assert!(
2567+ output.contains("inset:0"),
2568+ "@in-front should have inset:0: {}",
2569+ output
2570+ );
2571+}
2572+
2573+#[test]
2574+fn element_behind() {
2575+ let output =
2576+ compile("@page T\n@el [width 200, height 200]\n @behind\n @text Bg\n Foreground");
2577+ assert!(
2578+ output.contains("z-index:-1"),
2579+ "@behind should have z-index:-1: {}",
2580+ output
2581+ );
2582+ assert!(
2583+ output.contains("position:absolute"),
2584+ "@behind should have position:absolute: {}",
2585+ output
2586+ );
2587+}
2588+
2589+#[test]
2590+fn in_front_makes_parent_positioning_context() {
2591+ let output =
2592+ compile("@page T\n@el [width 200, height 200]\n Main\n @in-front\n Overlay");
2593+ assert!(
2594+ output.contains("position:relative"),
2595+ "parent of @in-front should be position:relative: {}",
2596+ output
2597+ );
2598+ assert!(
2599+ output.contains("isolation:isolate"),
2600+ "parent of @in-front should isolate stacking context: {}",
2601+ output
2602+ );
2603+}
2604+
2605+#[test]
2606+fn in_front_does_not_override_explicit_position() {
2607+ let output = compile(
2608+ "@page T\n@el [position absolute, top 0, left 0]\n Main\n @in-front\n Overlay",
2609+ );
2610+ assert!(
2611+ !output.contains("position:relative"),
2612+ "explicit position:absolute should win over auto position:relative: {}",
2613+ output
2614+ );
2615+ assert!(
2616+ output.contains("position:absolute"),
2617+ "explicit position:absolute should be present: {}",
2618+ output
2619+ );
2620+}
2621+
2622+#[test]
2623+fn parent_without_overlay_children_stays_static() {
2624+ let output = compile("@page T\n@el [width 200, height 200]\n Main");
2625+ assert!(
2626+ !output.contains("isolation:isolate"),
2627+ "elements without @in-front/@behind children should not get isolation: {}",
2628+ output
2629+ );
2630+}
2631+
25542632// --- Spacer element ---
25552633
25562634#[test]