Skip to main content

lumen_ir/
layout_ir.rs

1//! `LayoutIR` - Lumen markup as a tree of styled elements, decoupled from
2//! ECS spawning so multiple backends can consume it (runtime spawn, AOT
3//! codegen, typed Rust struct emission, FFI bytecode).
4
5use std::path::{Path, PathBuf};
6
7use thiserror::Error;
8
9// `From` impls below convert each IR spec type into its lumen_core
10// runtime counterpart. They live here (not in lumen-core) because the
11// orphan rule requires the IR types to be in the impl-defining crate;
12// keeping them here also means `.into()` Just Works in spawn.rs without
13// per-call match expressions. Each conversion is total - every spec
14// variant maps to exactly one core variant.
15
16/// Markup parse errors.
17#[derive(Debug, Error)]
18pub enum ParseError {
19    /// Malformed XML/HTML at the lexer level.
20    #[error("xml parse error: {0}")]
21    Xml(String),
22    /// Unknown tag name (not in the supported set).
23    #[error(
24        "unknown tag '{0}' at position {1}; a tag a module brings needs \
25         `tags = [\"{0}\"]` on that module's [dependencies] entry"
26    )]
27    UnknownTag(String, usize),
28    /// Attribute value didn't match its expected form.
29    #[error("bad attribute '{name}=\"{value}\"' on <{tag}>: {reason}")]
30    BadAttribute {
31        /// Attribute name.
32        name: String,
33        /// Raw value seen.
34        value: String,
35        /// Owning tag.
36        tag: String,
37        /// Why the parser rejected it.
38        reason: String,
39    },
40    /// A `<include>` or `@import` directive could not be resolved: a
41    /// missing file, a malformed directive, or a cycle. The message
42    /// carries the include-site position and, for cycles, the full chain.
43    #[error("{0}")]
44    Include(String),
45}
46
47/// serde adapter for [`Attributes::dir`]. `lumen-core` carries no serde
48/// dependency, so `LayoutDirection` cannot derive `Serialize`/`Deserialize`
49/// itself; this module maps `Option<LayoutDirection>` to a stable, compact
50/// `Option<u8>` (`0 = Auto`, `1 = Ltr`, `2 = Rtl`) for the AOT artifact.
51/// Keeping the mapping here (rather than adding serde to lumen-core) keeps
52/// the parser-independent runtime free of an extra core dependency.
53pub mod layout_direction_serde {
54    use lumen_core::components::LayoutDirection;
55    use serde::{Deserialize, Deserializer, Serialize, Serializer};
56
57    /// Serialize `Option<LayoutDirection>` as `Option<u8>`.
58    pub fn serialize<S: Serializer>(v: &Option<LayoutDirection>, s: S) -> Result<S::Ok, S::Error> {
59        let tag: Option<u8> = v.map(|d| match d {
60            LayoutDirection::Auto => 0,
61            LayoutDirection::Ltr => 1,
62            LayoutDirection::Rtl => 2,
63        });
64        tag.serialize(s)
65    }
66
67    /// Deserialize `Option<u8>` back into `Option<LayoutDirection>`.
68    /// Unknown tags degrade to [`LayoutDirection::Auto`], mirroring the
69    /// `From<&str>` parser fallback.
70    pub fn deserialize<'de, D: Deserializer<'de>>(
71        d: D,
72    ) -> Result<Option<LayoutDirection>, D::Error> {
73        let tag = Option::<u8>::deserialize(d)?;
74        Ok(tag.map(|t| match t {
75            1 => LayoutDirection::Ltr,
76            2 => LayoutDirection::Rtl,
77            _ => LayoutDirection::Auto,
78        }))
79    }
80}
81
82/// The `<script>` elements one markup source carries, read without building
83/// its tree.
84///
85/// The blocks a candela script writes become fragments, and markup names one
86/// of those by writing the function as a tag, so the scripts have to be known
87/// before the tree is built. Reading them is a walk of the `<script>`
88/// elements, which is all this carries; the tree build collects the same set
89/// into [`LayoutIR::script_source`] and [`LayoutIR::external_scripts`].
90#[derive(Debug, Clone, Default, PartialEq, Eq)]
91pub struct ScriptRefs {
92    /// Body text of every inline block, in source order, newline separated.
93    pub inline: String,
94    /// `src` of every external block, in source order.
95    pub external: Vec<String>,
96}
97
98/// Top of the IR. Single root element; multi-rooted markup is illegal.
99#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
100pub struct LayoutIR {
101    /// The single root element.
102    pub root: Element,
103    /// Concatenated body text of every inline `<script>` tag found in
104    /// the markup, in source order, separated by `\n`. Empty if none.
105    ///
106    /// Use `<script src="foo.cdl"/>` instead when the script contains
107    /// characters that confuse the XML parser (`<`, `<=`, `&`) - those
108    /// references land in [`external_scripts`].
109    pub script_source: String,
110    /// Where each inline block in [`Self::script_source`] was written: its
111    /// markup file and the line its text starts on, so an error in it names
112    /// that line.
113    pub script_map: crate::source_map::SourceMap,
114    /// Paths from every `<script src="..."/>` reference, in source
115    /// order. The runtime (`lumenc::run::load_ir`) resolves them
116    /// relative to the app directory and concatenates with
117    /// `script_source` before handing the combined source to the
118    /// scripting host.
119    pub external_scripts: Vec<String>,
120    /// Opt-in skin requested by the root element via `<root skin="...">`.
121    /// `None` means the bare framework - no default styling, fully
122    /// honouring the SDD "no opinionated default theme" principle. When
123    /// `Some("default")` (the only recognised value today), the runtime
124    /// prepends an embedded `lumen-skin-default` stylesheet before
125    /// parsing the user's `main.css`.
126    pub skin: Option<String>,
127    /// `<root frameless="true">` - suppress OS window chrome (title
128    /// bar, borders, close/min/max buttons). The window backend reads
129    /// this once at startup to set `WindowAttributes::decorations(false)`.
130    /// Custom title bars are the app's responsibility.
131    pub frameless: bool,
132    /// Native menu bar, collected from the top-level `<menubar>`
133    /// element (if any) and stripped from the layout tree. `None` =
134    /// no menu bar. Each entry maps to a `muda::Submenu` /
135    /// `MenuItem` / `PredefinedMenuItem::separator`.
136    pub menubar: Option<MenuBarSpec>,
137    /// Concatenated skin + user CSS, kept around so the for-block
138    /// reconciler can re-apply matching rules to virtualized template
139    /// instances *after* `{kind}`/`{level}` etc. placeholders have
140    /// been substituted into class names at runtime. Without this,
141    /// `<label class="md-h{level}">` never picks up `.md-h1` because
142    /// the parse-time apply pass only saw the literal placeholder.
143    pub combined_stylesheet: Option<crate::css::Stylesheet>,
144    /// Parse-time lint findings. Populated by the markup walker for
145    /// problems that don't invalidate the IR: `{name}` bare
146    /// interpolation, a boolean attribute with an off-list value, an
147    /// attribute nothing reads. The runtime / IR consumers ignore this;
148    /// every compile path (`lumenc check`, `run`, `build`) prints it to
149    /// stderr at the finding's own severity, and `lumenc lint --signals`
150    /// folds it into the finding stream.
151    pub lint_findings: Vec<LintFinding>,
152    /// Normalized paths of every `.lmn` file pulled in via
153    /// `<include src="..."/>` (transitively, in resolution order). Empty
154    /// when the markup used no includes or was parsed without a file
155    /// loader. The runtime (`lumenc::run`) adds these to the hot-reload
156    /// watch set so editing an included file re-triggers a reload, exactly
157    /// like [`external_scripts`].
158    pub included_files: Vec<PathBuf>,
159}
160
161/// Severity tier for a parse-time [`LintFinding`].
162///
163/// Mirrors the lint-CLI severity ladder. Kept here (rather than
164/// importing the CLI's enum) so the parser can populate findings
165/// without pulling the lint module into the IR's dependency graph.
166#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
167pub enum LintSeverity {
168    /// Definitive bug - would fail a build in strict mode.
169    Error,
170    /// Probably wrong - flagged in `--strict`, advisory otherwise.
171    Warn,
172    /// Stylistic / migration nudge.
173    Info,
174    /// Lowest priority - likely dead code.
175    Hint,
176}
177
178/// Category bucket for a parse-time [`LintFinding`]. New variants
179/// land here as the parser learns more rules.
180#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
181pub enum LintKind {
182    /// `{name}` interpolation without the explicit `$` prefix; the
183    /// preferred form is `{$name}` (global) or `{$self.field}` /
184    /// `{$parent.field}` (per-entity).
185    BareInterpolation,
186    /// A boolean attribute carrying a value outside the shared
187    /// truthiness set (`true` / `yes` / `1` / bare for true,
188    /// `false` / `no` / `0` for false). The attribute reads as false.
189    BooleanAttribute,
190    /// An attribute the markup vocabulary has no meaning for. It is
191    /// dropped: nothing reads it at spawn time, so a typo (`tect=` for
192    /// `text=`) or a web-only attribute silently does nothing.
193    UnknownAttribute,
194}
195
196impl From<LintKind> for &'static str {
197    fn from(k: LintKind) -> &'static str {
198        match k {
199            LintKind::BareInterpolation => "bare-interpolation",
200            LintKind::BooleanAttribute => "boolean-attribute",
201            LintKind::UnknownAttribute => "unknown-attribute",
202        }
203    }
204}
205
206impl From<LintSeverity> for &'static str {
207    fn from(s: LintSeverity) -> &'static str {
208        match s {
209            LintSeverity::Error => "error",
210            LintSeverity::Warn => "warn",
211            LintSeverity::Info => "info",
212            LintSeverity::Hint => "hint",
213        }
214    }
215}
216
217/// One parse-time finding. Carries a 1-based (line, col) anchor in
218/// the source so downstream tools can surface a diagnostic at the
219/// exact byte where the legacy shape lives.
220#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
221pub struct LintFinding {
222    /// Category bucket.
223    pub kind: LintKind,
224    /// Severity tier.
225    pub severity: LintSeverity,
226    /// Human-readable description.
227    pub message: String,
228    /// 1-based line number in the source.
229    pub line: usize,
230    /// 1-based column.
231    pub col: usize,
232    /// Suggested replacement text (`Some("{$count}")` for the bare
233    /// interpolation rule). `None` when no machine-applicable fix is
234    /// available.
235    pub suggest: Option<String>,
236}
237
238impl LintFinding {
239    /// Render the finding as the stderr diagnostic the compile paths
240    /// print: one severity-prefixed line anchored at `file:line:col`,
241    /// plus a `hint:` line when the rule carries a machine-applicable
242    /// fix. Every path that compiles markup from source calls this, so
243    /// `check`, `run` and `build` cannot drift apart on the wording.
244    pub fn render(&self, file: &Path) -> String {
245        let mut out = format!(
246            "{sev:<5} {file}:{line}:{col}  [{kind}] {msg}",
247            sev = <&'static str>::from(self.severity),
248            file = file.display(),
249            line = self.line,
250            col = self.col,
251            kind = <&'static str>::from(self.kind),
252            msg = self.message,
253        );
254        if let Some(s) = &self.suggest {
255            out.push_str(&format!("\n      hint: replace with `{s}`"));
256        }
257        out
258    }
259}
260
261/// Parsed `<menubar>` content. Top-level submenus + their items.
262#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
263pub struct MenuBarSpec {
264    /// Top-level submenus, in source order.
265    pub menus: Vec<MenuSpec>,
266}
267
268/// One `<menu label="File">...</menu>` submenu.
269#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
270pub struct MenuSpec {
271    /// Display label shown in the menu bar.
272    pub label: String,
273    /// Items inside the submenu, in source order.
274    pub items: Vec<MenuEntrySpec>,
275}
276
277/// One entry inside a submenu - either a clickable item or a
278/// separator line.
279#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
280pub enum MenuEntrySpec {
281    /// `<menuitem id="open" label="Open" accel="Cmd+O" />`.
282    Item {
283        /// `id="..."` - passed back to scripts as `on_menu(id)`.
284        id: String,
285        /// Display label.
286        label: String,
287        /// Optional accelerator string in muda format
288        /// (`"CommandOrControl+S"`, `"Alt+F4"`, ...).
289        accelerator: Option<String>,
290    },
291    /// `<separator />`.
292    Separator,
293}
294
295/// One node in the IR tree.
296#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
297pub struct Element {
298    /// Tag name (`root`, `column`, `tile`, `label`, ...).
299    pub tag: String,
300    /// Resolved attribute bag in normalized form.
301    pub attrs: Attributes,
302    /// Children, in source order.
303    pub children: Vec<Element>,
304    /// Round-8 wave-C: catalog of `{...}` placeholder slots that appear in
305    /// this element's string-valued attributes (text, id, classes, ...).
306    /// Populated by the parser so the spawner / for-block reconciler can
307    /// look up which scope to resolve each placeholder from instead of
308    /// falling back to substring replace against the global signal map.
309    ///
310    /// Order matches first-appearance source order across the scanned
311    /// attribute set; duplicates inside the same element collapse to one
312    /// entry. Empty for elements with no interpolation sites.
313    pub interpolations: Vec<InterpolationSlot>,
314    /// Set when this element instantiates a fragment rather than rendering
315    /// itself. `None` on every ordinary element, which is nearly all of
316    /// them; boxed so carrying the possibility costs one pointer on an
317    /// element the `<for>` reconciler deep-clones per row.
318    pub frag_use: Option<Box<FragmentUse>>,
319}
320
321/// A fragment instantiation, recorded on the element that stands in for the
322/// fragment's body until the tree is expanded.
323///
324/// The declaration side is [`crate::fragment::Fragment`], looked up by
325/// [`Self::key`] in the app's [`crate::fragment::FragmentTable`].
326#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
327pub struct FragmentUse {
328    /// Key of the fragment being instantiated.
329    pub key: String,
330    /// Arguments the use site passes, as `(parameter name, value)` pairs in
331    /// source order. A parameter absent here takes its declared default.
332    pub args: Vec<(String, String)>,
333    /// Whether the use site supplies children of its own for the fragment's
334    /// slot. The children themselves stay on [`Element::children`]; this
335    /// says they are slot content rather than the element's own subtree.
336    pub slot_children: bool,
337}
338
339impl Default for Element {
340    /// Empty element with no tag, default attrs, no children, no
341    /// interpolation slots, no fragment use. Synthetic-element
342    /// constructors in the parser (`<tabs>`, `<dropdown>`, ...) use
343    /// `..Default::default()` to fill in the trailing fields
344    /// automatically.
345    fn default() -> Self {
346        Self {
347            tag: String::new(),
348            attrs: Attributes::default(),
349            children: Vec::new(),
350            interpolations: Vec::new(),
351            frag_use: None,
352        }
353    }
354}
355
356/// Rewrite every `<image src>` under `el` that points inside `root` to a
357/// path relative to it, recording in `outside` the ones that point
358/// elsewhere.
359///
360/// The inverse of the compiler's asset resolution, which makes every `src`
361/// absolute against the app directory on the machine that compiles. That is
362/// the right answer for running the app in place and the wrong one for an
363/// artifact that travels, so anything shipping an IR elsewhere (a packaged
364/// folder, a web build) puts the paths back first.
365///
366/// Rewritten paths are joined with forward slashes whatever the compiling
367/// machine uses: every platform's loader accepts them, so an artifact built
368/// for another platform names its files in a way that platform can follow.
369pub fn relativize_asset_paths(el: &mut Element, root: &Path, outside: &mut Vec<String>) {
370    if el.tag == "image"
371        && let Some(path) = &el.attrs.src
372    {
373        let p = Path::new(path);
374        if p.is_absolute() {
375            match p.strip_prefix(root) {
376                Ok(rel) => {
377                    let parts: Vec<String> = rel
378                        .components()
379                        .map(|c| c.as_os_str().to_string_lossy().into_owned())
380                        .collect();
381                    el.attrs.src = Some(parts.join("/"));
382                }
383                Err(_) => outside.push(path.clone()),
384            }
385        }
386    }
387    for child in &mut el.children {
388        relativize_asset_paths(child, root, outside);
389    }
390}
391
392/// Round-8 wave-C: classification of a `{...}` placeholder inside markup.
393/// Carries the *resolution scope* the spawner consults when materializing
394/// the slot into a concrete string.
395///
396/// - `Global("name")` - `{$name}` or the legacy bare `{name}`. Resolves
397///   against [`lumen_core::signals::Signals`] (the global signal map).
398/// - `Row("field")` - `{row.field}`, only meaningful inside a `<for>`
399///   body. Resolves against the current iteration's
400///   [`lumen_core::signals::ArrayItem`] record. Crucially does not fall
401///   through to globals when the row record is missing the field - that
402///   substitution emits empty string and a one-shot `tracing::warn!`.
403/// - `RowIndex` - `{$index}` (preferred) or the legacy `{idx}` alias.
404///   Resolves to the 0-based iteration index, stringified.
405/// - `SelfField("f")` / `ParentField("f")` - `{$self.f}` / `{$parent.f}`.
406///   Stub today; the per-entity consumer system lands in a follow-up
407///   wave. Substitutes empty string and emits a debug trace.
408/// - `Arg("name")` - a fragment parameter, resolved from the arguments the
409///   use site passed. Only a fragment body carries these: the classifier
410///   below cannot produce one, because telling a parameter from a global
411///   needs the enclosing fragment's parameter list, which it cannot see.
412///   The fragment builder rewrites `Global` to `Arg` for each declared
413///   parameter once that scope is known.
414#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
415pub enum InterpolationSlot {
416    /// `{$name}` or `{name}` legacy shorthand - global signal lookup.
417    Global(String),
418    /// `{row.field}` - iteration-scope field lookup. `<for>` body only.
419    Row(String),
420    /// `{$index}` or `{idx}` legacy alias - 0-based iteration index.
421    RowIndex,
422    /// `{$self.field}` - per-entity field lookup. Stubbed today.
423    SelfField(String),
424    /// `{$parent.field}` - parent-entity field lookup. Stubbed today.
425    ParentField(String),
426    /// A fragment parameter, resolved from the instantiating
427    /// [`FragmentUse::args`] or the parameter's declared default.
428    Arg(String),
429}
430
431impl From<&str> for InterpolationSlot {
432    /// Classify a placeholder's *trimmed inner body* (without the
433    /// surrounding `{...}`). The parser's interpolation walker uses this
434    /// to convert each placeholder it finds into a typed slot:
435    ///
436    /// - `"$index"` / `"idx"` -> [`InterpolationSlot::RowIndex`].
437    /// - `"$self.<f>"` -> [`InterpolationSlot::SelfField`].
438    /// - `"$parent.<f>"` -> [`InterpolationSlot::ParentField`].
439    /// - `"row.<f>"` -> [`InterpolationSlot::Row`].
440    /// - `"$name"` / `"name"` -> [`InterpolationSlot::Global`].
441    ///
442    /// [`InterpolationSlot::Arg`] is never produced here: a parameter
443    /// reference is spelled the same as a global, and only the enclosing
444    /// fragment's parameter list separates them.
445    fn from(inner: &str) -> Self {
446        let trimmed = inner.trim();
447        // `idx` is the legacy row-index alias; only meaningful inside
448        // a `<for>` body, but the From impl can't see that context so
449        // we always route it to [`RowIndex`]. The parser still emits
450        // a `BareInterpolation` finding nudging the author to write
451        // `{$index}`.
452        if trimmed == "$index" || trimmed == "idx" {
453            return InterpolationSlot::RowIndex;
454        }
455        if let Some(rest) = trimmed.strip_prefix("$self.") {
456            return InterpolationSlot::SelfField(rest.to_string());
457        }
458        if let Some(rest) = trimmed.strip_prefix("$parent.") {
459            return InterpolationSlot::ParentField(rest.to_string());
460        }
461        if let Some(rest) = trimmed.strip_prefix("row.") {
462            return InterpolationSlot::Row(rest.to_string());
463        }
464        if let Some(rest) = trimmed.strip_prefix('$') {
465            return InterpolationSlot::Global(rest.to_string());
466        }
467        InterpolationSlot::Global(trimmed.to_string())
468    }
469}
470
471/// Length specifier as it appears in markup.
472#[derive(Debug, Default, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
473pub enum LengthSpec {
474    /// `auto`.
475    #[default]
476    Auto,
477    /// Pixels.
478    Px(f32),
479    /// Percentage of parent.
480    Percent(f32),
481}
482
483/// CSS `line-height` value. Unlike [`LengthSpec`], the two forms carry
484/// different meanings rather than different units of the same quantity:
485/// a bare number (`line-height: 1.2`) scales with the element's own
486/// font size, while a `px` value (`line-height: 19px`) is a fixed line
487/// box height that does not track font-size changes. Kept as two
488/// variants instead of collapsing to a single px number so a consumer
489/// can tell which behavior the author asked for.
490#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
491pub enum LineHeightSpec {
492    /// Unitless multiplier of the element's font size (CSS `normal`-like
493    /// scaling behavior).
494    Multiplier(f32),
495    /// Fixed line height in px, independent of font size.
496    Px(f32),
497}
498
499/// Four edge values (in CSS order: left, right, top, bottom).
500///
501/// W5.5 adds CSS Logical Properties Level 1 fields (`inline_start`,
502/// `inline_end`, `block_start`, `block_end`). When set, they ride along
503/// to the lumen-core `Edges` whose `resolved(dir)` method maps them onto
504/// the physical sides at layout time per [`ResolvedDirection`].
505#[derive(Debug, Clone, Copy, Default, PartialEq, serde::Serialize, serde::Deserialize)]
506pub struct Edges {
507    /// Left edge in px.
508    pub left: f32,
509    /// Right edge in px.
510    pub right: f32,
511    /// Top edge in px.
512    pub top: f32,
513    /// Bottom edge in px.
514    pub bottom: f32,
515    /// `*-inline-start` in px - maps to `left` (LTR) / `right` (RTL).
516    pub inline_start: Option<f32>,
517    /// `*-inline-end` in px - maps to `right` (LTR) / `left` (RTL).
518    pub inline_end: Option<f32>,
519    /// `*-block-start` in px - alias for `top` (no vertical writing modes yet).
520    pub block_start: Option<f32>,
521    /// `*-block-end` in px - alias for `bottom`.
522    pub block_end: Option<f32>,
523    /// CSS percent unit for the left edge (`padding: 5%`). When `Some`
524    /// the px field for the side is ignored; the layout backend hands
525    /// taffy a `LengthPercentage::percent` which resolves against the
526    /// containing block per CSS.
527    pub pct_left: Option<f32>,
528    /// See [`Self::pct_left`].
529    pub pct_right: Option<f32>,
530    /// See [`Self::pct_left`].
531    pub pct_top: Option<f32>,
532    /// See [`Self::pct_left`].
533    pub pct_bottom: Option<f32>,
534}
535
536impl Edges {
537    /// Every side `auto` (`NaN`), the initial value of `inset`. A side an
538    /// `inset` longhand does not name stays `auto`.
539    pub const fn auto() -> Self {
540        Self::all(f32::NAN)
541    }
542
543    /// Uniform edges.
544    pub const fn all(v: f32) -> Self {
545        Self {
546            left: v,
547            right: v,
548            top: v,
549            bottom: v,
550            inline_start: None,
551            inline_end: None,
552            block_start: None,
553            block_end: None,
554            pct_left: None,
555            pct_right: None,
556            pct_top: None,
557            pct_bottom: None,
558        }
559    }
560}
561
562/// Parsed `<tooltip text="..." delay="...">` payload, propagated from
563/// the wrapper to its single child as
564/// [`Attributes::tooltip`](crate::layout_ir::Attributes::tooltip).
565#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
566pub struct TooltipSpec {
567    /// Body text shown in the popup.
568    pub text: String,
569    /// `translatable="<key>"` on the `<tooltip>` wrapper - the catalogue
570    /// key the popup body resolves through. The wrapper is the element
571    /// that authors the string, so it takes a plain key of its own rather
572    /// than an attribute on the trigger's; the trigger may not keep one,
573    /// since a `<checkbox>` moves its key onto the caption it synthesizes.
574    pub translatable: Option<String>,
575    /// Hover dwell before the popup appears, in milliseconds. `None` =
576    /// author didn't set `delay="..."` inline; the cascade fills it from
577    /// the `--lumen-tooltip-delay` skin token when declared, and the
578    /// spawn layer falls back to the runtime default (500) last.
579    pub delay_ms: Option<u32>,
580    /// Gap between the cursor hotspot and the popup's top-left corner,
581    /// in logical pixels. Same resolution chain as [`Self::delay_ms`]:
582    /// inline attr -> `--lumen-tooltip-offset` token -> runtime default.
583    pub offset: Option<f32>,
584}
585
586/// Synthesised `<dropdown>` header payload: the open signal plus the
587/// full option list mirrored from the `<option>` children (markup
588/// order), so the runtime header component can run keyboard
589/// interaction while the popup body is unmounted.
590#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
591pub struct DropdownButtonSpec {
592    /// Open-panel signal (`__dropdown_open:<bind-value>`).
593    pub open_signal: String,
594    /// Author-bound value signal (`bind-value=`).
595    pub value_signal: String,
596    /// `(value, label, disabled)` per `<option>`, in markup order.
597    pub options: Vec<(String, String, bool)>,
598}
599
600/// Parsed `bg=` value. Solid colors, gradients, and images share one
601/// attribute surface so authors don't pick between `bg=` and `bg-gradient=`.
602#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
603pub enum BgSpec {
604    /// Solid color.
605    Solid(Rgba),
606    /// CSS-style linear gradient.
607    Linear {
608        /// Angle in degrees (CSS convention: 0deg = bottom->top, 90 = left->right).
609        angle_deg: f32,
610        /// `(offset, color)` pairs in source order, after parse-time
611        /// normalization (sorted by offset ascending).
612        stops: Vec<(f32, Rgba)>,
613    },
614    /// CSS-style radial gradient centred on the rect.
615    Radial {
616        /// Normalized radius `0..=1` of half-min-dim.
617        radius: f32,
618        /// `(offset, color)` pairs ascending offset.
619        stops: Vec<(f32, Rgba)>,
620    },
621    /// CSS-style conic (sweep) gradient.
622    Conic {
623        /// Starting angle in degrees (CSS: 0 = north, 90 = east).
624        from_deg: f32,
625        /// `(offset, color)` pairs ascending offset.
626        stops: Vec<(f32, Rgba)>,
627    },
628    /// `url("path")`: an image painted behind the element's content, sized by
629    /// [`Attributes::bg_fit`]. The path is as authored; it resolves against
630    /// the app directory the way an `<image src>` does.
631    Image(String),
632}
633
634/// RGBA color in `[0,1]^4`.
635#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
636pub struct Rgba {
637    /// Red.
638    pub r: f32,
639    /// Green.
640    pub g: f32,
641    /// Blue.
642    pub b: f32,
643    /// Alpha.
644    pub a: f32,
645}
646
647/// Scroll axis declared in markup.
648#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
649pub enum ScrollAxisSpec {
650    /// Vertical scroll only.
651    Y,
652    /// Horizontal scroll only.
653    X,
654    /// Both axes.
655    Both,
656}
657
658/// Resolved attribute bag; one struct so the parser fills it in a single
659/// pass and the spawner / codegen don't have to re-parse strings.
660#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
661pub struct Attributes {
662    /// `width` attribute.
663    pub width: Option<LengthSpec>,
664    /// `height` attribute.
665    pub height: Option<LengthSpec>,
666    /// `flex` attribute (`row` / `column`).
667    pub flex: Option<FlexAxis>,
668    /// `bg` attribute (background color).
669    pub bg: Option<BgSpec>,
670    /// `radius` attribute in px (uniform).
671    pub radius: Option<f32>,
672    /// Per-corner radii `[top-left, top-right, bottom-right,
673    /// bottom-left]` in px - the CSS `border-radius` 2-4 value
674    /// shorthand and the `border-top-left-radius` ... longhands. When
675    /// set, wins over the uniform [`Self::radius`] for paint; the
676    /// uniform slot is kept in sync (max corner) for the runtime paths
677    /// that only understand one number (knob geometry, hit rings).
678    pub radius_corners: Option<[f32; 4]>,
679    /// `padding` attribute.
680    pub padding: Option<Edges>,
681    /// `margin` attribute.
682    pub margin: Option<Edges>,
683    /// `text` content (also accepts text child node).
684    pub text: Option<String>,
685    /// `text-color`.
686    pub text_color: Option<Rgba>,
687    /// `selection-color` - text selection highlight. Skins default it
688    /// via the `--lumen-selection` token; unset falls back to the
689    /// renderer default (text fill at 32 % alpha).
690    pub selection_color: Option<Rgba>,
691    /// `caret-color` - text-input caret tint (CSS). Lands on
692    /// [`lumen_core::components::TextInputPaint::caret_color`]; unset
693    /// falls back to the text fill (web default). Only meaningful on
694    /// `<input>` / `<textarea>`.
695    pub caret_color: Option<Rgba>,
696    /// `selection-text-color` - selected-glyph foreground (Qt
697    /// `QPalette::HighlightedText` / Slint `selection-foreground-color`).
698    /// Lands on
699    /// [`lumen_core::components::TextInputPaint::selection_foreground`];
700    /// unset => selected glyphs keep their normal fill. Only meaningful on
701    /// `<input>` / `<textarea>`.
702    pub selection_text_color: Option<Rgba>,
703    /// `scroll` axis.
704    pub scroll: Option<ScrollAxisSpec>,
705    /// `sensitivity` (scroll containers only).
706    pub sensitivity: Option<f32>,
707    /// `inertia` (scroll containers only).
708    pub inertia: Option<f32>,
709    /// `tab-index`.
710    pub tab_index: Option<i32>,
711    /// `id="..."` - emits a `LumenId` marker.
712    pub id: Option<String>,
713    /// `class="a b"` - emits a `LumenClasses` marker (split on whitespace).
714    pub classes: Vec<String>,
715    /// `hover-bg` - when set, the entity gains a `HoverTint` component
716    /// whose value the primitives crate swaps in on hover.
717    pub hover_bg: Option<Rgba>,
718    /// `draggable="true"` - entity gains a `Draggable` marker so drag
719    /// moves translate its Transform automatically.
720    pub draggable: bool,
721    /// `drag-payload="..."` - makes the entity an in-app DnD
722    /// [`DragSource`](lumen_os_dnd::DragSource) publishing this text
723    /// payload (interpolated per-row inside a `<for>`). An empty value
724    /// derives the payload from the element's `id`. Mirrors HTML5
725    /// `dataTransfer.setData` / Qt `QMimeData`.
726    pub drag_payload: Option<String>,
727    /// `accept="text/plain"` on a drop target - MIME filter
728    /// ([`DropAccept`](lumen_os_dnd::DropAccept)). Absent = accept any.
729    pub drop_accept: Option<String>,
730    /// `placeholder="..."` on `<input>` - shown when [`text`] is empty.
731    pub placeholder: Option<String>,
732    /// `drop="true"` - entity gains a `DropTarget` marker so OS file
733    /// drags landing on its bounds fire `on_file_dropped(id, path)`.
734    pub drop_target: bool,
735    /// `drag="true"` on `<title-bar>` - entity gains a
736    /// `TitleBarDraggable` marker. Pressing the bar requests a native
737    /// window drag via the platform backend. Only meaningful in
738    /// conjunction with `<root frameless="true">`.
739    pub title_bar_drag: bool,
740    /// `layout-boundary="true"` - entity gains a `RelayoutBoundary`
741    /// marker so layout-dirty propagation stops here. Auto-set for
742    /// `<scroll>` containers and entities with both fixed width and
743    /// height; the explicit attribute lets authors override.
744    pub layout_boundary: bool,
745    /// Tooltip spec carried by the single child of a `<tooltip
746    /// text="..." delay="...">` element after the parser flattens the
747    /// wrapper away. `None` = no tooltip. Spawns a `TooltipSource`
748    /// component at spawn time.
749    pub tooltip: Option<TooltipSpec>,
750    /// Synthetic tab-strip-button attachment generated by the
751    /// `<tabs>` parser pass: `(signal_name, tab_value)`. When set,
752    /// spawn attaches a `TabStripButton` component so clicking the
753    /// button writes `signal_name = tab_value`.
754    pub tab_strip: Option<(String, String)>,
755    /// Synthetic "seed this signal on first spawn if absent"
756    /// directive. `(signal_name, default_value)`. The `<tabs>` pass
757    /// authors it for the default-active tab, and the `<dropdown>`
758    /// pass for the open-panel flag plus (on the generated header
759    /// button) the first option's value.
760    pub signal_seed: Option<(String, String)>,
761    /// `src="path/to/file.png"` on `<image>` - kicks off an async
762    /// decode via `lumen-assets`. The entity gets a `LoadedImage`
763    /// once the bytes are ready.
764    pub src: Option<String>,
765    /// `alt="a cat asleep on a keyboard"` on `<image>` - what the image
766    /// shows, for a reader who is not looking at it. The web target writes
767    /// it out as the `alt` attribute; nothing on the desktop reads it yet.
768    /// An `alt=""` an author wrote deliberately is kept as an empty string,
769    /// which is how a decorative image is marked. A `translatable="<key>"`
770    /// on the same element resolves it through `<key>.alt`.
771    pub alt: Option<String>,
772    /// `href="settings"` on `<a>` - the target page path for file-based
773    /// navigation. The spawner attaches an `Anchor` component so a click
774    /// on the element navigates the active page. Resolved by longest
775    /// existing `.lmn` prefix at navigation time, never here.
776    pub href: Option<String>,
777    /// `font-size="N"` - pixel size for rendered text. Inherited
778    /// implicitly via the `TextStyle` component; absent = 16px.
779    pub font_size: Option<f32>,
780    /// CSS `font-family` - the raw family list as authored (commas
781    /// preserved, quotes stripped per-name at spawn). Inherited like
782    /// `font-size`. The shaper resolves the first available family in
783    /// the list against the system font database, honouring the CSS
784    /// generic keywords (`sans-serif`, `serif`, `monospace`, ...); an
785    /// unresolvable list falls back to the platform sans-serif.
786    pub font_family: Option<String>,
787    /// CSS `font-weight` - `100..=1000`, `normal` (400), `bold` (700).
788    /// Inherited. `None` = 400.
789    pub font_weight: Option<u16>,
790    /// `style="display-lg"` - Material 3-style typography role. The
791    /// parser resolves the role to a concrete pixel size + sets
792    /// [`font_size`] if it isn't already explicit.
793    pub style_role: Option<String>,
794    /// `gap="12"` - spacing inserted between every adjacent pair of
795    /// children inside a flex container. Mirrors CSS `gap` (px).
796    pub gap: Option<f32>,
797    /// `row-gap="12"` - per-axis row gap (W5.9).
798    pub gap_row: Option<f32>,
799    /// `column-gap="12"` - per-axis column gap (W5.9).
800    pub gap_column: Option<f32>,
801    /// CSS `gap: 5%` - percent gap on both axes. Wins over [`Self::gap`]
802    /// for the axis; resolves against the container's content box.
803    pub gap_pct: Option<f32>,
804    /// CSS `row-gap: 5%`.
805    pub gap_row_pct: Option<f32>,
806    /// CSS `column-gap: 5%`.
807    pub gap_column_pct: Option<f32>,
808    /// `display="grid|flex|none"` (W5.9). When `None` the spawner
809    /// defaults to `Display::Flex`.
810    pub display: Option<DisplaySpec>,
811    /// Parsed `grid-template-rows` + `grid-template-columns` (W5.9).
812    pub grid_template: Option<GridTemplateSpec>,
813    /// `grid-row="<start> <end>"` (W5.9). `(0, 0)` = auto-place.
814    pub grid_row: Option<(i16, i16)>,
815    /// `grid-column="<start> <end>"` (W5.9).
816    pub grid_column: Option<(i16, i16)>,
817    /// `align-self="..."` (W5.9 - per-item override of container align).
818    pub align_self: Option<FlexAlign>,
819    /// `justify-items="..."` (W5.9 - grid-only inline-axis alignment).
820    pub justify_items: Option<FlexAlign>,
821    /// `justify-self="..."` (W5.9 - per-item override of justify-items).
822    pub justify_self: Option<FlexAlign>,
823    /// `grow="1"` - flex-grow factor. Mirrors CSS `flex-grow`. Default
824    /// 0 (no grow). `<spacer />` sets this to 1 implicitly so it
825    /// pushes neighbouring elements to opposite edges.
826    pub grow: Option<f32>,
827    /// `align="center|start|end|stretch"` - cross-axis alignment of children
828    /// (CSS `align-items`).
829    pub align: Option<FlexAlign>,
830    /// `justify="center|start|end|between|around|evenly"` - main-axis
831    /// distribution of children (CSS `justify-content`).
832    pub justify: Option<FlexJustify>,
833    /// `text-align="start|center|end"` - horizontal alignment of rendered
834    /// text inside the element's content rectangle.
835    pub text_align: Option<TextAlignSpec>,
836    /// `press-bg="#color"` - color shown while the entity is pressed.
837    pub press_bg: Option<Rgba>,
838    /// `:hover { text-color }` - text color swapped in while hovered.
839    pub hover_text_color: Option<Rgba>,
840    /// `:active { text-color }` - text color while pressed.
841    pub active_text_color: Option<Rgba>,
842    /// `:focus { text-color }` - text color while focused.
843    pub focus_text_color: Option<Rgba>,
844    /// `:disabled { text-color }` - text color while disabled (applied
845    /// at spawn alongside the `Disabled` marker).
846    pub disabled_text_color: Option<Rgba>,
847    /// `:hover { opacity }`.
848    pub hover_opacity: Option<f32>,
849    /// `:active { opacity }`.
850    pub active_opacity: Option<f32>,
851    /// `:focus { opacity }`.
852    pub focus_opacity: Option<f32>,
853    /// `:disabled { opacity }` - e.g. adwaita's documented 50 %-opacity
854    /// disabled controls.
855    pub disabled_opacity: Option<f32>,
856    /// `:hover { box-shadow }` - shadow stack swapped in while hovered.
857    pub hover_shadows: Option<Vec<ShadowSpec>>,
858    /// `:active { box-shadow }`.
859    pub active_shadows: Option<Vec<ShadowSpec>>,
860    /// `:focus { box-shadow }` - e.g. the WinUI TextBox focus underline.
861    pub focus_shadows: Option<Vec<ShadowSpec>>,
862    /// `:disabled { box-shadow }` (applied at spawn).
863    pub disabled_shadows: Option<Vec<ShadowSpec>>,
864    /// `:focus-visible { text-color }` - keyboard-only focus text swap.
865    pub focus_visible_text_color: Option<Rgba>,
866    /// `:focus-visible { opacity }`.
867    pub focus_visible_opacity: Option<f32>,
868    /// `:focus-visible { box-shadow }` - e.g. the inner ring of the
869    /// Windows keyboard-only double focus ring.
870    pub focus_visible_shadows: Option<Vec<ShadowSpec>>,
871    /// `:drag-over { bg }` - fill swapped in while an acceptable in-app
872    /// drag hovers this drop target (HTML5 `dragover` parity). Gated by
873    /// the runtime `DropHovered` marker via `StateVisuals::drag_over`.
874    pub drag_over_bg: Option<Rgba>,
875    /// `:drag-over { text-color }`.
876    pub drag_over_text_color: Option<Rgba>,
877    /// `:drag-over { opacity }`.
878    pub drag_over_opacity: Option<f32>,
879    /// `:drag-over { box-shadow }` - e.g. an inset ring lighting up the
880    /// hovered drop zone.
881    pub drag_over_shadows: Option<Vec<ShadowSpec>>,
882    /// `focus-outline="<width> <#color>"` - stroke ring rendered while
883    /// the entity has focus.
884    pub focus_outline: Option<OutlineSpec>,
885    /// `:focus-visible { outline: ... }` - stroke ring rendered only when
886    /// focus arrived via the keyboard (Tab / Shift-Tab), mirroring the
887    /// CSS `:focus-visible` heuristic. Pointer-driven focus does not
888    /// paint it. When both this and [`Self::focus_outline`] are set the
889    /// keyboard-only ring wins while the `FocusVisible` marker is
890    /// present.
891    pub focus_visible_outline: Option<OutlineSpec>,
892    /// CSS `outline-offset` in px - gap between the border box edge and
893    /// the focus outline. Folded into the outline specs at spawn.
894    pub outline_offset: Option<f32>,
895    /// `knob-color="#color"` / CSS `knob-color:` - fill of the toggle
896    /// knob / slider thumb child. Lumen-native analog property (real
897    /// CSS has no reachable pseudo-element for it in our subset);
898    /// absent = the runtime `KNOB_FILL` fallback.
899    pub knob_color: Option<Rgba>,
900    /// CSS `border-width` (and the width part of the `border`
901    /// shorthand): per-side widths in px. Participates in layout per the
902    /// CSS box model and paints when [`Self::border_style`] is `solid`.
903    pub border_width: Option<Edges>,
904    /// CSS `border-color` (the uniform base color for all four sides).
905    pub border_color: Option<Rgba>,
906    /// CSS `border-top-color` - per-side override of [`Self::border_color`].
907    pub border_color_top: Option<Rgba>,
908    /// CSS `border-right-color`.
909    pub border_color_right: Option<Rgba>,
910    /// CSS `border-bottom-color` - the Windows elevation bottom edge.
911    pub border_color_bottom: Option<Rgba>,
912    /// CSS `border-left-color`.
913    pub border_color_left: Option<Rgba>,
914    /// CSS `border-style` (`none` | `solid`). Per CSS, no style => no
915    /// border: the computed border-width is zero and nothing paints.
916    /// The `border:` shorthand always sets this explicitly.
917    pub border_style: Option<BorderStyleSpec>,
918    /// CSS `box-sizing`. `None` = the Lumen UA default (`border-box`).
919    pub box_sizing: Option<BoxSizingSpec>,
920    /// `:hover { border: ... }` / `hover-border:` - border swapped in
921    /// while the pointer hovers the entity.
922    pub hover_border: Option<BorderPaintSpec>,
923    /// `:focus { border: ... }` / `focus-border:` - border swapped in
924    /// while the entity has keyboard focus. Wins over
925    /// [`Self::hover_border`] when both states are active.
926    pub focus_border: Option<BorderPaintSpec>,
927    /// CSS `flex-shrink`. `None` = the CSS initial value `1`.
928    pub shrink: Option<f32>,
929    /// CSS `flex-basis`. `None` = `auto`.
930    pub basis: Option<LengthSpec>,
931    /// CSS `flex-wrap`. `None` = `nowrap`.
932    pub flex_wrap: Option<FlexWrapSpec>,
933    /// CSS `align-content` - cross-axis distribution of wrapped flex
934    /// lines / grid tracks.
935    pub align_content: Option<AlignContentSpec>,
936    /// CSS `z-index` - sibling paint-order override. `None` = `auto`.
937    pub z_index: Option<i32>,
938    /// `checked="true|false"` on `<toggle>` - initial state.
939    pub checked: Option<bool>,
940    /// `autofocus="true"` - when the containing `<dialog>` opens, this
941    /// element receives initial focus (HTML `autofocus` / Qt
942    /// `setFocus` on show).
943    pub autofocus: bool,
944    /// `<button default="true">` - the dialog's DEFAULT button: Enter
945    /// anywhere in the dialog (except on another button / an
946    /// Enter-consuming input) activates it, and closing through it
947    /// fires the `accepted` (not `rejected`) hook. Also appends the
948    /// `default` class so skins can style `button.default`.
949    pub default_button: bool,
950    /// `<checkbox indeterminate="true">` - tri-state dash until the
951    /// first user toggle clears it (web `indeterminate` IDL attr / Qt
952    /// `PartiallyChecked`).
953    pub indeterminate: bool,
954    /// `<radio group="...">` - the PropertyStore global holding the
955    /// radio group's selected value.
956    pub radio_group: Option<String>,
957    /// `<radio value="...">` - this member's (string) value. The shared
958    /// `value` attribute parses as f32 for sliders/progress; radios
959    /// route it here instead.
960    pub radio_value: Option<String>,
961    /// `<progress duration="...">` / CSS `progress-duration` - the
962    /// indeterminate sweep period in ms (skin token
963    /// `--lumen-progress-period`).
964    pub progress_duration: Option<u32>,
965    /// Synthetic widget-part marker set by the parser desugars so the
966    /// spawn layer can attach the matching runtime marker component to
967    /// a child element (`.checkbox-box` / `.radio-dot` /
968    /// `.progress-fill`).
969    pub part: Option<WidgetPart>,
970    /// The authored widget this element came out of, for the desugars that
971    /// replace a widget tag with plain boxes ([`WidgetRole`]). A surface that
972    /// can express the widget directly reads this and writes the widget back;
973    /// the runtime spawner ignores it, because the boxes beside it are already
974    /// the widget it would build.
975    pub widget: Option<WidgetRole>,
976    /// `:checked { bg: ... }` CSS - track fill shown while a `<toggle>`
977    /// is checked. Absent = the built-in accent fill.
978    pub checked_bg: Option<Rgba>,
979    /// `:selected { bg: ... }` CSS - fill shown on a `<tabs>` strip button
980    /// while it carries the [`lumen_core::components::Selected`] marker.
981    /// Absent = the built-in accent fill (same default-skin fallback
982    /// pattern as [`Self::checked_bg`]).
983    pub selected_bg: Option<Rgba>,
984    /// `disabled="true"` - entity spawns with the `Disabled` marker;
985    /// input routing skips it and the default render dims it.
986    pub disabled: bool,
987    /// `:disabled { bg: ... }` CSS - fill shown while the entity is
988    /// disabled. Absent = reduced-opacity render of the normal fill.
989    pub disabled_bg: Option<Rgba>,
990    /// `value="0.5"` on `<slider>` - initial slider value.
991    pub value: Option<f32>,
992    /// `min="0"` on `<slider>` - lower bound.
993    pub min: Option<f32>,
994    /// `max="1"` on `<slider>` - upper bound.
995    pub max: Option<f32>,
996    /// `step="0.1"` on `<slider>` - keyboard / wheel increment.
997    /// Absent = `(max - min) / 100`.
998    pub step: Option<f32>,
999    /// `bind="text:signal_name"` - declarative reactive binding to a named `Signals` entry. Only the `text:` prefix is recognised.
1000    pub bind: Option<BindSpec>,
1001    /// `bind-disabled="signal"` - one-way binding driving the `Disabled`
1002    /// marker from a boolean signal so scripts can enable / disable the
1003    /// widget live. Stored apart from [`Self::bind`] so it can coexist
1004    /// with `bind-checked` / `bind-value` / `bind-text` on one element.
1005    pub bind_disabled: Option<String>,
1006    /// `bind-scroll="signal"` on a scroll container - two-way binding
1007    /// between an f32 signal (logical px) and the container's vertical
1008    /// scroll offset (W6 T6). Stored apart from [`Self::bind`] (same
1009    /// coexistence rule as `bind_disabled`).
1010    pub bind_scroll: Option<String>,
1011    /// `each="rows"` on `<for>` - name of the `ArraySignals` entry to
1012    /// iterate. The element's inline children are the per-item body
1013    /// template; `{field}` placeholders inside their attrs / text get
1014    /// replaced at reconcile time with the matching record field.
1015    pub each: Option<String>,
1016    /// `key="id"` on `<for>` - record field used as the stable reconciliation key. Without it the reconciler keys rows by item index.
1017    pub key: Option<String>,
1018    /// `<for virtualized="true">` - spawn only rows in the visible scroll window. Requires a `<scroll>` ancestor for the reconciler to compute the visible band.
1019    pub virtualized: bool,
1020    /// `<for row-height="32">` - pixel height per virtualized row.
1021    /// Required when [`Self::virtualized`] is true; defaults to 32 px
1022    /// at spawn when the attr is absent.
1023    pub row_height: Option<f32>,
1024    /// `signal="loaded"` on `<if>` - name of the `Signals` entry whose
1025    /// truthiness gates the subtree. Truthy = non-empty AND not literal
1026    /// `"false"` / `"0"`. Toggle spawns / despawns body on next tick.
1027    pub if_signal: Option<String>,
1028    /// `eq="value"` on `<if>` - when set, the body mounts iff
1029    /// `Signals[if_signal] == eq`. Used by `<tabs>` to switch the
1030    /// active tab body; `None` falls back to truthiness.
1031    pub if_eq: Option<String>,
1032    /// `mode="render|hide"` on `<if>` - pick the reconciler policy.
1033    /// `render` (default) despawns/respawns the body each toggle;
1034    /// `hide` mounts once and flips a `Visible` flag, preserving focus
1035    /// / scroll / per-row signals across show-hide cycles.
1036    pub if_mode: IfModeSpec,
1037    /// `wrap="none|word|glyph"` - text wrap policy. Default is none
1038    /// (overflow clips horizontally).
1039    pub text_wrap: Option<TextWrapSpec>,
1040    /// `max-lines="N"` - hard cap on rendered line count for wrapped text.
1041    pub max_lines: Option<u32>,
1042    /// CSS `text-overflow: ellipsis | clip` (or `wrap="ellipsis"` in
1043    /// markup). `Ellipsis` elides overflowing single-line text with a
1044    /// trailing `...`; the spawn layer lowers it onto the runtime
1045    /// `TextStyle` as glyph-wrap + `max_lines = 1` unless the author
1046    /// supplied an explicit wrap / max-lines pair (multi-line clamp,
1047    /// which the shaper already ellipsizes). Not inherited (CSS
1048    /// `text-overflow` doesn't inherit either).
1049    pub text_overflow: Option<TextOverflowSpec>,
1050    /// `position="relative|absolute"` - CSS-style positioning mode.
1051    pub position: Option<PositionSpec>,
1052    /// `inset="t r b l"` (or 1/2/3-value shorthand) - offsets from each
1053    /// edge for `position="absolute"`.
1054    pub inset: Option<Edges>,
1055    /// `min-width="..."`.
1056    pub min_width: Option<LengthSpec>,
1057    /// `min-height="..."`.
1058    pub min_height: Option<LengthSpec>,
1059    /// `max-width="..."`.
1060    pub max_width: Option<LengthSpec>,
1061    /// `max-height="..."`.
1062    pub max_height: Option<LengthSpec>,
1063    /// `aspect-ratio="1.5"` - width / height constraint.
1064    pub aspect_ratio: Option<f32>,
1065    /// `overflow="visible|hidden|scroll"` - sets both axes. Use
1066    /// `overflow-x` / `overflow-y` to set per axis.
1067    pub overflow: Option<OverflowSpec>,
1068    /// `overflow-x`.
1069    pub overflow_x: Option<OverflowSpec>,
1070    /// `overflow-y`.
1071    pub overflow_y: Option<OverflowSpec>,
1072    /// `fit="fill|cover|contain|none|scale-down"` on `<image>` -
1073    /// CSS `object-fit`.
1074    pub image_fit: Option<ImageFitSpec>,
1075    /// `bg-fit` - how a `bg: url(...)` image fills the element's box. Takes
1076    /// the [`Self::image_fit`] values; `None` means cover.
1077    pub bg_fit: Option<ImageFitSpec>,
1078    /// CSS `scrollbar-color: <thumb> [<track>]` (Scrollbars Styling
1079    /// Level 1) - overlay-bar thumb + optional track fills for `<scroll>`
1080    /// containers. `None` = the runtime's `ScrollbarStyle` default.
1081    pub scrollbar_color: Option<(Rgba, Option<Rgba>)>,
1082    /// CSS `scrollbar-width: auto | thin | none` (Scrollbars Styling
1083    /// Level 1).
1084    pub scrollbar_width: Option<ScrollbarWidthSpec>,
1085    /// `shadow="<x> <y> <blur> <#color>"` - drop shadow.
1086    pub shadows: Vec<ShadowSpec>,
1087    /// `opacity="0.5"` - alpha multiplier in `[0, 1]`. Absent = 1.0.
1088    pub opacity: Option<f32>,
1089    /// CSS `transition: <property> <duration> [<easing>] [, ...]` declarations. Each entry tweens the property instead of snapping when the value changes.
1090    /// Animatable in v1: `opacity`, `background-color`/`bg`,
1091    /// `color`/`text-color`, `border-color`. Layout properties (width,
1092    /// height, ...) are rejected with a warn - see [`TransitionPropertyIr`].
1093    pub transitions: Vec<TransitionIr>,
1094    /// CSS `transition-property` longhand - comma list of property
1095    /// names. Combined with the duration / timing longhands by
1096    /// [`Attributes::effective_transitions`].
1097    pub transition_property: Option<Vec<TransitionPropertyIr>>,
1098    /// CSS `transition-duration` longhand - comma list of durations in
1099    /// ms, cycled over the property list per the CSS repeat rule.
1100    pub transition_duration: Option<Vec<u32>>,
1101    /// CSS `transition-timing-function` longhand - comma list of easing
1102    /// curves, cycled over the property list.
1103    pub transition_timing: Option<Vec<EasingIr>>,
1104    /// `required="true"` - form-field validity gate.
1105    pub required: bool,
1106    /// `pattern="<substring>"` - content must contain this literal
1107    /// substring to be valid. Values prefixed `shape:` are reserved for
1108    /// the built-in structural checks the parser attaches to
1109    /// `<date-picker>` (`shape:date`) and `<time-picker>`
1110    /// (`shape:time`); see `lumen_primitives::validation`.
1111    pub pattern: Option<String>,
1112    /// `multiline="true"` - text input accepts newlines. `<textarea>` defaults to true; `<input>` defaults to false.
1113    pub multiline: Option<bool>,
1114    /// Synthesised by the `<dropdown>` parser pass on the header
1115    /// button. The runtime attaches a [`lumen_primitives::DropdownButton`]
1116    /// whose click flips the open signal and whose option metadata
1117    /// drives *closed*-combobox keyboard interaction (Up/Down value
1118    /// stepping, Alt+Down open, type-ahead) before the lazily-mounted
1119    /// panel body exists.
1120    pub dropdown_button: Option<DropdownButtonSpec>,
1121    /// Synthesised per `<option>` inside `<dropdown>`: `(value_signal, value, open_signal)`. The runtime attaches a [`lumen_primitives::DropdownOptionButton`] whose click writes the value and closes the panel.
1122    pub dropdown_option: Option<(String, String, String)>,
1123    /// Synthesised per `<menuitem>` inside `<menu>`: `(open_signal, item_id)`. The runtime attaches a [`lumen_primitives::MenuItemButton`] whose click emits `MenuClicked { id: item_id }` and closes the menu.
1124    pub menu_item: Option<(String, String)>,
1125    /// Synthesised on the floating panel of a `<dropdown>` / `<menu>`:
1126    /// the open-state signal (`__dropdown_open:*` / `__menu_open:*`).
1127    /// The runtime attaches a [`lumen_primitives::PopupPanel`] so the
1128    /// outside-click dismissal and viewport edge-flip systems can find
1129    /// the panel and its bound open signal.
1130    pub popup_panel: Option<String>,
1131    /// `dir="ltr|rtl|auto"` - CSS Logical Properties writing direction
1132    /// (W5.4). When set, the spawn layer installs a
1133    /// [`lumen_core::components::LayoutDirection`] component; the
1134    /// `resolve_layout_direction` system then stamps a
1135    /// [`lumen_core::components::ResolvedDirection`] on every descendant.
1136    ///
1137    /// Serialized through [`layout_direction_serde`] because
1138    /// [`lumen_core::components::LayoutDirection`] lives in a crate that
1139    /// does not depend on serde; the proxy maps the three variants onto a
1140    /// stable `Option<u8>` so the AOT artifact stays parser-independent.
1141    #[serde(with = "layout_direction_serde")]
1142    pub dir: Option<lumen_core::components::LayoutDirection>,
1143    /// `translatable="<key>"` - names the catalogue message every string
1144    /// this element shows comes from. The text is the message value,
1145    /// falling back to the authored `text` and then to the key itself;
1146    /// [`Self::placeholder`] and [`Self::alt`] come from the Fluent
1147    /// attributes `<key>.placeholder` and `<key>.alt`, and only when the
1148    /// markup authored them. The key stops standing in for a missing text
1149    /// once the element names one of those, so an `<input>` marked for its
1150    /// placeholder does not spawn with the key as its value.
1151    /// [`crate::translate::translate`] is the rule, shared by the
1152    /// spawn layer and the web emitter; `lumenc i18n extract` collects the
1153    /// keys into `locale/<lang>.ftl`.
1154    pub translatable: Option<String>,
1155    /// `format="<spec>"` - formats the element's text for the app's
1156    /// locale. The spec is one of `number`, `currency:<code>`, `date`,
1157    /// `time`, `datetime`, `relative`; the text it applies to is
1158    /// whatever the element ended up with, whether that is the authored
1159    /// `text`, a translated string, or the value a `bind-text` signal
1160    /// holds. A spec the formatter does not know, or text it cannot
1161    /// read as the spec's kind, leaves the text alone.
1162    pub format: Option<String>,
1163    /// `lang="<bcp47>"` - BCP-47 language tag (W5.4). When set, the
1164    /// spawn layer installs a [`lumen_core::components::Lang`] component
1165    /// consumed by text shaping (cosmic-text) and AccessKit. Inherited
1166    /// at runtime from the nearest ancestor when absent. `format` reads
1167    /// the app's locale, not this tag.
1168    pub lang: Option<String>,
1169    /// `bind-text="$self.<field>"` - per-entity text binding. The
1170    /// field name (without the `$self.` prefix) lands here; the
1171    /// spawn layer installs a [`lumen_core::components::BindSelfText`]
1172    /// marker. Mutually exclusive with [`Self::bind`] for the same
1173    /// kind - the dollar-prefixed forms desugar to this field instead
1174    /// of the named-signal [`BindSpec`]. (W-signal-design step 1.)
1175    pub bind_self_text: Option<String>,
1176    /// `bind-value="$self.<field>"` - per-entity slider-value binding.
1177    /// See [`Self::bind_self_text`].
1178    pub bind_self_value: Option<String>,
1179    /// `bind-checked="$self.<field>"` - per-entity toggle binding.
1180    /// See [`Self::bind_self_text`].
1181    pub bind_self_checked: Option<String>,
1182    /// `bind-text="$parent.<field>"` - parent-entity text binding.
1183    /// The field name (without the `$parent.` prefix) lands here; the
1184    /// spawn layer installs a [`lumen_core::components::BindParentText`]
1185    /// marker. See [`Self::bind_self_text`].
1186    pub bind_parent_text: Option<String>,
1187    /// `bind-value="$parent.<field>"` - parent-entity slider-value binding.
1188    /// See [`Self::bind_parent_text`].
1189    pub bind_parent_value: Option<String>,
1190    /// `bind-checked="$parent.<field>"` - parent-entity toggle binding.
1191    /// See [`Self::bind_parent_text`].
1192    pub bind_parent_checked: Option<String>,
1193    /// `knob-inset="2"` / CSS `knob-inset` - gap in px between the knob
1194    /// child's edge and its track parent's edge on `<toggle>` /
1195    /// `<switch>`. Lumen-native analog property (mirrors [`Self::knob_color`]
1196    /// in having no real CSS pseudo-element target); absent = the
1197    /// runtime's own inset constant.
1198    pub knob_inset: Option<f32>,
1199    /// `thumb-size="20"` / CSS `thumb-size` - diameter in px of the
1200    /// `<slider>` thumb. Lumen-native analog property; absent = the
1201    /// runtime's own thumb-size constant.
1202    pub thumb_size: Option<f32>,
1203    /// `popup-gap="4"` / CSS `popup-gap` - offset in px between a
1204    /// `<dropdown>` / `<menu>` trigger and its floating panel. Absent =
1205    /// the runtime's own gap constant.
1206    pub popup_gap: Option<f32>,
1207    /// CSS `progress-chunk` - fraction of the track width (`0.0` to
1208    /// `1.0`) covered by the moving chunk of an indeterminate
1209    /// `<progress>` sweep. Absent = the runtime's own chunk-width
1210    /// constant.
1211    pub progress_chunk: Option<f32>,
1212    /// CSS `disabled-opacity` - alpha multiplier `[0, 1]` applied to a
1213    /// disabled entity when neither [`Self::disabled_bg`] nor an
1214    /// explicit `:disabled { opacity }` ([`Self::disabled_opacity`]) was
1215    /// authored. Distinct from `disabled_opacity`: that field is the
1216    /// per-element state-pseudo override, this field is the CSS-authored
1217    /// replacement for the runtime's own generic dimming fallback.
1218    pub disabled_opacity_default: Option<f32>,
1219    /// `caret-width="2"` / CSS `caret-width` - stroke width in px of the
1220    /// text-input caret. Absent = the runtime's own caret-width constant.
1221    /// Only meaningful on `<input>` / `<textarea>`.
1222    pub caret_width: Option<f32>,
1223    /// CSS `caret-blink` - full on/off blink period of the text-input
1224    /// caret, in milliseconds. Accepts `Nms` or `Ns`. Absent = the
1225    /// runtime's own blink-period constant. Only meaningful on
1226    /// `<input>` / `<textarea>`.
1227    pub caret_blink_ms: Option<u32>,
1228    /// CSS `password-character` - the glyph substituted for every
1229    /// character of a masked (`type="password"`-equivalent) text input.
1230    /// A single Unicode scalar value; absent = the runtime's own default
1231    /// mask glyph (typically `*` or a bullet).
1232    pub password_character: Option<char>,
1233    /// CSS `line-height` - see [`LineHeightSpec`] for why the unitless
1234    /// and px forms are kept distinct rather than folded into one number.
1235    /// Inherited like [`Self::font_size`]. Absent = the runtime's own
1236    /// line-height ratio.
1237    pub line_height: Option<LineHeightSpec>,
1238    /// CSS `scrollbar-thickness` - width in px of the overlay scrollbar
1239    /// track/thumb on `<scroll>` containers at `scrollbar-width: auto`.
1240    /// Absent = the runtime's own thickness constant.
1241    pub scrollbar_thickness: Option<f32>,
1242    /// CSS `scrollbar-thickness-thin` - width in px of the overlay
1243    /// scrollbar at `scrollbar-width: thin`. Absent = the runtime's own
1244    /// thin-thickness constant.
1245    pub scrollbar_thickness_thin: Option<f32>,
1246    /// CSS `scrollbar-margin` - gap in px between the scrollbar and the
1247    /// container's content edge. Absent = the runtime's own margin
1248    /// constant.
1249    pub scrollbar_margin: Option<f32>,
1250    /// CSS `scrollbar-min-thumb` - minimum thumb length in px, so a very
1251    /// long scrollable area still gets a grabbable thumb. Absent = the
1252    /// runtime's own minimum constant.
1253    pub scrollbar_min_thumb: Option<f32>,
1254    /// CSS `scrollbar-track-hover` - track fill shown while the pointer
1255    /// hovers the scrollbar. Lumen-native named property (mirrors
1256    /// [`Self::hover_bg`] in being a distinct property rather than a
1257    /// `:hover { ... }` pseudo rule, since the hover target is the
1258    /// scrollbar part, not the whole element). Absent = no track tint on
1259    /// hover.
1260    pub scrollbar_track_hover: Option<Rgba>,
1261    /// CSS `scrollbar-hover-boost` - multiplier applied to the thumb
1262    /// fill's brightness while hovered, paired with
1263    /// [`Self::scrollbar_track_hover`]. Absent = the runtime's own boost
1264    /// constant.
1265    pub scrollbar_hover_boost: Option<f32>,
1266    /// CSS `scrollbar-fade-delay` - idle time before an overlay
1267    /// scrollbar starts fading out, in milliseconds. Accepts `Nms` or
1268    /// `Ns`. Absent = the runtime's own delay constant.
1269    pub scrollbar_fade_delay_ms: Option<u32>,
1270    /// CSS `scrollbar-fade-duration` - length of the fade-out animation
1271    /// itself, in milliseconds. Accepts `Nms` or `Ns`. Absent = the
1272    /// runtime's own duration constant.
1273    pub scrollbar_fade_duration_ms: Option<u32>,
1274    /// Every styling attribute the element was given in markup, as
1275    /// `(property, value)` in the order they were written. The property is
1276    /// the one spelling the cascade files it under
1277    /// ([`crate::css::canonical_style_property`]); the value is the text the
1278    /// author wrote, unparsed.
1279    ///
1280    /// The fields above hold the *result* of the cascade and say nothing
1281    /// about where a value came from, so this is the record of which surface
1282    /// set what. It exists because the two surfaces do not rank the same way
1283    /// everywhere: in Lumen a styling attribute outranks any rule that
1284    /// targets the element, while on the web a rule outranks nothing but an
1285    /// inline declaration. A target with the web's ranking replays these as
1286    /// inline declarations so the author's markup keeps winning.
1287    pub markup_styles: Vec<(String, String)>,
1288    /// `name` on a `<slot>` inside a fragment body: which of the use site's
1289    /// children lands here. Absent on a `<slot>` names the default slot
1290    /// ([`crate::fragment::DEFAULT_SLOT`]), and absent on anything else
1291    /// means the element is not a slot at all.
1292    pub slot_name: Option<String>,
1293    /// Attributes whose value carries a fragment parameter, held back until
1294    /// a use site supplies the arguments.
1295    ///
1296    /// Most attribute values parse into typed layout data, which a
1297    /// `{parameter}` marker is not, so a fragment body keeps the authored
1298    /// text here instead and the compiler applies it once per instantiation.
1299    /// Empty on every element outside a fragment body, and emptied on the
1300    /// copy that reaches the tree.
1301    pub deferred: Vec<DeferredAttr>,
1302    /// Every attribute an element with a registered custom tag was written
1303    /// with, as `(name, value)` in the order they were written; `None` on an
1304    /// element whose tag is built in.
1305    ///
1306    /// The markup vocabulary cannot say what a custom widget's attributes
1307    /// mean, so the parser keeps them as authored and the spawner hands them
1308    /// to the widget registered for the tag. `Some` with an empty list is a
1309    /// custom element written without attributes, which still gets its
1310    /// widget.
1311    pub widget_attributes: Option<Vec<(String, String)>>,
1312}
1313
1314/// One attribute of a fragment body element, kept as authored.
1315///
1316/// See [`Attributes::deferred`]. The position is the value's own place in
1317/// the file that declared the fragment, so a diagnostic raised while
1318/// instantiating still points at where the value was written.
1319#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1320pub struct DeferredAttr {
1321    /// Attribute name as authored.
1322    pub name: String,
1323    /// Attribute value as authored, parameter markers included.
1324    pub value: String,
1325    /// 1-based line of the value in the declaring file.
1326    pub line: usize,
1327    /// 1-based column of the value in the declaring file.
1328    pub col: usize,
1329}
1330
1331impl Attributes {
1332    /// Resolve the accumulated `border-*` longhands into the effective
1333    /// CSS border, following the real cascade rules:
1334    ///
1335    /// * no `border-style` (or `border-style: none`) => no border - the
1336    ///   computed width of a styleless side is `0` (CSS Backgrounds &
1337    ///   Borders section 3.2), so `border-width`/`border-color` alone paint
1338    ///   nothing and consume no space;
1339    /// * `border-style: solid` => widths default to `3px` (CSS `medium`)
1340    ///   when `border-width` is absent, and the color falls back to
1341    ///   `currentColor` - approximated by the element's `text-color`
1342    ///   when authored, else opaque black (the CSS initial `color`).
1343    ///
1344    /// Returns `(widths, color)` for the solid case; `None` otherwise.
1345    /// The color is the uniform base - per-side overrides
1346    /// (`border-top-color` ...) are resolved by
1347    /// [`Self::effective_border_colors`].
1348    pub fn effective_border(&self) -> Option<(Edges, Rgba)> {
1349        match self.border_style {
1350            Some(BorderStyleSpec::Solid) => {
1351                let widths = self.border_width.unwrap_or(Edges::all(3.0));
1352                let color = self.border_color.or(self.text_color).unwrap_or(Rgba {
1353                    r: 0.0,
1354                    g: 0.0,
1355                    b: 0.0,
1356                    a: 1.0,
1357                });
1358                Some((widths, color))
1359            }
1360            Some(BorderStyleSpec::None) | None => None,
1361        }
1362    }
1363
1364    /// Per-side border colors `[top, right, bottom, left]`: each side
1365    /// takes its longhand (`border-top-color` ...) when authored, else
1366    /// the uniform `base`. Returns `None` when no side differs from the
1367    /// base, so the uniform fast path stays intact downstream.
1368    pub fn effective_border_colors(&self, base: Rgba) -> Option<[Rgba; 4]> {
1369        if self.border_color_top.is_none()
1370            && self.border_color_right.is_none()
1371            && self.border_color_bottom.is_none()
1372            && self.border_color_left.is_none()
1373        {
1374            return None;
1375        }
1376        Some([
1377            self.border_color_top.unwrap_or(base),
1378            self.border_color_right.unwrap_or(base),
1379            self.border_color_bottom.unwrap_or(base),
1380            self.border_color_left.unwrap_or(base),
1381        ])
1382    }
1383
1384    /// Resolve the transition shorthand + longhands into the effective
1385    /// per-property list.
1386    ///
1387    /// - The `transition:` shorthand, when authored, wins outright (it
1388    ///   resets the longhands per CSS shorthand semantics).
1389    /// - Otherwise `transition-property` defines the entry list, with
1390    ///   `transition-duration` / `transition-timing-function` values
1391    ///   cycled over it (the CSS list-repeat rule). A duration list
1392    ///   without `transition-property` produces nothing: there is no
1393    ///   default property list, so name the properties or write `all`.
1394    pub fn effective_transitions(&self) -> Vec<TransitionIr> {
1395        if !self.transitions.is_empty() {
1396            return self.transitions.clone();
1397        }
1398        let Some(props) = &self.transition_property else {
1399            return Vec::new();
1400        };
1401        // An *empty* list (reachable via `transition-duration: ,`) must be
1402        // treated like an absent one - otherwise `durations[0]` below indexes
1403        // an empty slice and panics.
1404        let durations: &[u32] = match self.transition_duration.as_deref() {
1405            Some(d) if !d.is_empty() => d,
1406            _ => &[0],
1407        };
1408        // CSS `ease`, the initial `transition-timing-function`, same
1409        // default the `transition` shorthand applies.
1410        let default_timing = [EasingIr::CubicBezier(0.25, 0.1, 0.25, 1.0)];
1411        let timings: &[EasingIr] = match self.transition_timing.as_deref() {
1412            Some(t) if !t.is_empty() => t,
1413            _ => &default_timing,
1414        };
1415        props
1416            .iter()
1417            .enumerate()
1418            .map(|(i, p)| TransitionIr {
1419                property: *p,
1420                duration_ms: durations[i % durations.len().max(1)],
1421                easing: timings[i % timings.len().max(1)],
1422            })
1423            .collect()
1424    }
1425}
1426
1427/// IR-side parsed `transition` shorthand entry. `From<&TransitionIr> for
1428/// lumen_primitives::TransitionSpec` converts to the runtime form at
1429/// spawn time.
1430#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1431pub struct TransitionIr {
1432    /// Property name as authored (`"opacity"` ...). The spawn layer maps
1433    /// to `lumen_primitives::TransitionProperty`.
1434    pub property: TransitionPropertyIr,
1435    /// Duration in milliseconds.
1436    pub duration_ms: u32,
1437    /// Easing curve as authored.
1438    pub easing: EasingIr,
1439}
1440
1441/// IR-side mirror of `lumen_primitives::TransitionProperty`. Kept local
1442/// to layout_ir so the parser doesn't need to depend on primitives.
1443///
1444/// v1 animatable set: colors + opacity only - geometry-free visual
1445/// props. Layout properties (`width`, `height`, padding, margins, ...)
1446/// are deliberately not transitionable in v1: animating them would
1447/// re-run layout every frame, and the parser warns + drops them.
1448#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
1449pub enum TransitionPropertyIr {
1450    /// CSS `opacity`.
1451    Opacity,
1452    /// CSS `background-color` (accepted spellings: `background-color`,
1453    /// `background`, `bg`).
1454    BackgroundColor,
1455    /// CSS `color` (accepted spellings: `color`, `text-color`).
1456    TextColor,
1457    /// CSS `border-color`.
1458    BorderColor,
1459}
1460
1461impl TransitionPropertyIr {
1462    /// Parse a CSS property name into a transitionable property.
1463    /// Returns `None` for unknown / non-animatable names (the caller
1464    /// warns and drops per CSS "ignore unanimatable" behavior).
1465    pub fn from_css_name(name: &str) -> Option<Self> {
1466        match name {
1467            "opacity" => Some(Self::Opacity),
1468            "background-color" | "background" | "bg" => Some(Self::BackgroundColor),
1469            "color" | "text-color" => Some(Self::TextColor),
1470            "border-color" => Some(Self::BorderColor),
1471            _ => None,
1472        }
1473    }
1474}
1475
1476/// IR-side mirror of `lumen_primitives::Easing`. Cubic-bezier carries
1477/// its four control points; named curves are zero-sized.
1478#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1479pub enum EasingIr {
1480    /// `linear`.
1481    Linear,
1482    /// `ease-in`.
1483    EaseIn,
1484    /// `ease-out`.
1485    EaseOut,
1486    /// `ease-in-out`.
1487    EaseInOut,
1488    /// `cubic-bezier(p1x, p1y, p2x, p2y)`.
1489    CubicBezier(f32, f32, f32, f32),
1490}
1491
1492/// Parsed `shadow=` / `box-shadow=` value. Authors may stack multiple
1493/// shadows via comma separation in CSS; markup accepts a single entry.
1494/// `inner` flips drop -> inset.
1495#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1496pub struct ShadowSpec {
1497    /// Horizontal offset in px.
1498    pub offset_x: f32,
1499    /// Vertical offset in px.
1500    pub offset_y: f32,
1501    /// Gaussian blur radius (std-dev) in px.
1502    pub blur: f32,
1503    /// CSS `box-shadow` spread radius in px - grows (positive) or
1504    /// shrinks (negative) the shadow rect before blurring. Enables the
1505    /// hard double-ring focus idiom (`box-shadow: 0 0 0 2px <color>`).
1506    pub spread: f32,
1507    /// Shadow color (alpha controls softness).
1508    pub color: Rgba,
1509    /// `true` = inset (inner) shadow. Default `false`.
1510    pub inner: bool,
1511}
1512
1513/// CSS `object-fit` analogue.
1514#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1515pub enum ImageFitSpec {
1516    /// Stretch to fill.
1517    Fill,
1518    /// Aspect-preserved cover (overflow clipped).
1519    Cover,
1520    /// Aspect-preserved fit (may leave space).
1521    Contain,
1522    /// Native pixel size, top-left aligned.
1523    None,
1524    /// Smaller of None / Contain.
1525    ScaleDown,
1526}
1527
1528/// CSS-style position mode.
1529#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1530pub enum PositionSpec {
1531    /// In-flow positioning.
1532    Relative,
1533    /// Out-of-flow, offset by `inset`.
1534    Absolute,
1535}
1536
1537/// CSS-style overflow control.
1538#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1539pub enum OverflowSpec {
1540    /// Children paint outside (default).
1541    Visible,
1542    /// Children clipped at the box edge.
1543    Hidden,
1544    /// Clipped + scrollable.
1545    Scroll,
1546}
1547
1548/// CSS `scrollbar-width` keyword (Scrollbars Styling Level 1).
1549#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1550pub enum ScrollbarWidthSpec {
1551    /// Platform-default overlay thickness.
1552    Auto,
1553    /// Narrow rail.
1554    Thin,
1555    /// Bars hidden entirely (content still scrolls).
1556    None,
1557}
1558
1559impl From<ScrollbarWidthSpec> for lumen_core::input::ScrollbarWidthMode {
1560    fn from(s: ScrollbarWidthSpec) -> Self {
1561        match s {
1562            ScrollbarWidthSpec::Auto => Self::Auto,
1563            ScrollbarWidthSpec::Thin => Self::Thin,
1564            ScrollbarWidthSpec::None => Self::None,
1565        }
1566    }
1567}
1568
1569/// Text wrap policy attribute.
1570#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1571pub enum TextWrapSpec {
1572    /// No automatic wrap.
1573    None,
1574    /// Word-break wrap.
1575    Word,
1576    /// Glyph-level wrap.
1577    Glyph,
1578}
1579
1580/// Synthetic widget-part roles attached by the parser desugars - see
1581/// [`Attributes::part`].
1582#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1583pub enum WidgetPart {
1584    /// The `.checkbox-box` tile inside `<checkbox>`.
1585    CheckboxBox,
1586    /// The `.radio-dot` tile inside `<radio>`.
1587    RadioDot,
1588    /// The `.progress-fill` tile inside `<progress>`.
1589    ProgressFill,
1590}
1591
1592impl WidgetPart {
1593    /// The class the part carries, which is what a skin styles it by.
1594    ///
1595    /// The desugar writes it onto the synthesized child and the web target
1596    /// writes it onto the element the browser draws for the same part, so
1597    /// one skin rule reaches both.
1598    pub const fn class(self) -> &'static str {
1599        match self {
1600            Self::CheckboxBox => "checkbox-box",
1601            Self::RadioDot => "radio-dot",
1602            Self::ProgressFill => "progress-fill",
1603        }
1604    }
1605}
1606
1607/// A widget tag that the parser replaces with plain boxes - see
1608/// [`Attributes::widget`].
1609///
1610/// These seven tags never reach the IR under their own name: the parser
1611/// expands each into the rows, buttons and gates that draw it, because the
1612/// runtime has no widget layer below the box layer. The expansion is lossy in
1613/// one direction only, and this is what records the loss, so a surface with a
1614/// widget of its own to offer can rebuild the authored one instead of drawing
1615/// the boxes.
1616#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1617pub enum WidgetRole {
1618    /// `<tabs>` - a strip of buttons over one panel per tab.
1619    Tabs,
1620    /// `<dropdown>` - a header button over a panel of options.
1621    Dropdown,
1622    /// `<menu>` - a panel of items shown while its signal is set.
1623    Menu,
1624    /// `<date-picker>` - a text entry holding a date.
1625    DatePicker,
1626    /// `<time-picker>` - a text entry holding a time of day.
1627    TimePicker,
1628    /// `<tooltip>` - text shown beside whatever it wraps. The marker lands
1629    /// on the wrapped element, which is what survives the expansion.
1630    Tooltip,
1631}
1632
1633/// CSS `text-overflow` - what to do with text that overflows its box.
1634#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1635pub enum TextOverflowSpec {
1636    /// Hard clip at the box edge (CSS default).
1637    Clip,
1638    /// Elide with a trailing `...` (Qt `elideMode`, CSS `ellipsis`).
1639    Ellipsis,
1640}
1641
1642/// `<if mode="render|hide">` policy.
1643#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1644pub enum IfModeSpec {
1645    /// Despawn the body subtree on falsy -> truthy transitions (default).
1646    #[default]
1647    Render,
1648    /// Mount the body once and toggle `Visible(bool)` for show / hide.
1649    Hide,
1650}
1651
1652impl From<EasingIr> for lumen_primitives::Easing {
1653    fn from(e: EasingIr) -> Self {
1654        use lumen_primitives::Easing as E;
1655        match e {
1656            EasingIr::Linear => E::Linear,
1657            EasingIr::EaseIn => E::EaseIn,
1658            EasingIr::EaseOut => E::EaseOut,
1659            EasingIr::EaseInOut => E::EaseInOut,
1660            EasingIr::CubicBezier(a, b, c, d) => E::CubicBezier(a, b, c, d),
1661        }
1662    }
1663}
1664
1665impl From<TransitionPropertyIr> for lumen_primitives::TransitionProperty {
1666    fn from(p: TransitionPropertyIr) -> Self {
1667        use lumen_primitives::TransitionProperty as P;
1668        match p {
1669            TransitionPropertyIr::Opacity => P::Opacity,
1670            TransitionPropertyIr::BackgroundColor => P::BackgroundColor,
1671            TransitionPropertyIr::TextColor => P::TextColor,
1672            TransitionPropertyIr::BorderColor => P::BorderColor,
1673        }
1674    }
1675}
1676
1677impl From<&TransitionIr> for lumen_primitives::TransitionSpec {
1678    fn from(ir: &TransitionIr) -> Self {
1679        lumen_primitives::TransitionSpec {
1680            property: ir.property.into(),
1681            duration: std::time::Duration::from_millis(ir.duration_ms as u64),
1682            easing: ir.easing.into(),
1683        }
1684    }
1685}
1686
1687/// Parsed `bind="<kind>:<name>"`.
1688#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1689pub struct BindSpec {
1690    /// Which component to drive.
1691    pub kind: BindKind,
1692    /// Signal name to read from.
1693    pub name: String,
1694}
1695
1696/// What `bind=` targets.
1697#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1698pub enum BindKind {
1699    /// Drive `TextContent` from the signal value (stringified).
1700    Text,
1701    /// Drive `Toggleable.checked` from the signal (truthy = on).
1702    Checked,
1703    /// Drive `SliderValue.value` from the signal (parsed as f32).
1704    Value,
1705}
1706
1707/// Parsed `focus-outline="<width> <#color>"` (plus the CSS
1708/// `outline-offset` folded in at spawn).
1709#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1710pub struct OutlineSpec {
1711    /// Stroke width in pixels.
1712    pub width: f32,
1713    /// Stroke color.
1714    pub color: Rgba,
1715    /// Gap between the border box edge and the inner edge of the ring
1716    /// (CSS `outline-offset`). Default 0.
1717    pub offset: f32,
1718}
1719
1720/// Horizontal text alignment.
1721#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1722pub enum TextAlignSpec {
1723    /// Left.
1724    Start,
1725    /// Centered.
1726    Center,
1727    /// Right.
1728    End,
1729}
1730
1731/// Cross-axis alignment (CSS `align-items`).
1732#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1733pub enum FlexAlign {
1734    /// Flex-start.
1735    Start,
1736    /// Flex-end.
1737    End,
1738    /// Centered.
1739    Center,
1740    /// Stretch to fill cross size.
1741    Stretch,
1742    /// Baseline alignment (W5.9).
1743    Baseline,
1744}
1745
1746/// CSS `display` value (W5.9). Mirrors lumen-core's `Display`.
1747#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1748pub enum DisplaySpec {
1749    /// Flexbox container (default).
1750    Flex,
1751    /// CSS Grid container.
1752    Grid,
1753    /// Hidden.
1754    None,
1755}
1756
1757/// One CSS Grid track-size term (W5.9). IR-side mirror of
1758/// [`lumen_core::components::TrackSize`].
1759#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
1760pub enum TrackSizeSpec {
1761    /// `<N>px`.
1762    Fixed(f32),
1763    /// `auto`.
1764    Auto,
1765    /// `<N>fr`.
1766    Fr(f32),
1767    /// `min-content`.
1768    MinContent,
1769    /// `max-content`.
1770    MaxContent,
1771    /// `minmax(<min>, <max>)`.
1772    MinMax(Box<TrackSizeSpec>, Box<TrackSizeSpec>),
1773}
1774
1775/// Parsed `grid-template-rows` + `grid-template-columns` lists (W5.9).
1776#[derive(Debug, Clone, Default, PartialEq, serde::Serialize, serde::Deserialize)]
1777pub struct GridTemplateSpec {
1778    /// `grid-template-rows`.
1779    pub rows: Vec<TrackSizeSpec>,
1780    /// `grid-template-columns`.
1781    pub columns: Vec<TrackSizeSpec>,
1782}
1783
1784/// Main-axis distribution (CSS `justify-content`).
1785#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1786pub enum FlexJustify {
1787    /// Pack at the start.
1788    Start,
1789    /// Pack at the end.
1790    End,
1791    /// Pack at the center.
1792    Center,
1793    /// Space between siblings, no edge padding.
1794    SpaceBetween,
1795    /// Space around siblings, half-step at edges.
1796    SpaceAround,
1797    /// Even spacing including edges.
1798    SpaceEvenly,
1799}
1800
1801/// Flex direction.
1802#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1803pub enum FlexAxis {
1804    /// Horizontal.
1805    Row,
1806    /// Vertical.
1807    Column,
1808    /// Horizontal, reversed (CSS `row-reverse`).
1809    RowReverse,
1810    /// Vertical, reversed (CSS `column-reverse`).
1811    ColumnReverse,
1812}
1813
1814/// CSS `flex-wrap` values (IR-side mirror of
1815/// [`lumen_core::components::FlexWrap`]).
1816#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1817pub enum FlexWrapSpec {
1818    /// Single line (CSS initial value).
1819    NoWrap,
1820    /// Wrap onto additional lines.
1821    Wrap,
1822    /// Wrap with reversed line order.
1823    WrapReverse,
1824}
1825
1826/// CSS `align-content` values (IR-side mirror of
1827/// [`lumen_core::components::AlignContent`]).
1828#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1829pub enum AlignContentSpec {
1830    /// Pack lines at the start.
1831    Start,
1832    /// Pack lines at the end.
1833    End,
1834    /// Pack lines at the center.
1835    Center,
1836    /// Stretch lines (CSS initial value).
1837    Stretch,
1838    /// Even gaps between lines.
1839    SpaceBetween,
1840    /// Half gaps at the edges.
1841    SpaceAround,
1842    /// Equal gaps everywhere.
1843    SpaceEvenly,
1844}
1845
1846/// CSS `border-style` subset. v1 recognises `none` and `solid`; other
1847/// keywords are rejected at parse time with a per-declaration warning.
1848#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1849pub enum BorderStyleSpec {
1850    /// No border - computed border-width is zero on every side (CSS).
1851    None,
1852    /// Solid stroke.
1853    Solid,
1854}
1855
1856/// CSS `box-sizing` values (IR-side mirror of
1857/// [`lumen_core::components::BoxSizing`]).
1858#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
1859pub enum BoxSizingSpec {
1860    /// Sizes include padding + border (Lumen UA default).
1861    BorderBox,
1862    /// Sizes cover the content box only.
1863    ContentBox,
1864}
1865
1866/// Resolved border paint for a state override (`:hover { border: ... }`,
1867/// `hover-border:` / `focus-border:`). Unlike the base border - which
1868/// accumulates from the `border-width` / `border-color` / `border-style`
1869/// longhands - a state border is authored via the shorthand only, so it
1870/// resolves to concrete widths + color at parse time.
1871#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
1872pub struct BorderPaintSpec {
1873    /// Per-side widths in px.
1874    pub widths: Edges,
1875    /// Border color.
1876    pub color: Rgba,
1877}
1878
1879// -- IR -> core type conversions. spawn.rs uses `.into()` instead of
1880// inline `match` expressions for every attribute. ---------------------
1881
1882impl From<LengthSpec> for lumen_core::components::Length {
1883    fn from(l: LengthSpec) -> Self {
1884        match l {
1885            LengthSpec::Auto => lumen_core::components::Length::Auto,
1886            LengthSpec::Px(v) => lumen_core::components::Length::Px(v),
1887            LengthSpec::Percent(v) => lumen_core::components::Length::Percent(v),
1888        }
1889    }
1890}
1891
1892impl From<FlexAxis> for lumen_core::components::FlexDirection {
1893    fn from(a: FlexAxis) -> Self {
1894        match a {
1895            FlexAxis::Row => lumen_core::components::FlexDirection::Row,
1896            FlexAxis::Column => lumen_core::components::FlexDirection::Column,
1897            FlexAxis::RowReverse => lumen_core::components::FlexDirection::RowReverse,
1898            FlexAxis::ColumnReverse => lumen_core::components::FlexDirection::ColumnReverse,
1899        }
1900    }
1901}
1902
1903impl From<FlexWrapSpec> for lumen_core::components::FlexWrap {
1904    fn from(w: FlexWrapSpec) -> Self {
1905        match w {
1906            FlexWrapSpec::NoWrap => lumen_core::components::FlexWrap::NoWrap,
1907            FlexWrapSpec::Wrap => lumen_core::components::FlexWrap::Wrap,
1908            FlexWrapSpec::WrapReverse => lumen_core::components::FlexWrap::WrapReverse,
1909        }
1910    }
1911}
1912
1913impl From<AlignContentSpec> for lumen_core::components::AlignContent {
1914    fn from(a: AlignContentSpec) -> Self {
1915        use lumen_core::components::AlignContent as Core;
1916        match a {
1917            AlignContentSpec::Start => Core::Start,
1918            AlignContentSpec::End => Core::End,
1919            AlignContentSpec::Center => Core::Center,
1920            AlignContentSpec::Stretch => Core::Stretch,
1921            AlignContentSpec::SpaceBetween => Core::SpaceBetween,
1922            AlignContentSpec::SpaceAround => Core::SpaceAround,
1923            AlignContentSpec::SpaceEvenly => Core::SpaceEvenly,
1924        }
1925    }
1926}
1927
1928impl From<BoxSizingSpec> for lumen_core::components::BoxSizing {
1929    fn from(b: BoxSizingSpec) -> Self {
1930        match b {
1931            BoxSizingSpec::BorderBox => lumen_core::components::BoxSizing::BorderBox,
1932            BoxSizingSpec::ContentBox => lumen_core::components::BoxSizing::ContentBox,
1933        }
1934    }
1935}
1936
1937impl From<BorderPaintSpec> for lumen_core::components::Border {
1938    fn from(b: BorderPaintSpec) -> Self {
1939        lumen_core::components::Border {
1940            widths: b.widths.into(),
1941            color: b.color.into(),
1942            side_colors: None,
1943        }
1944    }
1945}
1946
1947impl From<FlexAlign> for lumen_core::components::FlexAlign {
1948    fn from(a: FlexAlign) -> Self {
1949        match a {
1950            FlexAlign::Start => lumen_core::components::FlexAlign::Start,
1951            FlexAlign::End => lumen_core::components::FlexAlign::End,
1952            FlexAlign::Center => lumen_core::components::FlexAlign::Center,
1953            FlexAlign::Stretch => lumen_core::components::FlexAlign::Stretch,
1954            FlexAlign::Baseline => lumen_core::components::FlexAlign::Baseline,
1955        }
1956    }
1957}
1958
1959impl From<DisplaySpec> for lumen_core::components::Display {
1960    fn from(d: DisplaySpec) -> Self {
1961        match d {
1962            DisplaySpec::Flex => lumen_core::components::Display::Flex,
1963            DisplaySpec::Grid => lumen_core::components::Display::Grid,
1964            DisplaySpec::None => lumen_core::components::Display::None,
1965        }
1966    }
1967}
1968
1969impl From<&TrackSizeSpec> for lumen_core::components::TrackSize {
1970    fn from(t: &TrackSizeSpec) -> Self {
1971        use lumen_core::components::TrackSize as Core;
1972        match t {
1973            TrackSizeSpec::Fixed(v) => Core::Fixed(*v),
1974            TrackSizeSpec::Auto => Core::Auto,
1975            TrackSizeSpec::Fr(f) => Core::Fr(*f),
1976            TrackSizeSpec::MinContent => Core::MinContent,
1977            TrackSizeSpec::MaxContent => Core::MaxContent,
1978            TrackSizeSpec::MinMax(min, max) => Core::MinMax(
1979                Box::new(Core::from(min.as_ref())),
1980                Box::new(Core::from(max.as_ref())),
1981            ),
1982        }
1983    }
1984}
1985
1986impl From<&GridTemplateSpec> for lumen_core::components::GridTemplate {
1987    fn from(g: &GridTemplateSpec) -> Self {
1988        lumen_core::components::GridTemplate {
1989            rows: g.rows.iter().map(Into::into).collect(),
1990            columns: g.columns.iter().map(Into::into).collect(),
1991        }
1992    }
1993}
1994
1995impl From<FlexJustify> for lumen_core::components::FlexJustify {
1996    fn from(j: FlexJustify) -> Self {
1997        match j {
1998            FlexJustify::Start => lumen_core::components::FlexJustify::Start,
1999            FlexJustify::End => lumen_core::components::FlexJustify::End,
2000            FlexJustify::Center => lumen_core::components::FlexJustify::Center,
2001            FlexJustify::SpaceBetween => lumen_core::components::FlexJustify::SpaceBetween,
2002            FlexJustify::SpaceAround => lumen_core::components::FlexJustify::SpaceAround,
2003            FlexJustify::SpaceEvenly => lumen_core::components::FlexJustify::SpaceEvenly,
2004        }
2005    }
2006}
2007
2008impl From<TextAlignSpec> for lumen_core::components::TextAlign {
2009    fn from(a: TextAlignSpec) -> Self {
2010        match a {
2011            TextAlignSpec::Start => lumen_core::components::TextAlign::Start,
2012            TextAlignSpec::Center => lumen_core::components::TextAlign::Center,
2013            TextAlignSpec::End => lumen_core::components::TextAlign::End,
2014        }
2015    }
2016}
2017
2018impl From<PositionSpec> for lumen_core::components::Position {
2019    fn from(p: PositionSpec) -> Self {
2020        match p {
2021            PositionSpec::Relative => lumen_core::components::Position::Relative,
2022            PositionSpec::Absolute => lumen_core::components::Position::Absolute,
2023        }
2024    }
2025}
2026
2027impl From<ImageFitSpec> for lumen_core::components::ImageFit {
2028    fn from(f: ImageFitSpec) -> Self {
2029        match f {
2030            ImageFitSpec::Fill => lumen_core::components::ImageFit::Fill,
2031            ImageFitSpec::Cover => lumen_core::components::ImageFit::Cover,
2032            ImageFitSpec::Contain => lumen_core::components::ImageFit::Contain,
2033            ImageFitSpec::None => lumen_core::components::ImageFit::None,
2034            ImageFitSpec::ScaleDown => lumen_core::components::ImageFit::ScaleDown,
2035        }
2036    }
2037}
2038
2039impl From<OverflowSpec> for lumen_core::components::Overflow {
2040    fn from(o: OverflowSpec) -> Self {
2041        match o {
2042            OverflowSpec::Visible => lumen_core::components::Overflow::Visible,
2043            OverflowSpec::Hidden => lumen_core::components::Overflow::Hidden,
2044            OverflowSpec::Scroll => lumen_core::components::Overflow::Scroll,
2045        }
2046    }
2047}
2048
2049impl From<TextWrapSpec> for lumen_core::components::TextWrap {
2050    fn from(w: TextWrapSpec) -> Self {
2051        match w {
2052            TextWrapSpec::None => lumen_core::components::TextWrap::None,
2053            TextWrapSpec::Word => lumen_core::components::TextWrap::Word,
2054            TextWrapSpec::Glyph => lumen_core::components::TextWrap::Glyph,
2055        }
2056    }
2057}
2058
2059impl From<ScrollAxisSpec> for lumen_core::input::ScrollAxis {
2060    fn from(a: ScrollAxisSpec) -> Self {
2061        match a {
2062            ScrollAxisSpec::Y => lumen_core::input::ScrollAxis::Y,
2063            ScrollAxisSpec::X => lumen_core::input::ScrollAxis::X,
2064            ScrollAxisSpec::Both => lumen_core::input::ScrollAxis::Both,
2065        }
2066    }
2067}
2068
2069impl From<Edges> for lumen_core::components::Edges {
2070    fn from(e: Edges) -> Self {
2071        // W5.5: physical sides forward 1:1; the four logical-edge
2072        // overrides ride along so the layout backend can resolve them
2073        // against `ResolvedDirection` per CSS Logical Properties L1.
2074        // Percent units (CSS `padding: 5%`) ride along the same way.
2075        lumen_core::components::Edges {
2076            left: e.left,
2077            right: e.right,
2078            top: e.top,
2079            bottom: e.bottom,
2080            inline_start: e.inline_start,
2081            inline_end: e.inline_end,
2082            block_start: e.block_start,
2083            block_end: e.block_end,
2084            pct_left: e.pct_left,
2085            pct_right: e.pct_right,
2086            pct_top: e.pct_top,
2087            pct_bottom: e.pct_bottom,
2088        }
2089    }
2090}
2091
2092impl From<Rgba> for lumen_core::components::Color {
2093    fn from(c: Rgba) -> Self {
2094        lumen_core::components::Color::rgba(c.r, c.g, c.b, c.a)
2095    }
2096}
2097
2098#[cfg(test)]
2099mod bughunt_tests {
2100    use super::*;
2101
2102    #[test]
2103    fn effective_transitions_empty_duration_list_does_not_panic() {
2104        // A `Some(vec![])` (reachable via `transition-duration: ,`) used to
2105        // index an empty slice at `durations[0]` and panic. It must now be
2106        // treated like an absent list (default 0ms).
2107        let attrs = Attributes {
2108            transition_property: Some(vec![TransitionPropertyIr::Opacity]),
2109            transition_duration: Some(Vec::new()),
2110            transition_timing: Some(Vec::new()),
2111            ..Default::default()
2112        };
2113        let out = attrs.effective_transitions();
2114        assert_eq!(out.len(), 1);
2115        assert_eq!(out[0].duration_ms, 0);
2116        assert_eq!(out[0].easing, EasingIr::CubicBezier(0.25, 0.1, 0.25, 1.0));
2117    }
2118}
2119
2120#[cfg(test)]
2121mod tests {
2122    use super::*;
2123
2124    /// Asset paths come out of the compiler resolved against the machine
2125    /// that compiled and have to leave relative to the app, or a copied
2126    /// package looks for its images in a directory that only exists there.
2127    #[test]
2128    fn asset_paths_leave_relative_to_the_app() {
2129        // Absolute on every platform, and with each family's own separators:
2130        // a literal POSIX path is relative on Windows, where the rewrite would
2131        // have nothing to do and the test would pass without testing anything.
2132        let app = std::env::temp_dir().join("notes");
2133        let inside = app.join("icons").join("save.png");
2134        let elsewhere = std::env::temp_dir().join("pixmaps").join("other.png");
2135
2136        let image = |path: &Path| Element {
2137            tag: "image".to_string(),
2138            attrs: Attributes {
2139                src: Some(path.to_string_lossy().into_owned()),
2140                ..Default::default()
2141            },
2142            ..Default::default()
2143        };
2144        let mut root = Element {
2145            tag: "root".to_string(),
2146            children: vec![image(&inside), image(&elsewhere)],
2147            ..Default::default()
2148        };
2149
2150        let mut outside = Vec::new();
2151        relativize_asset_paths(&mut root, &app, &mut outside);
2152
2153        // Compared as a path, not as text: the separator between `icons` and
2154        // the file name is the platform's own.
2155        let rewritten = root.children[0]
2156            .attrs
2157            .src
2158            .as_deref()
2159            .expect("the asset keeps a path");
2160        assert_eq!(Path::new(rewritten), Path::new("icons").join("save.png"));
2161        assert!(
2162            Path::new(rewritten).is_relative(),
2163            "a packaged asset path must be relative to the app: {rewritten}"
2164        );
2165        assert_eq!(
2166            outside,
2167            vec![elsewhere.to_string_lossy().into_owned()],
2168            "a file outside the app directory keeps the path it had"
2169        );
2170    }
2171
2172    /// A placeholder's spelling alone decides its scope, and adding the
2173    /// fragment-parameter scope does not change that: `{$name}` and the bare
2174    /// `{name}` stay global lookups, because nothing in the text says which
2175    /// of the two a name is. Only a fragment's declared parameter list can
2176    /// turn one into [`InterpolationSlot::Arg`].
2177    #[test]
2178    fn placeholder_classification_is_by_spelling() {
2179        let cases = [
2180            ("$name", InterpolationSlot::Global("name".to_string())),
2181            ("name", InterpolationSlot::Global("name".to_string())),
2182            (" $name ", InterpolationSlot::Global("name".to_string())),
2183            ("$index", InterpolationSlot::RowIndex),
2184            ("idx", InterpolationSlot::RowIndex),
2185            ("row.title", InterpolationSlot::Row("title".to_string())),
2186            ("$self.x", InterpolationSlot::SelfField("x".to_string())),
2187            ("$parent.y", InterpolationSlot::ParentField("y".to_string())),
2188        ];
2189        for (text, expected) in cases {
2190            assert_eq!(InterpolationSlot::from(text), expected, "`{text}`");
2191        }
2192    }
2193
2194    /// The slot list is part of the artifact, so every variant has to make
2195    /// the round trip, the new one included.
2196    #[test]
2197    fn placeholder_slots_round_trip() {
2198        let slots = vec![
2199            InterpolationSlot::Global("count".to_string()),
2200            InterpolationSlot::Row("title".to_string()),
2201            InterpolationSlot::RowIndex,
2202            InterpolationSlot::SelfField("x".to_string()),
2203            InterpolationSlot::ParentField("y".to_string()),
2204            InterpolationSlot::Arg("tone".to_string()),
2205        ];
2206        let bytes = bincode::serialize(&slots).expect("slots encode");
2207        let back: Vec<InterpolationSlot> = bincode::deserialize(&bytes).expect("slots decode");
2208        assert_eq!(back, slots);
2209    }
2210}