1use 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('&', "&")
2555 .replace('<', "<")
2556 .replace('>', ">")
2557}
2558
2559pub fn escape_attr(value: &str) -> String {
2560 escape_html(value)
2561 .replace('"', """)
2562 .replace('\'', "'")
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(""sp":"chars"")
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(""v":1"));
3600 assert!(attr.contains(""e":"br""));
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}