Skip to main content

fenestra_core/
style.rs

1//! The fully-typed style IR. Three groups: layout (maps 1:1 onto taffy),
2//! paint, and text. No CSS strings anywhere; every property autocompletes.
3
4use peniko::Color;
5
6use crate::tokens::{FamilyRole, TextSize, Weight};
7
8/// A length value for sizes and flex basis.
9#[derive(Debug, Clone, Copy, PartialEq, Default)]
10pub enum Length {
11    /// Logical pixels.
12    Px(f32),
13    /// Percent of the parent, 0.0..=100.0.
14    Pct(f32),
15    /// A reading measure in CSS `ch` units: 1ch is the advance width of the
16    /// digit `'0'` in the element's own resolved text style. Used to cap a
17    /// prose column near the optimal line length (the default
18    /// [`MEASURE_CH`](crate::MEASURE_CH) is calibrated for ~66 characters)
19    /// independent of window width. Resolved to `Px` during layout, where font
20    /// metrics are available; treat it as `Auto` if it ever reaches taffy
21    /// unresolved.
22    Ch(f32),
23    /// Let layout decide.
24    #[default]
25    Auto,
26}
27
28impl Length {
29    /// Resolves a `Ch(n)` to `Px(n * ch_px)`; other variants pass through.
30    pub(crate) fn resolved(self, ch_px: f32) -> Length {
31        match self {
32            Length::Ch(n) => Length::Px(n * ch_px),
33            other => other,
34        }
35    }
36}
37
38/// Display mode of a box.
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
40pub enum Display {
41    /// Flexbox container (the default).
42    #[default]
43    Flex,
44    /// Grid container.
45    Grid,
46    /// Removed from layout entirely.
47    None,
48}
49
50/// Main axis direction of a flex container.
51#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
52pub enum Direction {
53    /// Left to right.
54    #[default]
55    Row,
56    /// Top to bottom.
57    Column,
58}
59
60/// Cross-axis alignment of children.
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
62pub enum AlignItems {
63    /// Stretch to fill the cross axis (CSS default).
64    #[default]
65    Stretch,
66    /// Pack toward the start.
67    Start,
68    /// Center.
69    Center,
70    /// Pack toward the end.
71    End,
72    /// Align children on their first text baseline (rows only). Boxes
73    /// without text use their bottom edge, like CSS synthesized baselines.
74    Baseline,
75}
76
77/// Main-axis distribution of children.
78#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
79pub enum JustifyContent {
80    /// Pack toward the start (the default).
81    #[default]
82    Start,
83    /// Center.
84    Center,
85    /// Pack toward the end.
86    End,
87    /// Distribute with space between items.
88    SpaceBetween,
89}
90
91/// Multi-line content alignment (flex wrap / grid).
92#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
93pub enum AlignContent {
94    /// Pack lines toward the start.
95    #[default]
96    Start,
97    /// Center lines.
98    Center,
99    /// Pack lines toward the end.
100    End,
101    /// Stretch lines to fill.
102    Stretch,
103    /// Distribute with space between lines.
104    SpaceBetween,
105}
106
107/// Positioning scheme.
108#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
109pub enum Position {
110    /// Normal flow; inset offsets visually only.
111    #[default]
112    Relative,
113    /// Out of flow, positioned against the nearest relative ancestor.
114    Absolute,
115    /// In normal flow until the nearest scroll container scrolls it past a
116    /// `sticky_*` threshold, then pinned to that edge of the viewport (CSS
117    /// `position: sticky`). Resolved post-layout in the realize pass.
118    Sticky,
119}
120
121/// Overflow behavior per axis.
122#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
123pub enum Overflow {
124    /// Content may paint outside the box.
125    #[default]
126    Visible,
127    /// Content is clipped to the box.
128    Hidden,
129    /// Content is clipped and the box scrolls (M3).
130    Scroll,
131}
132
133/// Per-edge values (padding, margin, inset).
134#[derive(Debug, Clone, Copy, PartialEq, Default)]
135pub struct Edges {
136    /// Top edge.
137    pub top: f32,
138    /// Right edge.
139    pub right: f32,
140    /// Bottom edge.
141    pub bottom: f32,
142    /// Left edge.
143    pub left: f32,
144}
145
146impl Edges {
147    /// The same value on all four edges.
148    pub const fn all(v: f32) -> Self {
149        Self {
150            top: v,
151            right: v,
152            bottom: v,
153            left: v,
154        }
155    }
156}
157
158/// Optional per-edge offsets for positioned elements.
159#[derive(Debug, Clone, Copy, PartialEq, Default)]
160pub struct Inset {
161    /// Offset from the top.
162    pub top: Option<f32>,
163    /// Offset from the right.
164    pub right: Option<f32>,
165    /// Offset from the bottom.
166    pub bottom: Option<f32>,
167    /// Offset from the left.
168    pub left: Option<f32>,
169}
170
171/// A grid track size — CSS `<track-size>`. `Px` and `Fr` are the common cases;
172/// `MinMax` plus the content keywords cover responsive templates.
173#[derive(Debug, Clone, Copy, PartialEq)]
174pub enum Track {
175    /// Fixed logical pixels.
176    Px(f32),
177    /// Fraction of remaining free space (CSS `fr`).
178    Fr(f32),
179    /// Sized to fit its content within the available space (CSS `auto`).
180    Auto,
181    /// The largest minimal content contribution (CSS `min-content`).
182    MinContent,
183    /// The largest maximal content contribution (CSS `max-content`).
184    MaxContent,
185    /// `fit-content(px)`: like `auto`, but capped at the given pixels.
186    FitContent(f32),
187    /// `minmax(min, max)`: a floor and a ceiling for the track size.
188    MinMax(TrackMin, TrackMax),
189}
190
191/// The `min` argument of a [`Track::MinMax`] — a track's floor. A floor cannot be
192/// flexible, so there is no `fr` here (CSS forbids it).
193#[derive(Debug, Clone, Copy, PartialEq)]
194pub enum TrackMin {
195    /// Fixed logical pixels.
196    Px(f32),
197    /// `auto` (the track's minimum content size).
198    Auto,
199    /// `min-content`.
200    MinContent,
201    /// `max-content`.
202    MaxContent,
203}
204
205/// The `max` argument of a [`Track::MinMax`] — a track's ceiling.
206#[derive(Debug, Clone, Copy, PartialEq)]
207pub enum TrackMax {
208    /// Fixed logical pixels.
209    Px(f32),
210    /// Fraction of remaining free space (CSS `fr`).
211    Fr(f32),
212    /// `auto`.
213    Auto,
214    /// `min-content`.
215    MinContent,
216    /// `max-content`.
217    MaxContent,
218    /// `fit-content(px)`.
219    FitContent(f32),
220}
221
222/// One entry of a grid template: a single [`Track`], or a `repeat(...)` of tracks
223/// (including the responsive `auto-fit` / `auto-fill`). Build a `Vec<GridTemplate>`
224/// with [`Style::grid_cols`] / [`Style::grid_rows`], which also accept plain
225/// `Track`s (each wrapped as [`GridTemplate::Single`]).
226#[derive(Debug, Clone, PartialEq)]
227pub enum GridTemplate {
228    /// A single track.
229    Single(Track),
230    /// `repeat(count, tracks)` — the fragment `tracks` repeated per [`Repeat`].
231    Repeat(Repeat, Vec<Track>),
232}
233
234/// How many times a `repeat(...)` fragment is generated.
235#[derive(Debug, Clone, Copy, PartialEq, Eq)]
236pub enum Repeat {
237    /// Exactly `n` repetitions.
238    Count(u16),
239    /// As many as fit, collapsing empty repetitions (CSS `auto-fit`).
240    AutoFit,
241    /// As many as fit, keeping empty repetitions (CSS `auto-fill`).
242    AutoFill,
243}
244
245impl GridTemplate {
246    /// `repeat(auto-fit, minmax(min_px, 1fr))` — the canonical responsive grid:
247    /// as many equal columns as fit, each at least `min_px` wide, collapsing
248    /// empty tracks so the row stays centered when it is not full.
249    #[must_use]
250    pub fn auto_fit_minmax(min_px: f32) -> Self {
251        Self::Repeat(
252            Repeat::AutoFit,
253            vec![Track::MinMax(TrackMin::Px(min_px), TrackMax::Fr(1.0))],
254        )
255    }
256
257    /// `repeat(auto-fill, minmax(min_px, 1fr))` — like [`Self::auto_fit_minmax`],
258    /// but empty trailing tracks keep their space (CSS `auto-fill`).
259    #[must_use]
260    pub fn auto_fill_minmax(min_px: f32) -> Self {
261        Self::Repeat(
262            Repeat::AutoFill,
263            vec![Track::MinMax(TrackMin::Px(min_px), TrackMax::Fr(1.0))],
264        )
265    }
266
267    /// `repeat(n, tracks)`.
268    #[must_use]
269    pub fn repeat(n: u16, tracks: impl IntoIterator<Item = Track>) -> Self {
270        Self::Repeat(Repeat::Count(n), tracks.into_iter().collect())
271    }
272}
273
274impl From<Track> for GridTemplate {
275    fn from(t: Track) -> Self {
276        Self::Single(t)
277    }
278}
279
280/// Grid item placement on one axis.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
282pub struct GridPlace {
283    /// 1-based start line; `None` for auto.
284    pub start: Option<i16>,
285    /// Number of tracks to span; `None` for 1 (or auto).
286    pub span: Option<u16>,
287}
288
289/// A named-line placement on one grid axis: the start and end line names
290/// (CSS `grid-column: a / b`). Empty by default — numeric/auto placement. Names
291/// resolve against the parent grid's `grid-template-areas` (`<name>-start` /
292/// `<name>-end`) and explicit line names.
293#[derive(Debug, Clone, PartialEq, Eq, Default)]
294pub struct GridLines {
295    /// Start line name (before the `/`).
296    pub start: Option<String>,
297    /// End line name (after the `/`).
298    pub end: Option<String>,
299}
300
301/// A gradient color stop: offset 0.0..=1.0 and a color.
302#[derive(Debug, Clone, Copy, PartialEq)]
303pub struct GradientStop {
304    /// Position along the gradient, 0.0..=1.0.
305    pub offset: f32,
306    /// Color at this stop.
307    pub color: Color,
308}
309
310/// Background paint.
311#[derive(Debug, Clone, PartialEq)]
312pub enum Paint {
313    /// A solid color.
314    Solid(Color),
315    /// A linear gradient at a CSS-style angle in degrees: 0 points up,
316    /// 90 points right. Endpoints are computed from the element's rect.
317    LinearGradient {
318        /// CSS-style angle in degrees.
319        angle_deg: f32,
320        /// Color stops, offsets 0.0..=1.0.
321        stops: Vec<GradientStop>,
322    },
323    /// A radial gradient centered at `center` (unit coordinates within the
324    /// element rect) reaching `radius` times half the rect diagonal.
325    RadialGradient {
326        /// Center in unit coordinates (0.5, 0.5 is the middle).
327        center: (f32, f32),
328        /// Radius as a multiple of half the rect's larger side.
329        radius: f32,
330        /// Color stops, offsets 0.0..=1.0.
331        stops: Vec<GradientStop>,
332    },
333    /// A conic (sweep) gradient centered at `center` (unit coordinates within
334    /// the element rect), sweeping the stops once around the full circle.
335    ConicGradient {
336        /// Center in unit coordinates (0.5, 0.5 is the middle).
337        center: (f32, f32),
338        /// Color stops, offsets 0.0..=1.0 mapped around the sweep.
339        stops: Vec<GradientStop>,
340    },
341}
342
343impl From<Color> for Paint {
344    fn from(c: Color) -> Self {
345        Self::Solid(c)
346    }
347}
348
349/// Expands OKLCH-interpolated color stops between anchor colors. Each adjacent
350/// anchor pair is walked in `steps` sub-segments through OKLCH (shortest hue
351/// arc, achromatic-endpoint handling, gamut-clamped — the exact OKLCH lerp the
352/// transition engine animates colors along), so the rendered ramp stays
353/// perceptually even with no desaturated "gray dead-zone" through the middle of
354/// a wide-hue transition. The vello renderer interpolates the returned stops in
355/// sRGB, but they sit densely on the OKLCH curve, so the on-screen ramp tracks
356/// it.
357/// Anchors are `(offset, color)` with offsets in `0.0..=1.0`; they are sorted
358/// ascending and the endpoints are preserved exactly. Colors must come from
359/// theme tokens or [`oklch`](crate::oklch) / [`oklch_of`](crate::oklch_of) —
360/// never a raw hex literal.
361///
362/// Edge cases: empty anchors yield `vec![]`; a single anchor yields one stop at
363/// its offset; `steps == 0` yields the (sorted) anchors verbatim, un-interpolated.
364#[must_use]
365pub fn oklch_stops(anchors: &[(f32, Color)], steps: usize) -> Vec<GradientStop> {
366    if anchors.is_empty() {
367        return Vec::new();
368    }
369    let mut sorted = anchors.to_vec();
370    sorted.sort_by(|a, b| a.0.total_cmp(&b.0));
371    // A single anchor (or no sub-segments requested) has nothing to walk
372    // through; emit the anchors verbatim.
373    if sorted.len() == 1 || steps == 0 {
374        return sorted
375            .iter()
376            .map(|&(offset, color)| GradientStop { offset, color })
377            .collect();
378    }
379    let mut out = Vec::with_capacity((sorted.len() - 1) * steps + 1);
380    for (seg, pair) in sorted.windows(2).enumerate() {
381        let (o0, c0) = pair[0];
382        let (o1, c1) = pair[1];
383        // The shared boundary stop is emitted once: skip j == 0 on every
384        // segment after the first.
385        let first = usize::from(seg != 0);
386        for j in first..=steps {
387            #[expect(clippy::cast_precision_loss, reason = "gradient step counts are tiny")]
388            let t = j as f32 / steps as f32;
389            out.push(GradientStop {
390                offset: o0 + (o1 - o0) * t,
391                color: crate::anim::lerp_color(c0, c1, t),
392            });
393        }
394    }
395    out
396}
397
398/// Spaces `colors` evenly across `0.0..=1.0` as `(offset, color)` anchors. One
399/// color anchors at 0.0; the empty list yields no anchors.
400fn even_anchors(colors: impl IntoIterator<Item = Color>) -> Vec<(f32, Color)> {
401    let colors: Vec<Color> = colors.into_iter().collect();
402    let last = colors.len().saturating_sub(1);
403    if last == 0 {
404        return colors.into_iter().map(|c| (0.0, c)).collect();
405    }
406    colors
407        .into_iter()
408        .enumerate()
409        .map(|(i, c)| {
410            #[expect(clippy::cast_precision_loss, reason = "color counts are tiny")]
411            let offset = i as f32 / last as f32;
412            (offset, c)
413        })
414        .collect()
415}
416
417/// A gradient needs at least two colors to interpolate; fewer collapses to a
418/// solid fill (the lone color, or fully transparent for none) so the painter
419/// never receives a degenerate one-or-zero-stop list.
420fn degenerate_solid(colors: &[Color]) -> Option<Paint> {
421    match colors {
422        [] => Some(Paint::Solid(Color::new([0.0, 0.0, 0.0, 0.0]))),
423        [c] => Some(Paint::Solid(*c)),
424        _ => None,
425    }
426}
427
428/// A linear [`Paint`] whose `colors` are spaced evenly across `0.0..=1.0` and
429/// expanded into a perceptually smooth OKLCH ramp ([`oklch_stops`] with
430/// [`GRADIENT_STEPS`](crate::GRADIENT_STEPS)). `angle_deg` is CSS-style (0 up,
431/// 90 right). Reads from tokens, e.g.
432/// `bg(linear_gradient(135.0, [t.accent, t.accent_text]))`. Fewer than two
433/// colors collapse to a solid fill (one color, or transparent for none).
434#[must_use]
435pub fn linear_gradient(angle_deg: f32, colors: impl IntoIterator<Item = Color>) -> Paint {
436    let colors: Vec<Color> = colors.into_iter().collect();
437    degenerate_solid(&colors).unwrap_or_else(|| Paint::LinearGradient {
438        angle_deg,
439        stops: oklch_stops(&even_anchors(colors), crate::tokens::GRADIENT_STEPS),
440    })
441}
442
443/// A radial [`Paint`] (see [`Paint::RadialGradient`] for `center` / `radius`)
444/// whose `colors` are spaced evenly across `0.0..=1.0` and expanded into an
445/// OKLCH ramp ([`oklch_stops`] with [`GRADIENT_STEPS`](crate::GRADIENT_STEPS)).
446/// Fewer than two colors collapse to a solid fill (one color, or transparent).
447#[must_use]
448pub fn radial_gradient(
449    center: (f32, f32),
450    radius: f32,
451    colors: impl IntoIterator<Item = Color>,
452) -> Paint {
453    let colors: Vec<Color> = colors.into_iter().collect();
454    degenerate_solid(&colors).unwrap_or_else(|| Paint::RadialGradient {
455        center,
456        radius,
457        stops: oklch_stops(&even_anchors(colors), crate::tokens::GRADIENT_STEPS),
458    })
459}
460
461/// A conic (sweep) [`Paint`] (see [`Paint::ConicGradient`]) centered at `center`
462/// (unit coordinates within the element rect), sweeping `colors` once around the
463/// full circle as a smooth OKLCH ramp ([`oklch_stops`] with
464/// [`GRADIENT_STEPS`](crate::GRADIENT_STEPS)). Fewer than two colors collapse to
465/// a solid fill. Reads from theme tokens — never a raw hex literal.
466#[must_use]
467pub fn conic_gradient(center: (f32, f32), colors: impl IntoIterator<Item = Color>) -> Paint {
468    let colors: Vec<Color> = colors.into_iter().collect();
469    degenerate_solid(&colors).unwrap_or_else(|| Paint::ConicGradient {
470        center,
471        stops: oklch_stops(&even_anchors(colors), crate::tokens::GRADIENT_STEPS),
472    })
473}
474
475/// A border stroke: a width and color. Apply it to every edge with
476/// [`Style::border`], or to a single edge with [`Style::border_top`] and
477/// friends (carried per-edge by [`EdgeBorders`]).
478#[derive(Debug, Clone, Copy, PartialEq)]
479pub struct Border {
480    /// Stroke width in logical pixels.
481    pub width: f32,
482    /// Stroke color.
483    pub color: Color,
484}
485
486/// Per-edge border strokes (top/right/bottom/left), each optional and
487/// independent of the uniform [`Border`]. Drawn as straight hairlines with
488/// square corners — for a rounded full edge use [`Style::border`]. Lets
489/// hairline-divided layouts (a header's bottom rule, a left accent rail, a
490/// table's ruled rows) skip manual 1px divider children.
491#[derive(Debug, Clone, Copy, PartialEq, Default)]
492pub struct EdgeBorders {
493    /// Top edge stroke.
494    pub top: Option<Border>,
495    /// Right edge stroke.
496    pub right: Option<Border>,
497    /// Bottom edge stroke.
498    pub bottom: Option<Border>,
499    /// Left edge stroke.
500    pub left: Option<Border>,
501}
502
503/// Per-corner radii in logical pixels.
504#[derive(Debug, Clone, Copy, PartialEq, Default)]
505pub struct CornerRadius {
506    /// Top-left.
507    pub tl: f32,
508    /// Top-right.
509    pub tr: f32,
510    /// Bottom-right.
511    pub br: f32,
512    /// Bottom-left.
513    pub bl: f32,
514}
515
516impl CornerRadius {
517    /// The same radius on all corners.
518    pub const fn all(r: f32) -> Self {
519        Self {
520            tl: r,
521            tr: r,
522            br: r,
523            bl: r,
524        }
525    }
526}
527
528/// One drop shadow layer.
529#[derive(Debug, Clone, Copy, PartialEq)]
530pub struct Shadow {
531    /// Horizontal offset.
532    pub dx: f32,
533    /// Vertical offset.
534    pub dy: f32,
535    /// Blur radius (CSS semantics: gaussian std dev = blur / 2).
536    pub blur: f32,
537    /// Outset applied to the shadow rect before blurring.
538    pub spread: f32,
539    /// Shadow color (usually black at low alpha).
540    pub color: Color,
541}
542
543/// Horizontal text alignment.
544#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
545pub enum TextAlign {
546    /// Align to the start (left in LTR).
547    #[default]
548    Start,
549    /// Center.
550    Center,
551    /// Align to the end.
552    End,
553}
554
555/// How text is broken into lines once shaping has wrapped it at the
556/// available width. parley line-breaks greedily (each line is filled as
557/// full as it can be); these modes refine that result by re-wrapping at a
558/// narrower width that the framework searches for. Part of the layout
559/// cache key (so a mode flip is never cached away).
560#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
561pub enum TextWrap {
562    /// Greedy line breaking — parley's native behavior. The default; the
563    /// only mode that costs nothing.
564    #[default]
565    Normal,
566    /// Even line lengths (CSS `text-wrap: balance`). After the greedy wrap
567    /// yields N lines, the smallest wrap width still yielding N lines is
568    /// found by binary search, so lines come out evenly filled instead of
569    /// `[full, full, short]`. For short, high-impact text — headings,
570    /// titles, pull quotes — not body copy. Line count is preserved.
571    Balance,
572    /// Avoid a stranded last word (CSS `text-wrap: pretty`). If the greedy
573    /// wrap leaves the final line a single short word (an orphan), the wrap
574    /// width is nudged down just enough to pull a second word onto the last
575    /// line, without adding a line. Best-effort: when no such width exists,
576    /// the greedy result is kept unchanged. For paragraphs.
577    Pretty,
578}
579
580/// Figure (numeral) shape. Old-style figures have varying heights and
581/// descenders that sit naturally in serif prose; lining figures are
582/// uniform cap-height digits for data and UI. `Default` leaves the font's
583/// own default figures untouched. Maps to the `onum`/`lnum` OpenType features.
584#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
585pub enum FigureStyle {
586    /// Leave the font's default figures.
587    #[default]
588    Default,
589    /// Lining figures (`lnum`): uniform cap-height digits.
590    Lining,
591    /// Old-style / text figures (`onum`): ascending and descending digits.
592    OldStyle,
593}
594
595/// Figure spacing. Proportional figures are individually spaced for prose;
596/// tabular figures share one advance so columns of numbers align and values
597/// that update in place don't jump. `Default` leaves the font's own spacing.
598/// Maps to the `pnum`/`tnum` OpenType features.
599#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
600pub enum NumericSpacing {
601    /// Leave the font's default spacing.
602    #[default]
603    Default,
604    /// Proportional figures (`pnum`): naturally spaced for prose.
605    Proportional,
606    /// Tabular figures (`tnum`): fixed-width for aligned columns.
607    Tabular,
608}
609
610/// A typed set of OpenType features applied to a text run. Orthogonal axes:
611/// figure shape (`figures`) and figure spacing (`spacing`) compose freely
612/// (e.g. tabular + old-style is valid), small caps, standard ligatures, and
613/// fractions are independent toggles. The default enables nothing, leaving
614/// every glyph at the font's own defaults. Built into a CSS
615/// `font-feature-settings` string for parley; part of the layout cache key.
616#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
617pub struct FontFeatures {
618    /// Figure shape (`onum`/`lnum`).
619    pub figures: FigureStyle,
620    /// Figure spacing (`pnum`/`tnum`).
621    pub spacing: NumericSpacing,
622    /// Small capitals for lowercase letters (`smcp`).
623    pub small_caps: bool,
624    /// Standard ligatures (`liga`): `None` = font default, `Some(false)`
625    /// disables, `Some(true)` forces on. Most fonts enable `liga` already.
626    pub ligatures: Option<bool>,
627    /// Common fractions (`frac`): turns `1/2` into a single fraction glyph.
628    pub fractions: bool,
629}
630
631impl FontFeatures {
632    /// The CSS `font-feature-settings` string for these features, or `None`
633    /// when nothing is enabled. Tags are emitted in a fixed order so the
634    /// output is deterministic: figures, spacing, small caps, ligatures,
635    /// fractions — e.g. `"onum" 1, "tnum" 1`.
636    pub(crate) fn feature_string(&self) -> Option<String> {
637        let mut parts: Vec<&'static str> = Vec::new();
638        match self.figures {
639            FigureStyle::Default => {}
640            FigureStyle::Lining => parts.push("\"lnum\" 1"),
641            FigureStyle::OldStyle => parts.push("\"onum\" 1"),
642        }
643        match self.spacing {
644            NumericSpacing::Default => {}
645            NumericSpacing::Proportional => parts.push("\"pnum\" 1"),
646            NumericSpacing::Tabular => parts.push("\"tnum\" 1"),
647        }
648        if self.small_caps {
649            parts.push("\"smcp\" 1");
650        }
651        match self.ligatures {
652            None => {}
653            Some(true) => parts.push("\"liga\" 1"),
654            Some(false) => parts.push("\"liga\" 0"),
655        }
656        if self.fractions {
657            parts.push("\"frac\" 1");
658        }
659        if parts.is_empty() {
660            None
661        } else {
662            Some(parts.join(", "))
663        }
664    }
665}
666
667/// How the `opsz` (optical size) variation axis of a *variable* font is set.
668/// Optical-size masters are drawn for a size range: fine, high-contrast cuts
669/// at large display sizes and sturdier, more open cuts at small text sizes, so
670/// a face looks right across the scale instead of one master scaled up and
671/// down. Maps to CSS `font-optical-sizing` / the `opsz` `font-variation-settings`
672/// axis.
673///
674/// This only affects faces that carry an `opsz` axis (e.g. the bundled Fraunces
675/// text serif); static faces — the embedded Inter, JetBrains Mono — have no
676/// such axis, so it is a no-op for them. The default ([`OpticalSizing::Inherit`])
677/// follows the theme's [`Theme::optical_sizing`], which is `Auto` out of the box
678/// (the CSS default `font-optical-sizing: auto`), so a variable text face tracks
679/// its `opsz` axis with no per-element setup; opt out per element with `Default`.
680///
681/// [`Theme::optical_sizing`]: crate::Theme::optical_sizing
682#[derive(Debug, Clone, Copy, PartialEq, Default)]
683pub enum OpticalSizing {
684    /// Inherit the theme default ([`Theme::optical_sizing`](crate::Theme::optical_sizing)).
685    /// The default; an unresolved style (e.g. before theme resolution) treats it
686    /// as [`Auto`](Self::Auto).
687    #[default]
688    Inherit,
689    /// Leave the font's own default `opsz` (emit no variation) — opt this element
690    /// out of optical sizing entirely.
691    Default,
692    /// `opsz` tracks the rendered size in px (CSS `font-optical-sizing: auto`):
693    /// small text picks up the text-optical master, large sizes the display
694    /// master. The everyday choice for a variable text face.
695    Auto,
696    /// Pin `opsz` to a fixed axis value (the font's `opsz` units, ≈ points),
697    /// independent of the rendered size. For showing one optical master at any
698    /// size (specimens, deliberate contrast).
699    Fixed(f32),
700}
701
702impl OpticalSizing {
703    /// The `opsz` axis value to apply for text rendered at `px` logical pixels,
704    /// or `None` to leave the font's default (emit no `opsz` variation).
705    /// [`Auto`](Self::Auto) returns `px`; [`Fixed`](Self::Fixed) its (clamped
706    /// non-negative) value.
707    #[must_use]
708    pub fn opsz_at(self, px: f32) -> Option<f32> {
709        match self {
710            // Unresolved `Inherit` falls back to `Auto` (the stock theme value),
711            // so text shaped without theme resolution still tracks `opsz`.
712            OpticalSizing::Inherit | OpticalSizing::Auto => Some(px.max(0.0)),
713            OpticalSizing::Default => None,
714            OpticalSizing::Fixed(v) => Some(v.max(0.0)),
715        }
716    }
717}
718
719/// The text style group. `color`, `line_height`, and `letter_spacing`
720/// default to the theme/text-size tokens when `None`.
721#[derive(Debug, Clone, Copy, PartialEq, Default)]
722pub struct TextStyle {
723    /// Size on the typographic scale.
724    pub size: TextSize,
725    /// Free-form size in logical px, overriding the scale token (editorial
726    /// display sizes). Line height defaults to 1.25 when set.
727    pub size_px: Option<f32>,
728    /// Font weight.
729    pub weight: Weight,
730    /// Text color; defaults to the theme's `text` role.
731    pub color: Option<Color>,
732    /// Line height multiple; defaults to the size token's value.
733    pub line_height: Option<f32>,
734    /// Letter spacing in em; defaults to the size token's value.
735    pub letter_spacing: Option<f32>,
736    /// Family role (sans or mono).
737    pub family: FamilyRole,
738    /// Horizontal alignment within the text box.
739    pub align: TextAlign,
740    /// Maximum number of lines before truncation with an ellipsis.
741    pub max_lines: Option<u32>,
742    /// OpenType features applied to the run (figures, spacing, small caps,
743    /// ligatures, fractions). Defaults to the font's own defaults.
744    pub features: FontFeatures,
745    /// Line-breaking refinement (balance / pretty). Defaults to greedy
746    /// [`TextWrap::Normal`], which costs nothing; other modes do extra
747    /// line-break passes inside shaping for this element only.
748    pub wrap: TextWrap,
749    /// Optical sizing: how the `opsz` variation axis of a variable font is set.
750    /// Defaults to [`OpticalSizing::Inherit`] — the theme's
751    /// [`Theme::optical_sizing`](crate::Theme::optical_sizing) (`Auto` out of the
752    /// box). A no-op on static faces; opt out per element with `Default`.
753    pub optical: OpticalSizing,
754}
755
756/// A foreground filter applied to an element's *own* rendered content (CSS
757/// `filter:`), as opposed to [`Style::backdrop_blur`] which filters what shows
758/// *through* a translucent element. Resolved by the shell's two-pass renderer:
759/// the element's pixels are read back, filtered on the CPU (deterministically),
760/// and composited in place. Each variant carries one lever in logical px (blur
761/// radius) or as a unit multiplier (`1.0` = identity).
762#[derive(Debug, Clone, Copy, PartialEq)]
763pub enum ElementFilter {
764    /// Gaussian-approximating blur; the value is the blur radius in logical px
765    /// (a standard deviation), realized as a deterministic 3-pass integer box
766    /// blur. `0.0` is a no-op.
767    Blur(f32),
768    /// Brightness multiplier on each color channel (`1.0` unchanged, `0.5`
769    /// half-bright, `>1.0` brighter, clamped at the channel ceiling).
770    Brightness(f32),
771    /// Saturation multiplier about each pixel's luma (`1.0` unchanged, `0.0`
772    /// grayscale, `>1.0` more saturated, clamped into gamut).
773    Saturate(f32),
774}
775
776/// A luminous specular edge rim — the iconic Liquid Glass perimeter light. A
777/// directional highlight that wraps the whole rounded silhouette, brightest on
778/// the edge that faces a fixed light (the top, by default) and fading toward the
779/// far side, so a translucent pane reads as lit, lensed glass rather than a flat
780/// outline. Painted as a gradient-brushed stroke just inside the silhouette,
781/// over an optional faint dark inner contact line that gives the rim a sense of
782/// thickness (the "double edge"). `None` (the default) paints no rim — every
783/// non-glass element stays byte-identical. Set by
784/// [`Surface::Glass`](crate::Surface::Glass); layered over the flat
785/// [`highlight_top`](Style::highlight_top) bar on glass (both are painted).
786#[derive(Debug, Clone, Copy, PartialEq)]
787pub struct SpecularEdge {
788    /// Light direction in CSS gradient degrees (`0` = up, `90` = right): the rim
789    /// is brightest on the edge facing this way and shaded on the opposite edge.
790    /// The Liquid Glass default light is from the top
791    /// ([`GLASS_LIGHT_DEG`](crate::tokens::GLASS_LIGHT_DEG)).
792    pub light_deg: f32,
793    /// Peak white alpha on the lit edge (the top), `0.0..=1.0` — the bright half
794    /// of the lens.
795    pub intensity: f32,
796    /// Peak dark alpha on the unlit far edge (the bottom), `0.0..=1.0` — the
797    /// shaded underside that makes the rim read as a lit bevel (a lens), not a
798    /// flat outline. A single rim stroke ramps from this shade, through clear, to
799    /// `intensity`, so the edge is light on top and dark on the bottom.
800    pub shade: f32,
801}
802
803impl SpecularEdge {
804    /// The Liquid Glass rim: a top-lit edge — bright white along the top and
805    /// upper corners, darkening to a shaded underside at the bottom — so the
806    /// perimeter reads as lit, lensed glass.
807    #[must_use]
808    pub const fn glass() -> Self {
809        Self {
810            light_deg: crate::tokens::GLASS_LIGHT_DEG,
811            intensity: 0.6,
812            shade: 0.18,
813        }
814    }
815}
816
817/// A directional body sheen across an element's face — the raking light that
818/// makes a translucent pane read as lit glass instead of a flat, uniform tint.
819/// A soft gradient wash, white at the end facing the light (top-left) grading
820/// through transparent to an optional faint shade at the far end, source-over
821/// the fill and clipped to the rounded silhouette. `None` (the default) paints
822/// no sheen. Pairs with [`SpecularEdge`]; both are set by
823/// [`Surface::Glass`](crate::Surface::Glass).
824#[derive(Debug, Clone, Copy, PartialEq)]
825pub struct Sheen {
826    /// Gradient axis in CSS degrees (`0` = up, `90` = right): the bright end
827    /// faces the light, the shaded end is opposite.
828    pub light_deg: f32,
829    /// White alpha at the lit (near) end of the wash, `0.0..=1.0`.
830    pub top: f32,
831    /// Dark alpha at the far end (`0.0` = a one-sided, brighten-only sheen).
832    pub bottom: f32,
833}
834
835impl Sheen {
836    /// The Liquid Glass face sheen: a gentle white rake from the upper-left
837    /// fading out across the pane, with a whisper of shade at the lower-right.
838    #[must_use]
839    pub const fn glass() -> Self {
840        Self {
841            light_deg: 135.0,
842            top: 0.12,
843            bottom: 0.06,
844        }
845    }
846}
847
848/// Backdrop-adaptive vibrancy for a glass material. In the headless two-pass
849/// renderer — where the frosted backdrop image is in hand at paint time — the
850/// painter measures the mean luminance of the blurred crop behind the pane and
851/// shifts the tint's OKLCH lightness toward white over dark backdrops and toward
852/// black over light ones, so the frosted surface keeps a stable contrast against
853/// whatever floats behind it. This is Fluent Acrylic's "luminosity clamp" idea
854/// reduced to a single mean-luminance lightness shift (Apple Liquid Glass does
855/// the same tonal adaptation, without a published formula). `None` (the default)
856/// keeps a fixed tint, so non-glass elements — and the single-pass live window,
857/// which has no backdrop image — stay byte-identical. Set by
858/// [`Surface::Glass`](crate::Surface::Glass).
859#[derive(Debug, Clone, Copy, PartialEq)]
860pub struct AdaptiveTint {
861    /// Backdrop luminance (`0` = black .. `1` = white) the shift pivots around: a
862    /// backdrop at exactly this luminance leaves the tint unchanged.
863    pub pivot: f32,
864    /// Lightness-shift gain. The tint's OKLCH lightness moves by
865    /// `gain * (pivot - backdrop_luminance)`, so a fully dark backdrop brightens
866    /// it by up to `gain * pivot` and a fully light one darkens it by up to
867    /// `gain * (1 - pivot)`.
868    pub gain: f32,
869}
870
871impl AdaptiveTint {
872    /// The calibrated glass recipe: pivot near mid-luminance, a calm gain tuned
873    /// so the adaptation reads as the material picking up the room behind it
874    /// without ever turning the pane opaque.
875    #[must_use]
876    pub const fn glass() -> Self {
877        Self {
878            pivot: 0.55,
879            gain: 0.20,
880        }
881    }
882}
883
884/// The complete style of an element: layout, paint, and text groups.
885#[derive(Debug, Clone, PartialEq)]
886pub struct Style {
887    // -- layout --
888    /// Display mode.
889    pub display: Display,
890    /// Flex main axis.
891    pub direction: Direction,
892    /// Whether flex children wrap.
893    pub wrap: bool,
894    /// Cross-axis alignment of children.
895    pub align_items: AlignItems,
896    /// Override of the parent's `align_items` for this element.
897    pub align_self: Option<AlignItems>,
898    /// Main-axis distribution of children.
899    pub justify_content: JustifyContent,
900    /// Multi-line alignment.
901    pub align_content: AlignContent,
902    /// Gap between children (both axes), logical px.
903    pub gap: f32,
904    /// Inner padding.
905    pub padding: Edges,
906    /// Outer margin.
907    pub margin: Edges,
908    /// Offsets for positioned elements.
909    pub inset: Inset,
910    /// Positioning scheme.
911    pub position: Position,
912    /// Preferred width.
913    pub width: Length,
914    /// Preferred height.
915    pub height: Length,
916    /// Minimum width.
917    pub min_width: Length,
918    /// Maximum width.
919    pub max_width: Length,
920    /// Minimum height.
921    pub min_height: Length,
922    /// Maximum height.
923    pub max_height: Length,
924    /// Flex grow factor.
925    pub flex_grow: f32,
926    /// Flex shrink factor.
927    pub flex_shrink: f32,
928    /// Flex basis.
929    pub flex_basis: Length,
930    /// Grid template columns (when `display` is `Grid`).
931    pub grid_template_columns: Vec<GridTemplate>,
932    /// Grid template rows.
933    pub grid_template_rows: Vec<GridTemplate>,
934    /// Column placement when inside a grid.
935    pub grid_column: GridPlace,
936    /// Row placement when inside a grid.
937    pub grid_row: GridPlace,
938    /// `grid-template-areas`: rows of cells, each `Some(name)` or `None` (an
939    /// empty `.` cell). Children placed with [`Style::grid_area`] occupy the
940    /// named rectangle. Empty when unused.
941    pub grid_template_areas: Vec<Vec<Option<String>>>,
942    /// Named-area placement (CSS `grid-area`): occupies the named area on both
943    /// axes, resolved against the parent's `grid_template_areas`.
944    pub grid_area: Option<String>,
945    /// Named-line column placement (CSS `grid-column: a / b`).
946    pub grid_column_lines: GridLines,
947    /// Named-line row placement (CSS `grid-row: a / b`).
948    pub grid_row_lines: GridLines,
949    /// Column line names, positional: the i-th entry names the (i+1)-th column
950    /// line. Empty when unused.
951    pub grid_col_line_names: Vec<Vec<String>>,
952    /// Row line names, positional: the i-th entry names the (i+1)-th row line.
953    pub grid_row_line_names: Vec<Vec<String>>,
954    /// Horizontal overflow.
955    pub overflow_x: Overflow,
956    /// Vertical overflow.
957    pub overflow_y: Overflow,
958    /// Sticky offset from the scroll viewport's top edge (with `Position::Sticky`).
959    pub sticky_top: Option<f32>,
960    /// Sticky offset from the scroll viewport's right edge.
961    pub sticky_right: Option<f32>,
962    /// Sticky offset from the scroll viewport's bottom edge.
963    pub sticky_bottom: Option<f32>,
964    /// Sticky offset from the scroll viewport's left edge.
965    pub sticky_left: Option<f32>,
966
967    // -- paint --
968    /// Background paint.
969    pub fill: Option<Paint>,
970    /// Uniform border.
971    pub border: Option<Border>,
972    /// Per-edge border strokes, independent of the uniform [`border`](Self::border).
973    pub side_borders: EdgeBorders,
974    /// Per-corner radii.
975    pub corner_radius: CornerRadius,
976    /// Continuous-curvature corner smoothing (Figma's "corner smoothing").
977    /// `None` (the default) inherits [`Theme::corner_smoothing`]; `Some(s)` with
978    /// `s` in `0.0..=1.0` overrides it for this element, and `Some(0.0)` forces
979    /// exact circular arcs. As smoothing rises toward `1.0` the corners blend
980    /// toward a fuller superellipse (an Apple-style squircle): the curve hugs
981    /// each straight edge longer and turns more gradually, removing the
982    /// curvature discontinuity ("kink") where a straight edge meets a circular
983    /// arc. Fill, border, and clip share one path so they stay aligned.
984    /// Structural, not animated: it is never lerped, so a target state's
985    /// smoothing simply wins.
986    ///
987    /// [`Theme::corner_smoothing`]: crate::Theme::corner_smoothing
988    pub corner_smoothing: Option<f32>,
989    /// A shadow elevation token, expanded against the theme at resolution.
990    pub shadow_token: Option<crate::tokens::ShadowToken>,
991    /// Concrete drop shadow layers, painted bottom-up. Filled from
992    /// `shadow_token` during style resolution; may also be set directly.
993    pub shadows: Vec<Shadow>,
994    /// A 1px inset highlight along the top inner edge (CSS `inset 0 1px 0`):
995    /// the cheapest "raised, crafted" signal on a solid control. Painted over
996    /// the fill, clipped to the corner radius. Usually white at low alpha.
997    pub highlight_top: Option<Color>,
998    /// A luminous specular edge rim wrapping the rounded silhouette (the iconic
999    /// Liquid Glass perimeter light). `None` (the default) paints no rim, so
1000    /// non-glass elements stay byte-identical. See [`SpecularEdge`].
1001    pub specular_edge: Option<SpecularEdge>,
1002    /// A directional body sheen across the face — the raking light that makes a
1003    /// translucent pane read as lit glass. `None` (the default) paints no sheen.
1004    /// See [`Sheen`].
1005    pub sheen: Option<Sheen>,
1006    /// Backdrop-adaptive vibrancy: shifts the glass tint's lightness by the mean
1007    /// luminance of the frosted backdrop behind it (headless-only — it needs the
1008    /// composited backdrop image). `None` (the default) keeps a fixed tint, so
1009    /// non-glass elements stay byte-identical. See [`AdaptiveTint`].
1010    pub adaptive_tint: Option<AdaptiveTint>,
1011    /// Opacity 0.0..=1.0 applied to the whole subtree.
1012    pub opacity: f32,
1013    /// Uniform scale applied at paint time about the element's center
1014    /// (1.0 = no transform). Pressed controls dip to [`crate::tokens::PRESS_SCALE`];
1015    /// it never affects layout or hit-testing, and it animates. Spring
1016    /// transitions may carry it past the target for a tactile overshoot.
1017    pub scale: f32,
1018    /// Paint-time translation in logical px `(x, y)` — never affects layout or
1019    /// hit-testing; animatable.
1020    pub translate: (f32, f32),
1021    /// Paint-time rotation in degrees about the element center; animatable.
1022    pub rotate: f32,
1023    /// Paint-time skew in degrees `(x, y)` about the element center; animatable.
1024    pub skew: (f32, f32),
1025    /// Non-uniform paint-time scale `(x, y)` composed with the uniform
1026    /// [`scale`](Self::scale) (`(1.0, 1.0)` = no transform). Like every paint
1027    /// transform it never affects layout, and hit-testing follows the paint;
1028    /// animatable.
1029    pub scale_xy: (f32, f32),
1030    /// The pivot for paint-time transforms, as a fraction of the element's
1031    /// rect (`(0.0, 0.0)` = top-left, `(0.5, 0.5)` = center, the default —
1032    /// CSS `transform-origin` semantics). Values outside `0..=1` pivot
1033    /// about a point beyond the rect; animatable.
1034    pub transform_origin: (f32, f32),
1035    /// Clip children to the (rounded) bounds.
1036    pub clip: bool,
1037    /// Draw progress of path elements, 0.0..=1.0 (animatable; this is how
1038    /// check marks draw on).
1039    pub path_trim: f32,
1040    /// Backdrop blur radius in logical px: when set (`> 0`), the content
1041    /// *behind* this (translucent) element is read back and blurred before the
1042    /// element's fill composites over it — a real frosted-glass pane. `None`
1043    /// (the default) paints with no backdrop pass, byte-identically to a plain
1044    /// fill. Carried by [`Surface::Glass`](crate::Surface::Glass); realized by
1045    /// the shell's two-pass renderer (deterministic CPU box blur). Inert in the
1046    /// single-pass live-window path (renders as the translucent tint alone).
1047    pub backdrop_blur: Option<f32>,
1048    /// A foreground filter on this element's *own* content (blur / brightness /
1049    /// saturate). `None` (the default) paints the content unfiltered. Realized
1050    /// by the shell's two-pass renderer alongside [`backdrop_blur`](Self::backdrop_blur).
1051    pub element_filter: Option<ElementFilter>,
1052
1053    // -- text --
1054    /// Text properties (used by text elements; inherited defaults elsewhere).
1055    pub text: TextStyle,
1056}
1057
1058impl Default for Style {
1059    fn default() -> Self {
1060        Self {
1061            display: Display::default(),
1062            direction: Direction::default(),
1063            wrap: false,
1064            align_items: AlignItems::default(),
1065            align_self: None,
1066            justify_content: JustifyContent::default(),
1067            align_content: AlignContent::default(),
1068            gap: 0.0,
1069            padding: Edges::default(),
1070            margin: Edges::default(),
1071            inset: Inset::default(),
1072            position: Position::default(),
1073            width: Length::Auto,
1074            height: Length::Auto,
1075            min_width: Length::Auto,
1076            max_width: Length::Auto,
1077            min_height: Length::Auto,
1078            max_height: Length::Auto,
1079            flex_grow: 0.0,
1080            flex_shrink: 1.0,
1081            flex_basis: Length::Auto,
1082            grid_template_columns: Vec::new(),
1083            grid_template_rows: Vec::new(),
1084            grid_column: GridPlace::default(),
1085            grid_row: GridPlace::default(),
1086            grid_template_areas: Vec::new(),
1087            grid_area: None,
1088            grid_column_lines: GridLines::default(),
1089            grid_row_lines: GridLines::default(),
1090            grid_col_line_names: Vec::new(),
1091            grid_row_line_names: Vec::new(),
1092            overflow_x: Overflow::Visible,
1093            overflow_y: Overflow::Visible,
1094            sticky_top: None,
1095            sticky_right: None,
1096            sticky_bottom: None,
1097            sticky_left: None,
1098            fill: None,
1099            border: None,
1100            side_borders: EdgeBorders::default(),
1101            corner_radius: CornerRadius::default(),
1102            corner_smoothing: None,
1103            shadow_token: None,
1104            shadows: Vec::new(),
1105            highlight_top: None,
1106            specular_edge: None,
1107            sheen: None,
1108            adaptive_tint: None,
1109            opacity: 1.0,
1110            scale: 1.0,
1111            translate: (0.0, 0.0),
1112            rotate: 0.0,
1113            skew: (0.0, 0.0),
1114            scale_xy: (1.0, 1.0),
1115            transform_origin: (0.5, 0.5),
1116            clip: false,
1117            path_trim: 1.0,
1118            backdrop_blur: None,
1119            element_filter: None,
1120            text: TextStyle::default(),
1121        }
1122    }
1123}
1124
1125impl Style {
1126    /// The pivot a paint-time transform rotates/scales about: the rect's
1127    /// [`transform_origin`](Self::transform_origin) point (center by
1128    /// default), projected into absolute coordinates. Shared by every
1129    /// transform this style drives — the static paint matrix
1130    /// ([`paint_affine`](Self::paint_affine)) and an exit animation's own
1131    /// scale/translate — so a leaving element's animated half pivots about
1132    /// the same point its frozen static half does.
1133    #[must_use]
1134    pub fn transform_origin_point(&self, rect: kurbo::Rect) -> kurbo::Point {
1135        kurbo::Point::new(
1136            rect.x0 + f64::from(self.transform_origin.0) * rect.width(),
1137            rect.y0 + f64::from(self.transform_origin.1) * rect.height(),
1138        )
1139    }
1140
1141    /// The paint-time affine this style applies over its (untransformed)
1142    /// layout rect — translate / rotate / skew / scale (uniform, then
1143    /// non-uniform) composed about the rect's
1144    /// [`transform_origin`](Self::transform_origin) point — or `None` when
1145    /// the transform is identity. The single source of truth for the paint
1146    /// matrix: painting draws under it, hit-testing inverts it, exit ghosts
1147    /// replay it frozen, and offline samplers (`fenestra-motion`) project
1148    /// bounding boxes through it.
1149    pub fn paint_affine(&self, rect: kurbo::Rect) -> Option<kurbo::Affine> {
1150        let s = self;
1151        let has_transform = (s.scale - 1.0).abs() > 1e-4
1152            || (s.scale_xy.0 - 1.0).abs() > 1e-4
1153            || (s.scale_xy.1 - 1.0).abs() > 1e-4
1154            || s.translate.0.abs() > 1e-4
1155            || s.translate.1.abs() > 1e-4
1156            || s.rotate.abs() > 1e-4
1157            || s.skew.0.abs() > 1e-4
1158            || s.skew.1.abs() > 1e-4;
1159        if !has_transform {
1160            return None;
1161        }
1162        let p = s.transform_origin_point(rect);
1163        // T(translate) · T(p) · R · Skew · S · T(-p)
1164        let mut a = kurbo::Affine::translate((f64::from(s.translate.0), f64::from(s.translate.1)))
1165            * kurbo::Affine::translate((p.x, p.y));
1166        if s.rotate.abs() > 1e-4 {
1167            a *= kurbo::Affine::rotate(f64::from(s.rotate).to_radians());
1168        }
1169        if s.skew.0.abs() > 1e-4 || s.skew.1.abs() > 1e-4 {
1170            a *= kurbo::Affine::new([
1171                1.0,
1172                f64::from(s.skew.1).to_radians().tan(),
1173                f64::from(s.skew.0).to_radians().tan(),
1174                1.0,
1175                0.0,
1176                0.0,
1177            ]);
1178        }
1179        if (s.scale - 1.0).abs() > 1e-4 {
1180            a *= kurbo::Affine::scale(f64::from(s.scale));
1181        }
1182        if (s.scale_xy.0 - 1.0).abs() > 1e-4 || (s.scale_xy.1 - 1.0).abs() > 1e-4 {
1183            a *= kurbo::Affine::scale_non_uniform(f64::from(s.scale_xy.0), f64::from(s.scale_xy.1));
1184        }
1185        a *= kurbo::Affine::translate((-p.x, -p.y));
1186        Some(a)
1187    }
1188}
1189
1190/// A theme-aware partial style overlay: interaction variants and kit
1191/// widgets' deferred base styling both use this shape.
1192pub type ThemedFn = Box<dyn Fn(&crate::theme::Theme, Style) -> Style>;
1193
1194/// Spring parameters for physical motion (see [`Transition::spring`]),
1195/// shared with `fenestra-motion`'s offline sampler and any other frame/tick
1196/// -based consumer — see [`fenestra_anim::SpringSpec`] for the closed-form
1197/// step response.
1198pub use fenestra_anim::SpringSpec;
1199
1200/// Declares which properties animate between style states, and how.
1201#[derive(Debug, Clone, Copy, PartialEq)]
1202pub struct Transition {
1203    /// Animate colors (fill, border, text), lerped in OKLCH.
1204    pub colors: bool,
1205    /// Animate opacity.
1206    pub opacity: bool,
1207    /// Animate lengths (sizes, padding, radii).
1208    pub lengths: bool,
1209    /// Animate position offsets.
1210    pub offsets: bool,
1211    /// Animate shadow alpha.
1212    pub shadows: bool,
1213    /// Duration in milliseconds.
1214    pub duration_ms: f32,
1215    /// Easing curve.
1216    pub easing: crate::tokens::CubicBezier,
1217    /// Physical spring response instead of the duration+curve pair.
1218    /// Lengths and offsets may overshoot; colors, opacity, and shadows
1219    /// clamp at the target (extrapolated colors aren't colors).
1220    pub spring: Option<SpringSpec>,
1221}
1222
1223/// A looping keyframe timeline: style stops at fractional times across one
1224/// period, sampled from the frame clock every frame. Built for ambient
1225/// motion (pulses, shimmers, breathing); one-shot state changes belong to
1226/// [`Transition`]. With reduced motion the first stop is pinned, keeping
1227/// headless renders deterministic.
1228pub struct Keyframes {
1229    pub(crate) stops: Vec<(f32, ThemedFn)>,
1230    pub(crate) duration_ms: f32,
1231    pub(crate) easing: crate::tokens::CubicBezier,
1232}
1233
1234impl Keyframes {
1235    /// A timeline lasting `duration_ms` per cycle (looped).
1236    pub fn new(duration_ms: f32) -> Self {
1237        Self {
1238            stops: Vec::new(),
1239            duration_ms,
1240            easing: crate::tokens::EASE_STANDARD,
1241        }
1242    }
1243
1244    /// Adds a stop at fraction `at` (clamped to 0..=1) transforming the
1245    /// element's resolved base style. For a seamless loop, make the styles
1246    /// at 0 and 1 match.
1247    pub fn stop(self, at: f32, f: impl Fn(Style) -> Style + 'static) -> Self {
1248        self.themed_stop(at, move |_, s| f(s))
1249    }
1250
1251    /// A theme-aware stop, for keyframes that color through tokens.
1252    pub fn themed_stop(
1253        mut self,
1254        at: f32,
1255        f: impl Fn(&crate::theme::Theme, Style) -> Style + 'static,
1256    ) -> Self {
1257        self.stops.push((at.clamp(0.0, 1.0), Box::new(f)));
1258        self.stops.sort_by(|a, b| a.0.total_cmp(&b.0));
1259        self
1260    }
1261
1262    /// Per-segment easing (standard ease by default).
1263    pub fn ease(mut self, easing: crate::tokens::CubicBezier) -> Self {
1264        self.easing = easing;
1265        self
1266    }
1267}
1268
1269impl Transition {
1270    /// The standard hover transition: colors and shadow alpha over the Fast
1271    /// duration with standard easing.
1272    pub fn colors() -> Self {
1273        Self {
1274            colors: true,
1275            opacity: false,
1276            lengths: false,
1277            offsets: false,
1278            shadows: true,
1279            duration_ms: crate::tokens::MotionDuration::Fast.ms(),
1280            easing: crate::tokens::EASE_STANDARD,
1281            spring: None,
1282        }
1283    }
1284
1285    /// Animate every animatable property over the Base duration.
1286    pub fn all() -> Self {
1287        Self {
1288            colors: true,
1289            opacity: true,
1290            lengths: true,
1291            offsets: true,
1292            shadows: true,
1293            duration_ms: crate::tokens::MotionDuration::Base.ms(),
1294            easing: crate::tokens::EASE_STANDARD,
1295            spring: None,
1296        }
1297    }
1298
1299    /// Every property on a brisk spring with a touch of overshoot
1300    /// (stiffness 380, damping 26). Lengths and offsets carry the
1301    /// bounce; colors clamp at the target.
1302    pub fn spring() -> Self {
1303        Self {
1304            spring: Some(SpringSpec {
1305                stiffness: 380.0,
1306                damping: 26.0,
1307            }),
1308            ..Self::all()
1309        }
1310    }
1311
1312    /// Overrides the spring parameters (and switches to spring motion).
1313    pub fn with_spring(mut self, stiffness: f32, damping: f32) -> Self {
1314        self.spring = Some(SpringSpec { stiffness, damping });
1315        self
1316    }
1317
1318    /// Overrides the duration with a token.
1319    pub fn duration(mut self, d: crate::tokens::MotionDuration) -> Self {
1320        self.duration_ms = d.ms();
1321        self
1322    }
1323
1324    /// Overrides the duration in milliseconds.
1325    pub fn duration_ms(mut self, ms: f32) -> Self {
1326        self.duration_ms = ms;
1327        self
1328    }
1329
1330    /// Enables or disables length animation (sizes, padding, radii, trim).
1331    pub fn lengths(mut self, on: bool) -> Self {
1332        self.lengths = on;
1333        self
1334    }
1335
1336    /// Enables or disables offset animation (inset).
1337    pub fn offsets(mut self, on: bool) -> Self {
1338        self.offsets = on;
1339        self
1340    }
1341
1342    /// Enables or disables opacity animation.
1343    pub fn opacity(mut self, on: bool) -> Self {
1344        self.opacity = on;
1345        self
1346    }
1347
1348    /// Overrides the easing curve.
1349    pub fn easing(mut self, e: crate::tokens::CubicBezier) -> Self {
1350        self.easing = e;
1351        self
1352    }
1353}
1354
1355impl From<f32> for Length {
1356    /// Raw `f32` means logical pixels.
1357    fn from(v: f32) -> Self {
1358        Self::Px(v)
1359    }
1360}
1361
1362impl Style {
1363    // -- layout: padding --
1364
1365    /// Padding on all edges.
1366    pub fn p(mut self, v: f32) -> Self {
1367        self.padding = Edges::all(v);
1368        self
1369    }
1370
1371    /// Horizontal padding (left and right).
1372    pub fn px(mut self, v: f32) -> Self {
1373        self.padding.left = v;
1374        self.padding.right = v;
1375        self
1376    }
1377
1378    /// Vertical padding (top and bottom).
1379    pub fn py(mut self, v: f32) -> Self {
1380        self.padding.top = v;
1381        self.padding.bottom = v;
1382        self
1383    }
1384
1385    /// Top padding.
1386    pub fn pt(mut self, v: f32) -> Self {
1387        self.padding.top = v;
1388        self
1389    }
1390
1391    /// Right padding.
1392    pub fn pr(mut self, v: f32) -> Self {
1393        self.padding.right = v;
1394        self
1395    }
1396
1397    /// Bottom padding.
1398    pub fn pb(mut self, v: f32) -> Self {
1399        self.padding.bottom = v;
1400        self
1401    }
1402
1403    /// Left padding.
1404    pub fn pl(mut self, v: f32) -> Self {
1405        self.padding.left = v;
1406        self
1407    }
1408
1409    // -- layout: margin --
1410
1411    /// Margin on all edges.
1412    pub fn m(mut self, v: f32) -> Self {
1413        self.margin = Edges::all(v);
1414        self
1415    }
1416
1417    /// Horizontal margin.
1418    pub fn mx(mut self, v: f32) -> Self {
1419        self.margin.left = v;
1420        self.margin.right = v;
1421        self
1422    }
1423
1424    /// Vertical margin.
1425    pub fn my(mut self, v: f32) -> Self {
1426        self.margin.top = v;
1427        self.margin.bottom = v;
1428        self
1429    }
1430
1431    /// Top margin.
1432    pub fn mt(mut self, v: f32) -> Self {
1433        self.margin.top = v;
1434        self
1435    }
1436
1437    /// Right margin.
1438    pub fn mr(mut self, v: f32) -> Self {
1439        self.margin.right = v;
1440        self
1441    }
1442
1443    /// Bottom margin.
1444    pub fn mb(mut self, v: f32) -> Self {
1445        self.margin.bottom = v;
1446        self
1447    }
1448
1449    /// Left margin.
1450    pub fn ml(mut self, v: f32) -> Self {
1451        self.margin.left = v;
1452        self
1453    }
1454
1455    // -- layout: size and flex --
1456
1457    /// Gap between children, both axes.
1458    pub fn gap(mut self, v: f32) -> Self {
1459        self.gap = v;
1460        self
1461    }
1462
1463    /// Preferred width. Raw `f32` means logical px; use `Length::Pct`/`Auto`.
1464    pub fn w(mut self, v: impl Into<Length>) -> Self {
1465        self.width = v.into();
1466        self
1467    }
1468
1469    /// Preferred height.
1470    pub fn h(mut self, v: impl Into<Length>) -> Self {
1471        self.height = v.into();
1472        self
1473    }
1474
1475    /// Minimum width.
1476    pub fn min_w(mut self, v: impl Into<Length>) -> Self {
1477        self.min_width = v.into();
1478        self
1479    }
1480
1481    /// Maximum width.
1482    pub fn max_w(mut self, v: impl Into<Length>) -> Self {
1483        self.max_width = v.into();
1484        self
1485    }
1486
1487    /// Minimum height.
1488    pub fn min_h(mut self, v: impl Into<Length>) -> Self {
1489        self.min_height = v.into();
1490        self
1491    }
1492
1493    /// Maximum height.
1494    pub fn max_h(mut self, v: impl Into<Length>) -> Self {
1495        self.max_height = v.into();
1496        self
1497    }
1498
1499    /// Width 100%.
1500    pub fn w_full(mut self) -> Self {
1501        self.width = Length::Pct(100.0);
1502        self
1503    }
1504
1505    /// Height 100%.
1506    pub fn h_full(mut self) -> Self {
1507        self.height = Length::Pct(100.0);
1508        self
1509    }
1510
1511    /// Caps this element's width at a reading measure of `chars` `ch` units
1512    /// (a `ch`-based `max-width`). 1ch is the advance of `'0'` in this
1513    /// element's resolved text style, so the column is `chars` `'0'`-widths
1514    /// wide regardless of how wide the window is — a proportional face fits
1515    /// somewhat *more* than `chars` real glyphs per line (the default
1516    /// [`MEASURE_CH`](crate::MEASURE_CH) is tuned for ~66 characters). Set the
1517    /// element's text `size` and `family` to the prose it wraps so the measure
1518    /// tracks the real text.
1519    pub fn measure(mut self, chars: f32) -> Self {
1520        self.max_width = Length::Ch(chars);
1521        self
1522    }
1523
1524    /// Preferred width in `ch` units (see [`Length::Ch`]).
1525    pub fn w_ch(mut self, chars: f32) -> Self {
1526        self.width = Length::Ch(chars);
1527        self
1528    }
1529
1530    /// Minimum width in `ch` units.
1531    pub fn min_w_ch(mut self, chars: f32) -> Self {
1532        self.min_width = Length::Ch(chars);
1533        self
1534    }
1535
1536    /// Maximum width in `ch` units (alias of [`Style::measure`] for symmetry).
1537    pub fn max_w_ch(mut self, chars: f32) -> Self {
1538        self.max_width = Length::Ch(chars);
1539        self
1540    }
1541
1542    /// Flex grow 1.
1543    pub fn grow(mut self) -> Self {
1544        self.flex_grow = 1.0;
1545        self
1546    }
1547
1548    /// Flex shrink 0.
1549    pub fn shrink0(mut self) -> Self {
1550        self.flex_shrink = 0.0;
1551        self
1552    }
1553
1554    // -- layout: alignment --
1555
1556    /// Align children to the cross-axis start.
1557    pub fn items_start(mut self) -> Self {
1558        self.align_items = AlignItems::Start;
1559        self
1560    }
1561
1562    /// Center children on the cross axis.
1563    pub fn items_center(mut self) -> Self {
1564        self.align_items = AlignItems::Center;
1565        self
1566    }
1567
1568    /// Align children to the cross-axis end.
1569    pub fn items_end(mut self) -> Self {
1570        self.align_items = AlignItems::End;
1571        self
1572    }
1573
1574    /// Align children on their first text baseline (rows only).
1575    pub fn items_baseline(mut self) -> Self {
1576        self.align_items = AlignItems::Baseline;
1577        self
1578    }
1579
1580    /// Override the parent's cross-axis alignment for this element alone,
1581    /// packing it toward the cross-axis start (so it hugs its content instead
1582    /// of stretching).
1583    pub fn self_start(mut self) -> Self {
1584        self.align_self = Some(AlignItems::Start);
1585        self
1586    }
1587
1588    /// Override the parent's cross-axis alignment for this element alone,
1589    /// centering it on the cross axis.
1590    pub fn self_center(mut self) -> Self {
1591        self.align_self = Some(AlignItems::Center);
1592        self
1593    }
1594
1595    /// Override the parent's cross-axis alignment for this element alone,
1596    /// packing it toward the cross-axis end.
1597    pub fn self_end(mut self) -> Self {
1598        self.align_self = Some(AlignItems::End);
1599        self
1600    }
1601
1602    /// Override the parent's cross-axis alignment for this element alone,
1603    /// stretching it to fill the cross axis.
1604    pub fn self_stretch(mut self) -> Self {
1605        self.align_self = Some(AlignItems::Stretch);
1606        self
1607    }
1608
1609    /// Pack children toward the main-axis start.
1610    pub fn justify_start(mut self) -> Self {
1611        self.justify_content = JustifyContent::Start;
1612        self
1613    }
1614
1615    /// Center children on the main axis.
1616    pub fn justify_center(mut self) -> Self {
1617        self.justify_content = JustifyContent::Center;
1618        self
1619    }
1620
1621    /// Pack children toward the main-axis end.
1622    pub fn justify_end(mut self) -> Self {
1623        self.justify_content = JustifyContent::End;
1624        self
1625    }
1626
1627    /// Distribute children with space between.
1628    pub fn justify_between(mut self) -> Self {
1629        self.justify_content = JustifyContent::SpaceBetween;
1630        self
1631    }
1632
1633    /// Allow flex children to wrap.
1634    pub fn wrap(mut self) -> Self {
1635        self.wrap = true;
1636        self
1637    }
1638
1639    // -- layout: position and overflow --
1640
1641    /// Position absolutely against the nearest relative ancestor.
1642    pub fn absolute(mut self) -> Self {
1643        self.position = Position::Absolute;
1644        self
1645    }
1646
1647    /// Offset from the top (positioned elements).
1648    pub fn top(mut self, v: f32) -> Self {
1649        self.inset.top = Some(v);
1650        self
1651    }
1652
1653    /// Offset from the right.
1654    pub fn right(mut self, v: f32) -> Self {
1655        self.inset.right = Some(v);
1656        self
1657    }
1658
1659    /// Offset from the bottom.
1660    pub fn bottom(mut self, v: f32) -> Self {
1661        self.inset.bottom = Some(v);
1662        self
1663    }
1664
1665    /// Offset from the left.
1666    pub fn left(mut self, v: f32) -> Self {
1667        self.inset.left = Some(v);
1668        self
1669    }
1670
1671    /// Clip children to the (rounded) bounds.
1672    pub fn overflow_hidden(mut self) -> Self {
1673        self.overflow_x = Overflow::Hidden;
1674        self.overflow_y = Overflow::Hidden;
1675        self.clip = true;
1676        self
1677    }
1678
1679    /// Vertical scrolling with clipped content (scroll state lands in M3).
1680    pub fn scroll_y(mut self) -> Self {
1681        self.overflow_y = Overflow::Scroll;
1682        self.clip = true;
1683        self
1684    }
1685
1686    /// Horizontal scrolling with clipped content.
1687    pub fn scroll_x(mut self) -> Self {
1688        self.overflow_x = Overflow::Scroll;
1689        self.clip = true;
1690        self
1691    }
1692
1693    /// Scrolling on both axes with clipped content.
1694    pub fn scroll_xy(mut self) -> Self {
1695        self.overflow_x = Overflow::Scroll;
1696        self.overflow_y = Overflow::Scroll;
1697        self.clip = true;
1698        self
1699    }
1700
1701    /// Sticks the element `offset` px below the scroll viewport's top edge once
1702    /// it would scroll past (CSS `position: sticky; top: offset`).
1703    pub fn sticky_top(mut self, offset: f32) -> Self {
1704        self.position = Position::Sticky;
1705        self.sticky_top = Some(offset);
1706        self
1707    }
1708
1709    /// Sticks the element `offset` px above the scroll viewport's bottom edge.
1710    pub fn sticky_bottom(mut self, offset: f32) -> Self {
1711        self.position = Position::Sticky;
1712        self.sticky_bottom = Some(offset);
1713        self
1714    }
1715
1716    /// Sticks the element `offset` px right of the scroll viewport's left edge.
1717    pub fn sticky_left(mut self, offset: f32) -> Self {
1718        self.position = Position::Sticky;
1719        self.sticky_left = Some(offset);
1720        self
1721    }
1722
1723    /// Sticks the element `offset` px left of the scroll viewport's right edge.
1724    pub fn sticky_right(mut self, offset: f32) -> Self {
1725        self.position = Position::Sticky;
1726        self.sticky_right = Some(offset);
1727        self
1728    }
1729
1730    // -- paint --
1731
1732    /// Background fill: a solid color or gradient.
1733    pub fn bg(mut self, paint: impl Into<Paint>) -> Self {
1734        self.fill = Some(paint.into());
1735        self
1736    }
1737
1738    /// Uniform border (a stroke on the element's edge).
1739    pub fn border(mut self, width: f32, color: Color) -> Self {
1740        self.border = Some(Border { width, color });
1741        self
1742    }
1743
1744    /// A border stroke on just the top edge (straight hairline, square corners).
1745    pub fn border_top(mut self, width: f32, color: Color) -> Self {
1746        self.side_borders.top = Some(Border { width, color });
1747        self
1748    }
1749
1750    /// A border stroke on just the right edge.
1751    pub fn border_right(mut self, width: f32, color: Color) -> Self {
1752        self.side_borders.right = Some(Border { width, color });
1753        self
1754    }
1755
1756    /// A border stroke on just the bottom edge.
1757    pub fn border_bottom(mut self, width: f32, color: Color) -> Self {
1758        self.side_borders.bottom = Some(Border { width, color });
1759        self
1760    }
1761
1762    /// A border stroke on just the left edge.
1763    pub fn border_left(mut self, width: f32, color: Color) -> Self {
1764        self.side_borders.left = Some(Border { width, color });
1765        self
1766    }
1767
1768    /// A crisp `width`-px ring *just outside* the box, hugging the corner
1769    /// radius — the "ring, not border" look (Geist). Rendered as a zero-blur
1770    /// spread shadow, so unlike [`border`](Self::border) (an edge stroke) it
1771    /// sits outside the element, never covers its content or children, and
1772    /// recolors with zero layout cost — ideal for selection/emphasis rings and
1773    /// sub-pixel hairlines. Composes with shadow tokens (the ring paints on top
1774    /// of any drop shadow). Stack multiple rings by calling it more than once.
1775    pub fn ring(mut self, width: f32, color: Color) -> Self {
1776        self.shadows.push(Shadow {
1777            dx: 0.0,
1778            dy: 0.0,
1779            blur: 0.0,
1780            spread: width,
1781            color,
1782        });
1783        self
1784    }
1785
1786    /// The same corner radius on all corners.
1787    pub fn rounded(mut self, r: f32) -> Self {
1788        self.corner_radius = CornerRadius::all(r);
1789        self
1790    }
1791
1792    /// Fully-rounded corners (pill / circle).
1793    pub fn rounded_full(mut self) -> Self {
1794        self.corner_radius = CornerRadius::all(crate::tokens::R_FULL);
1795        self
1796    }
1797
1798    /// Rounds the top two corners, leaving the others unchanged.
1799    pub fn rounded_t(mut self, r: f32) -> Self {
1800        self.corner_radius.tl = r;
1801        self.corner_radius.tr = r;
1802        self
1803    }
1804
1805    /// Rounds the bottom two corners, leaving the others unchanged.
1806    pub fn rounded_b(mut self, r: f32) -> Self {
1807        self.corner_radius.br = r;
1808        self.corner_radius.bl = r;
1809        self
1810    }
1811
1812    /// Rounds the left two corners, leaving the others unchanged.
1813    pub fn rounded_l(mut self, r: f32) -> Self {
1814        self.corner_radius.tl = r;
1815        self.corner_radius.bl = r;
1816        self
1817    }
1818
1819    /// Rounds the right two corners, leaving the others unchanged.
1820    pub fn rounded_r(mut self, r: f32) -> Self {
1821        self.corner_radius.tr = r;
1822        self.corner_radius.br = r;
1823        self
1824    }
1825
1826    /// Sets each corner radius independently: top-left, top-right,
1827    /// bottom-right, bottom-left (clockwise from the top-left).
1828    pub fn corners(mut self, tl: f32, tr: f32, br: f32, bl: f32) -> Self {
1829        self.corner_radius = CornerRadius { tl, tr, br, bl };
1830        self
1831    }
1832
1833    /// Overrides continuous-curvature corner smoothing for this element (see
1834    /// [`Style::corner_smoothing`]), opting out of the theme default. `0.0`
1835    /// keeps exact circular arcs; higher values blend toward a fuller squircle.
1836    /// Clamped to `0.0..=1.0`.
1837    pub fn corner_smoothing(mut self, s: f32) -> Self {
1838        self.corner_smoothing = Some(s.clamp(0.0, 1.0));
1839        self
1840    }
1841
1842    /// A shadow elevation token, resolved against the theme at render time.
1843    pub fn shadow(mut self, token: crate::tokens::ShadowToken) -> Self {
1844        self.shadow_token = Some(token);
1845        self
1846    }
1847
1848    /// A 1px inset highlight along the top inner edge — the subtle top sheen
1849    /// that makes a solid control read as raised. Usually a low-alpha white.
1850    pub fn highlight_top(mut self, color: Color) -> Self {
1851        self.highlight_top = Some(color);
1852        self
1853    }
1854
1855    /// A luminous specular edge rim wrapping the rounded silhouette — the iconic
1856    /// Liquid Glass perimeter light. See [`SpecularEdge`] (and
1857    /// [`SpecularEdge::glass`] for the stock recipe).
1858    pub fn specular_edge(mut self, edge: SpecularEdge) -> Self {
1859        self.specular_edge = Some(edge);
1860        self
1861    }
1862
1863    /// A directional body sheen across the face — the raking light that makes a
1864    /// translucent pane read as lit glass. See [`Sheen`] (and [`Sheen::glass`]).
1865    pub fn sheen(mut self, sheen: Sheen) -> Self {
1866        self.sheen = Some(sheen);
1867        self
1868    }
1869
1870    /// Backdrop-adaptive vibrancy — shift the glass tint's lightness by the mean
1871    /// luminance of the frosted backdrop behind it (headless-only). See
1872    /// [`AdaptiveTint`] (and [`AdaptiveTint::glass`] for the stock recipe).
1873    pub fn adaptive_tint(mut self, adaptive: AdaptiveTint) -> Self {
1874        self.adaptive_tint = Some(adaptive);
1875        self
1876    }
1877
1878    /// Subtree opacity 0.0..=1.0.
1879    pub fn opacity(mut self, v: f32) -> Self {
1880        self.opacity = v;
1881        self
1882    }
1883
1884    /// Paint-time uniform scale about the element center (1.0 = none). Used
1885    /// for press feedback; never disturbs layout.
1886    pub fn scale(mut self, v: f32) -> Self {
1887        self.scale = v;
1888        self
1889    }
1890
1891    /// Paint-time translation in logical px (never affects layout). Animatable.
1892    pub fn translate(mut self, x: f32, y: f32) -> Self {
1893        self.translate = (x, y);
1894        self
1895    }
1896
1897    /// Paint-time rotation in degrees about the element center. Animatable.
1898    pub fn rotate(mut self, degrees: f32) -> Self {
1899        self.rotate = degrees;
1900        self
1901    }
1902
1903    /// Paint-time skew in degrees `(x, y)` about the element center. Animatable.
1904    pub fn skew(mut self, x_degrees: f32, y_degrees: f32) -> Self {
1905        self.skew = (x_degrees, y_degrees);
1906        self
1907    }
1908
1909    /// Non-uniform paint-time scale `(x, y)`, composed with the uniform
1910    /// [`scale`](Self::scale). Never disturbs layout; animatable.
1911    pub fn scale_xy(mut self, x: f32, y: f32) -> Self {
1912        self.scale_xy = (x, y);
1913        self
1914    }
1915
1916    /// The pivot for paint-time transforms as a fraction of the element's
1917    /// rect (CSS `transform-origin`): `(0, 0)` top-left, `(0.5, 0.5)` center
1918    /// (the default). See [`Style::transform_origin`].
1919    pub fn transform_origin(mut self, fx: f32, fy: f32) -> Self {
1920        self.transform_origin = (fx, fy);
1921        self
1922    }
1923
1924    /// Draw progress for path elements (0 = nothing, 1 = full path).
1925    pub fn trim(mut self, v: f32) -> Self {
1926        self.path_trim = v.clamp(0.0, 1.0);
1927        self
1928    }
1929
1930    /// Frosted-glass backdrop blur: the content behind this (translucent)
1931    /// element is blurred by `radius` logical px before the fill composites
1932    /// over it. A non-positive radius clears it (no backdrop pass). See
1933    /// [`Style::backdrop_blur`].
1934    pub fn backdrop_blur(mut self, radius: f32) -> Self {
1935        self.backdrop_blur = (radius > 0.0).then_some(radius);
1936        self
1937    }
1938
1939    /// A foreground filter on this element's own content. See
1940    /// [`Style::element_filter`].
1941    pub fn element_filter(mut self, filter: ElementFilter) -> Self {
1942        self.element_filter = Some(filter);
1943        self
1944    }
1945
1946    // -- text --
1947
1948    /// Text size on the typographic scale.
1949    /// Free-form text size in logical px (overrides the scale token).
1950    pub fn size_px(mut self, px: f32) -> Self {
1951        self.text.size_px = Some(px);
1952        self
1953    }
1954
1955    /// Letter spacing in em (tracked-out editorial eyebrows etc.).
1956    pub fn tracking(mut self, em: f32) -> Self {
1957        self.text.letter_spacing = Some(em);
1958        self
1959    }
1960
1961    /// Line height as a multiple of the font size.
1962    pub fn leading(mut self, multiple: f32) -> Self {
1963        self.text.line_height = Some(multiple);
1964        self
1965    }
1966
1967    /// Tabular (fixed-width) numerals (`tnum`) — digits align in columns. For
1968    /// tables, timers, charts, and any numeric data that updates in place.
1969    pub fn tabular(mut self) -> Self {
1970        self.text.features.spacing = NumericSpacing::Tabular;
1971        self
1972    }
1973
1974    /// Proportional numerals — individually spaced for prose (`pnum`).
1975    pub fn proportional_nums(mut self) -> Self {
1976        self.text.features.spacing = NumericSpacing::Proportional;
1977        self
1978    }
1979
1980    /// Old-style / text figures (`onum`): ascending and descending digits
1981    /// that sit naturally in serif prose.
1982    pub fn oldstyle_nums(mut self) -> Self {
1983        self.text.features.figures = FigureStyle::OldStyle;
1984        self
1985    }
1986
1987    /// Lining figures (`lnum`): uniform cap-height digits for data and UI.
1988    pub fn lining_nums(mut self) -> Self {
1989        self.text.features.figures = FigureStyle::Lining;
1990        self
1991    }
1992
1993    /// Render lowercase letters as small capitals (`smcp`).
1994    pub fn small_caps(mut self) -> Self {
1995        self.text.features.small_caps = true;
1996        self
1997    }
1998
1999    /// Enable or disable standard ligatures (`liga`); most fonts default on.
2000    pub fn ligatures(mut self, on: bool) -> Self {
2001        self.text.features.ligatures = Some(on);
2002        self
2003    }
2004
2005    /// Common fractions (`frac`): `1/2` becomes a single fraction glyph.
2006    pub fn fractions(mut self) -> Self {
2007        self.text.features.fractions = true;
2008        self
2009    }
2010
2011    /// Font family role (Sans, Mono, or a registered Display/Serif face).
2012    pub fn family(mut self, family: crate::tokens::FamilyRole) -> Self {
2013        self.text.family = family;
2014        self
2015    }
2016
2017    pub fn size(mut self, size: crate::tokens::TextSize) -> Self {
2018        self.text.size = size;
2019        self
2020    }
2021
2022    /// Font weight.
2023    pub fn weight(mut self, weight: crate::tokens::Weight) -> Self {
2024        self.text.weight = weight;
2025        self
2026    }
2027
2028    /// Text color (defaults to the theme `text` role).
2029    pub fn color(mut self, color: Color) -> Self {
2030        self.text.color = Some(color);
2031        self
2032    }
2033
2034    /// Use the mono family role.
2035    pub fn mono(mut self) -> Self {
2036        self.text.family = crate::tokens::FamilyRole::Mono;
2037        self
2038    }
2039
2040    /// Truncate to one line with an ellipsis.
2041    pub fn truncate(mut self) -> Self {
2042        self.text.max_lines = Some(1);
2043        self
2044    }
2045
2046    /// Horizontal text alignment.
2047    pub fn text_align(mut self, align: TextAlign) -> Self {
2048        self.text.align = align;
2049        self
2050    }
2051
2052    /// Balance line lengths for this text ([`TextWrap::Balance`]) — even lines
2053    /// instead of a full-then-short ragged break. For headings and titles.
2054    pub fn balance(mut self) -> Self {
2055        self.text.wrap = TextWrap::Balance;
2056        self
2057    }
2058
2059    /// Avoid a stranded last word ([`TextWrap::Pretty`]) — best-effort for
2060    /// paragraphs; never adds a line and never makes the break worse.
2061    pub fn pretty(mut self) -> Self {
2062        self.text.wrap = TextWrap::Pretty;
2063        self
2064    }
2065
2066    /// Sets the line-breaking mode explicitly ([`TextWrap`]).
2067    pub fn text_wrap(mut self, wrap: TextWrap) -> Self {
2068        self.text.wrap = wrap;
2069        self
2070    }
2071
2072    /// Sets optical sizing explicitly ([`OpticalSizing`]) — how the `opsz`
2073    /// variation axis of a variable font is driven.
2074    pub fn optical(mut self, optical: OpticalSizing) -> Self {
2075        self.text.optical = optical;
2076        self
2077    }
2078
2079    /// Tracks the `opsz` axis to the rendered size ([`OpticalSizing::Auto`],
2080    /// CSS `font-optical-sizing: auto`) — small text gets the text-optical
2081    /// master, large sizes the display master. A no-op on static faces.
2082    pub fn optical_auto(mut self) -> Self {
2083        self.text.optical = OpticalSizing::Auto;
2084        self
2085    }
2086}
2087
2088impl Style {
2089    // -- grid --
2090
2091    /// Grid template columns (switches display to grid).
2092    pub fn grid_cols<T: Into<GridTemplate>>(mut self, tracks: impl IntoIterator<Item = T>) -> Self {
2093        self.display = Display::Grid;
2094        self.grid_template_columns = tracks.into_iter().map(Into::into).collect();
2095        self
2096    }
2097
2098    /// Grid template rows (switches display to grid). Accepts plain [`Track`]s or
2099    /// full [`GridTemplate`] entries (e.g. `repeat(...)`).
2100    pub fn grid_rows<T: Into<GridTemplate>>(mut self, tracks: impl IntoIterator<Item = T>) -> Self {
2101        self.display = Display::Grid;
2102        self.grid_template_rows = tracks.into_iter().map(Into::into).collect();
2103        self
2104    }
2105
2106    /// Places this element at a 1-based grid column, spanning `span` tracks.
2107    pub fn grid_col(mut self, start: i16, span: u16) -> Self {
2108        self.grid_column = GridPlace {
2109            start: Some(start),
2110            span: (span > 1).then_some(span),
2111        };
2112        self
2113    }
2114
2115    /// Places this element at a 1-based grid row, spanning `span` tracks.
2116    pub fn grid_row(mut self, start: i16, span: u16) -> Self {
2117        self.grid_row = GridPlace {
2118            start: Some(start),
2119            span: (span > 1).then_some(span),
2120        };
2121        self
2122    }
2123
2124    /// `grid-template-areas`: each row is a string of whitespace-separated area
2125    /// names, with `.` for an empty cell. Switches display to grid. Place
2126    /// children with [`Style::grid_area`]. Without explicit
2127    /// [`grid_cols`](Style::grid_cols)/[`grid_rows`](Style::grid_rows), an
2128    /// implicit grid of `auto` tracks matching the area shape is created.
2129    pub fn grid_template_areas<R: AsRef<str>>(mut self, rows: impl IntoIterator<Item = R>) -> Self {
2130        self.display = Display::Grid;
2131        self.grid_template_areas = rows
2132            .into_iter()
2133            .map(|row| {
2134                row.as_ref()
2135                    .split_whitespace()
2136                    .map(|cell| (cell != ".").then(|| cell.to_string()))
2137                    .collect()
2138            })
2139            .collect();
2140        self
2141    }
2142
2143    /// Places this element in a named grid area (CSS `grid-area: name`),
2144    /// resolved against the parent's [`grid_template_areas`](Style::grid_template_areas).
2145    pub fn grid_area(mut self, name: impl Into<String>) -> Self {
2146        self.grid_area = Some(name.into());
2147        self
2148    }
2149
2150    /// Places this element's columns between two named grid lines
2151    /// (CSS `grid-column: start / end`).
2152    pub fn grid_col_lines(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
2153        self.grid_column_lines = GridLines {
2154            start: Some(start.into()),
2155            end: Some(end.into()),
2156        };
2157        self
2158    }
2159
2160    /// Places this element's rows between two named grid lines
2161    /// (CSS `grid-row: start / end`).
2162    pub fn grid_row_lines(mut self, start: impl Into<String>, end: impl Into<String>) -> Self {
2163        self.grid_row_lines = GridLines {
2164            start: Some(start.into()),
2165            end: Some(end.into()),
2166        };
2167        self
2168    }
2169
2170    /// Names the column grid lines positionally: the i-th name labels the
2171    /// (i+1)-th line. Reference them from [`grid_col_lines`](Style::grid_col_lines).
2172    pub fn grid_col_names<S: Into<String>>(mut self, names: impl IntoIterator<Item = S>) -> Self {
2173        self.grid_col_line_names = names.into_iter().map(|n| vec![n.into()]).collect();
2174        self
2175    }
2176
2177    /// Names the row grid lines positionally: the i-th name labels the (i+1)-th
2178    /// line. Reference them from [`grid_row_lines`](Style::grid_row_lines).
2179    pub fn grid_row_names<S: Into<String>>(mut self, names: impl IntoIterator<Item = S>) -> Self {
2180        self.grid_row_line_names = names.into_iter().map(|n| vec![n.into()]).collect();
2181        self
2182    }
2183}
2184
2185impl Style {
2186    /// True if any size constraint is expressed in `ch` (needs font metrics
2187    /// to resolve before taffy runs).
2188    pub(crate) fn has_ch(&self) -> bool {
2189        matches!(self.width, Length::Ch(_))
2190            || matches!(self.min_width, Length::Ch(_))
2191            || matches!(self.max_width, Length::Ch(_))
2192            || matches!(self.height, Length::Ch(_))
2193            || matches!(self.min_height, Length::Ch(_))
2194            || matches!(self.max_height, Length::Ch(_))
2195            || matches!(self.flex_basis, Length::Ch(_))
2196    }
2197
2198    /// Replaces every `Length::Ch(n)` size constraint with `Length::Px(n *
2199    /// ch_px)`, using the advance of `'0'` in this element's resolved text
2200    /// style. Called during `build` once font metrics are available.
2201    pub(crate) fn resolve_ch(&mut self, ch_px: f32) {
2202        self.width = self.width.resolved(ch_px);
2203        self.min_width = self.min_width.resolved(ch_px);
2204        self.max_width = self.max_width.resolved(ch_px);
2205        self.height = self.height.resolved(ch_px);
2206        self.min_height = self.min_height.resolved(ch_px);
2207        self.max_height = self.max_height.resolved(ch_px);
2208        self.flex_basis = self.flex_basis.resolved(ch_px);
2209    }
2210}
2211
2212#[cfg(test)]
2213mod corner_smoothing_tests {
2214    use super::*;
2215
2216    #[test]
2217    fn corner_smoothing_defaults_none_and_clamps() {
2218        // None ⇒ inherit the theme; the builder sets an explicit, clamped Some.
2219        assert_eq!(Style::default().corner_smoothing, None);
2220        assert_eq!(
2221            Style::default().corner_smoothing(0.6).corner_smoothing,
2222            Some(0.6)
2223        );
2224        assert_eq!(
2225            Style::default().corner_smoothing(5.0).corner_smoothing,
2226            Some(1.0)
2227        );
2228        assert_eq!(
2229            Style::default().corner_smoothing(-1.0).corner_smoothing,
2230            Some(0.0)
2231        );
2232    }
2233}
2234
2235#[cfg(test)]
2236mod feature_tests {
2237    use super::*;
2238
2239    /// The feature string of a style built with the given builders.
2240    fn fs(style: Style) -> Option<String> {
2241        style.text.features.feature_string()
2242    }
2243
2244    #[test]
2245    fn default_features_emit_nothing() {
2246        assert_eq!(FontFeatures::default().feature_string(), None);
2247    }
2248
2249    #[test]
2250    fn tabular_unchanged() {
2251        // Locks the exact prior `.tabular()` behavior (`"tnum" 1`), so every
2252        // existing golden that uses it stays byte-identical.
2253        assert_eq!(
2254            fs(Style::default().tabular()),
2255            Some("\"tnum\" 1".to_owned())
2256        );
2257    }
2258
2259    #[test]
2260    fn oldstyle_and_smcp() {
2261        let s = fs(Style::default().oldstyle_nums().small_caps()).unwrap();
2262        assert!(s.contains("\"onum\" 1"), "{s}");
2263        assert!(s.contains("\"smcp\" 1"), "{s}");
2264        assert!(!s.contains("\"lnum\""), "{s}");
2265        assert!(!s.contains("\"tnum\""), "{s}");
2266        assert!(!s.contains("\"pnum\""), "{s}");
2267    }
2268
2269    #[test]
2270    fn tnum_onum_mutually_consistent() {
2271        // Figure shape and figure spacing are orthogonal axes; both apply.
2272        let s = fs(Style::default().tabular().oldstyle_nums()).unwrap();
2273        assert!(s.contains("\"tnum\" 1"), "{s}");
2274        assert!(s.contains("\"onum\" 1"), "{s}");
2275    }
2276
2277    #[test]
2278    fn ligatures_off_and_on() {
2279        assert!(
2280            fs(Style::default().ligatures(false))
2281                .unwrap()
2282                .contains("\"liga\" 0")
2283        );
2284        assert!(
2285            fs(Style::default().ligatures(true))
2286                .unwrap()
2287                .contains("\"liga\" 1")
2288        );
2289    }
2290
2291    #[test]
2292    fn fractions_and_proportional() {
2293        assert!(
2294            fs(Style::default().fractions())
2295                .unwrap()
2296                .contains("\"frac\" 1")
2297        );
2298        assert!(
2299            fs(Style::default().proportional_nums())
2300                .unwrap()
2301                .contains("\"pnum\" 1")
2302        );
2303    }
2304
2305    #[test]
2306    fn figure_axis_is_exclusive() {
2307        // The figure axis is one slot: the last builder wins.
2308        let style = Style::default().oldstyle_nums().lining_nums();
2309        assert_eq!(style.text.features.figures, FigureStyle::Lining);
2310        let s = fs(style).unwrap();
2311        assert!(s.contains("\"lnum\""), "{s}");
2312        assert!(!s.contains("\"onum\""), "{s}");
2313    }
2314}
2315
2316#[cfg(test)]
2317mod gradient_tests {
2318    use super::*;
2319    use crate::oklch_of;
2320    use crate::theme::Theme;
2321    use crate::tokens::GRADIENT_STEPS;
2322
2323    #[test]
2324    fn midpoint_keeps_chroma_no_gray_deadzone() {
2325        // A wide-hue transition (accent ~262° → warning ~80°): the naive sRGB
2326        // average of the two anchors collapses toward gray, but the OKLCH
2327        // midpoint stays vivid.
2328        let theme = Theme::light();
2329        let a = theme.accent;
2330        let b = theme.warning.solid;
2331        let stops = oklch_stops(&[(0.0, a), (1.0, b)], GRADIENT_STEPS);
2332        let mid = stops
2333            .iter()
2334            .min_by(|x, y| (x.offset - 0.5).abs().total_cmp(&(y.offset - 0.5).abs()))
2335            .unwrap();
2336        let ca = a.components;
2337        let cb = b.components;
2338        let srgb_mid = Color::new([
2339            (ca[0] + cb[0]) / 2.0,
2340            (ca[1] + cb[1]) / 2.0,
2341            (ca[2] + cb[2]) / 2.0,
2342            (ca[3] + cb[3]) / 2.0,
2343        ]);
2344        let c_oklch = oklch_of(mid.color)[1];
2345        let c_srgb = oklch_of(srgb_mid)[1];
2346        assert!(
2347            c_oklch > 1.5 * c_srgb,
2348            "OKLCH mid chroma {c_oklch} should far exceed sRGB mid chroma {c_srgb}"
2349        );
2350    }
2351
2352    #[test]
2353    fn lightness_is_monotonic_across_stops() {
2354        // A9-style same-hue ramp (A7 L 0.725 → A10 L 0.545), fully in gamut:
2355        // lightness must never reverse (no dark bump mid-ramp). The epsilon
2356        // absorbs per-channel gamut-clamp noise.
2357        let theme = Theme::light();
2358        let a = theme.accents.step(7);
2359        let b = theme.accents.step(10);
2360        let stops = oklch_stops(&[(0.0, a), (1.0, b)], GRADIENT_STEPS);
2361        for w in stops.windows(2) {
2362            let l0 = oklch_of(w[0].color)[0];
2363            let l1 = oklch_of(w[1].color)[0];
2364            assert!(l1 <= l0 + 1e-3, "lightness rose mid-ramp: {l0} -> {l1}");
2365        }
2366    }
2367
2368    #[test]
2369    fn offsets_sorted_and_span_anchors() {
2370        let theme = Theme::light();
2371        let a = theme.accent;
2372        let b = theme.warning.solid;
2373        let stops = oklch_stops(&[(0.0, a), (1.0, b)], 16);
2374        assert_eq!(stops.len(), 17);
2375        assert_eq!(stops.first().unwrap().offset, 0.0);
2376        assert_eq!(stops.last().unwrap().offset, 1.0);
2377        for w in stops.windows(2) {
2378            assert!(w[1].offset > w[0].offset, "offsets must strictly increase");
2379        }
2380        // Unsorted anchors must produce the identical result.
2381        let unsorted = oklch_stops(&[(1.0, b), (0.0, a)], 16);
2382        assert_eq!(unsorted, stops);
2383    }
2384
2385    #[test]
2386    fn endpoints_are_exact() {
2387        // Pre-expansion must never shift the anchors themselves.
2388        let theme = Theme::light();
2389        let a = theme.accent;
2390        let b = theme.warning.solid;
2391        let stops = oklch_stops(&[(0.0, a), (1.0, b)], 16);
2392        assert_eq!(stops.first().unwrap().color.to_rgba8(), a.to_rgba8());
2393        assert_eq!(stops.last().unwrap().color.to_rgba8(), b.to_rgba8());
2394    }
2395
2396    #[test]
2397    fn linear_gradient_even_spacing() {
2398        let theme = Theme::light();
2399        let (a, b, c) = (theme.accent, theme.warning.solid, theme.success.solid);
2400        let paint = linear_gradient(90.0, [a, b, c]);
2401        let Paint::LinearGradient { angle_deg, stops } = paint else {
2402            panic!("linear_gradient must build a LinearGradient");
2403        };
2404        assert_eq!(angle_deg, 90.0);
2405        // Three colors land evenly at 0.0 / 0.5 / 1.0, each carrying its anchor.
2406        for (off, color) in [(0.0, a), (0.5, b), (1.0, c)] {
2407            let found = stops
2408                .iter()
2409                .find(|s| (s.offset - off).abs() < 1e-4)
2410                .unwrap_or_else(|| panic!("no stop at offset {off}"));
2411            assert_eq!(
2412                found.color.to_rgba8(),
2413                color.to_rgba8(),
2414                "anchor color at offset {off}"
2415            );
2416        }
2417    }
2418
2419    #[test]
2420    fn degenerate_inputs() {
2421        let theme = Theme::light();
2422        let a = theme.accent;
2423        let b = theme.warning.solid;
2424        // Empty anchors → empty.
2425        assert!(oklch_stops(&[], 16).is_empty());
2426        // Single anchor → one stop at its offset.
2427        let single = oklch_stops(&[(0.3, a)], 16);
2428        assert_eq!(single.len(), 1);
2429        assert_eq!(single[0].offset, 0.3);
2430        assert_eq!(single[0].color.to_rgba8(), a.to_rgba8());
2431        // steps == 0 → exactly the two endpoints, no interpolation.
2432        let zero_steps = oklch_stops(&[(0.0, a), (1.0, b)], 0);
2433        assert_eq!(zero_steps.len(), 2);
2434        assert_eq!(zero_steps[0].color.to_rgba8(), a.to_rgba8());
2435        assert_eq!(zero_steps[1].color.to_rgba8(), b.to_rgba8());
2436    }
2437
2438    #[test]
2439    fn optical_sizing_resolves_opsz_per_mode() {
2440        // Default emits no axis at any size; Auto tracks the rendered px;
2441        // Fixed pins a value (clamped non-negative) regardless of size.
2442        assert_eq!(OpticalSizing::Default.opsz_at(16.0), None);
2443        assert_eq!(OpticalSizing::Default.opsz_at(48.0), None);
2444        assert_eq!(OpticalSizing::Auto.opsz_at(16.0), Some(16.0));
2445        assert_eq!(OpticalSizing::Auto.opsz_at(48.0), Some(48.0));
2446        assert_eq!(OpticalSizing::Fixed(72.0).opsz_at(16.0), Some(72.0));
2447        assert_eq!(OpticalSizing::Fixed(72.0).opsz_at(48.0), Some(72.0));
2448        // Negatives clamp to 0 (a valid axis floor, never a negative coord).
2449        assert_eq!(OpticalSizing::Fixed(-5.0).opsz_at(16.0), Some(0.0));
2450        // Unresolved `Inherit` falls back to `Auto` (the stock theme value).
2451        assert_eq!(OpticalSizing::Inherit.opsz_at(16.0), Some(16.0));
2452        // The default of the typed value is `Inherit` (follows the theme).
2453        assert_eq!(OpticalSizing::default(), OpticalSizing::Inherit);
2454    }
2455
2456    #[test]
2457    fn optical_builders_set_the_axis() {
2458        // The ergonomic builders flow into the text style group.
2459        assert_eq!(Style::default().text.optical, OpticalSizing::Inherit);
2460        assert_eq!(
2461            Style::default().optical_auto().text.optical,
2462            OpticalSizing::Auto
2463        );
2464        assert_eq!(
2465            Style::default()
2466                .optical(OpticalSizing::Fixed(60.0))
2467                .text
2468                .optical,
2469            OpticalSizing::Fixed(60.0)
2470        );
2471    }
2472}