Skip to main content

dioxus_textfx_core/
lib.rs

1//! native-port: supports css-filter
2
3use serde::{Deserialize, Serialize};
4use std::collections::BTreeMap;
5use std::fmt;
6use std::ops::RangeInclusive;
7use std::sync::OnceLock;
8use std::time::Duration;
9
10mod integration;
11pub use integration::*;
12
13pub const TEXTFX_PACKAGE_NAME: &str = "dioxus-textfx";
14pub const TEXTFX_PACKAGE_VERSION: &str = env!("CARGO_PKG_VERSION");
15pub const DEFAULT_TEXTFX_RUNTIME_PATH: &str = "/assets/dioxus-textfx.js?v=35";
16pub const DEFAULT_TEXTFX_DURATION_MS: u32 = 640;
17pub const DEFAULT_TEXTFX_STAGGER_MS: u32 = 28;
18pub const DEFAULT_TEXTFX_SPEED_MS: u32 = 32;
19pub const DEFAULT_TEXTFX_CHARSET: &str =
20    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
21const BLUR_REVEAL_ATTR: &str = concat!("blur", "-reveal");
22
23pub type TextCfg = TextFxConfig;
24pub type TextEffect = TextFxEffect;
25pub type TextEase = TextFxEasing;
26pub type TextProfile = TextFxProfile;
27
28pub fn textfx(id: impl Into<String>, text: impl Into<String>) -> TextFxConfig {
29    TextFxConfig::new(id, text)
30}
31
32pub fn text_fx(id: impl Into<String>, text: impl Into<String>) -> TextFxConfig {
33    TextFxConfig::new(id, text)
34}
35
36pub fn fx(
37    id: impl Into<String>,
38    text: impl Into<String>,
39    script: impl Into<String>,
40) -> Result<TextFxConfig, TextFxParseError> {
41    TextFxConfig::from_fx(id, text, script)
42}
43
44pub fn timing() -> TextFxTiming {
45    TextFxTiming::default()
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
49#[serde(rename_all = "kebab-case")]
50#[derive(Default)]
51pub enum TextFxEffect {
52    Fade,
53    Slide,
54    #[default]
55    BlurReveal,
56    Scale,
57    Typewriter,
58    Scramble,
59    Stagger,
60    CountUp,
61    Wave,
62    Flip,
63    MaskReveal,
64    Glitch,
65    HighlightSweep,
66    GradientShift,
67    KerningExpand,
68    NumberTicker,
69    LiveContrast,
70}
71
72impl TextFxEffect {
73    pub const ALL: [Self; 17] = [
74        Self::Fade,
75        Self::Slide,
76        Self::BlurReveal,
77        Self::Scale,
78        Self::Typewriter,
79        Self::Scramble,
80        Self::Stagger,
81        Self::CountUp,
82        Self::Wave,
83        Self::Flip,
84        Self::MaskReveal,
85        Self::Glitch,
86        Self::HighlightSweep,
87        Self::GradientShift,
88        Self::KerningExpand,
89        Self::NumberTicker,
90        Self::LiveContrast,
91    ];
92
93    pub fn as_attr(self) -> &'static str {
94        match self {
95            Self::Fade => "fade",
96            Self::Slide => "slide",
97            Self::BlurReveal => BLUR_REVEAL_ATTR,
98            Self::Scale => "scale",
99            Self::Typewriter => "typewriter",
100            Self::Scramble => "scramble",
101            Self::Stagger => "stagger",
102            Self::CountUp => "count-up",
103            Self::Wave => "wave",
104            Self::Flip => "flip",
105            Self::MaskReveal => "mask-reveal",
106            Self::Glitch => "glitch",
107            Self::HighlightSweep => "highlight-sweep",
108            Self::GradientShift => "gradient-shift",
109            Self::KerningExpand => "kerning-expand",
110            Self::NumberTicker => "number-ticker",
111            Self::LiveContrast => "live-contrast",
112        }
113    }
114
115    pub fn from_attr(value: &str) -> Option<Self> {
116        Self::ALL
117            .into_iter()
118            .find(|effect| effect.as_attr() == value)
119    }
120
121    pub fn compact_id(self) -> &'static str {
122        match self {
123            Self::Fade => "f",
124            Self::Slide => "sl",
125            Self::BlurReveal => "br",
126            Self::Scale => "sc",
127            Self::Typewriter => "tw",
128            Self::Scramble => "sr",
129            Self::Stagger => "st",
130            Self::CountUp => "cu",
131            Self::Wave => "wv",
132            Self::Flip => "fl",
133            Self::MaskReveal => "mr",
134            Self::Glitch => "gl",
135            Self::HighlightSweep => "hs",
136            Self::GradientShift => "gs",
137            Self::KerningExpand => "ke",
138            Self::NumberTicker => "nt",
139            Self::LiveContrast => "lc",
140        }
141    }
142
143    pub fn label(self) -> &'static str {
144        match self {
145            Self::Fade => "Fade",
146            Self::Slide => "Slide",
147            Self::BlurReveal => "Blur Reveal",
148            Self::Scale => "Scale",
149            Self::Typewriter => "Typewriter",
150            Self::Scramble => "Scramble",
151            Self::Stagger => "Stagger",
152            Self::CountUp => "Count Up",
153            Self::Wave => "Wave",
154            Self::Flip => "Flip",
155            Self::MaskReveal => "Mask Reveal",
156            Self::Glitch => "Glitch",
157            Self::HighlightSweep => "Highlight Sweep",
158            Self::GradientShift => "Gradient Shift",
159            Self::KerningExpand => "Kerning Expand",
160            Self::NumberTicker => "Number Ticker",
161            Self::LiveContrast => "Live Contrast",
162        }
163    }
164
165    pub fn needs_split(self) -> bool {
166        matches!(
167            self,
168            Self::Stagger | Self::Wave | Self::Flip | Self::Glitch | Self::KerningExpand
169        )
170    }
171}
172
173#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
174#[serde(rename_all = "kebab-case")]
175#[derive(Default)]
176pub enum TextFxLiveContrast {
177    #[default]
178    Difference,
179    Exclusion,
180    Plus,
181}
182
183impl TextFxLiveContrast {
184    pub fn as_attr(self) -> &'static str {
185        match self {
186            Self::Difference => "difference",
187            Self::Exclusion => "exclusion",
188            Self::Plus => "plus",
189        }
190    }
191}
192
193#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
194#[serde(rename_all = "kebab-case")]
195#[derive(Default)]
196pub enum TextFxEasing {
197    Linear,
198    EaseIn,
199    #[default]
200    EaseOut,
201    EaseInOut,
202    Spring,
203    CubicBezier(f32, f32, f32, f32),
204}
205
206impl TextFxEasing {
207    pub fn css_value(self) -> String {
208        match self {
209            Self::Linear => "linear".to_string(),
210            Self::EaseIn => "cubic-bezier(.42,0,1,1)".to_string(),
211            Self::EaseOut => "cubic-bezier(0,0,.2,1)".to_string(),
212            Self::EaseInOut => "cubic-bezier(.42,0,.58,1)".to_string(),
213            Self::Spring => "cubic-bezier(.18,.89,.32,1.28)".to_string(),
214            Self::CubicBezier(a, b, c, d) => format!("cubic-bezier({a},{b},{c},{d})"),
215        }
216    }
217
218    #[cfg(feature = "viewtx-interop")]
219    pub fn from_viewtx_easing(easing: &str) -> Self {
220        match easing.trim().to_ascii_lowercase().as_str() {
221            "linear" => Self::Linear,
222            "ease-in" => Self::EaseIn,
223            "ease-out" => Self::EaseOut,
224            "ease" | "ease-in-out" => Self::EaseInOut,
225            "spring" => Self::Spring,
226            _ => dioxus_viewtx_core::parse_viewtx_cubic_bezier(easing)
227                .map(|(a, b, c, d)| Self::CubicBezier(a, b, c, d))
228                .unwrap_or(Self::EaseOut),
229        }
230    }
231}
232
233#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
234#[serde(tag = "kind", rename_all = "kebab-case")]
235#[derive(Default)]
236pub enum TextFxTrigger {
237    Load,
238    #[default]
239    Visible,
240    Interaction,
241    Manual,
242    Hover,
243    Click,
244    Focus,
245    Blur,
246    WordHover,
247    WordClick,
248    SelectorClick {
249        selector: String,
250    },
251    SelectorHover {
252        selector: String,
253    },
254    Event {
255        name: String,
256    },
257    Cascade {
258        name: String,
259    },
260}
261
262impl TextFxTrigger {
263    pub fn resume_attr(&self) -> Option<String> {
264        match self {
265            Self::Load => Some(r#"data-dxr-on-load="textfx.run""#.to_string()),
266            Self::Hover | Self::WordHover => {
267                Some(r#"data-dxr-on-pointerover="textfx.run""#.to_string())
268            }
269            Self::Click | Self::Interaction | Self::WordClick => {
270                Some(r#"data-dxr-on-click="textfx.run""#.to_string())
271            }
272            Self::Manual => None,
273            _ => Some(r#"data-dxr-on-visible="textfx.run""#.to_string()),
274        }
275    }
276}
277
278#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
279#[serde(rename_all = "kebab-case")]
280#[derive(Default)]
281pub enum TextFxLoop {
282    #[default]
283    Once,
284    Infinite,
285    Count(u16),
286}
287
288#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct TextFxPlayback {
291    pub loop_mode: TextFxLoop,
292    pub reverse: bool,
293    pub alternate: bool,
294    pub yoyo: bool,
295    pub repeat_delay_ms: u32,
296}
297
298impl Default for TextFxPlayback {
299    fn default() -> Self {
300        Self {
301            loop_mode: TextFxLoop::Once,
302            reverse: false,
303            alternate: false,
304            yoyo: false,
305            repeat_delay_ms: 0,
306        }
307    }
308}
309
310#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "kebab-case")]
312#[derive(Default)]
313pub enum TextSplit {
314    #[default]
315    None,
316    Chars,
317    Words,
318    Lines,
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
322#[serde(rename_all = "kebab-case")]
323#[derive(Default)]
324pub enum ReducedMotion {
325    Static,
326    #[default]
327    FadeOnly,
328    Ignore,
329}
330
331impl ReducedMotion {
332    #[cfg(feature = "viewtx-interop")]
333    pub fn from_viewtx_reduced_motion(
334        reduced_motion: dioxus_viewtx_core::ViewTransitionReducedMotion,
335    ) -> Self {
336        match reduced_motion {
337            dioxus_viewtx_core::ViewTransitionReducedMotion::Disable => Self::Static,
338            dioxus_viewtx_core::ViewTransitionReducedMotion::FadeOnly => Self::FadeOnly,
339            dioxus_viewtx_core::ViewTransitionReducedMotion::Ignore => Self::Ignore,
340        }
341    }
342}
343
344#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
345#[serde(rename_all = "kebab-case")]
346#[derive(Default)]
347pub enum TextFxDirection {
348    #[default]
349    Up,
350    Right,
351    Down,
352    Left,
353}
354
355#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
356#[serde(rename_all = "camelCase")]
357pub struct TokenMark {
358    pub name: String,
359    pub text: String,
360    pub char_start: usize,
361    pub char_end: usize,
362    pub word_start: usize,
363    pub word_end: usize,
364}
365
366#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
367#[serde(tag = "kind", rename_all = "kebab-case")]
368pub enum TokenTarget {
369    All,
370    Others,
371    Mark { name: String },
372    Word { index: usize },
373    WordRange { start: usize, end: usize },
374    CharRange { start: usize, end: usize },
375    WordText { value: String },
376    Contains { value: String },
377}
378
379impl TokenTarget {
380    pub fn all() -> Self {
381        Self::All
382    }
383
384    pub fn others() -> Self {
385        Self::Others
386    }
387
388    pub fn mark(name: impl Into<String>) -> Self {
389        Self::Mark { name: name.into() }
390    }
391
392    pub fn word(index: usize) -> Self {
393        Self::Word { index }
394    }
395
396    pub fn word_range(range: RangeInclusive<usize>) -> Self {
397        Self::WordRange {
398            start: *range.start(),
399            end: *range.end(),
400        }
401    }
402
403    pub fn char_range(range: RangeInclusive<usize>) -> Self {
404        Self::CharRange {
405            start: *range.start(),
406            end: *range.end(),
407        }
408    }
409
410    pub fn word_text(value: impl Into<String>) -> Self {
411        Self::WordText {
412            value: value.into(),
413        }
414    }
415
416    pub fn contains(value: impl Into<String>) -> Self {
417        Self::Contains {
418            value: value.into(),
419        }
420    }
421}
422
423#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
424#[serde(rename_all = "camelCase")]
425#[derive(Default)]
426pub struct TokenAction {
427    pub stay: bool,
428    pub scale: Option<f32>,
429    pub slide_away: Option<TextFxDirection>,
430    pub opacity: Option<f32>,
431    pub highlight: bool,
432    pub underline_sweep: bool,
433    pub swap: Option<String>,
434    pub scramble_to: Option<String>,
435    pub blur: bool,
436    pub color: Option<String>,
437    pub delay_ms: Option<u32>,
438    pub stagger_ms: Option<u32>,
439    pub live_contrast: Option<TextFxLiveContrast>,
440}
441
442impl TokenAction {
443    pub fn stay(mut self) -> Self {
444        self.stay = true;
445        self
446    }
447
448    pub fn scale(value: f32) -> Self {
449        Self {
450            scale: Some(value),
451            ..Self::default()
452        }
453    }
454
455    pub fn slide_away(direction: TextFxDirection) -> Self {
456        Self {
457            slide_away: Some(direction),
458            ..Self::default()
459        }
460    }
461
462    pub fn highlight() -> Self {
463        Self {
464            highlight: true,
465            ..Self::default()
466        }
467    }
468
469    pub fn swap(value: impl Into<String>) -> Self {
470        Self {
471            swap: Some(value.into()),
472            ..Self::default()
473        }
474    }
475
476    pub fn live_contrast() -> Self {
477        Self::live_contrast_mode(TextFxLiveContrast::Difference)
478    }
479
480    pub fn live_contrast_mode(mode: TextFxLiveContrast) -> Self {
481        Self {
482            live_contrast: Some(mode),
483            ..Self::default()
484        }
485    }
486
487    pub fn merge(mut self, other: Self) -> Self {
488        self.stay |= other.stay;
489        self.highlight |= other.highlight;
490        self.underline_sweep |= other.underline_sweep;
491        self.blur |= other.blur;
492        self.scale = other.scale.or(self.scale);
493        self.slide_away = other.slide_away.or(self.slide_away);
494        self.opacity = other.opacity.or(self.opacity);
495        self.swap = other.swap.or(self.swap);
496        self.scramble_to = other.scramble_to.or(self.scramble_to);
497        self.color = other.color.or(self.color);
498        self.delay_ms = other.delay_ms.or(self.delay_ms);
499        self.stagger_ms = other.stagger_ms.or(self.stagger_ms);
500        self.live_contrast = other.live_contrast.or(self.live_contrast);
501        self
502    }
503}
504
505#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
506#[serde(rename_all = "camelCase")]
507pub struct TextFxChoreography {
508    pub target: TokenTarget,
509    pub action: TokenAction,
510}
511
512#[derive(Debug, Clone, PartialEq, Eq)]
513pub struct TextFxParseError {
514    message: String,
515}
516
517impl TextFxParseError {
518    fn new(message: impl Into<String>) -> Self {
519        Self {
520            message: message.into(),
521        }
522    }
523}
524
525impl fmt::Display for TextFxParseError {
526    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
527        self.message.fmt(f)
528    }
529}
530
531impl std::error::Error for TextFxParseError {}
532
533#[derive(Debug, Clone, PartialEq, Eq)]
534pub struct MarkedText {
535    pub clean_text: String,
536    pub marks: Vec<TokenMark>,
537}
538
539#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
540#[serde(rename_all = "camelCase")]
541pub struct TextFxTiming {
542    pub duration_ms: u32,
543    pub delay_ms: u32,
544    pub speed_ms: u32,
545    pub stagger_ms: u32,
546    pub easing: TextFxEasing,
547}
548
549impl Default for TextFxTiming {
550    fn default() -> Self {
551        Self {
552            duration_ms: DEFAULT_TEXTFX_DURATION_MS,
553            delay_ms: 0,
554            speed_ms: DEFAULT_TEXTFX_SPEED_MS,
555            stagger_ms: DEFAULT_TEXTFX_STAGGER_MS,
556            easing: TextFxEasing::EaseOut,
557        }
558    }
559}
560
561impl TextFxTiming {
562    pub fn dur(mut self, duration: Duration) -> Self {
563        self.duration_ms = duration.as_millis().min(u128::from(u32::MAX)) as u32;
564        self
565    }
566
567    pub fn dur_ms(mut self, duration_ms: u32) -> Self {
568        self.duration_ms = duration_ms;
569        self
570    }
571
572    pub fn delay(mut self, delay: Duration) -> Self {
573        self.delay_ms = delay.as_millis().min(u128::from(u32::MAX)) as u32;
574        self
575    }
576
577    pub fn delay_ms(mut self, delay_ms: u32) -> Self {
578        self.delay_ms = delay_ms;
579        self
580    }
581
582    pub fn speed(mut self, speed: Duration) -> Self {
583        self.speed_ms = (speed.as_millis().min(u128::from(u32::MAX)) as u32).max(1);
584        self
585    }
586
587    pub fn speed_ms(mut self, speed_ms: u32) -> Self {
588        self.speed_ms = speed_ms.max(1);
589        self
590    }
591
592    pub fn stagger(mut self, stagger: Duration) -> Self {
593        self.stagger_ms = stagger.as_millis().min(u128::from(u32::MAX)) as u32;
594        self
595    }
596
597    pub fn stagger_ms(mut self, stagger_ms: u32) -> Self {
598        self.stagger_ms = stagger_ms;
599        self
600    }
601
602    pub fn ease(mut self, easing: TextFxEasing) -> Self {
603        self.easing = easing;
604        self
605    }
606}
607
608#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
609#[serde(rename_all = "camelCase")]
610pub struct TextFxPhaseTiming {
611    #[serde(default, skip_serializing_if = "Option::is_none")]
612    pub duration_ms: Option<u32>,
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub delay_ms: Option<u32>,
615    #[serde(default, skip_serializing_if = "Option::is_none")]
616    pub speed_ms: Option<u32>,
617    #[serde(default, skip_serializing_if = "Option::is_none")]
618    pub stagger_ms: Option<u32>,
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub easing: Option<TextFxEasing>,
621}
622
623impl TextFxPhaseTiming {
624    pub fn is_empty(&self) -> bool {
625        self.duration_ms.is_none()
626            && self.delay_ms.is_none()
627            && self.speed_ms.is_none()
628            && self.stagger_ms.is_none()
629            && self.easing.is_none()
630    }
631}
632
633#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
634#[serde(rename_all = "camelCase")]
635pub struct TextFxPhase {
636    #[serde(default, skip_serializing_if = "Option::is_none")]
637    pub effect: Option<TextFxEffect>,
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub timing: Option<TextFxPhaseTiming>,
640    #[serde(default, skip_serializing_if = "Option::is_none")]
641    pub split: Option<TextSplit>,
642    #[serde(default, skip_serializing_if = "Option::is_none")]
643    pub direction: Option<TextFxDirection>,
644    #[serde(default, skip_serializing_if = "Option::is_none")]
645    pub playback: Option<TextFxPlayback>,
646}
647
648impl TextFxPhase {
649    pub fn new() -> Self {
650        Self::default()
651    }
652
653    pub fn reverse_of_enter() -> Self {
654        let playback = TextFxPlayback {
655            reverse: true,
656            ..TextFxPlayback::default()
657        };
658        Self {
659            playback: Some(playback),
660            ..Self::default()
661        }
662    }
663
664    pub fn is_empty(&self) -> bool {
665        self.effect.is_none()
666            && self.timing.as_ref().is_none_or(TextFxPhaseTiming::is_empty)
667            && self.split.is_none()
668            && self.direction.is_none()
669            && self.playback.is_none()
670    }
671
672    fn timing_mut(&mut self) -> &mut TextFxPhaseTiming {
673        self.timing.get_or_insert_with(TextFxPhaseTiming::default)
674    }
675}
676
677#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
678#[serde(rename_all = "camelCase")]
679pub struct TextFxLifecycle {
680    #[serde(default, skip_serializing_if = "Option::is_none")]
681    pub enter: Option<TextFxPhase>,
682    #[serde(default, skip_serializing_if = "Option::is_none")]
683    pub exit: Option<TextFxPhase>,
684}
685
686impl TextFxLifecycle {
687    pub fn is_empty(&self) -> bool {
688        self.enter.as_ref().is_none_or(TextFxPhase::is_empty)
689            && self.exit.as_ref().is_none_or(TextFxPhase::is_empty)
690    }
691}
692
693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
694enum TextFxPhaseKind {
695    Enter,
696    Exit,
697}
698
699#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
700#[serde(rename_all = "kebab-case")]
701pub enum TextFxProfile {
702    Lighthouse,
703    Showcase,
704    Interactive,
705}
706
707#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
708#[serde(rename_all = "kebab-case")]
709#[derive(Default)]
710pub enum TextFxPerformanceProfile {
711    #[default]
712    CssFirst,
713    Balanced,
714    VisualExact,
715}
716
717#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
718#[serde(rename_all = "kebab-case")]
719#[derive(Default)]
720pub enum TextFxGpuBudget {
721    #[default]
722    Auto,
723    LowPower,
724    Normal,
725    Exact,
726}
727
728impl TextFxGpuBudget {
729    pub fn as_attr(self) -> &'static str {
730        match self {
731            Self::Auto => "auto",
732            Self::LowPower => "low-power",
733            Self::Normal => "normal",
734            Self::Exact => "exact",
735        }
736    }
737
738    pub fn compact_id(self) -> &'static str {
739        match self {
740            Self::Auto => "a",
741            Self::LowPower => "l",
742            Self::Normal => "n",
743            Self::Exact => "x",
744        }
745    }
746}
747
748#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
749#[serde(rename_all = "kebab-case")]
750pub enum TextFxRenderPreference {
751    #[default]
752    Auto,
753    CssFirst,
754    #[serde(rename = "workertown-render")]
755    WorkerTownRender,
756    MainThreadFallback,
757}
758
759impl TextFxRenderPreference {
760    pub fn as_attr(self) -> &'static str {
761        match self {
762            Self::Auto => "auto",
763            Self::CssFirst => "css-first",
764            Self::WorkerTownRender => "workertown-render",
765            Self::MainThreadFallback => "main-thread-fallback",
766        }
767    }
768}
769
770#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
771#[serde(rename_all = "kebab-case")]
772#[derive(Default)]
773pub enum TextFxLayoutReserve {
774    Off,
775    #[default]
776    Auto,
777    Exact,
778}
779
780impl TextFxLayoutReserve {
781    pub fn as_attr(self) -> &'static str {
782        match self {
783            Self::Off => "off",
784            Self::Auto => "auto",
785            Self::Exact => "exact",
786        }
787    }
788}
789
790impl TextFxPerformanceProfile {
791    pub fn as_attr(self) -> &'static str {
792        match self {
793            Self::CssFirst => "css-first",
794            Self::Balanced => "balanced",
795            Self::VisualExact => "visual-exact",
796        }
797    }
798
799    pub fn compact_id(self) -> &'static str {
800        match self {
801            Self::CssFirst => "css",
802            Self::Balanced => "bal",
803            Self::VisualExact => "exact",
804        }
805    }
806}
807
808impl TextFxProfile {
809    pub fn as_attr(self) -> &'static str {
810        match self {
811            Self::Lighthouse => "lighthouse",
812            Self::Showcase => "showcase",
813            Self::Interactive => "interactive",
814        }
815    }
816
817    pub fn timing(self) -> TextFxTiming {
818        match self {
819            Self::Lighthouse => TextFxTiming {
820                duration_ms: 360,
821                delay_ms: 0,
822                speed_ms: 24,
823                stagger_ms: 10,
824                easing: TextFxEasing::EaseOut,
825            },
826            Self::Showcase => TextFxTiming {
827                duration_ms: 760,
828                delay_ms: 0,
829                speed_ms: 32,
830                stagger_ms: 32,
831                easing: TextFxEasing::Spring,
832            },
833            Self::Interactive => TextFxTiming {
834                duration_ms: 520,
835                delay_ms: 0,
836                speed_ms: 24,
837                stagger_ms: 18,
838                easing: TextFxEasing::EaseOut,
839            },
840        }
841    }
842
843    pub fn reduced_motion(self) -> ReducedMotion {
844        match self {
845            Self::Lighthouse => ReducedMotion::Static,
846            Self::Showcase | Self::Interactive => ReducedMotion::FadeOnly,
847        }
848    }
849
850    pub fn trigger(self) -> TextFxTrigger {
851        match self {
852            Self::Lighthouse => TextFxTrigger::Load,
853            Self::Showcase => TextFxTrigger::Visible,
854            Self::Interactive => TextFxTrigger::Interaction,
855        }
856    }
857
858    pub fn prefers_css_first(self) -> bool {
859        matches!(self, Self::Lighthouse | Self::Showcase)
860    }
861
862    pub fn performance_profile(self) -> TextFxPerformanceProfile {
863        match self {
864            Self::Lighthouse => TextFxPerformanceProfile::CssFirst,
865            Self::Showcase => TextFxPerformanceProfile::VisualExact,
866            Self::Interactive => TextFxPerformanceProfile::Balanced,
867        }
868    }
869
870    pub fn gpu_budget(self) -> TextFxGpuBudget {
871        match self {
872            Self::Lighthouse | Self::Interactive => TextFxGpuBudget::Auto,
873            Self::Showcase => TextFxGpuBudget::Exact,
874        }
875    }
876}
877
878#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
879#[serde(rename_all = "camelCase")]
880pub struct TextFxConfig {
881    pub id: String,
882    pub text: String,
883    pub effect: TextFxEffect,
884    pub timing: TextFxTiming,
885    pub split: TextSplit,
886    pub reduced_motion: ReducedMotion,
887    pub performance_profile: TextFxPerformanceProfile,
888    pub gpu_budget: TextFxGpuBudget,
889    #[serde(default)]
890    pub render_preference: TextFxRenderPreference,
891    #[serde(default)]
892    pub layout_reserve: TextFxLayoutReserve,
893    pub trigger: TextFxTrigger,
894    pub direction: TextFxDirection,
895    pub playback: TextFxPlayback,
896    pub intensity: f32,
897    pub palette: Vec<String>,
898    pub charset: String,
899    pub cursor: bool,
900    pub from: Option<f64>,
901    pub to: Option<f64>,
902    pub fx: Option<String>,
903    #[serde(default, skip_serializing_if = "TextFxLifecycle::is_empty")]
904    pub lifecycle: TextFxLifecycle,
905    pub marks: Vec<TokenMark>,
906    pub choreography: Vec<TextFxChoreography>,
907}
908
909impl TextFxConfig {
910    pub fn new(id: impl Into<String>, text: impl Into<String>) -> Self {
911        let marked = parse_inline_marks_owned(text.into());
912        Self {
913            id: id.into(),
914            text: marked.clean_text,
915            effect: TextFxEffect::default(),
916            timing: TextFxTiming::default(),
917            split: TextSplit::None,
918            reduced_motion: ReducedMotion::default(),
919            performance_profile: TextFxPerformanceProfile::default(),
920            gpu_budget: TextFxGpuBudget::default(),
921            render_preference: TextFxRenderPreference::default(),
922            layout_reserve: TextFxLayoutReserve::default(),
923            trigger: TextFxTrigger::default(),
924            direction: TextFxDirection::default(),
925            playback: TextFxPlayback::default(),
926            intensity: 1.0,
927            palette: vec![
928                "#ff7a1a".to_string(),
929                "#ffffff".to_string(),
930                "#9fb7ff".to_string(),
931            ],
932            charset: DEFAULT_TEXTFX_CHARSET.to_string(),
933            cursor: true,
934            from: None,
935            to: None,
936            fx: None,
937            lifecycle: TextFxLifecycle::default(),
938            marks: marked.marks,
939            choreography: Vec::new(),
940        }
941    }
942
943    pub fn with_text(mut self, text: impl Into<String>) -> Self {
944        let marked = parse_inline_marks_owned(text.into());
945        self.text = marked.clean_text;
946        self.marks = marked.marks;
947        self
948    }
949
950    pub fn content(self, text: impl Into<String>) -> Self {
951        self.with_text(text)
952    }
953
954    pub fn from_fx(
955        id: impl Into<String>,
956        text: impl Into<String>,
957        fx: impl Into<String>,
958    ) -> Result<Self, TextFxParseError> {
959        let fx = fx.into();
960        let mut config = Self::new(id, text);
961        config.fx = Some(fx.clone());
962        parse_fx_tokens(&mut config, &fx)?;
963        Ok(config)
964    }
965
966    pub fn profile(id: impl Into<String>, text: impl Into<String>, profile: TextFxProfile) -> Self {
967        Self::new(id, text).with_profile(profile)
968    }
969
970    pub fn with_profile(mut self, profile: TextFxProfile) -> Self {
971        self.timing = profile.timing();
972        self.reduced_motion = profile.reduced_motion();
973        self.performance_profile = profile.performance_profile();
974        self.gpu_budget = profile.gpu_budget();
975        self.trigger = profile.trigger();
976        if profile.prefers_css_first() && !self.effect.needs_split() {
977            self.split = TextSplit::None;
978        }
979        self
980    }
981
982    pub fn profile_preset(self, profile: TextFxProfile) -> Self {
983        self.with_profile(profile)
984    }
985
986    pub fn with_effect(mut self, effect: TextFxEffect) -> Self {
987        self.effect = effect;
988        if effect.needs_split() && self.split == TextSplit::None {
989            self.split = TextSplit::Chars;
990            self.promote_for_runtime_text_motion();
991        }
992        if matches!(effect, TextFxEffect::CountUp | TextFxEffect::NumberTicker) {
993            self.split = TextSplit::None;
994        }
995        self
996    }
997
998    pub fn effect(self, effect: TextFxEffect) -> Self {
999        self.with_effect(effect)
1000    }
1001
1002    pub fn fade(self) -> Self {
1003        self.with_effect(TextFxEffect::Fade)
1004    }
1005
1006    pub fn slide(self) -> Self {
1007        self.with_effect(TextFxEffect::Slide)
1008    }
1009
1010    pub fn blur(self) -> Self {
1011        self.with_effect(TextFxEffect::BlurReveal)
1012    }
1013
1014    pub fn scale(self) -> Self {
1015        self.with_effect(TextFxEffect::Scale)
1016    }
1017
1018    pub fn typewriter(self) -> Self {
1019        self.with_effect(TextFxEffect::Typewriter)
1020    }
1021
1022    pub fn scramble(self) -> Self {
1023        self.with_effect(TextFxEffect::Scramble)
1024    }
1025
1026    pub fn staggered(self) -> Self {
1027        self.with_effect(TextFxEffect::Stagger)
1028    }
1029
1030    pub fn with_timing(mut self, timing: TextFxTiming) -> Self {
1031        self.timing = timing;
1032        self
1033    }
1034
1035    pub fn timing(self, timing: TextFxTiming) -> Self {
1036        self.with_timing(timing)
1037    }
1038
1039    pub fn with_duration_ms(mut self, duration_ms: u32) -> Self {
1040        self.timing.duration_ms = duration_ms;
1041        self
1042    }
1043
1044    pub fn dur_ms(self, duration_ms: u32) -> Self {
1045        self.with_duration_ms(duration_ms)
1046    }
1047
1048    pub fn dur(self, duration: Duration) -> Self {
1049        self.with_duration_ms(duration.as_millis().min(u128::from(u32::MAX)) as u32)
1050    }
1051
1052    pub fn with_delay_ms(mut self, delay_ms: u32) -> Self {
1053        self.timing.delay_ms = delay_ms;
1054        self
1055    }
1056
1057    pub fn delay_ms(self, delay_ms: u32) -> Self {
1058        self.with_delay_ms(delay_ms)
1059    }
1060
1061    pub fn with_speed_ms(mut self, speed_ms: u32) -> Self {
1062        self.timing.speed_ms = speed_ms.max(1);
1063        self
1064    }
1065
1066    pub fn speed_ms(self, speed_ms: u32) -> Self {
1067        self.with_speed_ms(speed_ms)
1068    }
1069
1070    pub fn with_stagger_ms(mut self, stagger_ms: u32) -> Self {
1071        self.timing.stagger_ms = stagger_ms;
1072        self
1073    }
1074
1075    pub fn stagger_ms(self, stagger_ms: u32) -> Self {
1076        self.with_stagger_ms(stagger_ms)
1077    }
1078
1079    pub fn stagger(self, stagger: Duration) -> Self {
1080        self.with_stagger_ms(stagger.as_millis().min(u128::from(u32::MAX)) as u32)
1081    }
1082
1083    pub fn with_enter_effect(mut self, effect: TextFxEffect) -> Self {
1084        self.apply_phase_effect(TextFxPhaseKind::Enter, effect);
1085        self
1086    }
1087
1088    pub fn enter(self, effect: TextFxEffect) -> Self {
1089        self.with_enter_effect(effect)
1090    }
1091
1092    pub fn with_enter_delay_ms(mut self, delay_ms: u32) -> Self {
1093        self.enter_phase_mut().timing_mut().delay_ms = Some(delay_ms);
1094        self
1095    }
1096
1097    pub fn enter_delay_ms(self, delay_ms: u32) -> Self {
1098        self.with_enter_delay_ms(delay_ms)
1099    }
1100
1101    pub fn with_enter_duration_ms(mut self, duration_ms: u32) -> Self {
1102        self.enter_phase_mut().timing_mut().duration_ms = Some(duration_ms);
1103        self
1104    }
1105
1106    pub fn enter_dur_ms(self, duration_ms: u32) -> Self {
1107        self.with_enter_duration_ms(duration_ms)
1108    }
1109
1110    pub fn with_enter_stagger_ms(mut self, stagger_ms: u32) -> Self {
1111        self.enter_phase_mut().timing_mut().stagger_ms = Some(stagger_ms);
1112        self
1113    }
1114
1115    pub fn enter_stagger_ms(self, stagger_ms: u32) -> Self {
1116        self.with_enter_stagger_ms(stagger_ms)
1117    }
1118
1119    pub fn with_exit_effect(mut self, effect: TextFxEffect) -> Self {
1120        self.apply_phase_effect(TextFxPhaseKind::Exit, effect);
1121        self
1122    }
1123
1124    pub fn exit(self, effect: TextFxEffect) -> Self {
1125        self.with_exit_effect(effect)
1126    }
1127
1128    pub fn with_exit_delay_ms(mut self, delay_ms: u32) -> Self {
1129        self.exit_phase_mut().timing_mut().delay_ms = Some(delay_ms);
1130        self
1131    }
1132
1133    pub fn exit_delay_ms(self, delay_ms: u32) -> Self {
1134        self.with_exit_delay_ms(delay_ms)
1135    }
1136
1137    pub fn with_exit_duration_ms(mut self, duration_ms: u32) -> Self {
1138        self.exit_phase_mut().timing_mut().duration_ms = Some(duration_ms);
1139        self
1140    }
1141
1142    pub fn exit_dur_ms(self, duration_ms: u32) -> Self {
1143        self.with_exit_duration_ms(duration_ms)
1144    }
1145
1146    pub fn with_exit_stagger_ms(mut self, stagger_ms: u32) -> Self {
1147        self.exit_phase_mut().timing_mut().stagger_ms = Some(stagger_ms);
1148        self
1149    }
1150
1151    pub fn exit_stagger_ms(self, stagger_ms: u32) -> Self {
1152        self.with_exit_stagger_ms(stagger_ms)
1153    }
1154
1155    pub fn with_exit_reverse_of_enter(mut self) -> Self {
1156        let phase = self.exit_phase_mut();
1157        let playback = TextFxPlayback {
1158            reverse: true,
1159            ..phase.playback.clone().unwrap_or_default()
1160        };
1161        phase.playback = Some(playback);
1162        self
1163    }
1164
1165    pub fn exit_reverse(self) -> Self {
1166        self.with_exit_reverse_of_enter()
1167    }
1168
1169    pub fn with_easing(mut self, easing: TextFxEasing) -> Self {
1170        self.timing.easing = easing;
1171        self
1172    }
1173
1174    pub fn ease(self, easing: TextFxEasing) -> Self {
1175        self.with_easing(easing)
1176    }
1177
1178    #[cfg(feature = "viewtx-interop")]
1179    pub fn with_viewtx_motion_policy(
1180        mut self,
1181        policy: &dioxus_viewtx_core::ViewMotionPolicy,
1182    ) -> Self {
1183        self.timing.duration_ms = policy.duration_ms;
1184        self.timing.easing = TextFxEasing::from_viewtx_easing(&policy.easing);
1185        self.reduced_motion = ReducedMotion::from_viewtx_reduced_motion(policy.reduced_motion);
1186        self
1187    }
1188
1189    pub fn with_split(mut self, split: TextSplit) -> Self {
1190        self.split = split;
1191        if split != TextSplit::None {
1192            self.promote_for_runtime_text_motion();
1193        }
1194        self
1195    }
1196
1197    pub fn split(self, split: TextSplit) -> Self {
1198        self.with_split(split)
1199    }
1200
1201    pub fn split_lines(self) -> Self {
1202        self.with_split(TextSplit::Lines)
1203    }
1204
1205    pub fn with_performance_profile(mut self, profile: TextFxPerformanceProfile) -> Self {
1206        self.performance_profile = profile;
1207        self
1208    }
1209
1210    pub fn perf(self, profile: TextFxPerformanceProfile) -> Self {
1211        self.with_performance_profile(profile)
1212    }
1213
1214    pub fn with_gpu_budget(mut self, budget: TextFxGpuBudget) -> Self {
1215        self.gpu_budget = budget;
1216        self
1217    }
1218
1219    pub fn gpu(self, budget: TextFxGpuBudget) -> Self {
1220        self.with_gpu_budget(budget)
1221    }
1222
1223    pub fn with_render_preference(mut self, preference: TextFxRenderPreference) -> Self {
1224        self.render_preference = preference;
1225        if matches!(preference, TextFxRenderPreference::WorkerTownRender) {
1226            self.performance_profile = TextFxPerformanceProfile::VisualExact;
1227            self.gpu_budget = TextFxGpuBudget::Exact;
1228        }
1229        self
1230    }
1231
1232    pub fn render(self, preference: TextFxRenderPreference) -> Self {
1233        self.with_render_preference(preference)
1234    }
1235
1236    pub fn with_layout_reserve(mut self, reserve: TextFxLayoutReserve) -> Self {
1237        self.layout_reserve = reserve;
1238        self
1239    }
1240
1241    pub fn reserve(self, reserve: TextFxLayoutReserve) -> Self {
1242        self.with_layout_reserve(reserve)
1243    }
1244
1245    pub fn css_first(self) -> Self {
1246        self.with_performance_profile(TextFxPerformanceProfile::CssFirst)
1247    }
1248
1249    pub fn balanced(self) -> Self {
1250        self.with_performance_profile(TextFxPerformanceProfile::Balanced)
1251    }
1252
1253    pub fn visual_exact(self) -> Self {
1254        self.with_performance_profile(TextFxPerformanceProfile::VisualExact)
1255    }
1256
1257    pub fn gpu_auto(self) -> Self {
1258        self.with_gpu_budget(TextFxGpuBudget::Auto)
1259    }
1260
1261    pub fn gpu_low_power(self) -> Self {
1262        self.with_gpu_budget(TextFxGpuBudget::LowPower)
1263    }
1264
1265    pub fn gpu_normal(self) -> Self {
1266        self.with_gpu_budget(TextFxGpuBudget::Normal)
1267    }
1268
1269    pub fn gpu_exact(self) -> Self {
1270        self.with_gpu_budget(TextFxGpuBudget::Exact)
1271    }
1272
1273    pub fn workertown_render(self) -> Self {
1274        self.with_render_preference(TextFxRenderPreference::WorkerTownRender)
1275    }
1276
1277    pub fn layout_reserve_off(self) -> Self {
1278        self.with_layout_reserve(TextFxLayoutReserve::Off)
1279    }
1280
1281    pub fn layout_reserve_auto(self) -> Self {
1282        self.with_layout_reserve(TextFxLayoutReserve::Auto)
1283    }
1284
1285    pub fn layout_reserve_exact(self) -> Self {
1286        self.with_layout_reserve(TextFxLayoutReserve::Exact)
1287    }
1288
1289    fn promote_for_runtime_text_motion(&mut self) {
1290        if self.performance_profile == TextFxPerformanceProfile::CssFirst {
1291            self.performance_profile = TextFxPerformanceProfile::Balanced;
1292        }
1293    }
1294
1295    fn enter_phase_mut(&mut self) -> &mut TextFxPhase {
1296        self.lifecycle
1297            .enter
1298            .get_or_insert_with(TextFxPhase::default)
1299    }
1300
1301    fn exit_phase_mut(&mut self) -> &mut TextFxPhase {
1302        self.lifecycle.exit.get_or_insert_with(TextFxPhase::default)
1303    }
1304
1305    fn phase_mut(&mut self, phase: TextFxPhaseKind) -> &mut TextFxPhase {
1306        match phase {
1307            TextFxPhaseKind::Enter => self.enter_phase_mut(),
1308            TextFxPhaseKind::Exit => self.exit_phase_mut(),
1309        }
1310    }
1311
1312    fn apply_phase_effect(&mut self, phase: TextFxPhaseKind, effect: TextFxEffect) {
1313        let should_promote = {
1314            let phase = self.phase_mut(phase);
1315            phase.effect = Some(effect);
1316            let mut should_promote = false;
1317            if effect.needs_split() && phase.split.is_none() {
1318                phase.split = Some(TextSplit::Chars);
1319                should_promote = true;
1320            }
1321            if matches!(effect, TextFxEffect::CountUp | TextFxEffect::NumberTicker) {
1322                phase.split = Some(TextSplit::None);
1323            }
1324            should_promote
1325        };
1326        if should_promote {
1327            self.promote_for_runtime_text_motion();
1328        }
1329    }
1330
1331    pub fn with_trigger(mut self, trigger: TextFxTrigger) -> Self {
1332        self.trigger = trigger;
1333        self
1334    }
1335
1336    pub fn trigger(self, trigger: TextFxTrigger) -> Self {
1337        self.with_trigger(trigger)
1338    }
1339
1340    pub fn on_hover(self) -> Self {
1341        self.with_trigger(TextFxTrigger::Hover)
1342    }
1343
1344    pub fn on_click(self) -> Self {
1345        self.with_trigger(TextFxTrigger::Click)
1346    }
1347
1348    pub fn split_words(self) -> Self {
1349        self.with_split(TextSplit::Words)
1350    }
1351
1352    pub fn split_chars(self) -> Self {
1353        self.with_split(TextSplit::Chars)
1354    }
1355
1356    pub fn loop_count(mut self, count: u16) -> Self {
1357        self.playback.loop_mode = TextFxLoop::Count(count.max(1));
1358        self
1359    }
1360
1361    pub fn loop_infinite(mut self) -> Self {
1362        self.playback.loop_mode = TextFxLoop::Infinite;
1363        self
1364    }
1365
1366    pub fn reverse(mut self) -> Self {
1367        self.playback.reverse = true;
1368        self
1369    }
1370
1371    pub fn alternate(mut self) -> Self {
1372        self.playback.alternate = true;
1373        self
1374    }
1375
1376    pub fn yoyo(mut self) -> Self {
1377        self.playback.yoyo = true;
1378        self
1379    }
1380
1381    pub fn target(mut self, target: TokenTarget, action: TokenAction) -> Self {
1382        self.add_target(target, action);
1383        self
1384    }
1385
1386    pub fn add_target(&mut self, target: TokenTarget, action: TokenAction) {
1387        if matches!(
1388            target,
1389            TokenTarget::Word { .. }
1390                | TokenTarget::WordRange { .. }
1391                | TokenTarget::WordText { .. }
1392                | TokenTarget::Contains { .. }
1393                | TokenTarget::Mark { .. }
1394                | TokenTarget::Others
1395        ) && self.split == TextSplit::None
1396        {
1397            self.split = TextSplit::Words;
1398        }
1399        if matches!(target, TokenTarget::CharRange { .. }) {
1400            self.split = TextSplit::Chars;
1401        }
1402        self.promote_for_runtime_text_motion();
1403        self.choreography
1404            .push(TextFxChoreography { target, action });
1405    }
1406
1407    pub fn with_reduced_motion(mut self, reduced_motion: ReducedMotion) -> Self {
1408        self.reduced_motion = reduced_motion;
1409        self
1410    }
1411
1412    pub fn reduced(self, reduced_motion: ReducedMotion) -> Self {
1413        self.with_reduced_motion(reduced_motion)
1414    }
1415
1416    pub fn with_direction(mut self, direction: TextFxDirection) -> Self {
1417        self.direction = direction;
1418        self
1419    }
1420
1421    pub fn direction(self, direction: TextFxDirection) -> Self {
1422        self.with_direction(direction)
1423    }
1424
1425    pub fn with_palette(mut self, palette: impl IntoIterator<Item = impl Into<String>>) -> Self {
1426        self.palette = palette.into_iter().map(Into::into).collect();
1427        self
1428    }
1429
1430    pub fn palette(self, palette: impl IntoIterator<Item = impl Into<String>>) -> Self {
1431        self.with_palette(palette)
1432    }
1433
1434    pub fn with_numbers(mut self, from: f64, to: f64) -> Self {
1435        self.from = Some(from);
1436        self.to = Some(to);
1437        self
1438    }
1439
1440    pub fn nums(self, from: f64, to: f64) -> Self {
1441        self.with_numbers(from, to)
1442    }
1443
1444    pub fn with_cursor(mut self, cursor: bool) -> Self {
1445        self.cursor = cursor;
1446        self
1447    }
1448
1449    pub fn cursor(self, cursor: bool) -> Self {
1450        self.with_cursor(cursor)
1451    }
1452
1453    pub fn with_charset(mut self, charset: impl Into<String>) -> Self {
1454        self.charset = charset.into();
1455        self
1456    }
1457
1458    pub fn charset(self, charset: impl Into<String>) -> Self {
1459        self.with_charset(charset)
1460    }
1461
1462    pub fn to_json(&self) -> Result<String, serde_json::Error> {
1463        serde_json::to_string(self)
1464    }
1465
1466    pub fn to_compact_json(&self) -> Result<String, serde_json::Error> {
1467        let value = serde_json::to_value(self)?;
1468        let Some(full) = value.as_object() else {
1469            return serde_json::to_string(self);
1470        };
1471        let default = textfx_default_value_object();
1472        let mut compact = serde_json::Map::new();
1473        compact.insert("v".to_string(), serde_json::json!(1));
1474        compact.insert("i".to_string(), serde_json::json!(self.id));
1475        compact.insert("t".to_string(), serde_json::json!(self.text));
1476        compact.insert("e".to_string(), serde_json::json!(self.effect.compact_id()));
1477        if let Some(enter) = self
1478            .lifecycle
1479            .enter
1480            .as_ref()
1481            .filter(|phase| !phase.is_empty())
1482        {
1483            compact.insert("en".to_string(), serde_json::to_value(enter)?);
1484        }
1485        if let Some(exit) = self
1486            .lifecycle
1487            .exit
1488            .as_ref()
1489            .filter(|phase| !phase.is_empty())
1490        {
1491            compact.insert("ex".to_string(), serde_json::to_value(exit)?);
1492        }
1493
1494        for (long, short) in [
1495            ("timing", "tm"),
1496            ("split", "sp"),
1497            ("reducedMotion", "rm"),
1498            ("performanceProfile", "pf"),
1499            ("gpuBudget", "gb"),
1500            ("renderPreference", "rp"),
1501            ("layoutReserve", "tlr"),
1502            ("trigger", "tr"),
1503            ("direction", "dir"),
1504            ("playback", "pb"),
1505            ("intensity", "in"),
1506            ("palette", "pa"),
1507            ("charset", "ch"),
1508            ("cursor", "cu"),
1509            ("from", "fr"),
1510            ("to", "to"),
1511            ("fx", "fx"),
1512            ("marks", "mk"),
1513            ("choreography", "cg"),
1514        ] {
1515            let Some(value) = full.get(long) else {
1516                continue;
1517            };
1518            let is_default = default.get(long).is_some_and(|default| default == value);
1519            if !is_default {
1520                compact.insert(short.to_string(), value.clone());
1521            }
1522        }
1523
1524        serde_json::to_string(&compact)
1525    }
1526
1527    pub fn data_attr(&self) -> Result<String, serde_json::Error> {
1528        let json = self.preferred_payload_json()?;
1529        Ok(format!(r#"data-dxt-textfx="{}""#, escape_attr(&json)))
1530    }
1531
1532    pub fn locale_data_attr(&self) -> Result<String, serde_json::Error> {
1533        let json = self.preferred_payload_json()?;
1534        let attr = format!(r#"data-dxt-locale-fx="{}""#, escape_attr(&json));
1535        Ok(match self.layout_reserve_attr() {
1536            Some(layout) => format!("{attr} {layout}"),
1537            None => attr,
1538        })
1539    }
1540
1541    fn preferred_payload_json(&self) -> Result<String, serde_json::Error> {
1542        let full = self.to_json()?;
1543        let compact = self.to_compact_json()?;
1544        if compact.len() < full.len() {
1545            Ok(compact)
1546        } else {
1547            Ok(full)
1548        }
1549    }
1550
1551    pub fn is_css_first(&self) -> bool {
1552        if matches!(
1553            self.render_preference,
1554            TextFxRenderPreference::WorkerTownRender
1555        ) {
1556            return false;
1557        }
1558        matches!(
1559            self.effect,
1560            TextFxEffect::Fade
1561                | TextFxEffect::Slide
1562                | TextFxEffect::BlurReveal
1563                | TextFxEffect::Scale
1564                | TextFxEffect::MaskReveal
1565                | TextFxEffect::HighlightSweep
1566                | TextFxEffect::GradientShift
1567        ) && self.split == TextSplit::None
1568            && self.choreography.is_empty()
1569            && matches!(self.trigger, TextFxTrigger::Load | TextFxTrigger::Visible)
1570            && self.playback.repeat_delay_ms == 0
1571    }
1572
1573    pub fn is_css_first_split(&self) -> bool {
1574        if matches!(
1575            self.render_preference,
1576            TextFxRenderPreference::WorkerTownRender
1577        ) {
1578            return false;
1579        }
1580        matches!(
1581            self.effect,
1582            TextFxEffect::Stagger
1583                | TextFxEffect::Wave
1584                | TextFxEffect::Flip
1585                | TextFxEffect::Glitch
1586                | TextFxEffect::KerningExpand
1587        ) && matches!(
1588            self.split,
1589            TextSplit::Chars | TextSplit::Words | TextSplit::Lines
1590        ) && self.choreography.is_empty()
1591            && matches!(self.trigger, TextFxTrigger::Load | TextFxTrigger::Visible)
1592            && self.playback.repeat_delay_ms == 0
1593    }
1594
1595    pub fn is_css_first_renderable(&self) -> bool {
1596        self.is_css_first() || self.is_css_first_split()
1597    }
1598
1599    pub fn css_first_class(&self) -> Option<String> {
1600        self.is_css_first_renderable()
1601            .then(|| format!("dxt-effect-{}", self.effect.as_attr()))
1602    }
1603
1604    pub fn css_first_state_attrs(&self) -> Option<String> {
1605        if !self.is_css_first_renderable() {
1606            return None;
1607        }
1608        let iterations = match self.playback.loop_mode {
1609            TextFxLoop::Once => "1".to_string(),
1610            TextFxLoop::Infinite => "infinite".to_string(),
1611            TextFxLoop::Count(count) => count.max(1).to_string(),
1612        };
1613        let direction = if self.playback.reverse {
1614            "reverse"
1615        } else if self.playback.alternate || self.playback.yoyo {
1616            "alternate"
1617        } else {
1618            "normal"
1619        };
1620        let gradient_a = self
1621            .palette
1622            .first()
1623            .map(String::as_str)
1624            .unwrap_or("#ff7a1a");
1625        let gradient_b = self.palette.get(1).map(String::as_str).unwrap_or("#ffffff");
1626        let gradient_c = self.palette.get(2).map(String::as_str).unwrap_or("#9fb7ff");
1627        let mut style = format!(
1628            "--dxt-duration:{}ms;--dxt-delay:{}ms;--dxt-stagger:{}ms;--dxt-ease:{};--dxt-iterations:{};--dxt-direction:{};--dxt-gradient-a:{};--dxt-gradient-b:{};--dxt-gradient-c:{};",
1629            self.timing.duration_ms,
1630            self.timing.delay_ms,
1631            self.timing.stagger_ms,
1632            escape_attr(&self.timing.easing.css_value()),
1633            escape_attr(&iterations),
1634            direction,
1635            escape_attr(gradient_a),
1636            escape_attr(gradient_b),
1637            escape_attr(gradient_c),
1638        );
1639        if self.is_css_first_split() && self.reserves_layout() {
1640            style.push_str(&format!(
1641                "--dxt-layout-fallback-lines:{};min-block-size:calc(var(--dxt-layout-fallback-lines) * 1.2em);",
1642                self.layout_fallback_lines()
1643            ));
1644        }
1645        let attrs = [
1646            r#"data-dxt-css-first="true""#.to_string(),
1647            r#"data-dxt-state="running""#.to_string(),
1648            format!(r#"style="{style}""#),
1649        ];
1650        Some(attrs.join(" "))
1651    }
1652
1653    pub fn reserves_layout(&self) -> bool {
1654        self.layout_reserve != TextFxLayoutReserve::Off
1655    }
1656
1657    pub fn layout_reserve_attr(&self) -> Option<String> {
1658        self.reserves_layout().then(|| {
1659            format!(
1660                r#"data-dxr-text-layout-target="{}""#,
1661                escape_attr(self.layout_reserve.as_attr())
1662            )
1663        })
1664    }
1665
1666    pub fn layout_fallback_lines(&self) -> usize {
1667        self.text.split('\n').count().max(1)
1668    }
1669
1670    pub fn live_contrast_mode(&self) -> Option<TextFxLiveContrast> {
1671        if self.effect == TextFxEffect::LiveContrast {
1672            Some(TextFxLiveContrast::Difference)
1673        } else {
1674            None
1675        }
1676    }
1677
1678    pub fn live_contrast_attr(&self) -> Option<String> {
1679        self.live_contrast_mode().map(|mode| {
1680            format!(
1681                r#"data-dxt-live-contrast="{}""#,
1682                escape_attr(mode.as_attr())
1683            )
1684        })
1685    }
1686
1687    pub fn requires_workertown_render(&self) -> bool {
1688        matches!(
1689            self.render_preference,
1690            TextFxRenderPreference::WorkerTownRender
1691        )
1692    }
1693
1694    pub fn trigger_attr(&self) -> Option<&'static str> {
1695        None
1696    }
1697
1698    pub fn resume_trigger_attr(&self) -> Option<String> {
1699        if matches!(
1700            self.render_preference,
1701            TextFxRenderPreference::WorkerTownRender
1702        ) {
1703            return self.trigger.resume_attr();
1704        }
1705        if self.is_css_first_renderable()
1706            || (self.effect == TextFxEffect::LiveContrast && self.choreography.is_empty())
1707        {
1708            None
1709        } else {
1710            self.trigger.resume_attr()
1711        }
1712    }
1713
1714    pub fn html_attrs(&self) -> Result<String, serde_json::Error> {
1715        let class = self
1716            .css_first_class()
1717            .map(|effect_class| {
1718                if self.is_css_first_split() {
1719                    format!("dxt-textfx dxt-split {effect_class}")
1720                } else {
1721                    format!("dxt-textfx {effect_class}")
1722                }
1723            })
1724            .unwrap_or_else(|| "dxt-textfx".to_string());
1725        let mut attrs = vec![
1726            format!(r#"id="{}""#, escape_attr(&self.id)),
1727            format!(r#"class="{}""#, escape_attr(&class)),
1728            self.data_attr()?,
1729            format!(
1730                r#"data-dxt-performance="{}""#,
1731                escape_attr(self.performance_profile.as_attr())
1732            ),
1733            format!(
1734                r#"data-dxt-gpu-budget="{}""#,
1735                escape_attr(self.gpu_budget.as_attr())
1736            ),
1737        ];
1738        if self.render_preference != TextFxRenderPreference::Auto {
1739            attrs.push(format!(
1740                r#"data-dxt-renderer="{}""#,
1741                escape_attr(self.render_preference.as_attr())
1742            ));
1743        }
1744        if let Some(attr) = self.css_first_state_attrs() {
1745            attrs.push(attr);
1746        }
1747        if let Some(attr) = self.live_contrast_attr() {
1748            attrs.push(attr);
1749        }
1750        if let Some(attr) = self.layout_reserve_attr() {
1751            attrs.push(attr);
1752        }
1753        if let Some(trigger) = self.resume_trigger_attr() {
1754            attrs.push(trigger);
1755        }
1756        Ok(attrs.join(" "))
1757    }
1758
1759    pub fn static_html(
1760        &self,
1761        tag: impl AsRef<str>,
1762        extra_attrs: impl AsRef<str>,
1763    ) -> Result<String, serde_json::Error> {
1764        let tag = sanitize_tag(tag.as_ref());
1765        let attrs = self.html_attrs()?;
1766        let extra_attrs = extra_attrs.as_ref().trim();
1767        let attrs = if extra_attrs.is_empty() {
1768            attrs
1769        } else {
1770            format!("{attrs} {extra_attrs}")
1771        };
1772        let inner = escape_html(&self.text);
1773        Ok(format!("<{tag} {attrs}>{inner}</{tag}>"))
1774    }
1775
1776    pub fn with_route_profile(mut self, profile: TextFxPresetProfile) -> Self {
1777        profile.apply_to_config(&mut self);
1778        self
1779    }
1780
1781    pub fn route_profile(self, profile: TextFxPresetProfile) -> Self {
1782        self.with_route_profile(profile)
1783    }
1784
1785    pub fn cache_key(&self, route: Option<&str>) -> String {
1786        textfx_cache_key([self], route, None)
1787    }
1788
1789    pub fn diagnostics(&self, verbosity: TextFxDiagnosticVerbosity) -> TextFxDiagnosticReport {
1790        textfx_diagnostics([self], verbosity)
1791    }
1792
1793    pub fn explain(&self, policy: &TextFxRoutePolicy) -> TextFxExplainReport {
1794        explain_textfx([self], policy)
1795    }
1796}
1797
1798#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1799#[serde(rename_all = "kebab-case")]
1800pub enum TextFxRuntimeEmission {
1801    Always,
1802    #[default]
1803    WhenNeeded,
1804    CssFirst,
1805    Disabled,
1806}
1807
1808impl TextFxRuntimeEmission {
1809    pub const fn as_attr(self) -> &'static str {
1810        match self {
1811            Self::Always => "always",
1812            Self::WhenNeeded => "when-needed",
1813            Self::CssFirst => "css-first",
1814            Self::Disabled => "disabled",
1815        }
1816    }
1817}
1818
1819#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1820#[serde(rename_all = "kebab-case")]
1821pub enum TextFxSerializationFormat {
1822    ReadableJson,
1823    #[default]
1824    CompactWhenSmaller,
1825    StableJson,
1826}
1827
1828impl TextFxSerializationFormat {
1829    pub const fn as_attr(self) -> &'static str {
1830        match self {
1831            Self::ReadableJson => "readable-json",
1832            Self::CompactWhenSmaller => "compact-when-smaller",
1833            Self::StableJson => "stable-json",
1834        }
1835    }
1836}
1837
1838#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1839#[serde(rename_all = "kebab-case")]
1840pub enum TextFxDiagnosticVerbosity {
1841    Off,
1842    Summary,
1843    #[default]
1844    Detailed,
1845}
1846
1847#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
1848#[serde(rename_all = "kebab-case")]
1849pub enum TextFxPresetProfile {
1850    Conservative,
1851    #[default]
1852    Balanced,
1853    Expressive,
1854}
1855
1856impl TextFxPresetProfile {
1857    pub const fn as_attr(self) -> &'static str {
1858        match self {
1859            Self::Conservative => "conservative",
1860            Self::Balanced => "balanced",
1861            Self::Expressive => "expressive",
1862        }
1863    }
1864
1865    pub fn apply_to_config(self, config: &mut TextFxConfig) {
1866        match self {
1867            Self::Conservative => {
1868                config.performance_profile = TextFxPerformanceProfile::CssFirst;
1869                config.gpu_budget = TextFxGpuBudget::LowPower;
1870                config.render_preference = TextFxRenderPreference::CssFirst;
1871                config.reduced_motion = ReducedMotion::Static;
1872                config.layout_reserve = TextFxLayoutReserve::Auto;
1873                if config.effect.needs_split() {
1874                    config.split = TextSplit::Words;
1875                }
1876            }
1877            Self::Balanced => {
1878                config.performance_profile = TextFxPerformanceProfile::Balanced;
1879                config.gpu_budget = TextFxGpuBudget::Auto;
1880                config.render_preference = TextFxRenderPreference::Auto;
1881                config.layout_reserve = TextFxLayoutReserve::Auto;
1882            }
1883            Self::Expressive => {
1884                config.performance_profile = TextFxPerformanceProfile::VisualExact;
1885                config.gpu_budget = TextFxGpuBudget::Exact;
1886                config.render_preference = TextFxRenderPreference::WorkerTownRender;
1887                config.layout_reserve = TextFxLayoutReserve::Exact;
1888                config.reduced_motion = ReducedMotion::FadeOnly;
1889            }
1890        }
1891    }
1892}
1893
1894#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1895#[serde(rename_all = "camelCase")]
1896pub struct TextFxInteropPolicy {
1897    pub strata: bool,
1898    pub resume: bool,
1899    pub native_port: bool,
1900    pub workertown: bool,
1901    pub hoverfx: bool,
1902    pub theme: bool,
1903    pub viewtx: bool,
1904}
1905
1906impl Default for TextFxInteropPolicy {
1907    fn default() -> Self {
1908        Self {
1909            strata: true,
1910            resume: true,
1911            native_port: true,
1912            workertown: true,
1913            hoverfx: true,
1914            theme: true,
1915            viewtx: true,
1916        }
1917    }
1918}
1919
1920#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
1921#[serde(rename_all = "camelCase")]
1922pub struct TextFxOutputBudget {
1923    #[serde(default, skip_serializing_if = "Option::is_none")]
1924    pub max_config_bytes: Option<usize>,
1925    #[serde(default, skip_serializing_if = "Option::is_none")]
1926    pub max_runtime_bytes: Option<usize>,
1927    #[serde(default, skip_serializing_if = "Option::is_none")]
1928    pub max_static_html_bytes: Option<usize>,
1929    #[serde(default, skip_serializing_if = "Option::is_none")]
1930    pub max_effect_count: Option<usize>,
1931}
1932
1933impl TextFxOutputBudget {
1934    pub fn new() -> Self {
1935        Self::default()
1936    }
1937
1938    pub fn config_bytes(mut self, max: usize) -> Self {
1939        self.max_config_bytes = Some(max);
1940        self
1941    }
1942
1943    pub fn runtime_bytes(mut self, max: usize) -> Self {
1944        self.max_runtime_bytes = Some(max);
1945        self
1946    }
1947
1948    pub fn static_html_bytes(mut self, max: usize) -> Self {
1949        self.max_static_html_bytes = Some(max);
1950        self
1951    }
1952
1953    pub fn effect_count(mut self, max: usize) -> Self {
1954        self.max_effect_count = Some(max);
1955        self
1956    }
1957}
1958
1959#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
1960#[serde(rename_all = "camelCase")]
1961pub struct TextFxRoutePolicy {
1962    #[serde(default, skip_serializing_if = "Option::is_none")]
1963    pub route: Option<String>,
1964    pub enabled: bool,
1965    pub profile: TextFxPresetProfile,
1966    pub emission: TextFxRuntimeEmission,
1967    pub serialization: TextFxSerializationFormat,
1968    pub diagnostics: TextFxDiagnosticVerbosity,
1969    #[serde(default)]
1970    pub interop: TextFxInteropPolicy,
1971    #[serde(default)]
1972    pub budget: TextFxOutputBudget,
1973    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
1974    pub labels: BTreeMap<String, String>,
1975    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1976    pub tags: Vec<String>,
1977}
1978
1979impl Default for TextFxRoutePolicy {
1980    fn default() -> Self {
1981        Self {
1982            route: None,
1983            enabled: true,
1984            profile: TextFxPresetProfile::Balanced,
1985            emission: TextFxRuntimeEmission::WhenNeeded,
1986            serialization: TextFxSerializationFormat::CompactWhenSmaller,
1987            diagnostics: TextFxDiagnosticVerbosity::Detailed,
1988            interop: TextFxInteropPolicy::default(),
1989            budget: TextFxOutputBudget::default(),
1990            labels: BTreeMap::new(),
1991            tags: Vec::new(),
1992        }
1993    }
1994}
1995
1996impl TextFxRoutePolicy {
1997    pub fn new() -> Self {
1998        Self::default()
1999    }
2000
2001    pub fn route(mut self, route: impl Into<String>) -> Self {
2002        self.route = Some(route.into());
2003        self
2004    }
2005
2006    pub fn enabled(mut self, enabled: bool) -> Self {
2007        self.enabled = enabled;
2008        self
2009    }
2010
2011    pub fn profile(mut self, profile: TextFxPresetProfile) -> Self {
2012        self.profile = profile;
2013        self
2014    }
2015
2016    pub fn emission(mut self, emission: TextFxRuntimeEmission) -> Self {
2017        self.emission = emission;
2018        self
2019    }
2020
2021    pub fn serialization(mut self, serialization: TextFxSerializationFormat) -> Self {
2022        self.serialization = serialization;
2023        self
2024    }
2025
2026    pub fn diagnostics(mut self, diagnostics: TextFxDiagnosticVerbosity) -> Self {
2027        self.diagnostics = diagnostics;
2028        self
2029    }
2030
2031    pub fn budget(mut self, budget: TextFxOutputBudget) -> Self {
2032        self.budget = budget;
2033        self
2034    }
2035
2036    pub fn label(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
2037        self.labels.insert(key.into(), value.into());
2038        self
2039    }
2040
2041    pub fn tag(mut self, tag: impl Into<String>) -> Self {
2042        let tag = tag.into();
2043        if !tag.is_empty() && !self.tags.contains(&tag) {
2044            self.tags.push(tag);
2045            self.tags.sort();
2046        }
2047        self
2048    }
2049}
2050
2051#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
2052#[serde(rename_all = "kebab-case")]
2053pub enum TextFxDiagnosticSeverity {
2054    Info,
2055    Warning,
2056    Error,
2057}
2058
2059#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2060#[serde(rename_all = "camelCase")]
2061pub struct TextFxDiagnostic {
2062    pub severity: TextFxDiagnosticSeverity,
2063    pub code: String,
2064    pub message: String,
2065    #[serde(default, skip_serializing_if = "Option::is_none")]
2066    pub id: Option<String>,
2067}
2068
2069#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
2070#[serde(rename_all = "camelCase")]
2071pub struct TextFxDiagnosticReport {
2072    pub diagnostics: Vec<TextFxDiagnostic>,
2073}
2074
2075impl TextFxDiagnosticReport {
2076    pub fn is_valid(&self) -> bool {
2077        self.diagnostics
2078            .iter()
2079            .all(|diagnostic| diagnostic.severity != TextFxDiagnosticSeverity::Error)
2080    }
2081}
2082
2083#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2084#[serde(rename_all = "camelCase")]
2085pub struct TextFxManifestFragment {
2086    pub package: String,
2087    pub version: String,
2088    #[serde(default, skip_serializing_if = "Option::is_none")]
2089    pub route: Option<String>,
2090    pub enabled: bool,
2091    pub cache_key: String,
2092    pub profile: TextFxPresetProfile,
2093    pub emission: TextFxRuntimeEmission,
2094    pub config_count: usize,
2095    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2096    pub ids: Vec<String>,
2097    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2098    pub labels: BTreeMap<String, String>,
2099    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2100    pub tags: Vec<String>,
2101    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2102    pub metrics: BTreeMap<String, u64>,
2103    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
2104    pub policies: BTreeMap<String, serde_json::Value>,
2105}
2106
2107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2108#[serde(rename_all = "camelCase")]
2109pub struct TextFxOutputViolation {
2110    pub field: String,
2111    pub actual: usize,
2112    pub budget: usize,
2113}
2114
2115#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2116#[serde(rename_all = "camelCase")]
2117pub struct TextFxOutputReport {
2118    pub package: String,
2119    #[serde(default, skip_serializing_if = "Option::is_none")]
2120    pub route: Option<String>,
2121    pub cache_key: String,
2122    pub config_bytes: usize,
2123    pub runtime_bytes: usize,
2124    pub static_html_bytes: usize,
2125    pub effect_count: usize,
2126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2127    pub violations: Vec<TextFxOutputViolation>,
2128}
2129
2130impl TextFxOutputReport {
2131    pub fn is_within_budget(&self) -> bool {
2132        self.violations.is_empty()
2133    }
2134}
2135
2136#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2137#[serde(rename_all = "camelCase")]
2138pub struct TextFxExplainReport {
2139    pub package: String,
2140    #[serde(default, skip_serializing_if = "Option::is_none")]
2141    pub route: Option<String>,
2142    pub cache_key: String,
2143    pub runtime_decision: String,
2144    pub layout_decision: String,
2145    pub diagnostics: TextFxDiagnosticReport,
2146    pub manifest: TextFxManifestFragment,
2147    pub output: TextFxOutputReport,
2148    #[serde(default, skip_serializing_if = "Vec::is_empty")]
2149    pub notes: Vec<String>,
2150}
2151
2152#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2153#[serde(rename_all = "camelCase")]
2154pub struct TextFxCompatibilityRow {
2155    pub target: String,
2156    pub support: String,
2157    pub runtime: String,
2158    pub fallback: String,
2159    pub notes: String,
2160}
2161
2162#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2163#[serde(rename_all = "camelCase")]
2164pub struct TextFxCompatibilityMatrix {
2165    pub package: String,
2166    pub rows: Vec<TextFxCompatibilityRow>,
2167}
2168
2169pub trait TextFxManifestPolicyHook {
2170    fn apply(&self, fragment: TextFxManifestFragment) -> Option<TextFxManifestFragment>;
2171}
2172
2173pub fn apply_textfx_manifest_hook<'a, H>(
2174    configs: impl IntoIterator<Item = &'a TextFxConfig>,
2175    policy: &TextFxRoutePolicy,
2176    hook: &H,
2177) -> Option<TextFxManifestFragment>
2178where
2179    H: TextFxManifestPolicyHook,
2180{
2181    hook.apply(textfx_manifest_fragment(configs, policy))
2182}
2183
2184pub fn textfx_route_policy() -> TextFxRoutePolicy {
2185    TextFxRoutePolicy::new()
2186}
2187
2188pub fn textfx_output_budget() -> TextFxOutputBudget {
2189    TextFxOutputBudget::new()
2190}
2191
2192pub fn textfx_cache_key<'a>(
2193    configs: impl IntoIterator<Item = &'a TextFxConfig>,
2194    route: Option<&str>,
2195    extra: Option<&str>,
2196) -> String {
2197    let configs = configs.into_iter().collect::<Vec<_>>();
2198    let mut parts = vec![
2199        TEXTFX_PACKAGE_NAME.to_string(),
2200        TEXTFX_PACKAGE_VERSION.to_string(),
2201        route.unwrap_or("*").to_string(),
2202        extra.unwrap_or("").to_string(),
2203    ];
2204    for config in configs {
2205        parts.push(config.to_compact_json().unwrap_or_default());
2206    }
2207    stable_hash_hex(parts.iter().map(String::as_str))
2208}
2209
2210pub fn textfx_diagnostics<'a>(
2211    configs: impl IntoIterator<Item = &'a TextFxConfig>,
2212    verbosity: TextFxDiagnosticVerbosity,
2213) -> TextFxDiagnosticReport {
2214    let mut report = TextFxDiagnosticReport::default();
2215    if verbosity == TextFxDiagnosticVerbosity::Off {
2216        return report;
2217    }
2218    for config in configs {
2219        if config.id.trim().is_empty() {
2220            report.diagnostics.push(TextFxDiagnostic {
2221                severity: TextFxDiagnosticSeverity::Error,
2222                code: "empty-id".to_string(),
2223                message: "TextFX config id must not be empty".to_string(),
2224                id: None,
2225            });
2226        }
2227        if config.text.is_empty() && verbosity == TextFxDiagnosticVerbosity::Detailed {
2228            report.diagnostics.push(TextFxDiagnostic {
2229                severity: TextFxDiagnosticSeverity::Warning,
2230                code: "empty-text".to_string(),
2231                message: "TextFX config has no static text fallback".to_string(),
2232                id: Some(config.id.clone()),
2233            });
2234        }
2235        if config.requires_workertown_render() && verbosity == TextFxDiagnosticVerbosity::Detailed {
2236            report.diagnostics.push(TextFxDiagnostic {
2237                severity: TextFxDiagnosticSeverity::Info,
2238                code: "workertown-render".to_string(),
2239                message: "WorkerTown render preference will require a worker-capable runtime lane"
2240                    .to_string(),
2241                id: Some(config.id.clone()),
2242            });
2243        }
2244    }
2245    report
2246}
2247
2248pub fn textfx_manifest_fragment<'a>(
2249    configs: impl IntoIterator<Item = &'a TextFxConfig>,
2250    policy: &TextFxRoutePolicy,
2251) -> TextFxManifestFragment {
2252    let configs = configs.into_iter().collect::<Vec<_>>();
2253    let output = textfx_output_report(configs.iter().copied(), policy);
2254    let mut ids = configs
2255        .iter()
2256        .map(|config| config.id.clone())
2257        .collect::<Vec<_>>();
2258    ids.sort();
2259    let mut metrics = BTreeMap::new();
2260    metrics.insert("configBytes".to_string(), output.config_bytes as u64);
2261    metrics.insert("effectCount".to_string(), output.effect_count as u64);
2262    metrics.insert("runtimeBytes".to_string(), output.runtime_bytes as u64);
2263    metrics.insert(
2264        "staticHtmlBytes".to_string(),
2265        output.static_html_bytes as u64,
2266    );
2267
2268    let mut policies = BTreeMap::new();
2269    policies.insert(
2270        "interop".to_string(),
2271        serde_json::to_value(&policy.interop).unwrap_or(serde_json::Value::Null),
2272    );
2273    policies.insert(
2274        "route".to_string(),
2275        serde_json::json!({
2276            "enabled": policy.enabled,
2277            "profile": policy.profile,
2278            "emission": policy.emission,
2279            "serialization": policy.serialization,
2280        }),
2281    );
2282
2283    TextFxManifestFragment {
2284        package: TEXTFX_PACKAGE_NAME.to_string(),
2285        version: TEXTFX_PACKAGE_VERSION.to_string(),
2286        route: policy.route.clone(),
2287        enabled: policy.enabled,
2288        cache_key: output.cache_key,
2289        profile: policy.profile,
2290        emission: policy.emission,
2291        config_count: configs.len(),
2292        ids,
2293        labels: policy.labels.clone(),
2294        tags: policy.tags.clone(),
2295        metrics,
2296        policies,
2297    }
2298}
2299
2300pub fn textfx_output_report<'a>(
2301    configs: impl IntoIterator<Item = &'a TextFxConfig>,
2302    policy: &TextFxRoutePolicy,
2303) -> TextFxOutputReport {
2304    let configs = configs.into_iter().collect::<Vec<_>>();
2305    let mut config_bytes = 0;
2306    let mut static_html_bytes = 0;
2307    let mut needs_runtime = policy.emission == TextFxRuntimeEmission::Always;
2308    for config in &configs {
2309        let json = match policy.serialization {
2310            TextFxSerializationFormat::ReadableJson | TextFxSerializationFormat::StableJson => {
2311                config.to_json()
2312            }
2313            TextFxSerializationFormat::CompactWhenSmaller => {
2314                match (config.to_json(), config.to_compact_json()) {
2315                    (Ok(full), Ok(compact)) => Ok(if compact.len() < full.len() {
2316                        compact
2317                    } else {
2318                        full
2319                    }),
2320                    (Ok(full), Err(_)) => Ok(full),
2321                    (Err(err), _) => Err(err),
2322                }
2323            }
2324        }
2325        .unwrap_or_default();
2326        config_bytes += json.len();
2327        static_html_bytes += config
2328            .static_html("span", "")
2329            .map(|html| html.len())
2330            .unwrap_or_default();
2331        needs_runtime |= !config.is_css_first_renderable() || config.requires_workertown_render();
2332    }
2333    let runtime_bytes =
2334        if policy.enabled && policy.emission != TextFxRuntimeEmission::Disabled && needs_runtime {
2335            DEFAULT_TEXTFX_RUNTIME_PATH.len()
2336        } else {
2337            0
2338        };
2339    let effect_count = configs.len();
2340    let mut violations = Vec::new();
2341    push_textfx_budget_violation(
2342        &mut violations,
2343        "configBytes",
2344        config_bytes,
2345        policy.budget.max_config_bytes,
2346    );
2347    push_textfx_budget_violation(
2348        &mut violations,
2349        "runtimeBytes",
2350        runtime_bytes,
2351        policy.budget.max_runtime_bytes,
2352    );
2353    push_textfx_budget_violation(
2354        &mut violations,
2355        "staticHtmlBytes",
2356        static_html_bytes,
2357        policy.budget.max_static_html_bytes,
2358    );
2359    push_textfx_budget_violation(
2360        &mut violations,
2361        "effectCount",
2362        effect_count,
2363        policy.budget.max_effect_count,
2364    );
2365
2366    TextFxOutputReport {
2367        package: TEXTFX_PACKAGE_NAME.to_string(),
2368        route: policy.route.clone(),
2369        cache_key: textfx_cache_key(
2370            configs.iter().copied(),
2371            policy.route.as_deref(),
2372            Some(policy.profile.as_attr()),
2373        ),
2374        config_bytes,
2375        runtime_bytes,
2376        static_html_bytes,
2377        effect_count,
2378        violations,
2379    }
2380}
2381
2382pub fn explain_textfx<'a>(
2383    configs: impl IntoIterator<Item = &'a TextFxConfig>,
2384    policy: &TextFxRoutePolicy,
2385) -> TextFxExplainReport {
2386    let configs = configs.into_iter().collect::<Vec<_>>();
2387    let diagnostics = textfx_diagnostics(configs.iter().copied(), policy.diagnostics);
2388    let output = textfx_output_report(configs.iter().copied(), policy);
2389    let manifest = textfx_manifest_fragment(configs.iter().copied(), policy);
2390    let runtime_decision = if !policy.enabled {
2391        "route disabled TextFX emission".to_string()
2392    } else if policy.emission == TextFxRuntimeEmission::Disabled {
2393        "runtime emission disabled by route policy".to_string()
2394    } else if output.runtime_bytes == 0 {
2395        "all TextFX configs can render static/CSS-first for this route".to_string()
2396    } else {
2397        "TextFX runtime is required by at least one config or policy".to_string()
2398    };
2399    let layout_decision = if configs.iter().any(|config| config.reserves_layout()) {
2400        "layout reserve attributes will be emitted for stable text boxes".to_string()
2401    } else {
2402        "no layout reserve attributes are required".to_string()
2403    };
2404    let mut notes = Vec::new();
2405    if policy.interop.hoverfx {
2406        notes.push("HoverFX can trigger TextFX through shared data attributes".to_string());
2407    }
2408    if policy.interop.theme {
2409        notes.push("theme token gradients remain CSS custom properties".to_string());
2410    }
2411    if !output.is_within_budget() {
2412        notes.push("one or more TextFX output budgets were exceeded".to_string());
2413    }
2414
2415    TextFxExplainReport {
2416        package: TEXTFX_PACKAGE_NAME.to_string(),
2417        route: policy.route.clone(),
2418        cache_key: output.cache_key.clone(),
2419        runtime_decision,
2420        layout_decision,
2421        diagnostics,
2422        manifest,
2423        output,
2424        notes,
2425    }
2426}
2427
2428pub fn textfx_compatibility_matrix() -> TextFxCompatibilityMatrix {
2429    TextFxCompatibilityMatrix {
2430        package: TEXTFX_PACKAGE_NAME.to_string(),
2431        rows: vec![
2432            TextFxCompatibilityRow {
2433                target: "web".to_string(),
2434                support: "full".to_string(),
2435                runtime: "CSS-first or module runtime".to_string(),
2436                fallback: "static text".to_string(),
2437                notes:
2438                    "HoverFX triggers, theme gradients, and WorkerTown render hints are supported"
2439                        .to_string(),
2440            },
2441            TextFxCompatibilityRow {
2442                target: "server".to_string(),
2443                support: "manifest".to_string(),
2444                runtime: "route-gated SSR requirements".to_string(),
2445                fallback: "escaped static HTML".to_string(),
2446                notes: "resume/Strata consumers can use cache keys and output reports".to_string(),
2447            },
2448            TextFxCompatibilityRow {
2449                target: "native".to_string(),
2450                support: "adapter".to_string(),
2451                runtime: "native-port action hints".to_string(),
2452                fallback: "semantic text".to_string(),
2453                notes: "native renderers can consume split/token/timeline summaries".to_string(),
2454            },
2455            TextFxCompatibilityRow {
2456                target: "cli".to_string(),
2457                support: "report".to_string(),
2458                runtime: "none".to_string(),
2459                fallback: "compact-json".to_string(),
2460                notes: "diagnostics and budget reports are machine-readable".to_string(),
2461            },
2462        ],
2463    }
2464}
2465
2466pub fn textfx_native_port_hints<'a>(
2467    configs: impl IntoIterator<Item = &'a TextFxConfig>,
2468    policy: &TextFxRoutePolicy,
2469) -> BTreeMap<String, String> {
2470    let configs = configs.into_iter().collect::<Vec<_>>();
2471    let mut hints = BTreeMap::new();
2472    hints.insert("package".to_string(), TEXTFX_PACKAGE_NAME.to_string());
2473    hints.insert("version".to_string(), TEXTFX_PACKAGE_VERSION.to_string());
2474    hints.insert(
2475        "cacheKey".to_string(),
2476        textfx_cache_key(configs.iter().copied(), policy.route.as_deref(), None),
2477    );
2478    hints.insert(
2479        "route".to_string(),
2480        policy.route.clone().unwrap_or_else(|| "*".to_string()),
2481    );
2482    hints.insert("runtime".to_string(), policy.emission.as_attr().to_string());
2483    hints.insert("profile".to_string(), policy.profile.as_attr().to_string());
2484    hints.insert("configCount".to_string(), configs.len().to_string());
2485    hints
2486}
2487
2488fn push_textfx_budget_violation(
2489    violations: &mut Vec<TextFxOutputViolation>,
2490    field: &str,
2491    actual: usize,
2492    budget: Option<usize>,
2493) {
2494    if let Some(budget) = budget
2495        && actual > budget
2496    {
2497        violations.push(TextFxOutputViolation {
2498            field: field.to_string(),
2499            actual,
2500            budget,
2501        });
2502    }
2503}
2504
2505fn stable_hash_hex<'a>(parts: impl IntoIterator<Item = &'a str>) -> String {
2506    let mut hash = 0xcbf29ce484222325u64;
2507    for part in parts {
2508        for byte in part.as_bytes() {
2509            hash ^= u64::from(*byte);
2510            hash = hash.wrapping_mul(0x100000001b3);
2511        }
2512        hash ^= 0xff;
2513        hash = hash.wrapping_mul(0x100000001b3);
2514    }
2515    format!("{hash:016x}")
2516}
2517
2518pub mod prelude {
2519    pub use crate::integration::*;
2520    pub use crate::{
2521        ReducedMotion, TextCfg, TextEase, TextEffect, TextFxCompatibilityMatrix,
2522        TextFxCompatibilityRow, TextFxConfig, TextFxDiagnostic, TextFxDiagnosticReport,
2523        TextFxDiagnosticSeverity, TextFxDiagnosticVerbosity, TextFxDirection, TextFxEasing,
2524        TextFxEffect, TextFxExplainReport, TextFxGpuBudget, TextFxInteropPolicy,
2525        TextFxLayoutReserve, TextFxManifestFragment, TextFxManifestPolicyHook, TextFxOutputBudget,
2526        TextFxOutputReport, TextFxOutputViolation, TextFxPlayback, TextFxPresetProfile,
2527        TextFxProfile, TextFxRenderPreference, TextFxRoutePolicy, TextFxRuntimeEmission,
2528        TextFxSerializationFormat, TextFxTiming, TextFxTrigger, TextProfile, TextSplit,
2529        TokenAction, TokenTarget, explain_textfx, fx, text_fx, textfx, textfx_cache_key,
2530        textfx_compatibility_matrix, textfx_diagnostics, textfx_manifest_fragment,
2531        textfx_native_port_hints, textfx_output_budget, textfx_output_report, textfx_route_policy,
2532    };
2533}
2534
2535impl Default for TextFxConfig {
2536    fn default() -> Self {
2537        Self::new("textfx", "")
2538    }
2539}
2540
2541fn textfx_default_value_object() -> &'static serde_json::Map<String, serde_json::Value> {
2542    static DEFAULT: OnceLock<serde_json::Map<String, serde_json::Value>> = OnceLock::new();
2543    DEFAULT.get_or_init(|| {
2544        serde_json::to_value(TextFxConfig::default())
2545            .expect("default TextFxConfig serializes")
2546            .as_object()
2547            .expect("default TextFxConfig serializes to an object")
2548            .clone()
2549    })
2550}
2551
2552pub fn escape_html(value: &str) -> String {
2553    value
2554        .replace('&', "&amp;")
2555        .replace('<', "&lt;")
2556        .replace('>', "&gt;")
2557}
2558
2559pub fn escape_attr(value: &str) -> String {
2560    escape_html(value)
2561        .replace('"', "&quot;")
2562        .replace('\'', "&#39;")
2563}
2564
2565fn sanitize_tag(tag: &str) -> &str {
2566    match tag {
2567        "h1" | "h2" | "h3" | "h4" | "p" | "span" | "strong" | "em" | "small" | "div" => tag,
2568        _ => "span",
2569    }
2570}
2571
2572fn parse_inline_marks_owned(source: String) -> MarkedText {
2573    if !source.contains("[[") {
2574        return MarkedText {
2575            clean_text: source,
2576            marks: Vec::new(),
2577        };
2578    }
2579    parse_inline_marks_impl(&source)
2580}
2581
2582pub fn parse_inline_marks(source: &str) -> MarkedText {
2583    if !source.contains("[[") {
2584        return MarkedText {
2585            clean_text: source.to_string(),
2586            marks: Vec::new(),
2587        };
2588    }
2589    parse_inline_marks_impl(source)
2590}
2591
2592fn parse_inline_marks_impl(source: &str) -> MarkedText {
2593    let mut clean_text = String::with_capacity(source.len());
2594    let mut marks = Vec::new();
2595    let mut rest = source;
2596    let mut word_count = 0usize;
2597
2598    while let Some(start) = rest.find("[[") {
2599        let before = &rest[..start];
2600        clean_text.push_str(before);
2601        word_count += count_words(before);
2602        let after_start = &rest[start + 2..];
2603        let Some(end) = after_start.find("]]") else {
2604            clean_text.push_str(&rest[start..]);
2605            return MarkedText { clean_text, marks };
2606        };
2607        let marker = &after_start[..end];
2608        if let Some((visible, name)) = marker.rsplit_once('|') {
2609            let char_start = clean_text.chars().count();
2610            let word_start = word_count;
2611            clean_text.push_str(visible);
2612            let word_len = count_words(visible).max(1);
2613            word_count += word_len;
2614            let char_end = clean_text.chars().count();
2615            marks.push(TokenMark {
2616                name: name.trim().to_string(),
2617                text: visible.to_string(),
2618                char_start,
2619                char_end,
2620                word_start,
2621                word_end: word_start + word_len.saturating_sub(1),
2622            });
2623        } else {
2624            clean_text.push_str(marker);
2625            word_count += count_words(marker);
2626        }
2627        rest = &after_start[end + 2..];
2628    }
2629    clean_text.push_str(rest);
2630
2631    MarkedText { clean_text, marks }
2632}
2633
2634fn count_words(value: &str) -> usize {
2635    value
2636        .split_whitespace()
2637        .filter(|part| !part.is_empty())
2638        .count()
2639}
2640
2641fn parse_fx_tokens(config: &mut TextFxConfig, fx: &str) -> Result<(), TextFxParseError> {
2642    for token in split_fx_tokens(fx) {
2643        parse_fx_token(config, &token)?;
2644    }
2645    Ok(())
2646}
2647
2648fn split_fx_tokens(fx: &str) -> Vec<String> {
2649    let mut tokens = Vec::new();
2650    let mut current = String::new();
2651    let mut paren_depth = 0usize;
2652    let mut quote: Option<char> = None;
2653
2654    for ch in fx.chars() {
2655        match ch {
2656            '\'' | '"' if quote == Some(ch) => {
2657                quote = None;
2658                current.push(ch);
2659            }
2660            '\'' | '"' if quote.is_none() => {
2661                quote = Some(ch);
2662                current.push(ch);
2663            }
2664            '(' if quote.is_none() => {
2665                paren_depth += 1;
2666                current.push(ch);
2667            }
2668            ')' if quote.is_none() => {
2669                paren_depth = paren_depth.saturating_sub(1);
2670                current.push(ch);
2671            }
2672            ch if ch.is_whitespace() && quote.is_none() && paren_depth == 0 => {
2673                if !current.trim().is_empty() {
2674                    tokens.push(current.trim().to_string());
2675                    current.clear();
2676                }
2677            }
2678            _ => current.push(ch),
2679        }
2680    }
2681    if !current.trim().is_empty() {
2682        tokens.push(current.trim().to_string());
2683    }
2684    tokens
2685}
2686
2687fn parse_fx_token(config: &mut TextFxConfig, token: &str) -> Result<(), TextFxParseError> {
2688    if let Some(value) = token.strip_prefix("enter:") {
2689        return parse_fx_phase_token(config, TextFxPhaseKind::Enter, value);
2690    }
2691    if let Some(value) = token.strip_prefix("exit:") {
2692        return parse_fx_phase_token(config, TextFxPhaseKind::Exit, value);
2693    }
2694
2695    match token {
2696        "split-words" => {
2697            config.split = TextSplit::Words;
2698            config.promote_for_runtime_text_motion();
2699            return Ok(());
2700        }
2701        "split-chars" | "split-letters" => {
2702            config.split = TextSplit::Chars;
2703            config.promote_for_runtime_text_motion();
2704            return Ok(());
2705        }
2706        "split-lines" => {
2707            config.split = TextSplit::Lines;
2708            config.promote_for_runtime_text_motion();
2709            return Ok(());
2710        }
2711        "on-hover" => {
2712            config.trigger = TextFxTrigger::Hover;
2713            return Ok(());
2714        }
2715        "on-click" => {
2716            config.trigger = TextFxTrigger::Click;
2717            return Ok(());
2718        }
2719        "on-visible" => {
2720            config.trigger = TextFxTrigger::Visible;
2721            return Ok(());
2722        }
2723        "on-load" => {
2724            config.trigger = TextFxTrigger::Load;
2725            return Ok(());
2726        }
2727        "on-word-hover" => {
2728            config.trigger = TextFxTrigger::WordHover;
2729            config.split = TextSplit::Words;
2730            config.promote_for_runtime_text_motion();
2731            return Ok(());
2732        }
2733        "on-word-click" => {
2734            config.trigger = TextFxTrigger::WordClick;
2735            config.split = TextSplit::Words;
2736            config.promote_for_runtime_text_motion();
2737            return Ok(());
2738        }
2739        "loop" => {
2740            config.playback.loop_mode = TextFxLoop::Infinite;
2741            return Ok(());
2742        }
2743        "reverse" => {
2744            config.playback.reverse = true;
2745            return Ok(());
2746        }
2747        "alternate" => {
2748            config.playback.alternate = true;
2749            return Ok(());
2750        }
2751        "yoyo" => {
2752            config.playback.yoyo = true;
2753            return Ok(());
2754        }
2755        "perf:css-first" | "perf:css" | "css-first" => {
2756            config.performance_profile = TextFxPerformanceProfile::CssFirst;
2757            return Ok(());
2758        }
2759        "perf:balanced" | "perf:balance" => {
2760            config.performance_profile = TextFxPerformanceProfile::Balanced;
2761            return Ok(());
2762        }
2763        "perf:exact" | "perf:visual-exact" | "visual-exact" => {
2764            config.performance_profile = TextFxPerformanceProfile::VisualExact;
2765            return Ok(());
2766        }
2767        "gpu:auto" | "gpu-auto" => {
2768            config.gpu_budget = TextFxGpuBudget::Auto;
2769            return Ok(());
2770        }
2771        "gpu:low-power" | "gpu-low-power" | "gpu:low" | "gpu-low" => {
2772            config.gpu_budget = TextFxGpuBudget::LowPower;
2773            return Ok(());
2774        }
2775        "gpu:normal" | "gpu-normal" => {
2776            config.gpu_budget = TextFxGpuBudget::Normal;
2777            return Ok(());
2778        }
2779        "gpu:exact" | "gpu-exact" => {
2780            config.gpu_budget = TextFxGpuBudget::Exact;
2781            return Ok(());
2782        }
2783        "render:auto" | "renderer:auto" | "render-auto" => {
2784            config.render_preference = TextFxRenderPreference::Auto;
2785            return Ok(());
2786        }
2787        "render:css-first" | "renderer:css-first" | "render-css-first" => {
2788            config.render_preference = TextFxRenderPreference::CssFirst;
2789            return Ok(());
2790        }
2791        "render:workertown"
2792        | "renderer:workertown"
2793        | "render:workertown-render"
2794        | "renderer:workertown-render"
2795        | "render-workertown" => {
2796            *config = config
2797                .clone()
2798                .with_render_preference(TextFxRenderPreference::WorkerTownRender);
2799            return Ok(());
2800        }
2801        "render:main-thread-fallback"
2802        | "renderer:main-thread-fallback"
2803        | "render-main-thread-fallback" => {
2804            config.render_preference = TextFxRenderPreference::MainThreadFallback;
2805            return Ok(());
2806        }
2807        "layout-reserve:off" | "layout-reserve-off" | "tlr:off" | "tlr-off" => {
2808            config.layout_reserve = TextFxLayoutReserve::Off;
2809            return Ok(());
2810        }
2811        "layout-reserve:auto" | "layout-reserve-auto" | "tlr:auto" | "tlr-auto" => {
2812            config.layout_reserve = TextFxLayoutReserve::Auto;
2813            return Ok(());
2814        }
2815        "layout-reserve:exact" | "layout-reserve-exact" | "tlr:exact" | "tlr-exact" => {
2816            config.layout_reserve = TextFxLayoutReserve::Exact;
2817            return Ok(());
2818        }
2819        "ease-in" => {
2820            config.timing.easing = TextFxEasing::EaseIn;
2821            return Ok(());
2822        }
2823        "ease-out" => {
2824            config.timing.easing = TextFxEasing::EaseOut;
2825            return Ok(());
2826        }
2827        "ease-in-out" => {
2828            config.timing.easing = TextFxEasing::EaseInOut;
2829            return Ok(());
2830        }
2831        "ease-linear" | "linear" => {
2832            config.timing.easing = TextFxEasing::Linear;
2833            return Ok(());
2834        }
2835        _ => {}
2836    }
2837
2838    if let Some(value) = token.strip_prefix("duration-") {
2839        config.timing.duration_ms = parse_u32(value, token)?;
2840        return Ok(());
2841    }
2842    if let Some(value) = token.strip_prefix("delay-") {
2843        config.timing.delay_ms = parse_u32(value, token)?;
2844        return Ok(());
2845    }
2846    if let Some(value) = token.strip_prefix("stagger-") {
2847        config.timing.stagger_ms = parse_u32(value, token)?;
2848        return Ok(());
2849    }
2850    if let Some(value) = token.strip_prefix("speed-") {
2851        config.timing.speed_ms = parse_u32(value, token)?.max(1);
2852        return Ok(());
2853    }
2854    if let Some(value) = token.strip_prefix("loop-") {
2855        config.playback.loop_mode = TextFxLoop::Count(parse_u16(value, token)?.max(1));
2856        return Ok(());
2857    }
2858    if let Some(selector) = token.strip_prefix("on-click:") {
2859        config.trigger = TextFxTrigger::SelectorClick {
2860            selector: selector.to_string(),
2861        };
2862        return Ok(());
2863    }
2864    if let Some(selector) = token.strip_prefix("on-hover:") {
2865        config.trigger = TextFxTrigger::SelectorHover {
2866            selector: selector.to_string(),
2867        };
2868        return Ok(());
2869    }
2870    if let Some(event) = token.strip_prefix("on-event:") {
2871        config.trigger = TextFxTrigger::Event {
2872            name: event.to_string(),
2873        };
2874        return Ok(());
2875    }
2876    if token.starts_with("target:") || token.starts_with("mark:") {
2877        let rule = parse_rule_token(token)?;
2878        config.add_target(rule.target, rule.action);
2879        return Ok(());
2880    }
2881
2882    if let Some(effect) = parse_effect_token(token) {
2883        config.effect = effect;
2884        if effect.needs_split() && config.split == TextSplit::None {
2885            config.split = TextSplit::Chars;
2886            config.promote_for_runtime_text_motion();
2887        }
2888        return Ok(());
2889    }
2890
2891    Err(TextFxParseError::new(format!(
2892        "unknown textfx token `{token}`"
2893    )))
2894}
2895
2896fn parse_fx_phase_token(
2897    config: &mut TextFxConfig,
2898    phase: TextFxPhaseKind,
2899    token: &str,
2900) -> Result<(), TextFxParseError> {
2901    match token {
2902        "split-words" => {
2903            config.phase_mut(phase).split = Some(TextSplit::Words);
2904            config.promote_for_runtime_text_motion();
2905            return Ok(());
2906        }
2907        "split-chars" | "split-letters" => {
2908            config.phase_mut(phase).split = Some(TextSplit::Chars);
2909            config.promote_for_runtime_text_motion();
2910            return Ok(());
2911        }
2912        "split-lines" => {
2913            config.phase_mut(phase).split = Some(TextSplit::Lines);
2914            config.promote_for_runtime_text_motion();
2915            return Ok(());
2916        }
2917        "split-none" => {
2918            config.phase_mut(phase).split = Some(TextSplit::None);
2919            return Ok(());
2920        }
2921        "reverse" => {
2922            let playback = TextFxPlayback {
2923                reverse: true,
2924                ..TextFxPlayback::default()
2925            };
2926            config.phase_mut(phase).playback = Some(playback);
2927            return Ok(());
2928        }
2929        "ease-in" => {
2930            config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::EaseIn);
2931            return Ok(());
2932        }
2933        "ease-out" => {
2934            config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::EaseOut);
2935            return Ok(());
2936        }
2937        "ease-in-out" => {
2938            config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::EaseInOut);
2939            return Ok(());
2940        }
2941        "ease-linear" | "linear" => {
2942            config.phase_mut(phase).timing_mut().easing = Some(TextFxEasing::Linear);
2943            return Ok(());
2944        }
2945        _ => {}
2946    }
2947
2948    if let Some(value) = token.strip_prefix("duration-") {
2949        config.phase_mut(phase).timing_mut().duration_ms = Some(parse_u32(value, token)?);
2950        return Ok(());
2951    }
2952    if let Some(value) = token.strip_prefix("delay-") {
2953        config.phase_mut(phase).timing_mut().delay_ms = Some(parse_u32(value, token)?);
2954        return Ok(());
2955    }
2956    if let Some(value) = token.strip_prefix("stagger-") {
2957        config.phase_mut(phase).timing_mut().stagger_ms = Some(parse_u32(value, token)?);
2958        return Ok(());
2959    }
2960    if let Some(value) = token.strip_prefix("speed-") {
2961        config.phase_mut(phase).timing_mut().speed_ms = Some(parse_u32(value, token)?.max(1));
2962        return Ok(());
2963    }
2964    if let Some(value) = token
2965        .strip_prefix("direction-")
2966        .or_else(|| token.strip_prefix("dir-"))
2967    {
2968        config.phase_mut(phase).direction = Some(parse_direction(value)?);
2969        return Ok(());
2970    }
2971    if let Some(effect) = parse_effect_token(token) {
2972        config.apply_phase_effect(phase, effect);
2973        return Ok(());
2974    }
2975
2976    Err(TextFxParseError::new(format!(
2977        "unknown textfx phase token `{token}`"
2978    )))
2979}
2980
2981fn parse_rule_token(token: &str) -> Result<TextFxChoreography, TextFxParseError> {
2982    let parts = split_colon_parts(token);
2983    if parts.len() < 3 {
2984        return Err(TextFxParseError::new(format!(
2985            "token `{token}` must include a target and at least one action"
2986        )));
2987    }
2988
2989    let target = if parts[0] == "target" {
2990        parse_target(parts[1])?
2991    } else if parts[0] == "mark" {
2992        if parts[1] == "others" {
2993            TokenTarget::Others
2994        } else {
2995            TokenTarget::Mark {
2996                name: parts[1].to_string(),
2997            }
2998        }
2999    } else {
3000        return Err(TextFxParseError::new(format!(
3001            "token `{token}` must start with target: or mark:"
3002        )));
3003    };
3004
3005    let mut action = TokenAction::default();
3006    for part in parts.iter().skip(2) {
3007        action = action.merge(parse_action(part)?);
3008    }
3009    Ok(TextFxChoreography { target, action })
3010}
3011
3012fn split_colon_parts(value: &str) -> Vec<&str> {
3013    let mut parts = Vec::new();
3014    let mut start = 0usize;
3015    let mut paren_depth = 0usize;
3016    let mut quote: Option<char> = None;
3017
3018    for (idx, ch) in value.char_indices() {
3019        match ch {
3020            '\'' | '"' if quote == Some(ch) => quote = None,
3021            '\'' | '"' if quote.is_none() => quote = Some(ch),
3022            '(' if quote.is_none() => paren_depth += 1,
3023            ')' if quote.is_none() => paren_depth = paren_depth.saturating_sub(1),
3024            ':' if quote.is_none() && paren_depth == 0 => {
3025                parts.push(&value[start..idx]);
3026                start = idx + 1;
3027            }
3028            _ => {}
3029        }
3030    }
3031    parts.push(&value[start..]);
3032    parts
3033}
3034
3035fn parse_target(value: &str) -> Result<TokenTarget, TextFxParseError> {
3036    if value == "all" {
3037        return Ok(TokenTarget::All);
3038    }
3039    if value == "others" {
3040        return Ok(TokenTarget::Others);
3041    }
3042    if let Some(inner) = value
3043        .strip_prefix("words(")
3044        .and_then(|v| v.strip_suffix(')'))
3045    {
3046        return parse_range_or_index(inner).map(|(start, end)| {
3047            if start == end {
3048                TokenTarget::Word { index: start }
3049            } else {
3050                TokenTarget::WordRange { start, end }
3051            }
3052        });
3053    }
3054    if let Some(inner) = value
3055        .strip_prefix("word(")
3056        .and_then(|v| v.strip_suffix(')'))
3057    {
3058        return Ok(TokenTarget::WordText {
3059            value: unquote(inner).to_string(),
3060        });
3061    }
3062    if let Some(inner) = value
3063        .strip_prefix("chars(")
3064        .and_then(|v| v.strip_suffix(')'))
3065    {
3066        return parse_range_or_index(inner)
3067            .map(|(start, end)| TokenTarget::CharRange { start, end });
3068    }
3069    if let Some(inner) = value
3070        .strip_prefix("contains(")
3071        .and_then(|v| v.strip_suffix(')'))
3072    {
3073        return Ok(TokenTarget::Contains {
3074            value: unquote(inner).to_string(),
3075        });
3076    }
3077    Err(TextFxParseError::new(format!("invalid target `{value}`")))
3078}
3079
3080fn parse_action(value: &str) -> Result<TokenAction, TextFxParseError> {
3081    if value == "stay" {
3082        return Ok(TokenAction::default().stay());
3083    }
3084    if value == "highlight" {
3085        return Ok(TokenAction::highlight());
3086    }
3087    if value == "underline-sweep" {
3088        return Ok(TokenAction {
3089            underline_sweep: true,
3090            ..TokenAction::default()
3091        });
3092    }
3093    if value == "live-contrast" {
3094        return Ok(TokenAction::live_contrast());
3095    }
3096    if value == "live-contrast-exclusion" {
3097        return Ok(TokenAction::live_contrast_mode(
3098            TextFxLiveContrast::Exclusion,
3099        ));
3100    }
3101    if value == "live-contrast-plus" {
3102        return Ok(TokenAction::live_contrast_mode(TextFxLiveContrast::Plus));
3103    }
3104    if value == "blur" {
3105        return Ok(TokenAction {
3106            blur: true,
3107            ..TokenAction::default()
3108        });
3109    }
3110    if value == "slide-away" {
3111        return Ok(TokenAction::slide_away(TextFxDirection::Left));
3112    }
3113    if let Some(direction) = value.strip_prefix("slide-away-") {
3114        return Ok(TokenAction::slide_away(parse_direction(direction)?));
3115    }
3116    if let Some(value) = value.strip_prefix("scale-") {
3117        return Ok(TokenAction::scale(parse_scale(value)?));
3118    }
3119    if let Some(value) = value.strip_prefix("fade-") {
3120        return Ok(TokenAction {
3121            opacity: Some(parse_fade(value)?),
3122            ..TokenAction::default()
3123        });
3124    }
3125    if let Some(value) = value.strip_prefix("delay-") {
3126        return Ok(TokenAction {
3127            delay_ms: Some(parse_u32(value, value)?),
3128            ..TokenAction::default()
3129        });
3130    }
3131    if let Some(value) = value.strip_prefix("stagger-") {
3132        return Ok(TokenAction {
3133            stagger_ms: Some(parse_u32(value, value)?),
3134            ..TokenAction::default()
3135        });
3136    }
3137    if let Some(value) = value.strip_prefix("color-[") {
3138        let value = value
3139            .strip_suffix(']')
3140            .ok_or_else(|| TextFxParseError::new("color token must end with ]"))?;
3141        return Ok(TokenAction {
3142            color: Some(value.to_string()),
3143            ..TokenAction::default()
3144        });
3145    }
3146    if let Some(inner) = value
3147        .strip_prefix("swap(")
3148        .and_then(|v| v.strip_suffix(')'))
3149    {
3150        return Ok(TokenAction::swap(unquote(inner)));
3151    }
3152    if let Some(inner) = value
3153        .strip_prefix("scramble-to(")
3154        .and_then(|v| v.strip_suffix(')'))
3155    {
3156        return Ok(TokenAction {
3157            scramble_to: Some(unquote(inner).to_string()),
3158            ..TokenAction::default()
3159        });
3160    }
3161    Err(TextFxParseError::new(format!("invalid action `{value}`")))
3162}
3163
3164fn parse_effect_token(token: &str) -> Option<TextFxEffect> {
3165    TextFxEffect::from_attr(token)
3166}
3167
3168fn parse_range_or_index(value: &str) -> Result<(usize, usize), TextFxParseError> {
3169    if let Some((start, end)) = value.split_once("..") {
3170        let start = parse_usize(start, value)?;
3171        let end = parse_usize(end.trim_start_matches('='), value)?;
3172        if start > end {
3173            return Err(TextFxParseError::new(format!(
3174                "invalid descending range `{value}`"
3175            )));
3176        }
3177        return Ok((start, end));
3178    }
3179    let index = parse_usize(value, value)?;
3180    Ok((index, index))
3181}
3182
3183fn parse_direction(value: &str) -> Result<TextFxDirection, TextFxParseError> {
3184    match value {
3185        "up" => Ok(TextFxDirection::Up),
3186        "right" => Ok(TextFxDirection::Right),
3187        "down" => Ok(TextFxDirection::Down),
3188        "left" => Ok(TextFxDirection::Left),
3189        _ => Err(TextFxParseError::new(format!(
3190            "invalid direction `{value}`"
3191        ))),
3192    }
3193}
3194
3195fn parse_scale(value: &str) -> Result<f32, TextFxParseError> {
3196    let number = parse_u32(value, value)?;
3197    Ok(number as f32 / 100.0)
3198}
3199
3200fn parse_fade(value: &str) -> Result<f32, TextFxParseError> {
3201    let number = parse_u32(value, value)?;
3202    Ok((number.min(100) as f32) / 100.0)
3203}
3204
3205fn parse_usize(value: &str, token: &str) -> Result<usize, TextFxParseError> {
3206    value
3207        .parse::<usize>()
3208        .map_err(|_| TextFxParseError::new(format!("invalid number in `{token}`")))
3209}
3210
3211fn parse_u32(value: &str, token: &str) -> Result<u32, TextFxParseError> {
3212    value
3213        .parse::<u32>()
3214        .map_err(|_| TextFxParseError::new(format!("invalid number in `{token}`")))
3215}
3216
3217fn parse_u16(value: &str, token: &str) -> Result<u16, TextFxParseError> {
3218    value
3219        .parse::<u16>()
3220        .map_err(|_| TextFxParseError::new(format!("invalid number in `{token}`")))
3221}
3222
3223fn unquote(value: &str) -> &str {
3224    value.trim().trim_matches('"').trim_matches('\'')
3225}
3226
3227#[cfg(test)]
3228mod tests {
3229    use super::*;
3230
3231    #[test]
3232    fn serializes_all_effects() {
3233        for effect in TextFxEffect::ALL {
3234            let json = TextFxConfig::new(effect.as_attr(), effect.label())
3235                .with_effect(effect)
3236                .to_json()
3237                .unwrap();
3238            assert!(json.contains(effect.as_attr()), "{json}");
3239        }
3240    }
3241
3242    #[test]
3243    fn effect_attr_lookup_matches_serialized_effect_ids() {
3244        for effect in TextFxEffect::ALL {
3245            assert_eq!(TextFxEffect::from_attr(effect.as_attr()), Some(effect));
3246            assert_eq!(parse_effect_token(effect.as_attr()), Some(effect));
3247        }
3248        assert_eq!(TextFxEffect::from_attr("blur_reveal"), None);
3249        assert_eq!(parse_effect_token("unknown"), None);
3250    }
3251
3252    #[test]
3253    fn fx_token_splitter_keeps_quoted_and_parenthesized_actions_together() {
3254        let tokens = split_fx_tokens(
3255            "split-words mark:hero:swap('instant resumes') target:contains(\"fast sites\"):stay",
3256        );
3257        assert_eq!(
3258            tokens,
3259            vec![
3260                "split-words",
3261                "mark:hero:swap('instant resumes')",
3262                "target:contains(\"fast sites\"):stay",
3263            ]
3264        );
3265    }
3266
3267    #[test]
3268    fn plain_text_construction_preserves_large_unmarked_text() {
3269        let text = "Plain text without inline marks. ".repeat(128);
3270        let config = TextFxConfig::new("plain", text.clone());
3271        assert_eq!(config.text, text);
3272        assert!(config.marks.is_empty());
3273
3274        let parsed = parse_inline_marks(&config.text);
3275        assert_eq!(parsed.clean_text, config.text);
3276        assert!(parsed.marks.is_empty());
3277    }
3278
3279    #[test]
3280    fn compact_payload_is_stable_and_stays_under_growth_budget() {
3281        let config = TextFxConfig::from_fx(
3282            "hero",
3283            "Build [[fast sites|focus]] with [[instant resumes|swap]]",
3284            concat!(
3285                "split-words on-word-click duration-700 stagger-18 ",
3286                "mark:focus:highlight mark:swap:swap('zero reloads') gpu:low-power"
3287            ),
3288        )
3289        .unwrap();
3290        let compact = config.to_compact_json().unwrap();
3291        let repeated = config.to_compact_json().unwrap();
3292        let full = config.to_json().unwrap();
3293
3294        assert_eq!(compact, repeated);
3295        assert!(compact.len() < full.len(), "{compact} >= full payload");
3296        assert!(
3297            compact.len() < 1200,
3298            "compact payload grew to {} bytes",
3299            compact.len()
3300        );
3301        assert_eq!(config.preferred_payload_json().unwrap(), compact);
3302        let compact_attr = config.data_attr().unwrap();
3303        let full_attr = format!(r#"data-dxt-textfx="{}""#, escape_attr(&full));
3304        assert!(compact_attr.len() < full_attr.len());
3305        assert!(compact_attr.len() < 3500);
3306    }
3307
3308    #[test]
3309    fn long_text_tokenization_and_compact_payload_stay_stable() {
3310        let mut text = String::with_capacity(16_384);
3311        for index in 0..512 {
3312            if index % 16 == 0 {
3313                text.push_str("[[important segment|focus]] ");
3314            } else {
3315                text.push_str("ordinary segment ");
3316            }
3317        }
3318
3319        let parsed = parse_inline_marks(&text);
3320        let reparsed = parse_inline_marks(&text);
3321        assert_eq!(parsed.clean_text, reparsed.clean_text);
3322        assert_eq!(parsed.marks, reparsed.marks);
3323        assert_eq!(parsed.marks.len(), 32);
3324
3325        let config = TextFxConfig::new("stress", text)
3326            .with_effect(TextFxEffect::Typewriter)
3327            .with_split(TextSplit::Words);
3328        let compact = config.to_compact_json().unwrap();
3329        let repeated = config.to_compact_json().unwrap();
3330        assert_eq!(compact, repeated);
3331        assert!(
3332            compact.len() < config.to_json().unwrap().len(),
3333            "compact payload should stay smaller than full JSON"
3334        );
3335    }
3336
3337    #[test]
3338    fn css_first_gradient_shift_can_loop_and_yoyo() {
3339        let config = TextFxConfig::new("gradient", "Gradient shift")
3340            .with_effect(TextFxEffect::GradientShift)
3341            .with_palette(["#111111", "#ffffff", "#ff7a1a"])
3342            .loop_infinite()
3343            .yoyo();
3344        assert!(config.is_css_first());
3345        let attrs = config.css_first_state_attrs().unwrap();
3346        assert!(attrs.contains("--dxt-iterations:infinite"));
3347        assert!(attrs.contains("--dxt-direction:alternate"));
3348        assert!(attrs.contains("--dxt-gradient-c:#ff7a1a"));
3349    }
3350
3351    #[test]
3352    fn showcase_profile_preserves_split_effects_for_runtime_loops() {
3353        let config = TextFxConfig::new("wave", "Wave motion")
3354            .with_effect(TextFxEffect::Wave)
3355            .with_profile(TextFxProfile::Showcase)
3356            .loop_infinite()
3357            .yoyo();
3358        assert_eq!(config.split, TextSplit::Chars);
3359        assert!(!config.is_css_first());
3360        assert!(config.is_css_first_split());
3361        assert!(config.is_css_first_renderable());
3362        assert_eq!(config.resume_trigger_attr(), None);
3363        assert!(
3364            config
3365                .data_attr()
3366                .unwrap()
3367                .contains("&quot;sp&quot;:&quot;chars&quot;")
3368        );
3369    }
3370
3371    #[test]
3372    fn split_showcase_effects_are_css_first_renderable() {
3373        for effect in [
3374            TextFxEffect::Stagger,
3375            TextFxEffect::Wave,
3376            TextFxEffect::Flip,
3377            TextFxEffect::Glitch,
3378            TextFxEffect::KerningExpand,
3379        ] {
3380            let config = TextFxConfig::new(effect.as_attr(), effect.label())
3381                .with_effect(effect)
3382                .with_profile(TextFxProfile::Showcase)
3383                .loop_infinite()
3384                .yoyo();
3385            assert!(config.is_css_first_split(), "{effect:?}");
3386            assert!(config.css_first_class().is_some(), "{effect:?}");
3387            assert!(
3388                config
3389                    .css_first_state_attrs()
3390                    .unwrap()
3391                    .contains("--dxt-iterations:infinite")
3392            );
3393            assert_eq!(config.resume_trigger_attr(), None);
3394        }
3395    }
3396
3397    #[test]
3398    fn whole_node_live_contrast_needs_no_runtime_trigger() {
3399        let config = TextFxConfig::new("contrast", "Readable")
3400            .with_effect(TextFxEffect::LiveContrast)
3401            .with_trigger(TextFxTrigger::Load);
3402        assert_eq!(config.resume_trigger_attr(), None);
3403        assert_eq!(
3404            config.live_contrast_attr().as_deref(),
3405            Some(r#"data-dxt-live-contrast="difference""#)
3406        );
3407    }
3408
3409    #[test]
3410    fn css_first_classification_includes_safe_single_node_effects() {
3411        for effect in [
3412            TextFxEffect::Fade,
3413            TextFxEffect::Slide,
3414            TextFxEffect::BlurReveal,
3415            TextFxEffect::Scale,
3416            TextFxEffect::MaskReveal,
3417            TextFxEffect::HighlightSweep,
3418            TextFxEffect::GradientShift,
3419        ] {
3420            let config = TextFxConfig::new(effect.as_attr(), effect.label()).with_effect(effect);
3421            assert!(config.is_css_first(), "{effect:?}");
3422        }
3423
3424        assert!(
3425            !TextFxConfig::new("typewriter", "Typewriter")
3426                .with_effect(TextFxEffect::Typewriter)
3427                .is_css_first()
3428        );
3429        assert!(
3430            !TextFxConfig::new("hover", "Hover")
3431                .with_effect(TextFxEffect::Fade)
3432                .on_hover()
3433                .is_css_first()
3434        );
3435    }
3436
3437    #[test]
3438    fn profiles_apply_runtime_defaults() {
3439        let lighthouse =
3440            TextFxConfig::profile("hero", "Fast first paint", TextFxProfile::Lighthouse);
3441        assert_eq!(lighthouse.trigger, TextFxTrigger::Load);
3442        assert_eq!(lighthouse.reduced_motion, ReducedMotion::Static);
3443        assert_eq!(
3444            lighthouse.performance_profile,
3445            TextFxPerformanceProfile::CssFirst
3446        );
3447        assert_eq!(lighthouse.gpu_budget, TextFxGpuBudget::Auto);
3448        assert_eq!(lighthouse.timing.duration_ms, 360);
3449        assert!(lighthouse.is_css_first());
3450
3451        let interactive = TextFxConfig::profile("cta", "Click me", TextFxProfile::Interactive);
3452        assert_eq!(interactive.trigger, TextFxTrigger::Interaction);
3453        assert_eq!(
3454            interactive.performance_profile,
3455            TextFxPerformanceProfile::Balanced
3456        );
3457        assert_eq!(interactive.gpu_budget, TextFxGpuBudget::Auto);
3458        assert_eq!(interactive.timing.stagger_ms, 18);
3459        assert!(!interactive.is_css_first());
3460
3461        let showcase = TextFxConfig::profile("demo", "Loop", TextFxProfile::Showcase);
3462        assert_eq!(showcase.gpu_budget, TextFxGpuBudget::Exact);
3463    }
3464
3465    #[test]
3466    fn performance_profile_serializes_and_parses() {
3467        let defaulted = TextFxConfig::new("hero", "Fast text");
3468        assert_eq!(
3469            defaulted.performance_profile,
3470            TextFxPerformanceProfile::CssFirst
3471        );
3472        let balanced = TextFxConfig::from_fx(
3473            "hero",
3474            "Build [[fast|focus]]",
3475            "perf:balanced mark:focus:scale-150",
3476        )
3477        .unwrap();
3478        assert_eq!(
3479            balanced.performance_profile,
3480            TextFxPerformanceProfile::Balanced
3481        );
3482        assert_eq!(balanced.split, TextSplit::Words);
3483        let exact = TextFxConfig::from_fx("hero", "Exact", "perf:exact fade").unwrap();
3484        assert_eq!(
3485            exact.performance_profile,
3486            TextFxPerformanceProfile::VisualExact
3487        );
3488        let attr = exact.html_attrs().unwrap();
3489        assert!(attr.contains(r#"data-dxt-performance="visual-exact""#));
3490        assert!(
3491            exact
3492                .to_compact_json()
3493                .unwrap()
3494                .contains(r#""pf":"visual-exact""#)
3495        );
3496    }
3497
3498    #[test]
3499    fn gpu_budget_serializes_and_parses() {
3500        let config =
3501            TextFxConfig::from_fx("hero", "GPU budget", "fade gpu:low-power perf:balanced")
3502                .unwrap();
3503        assert_eq!(config.gpu_budget, TextFxGpuBudget::LowPower);
3504        assert_eq!(
3505            config.performance_profile,
3506            TextFxPerformanceProfile::Balanced
3507        );
3508        let json = config.to_compact_json().unwrap();
3509        assert!(json.contains(r#""gb":"low-power""#));
3510        let attr = config.html_attrs().unwrap();
3511        assert!(attr.contains(r#"data-dxt-gpu-budget="low-power""#));
3512
3513        let exact = TextFxConfig::from_fx("hero", "Exact", "gpu-exact").unwrap();
3514        assert_eq!(exact.gpu_budget, TextFxGpuBudget::Exact);
3515    }
3516
3517    #[test]
3518    fn workertown_render_preference_is_explicit_and_route_scoped() {
3519        let defaulted = TextFxConfig::new("hero", "Static text");
3520        assert_eq!(defaulted.render_preference, TextFxRenderPreference::Auto);
3521        assert!(!defaulted.requires_workertown_render());
3522
3523        let worker = TextFxConfig::from_fx(
3524            "hero",
3525            "Worker rendered text",
3526            "highlight-sweep render:workertown",
3527        )
3528        .unwrap();
3529        assert_eq!(
3530            worker.render_preference,
3531            TextFxRenderPreference::WorkerTownRender
3532        );
3533        assert_eq!(
3534            worker.performance_profile,
3535            TextFxPerformanceProfile::VisualExact
3536        );
3537        assert_eq!(worker.gpu_budget, TextFxGpuBudget::Exact);
3538        assert!(worker.requires_workertown_render());
3539        assert!(
3540            worker
3541                .html_attrs()
3542                .unwrap()
3543                .contains(r#"data-dxt-renderer="workertown-render""#)
3544        );
3545        assert!(
3546            worker
3547                .to_compact_json()
3548                .unwrap()
3549                .contains(r#""rp":"workertown-render""#)
3550        );
3551    }
3552
3553    #[test]
3554    fn layout_reserve_serializes_compact_target_metadata_and_fx_tokens() {
3555        let defaulted = TextFxConfig::new("hero", "Stable text");
3556        assert_eq!(defaulted.layout_reserve, TextFxLayoutReserve::Auto);
3557        assert!(defaulted.reserves_layout());
3558        assert!(!defaulted.to_compact_json().unwrap().contains(r#""tlr""#));
3559        assert!(
3560            defaulted
3561                .html_attrs()
3562                .unwrap()
3563                .contains(r#"data-dxr-text-layout-target="auto""#)
3564        );
3565
3566        let exact = TextFxConfig::from_fx("hero", "Exact reserve", "fade tlr:exact").unwrap();
3567        assert_eq!(exact.layout_reserve, TextFxLayoutReserve::Exact);
3568        assert!(
3569            exact
3570                .to_compact_json()
3571                .unwrap()
3572                .contains(r#""tlr":"exact""#)
3573        );
3574        assert!(
3575            exact
3576                .html_attrs()
3577                .unwrap()
3578                .contains(r#"data-dxr-text-layout-target="exact""#)
3579        );
3580
3581        let off = TextFxConfig::new("hero", "No reserve").layout_reserve_off();
3582        assert_eq!(off.layout_reserve, TextFxLayoutReserve::Off);
3583        assert!(!off.reserves_layout());
3584        assert!(off.to_compact_json().unwrap().contains(r#""tlr":"off""#));
3585        assert!(
3586            !off.html_attrs()
3587                .unwrap()
3588                .contains("data-dxr-text-layout-target")
3589        );
3590    }
3591
3592    #[test]
3593    fn compact_data_attr_keeps_textfx_contract() {
3594        let config = TextFxConfig::new("hero", "Readable first paint")
3595            .with_effect(TextFxEffect::BlurReveal)
3596            .with_profile(TextFxProfile::Lighthouse);
3597        let attr = config.data_attr().unwrap();
3598        assert!(attr.starts_with("data-dxt-textfx="));
3599        assert!(attr.contains("&quot;v&quot;:1"));
3600        assert!(attr.contains("&quot;e&quot;:&quot;br&quot;"));
3601        assert!(attr.len() < config.to_json().unwrap().len() + "data-dxt-textfx=\"\"".len());
3602    }
3603
3604    #[test]
3605    fn locale_data_attr_uses_locale_specific_contract() {
3606        for effect in TextFxEffect::ALL {
3607            let attr = TextFxConfig::new(effect.as_attr(), effect.label())
3608                .with_effect(effect)
3609                .locale_data_attr()
3610                .unwrap();
3611            assert!(attr.starts_with("data-dxt-locale-fx="));
3612            assert!(!attr.contains("data-dxt-textfx"));
3613            assert!(attr.contains(effect.compact_id()) || attr.contains(effect.as_attr()));
3614        }
3615    }
3616
3617    #[test]
3618    fn lifecycle_builders_serialize_compact_enter_and_exit_phases() {
3619        let config = TextFxConfig::new("route-title", "Route")
3620            .with_effect(TextFxEffect::Flip)
3621            .with_duration_ms(520)
3622            .with_stagger_ms(18)
3623            .with_enter_delay_ms(30)
3624            .with_exit_duration_ms(260)
3625            .with_exit_stagger_ms(8)
3626            .with_exit_reverse_of_enter();
3627
3628        assert_eq!(
3629            config
3630                .lifecycle
3631                .exit
3632                .as_ref()
3633                .and_then(|phase| phase.playback.as_ref())
3634                .map(|playback| playback.reverse),
3635            Some(true)
3636        );
3637        let compact = config.to_compact_json().unwrap();
3638        assert!(compact.contains(r#""en":"#), "{compact}");
3639        assert!(compact.contains(r#""ex":"#), "{compact}");
3640        assert!(compact.contains(r#""delayMs":30"#), "{compact}");
3641        assert!(compact.contains(r#""durationMs":260"#), "{compact}");
3642    }
3643
3644    #[test]
3645    fn lifecycle_fx_tokens_parse_phase_overrides_without_changing_base_timing() {
3646        let config = TextFxConfig::from_fx(
3647            "hero",
3648            "Tabbed title",
3649            concat!(
3650                "flip duration-520 stagger-18 enter:delay-80 exit:",
3651                "blur",
3652                "-reveal exit:duration-240 exit:reverse"
3653            ),
3654        )
3655        .unwrap();
3656
3657        assert_eq!(config.effect, TextFxEffect::Flip);
3658        assert_eq!(config.timing.duration_ms, 520);
3659        assert_eq!(config.timing.stagger_ms, 18);
3660        assert_eq!(
3661            config
3662                .lifecycle
3663                .enter
3664                .as_ref()
3665                .and_then(|phase| phase.timing.as_ref())
3666                .and_then(|timing| timing.delay_ms),
3667            Some(80)
3668        );
3669        assert_eq!(
3670            config
3671                .lifecycle
3672                .exit
3673                .as_ref()
3674                .and_then(|phase| phase.effect),
3675            Some(TextFxEffect::BlurReveal)
3676        );
3677        assert_eq!(
3678            config
3679                .lifecycle
3680                .exit
3681                .as_ref()
3682                .and_then(|phase| phase.timing.as_ref())
3683                .and_then(|timing| timing.duration_ms),
3684            Some(240)
3685        );
3686        assert_eq!(
3687            config
3688                .lifecycle
3689                .exit
3690                .as_ref()
3691                .and_then(|phase| phase.playback.as_ref())
3692                .map(|playback| playback.reverse),
3693            Some(true)
3694        );
3695    }
3696
3697    #[test]
3698    fn configs_without_lifecycle_keep_existing_compact_shape() {
3699        let compact = TextFxConfig::new("hero", "Stable")
3700            .with_effect(TextFxEffect::BlurReveal)
3701            .to_compact_json()
3702            .unwrap();
3703        assert!(!compact.contains(r#""en""#), "{compact}");
3704        assert!(!compact.contains(r#""ex""#), "{compact}");
3705        assert!(!compact.contains("lifecycle"), "{compact}");
3706    }
3707
3708    #[test]
3709    fn default_timing_matches_package_contract() {
3710        let timing = TextFxTiming::default();
3711        assert_eq!(timing.duration_ms, 640);
3712        assert_eq!(timing.stagger_ms, 28);
3713        assert_eq!(timing.easing, TextFxEasing::EaseOut);
3714        assert_eq!(ReducedMotion::default(), ReducedMotion::FadeOnly);
3715    }
3716
3717    #[test]
3718    fn static_html_keeps_semantic_text_and_no_script() {
3719        let html = TextFxConfig::new("hero", "Readable first paint")
3720            .with_effect(TextFxEffect::Typewriter)
3721            .static_html("h1", "")
3722            .unwrap();
3723        assert!(html.contains("Readable first paint"));
3724        assert!(html.contains("data-dxt-textfx"));
3725        assert!(html.contains("data-dxr-on-visible=\"textfx.run\""));
3726        assert!(!html.contains("aria-label="));
3727        assert!(!html.contains("<script"));
3728        assert!(!html.contains("modulepreload"));
3729    }
3730
3731    #[test]
3732    fn html_attrs_do_not_name_generic_textfx_elements() {
3733        let attrs = TextFxConfig::new("hero", "Readable first paint")
3734            .with_effect(TextFxEffect::Wave)
3735            .html_attrs()
3736            .unwrap();
3737        assert!(attrs.contains("data-dxt-textfx"));
3738        assert!(!attrs.contains("aria-label="));
3739    }
3740
3741    #[test]
3742    fn trigger_attrs_match_resume_events() {
3743        assert_eq!(
3744            TextFxTrigger::Visible.resume_attr().as_deref(),
3745            Some(r#"data-dxr-on-visible="textfx.run""#)
3746        );
3747        assert_eq!(
3748            TextFxTrigger::Hover.resume_attr().as_deref(),
3749            Some(r#"data-dxr-on-pointerover="textfx.run""#)
3750        );
3751        assert_eq!(
3752            TextFxTrigger::WordClick.resume_attr().as_deref(),
3753            Some(r#"data-dxr-on-click="textfx.run""#)
3754        );
3755        assert_eq!(TextFxTrigger::Manual.resume_attr(), None);
3756    }
3757
3758    #[test]
3759    fn parses_inline_marks_into_clean_text() {
3760        let marked = parse_inline_marks("Build [[fast websites|focus]] with [[zero reloads|swap]]");
3761        assert_eq!(marked.clean_text, "Build fast websites with zero reloads");
3762        assert_eq!(marked.marks.len(), 2);
3763        assert_eq!(marked.marks[0].name, "focus");
3764        assert_eq!(marked.marks[0].word_start, 1);
3765        assert_eq!(marked.marks[0].word_end, 2);
3766    }
3767
3768    #[test]
3769    fn content_setter_reparses_inline_marks() {
3770        let config = TextFxConfig::new("headline", "Draft").content("Launch [[ready|focus]]");
3771
3772        assert_eq!(config.text, "Launch ready");
3773        assert_eq!(config.marks.len(), 1);
3774        assert_eq!(config.marks[0].name, "focus");
3775    }
3776
3777    #[test]
3778    fn parses_tailwind_like_target_tokens() {
3779        let config = TextFxConfig::from_fx(
3780            "hero",
3781            "Build fast websites with zero reloads",
3782            "split-words on-hover target:words(1..2):scale-150:stay target:others:slide-away-left duration-700 ease-in-out loop-3",
3783        )
3784        .unwrap();
3785        assert_eq!(config.split, TextSplit::Words);
3786        assert_eq!(config.trigger, TextFxTrigger::Hover);
3787        assert_eq!(config.timing.duration_ms, 700);
3788        assert_eq!(config.playback.loop_mode, TextFxLoop::Count(3));
3789        assert_eq!(config.choreography.len(), 2);
3790        assert!(matches!(
3791            config.choreography[0].target,
3792            TokenTarget::WordRange { start: 1, end: 2 }
3793        ));
3794        assert_eq!(config.choreography[0].action.scale, Some(1.5));
3795        assert!(config.choreography[0].action.stay);
3796    }
3797
3798    #[test]
3799    fn parses_mark_swap_tokens() {
3800        let config = TextFxConfig::from_fx(
3801            "hero",
3802            "Build [[fast websites|focus]] with [[zero reloads|swap]]",
3803            "on-word-click mark:focus:highlight mark:swap:swap('instant resumes')",
3804        )
3805        .unwrap();
3806        assert_eq!(config.text, "Build fast websites with zero reloads");
3807        assert_eq!(config.marks.len(), 2);
3808        assert_eq!(config.trigger, TextFxTrigger::WordClick);
3809        assert_eq!(
3810            config.choreography[1].action.swap.as_deref(),
3811            Some("instant resumes")
3812        );
3813    }
3814
3815    #[test]
3816    fn parses_live_contrast_effect_and_token_actions() {
3817        let config = TextFxConfig::from_fx(
3818            "contrast",
3819            "Only [[these words|focus]] adapt live",
3820            "live-contrast mark:focus:live-contrast-exclusion",
3821        )
3822        .unwrap();
3823        assert_eq!(config.effect, TextFxEffect::LiveContrast);
3824        assert_eq!(
3825            config.choreography[0].action.live_contrast,
3826            Some(TextFxLiveContrast::Exclusion)
3827        );
3828        assert!(config.to_json().unwrap().contains("liveContrast"));
3829        assert_eq!(
3830            config.live_contrast_attr().as_deref(),
3831            Some(r#"data-dxt-live-contrast="difference""#)
3832        );
3833    }
3834
3835    #[test]
3836    fn route_policy_manifest_and_budget_report_batch_configs() {
3837        let headline = TextFxConfig::new("headline", "Launch ready")
3838            .scramble()
3839            .route_profile(TextFxPresetProfile::Expressive);
3840        let kicker = TextFxConfig::new("kicker", "Fast")
3841            .fade()
3842            .route_profile(TextFxPresetProfile::Conservative);
3843        let policy = textfx_route_policy()
3844            .route("/textfx")
3845            .profile(TextFxPresetProfile::Expressive)
3846            .emission(TextFxRuntimeEmission::WhenNeeded)
3847            .budget(textfx_output_budget().config_bytes(4).effect_count(3))
3848            .label("owner", "copy-motion")
3849            .tag("hero");
3850
3851        let manifest = textfx_manifest_fragment([&headline, &kicker], &policy);
3852        let report = textfx_output_report([&headline, &kicker], &policy);
3853        let hints = textfx_native_port_hints([&headline, &kicker], &policy);
3854
3855        assert_eq!(manifest.package, TEXTFX_PACKAGE_NAME);
3856        assert_eq!(manifest.route.as_deref(), Some("/textfx"));
3857        assert_eq!(manifest.config_count, 2);
3858        assert_eq!(
3859            manifest.ids,
3860            vec!["headline".to_string(), "kicker".to_string()]
3861        );
3862        assert_eq!(manifest.metrics["effectCount"], 2);
3863        assert_eq!(hints["configCount"], "2");
3864        assert!(
3865            report
3866                .violations
3867                .iter()
3868                .any(|violation| violation.field == "configBytes")
3869        );
3870        assert_eq!(
3871            textfx_cache_key([&headline, &kicker], Some("/textfx"), None),
3872            textfx_cache_key([&headline, &kicker], Some("/textfx"), None)
3873        );
3874    }
3875
3876    #[test]
3877    fn explain_report_diagnostics_and_hook_cover_interop() {
3878        struct DropDisabled;
3879
3880        impl TextFxManifestPolicyHook for DropDisabled {
3881            fn apply(&self, fragment: TextFxManifestFragment) -> Option<TextFxManifestFragment> {
3882                fragment.enabled.then_some(fragment)
3883            }
3884        }
3885
3886        let config = TextFxConfig::new("tooltip", "Helpful copy").typewriter();
3887        let enabled_policy = textfx_route_policy().route("/textfx").tag("hoverfx");
3888        let disabled_policy = textfx_route_policy()
3889            .route("/textfx/off")
3890            .enabled(false)
3891            .emission(TextFxRuntimeEmission::Disabled);
3892        let explain = explain_textfx([&config], &enabled_policy);
3893        let matrix = textfx_compatibility_matrix();
3894
3895        assert!(explain.diagnostics.is_valid());
3896        assert!(explain.notes.iter().any(|note| note.contains("HoverFX")));
3897        assert!(matrix.rows.iter().any(|row| row.target == "native"));
3898        assert!(apply_textfx_manifest_hook([&config], &enabled_policy, &DropDisabled).is_some());
3899        assert!(apply_textfx_manifest_hook([&config], &disabled_policy, &DropDisabled).is_none());
3900    }
3901}