@impractical-ai/motion 0.1.6 → 0.1.9

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.
@@ -1,4 +1,4 @@
1
- // Impractical Motion — public engine surface v1.0.
1
+ // Impractical Motion — public engine surface v1.1.
2
2
  //
3
3
  // A scene is two files:
4
4
  // App.tsx the stage markup (plain React + CSS; this is your product UI)
@@ -41,6 +41,48 @@ export type TransitionResult = {
41
41
  root: HTMLElement
42
42
  }
43
43
 
44
+ export type ThroughOpts = {
45
+ /** Takeover color. Default: the target's effective background (first
46
+ * non-transparent backgroundColor walking up from the target), read at
47
+ * build time. */
48
+ color?: string
49
+ /** Dive duration in seconds. Default 0.5. */
50
+ duration?: number
51
+ /** Dive ease. Default 'power3.in' (accelerates into the surface). */
52
+ ease?: string
53
+ /** Cover overshoot: 1.0 = the target exactly covers the frame at the cut.
54
+ * Default 1.15, so a few px of layout drift can never expose an edge. */
55
+ margin?: number
56
+ /** The world swap, applied as seek-safe `tl.set`s at the exact cut moment
57
+ * while the frame is fully covered: `hide` gets autoAlpha 0, `show` gets
58
+ * autoAlpha 1. For anything richer, place your own `tl.set`s at the
59
+ * returned `cutAt` time. */
60
+ swap?: { hide?: gsap.TweenTarget; show?: gsap.TweenTarget }
61
+ /** Element in the post-swap world to ARRIVE from: at the cut the camera
62
+ * snaps to this element's own cover framing (measured after the swap), so
63
+ * releasing the overlay reveals it filling the frame, and the pull-out
64
+ * starts inside the new world — the match-frame landing. Omit to pull out
65
+ * from the dive's own framing. */
66
+ land?: string | Element
67
+ /** Framing the pull-out settles on. Default the full frame
68
+ * `{ scale: 1, x: 0, y: 0 }`. */
69
+ settle?: { scale: number; x: number; y: number }
70
+ /** Pull-out duration. Default 1.0. */
71
+ outDuration?: number
72
+ /** Pull-out ease. Default 'expo.out'. */
73
+ outEase?: string
74
+ }
75
+
76
+ export type ThroughResult = {
77
+ /** Full span: dive + pull-out. */
78
+ duration: number
79
+ /** Absolute timeline time of the cut — the frame is guaranteed fully
80
+ * covered here; schedule extra world-swap `tl.set`s at this time. */
81
+ cutAt: number
82
+ /** The takeover overlay's root element. */
83
+ root: HTMLElement
84
+ }
85
+
44
86
  export type TransitionKit = {
45
87
  /** Cover the frame — put this at the end of a clip. Appends to the
46
88
  * timeline's end when no position is given. */
@@ -56,6 +98,21 @@ export type TransitionKit = {
56
98
  opts?: TransitionOpts,
57
99
  position?: gsap.Position
58
100
  ) => TransitionResult
101
+ /** The go-through: dive the camera INTO an element until its color takes
102
+ * over the whole frame, cut worlds under the cover, then pull out into the
103
+ * new world. NEVER hand-roll this (a magic-scale zoom + a hand-timed
104
+ * `tl.set` swap + a re-aim) — through() computes the exact cover scale for
105
+ * the target, measures the target in its state AT THE DIVE'S START TIME
106
+ * (entrances, moves, and tilts since build are all accounted for), and
107
+ * snaps a full-frame overlay in the target's color at the cut so the
108
+ * takeover is guaranteed to the pixel. Deterministic: every value is
109
+ * resolved once at build; the swap is `tl.set`s, never callbacks.
110
+ * Appends at the timeline's end when no position is given. */
111
+ through: (
112
+ target: string | Element,
113
+ opts?: ThroughOpts,
114
+ position?: gsap.Position
115
+ ) => ThroughResult
59
116
  }
60
117
 
61
118
  // ─── Text — named, deterministic text animations ─────────────────────────
@@ -506,10 +563,113 @@ export type ThreeLayerOpts = {
506
563
  depth?: 'back' | 'front'
507
564
  }
508
565
 
566
+ export type ThreeCameraAngle =
567
+ | 'front'
568
+ | 'three-quarter-left'
569
+ | 'three-quarter-right'
570
+ | 'high'
571
+
572
+ export type ThreeCameraPose = {
573
+ position: { x: number; y: number; z: number }
574
+ target: { x: number; y: number; z: number }
575
+ roll?: number
576
+ near?: number
577
+ far?: number
578
+ }
579
+
580
+ export type ThreeFrameOpts = {
581
+ /** Projected fraction of frame height occupied by the subject. Default 0.7. */
582
+ height?: number
583
+ /** `contain` also enforces maxWidth; `height` may crop horizontally. */
584
+ mode?: 'contain' | 'height'
585
+ maxWidth?: number
586
+ angle?: ThreeCameraAngle
587
+ /** Subject-to-camera direction. Overrides angle. */
588
+ direction?: { x: number; y: number; z: number }
589
+ roll?: number
590
+ }
591
+
592
+ export type ThreeMoveOpts = {
593
+ duration?: number
594
+ /** Camera travel ease. Defaults to the house dolly (`expo.inOut`), same as
595
+ * the 2D camera; pass one only when the user asks for a different feel. */
596
+ ease?: string
597
+ }
598
+
599
+ export type ThreeOrbitOpts = ThreeFrameOpts &
600
+ ThreeMoveOpts & {
601
+ /** Horizontal orbit delta in degrees. */
602
+ azimuth?: number
603
+ /** Vertical orbit delta in degrees. */
604
+ elevation?: number
605
+ }
606
+
607
+ export type ThreeLightingPreset =
608
+ | 'ui-flat'
609
+ | 'product-soft'
610
+ | 'dramatic-rim'
611
+ | 'studio-neutral'
612
+
613
+ export type ThreeLightingOpts = {
614
+ /** Camera-relative stays flattering through a move; world-locked reveals form. */
615
+ mode?: 'camera-relative' | 'world-locked'
616
+ intensity?: number
617
+ exposure?: number
618
+ rimColor?: THREE.ColorRepresentation
619
+ }
620
+
621
+ export type ThreeLightRig = {
622
+ ambient: THREE.AmbientLight
623
+ key: THREE.DirectionalLight
624
+ fill: THREE.DirectionalLight
625
+ rim: THREE.DirectionalLight
626
+ update: () => void
627
+ }
628
+
629
+ export type ThreeCameraRig = {
630
+ /** Solve a pose whose projected subject bounds meet the framing constraint. */
631
+ frame: (subject: THREE.Object3D, opts?: ThreeFrameOpts) => ThreeCameraPose
632
+ /** Immediate deterministic camera cut to a resolved pose. */
633
+ set: (pose: ThreeCameraPose) => void
634
+ /** Tween XYZ, target, roll, and clipping planes while keeping the target locked. */
635
+ moveTo: (
636
+ pose: ThreeCameraPose,
637
+ opts?: ThreeMoveOpts,
638
+ position?: gsap.Position
639
+ ) => { duration: number }
640
+ /** Solve and dolly to a subject. */
641
+ focus: (
642
+ subject: THREE.Object3D,
643
+ frame?: ThreeFrameOpts,
644
+ move?: ThreeMoveOpts,
645
+ position?: gsap.Position
646
+ ) => { duration: number; pose: ThreeCameraPose }
647
+ /** Orbit in camera-local spherical coordinates and re-solve framing. */
648
+ orbit: (
649
+ subject: THREE.Object3D,
650
+ opts?: ThreeOrbitOpts,
651
+ position?: gsap.Position
652
+ ) => { duration: number; pose: ThreeCameraPose }
653
+ /** Current planned pose, including moves not yet reached by the playhead. */
654
+ pose: () => ThreeCameraPose
655
+ }
656
+
657
+ export type ThreeLightingRig = {
658
+ apply: (
659
+ subject: THREE.Object3D,
660
+ preset?: ThreeLightingPreset,
661
+ opts?: ThreeLightingOpts
662
+ ) => ThreeLightRig
663
+ }
664
+
509
665
  export type ThreeLayer = {
510
666
  /** The scene graph — add meshes, lights, groups. */
511
667
  scene: THREE.Scene
512
668
  camera: THREE.PerspectiveCamera
669
+ /** Subject-first XYZ camera control: exact projected framing, dolly, and orbit. */
670
+ rig: ThreeCameraRig
671
+ /** Deterministic key/fill/rim presets bound to a 3D subject. */
672
+ lighting: ThreeLightingRig
513
673
  /** The canvas element, for tweening opacity/filters like any element. */
514
674
  canvas: HTMLCanvasElement
515
675
  /** Drive the layer over [position, position+duration]. `update` runs every
@@ -530,6 +690,463 @@ export type ThreeKit = {
530
690
  layer: (opts?: ThreeLayerOpts) => ThreeLayer
531
691
  }
532
692
 
693
+ // ─── Actions — stateful cursor actors, press, scroll, drag ───────────────
694
+
695
+ /** Built-in cursor icons. `collab` is the Figma-style colored arrow + name
696
+ * chip for multi-user scenes. Custom art passes `{ svg, hotspot }` — the
697
+ * hotspot (0..1 of the icon box, where the tip sits) is part of the contract,
698
+ * so brand cursors cannot reopen the misaligned-click bug. */
699
+ export type CursorIconName =
700
+ | 'arrow'
701
+ | 'pointer'
702
+ | 'ibeam'
703
+ | 'grab'
704
+ | 'grabbing'
705
+ | 'crosshair'
706
+ | 'collab'
707
+
708
+ export type CursorIcon = CursorIconName | { svg: string; hotspot: { x: number; y: number } }
709
+
710
+ export type CursorActorOpts = {
711
+ /** Icon set. Default 'arrow'; named collaborators usually want 'collab'. */
712
+ icon?: CursorIcon
713
+ /** Tint for the collab arrow and label chip. */
714
+ color?: string
715
+ /** Figma-style name tag riding the cursor. */
716
+ label?: string
717
+ /** Icon box size in scene px. Default 56 (at 1080p) — marketing videos
718
+ * oversize the cursor so the action reads at a glance. */
719
+ size?: number
720
+ /** 'still' (default) or 'human' — sub-pixel drift while parked between
721
+ * actions, so a resting cursor never reads as a freeze-frame. */
722
+ idle?: 'still' | 'human'
723
+ /** Auto-morph arrow → pointer over the last stretch of a click travel.
724
+ * Default true. */
725
+ morph?: boolean
726
+ }
727
+
728
+ export type CursorMoveOpts = {
729
+ /** Travel time in seconds. Default 0.8. */
730
+ duration?: number
731
+ /** Default 'ease-in-out-cubic' — every action eases in-out. */
732
+ ease?: string
733
+ /** Where on the target to land, as fractions of its box. Default
734
+ * `{ x: 0.5, y: 0.52 }` — the corpus's observed natural click point. */
735
+ anchor?: { x?: number; y?: number }
736
+ /** Extra scene-px nudge after the anchor. */
737
+ offset?: { x?: number; y?: number }
738
+ /** First-action entrance: 'edge' (default — slides in from the nearest
739
+ * frame edge) or 'fade' (materializes just short of the target, for tight
740
+ * close-ups where the edge is far away). Ignored while already visible. */
741
+ enter?: 'edge' | 'fade'
742
+ }
743
+
744
+ /** What the cursor does after the action — the continuity contract.
745
+ * 'stay' (default) parks it where it is so the NEXT action continues from
746
+ * there; 'exit' leaves via the nearest edge; 'park' steps aside just below
747
+ * the target (clears it for typing or a reveal); a selector travels to that
748
+ * element and parks there. */
749
+ export type CursorThen = 'stay' | 'exit' | 'park' | string
750
+
751
+ export type CursorClickOpts = CursorMoveOpts & {
752
+ /** Target squash scale. 'auto' (default) derives from the element's size —
753
+ * big surfaces squash less. A number (e.g. 0.9) is used verbatim. */
754
+ press?: number | 'auto'
755
+ /** Squash time each way. Default 0.09. */
756
+ pressDuration?: number
757
+ then?: CursorThen
758
+ }
759
+
760
+ export type CursorDragOpts = CursorMoveOpts & {
761
+ /** Subtle scale lift on the payload while it is held. Default true. */
762
+ lift?: boolean
763
+ then?: CursorThen
764
+ }
765
+
766
+ export type CursorExitOpts = {
767
+ duration?: number
768
+ ease?: string
769
+ /** 'edge' (default) or 'fade'. */
770
+ via?: 'edge' | 'fade'
771
+ }
772
+
773
+ export type CursorActor = {
774
+ /** Move to an element (or continue there from the last position). */
775
+ to: (
776
+ target: string | Element,
777
+ opts?: CursorMoveOpts,
778
+ position?: gsap.Position
779
+ ) => { duration: number }
780
+ /** Travel + press: squashes the cursor AND the target, ordered so the
781
+ * click visibly precedes any result the direction schedules after it. */
782
+ click: (
783
+ target: string | Element,
784
+ opts?: CursorClickOpts,
785
+ position?: gsap.Position
786
+ ) => { duration: number }
787
+ /** Grab a payload and carry it: cursor and payload move as ONE tween pair
788
+ * with identical timing, so they cannot desync. `to` is an element/selector
789
+ * (payload centers on it) or a scene-px delta `{ x, y }`. */
790
+ drag: (
791
+ payload: string | Element,
792
+ to: string | Element | { x: number; y: number },
793
+ opts?: CursorDragOpts,
794
+ position?: gsap.Position
795
+ ) => { duration: number }
796
+ /** Leave the frame. The next action re-enters fresh. */
797
+ exit: (opts?: CursorExitOpts, position?: gsap.Position) => { duration: number }
798
+ /** Update the collab name chip on the timeline. */
799
+ label: (text: string, position?: gsap.Position) => void
800
+ }
801
+
802
+ export type CursorKit = CursorActor & {
803
+ /** Declare a named cursor — an independent actor with its own icon, color,
804
+ * label chip, and continuity state. Two actors animate concurrently just by
805
+ * overlapping their positions. */
806
+ actor: (name: string, opts?: CursorActorOpts) => CursorActor
807
+ }
808
+
809
+ export type PressOpts = {
810
+ /** Squash scale; 'auto' (default) derives from element size. */
811
+ amount?: number | 'auto'
812
+ /** Each way. Default 0.09. */
813
+ duration?: number
814
+ ease?: string
815
+ }
816
+
817
+ export type ScrollOpts = {
818
+ duration?: number
819
+ ease?: string
820
+ /** Where the target settles in the container: 0 top, 0.5 center (default),
821
+ * 1 bottom. */
822
+ align?: number
823
+ }
824
+
825
+ export type UiChange = {
826
+ /** Replace the element's text content. */
827
+ text?: string
828
+ /** Set an input's value. */
829
+ value?: string
830
+ /** Swap an image/video source. */
831
+ src?: string
832
+ /** Set attributes verbatim. */
833
+ attrs?: Record<string, string>
834
+ /** Add/remove classes (resolved against the authored class list). */
835
+ classes?: { add?: string[]; remove?: string[] }
836
+ /** Brief brightness pulse after the change, so the state change reads as an
837
+ * event rather than a glitch. */
838
+ flash?: boolean
839
+ }
840
+
841
+ export type ActionsKit = {
842
+ cursor: CursorKit
843
+ /** Squash without a cursor — keyboard sends, programmatic actions. */
844
+ press: (
845
+ target: string | Element,
846
+ opts?: PressOpts,
847
+ position?: gsap.Position
848
+ ) => { duration: number }
849
+ /** Animated scroll of a container until `to` (element, selector, or an
850
+ * absolute scrollTop) is in view. `duration: 0` sets it instantly for
851
+ * initial state. */
852
+ scroll: (
853
+ container: string | Element,
854
+ to: string | Element | number,
855
+ opts?: ScrollOpts,
856
+ position?: gsap.Position
857
+ ) => { duration: number }
858
+ /** Declarative hard UI state change on the timeline (seek-reversible).
859
+ * Instant by design — a cut is hard; `flash` adds the one animated accent. */
860
+ ui: {
861
+ set: (
862
+ target: string | Element,
863
+ change: UiChange,
864
+ position?: gsap.Position
865
+ ) => { duration: number }
866
+ }
867
+ }
868
+
869
+ // ─── Highlight — annotations that derive from their target ───────────────
870
+
871
+ export type HighlightRelease = 'fade' | 'undraw' | 'hold'
872
+
873
+ export type HighlightOpts = {
874
+ /** Stroke/fill color. Each primitive has its own sensible default. */
875
+ color?: string
876
+ /** Draw-on time in seconds. */
877
+ duration?: number
878
+ /** Default 'ease-in-out-cubic'. */
879
+ ease?: string
880
+ /** Seconds the highlight holds after drawing, or 'hold' to stay to the end.
881
+ * Default 'hold' — releasing is a deliberate choice. */
882
+ persist?: number | 'hold'
883
+ /** How it leaves when persist is a number: 'fade' (default) or 'undraw'. */
884
+ release?: Exclude<HighlightRelease, 'hold'>
885
+ /** Extra space around the target in scene px. Default derives from size. */
886
+ padding?: number
887
+ /** Stroke thickness in px. */
888
+ thickness?: number
889
+ /** What to fit: 'text' hugs the glyphs (a block element's box spans its
890
+ * full column — a marker on "99.99%" must not swallow the empty half of
891
+ * the card); 'box' takes the element box. Marker, circle, and underline
892
+ * default to 'text'; ring, glow, and spotlight default to 'box'. */
893
+ fit?: 'text' | 'box'
894
+ }
895
+
896
+ export type SpotlightOpts = {
897
+ /** How dark the rest of the frame goes (0..1). Default 0.55. */
898
+ dim?: number
899
+ /** Defocus everything OUTSIDE the cutout by this many px (the target stays
900
+ * sharp). 0 (default) dims only. Engine ceiling: 20. */
901
+ blur?: number
902
+ /** Gaussian softness of the cutout edge in px. Default 26. */
903
+ feather?: number
904
+ /** Breathing room between the element's edge and the cutout, px. Default 16. */
905
+ padding?: number
906
+ duration?: number
907
+ ease?: string
908
+ persist?: number | 'hold'
909
+ }
910
+
911
+ export type HighlightKit = {
912
+ /** Rounded ring draws itself around the element, following its corner
913
+ * radius. The workhorse product-tour annotation. */
914
+ ring: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
915
+ /** Hand-drawn ellipse loop with overshoot — red-pen annotation energy. */
916
+ circle: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
917
+ /** Highlighter-pen swipe behind the glyphs: a skewed band wipes across in
918
+ * multiply blend, sized from the line height. */
919
+ marker: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
920
+ /** Underline draw. Styles: 'straight' | 'marker' | 'sketch'. */
921
+ underline: (
922
+ target: string | Element,
923
+ style?: 'straight' | 'marker' | 'sketch',
924
+ opts?: HighlightOpts,
925
+ position?: gsap.Position
926
+ ) => { duration: number }
927
+ /** One soft glow pulse as a beat lands — light, not opacity. */
928
+ glow: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
929
+ /** Inverse move: everything EXCEPT the target dims behind a feathered
930
+ * cutout. The strongest "look here" in the vocabulary. */
931
+ spotlight: (target: string | Element, opts?: SpotlightOpts, position?: gsap.Position) => { duration: number }
932
+ }
933
+
934
+ // ─── Looks — scene light, shading, backdrops, hero shot, track ───────────
935
+
936
+ export type LightState = {
937
+ /** Direction the light COMES FROM, degrees clockwise from 12 o'clock.
938
+ * 135 = upper-left key (the portrait default). */
939
+ angle: number
940
+ /** 0 cool … 1 warm. Tints sweeps and rims. */
941
+ warmth: number
942
+ }
943
+
944
+ export type LightKit = {
945
+ /** Declare the scene light. Call once, early; sweeps, rims, and cast
946
+ * shadows derive their direction from it. */
947
+ set: (state: Partial<LightState>) => void
948
+ /** A glint sweeps across the element, angled with the scene light — the
949
+ * `logo.shine` move generalized to any surface. */
950
+ sweep: (
951
+ target: string | Element,
952
+ opts?: { duration?: number; intensity?: number; width?: number; ease?: string },
953
+ position?: gsap.Position
954
+ ) => { duration: number }
955
+ /** Edge light on the side facing the scene light — the premium product-
956
+ * shot rim. Fades in and holds. */
957
+ rim: (
958
+ target: string | Element,
959
+ opts?: { color?: string; intensity?: number; duration?: number },
960
+ position?: gsap.Position
961
+ ) => { duration: number }
962
+ }
963
+
964
+ export type ShadeKit = {
965
+ /** Soft shadow under the element, offset along the scene light. Replaces
966
+ * hand-tuned boxShadow strings and always agrees with every other shadow. */
967
+ cast: (
968
+ target: string | Element,
969
+ opts?: { distance?: number; softness?: number; opacity?: number; duration?: number },
970
+ position?: gsap.Position
971
+ ) => { duration: number }
972
+ /** Frame vignette, faded in once and held (like the grain plate — never
973
+ * cut, so it doesn't blink at hard cuts). */
974
+ vignette: (
975
+ opts?: { strength?: number; duration?: number },
976
+ position?: gsap.Position
977
+ ) => { duration: number }
978
+ /** Named color grade on the scene content. 'warm-product' | 'cool-saas' |
979
+ * 'noir'. Applied to the scene root, so overlays (cursors, highlights)
980
+ * stay ungraded and legible. */
981
+ grade: (
982
+ preset: 'warm-product' | 'cool-saas' | 'noir',
983
+ opts?: { duration?: number },
984
+ position?: gsap.Position
985
+ ) => { duration: number }
986
+ }
987
+
988
+ export type BackdropStyle =
989
+ | 'gradient-drift'
990
+ | 'orbs'
991
+ | 'dot-grid'
992
+ | 'paper'
993
+ // Designed grounds mined from web heroes (references/cards/web-hero-grounds.md):
994
+ | 'aurora' // Stripe — mesh gradient ribbons on a diagonal, drifting slowly
995
+ | 'threads' // Mintlify — fanned fine line strands sweeping the corners
996
+ | 'dot-matrix' // AgentMail — shimmering dot field, radial falloff off content
997
+ | 'grid-glow'
998
+
999
+ export type BackdropOpts = {
1000
+ /** 0..1 — scales amplitude, speed, and opacity together. The default 0.3
1001
+ * keeps the preset inside the sub-salient budget. */
1002
+ energy?: number
1003
+ /** Colors for the soft forms. Keep them near the scene's background
1004
+ * luminance — the preset caps opacity, but hue contrast is yours. */
1005
+ palette?: string[]
1006
+ /** Base fill the backdrop paints under its forms. The backdrop mounts
1007
+ * BEHIND the scene root, so a root that goes transparent needs the
1008
+ * backdrop to own the ground color. */
1009
+ base?: string
1010
+ /** FIXED BACKPLATE (default true): mount outside the camera-carrying
1011
+ * stage, so zooms and pans move the UI plane while the ground stays put —
1012
+ * the studio-cyclorama behavior of reference launch films. Set false to
1013
+ * make the backdrop part of the world the camera moves through. */
1014
+ fixed?: boolean
1015
+ }
1016
+
1017
+ export type BackdropHandle = {
1018
+ el: HTMLElement
1019
+ /** Duck the backdrop under a dense foreground beat. */
1020
+ calm: (position?: gsap.Position) => void
1021
+ /** Bring it back up during a hold. */
1022
+ lift: (position?: gsap.Position) => void
1023
+ }
1024
+
1025
+ export type HeroAnchor =
1026
+ | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'
1027
+ | { x: number; y: number }
1028
+
1029
+ export type HeroOpts = {
1030
+ /** Which region of the target the camera pushes toward. Default
1031
+ * 'bottom-right' — the submit-button corner. */
1032
+ anchor?: HeroAnchor
1033
+ /** How far in: scale ≈ (frame width / target width) × zoom. Default 1.35 —
1034
+ * the target overflows the frame, close-up territory. */
1035
+ zoom?: number
1036
+ /** Perspective tilt in degrees. Defaults derive from the anchor (the panel
1037
+ * leans its far corner away): e.g. bottom-right → pitch 7, yaw -9. */
1038
+ pitch?: number
1039
+ yaw?: number
1040
+ /** Push-in time. Default 0.9. */
1041
+ duration?: number
1042
+ /** Seconds held at the angle before returning. Default 1.2. */
1043
+ hold?: number
1044
+ /** Return time. Default 0.8; `settle: false` stays in the pose. */
1045
+ returnDuration?: number
1046
+ settle?: boolean
1047
+ /** Sheen sweep across the surface during the push. Default true. */
1048
+ sheen?: boolean
1049
+ /** Shadow deepens as the panel lifts. Default true. */
1050
+ shadow?: boolean
1051
+ ease?: string
1052
+ }
1053
+
1054
+ export type LooksKit = {
1055
+ light: LightKit
1056
+ shade: ShadeKit
1057
+ /** Mount an ambient animated backdrop behind the scene root. */
1058
+ backdrop: (style: BackdropStyle, opts?: BackdropOpts) => BackdropHandle
1059
+ /** The angle-zoom product shot: camera pushes toward one corner of the
1060
+ * target while it tilts in perspective, sheen and shadow responding.
1061
+ * A full round trip by default. */
1062
+ hero: (target: string | Element, opts?: HeroOpts, position?: gsap.Position) => { duration: number }
1063
+ /** Standalone perspective pose: tilt an element (not the stage) and hold.
1064
+ * Tilt back with pitch/yaw 0. */
1065
+ tilt: (
1066
+ target: string | Element,
1067
+ opts?: { pitch?: number; yaw?: number; duration?: number; ease?: string; perspective?: number },
1068
+ position?: gsap.Position
1069
+ ) => { duration: number }
1070
+ /** Camera FOLLOWS an element while it moves: per-frame the tracked
1071
+ * element's live position re-centers the camera at a locked occupancy.
1072
+ * Seek-deterministic — each frame is a pure function of timeline position
1073
+ * (the catch-up from `from` is an eased blend, not stateful smoothing).
1074
+ * AUTHOR IT AFTER the motion it follows, so it renders after the movers. */
1075
+ track: (
1076
+ target: string | Element,
1077
+ opts?: {
1078
+ /** Tracking window in seconds. Default 2. */
1079
+ duration?: number
1080
+ /** Target occupancy as fraction of frame height. Default 0.5. */
1081
+ height?: number
1082
+ /** Fraction of the window spent easing from `from` onto the target.
1083
+ * Default 0.25. */
1084
+ attach?: number
1085
+ /** Camera pose the track blends FROM (what the camera shows when the
1086
+ * window opens). Default full frame. */
1087
+ from?: { scale: number; x: number; y: number }
1088
+ /** After the window: 'hold' keeps the last framing (a later camera call
1089
+ * takes over); 'full' returns to the full frame. Default 'full'. */
1090
+ release?: 'hold' | 'full'
1091
+ },
1092
+ position?: gsap.Position
1093
+ ) => { duration: number }
1094
+ }
1095
+
1096
+ // ─── Sentence — a sentence as a stateful object ──────────────────────────
1097
+
1098
+ export type SentenceHandle = {
1099
+ el: HTMLElement
1100
+ append: (text: string, opts?: { stagger?: number; duration?: number; recenter?: boolean; stress?: boolean }, position?: Position) => { duration: number }
1101
+ swap: (slot: string, alternatives: string[], opts?: { interval?: number; stress?: boolean; align?: 'anchor' | 'recenter' }, position?: Position) => { duration: number }
1102
+ stress: (word: string, opts?: { color?: string; duration?: number }, position?: Position) => { duration: number }
1103
+ morphWord: (from: string, to: string, opts?: { duration?: number }, position?: Position) => { duration: number }
1104
+ chip: (word: string, media: HTMLElement, opts?: { height?: number; radius?: number }, position?: Position) => HTMLElement
1105
+ }
1106
+
1107
+ // ─── Morph — any element becomes any other element ───────────────────────
1108
+
1109
+ export type MorphOpts = {
1110
+ /** 'apple' = expo-out flight (~90% of the change in the first ~15% of the
1111
+ * time, long soft landing). 'spring' = the reference bounce: expo-out
1112
+ * flight, then a LIGHT damped sine rides the same path — calibrated from
1113
+ * the dnyxstudios AE expression (amp .04·v, freq 1.8Hz, decay e^-3t,
1114
+ * eased envelope). One motion, no delayed settle, ~one visible overshoot. */
1115
+ feel?: 'apple' | 'spring'
1116
+ /** Tune the 'spring' bounce. `amp` = first overshoot as a fraction of the
1117
+ * travel (default 0.025 — light), `freq` Hz (default 1.8), `decay` per
1118
+ * second (default 3), `flight` = fraction of `duration` spent on the main
1119
+ * move before the bounce tail (default 0.55). */
1120
+ bounce?: { amp?: number; freq?: number; decay?: number; flight?: number }
1121
+ duration?: number
1122
+ /** Flight curve — ANY GSAP ease works: house names, 'elastic.out(1, 0.4)',
1123
+ * 'back.out(1.8)'… Overshoots extrapolate past the target (kept renderable). */
1124
+ ease?: string
1125
+ /** 0..1 — center of the A→B crossfade along the flight. */
1126
+ handoff?: number
1127
+ /** Width of the crossfade as a fraction of the flight (default 0.3 —
1128
+ * tight; each side shows only near its natural shape). */
1129
+ crossfade?: number
1130
+ /** Arc lift in px (0 disables). Default: auto from travel distance. */
1131
+ arc?: number
1132
+ /** Part choreography (default 'stagger'): the target's direct children
1133
+ * arrive individually inside the landing box; the source's children are
1134
+ * absorbed as it leaves. `false` = containers only. */
1135
+ parts?: 'stagger' | false
1136
+ /** Sub-component matching (default 'auto'): elements tagged
1137
+ * `data-morph="name"` on BOTH sides (any depth) morph INTO each other —
1138
+ * each pair rides its own interpolated box inside the flight, so the
1139
+ * source's sub-component visibly becomes the target's. Untagged direct
1140
+ * children are greedily auto-paired by text overlap. `false` disables. */
1141
+ match?: 'auto' | false
1142
+ /** Landing overshoot on top of a non-spring flight. `true`/default object
1143
+ * for the soft house settle, `{ scale, duration, ease }` to tune (elastic
1144
+ * landings live here), `false` for none. 'spring' feel ignores this — its
1145
+ * ring is already in the flight. */
1146
+ settle?: boolean | { scale?: number; duration?: number; ease?: string }
1147
+ zIndex?: number
1148
+ }
1149
+
533
1150
  // ─── Engine — the direction context you write against ────────────────────
534
1151
 
535
1152
  /** Render directives a scene (or a kit, during the direction) hands to the
@@ -618,6 +1235,25 @@ export type FramingTarget = string | Element | Rect | FramingTarget[]
618
1235
 
619
1236
  export type Framing = { scale: number; x: number; y: number }
620
1237
 
1238
+ export type CameraFocusOpts = {
1239
+ /** Fraction of the composition height occupied by the subject. Default 0.7. */
1240
+ height?: number
1241
+ /** `contain` also caps width; `height` guarantees height and may crop horizontally. */
1242
+ mode?: 'contain' | 'height'
1243
+ /** Width cap used by `contain`. Default 0.9. */
1244
+ maxWidth?: number
1245
+ /** Optional safety ceiling. When hit, exact occupancy cannot be guaranteed. */
1246
+ maxScale?: number
1247
+ offsetX?: number
1248
+ offsetY?: number
1249
+ clamp?: boolean
1250
+ /** Camera travel time. Default 0.8 seconds. */
1251
+ duration?: number
1252
+ /** Camera travel ease. Defaults to the house dolly (`expo.inOut`); pass one
1253
+ * only when the user explicitly asks for a different camera feel. */
1254
+ ease?: string
1255
+ }
1256
+
621
1257
  export type FramingOpts = {
622
1258
  scale?: number // explicit zoom level; skips fitting
623
1259
  fitWidth?: number // target occupies this fraction of the frame width
@@ -631,6 +1267,13 @@ export type FramingOpts = {
631
1267
  export type TypeOpts = {
632
1268
  cps?: number // characters per second
633
1269
  caret?: boolean
1270
+ /** Opt-in invisible tracking head that glides smoothly along the typed
1271
+ * text — constant speed within each visual line, a quick eased drop at
1272
+ * every wrap. Hand it to `track()` or a camera framing; the caret itself
1273
+ * steps per character and wraps, so never track the caret directly.
1274
+ * `width` is the head's scene-px width (default 300); `height` is a
1275
+ * multiple of the line height (default 2.9). */
1276
+ head?: boolean | { width?: number; height?: number }
634
1277
  }
635
1278
 
636
1279
  export type TweenOverride = {
@@ -753,21 +1396,55 @@ export type DirectionContext = {
753
1396
  qa: (selector: string) => HTMLElement[]
754
1397
  camera: {
755
1398
  framing: (target: FramingTarget, opts?: FramingOpts) => Framing
1399
+ focus: (
1400
+ target: FramingTarget,
1401
+ opts?: CameraFocusOpts,
1402
+ position?: gsap.Position
1403
+ ) => Framing
756
1404
  full: () => Framing
1405
+ /** CROP framing: the reference resting shot — the target deliberately
1406
+ * OVERFLOWS the frame (`fill` × cover, default 1.4) and one side hugs a
1407
+ * frame edge (`anchor`: 'left' | 'right' | 'top' | 'bottom' | corners |
1408
+ * {x,y} fraction), the rest bleeding off. Returns a Framing: compose
1409
+ * with camera.set for the CUT-TO-CROP edit (wide→detail is a cut) or
1410
+ * camera.move for a motivated push. A full-frame centered app is an
1411
+ * establishing shot, used once — proof shots default to crop. */
1412
+ crop: (
1413
+ target: FramingTarget,
1414
+ opts?: {
1415
+ fill?: number
1416
+ anchor?:
1417
+ | 'left' | 'right' | 'top' | 'bottom'
1418
+ | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
1419
+ | 'center'
1420
+ | { x: number; y: number }
1421
+ }
1422
+ ) => Framing
757
1423
  set: (framing: Framing) => void
1424
+ /** The house dolly. DEFAULTS: `duration: 0.8`, `ease: 'expo.inOut'` —
1425
+ * a long dwell at both ends and a confident glide through the middle.
1426
+ * These defaults ARE the recommended camera physics: do NOT pass ease
1427
+ * or duration here unless the user explicitly asks for a different
1428
+ * camera feel. The two sanctioned exceptions: a slow ambient drift
1429
+ * (`duration: 2–5, ease: 'none'`) and an instant reframe behind a
1430
+ * covering element (`duration: 0.01`). Anything between 0.05–0.4s is
1431
+ * neither a move nor a cut — avoid it. `ease-in-out-cubic` is for
1432
+ * ELEMENT travel, never the camera. */
758
1433
  move: (
759
1434
  framing: Framing,
760
1435
  vars?: gsap.TweenVars,
761
1436
  position?: gsap.Position
762
1437
  ) => void
763
1438
  }
764
- /** Deterministic, seek-driven typing with a fake caret. */
1439
+ /** Deterministic, seek-driven typing with a fake caret. The caret follows
1440
+ * real layout (mirror-probed), so it wraps with the text in a textarea.
1441
+ * `head` (opt-in) is a smooth glide marker for camera tracking. */
765
1442
  typeInto: (
766
1443
  target: string | HTMLTextAreaElement | HTMLInputElement,
767
1444
  text: string,
768
1445
  opts?: TypeOpts,
769
1446
  position?: gsap.Position
770
- ) => { duration: number; caret: HTMLElement | null }
1447
+ ) => { duration: number; caret: HTMLElement | null; head: HTMLElement | null }
771
1448
  /** Rect the finished text will occupy in `target` (for framing the close-up). */
772
1449
  measureTypedLine: (
773
1450
  target: string | HTMLTextAreaElement | HTMLInputElement,
@@ -807,7 +1484,14 @@ export type DirectionContext = {
807
1484
  * 'pixels', 'iris', opts `{ color?, direction?: 'left'|'right'|'up'|
808
1485
  * 'down', duration?, cell?, at? }`. `out` with no position appends at the
809
1486
  * clip's end — the standard way to end a clip for a cut; `in` at position
810
- * 0 starts the clip covered and reveals it. All return `{ duration }` (full span including
1487
+ * 0 starts the clip covered and reveals it.
1488
+ * `transition.through(target, { swap?, land?, color?, duration?,
1489
+ * outDuration? }, position?)` is the GO-THROUGH: dive INTO an element
1490
+ * until its color takes over the frame, world-swap under the cover, pull
1491
+ * out into the new world. Never hand-roll a dive (a magic-scale
1492
+ * camera.move + a hand-timed swap): through() computes the exact cover
1493
+ * scale, measures the target at the dive's own start time, and guarantees
1494
+ * full coverage at the cut. All return `{ duration }` (full span including
811
1495
  * stagger) for sequencing, and accept `(target, effect, opts?, position?)`
812
1496
  * with opts `{ by?: 'chars'|'words'|'lines', duration?, stagger?, ease?,
813
1497
  * from?: 'start'|'center'|'end'|'edges' }`. */
@@ -896,6 +1580,138 @@ export type DirectionContext = {
896
1580
  * `tl.to(stage, { filter: 'blur(12px)' })` tweens, which produces two
897
1581
  * unsynced rows in the Tweak panel. */
898
1582
  fx: FilterKit
1583
+ /** Interaction actions — the cursor is a STATEFUL ACTOR, not a per-call
1584
+ * sprite. The engine tracks where every cursor is and whether it is on
1585
+ * screen: continuation from the last resting position is the default, and
1586
+ * entry/exit are explicit moments. First action enters from the nearest
1587
+ * frame edge (`enter: 'fade'` materializes near the target instead); the
1588
+ * next action continues from wherever the last one parked; `cursor.exit()`
1589
+ * leaves via the nearest edge. `cursor.click(el)` travels, auto-morphs
1590
+ * arrow→pointer on approach, and squashes cursor AND target (squash derived
1591
+ * from target size). `cursor.drag(payload, to)` carries the payload locked
1592
+ * to the cursor in one tween pair. `then: 'stay'|'exit'|'park'|selector`
1593
+ * says where the cursor goes after an action. `cursor.actor(name, { icon,
1594
+ * color, label })` declares independent named cursors (multi-user scenes —
1595
+ * 'collab' icon carries a Figma-style name chip); two actors animate
1596
+ * concurrently just by overlapping positions. Icons carry REGISTERED
1597
+ * hotspots, so click math cannot drift. Every action defaults to
1598
+ * `ease-in-out-cubic` and takes `duration`. */
1599
+ cursor: CursorKit
1600
+ /** Squash without a cursor — keyboard sends, programmatic actions. Amount
1601
+ * defaults from the element's size (big surfaces squash less). */
1602
+ press: ActionsKit['press']
1603
+ /** Animated scroll of a container until a target is in view (`duration: 0`
1604
+ * sets it instantly for initial state). */
1605
+ scroll: ActionsKit['scroll']
1606
+ /** Declarative hard UI state change on the timeline, seek-reversible:
1607
+ * `ui.set(el, { text?, value?, src?, attrs?, classes?, flash? })`. Instant
1608
+ * by design — a cut is hard; `flash` adds the one animated accent. */
1609
+ ui: ActionsKit['ui']
1610
+ /** Annotation primitives for pre-existing components — the "look here"
1611
+ * vocabulary. `highlight.ring(el)` draws a rounded ring around the element
1612
+ * (follows its corner radius); `circle` is the hand-drawn red-pen loop with
1613
+ * overshoot; `marker` is a highlighter swipe behind the glyphs (multiply
1614
+ * blend, sized from line height); `underline(el, 'sketch'|'straight'|
1615
+ * 'marker')`; `glow` is one soft halo pulse; `spotlight(el)` dims
1616
+ * everything EXCEPT the target behind a feathered cutout — the strongest
1617
+ * "look here" available. All geometry derives from the target; every
1618
+ * highlight takes `persist: 'hold' | seconds` and `release: 'fade' |
1619
+ * 'undraw'`, so nothing loiters. Pairs with the cursor kit: cursor.click +
1620
+ * highlight.ring is the product-tour grammar. */
1621
+ highlight: HighlightKit
1622
+ /** Scene lighting. `light.set({ angle, warmth })` declares THE light once —
1623
+ * sweeps, rims, and cast shadows all derive their direction from it, so
1624
+ * shadows can never disagree. `light.sweep(el)` runs a glint across any
1625
+ * surface; `light.rim(el)` is the premium edge light. */
1626
+ light: LooksKit['light']
1627
+ /** Shading and grade. `shade.cast(el)` — soft shadow offset along the
1628
+ * declared scene light; `shade.vignette()` — one held frame vignette;
1629
+ * `shade.grade('warm-product'|'cool-saas'|'noir')` — named color grade on
1630
+ * the scene content (overlays stay ungraded and legible). */
1631
+ shade: LooksKit['shade']
1632
+ /** Ambient animated backdrop mounted BEHIND the scene root (the scene's
1633
+ * own background must let it show through). Styles: 'gradient-drift',
1634
+ * 'orbs', 'dot-grid'. Presets are tuned to stay alive but sub-salient
1635
+ * (~0.5–2% pixel change per interval); `energy` scales within the budget.
1636
+ * The returned handle's `calm()`/`lift()` duck the backdrop under dense
1637
+ * foreground beats — audio ducking for pixels. */
1638
+ backdrop: LooksKit['backdrop']
1639
+ /** The angle-zoom product shot: the camera pushes toward one corner of the
1640
+ * target (`anchor: 'bottom-right'` = the submit-button corner) WHILE the
1641
+ * panel tilts a few degrees in perspective, a sheen sweeps the surface as
1642
+ * the angle changes, and the shadow deepens. Full round trip by default
1643
+ * (in → hold → settle); `settle: false` stays in the pose. Perspective is
1644
+ * applied to the target only, never the stage. */
1645
+ hero: LooksKit['hero']
1646
+ /** Standalone perspective pose for an element: `tilt(el, { pitch, yaw })`,
1647
+ * tilt back to 0 to flatten. */
1648
+ tilt: LooksKit['tilt']
1649
+ /** Camera FOLLOWS a moving element — per-frame re-centering at a locked
1650
+ * occupancy, seek-deterministic (the catch-up is an eased blend from a
1651
+ * declared start pose, not stateful smoothing). AUTHOR IT AFTER the motion
1652
+ * it follows. `track(el, { duration, height, release: 'full'|'hold' })`. */
1653
+ track: LooksKit['track']
1654
+ /** A sentence as a stateful object. `sentence(el)` takes over the element's
1655
+ * text (word spans) and returns a handle: `.append(words)` — words land
1656
+ * into the held line; `.swap(slot, [...])` — one word cycles in place;
1657
+ * `.stress(word)` — the one-accent-color semantic emphasis; `.morphWord`
1658
+ * ("it!" → "it?"); `.chip(word, media)` — a word slot holds a media chip.
1659
+ * New words are mounted hidden at build (layout stays stable); `recenter`
1660
+ * eases the container instead of live reflow. */
1661
+ sentence: SentenceKit['sentence']
1662
+ /** Universal shape morph: any element becomes any other element, a rect, or
1663
+ * `'frame'`. FLIP box + interpolated rounded-rect clip-path silhouette +
1664
+ * content-clone crossfade (`handoff` sets where). Author the TARGET hidden
1665
+ * (`autoAlpha: 0`); the morph reveals it at arrival. `fromPath`/`toPath`
1666
+ * accept real SVG paths for logo endcards. Export-worker safe. */
1667
+ morph: MorphKit['morph']
1668
+ /** The chip⇄full-bleed round trip: grows the element to cover the frame,
1669
+ * holds, returns home (`back: false` to stay). Scale IS the transition —
1670
+ * the object persists (Apple News+ grammar). */
1671
+ expand: MorphKit['expand']
1672
+ /** Elements arrive from BEHIND THE CAMERA onto the layout: each starts
1673
+ * oversized and offset radially (the whole UI plane blown up past the
1674
+ * lens), soft and translucent, then flies into its exact layout slot,
1675
+ * staggered. `dropIn('[data-panel] > *', { from: 3.2, stagger: 0.1 })`.
1676
+ * The container should already be visible; its CHILDREN drop in. */
1677
+ dropIn: EnsembleKit['dropIn']
1678
+ /** Elements fly in from scattered planar directions ('edges' | 'center' |
1679
+ * 'above') and settle into their exact layout slots — the tile-wall /
1680
+ * place-on-canvas grammar. Ends at exact identity. */
1681
+ assemble: EnsembleKit['assemble']
1682
+ /** Inverse of assemble: elements explode radially out of their layout and
1683
+ * fade. Layout is left clean (hidden, transforms reset). */
1684
+ scatter: EnsembleKit['scatter']
1685
+ /** SHOWPIECE — elements fly ballistically through the frame: tossed 'up'
1686
+ * and falling back out, raining 'down', or 'burst'. Items EXIT. Use on a
1687
+ * story payoff (something launched/generated/released), at most 1–2
1688
+ * showpieces per film — on an ambient beat this is noise. */
1689
+ fountain: EnsembleKit['fountain']
1690
+ /** SHOWPIECE — generated celebration burst (no authored elements):
1691
+ * particles with deterministic ballistics, spin, flutter, fade.
1692
+ * `confetti({ at: '[data-submit]' })`. ONE per film, ONLY on a genuine
1693
+ * payoff beat (shipped / delivered / won). */
1694
+ confetti: EnsembleKit['confetti']
1695
+ /** Gallery belt: the container's row of items scrolls continuously through
1696
+ * a clipped viewport (children duplicated once; the wrap is seamless).
1697
+ * A showcase beat — logos, covers, results — never ambient filler. */
1698
+ river: EnsembleKit['river']
1699
+ /** Bento reflow: tiles in one grid continuously trade space — spans change
1700
+ * and every tile FLIPs to its measured new slot, the grid staying full
1701
+ * while cells resize around each other. Ends on the authored layout. */
1702
+ bento: EnsembleKit['bento']
1703
+ /** Choreographed waiting — the beat AI products actually live in.
1704
+ * `stream.answer(el, text)` arrives word by word (machine writing, no
1705
+ * caret); `stream.skeleton(el)` shimmers placeholders then resolves into
1706
+ * the real content; `stream.status(el, ['Analyzing', 'Searching 3 sources'])`
1707
+ * names the work. All seek-deterministic. */
1708
+ stream: StreamKit
1709
+ /** The impact move. `slam(el)` lands type/cards hard from a perspective
1710
+ * turn; `slam(el, { direction: 'away' })` tips them back out of frame.
1711
+ * DISTORTION BELONGS TO MOTION: 'in' ends at exact identity, 'away' ends
1712
+ * invisible — an element must never come to rest sheared. Pair with
1713
+ * `blur()` across the fast window so the speed reads as smear, not a pop. */
1714
+ slam: ImpactKit['slam']
899
1715
  /** Masks, mattes, and blend composition — the things `clip-path` cannot do.
900
1716
  *
901
1717
  * `mask.reveal(el, shape, opts?, position?)` and `mask.hide(...)` animate