@vectoriox/iox-ui 5.71.2 → 5.72.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts CHANGED
@@ -2206,6 +2206,12 @@ declare enum VisibilityActionType {
2206
2206
  Hide = "hide",
2207
2207
  Toggle = "toggleVisibility"
2208
2208
  }
2209
+ /** The reveals (premium sites SC3): a wipe from a chosen side, a picture settling, text rising line by line. */
2210
+ declare enum RevealActionType {
2211
+ Clip = "clipReveal",
2212
+ Zoom = "zoomOut",
2213
+ Text = "revealText"
2214
+ }
2209
2215
  /** Actions that reveal an element (hidden → visible). Their FIRST frame is the pre-state. */
2210
2216
  declare const ENTRANCE_ACTION_TYPES: ReadonlySet<string>;
2211
2217
  /** Actions that hide an element (visible → hidden). */
@@ -2220,6 +2226,19 @@ declare function effectiveActionType(type: string, isHidden: boolean): string;
2220
2226
  * directly it answers with `hide`'s frames, the toggle of an element that starts visible.
2221
2227
  */
2222
2228
  declare function buildInteractionKeyframes(type: string, params?: Readonly<Record<string, unknown>> | null): InteractionKeyframe[] | null;
2229
+ interface AnimateActionOptions {
2230
+ /** The frames are `buildInteractionKeyframes`' reversed: a reverse pass never splits text, it just fades back. */
2231
+ reverse?: boolean;
2232
+ }
2233
+ /**
2234
+ * Start an element action's animation: THE call both runtimes make. Most actions are one `element.animate`; a text
2235
+ * reveal plays its pieces and a clip reveal opens from the side the page's direction makes of Start and End, then takes
2236
+ * its clip away so a resting element never crops its own shadow. Each still comes back as one Animation.
2237
+ */
2238
+ declare function animateAction(el: HTMLElement, action: {
2239
+ type: string;
2240
+ params?: Readonly<Record<string, unknown>> | null;
2241
+ }, frames: ReadonlyArray<InteractionKeyframe>, timing: KeyframeAnimationOptions, options?: AnimateActionOptions): Animation;
2223
2242
  /** `translateX` → `translate-x`, `opacity` → `opacity`: the CSS property name of a keyframe key. */
2224
2243
  declare function keyframePropertyName(key: string): string;
2225
2244
  /** A keyframe as `[css-property, value]` pairs — what a runtime writes inline. */
@@ -2249,8 +2268,11 @@ declare const ENTRANCE_PRESTATE_ATTRIBUTE = "data-iox-enter";
2249
2268
  */
2250
2269
  declare const ENTRANCE_FALLBACK_CSS: string;
2251
2270
  /** The same, for a visitor with no script: every pre-stated element shown at once (served inside `<noscript>`). */
2252
- declare const ENTRANCE_NOSCRIPT_CSS = "[data-iox-enter]{opacity:1!important;transform:none!important;visibility:visible!important;pointer-events:auto!important;animation:none!important}";
2253
- /** The frame an animation comes to rest on (what reduced motion applies at once). */
2271
+ declare const ENTRANCE_NOSCRIPT_CSS = "[data-iox-enter]{opacity:1!important;transform:none!important;clip-path:none!important;visibility:visible!important;pointer-events:auto!important;animation:none!important}";
2272
+ /**
2273
+ * The frame an animation comes to rest on (what reduced motion applies at once). A clip that has fully opened rests as
2274
+ * no clip at all: `inset(0)` would still crop the element's shadow and stack it, for the rest of the visit.
2275
+ */
2254
2276
  declare function finalKeyframe(frames: ReadonlyArray<InteractionKeyframe> | null | undefined): InteractionKeyframe | null;
2255
2277
 
2256
2278
  /**
@@ -3196,5 +3218,5 @@ declare class GalleryLoop {
3196
3218
  private emit;
3197
3219
  }
3198
3220
 
3199
- export { A11yAttribute, A11yTrait, ACCESSIBLE_NAME_TYPES, AOSService, ARIA_CURRENT_PAGE, BACKDROP_TWIN_KEY, BACKDROP_WHOLE_KEY, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, COMPONENT_STATE_SELECTOR, CONTAINER_HTML_TAGS, CONTEXT_ACTION_TYPES, CURRENT_LINK_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ComponentState, ContainerComponent, ContextStore, DECORATIVE_TYPES, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_PHOSPHOR_WEIGHT, DEFAULT_WIDTH_BY_TYPE, DIALOG_CLOSE_ABOVE_ATTRIBUTE, DIALOG_NODE_TYPE, DeclarationContext, ENTRANCE_ACTION_TYPES, ENTRANCE_FALLBACK_CSS, ENTRANCE_NOSCRIPT_CSS, ENTRANCE_PRESTATE_ATTRIBUTE, EXIT_ACTION_TYPES, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, HOVER_DEFAULT_TRANSITION, HOVER_STATES, INTERACTION_HIDDEN_ATTRIBUTE, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxDialogComponent, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, IoxVideoComponent, LAST_EMITTED_STATES, LEGACY_DIRECTION_KEY, LEGACY_HOVER_TRANSITION, LEGACY_SWIPER_CONTROLS, LinkedContainerComponent, MANAGED_A11Y_ATTRIBUTES, MAX_LINES_KEY, NAMED_GROUP_ROLES, NO_PAGE_TRANSITION, ON_STATE_ROLES, PAGE_STYLE_TOKENS, PAGE_TOKEN_FALLBACKS, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PART_CLASS_NAMES, PART_PAGINATION_MODIFIER, PHONE_TRANSITION_QUERY, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PageStyleField, PageStyleSource, REDUCED_MOTION_QUERY, SCROLL_EASINGS, SELF_PLAYING_TRIGGERS, SLIDER_INPUTS_ATTRIBUTE, SLIDER_INPUT_NAMES, STATE_PSEUDO_CLASS, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_A11Y_OPTIONS, SWIPER_RESPONSIVE_TRAITS, SWIPER_TRIGGER_EVENTS, ScrollEasing, SectionComponent, SwiperModuleName, TARGETED_STATE_SUFFIX, TEXT_ALIGN_KEY, TEXT_DIRECTION_KEY, TRANSITION_FEELS, TextAlign, TextBlockComponent, VALIDATION_STATES, VALIDATION_STATE_CLASS, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, VisibilityActionType, activeActions, applyBindingPipes, applyFeel, bootSlider, breakpointDef, breakpointForWidth, breakpointQueryFor, buildInteractionKeyframes, buildPartSelector, buildSwiperBreakpoints, buildTargetedStateSelector, buildTargetedSuffixSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, closeAboveMediaQuery, collectContextSlotKeys, compileDeclarations, compilePageStyle, componentStatesFor, composeVirtualTraits, containerHtmlTag, contextActionEffect, contextSlotKeys, countOverrides, declaredInputNames, decomposeBackdropFilter, defaultHoverTransition, defaultMessageResolver, deriveSeoFromContent, describePageStyle, describeSeoFields, dialogCloseAboveMinWidth, domHasSliderParts, effectiveActionType, effectiveTransitionId, entrancePreStateStyle, entranceStartsHidden, feelFor, finalKeyframe, getSeoValue, hasHoverState, hasOwnPageTransition, hasOwnTransition, initialSlotValues, isActionEnabled, isContextAction, isCurrentLink, isExternalOrFragment, isGradientPaint, isInputOverriddenAt, isLastEmittedStateKey, isOverriddenAt, isValidSlotKey, isWholeBackdropSet, keyframePropertyName, keyframeStyleEntries, legacyEnterToTransition, mediaQueryFor, mergeSwiperBreakpoints, migrateLegacyDirection, migrateLegacyTextAlign, motionAllowed, nearestCommonAncestorId, needsAccessibleName, needsGroupName, nodeA11yAttributes, nodeCssId, nodeTraitValue, nodeUsesContext, normalizeAppPath, normalizeGalleryImage, normalizeIconClass, orderStatesForEmit, pageTransitionOf, parentBreakpoint, parseUrlList, partClassName, planResponsiveRules, planScroll, planSwiperOptions, playDualLayerTransition, prefersReducedMotion, renderDefaultDeclarations, resetPageStyleField, resetSeoField, resolveBoundValue, resolveContextValue, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveSeo, resolveStateStyles, resolveStyleProps, resolveToggleVisibility, resolveValueChain, rewriteViewportUnits, safeSetInput, serializeA11yAttributes, setInputOverride, setOverride, setSeoAuto, setSeoBinding, setSeoOverride, setSeoValue, sliderControlElements, sliderInputsFromProps, sliderInputsOf, stateNamesIn, statePseudoClass, stateSelectorSuffix, stripSuppressedProps, swiperAutoplayAllowed, swiperLoopOptions, swiperModuleNames, syncNodeA11yAttributes, targetedStateSelector, viewportTransitionContext, withContextSlots, withDefaultHoverTransition, wrapInBreakpointQuery, wrapInMediaQuery };
3200
- export type { A11yNodeSource, BoundValueSpec, BreakpointDef, ContextActionEffect, ContextActionLike, ContextItemRef, ContextSlotDefault, ContextValueSpec, ContextWriteScope, CurrentLinkOptions, FormMessageResolver, GalleryBoundSource, GalleryDurations, GalleryEffect, GalleryEffectDescriptor, GalleryImage, GalleryLoopOptions, GalleryLoopScheduler, GalleryLoopState, GallerySlideState, IBuilderEventEmitter, InputMap, InteractionKeyframe, IoxContextSlot, IoxPipe, MatchMediaHost, NodeA11yContext, OverrideBreakpoint, PageScrollStart, PageStyleFieldState, PageStyleOptions, PageStyleSettings, PageTransitionCategory, PageTransitionCategoryMeta, PageTransitionContext, PageTransitionOptions, PageTransitionPreset, PipeArgSpec, PipeContext, PipeDescriptor, PlannedPageTransition, ResolvedPageTransition, ResponsiveLayer, ResponsiveOverrides, ResponsiveRuleSet, RouteAnimationLike, ScrollPlanContext, SeoDerivedContent, SeoFieldSource, SeoFieldState, SeoResolveContext, SetContextParams, SliderBootOptions, SliderControlElements, SliderInputs, StateStyleMap, StyleMap, StyleTreeNode, SwiperBreakpointMap, SwiperModuleFlags, SwiperPlan, SwiperPlanContext, ToggleableAction, TransitionFeel, TransitionPhase, TransitionPreviewLayers, TransitionTimelineInput, TransitionViewport, ValueSource, VirtualTraitGroup };
3221
+ export { A11yAttribute, A11yTrait, ACCESSIBLE_NAME_TYPES, AOSService, ARIA_CURRENT_PAGE, BACKDROP_TWIN_KEY, BACKDROP_WHOLE_KEY, BREAKPOINTS, Breakpoint, BreakpointQueryKind, BuilderButtonComponent, BuilderDividerComponent, BuilderHeadingComponent, BuilderIconComponent, BuilderImageComponent, BuilderLinkComponent, BuilderSpacerComponent, ButtonBlockComponent, COMPONENT_STATE_SELECTOR, CONTAINER_HTML_TAGS, CONTEXT_ACTION_TYPES, CURRENT_LINK_TYPES, CardComponent, ClientGalleryComponent, ClientListComponent, ClientListItemComponent, ClientRepeaterComponent, ClientSliderContainerComponent, ClientSliderSlideComponent, ComponentInstanceRegistryService, ComponentState, ContainerComponent, ContextStore, DECORATIVE_TYPES, DEFAULT_GALLERY_SCHEDULER, DEFAULT_PAGE_TRANSITION, DEFAULT_PAGE_TRANSITION_DURATION, DEFAULT_PAGE_TRANSITION_EASING, DEFAULT_PAGE_TRANSITION_PERSPECTIVE, DEFAULT_PHOSPHOR_WEIGHT, DEFAULT_WIDTH_BY_TYPE, DIALOG_CLOSE_ABOVE_ATTRIBUTE, DIALOG_NODE_TYPE, DeclarationContext, ENTRANCE_ACTION_TYPES, ENTRANCE_FALLBACK_CSS, ENTRANCE_NOSCRIPT_CSS, ENTRANCE_PRESTATE_ATTRIBUTE, EXIT_ACTION_TYPES, FormRenderState, FormRuntimeService, GALLERY_EFFECTS, GALLERY_EFFECT_DESCRIPTORS, GalleryLoop, HOVER_DEFAULT_TRANSITION, HOVER_STATES, INTERACTION_HIDDEN_ATTRIBUTE, IOX_BUILDER_EVENTS, IOX_FORM_MESSAGES, IOX_FORM_NAVIGATOR, IOX_FORM_TRANSPORT, IS_PREVIEW, IoxAnimateContainer, IoxAosDirective, IoxAosModule, IoxBuilderComponentsModule, IoxComponentRegistryService, IoxDialogComponent, IoxFieldComponent, IoxFormComponent, IoxFormErrorComponent, IoxFormSuccessComponent, IoxPageComponent, IoxPageModule, IoxSubmitButtonComponent, IoxUiModule, IoxVideoComponent, LAST_EMITTED_STATES, LEGACY_DIRECTION_KEY, LEGACY_HOVER_TRANSITION, LEGACY_SWIPER_CONTROLS, LinkedContainerComponent, MANAGED_A11Y_ATTRIBUTES, MAX_LINES_KEY, NAMED_GROUP_ROLES, NO_PAGE_TRANSITION, ON_STATE_ROLES, PAGE_STYLE_TOKENS, PAGE_TOKEN_FALLBACKS, PAGE_TRANSITION_CATEGORIES, PAGE_TRANSITION_PRESETS, PART_CLASS_NAMES, PART_PAGINATION_MODIFIER, PHONE_TRANSITION_QUERY, PIPE_DESCRIPTORS, PIPE_REGISTRY, PageScrollProvider, PageStyleField, PageStyleSource, REDUCED_MOTION_QUERY, RevealActionType, SCROLL_EASINGS, SELF_PLAYING_TRIGGERS, SLIDER_INPUTS_ATTRIBUTE, SLIDER_INPUT_NAMES, STATE_PSEUDO_CLASS, SUPPRESSED_STYLE_PROPS_BY_TYPE, SWIPER_A11Y_OPTIONS, SWIPER_RESPONSIVE_TRAITS, SWIPER_TRIGGER_EVENTS, ScrollEasing, SectionComponent, SwiperModuleName, TARGETED_STATE_SUFFIX, TEXT_ALIGN_KEY, TEXT_DIRECTION_KEY, TRANSITION_FEELS, TextAlign, TextBlockComponent, VALIDATION_STATES, VALIDATION_STATE_CLASS, VIRTUAL_TRAIT_GROUPS, VIRTUAL_TRAIT_KEYS, ViewPositionDirective, ViewPositionModule, VisibilityActionType, activeActions, animateAction, applyBindingPipes, applyFeel, bootSlider, breakpointDef, breakpointForWidth, breakpointQueryFor, buildInteractionKeyframes, buildPartSelector, buildSwiperBreakpoints, buildTargetedStateSelector, buildTargetedSuffixSelector, buildTransitionTimeline, cascadeChain, clearInputOverride, clearOverride, closeAboveMediaQuery, collectContextSlotKeys, compileDeclarations, compilePageStyle, componentStatesFor, composeVirtualTraits, containerHtmlTag, contextActionEffect, contextSlotKeys, countOverrides, declaredInputNames, decomposeBackdropFilter, defaultHoverTransition, defaultMessageResolver, deriveSeoFromContent, describePageStyle, describeSeoFields, dialogCloseAboveMinWidth, domHasSliderParts, effectiveActionType, effectiveTransitionId, entrancePreStateStyle, entranceStartsHidden, feelFor, finalKeyframe, getSeoValue, hasHoverState, hasOwnPageTransition, hasOwnTransition, initialSlotValues, isActionEnabled, isContextAction, isCurrentLink, isExternalOrFragment, isGradientPaint, isInputOverriddenAt, isLastEmittedStateKey, isOverriddenAt, isValidSlotKey, isWholeBackdropSet, keyframePropertyName, keyframeStyleEntries, legacyEnterToTransition, mediaQueryFor, mergeSwiperBreakpoints, migrateLegacyDirection, migrateLegacyTextAlign, motionAllowed, nearestCommonAncestorId, needsAccessibleName, needsGroupName, nodeA11yAttributes, nodeCssId, nodeTraitValue, nodeUsesContext, normalizeAppPath, normalizeGalleryImage, normalizeIconClass, orderStatesForEmit, pageTransitionOf, parentBreakpoint, parseUrlList, partClassName, planResponsiveRules, planScroll, planSwiperOptions, playDualLayerTransition, prefersReducedMotion, renderDefaultDeclarations, resetPageStyleField, resetSeoField, resolveBoundValue, resolveContextValue, resolveGalleryEffect, resolveGalleryUrls, resolveInputChain, resolveInputs, resolvePageTransition, resolvePath, resolveSeo, resolveStateStyles, resolveStyleProps, resolveToggleVisibility, resolveValueChain, rewriteViewportUnits, safeSetInput, serializeA11yAttributes, setInputOverride, setOverride, setSeoAuto, setSeoBinding, setSeoOverride, setSeoValue, sliderControlElements, sliderInputsFromProps, sliderInputsOf, stateNamesIn, statePseudoClass, stateSelectorSuffix, stripSuppressedProps, swiperAutoplayAllowed, swiperLoopOptions, swiperModuleNames, syncNodeA11yAttributes, targetedStateSelector, viewportTransitionContext, withContextSlots, withDefaultHoverTransition, wrapInBreakpointQuery, wrapInMediaQuery };
3222
+ export type { A11yNodeSource, AnimateActionOptions, BoundValueSpec, BreakpointDef, ContextActionEffect, ContextActionLike, ContextItemRef, ContextSlotDefault, ContextValueSpec, ContextWriteScope, CurrentLinkOptions, FormMessageResolver, GalleryBoundSource, GalleryDurations, GalleryEffect, GalleryEffectDescriptor, GalleryImage, GalleryLoopOptions, GalleryLoopScheduler, GalleryLoopState, GallerySlideState, IBuilderEventEmitter, InputMap, InteractionKeyframe, IoxContextSlot, IoxPipe, MatchMediaHost, NodeA11yContext, OverrideBreakpoint, PageScrollStart, PageStyleFieldState, PageStyleOptions, PageStyleSettings, PageTransitionCategory, PageTransitionCategoryMeta, PageTransitionContext, PageTransitionOptions, PageTransitionPreset, PipeArgSpec, PipeContext, PipeDescriptor, PlannedPageTransition, ResolvedPageTransition, ResponsiveLayer, ResponsiveOverrides, ResponsiveRuleSet, RouteAnimationLike, ScrollPlanContext, SeoDerivedContent, SeoFieldSource, SeoFieldState, SeoResolveContext, SetContextParams, SliderBootOptions, SliderControlElements, SliderInputs, StateStyleMap, StyleMap, StyleTreeNode, SwiperBreakpointMap, SwiperModuleFlags, SwiperPlan, SwiperPlanContext, ToggleableAction, TransitionFeel, TransitionPhase, TransitionPreviewLayers, TransitionTimelineInput, TransitionViewport, ValueSource, VirtualTraitGroup };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vectoriox/iox-ui",
3
- "version": "5.71.2",
3
+ "version": "5.72.1",
4
4
  "peerDependencies": {
5
5
  "@angular/common": ">=20.0.0",
6
6
  "@angular/core": ">=20.0.0",
@@ -19,19 +19,33 @@ declare enum SceneProp {
19
19
  Rotate = "rotate",
20
20
  /** 0–1. */
21
21
  Opacity = "opacity",
22
- /** Inset from every side, in % of the layer (0 = whole, 50 = gone). */
22
+ /** How much of the layer shows, in % (100 = whole, 0 = gone). Its track's `from` says where it opens from. */
23
23
  Clip = "clip",
24
+ /** How many of the layer's words are lit, in % (100 = all): the word-by-word light-up. Split in the browser only. */
25
+ Words = "words",
24
26
  /** The layer's background colour. */
25
27
  Ground = "ground",
26
28
  /** The layer's text colour. */
27
29
  Ink = "ink"
28
30
  }
31
+ /** Where a reveal opens from. Start and End mirror in RTL. */
32
+ declare enum SceneRevealFrom {
33
+ Centre = "centre",
34
+ Bottom = "bottom",
35
+ Top = "top",
36
+ Start = "start",
37
+ End = "end"
38
+ }
39
+ /** What a reveal opens from when nobody has said: the page's own direction of travel, upward as it scrolls. */
40
+ declare const SCENE_REVEAL_DEFAULT = SceneRevealFrom.Bottom;
29
41
  /** One key: at progress `[0]` (0–1) the property holds `[1]`: a number, or a colour for Ground and Ink. */
30
42
  type SceneKey = readonly [number, number | string];
31
43
  interface SceneTrack {
32
44
  prop: SceneProp;
33
45
  /** Ascending by progress. Between two keys the value eases (smoothstep); outside them it holds the nearest. */
34
46
  keys: ReadonlyArray<SceneKey>;
47
+ /** A Clip track's direction. */
48
+ from?: SceneRevealFrom;
35
49
  }
36
50
  /** What a node carries (both a builder node and a saved one). */
37
51
  interface SceneNodeSource {
@@ -63,6 +77,11 @@ declare const SCENE_PHONE_MAX_WIDTH = 767;
63
77
  * keys sorted by progress, a track left with no key dropped. Never throws on what a page document holds.
64
78
  */
65
79
  declare function normalizeTracks(tracks: unknown): SceneTrack[];
80
+ /**
81
+ * The clip-path of a layer `showing` % revealed, opening from `from`. The one shape behind a Scene's Clip track and the
82
+ * clipReveal action. `clip-path` has no logical sides, so Start and End take the direction from `inlineSign`.
83
+ */
84
+ declare function revealClipPath(showing: number, from?: SceneRevealFrom, inlineSign?: 1 | -1): string;
66
85
  /** A numeric track's value at progress `p`. */
67
86
  declare function valueAt(keys: ReadonlyArray<SceneKey>, p: number): number;
68
87
  /**
@@ -79,6 +98,12 @@ interface SceneFrameOptions {
79
98
  * canvas zoom, on the server and with no measuring: the stage is the size container (`iox-design.css`).
80
99
  */
81
100
  declare function sceneFrame(tracks: ReadonlyArray<SceneTrack>, p: number, options?: SceneFrameOptions): SceneFrame;
101
+ /** How far an unlit word still shows: readable as a shape, plainly not yet said. */
102
+ declare const SCENE_WORD_DIM = 0.16;
103
+ /** How many of a layer's words are lit at `p`, in %; null when it has no such track. */
104
+ declare function sceneWordsLit(tracks: ReadonlyArray<SceneTrack>, p: number): number | null;
105
+ /** Word `index` of `count`, with `lit` % lit: words light one after the other, each easing in as the last finishes. */
106
+ declare function sceneWordOpacity(index: number, count: number, lit: number): number;
82
107
  /** A frame as kebab-case style declarations, for a string renderer. */
83
108
  declare function sceneFrameDeclarations(frame: SceneFrame): Record<string, string>;
84
109
  /** The attributes a node's own element carries — the same decision in all three render paths. The runtime reads them,
@@ -129,6 +154,13 @@ declare function sceneDisplayValue(tracks: ReadonlyArray<SceneTrack>, prop: Scen
129
154
  */
130
155
  declare function sceneColourKeyAt(tracks: ReadonlyArray<SceneTrack>, prop: SceneProp, p: number): string;
131
156
  declare function sceneHasKeyAt(tracks: ReadonlyArray<SceneTrack>, prop: SceneProp, p: number): boolean;
157
+ /** Where a layer's reveal opens from: its track's direction, else where a new one would open from. */
158
+ declare function sceneRevealFromOf(tracks: ReadonlyArray<SceneTrack>): SceneRevealFrom;
159
+ /**
160
+ * Open a layer's reveal from `from`. With no reveal yet it starts one with a key at `p` holding the layer whole, so the
161
+ * direction is kept and nothing on screen changes until the author sets a second key.
162
+ */
163
+ declare function withSceneRevealFrom(tracks: ReadonlyArray<SceneTrack>, from: SceneRevealFrom, p: number): SceneTrack[];
132
164
  /**
133
165
  * Set a key at `p`, in the author's units. A number is clamped to its bounds; a colour that is no colour changes nothing,
134
166
  * because `normalizeTracks` would drop it anyway and the author's other keys must survive.
@@ -287,16 +319,68 @@ declare class ScrollDriver {
287
319
  /** Read a root's stage and layers from its DOM: its own Scene's only, never a nested one's. */
288
320
  private readScene;
289
321
  private letGo;
322
+ private restoreWords;
290
323
  private readLayer;
291
324
  private remeasureAll;
292
325
  private measure;
293
326
  private readonly tick;
294
327
  private read;
295
328
  private write;
329
+ /** One opacity per word, and a write only for the few the scroll actually changed. */
330
+ private writeWords;
296
331
  }
297
332
  /** A page's Scenes, driven by one driver; null when it holds none, or when the visitor asked for less motion (the
298
333
  * page's own CSS then lets each Scene flow). The live site's door. */
299
334
  declare function attachScenes(container: HTMLElement, options?: ScrollDriverOptions): ScrollDriver | null;
300
335
 
301
- export { SCENE_BUDGET, SCENE_DAMPING, SCENE_FLOW_RULES, SCENE_LENGTH, SCENE_MAX_SCREENS, SCENE_MIN_SCREENS, SCENE_PHONE_LENGTH, SCENE_PHONE_MAX_WIDTH, SCENE_REDUCED_MOTION_CSS, SCENE_STAGE_HEIGHT, SCENE_TRACK_DEFS, SCENE_WORDS, SceneAttribute, SceneProp, ScrollDriver, applySceneFirstFrame, attachScenes, buildSceneRecipe, colourAt, dampToward, movedSceneKey, normalizeTracks, sceneBudget, sceneColourKeyAt, sceneDisplayValue, sceneFirstFrame, sceneFrame, sceneFrameDeclarations, sceneHasKeyAt, sceneHeightForScreens, sceneKeyPosition, sceneLayersOf, sceneNodeAttributes, sceneRootOnPath, sceneScreensOf, sceneScrollState, sceneTrackDef, sceneTrackSpan, scenesIn, syncSceneAttributes, valueAt, withSceneKey, withoutSceneKey };
302
- export type { SceneBudget, SceneFrame, SceneFrameOptions, SceneFrameState, SceneKey, SceneLayerNode, SceneNodeSource, SceneTrack, SceneTrackDef, SceneTreeNode, SceneWords, ScrollDriverOptions };
336
+ /**
337
+ * Text into pieces, in the browser only (premium sites SC3, Motion v2 §3c). A saved node keeps its plain text and the
338
+ * server sends it whole, so every word is in the raw response, editing never meets a piece, and there is no markup for
339
+ * hydration to disagree with. Each original text node is kept and put back as it was, never rebuilt from a string: a
340
+ * framework may hold that node, and a layer may hold live components.
341
+ */
342
+ declare enum TextRevealBy {
343
+ Line = "line",
344
+ Word = "word"
345
+ }
346
+ /** Past these a text is plainly no heading: it fades whole, and lights up whole. */
347
+ declare const REVEAL_PIECE_CAP = 120;
348
+ declare const LIGHT_UP_PIECE_CAP = 300;
349
+ interface TextPiece {
350
+ text: string;
351
+ /** False for the space between two words, which stays bare text so lines wrap exactly as they did. */
352
+ word: boolean;
353
+ }
354
+ /**
355
+ * A string as words and the space between them. With `keepLtrRuns` a run of words holding no RTL letter stays ONE word:
356
+ * an inline-block is neutral to the bidi algorithm, so "Studio Alma" inside a Hebrew heading, split, would read reversed.
357
+ */
358
+ declare function textPieces(text: string, keepLtrRuns?: boolean): TextPiece[];
359
+ interface SplitTextOptions {
360
+ cap: number;
361
+ /** Each piece in a clipping mask, to rise out of (and so an inline-block). Without it a piece is a plain inline span. */
362
+ mask?: boolean;
363
+ /** The element reads right-to-left. Only a mask needs to know. */
364
+ rtl?: boolean;
365
+ }
366
+ interface SplitText {
367
+ /** In reading order. With a mask, the moving part inside it. */
368
+ pieces: HTMLElement[];
369
+ /** Put every original text node back. Safe after the text was replaced: what is gone stays gone. */
370
+ restore(): void;
371
+ }
372
+ /** Split `root`'s text, or null when it holds none or more than `cap` pieces (nothing is changed then). */
373
+ declare function splitText(root: HTMLElement, options: SplitTextOptions): SplitText | null;
374
+ /** The line each piece stands on: 0, 0, 1, 1, 1, 2… Read once, as a reveal starts. */
375
+ declare function lineIndexes(tops: ReadonlyArray<number>, heights: ReadonlyArray<number>): number[];
376
+ declare function revealStagger(groups: number, by: TextRevealBy): number;
377
+ /**
378
+ * Play a text reveal: each piece rises out of its mask, line by line or word by word. Returns ONE animation for the
379
+ * whole, so a runtime treats it as any other: it finishes when the last piece lands, and on finish or cancel the text
380
+ * is put back whole, which leaves nothing for a screen reader to stumble on and nothing to re-split on a resize. Null
381
+ * when there is nothing to split or it is being edited: the caller plays its plain fade.
382
+ */
383
+ declare function playTextReveal(el: HTMLElement, by: TextRevealBy, timing: KeyframeAnimationOptions): Animation | null;
384
+
385
+ export { LIGHT_UP_PIECE_CAP, REVEAL_PIECE_CAP, SCENE_BUDGET, SCENE_DAMPING, SCENE_FLOW_RULES, SCENE_LENGTH, SCENE_MAX_SCREENS, SCENE_MIN_SCREENS, SCENE_PHONE_LENGTH, SCENE_PHONE_MAX_WIDTH, SCENE_REDUCED_MOTION_CSS, SCENE_REVEAL_DEFAULT, SCENE_STAGE_HEIGHT, SCENE_TRACK_DEFS, SCENE_WORDS, SCENE_WORD_DIM, SceneAttribute, SceneProp, SceneRevealFrom, ScrollDriver, TextRevealBy, applySceneFirstFrame, attachScenes, buildSceneRecipe, colourAt, dampToward, lineIndexes, movedSceneKey, normalizeTracks, playTextReveal, revealClipPath, revealStagger, sceneBudget, sceneColourKeyAt, sceneDisplayValue, sceneFirstFrame, sceneFrame, sceneFrameDeclarations, sceneHasKeyAt, sceneHeightForScreens, sceneKeyPosition, sceneLayersOf, sceneNodeAttributes, sceneRevealFromOf, sceneRootOnPath, sceneScreensOf, sceneScrollState, sceneTrackDef, sceneTrackSpan, sceneWordOpacity, sceneWordsLit, scenesIn, splitText, syncSceneAttributes, textPieces, valueAt, withSceneKey, withSceneRevealFrom, withoutSceneKey };
386
+ export type { SceneBudget, SceneFrame, SceneFrameOptions, SceneFrameState, SceneKey, SceneLayerNode, SceneNodeSource, SceneTrack, SceneTrackDef, SceneTreeNode, SceneWords, ScrollDriverOptions, SplitText, SplitTextOptions, TextPiece };
@@ -31,6 +31,21 @@ export const ELEMENT_ACTION_DESCRIPTORS: readonly ActionDescriptor[] = Object.fr
31
31
  { type: 'scaleOut', labelKey: 'builder.actions.scaleOut', kind: 'animated', group: 'motion', builderBehaviour: 'run' },
32
32
  { type: 'rotate', labelKey: 'builder.actions.rotate', kind: 'animated', group: 'motion', builderBehaviour: 'run',
33
33
  params: [{ name: 'degrees', type: 'number', label: 'Degrees' }] },
34
+ // SC3: the reveals. Each opens from where the author says and rests as the element itself.
35
+ { type: 'clipReveal', labelKey: 'builder.actions.clipReveal', kind: 'animated', group: 'motion', builderBehaviour: 'run',
36
+ params: [{ name: 'from', type: 'string', label: 'builder.actions.params.revealFrom', options: [
37
+ { value: 'bottom', labelKey: 'builder.scene.from.bottom' },
38
+ { value: 'top', labelKey: 'builder.scene.from.top' },
39
+ { value: 'start', labelKey: 'builder.scene.from.start' },
40
+ { value: 'end', labelKey: 'builder.scene.from.end' },
41
+ { value: 'centre', labelKey: 'builder.scene.from.centre' },
42
+ ] }] },
43
+ { type: 'zoomOut', labelKey: 'builder.actions.zoomOut', kind: 'animated', group: 'motion', builderBehaviour: 'run' },
44
+ { type: 'revealText', labelKey: 'builder.actions.revealText', kind: 'animated', group: 'motion', builderBehaviour: 'run',
45
+ params: [{ name: 'by', type: 'string', label: 'builder.actions.params.revealBy', options: [
46
+ { value: 'line', labelKey: 'builder.actions.params.byLine' },
47
+ { value: 'word', labelKey: 'builder.actions.params.byWord' },
48
+ ] }] },
34
49
 
35
50
  // ── Visibility (shipped) ─────────────────────────────────────────────────
36
51
  { type: 'show', labelKey: 'builder.actions.show', kind: 'instant', group: 'visibility', builderBehaviour: 'run' },
@@ -8,3 +8,4 @@ export * from './scene';
8
8
  export * from './scene-edit';
9
9
  export * from './scene-recipe';
10
10
  export * from './scroll-driver';
11
+ export * from './split-text';
@@ -1,6 +1,6 @@
1
1
  import { SceneRole } from '@vectoriox/iox-ui/composite-core';
2
2
 
3
- import { normalizeTracks, SceneKey, SceneNodeSource, SceneProp, SceneTrack, valueAt } from './scene';
3
+ import { normalizeTracks, SCENE_REVEAL_DEFAULT, SceneKey, SceneNodeSource, SceneProp, SceneRevealFrom, SceneTrack, valueAt } from './scene';
4
4
 
5
5
  /**
6
6
  * Authoring a Scene (premium-sites plan §5b, SC2): the decisions the builder's timeline and its panel share, so neither
@@ -43,7 +43,8 @@ export const SCENE_TRACK_DEFS: ReadonlyArray<SceneTrackDef> = [
43
43
  { prop: SceneProp.Scale, colour: false, rest: 1, min: 0, max: 2000, step: 1, factor: 100, unit: PERCENT },
44
44
  { prop: SceneProp.Rotate, colour: false, rest: 0, min: -720, max: 720, step: 1, factor: 1, unit: DEGREES },
45
45
  { prop: SceneProp.Opacity, colour: false, rest: 1, min: 0, max: 100, step: 1, factor: 100, unit: PERCENT },
46
- { prop: SceneProp.Clip, colour: false, rest: 0, min: 0, max: 50, step: 1, factor: 1, unit: PERCENT },
46
+ { prop: SceneProp.Clip, colour: false, rest: 100, min: 0, max: 100, step: 1, factor: 1, unit: PERCENT },
47
+ { prop: SceneProp.Words, colour: false, rest: 100, min: 0, max: 100, step: 1, factor: 1, unit: PERCENT },
47
48
  { prop: SceneProp.Ground, colour: true, rest: 0, min: 0, max: 0, step: 0, factor: 1, unit: '' },
48
49
  { prop: SceneProp.Ink, colour: true, rest: 0, min: 0, max: 0, step: 0, factor: 1, unit: '' },
49
50
  ];
@@ -91,10 +92,25 @@ function replaceTrack(tracks: ReadonlyArray<SceneTrack>, prop: SceneProp, keys:
91
92
  if (!keys.length) return rest;
92
93
  const at = tracks.findIndex(t => t.prop === prop);
93
94
  const next = [...rest];
94
- next.splice(at < 0 ? rest.length : at, 0, { prop, keys });
95
+ const from = prop === SceneProp.Clip ? { from: tracks[at]?.from ?? SCENE_REVEAL_DEFAULT } : {};
96
+ next.splice(at < 0 ? rest.length : at, 0, { prop, ...from, keys });
95
97
  return next;
96
98
  }
97
99
 
100
+ /** Where a layer's reveal opens from: its track's direction, else where a new one would open from. */
101
+ export function sceneRevealFromOf(tracks: ReadonlyArray<SceneTrack>): SceneRevealFrom {
102
+ return trackOf(tracks, SceneProp.Clip)?.from ?? SCENE_REVEAL_DEFAULT;
103
+ }
104
+
105
+ /**
106
+ * Open a layer's reveal from `from`. With no reveal yet it starts one with a key at `p` holding the layer whole, so the
107
+ * direction is kept and nothing on screen changes until the author sets a second key.
108
+ */
109
+ export function withSceneRevealFrom(tracks: ReadonlyArray<SceneTrack>, from: SceneRevealFrom, p: number): SceneTrack[] {
110
+ if (!trackOf(tracks, SceneProp.Clip)) return [...tracks, { prop: SceneProp.Clip, from, keys: [[sceneKeyPosition(p), sceneTrackDef(SceneProp.Clip).rest]] }];
111
+ return tracks.map(track => (track.prop === SceneProp.Clip ? { ...track, from } : track));
112
+ }
113
+
98
114
  /**
99
115
  * Set a key at `p`, in the author's units. A number is clamped to its bounds; a colour that is no colour changes nothing,
100
116
  * because `normalizeTracks` would drop it anyway and the author's other keys must survive.
@@ -20,14 +20,28 @@ export enum SceneProp {
20
20
  Rotate = 'rotate',
21
21
  /** 0–1. */
22
22
  Opacity = 'opacity',
23
- /** Inset from every side, in % of the layer (0 = whole, 50 = gone). */
23
+ /** How much of the layer shows, in % (100 = whole, 0 = gone). Its track's `from` says where it opens from. */
24
24
  Clip = 'clip',
25
+ /** How many of the layer's words are lit, in % (100 = all): the word-by-word light-up. Split in the browser only. */
26
+ Words = 'words',
25
27
  /** The layer's background colour. */
26
28
  Ground = 'ground',
27
29
  /** The layer's text colour. */
28
30
  Ink = 'ink',
29
31
  }
30
32
 
33
+ /** Where a reveal opens from. Start and End mirror in RTL. */
34
+ export enum SceneRevealFrom {
35
+ Centre = 'centre',
36
+ Bottom = 'bottom',
37
+ Top = 'top',
38
+ Start = 'start',
39
+ End = 'end',
40
+ }
41
+
42
+ /** What a reveal opens from when nobody has said: the page's own direction of travel, upward as it scrolls. */
43
+ export const SCENE_REVEAL_DEFAULT = SceneRevealFrom.Bottom;
44
+
31
45
  /** One key: at progress `[0]` (0–1) the property holds `[1]`: a number, or a colour for Ground and Ink. */
32
46
  export type SceneKey = readonly [number, number | string];
33
47
 
@@ -35,6 +49,8 @@ export interface SceneTrack {
35
49
  prop: SceneProp;
36
50
  /** Ascending by progress. Between two keys the value eases (smoothstep); outside them it holds the nearest. */
37
51
  keys: ReadonlyArray<SceneKey>;
52
+ /** A Clip track's direction. */
53
+ from?: SceneRevealFrom;
38
54
  }
39
55
 
40
56
  /** What a node carries (both a builder node and a saved one). */
@@ -76,6 +92,9 @@ const smoothstep = (t: number): number => t * t * (3 - 2 * t);
76
92
  const COLOUR = /^(#[0-9a-f]{3,8}|(rgb|hsl)a?\([\d\s.,%/]+\)|var\(--iox-[\w-]+\))$/i;
77
93
  const COLOUR_PROPS: ReadonlySet<SceneProp> = new Set([SceneProp.Ground, SceneProp.Ink]);
78
94
  const ALL_PROPS: ReadonlySet<string> = new Set(Object.values(SceneProp));
95
+ const REVEAL_FROMS: ReadonlySet<string> = new Set(Object.values(SceneRevealFrom));
96
+ const PERCENT = 100;
97
+ const clampPercent = (v: number): number => Math.min(PERCENT, Math.max(0, v));
79
98
 
80
99
  function validKey(key: unknown, colour: boolean): key is SceneKey {
81
100
  if (!Array.isArray(key) || key.length !== 2) return false;
@@ -98,11 +117,36 @@ export function normalizeTracks(tracks: unknown): SceneTrack[] {
98
117
  .filter(key => validKey(key, COLOUR_PROPS.has(prop)))
99
118
  .slice()
100
119
  .sort((a, b) => a[0] - b[0]);
101
- if (keys.length) out.push({ prop, keys });
120
+ if (!keys.length) continue;
121
+ out.push(prop === SceneProp.Clip ? clipTrack(keys, (track as SceneTrack).from) : { prop, keys });
102
122
  }
103
123
  return out;
104
124
  }
105
125
 
126
+ /**
127
+ * A Clip track with a direction. One with none was saved when a reveal could only close in from every side, its value
128
+ * the inset per side (50 = gone): it reads as the same movement, from the centre, in % showing.
129
+ */
130
+ function clipTrack(keys: ReadonlyArray<SceneKey>, from: unknown): SceneTrack {
131
+ if (typeof from === 'string' && REVEAL_FROMS.has(from)) return { prop: SceneProp.Clip, from: from as SceneRevealFrom, keys };
132
+ return { prop: SceneProp.Clip, from: SceneRevealFrom.Centre, keys: keys.map(([p, inset]): SceneKey => [p, clampPercent(PERCENT - Number(inset) * 2)]) };
133
+ }
134
+
135
+ /**
136
+ * The clip-path of a layer `showing` % revealed, opening from `from`. The one shape behind a Scene's Clip track and the
137
+ * clipReveal action. `clip-path` has no logical sides, so Start and End take the direction from `inlineSign`.
138
+ */
139
+ export function revealClipPath(showing: number, from: SceneRevealFrom = SCENE_REVEAL_DEFAULT, inlineSign: 1 | -1 = 1): string {
140
+ const hidden = round(PERCENT - clampPercent(showing));
141
+ switch (from) {
142
+ case SceneRevealFrom.Centre: return `inset(${round(hidden / 2)}%)`;
143
+ case SceneRevealFrom.Top: return `inset(0 0 ${hidden}% 0)`;
144
+ case SceneRevealFrom.Start: return inlineSign === 1 ? `inset(0 ${hidden}% 0 0)` : `inset(0 0 0 ${hidden}%)`;
145
+ case SceneRevealFrom.End: return inlineSign === 1 ? `inset(0 0 0 ${hidden}%)` : `inset(0 ${hidden}% 0 0)`;
146
+ default: return `inset(${hidden}% 0 0 0)`;
147
+ }
148
+ }
149
+
106
150
  /** The two keys around `p` and how far between them it eases. */
107
151
  function span(keys: ReadonlyArray<SceneKey>, p: number): { a: SceneKey; b: SceneKey; f: number } {
108
152
  const at = clamp01(p);
@@ -154,14 +198,14 @@ const TRANSFORM_REST: Readonly<Partial<Record<SceneProp, number>>> = { [ScenePro
154
198
  export function sceneFrame(tracks: ReadonlyArray<SceneTrack>, p: number, options: SceneFrameOptions = {}): SceneFrame {
155
199
  const frame: SceneFrame = {};
156
200
  const moves: Partial<Record<SceneProp, number>> = {};
157
- for (const { prop, keys } of tracks) {
201
+ for (const { prop, keys, from } of tracks) {
158
202
  if (prop in TRANSFORM_REST) moves[prop] = valueAt(keys, p);
159
203
  else if (prop === SceneProp.Opacity) {
160
204
  const opacity = clamp01(valueAt(keys, p));
161
205
  frame.opacity = String(opacity);
162
206
  frame.pointerEvents = opacity > 0 ? '' : 'none';
163
207
  }
164
- else if (prop === SceneProp.Clip) frame.clipPath = `inset(${Math.min(50, Math.max(0, valueAt(keys, p)))}%)`;
208
+ else if (prop === SceneProp.Clip) frame.clipPath = revealClipPath(valueAt(keys, p), from ?? SceneRevealFrom.Centre, options.inlineSign ?? 1);
165
209
  else if (prop === SceneProp.Ground) frame.backgroundColor = colourAt(keys, p);
166
210
  else if (prop === SceneProp.Ink) frame.color = colourAt(keys, p);
167
211
  }
@@ -173,6 +217,20 @@ export function sceneFrame(tracks: ReadonlyArray<SceneTrack>, p: number, options
173
217
  return frame;
174
218
  }
175
219
 
220
+ /** How far an unlit word still shows: readable as a shape, plainly not yet said. */
221
+ export const SCENE_WORD_DIM = 0.16;
222
+
223
+ /** How many of a layer's words are lit at `p`, in %; null when it has no such track. */
224
+ export function sceneWordsLit(tracks: ReadonlyArray<SceneTrack>, p: number): number | null {
225
+ const track = tracks.find(t => t.prop === SceneProp.Words);
226
+ return track ? clampPercent(valueAt(track.keys, p)) : null;
227
+ }
228
+
229
+ /** Word `index` of `count`, with `lit` % lit: words light one after the other, each easing in as the last finishes. */
230
+ export function sceneWordOpacity(index: number, count: number, lit: number): number {
231
+ return round(SCENE_WORD_DIM + (1 - SCENE_WORD_DIM) * clamp01((lit / PERCENT) * count - index));
232
+ }
233
+
176
234
  const CSS_NAME: Readonly<Record<keyof SceneFrame, string>> = {
177
235
  transform: 'transform',
178
236
  opacity: 'opacity',
@@ -204,7 +262,11 @@ export function sceneNodeAttributes(node: SceneNodeSource | null | undefined): R
204
262
  /** The frame a layer ships in from the server: where it stands before anyone has scrolled. Without it the page would
205
263
  * paint every layer at once, and then jump when the script arrives. */
206
264
  export function sceneFirstFrame(node: SceneNodeSource | null | undefined): SceneFrame {
207
- return sceneFrame(normalizeTracks(node?.sceneTracks), 0);
265
+ const tracks = normalizeTracks(node?.sceneTracks);
266
+ const frame = sceneFrame(tracks, 0);
267
+ // The words are split in the browser, so until then the whole layer stands for them, or it paints lit and then dims.
268
+ const lit = sceneWordsLit(tracks, 0);
269
+ return lit !== null && lit < PERCENT && frame.opacity === undefined ? { ...frame, opacity: String(SCENE_WORD_DIM) } : frame;
208
270
  }
209
271
 
210
272
  /**
@@ -1,6 +1,7 @@
1
1
  import { SceneRole } from '@vectoriox/iox-ui/composite-core';
2
2
 
3
- import { normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, SceneTrack } from './scene';
3
+ import { normalizeTracks, SCENE_PHONE_MAX_WIDTH, SceneAttribute, sceneFrame, SceneFrame, sceneWordOpacity, sceneWordsLit, SceneTrack } from './scene';
4
+ import { LIGHT_UP_PIECE_CAP, SplitText, splitText } from './split-text';
4
5
 
5
6
  /**
6
7
  * The ONE scroll loop (premium-sites plan §5b, SC0). Every Scene on a page reads one progress value from it, on the
@@ -65,6 +66,10 @@ interface Layer {
65
66
  last: SceneFrame;
66
67
  /** Write every property on the next frame, changed or not: a measure may have moved what a frame resolves to. */
67
68
  stale: boolean;
69
+ /** Its words, split while a Words track plays them. Null until the first frame that needs them. */
70
+ words: SplitText | null;
71
+ /** Too many words to split: it holds as it is, and is not counted again on every frame. */
72
+ wordsRefused: boolean;
68
73
  }
69
74
 
70
75
  interface Scene {
@@ -127,7 +132,10 @@ export class ScrollDriver {
127
132
  scene.mutations?.disconnect();
128
133
  this.resize?.unobserve(root);
129
134
  if (scene.stage) scene.stage.style.removeProperty('translate');
130
- for (const layer of scene.layers) layer.el.style.removeProperty('will-change');
135
+ for (const layer of scene.layers) {
136
+ layer.el.style.removeProperty('will-change');
137
+ this.restoreWords(layer);
138
+ }
131
139
  this.scenes.delete(root);
132
140
  }
133
141
 
@@ -207,12 +215,18 @@ export class ScrollDriver {
207
215
  private letGo(layer: Layer): void {
208
216
  layer.el.style.removeProperty('will-change');
209
217
  for (const key of Object.keys(layer.last) as (keyof SceneFrame)[]) layer.el.style[key] = '';
218
+ this.restoreWords(layer);
219
+ }
220
+
221
+ private restoreWords(layer: Layer): void {
222
+ layer.words?.restore();
223
+ layer.words = null;
210
224
  }
211
225
 
212
226
  private readLayer(el: HTMLElement, scene: Scene): Layer {
213
227
  const previous = scene.layers.find(layer => layer.el === el);
214
228
  el.style.setProperty('will-change', TRANSFORM_WILL_CHANGE);
215
- return { el, tracks: parseTracks(el.getAttribute(SceneAttribute.Tracks)), phoneTracks: parseTracks(el.getAttribute(SceneAttribute.PhoneTracks)), last: previous?.last ?? {}, stale: true };
229
+ return { el, tracks: parseTracks(el.getAttribute(SceneAttribute.Tracks)), phoneTracks: parseTracks(el.getAttribute(SceneAttribute.PhoneTracks)), last: previous?.last ?? {}, stale: true, words: previous?.words ?? null, wordsRefused: false };
216
230
  }
217
231
 
218
232
  private remeasureAll(): void {
@@ -260,6 +274,7 @@ export class ScrollDriver {
260
274
  private write(scene: Scene): void {
261
275
  for (const layer of scene.layers) {
262
276
  const tracks = scene.phone && layer.phoneTracks.length ? layer.phoneTracks : layer.tracks;
277
+ this.writeWords(layer, sceneWordsLit(tracks, scene.progress));
263
278
  const frame = sceneFrame(tracks, scene.progress, { inlineSign: scene.inlineSign });
264
279
  for (const key of Object.keys(frame) as (keyof SceneFrame)[]) {
265
280
  if (layer.stale || frame[key] !== layer.last[key]) layer.el.style[key] = frame[key] ?? '';
@@ -272,6 +287,28 @@ export class ScrollDriver {
272
287
  layer.stale = false;
273
288
  }
274
289
  }
290
+
291
+ /** One opacity per word, and a write only for the few the scroll actually changed. */
292
+ private writeWords(layer: Layer, lit: number | null): void {
293
+ if (lit === null) {
294
+ this.restoreWords(layer);
295
+ return;
296
+ }
297
+ // Gone from the page: the text was replaced under it (an edit on the canvas), so its words are new.
298
+ if (layer.words && !layer.words.pieces[0]?.isConnected) layer.words = null;
299
+ if (!layer.words && !layer.wordsRefused) {
300
+ layer.words = splitText(layer.el, { cap: LIGHT_UP_PIECE_CAP });
301
+ layer.wordsRefused = !layer.words;
302
+ // The whole layer stood for its words until now (`sceneFirstFrame`); an Opacity track's frame writes after this.
303
+ layer.el.style.removeProperty('opacity');
304
+ layer.stale = true;
305
+ }
306
+ const pieces = layer.words?.pieces ?? [];
307
+ pieces.forEach((piece, i) => {
308
+ const opacity = String(sceneWordOpacity(i, pieces.length, lit));
309
+ if (piece.style.opacity !== opacity) piece.style.opacity = opacity;
310
+ });
311
+ }
275
312
  }
276
313
 
277
314
  /** A page's Scenes, driven by one driver; null when it holds none, or when the visitor asked for less motion (the