@impractical-ai/motion 0.1.6 → 0.1.7

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)
@@ -506,10 +506,113 @@ export type ThreeLayerOpts = {
506
506
  depth?: 'back' | 'front'
507
507
  }
508
508
 
509
+ export type ThreeCameraAngle =
510
+ | 'front'
511
+ | 'three-quarter-left'
512
+ | 'three-quarter-right'
513
+ | 'high'
514
+
515
+ export type ThreeCameraPose = {
516
+ position: { x: number; y: number; z: number }
517
+ target: { x: number; y: number; z: number }
518
+ roll?: number
519
+ near?: number
520
+ far?: number
521
+ }
522
+
523
+ export type ThreeFrameOpts = {
524
+ /** Projected fraction of frame height occupied by the subject. Default 0.7. */
525
+ height?: number
526
+ /** `contain` also enforces maxWidth; `height` may crop horizontally. */
527
+ mode?: 'contain' | 'height'
528
+ maxWidth?: number
529
+ angle?: ThreeCameraAngle
530
+ /** Subject-to-camera direction. Overrides angle. */
531
+ direction?: { x: number; y: number; z: number }
532
+ roll?: number
533
+ }
534
+
535
+ export type ThreeMoveOpts = {
536
+ duration?: number
537
+ /** Camera travel ease. Defaults to the house dolly (`expo.inOut`), same as
538
+ * the 2D camera; pass one only when the user asks for a different feel. */
539
+ ease?: string
540
+ }
541
+
542
+ export type ThreeOrbitOpts = ThreeFrameOpts &
543
+ ThreeMoveOpts & {
544
+ /** Horizontal orbit delta in degrees. */
545
+ azimuth?: number
546
+ /** Vertical orbit delta in degrees. */
547
+ elevation?: number
548
+ }
549
+
550
+ export type ThreeLightingPreset =
551
+ | 'ui-flat'
552
+ | 'product-soft'
553
+ | 'dramatic-rim'
554
+ | 'studio-neutral'
555
+
556
+ export type ThreeLightingOpts = {
557
+ /** Camera-relative stays flattering through a move; world-locked reveals form. */
558
+ mode?: 'camera-relative' | 'world-locked'
559
+ intensity?: number
560
+ exposure?: number
561
+ rimColor?: THREE.ColorRepresentation
562
+ }
563
+
564
+ export type ThreeLightRig = {
565
+ ambient: THREE.AmbientLight
566
+ key: THREE.DirectionalLight
567
+ fill: THREE.DirectionalLight
568
+ rim: THREE.DirectionalLight
569
+ update: () => void
570
+ }
571
+
572
+ export type ThreeCameraRig = {
573
+ /** Solve a pose whose projected subject bounds meet the framing constraint. */
574
+ frame: (subject: THREE.Object3D, opts?: ThreeFrameOpts) => ThreeCameraPose
575
+ /** Immediate deterministic camera cut to a resolved pose. */
576
+ set: (pose: ThreeCameraPose) => void
577
+ /** Tween XYZ, target, roll, and clipping planes while keeping the target locked. */
578
+ moveTo: (
579
+ pose: ThreeCameraPose,
580
+ opts?: ThreeMoveOpts,
581
+ position?: gsap.Position
582
+ ) => { duration: number }
583
+ /** Solve and dolly to a subject. */
584
+ focus: (
585
+ subject: THREE.Object3D,
586
+ frame?: ThreeFrameOpts,
587
+ move?: ThreeMoveOpts,
588
+ position?: gsap.Position
589
+ ) => { duration: number; pose: ThreeCameraPose }
590
+ /** Orbit in camera-local spherical coordinates and re-solve framing. */
591
+ orbit: (
592
+ subject: THREE.Object3D,
593
+ opts?: ThreeOrbitOpts,
594
+ position?: gsap.Position
595
+ ) => { duration: number; pose: ThreeCameraPose }
596
+ /** Current planned pose, including moves not yet reached by the playhead. */
597
+ pose: () => ThreeCameraPose
598
+ }
599
+
600
+ export type ThreeLightingRig = {
601
+ apply: (
602
+ subject: THREE.Object3D,
603
+ preset?: ThreeLightingPreset,
604
+ opts?: ThreeLightingOpts
605
+ ) => ThreeLightRig
606
+ }
607
+
509
608
  export type ThreeLayer = {
510
609
  /** The scene graph — add meshes, lights, groups. */
511
610
  scene: THREE.Scene
512
611
  camera: THREE.PerspectiveCamera
612
+ /** Subject-first XYZ camera control: exact projected framing, dolly, and orbit. */
613
+ rig: ThreeCameraRig
614
+ /** Deterministic key/fill/rim presets bound to a 3D subject. */
615
+ lighting: ThreeLightingRig
513
616
  /** The canvas element, for tweening opacity/filters like any element. */
514
617
  canvas: HTMLCanvasElement
515
618
  /** Drive the layer over [position, position+duration]. `update` runs every
@@ -530,6 +633,463 @@ export type ThreeKit = {
530
633
  layer: (opts?: ThreeLayerOpts) => ThreeLayer
531
634
  }
532
635
 
636
+ // ─── Actions — stateful cursor actors, press, scroll, drag ───────────────
637
+
638
+ /** Built-in cursor icons. `collab` is the Figma-style colored arrow + name
639
+ * chip for multi-user scenes. Custom art passes `{ svg, hotspot }` — the
640
+ * hotspot (0..1 of the icon box, where the tip sits) is part of the contract,
641
+ * so brand cursors cannot reopen the misaligned-click bug. */
642
+ export type CursorIconName =
643
+ | 'arrow'
644
+ | 'pointer'
645
+ | 'ibeam'
646
+ | 'grab'
647
+ | 'grabbing'
648
+ | 'crosshair'
649
+ | 'collab'
650
+
651
+ export type CursorIcon = CursorIconName | { svg: string; hotspot: { x: number; y: number } }
652
+
653
+ export type CursorActorOpts = {
654
+ /** Icon set. Default 'arrow'; named collaborators usually want 'collab'. */
655
+ icon?: CursorIcon
656
+ /** Tint for the collab arrow and label chip. */
657
+ color?: string
658
+ /** Figma-style name tag riding the cursor. */
659
+ label?: string
660
+ /** Icon box size in scene px. Default 56 (at 1080p) — marketing videos
661
+ * oversize the cursor so the action reads at a glance. */
662
+ size?: number
663
+ /** 'still' (default) or 'human' — sub-pixel drift while parked between
664
+ * actions, so a resting cursor never reads as a freeze-frame. */
665
+ idle?: 'still' | 'human'
666
+ /** Auto-morph arrow → pointer over the last stretch of a click travel.
667
+ * Default true. */
668
+ morph?: boolean
669
+ }
670
+
671
+ export type CursorMoveOpts = {
672
+ /** Travel time in seconds. Default 0.8. */
673
+ duration?: number
674
+ /** Default 'ease-in-out-cubic' — every action eases in-out. */
675
+ ease?: string
676
+ /** Where on the target to land, as fractions of its box. Default
677
+ * `{ x: 0.5, y: 0.52 }` — the corpus's observed natural click point. */
678
+ anchor?: { x?: number; y?: number }
679
+ /** Extra scene-px nudge after the anchor. */
680
+ offset?: { x?: number; y?: number }
681
+ /** First-action entrance: 'edge' (default — slides in from the nearest
682
+ * frame edge) or 'fade' (materializes just short of the target, for tight
683
+ * close-ups where the edge is far away). Ignored while already visible. */
684
+ enter?: 'edge' | 'fade'
685
+ }
686
+
687
+ /** What the cursor does after the action — the continuity contract.
688
+ * 'stay' (default) parks it where it is so the NEXT action continues from
689
+ * there; 'exit' leaves via the nearest edge; 'park' steps aside just below
690
+ * the target (clears it for typing or a reveal); a selector travels to that
691
+ * element and parks there. */
692
+ export type CursorThen = 'stay' | 'exit' | 'park' | string
693
+
694
+ export type CursorClickOpts = CursorMoveOpts & {
695
+ /** Target squash scale. 'auto' (default) derives from the element's size —
696
+ * big surfaces squash less. A number (e.g. 0.9) is used verbatim. */
697
+ press?: number | 'auto'
698
+ /** Squash time each way. Default 0.09. */
699
+ pressDuration?: number
700
+ then?: CursorThen
701
+ }
702
+
703
+ export type CursorDragOpts = CursorMoveOpts & {
704
+ /** Subtle scale lift on the payload while it is held. Default true. */
705
+ lift?: boolean
706
+ then?: CursorThen
707
+ }
708
+
709
+ export type CursorExitOpts = {
710
+ duration?: number
711
+ ease?: string
712
+ /** 'edge' (default) or 'fade'. */
713
+ via?: 'edge' | 'fade'
714
+ }
715
+
716
+ export type CursorActor = {
717
+ /** Move to an element (or continue there from the last position). */
718
+ to: (
719
+ target: string | Element,
720
+ opts?: CursorMoveOpts,
721
+ position?: gsap.Position
722
+ ) => { duration: number }
723
+ /** Travel + press: squashes the cursor AND the target, ordered so the
724
+ * click visibly precedes any result the direction schedules after it. */
725
+ click: (
726
+ target: string | Element,
727
+ opts?: CursorClickOpts,
728
+ position?: gsap.Position
729
+ ) => { duration: number }
730
+ /** Grab a payload and carry it: cursor and payload move as ONE tween pair
731
+ * with identical timing, so they cannot desync. `to` is an element/selector
732
+ * (payload centers on it) or a scene-px delta `{ x, y }`. */
733
+ drag: (
734
+ payload: string | Element,
735
+ to: string | Element | { x: number; y: number },
736
+ opts?: CursorDragOpts,
737
+ position?: gsap.Position
738
+ ) => { duration: number }
739
+ /** Leave the frame. The next action re-enters fresh. */
740
+ exit: (opts?: CursorExitOpts, position?: gsap.Position) => { duration: number }
741
+ /** Update the collab name chip on the timeline. */
742
+ label: (text: string, position?: gsap.Position) => void
743
+ }
744
+
745
+ export type CursorKit = CursorActor & {
746
+ /** Declare a named cursor — an independent actor with its own icon, color,
747
+ * label chip, and continuity state. Two actors animate concurrently just by
748
+ * overlapping their positions. */
749
+ actor: (name: string, opts?: CursorActorOpts) => CursorActor
750
+ }
751
+
752
+ export type PressOpts = {
753
+ /** Squash scale; 'auto' (default) derives from element size. */
754
+ amount?: number | 'auto'
755
+ /** Each way. Default 0.09. */
756
+ duration?: number
757
+ ease?: string
758
+ }
759
+
760
+ export type ScrollOpts = {
761
+ duration?: number
762
+ ease?: string
763
+ /** Where the target settles in the container: 0 top, 0.5 center (default),
764
+ * 1 bottom. */
765
+ align?: number
766
+ }
767
+
768
+ export type UiChange = {
769
+ /** Replace the element's text content. */
770
+ text?: string
771
+ /** Set an input's value. */
772
+ value?: string
773
+ /** Swap an image/video source. */
774
+ src?: string
775
+ /** Set attributes verbatim. */
776
+ attrs?: Record<string, string>
777
+ /** Add/remove classes (resolved against the authored class list). */
778
+ classes?: { add?: string[]; remove?: string[] }
779
+ /** Brief brightness pulse after the change, so the state change reads as an
780
+ * event rather than a glitch. */
781
+ flash?: boolean
782
+ }
783
+
784
+ export type ActionsKit = {
785
+ cursor: CursorKit
786
+ /** Squash without a cursor — keyboard sends, programmatic actions. */
787
+ press: (
788
+ target: string | Element,
789
+ opts?: PressOpts,
790
+ position?: gsap.Position
791
+ ) => { duration: number }
792
+ /** Animated scroll of a container until `to` (element, selector, or an
793
+ * absolute scrollTop) is in view. `duration: 0` sets it instantly for
794
+ * initial state. */
795
+ scroll: (
796
+ container: string | Element,
797
+ to: string | Element | number,
798
+ opts?: ScrollOpts,
799
+ position?: gsap.Position
800
+ ) => { duration: number }
801
+ /** Declarative hard UI state change on the timeline (seek-reversible).
802
+ * Instant by design — a cut is hard; `flash` adds the one animated accent. */
803
+ ui: {
804
+ set: (
805
+ target: string | Element,
806
+ change: UiChange,
807
+ position?: gsap.Position
808
+ ) => { duration: number }
809
+ }
810
+ }
811
+
812
+ // ─── Highlight — annotations that derive from their target ───────────────
813
+
814
+ export type HighlightRelease = 'fade' | 'undraw' | 'hold'
815
+
816
+ export type HighlightOpts = {
817
+ /** Stroke/fill color. Each primitive has its own sensible default. */
818
+ color?: string
819
+ /** Draw-on time in seconds. */
820
+ duration?: number
821
+ /** Default 'ease-in-out-cubic'. */
822
+ ease?: string
823
+ /** Seconds the highlight holds after drawing, or 'hold' to stay to the end.
824
+ * Default 'hold' — releasing is a deliberate choice. */
825
+ persist?: number | 'hold'
826
+ /** How it leaves when persist is a number: 'fade' (default) or 'undraw'. */
827
+ release?: Exclude<HighlightRelease, 'hold'>
828
+ /** Extra space around the target in scene px. Default derives from size. */
829
+ padding?: number
830
+ /** Stroke thickness in px. */
831
+ thickness?: number
832
+ /** What to fit: 'text' hugs the glyphs (a block element's box spans its
833
+ * full column — a marker on "99.99%" must not swallow the empty half of
834
+ * the card); 'box' takes the element box. Marker, circle, and underline
835
+ * default to 'text'; ring, glow, and spotlight default to 'box'. */
836
+ fit?: 'text' | 'box'
837
+ }
838
+
839
+ export type SpotlightOpts = {
840
+ /** How dark the rest of the frame goes (0..1). Default 0.55. */
841
+ dim?: number
842
+ /** Defocus everything OUTSIDE the cutout by this many px (the target stays
843
+ * sharp). 0 (default) dims only. Engine ceiling: 20. */
844
+ blur?: number
845
+ /** Gaussian softness of the cutout edge in px. Default 26. */
846
+ feather?: number
847
+ /** Breathing room between the element's edge and the cutout, px. Default 16. */
848
+ padding?: number
849
+ duration?: number
850
+ ease?: string
851
+ persist?: number | 'hold'
852
+ }
853
+
854
+ export type HighlightKit = {
855
+ /** Rounded ring draws itself around the element, following its corner
856
+ * radius. The workhorse product-tour annotation. */
857
+ ring: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
858
+ /** Hand-drawn ellipse loop with overshoot — red-pen annotation energy. */
859
+ circle: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
860
+ /** Highlighter-pen swipe behind the glyphs: a skewed band wipes across in
861
+ * multiply blend, sized from the line height. */
862
+ marker: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
863
+ /** Underline draw. Styles: 'straight' | 'marker' | 'sketch'. */
864
+ underline: (
865
+ target: string | Element,
866
+ style?: 'straight' | 'marker' | 'sketch',
867
+ opts?: HighlightOpts,
868
+ position?: gsap.Position
869
+ ) => { duration: number }
870
+ /** One soft glow pulse as a beat lands — light, not opacity. */
871
+ glow: (target: string | Element, opts?: HighlightOpts, position?: gsap.Position) => { duration: number }
872
+ /** Inverse move: everything EXCEPT the target dims behind a feathered
873
+ * cutout. The strongest "look here" in the vocabulary. */
874
+ spotlight: (target: string | Element, opts?: SpotlightOpts, position?: gsap.Position) => { duration: number }
875
+ }
876
+
877
+ // ─── Looks — scene light, shading, backdrops, hero shot, track ───────────
878
+
879
+ export type LightState = {
880
+ /** Direction the light COMES FROM, degrees clockwise from 12 o'clock.
881
+ * 135 = upper-left key (the portrait default). */
882
+ angle: number
883
+ /** 0 cool … 1 warm. Tints sweeps and rims. */
884
+ warmth: number
885
+ }
886
+
887
+ export type LightKit = {
888
+ /** Declare the scene light. Call once, early; sweeps, rims, and cast
889
+ * shadows derive their direction from it. */
890
+ set: (state: Partial<LightState>) => void
891
+ /** A glint sweeps across the element, angled with the scene light — the
892
+ * `logo.shine` move generalized to any surface. */
893
+ sweep: (
894
+ target: string | Element,
895
+ opts?: { duration?: number; intensity?: number; width?: number; ease?: string },
896
+ position?: gsap.Position
897
+ ) => { duration: number }
898
+ /** Edge light on the side facing the scene light — the premium product-
899
+ * shot rim. Fades in and holds. */
900
+ rim: (
901
+ target: string | Element,
902
+ opts?: { color?: string; intensity?: number; duration?: number },
903
+ position?: gsap.Position
904
+ ) => { duration: number }
905
+ }
906
+
907
+ export type ShadeKit = {
908
+ /** Soft shadow under the element, offset along the scene light. Replaces
909
+ * hand-tuned boxShadow strings and always agrees with every other shadow. */
910
+ cast: (
911
+ target: string | Element,
912
+ opts?: { distance?: number; softness?: number; opacity?: number; duration?: number },
913
+ position?: gsap.Position
914
+ ) => { duration: number }
915
+ /** Frame vignette, faded in once and held (like the grain plate — never
916
+ * cut, so it doesn't blink at hard cuts). */
917
+ vignette: (
918
+ opts?: { strength?: number; duration?: number },
919
+ position?: gsap.Position
920
+ ) => { duration: number }
921
+ /** Named color grade on the scene content. 'warm-product' | 'cool-saas' |
922
+ * 'noir'. Applied to the scene root, so overlays (cursors, highlights)
923
+ * stay ungraded and legible. */
924
+ grade: (
925
+ preset: 'warm-product' | 'cool-saas' | 'noir',
926
+ opts?: { duration?: number },
927
+ position?: gsap.Position
928
+ ) => { duration: number }
929
+ }
930
+
931
+ export type BackdropStyle =
932
+ | 'gradient-drift'
933
+ | 'orbs'
934
+ | 'dot-grid'
935
+ | 'paper'
936
+ // Designed grounds mined from web heroes (references/cards/web-hero-grounds.md):
937
+ | 'aurora' // Stripe — mesh gradient ribbons on a diagonal, drifting slowly
938
+ | 'threads' // Mintlify — fanned fine line strands sweeping the corners
939
+ | 'dot-matrix' // AgentMail — shimmering dot field, radial falloff off content
940
+ | 'grid-glow'
941
+
942
+ export type BackdropOpts = {
943
+ /** 0..1 — scales amplitude, speed, and opacity together. The default 0.3
944
+ * keeps the preset inside the sub-salient budget. */
945
+ energy?: number
946
+ /** Colors for the soft forms. Keep them near the scene's background
947
+ * luminance — the preset caps opacity, but hue contrast is yours. */
948
+ palette?: string[]
949
+ /** Base fill the backdrop paints under its forms. The backdrop mounts
950
+ * BEHIND the scene root, so a root that goes transparent needs the
951
+ * backdrop to own the ground color. */
952
+ base?: string
953
+ /** FIXED BACKPLATE (default true): mount outside the camera-carrying
954
+ * stage, so zooms and pans move the UI plane while the ground stays put —
955
+ * the studio-cyclorama behavior of reference launch films. Set false to
956
+ * make the backdrop part of the world the camera moves through. */
957
+ fixed?: boolean
958
+ }
959
+
960
+ export type BackdropHandle = {
961
+ el: HTMLElement
962
+ /** Duck the backdrop under a dense foreground beat. */
963
+ calm: (position?: gsap.Position) => void
964
+ /** Bring it back up during a hold. */
965
+ lift: (position?: gsap.Position) => void
966
+ }
967
+
968
+ export type HeroAnchor =
969
+ | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right' | 'center'
970
+ | { x: number; y: number }
971
+
972
+ export type HeroOpts = {
973
+ /** Which region of the target the camera pushes toward. Default
974
+ * 'bottom-right' — the submit-button corner. */
975
+ anchor?: HeroAnchor
976
+ /** How far in: scale ≈ (frame width / target width) × zoom. Default 1.35 —
977
+ * the target overflows the frame, close-up territory. */
978
+ zoom?: number
979
+ /** Perspective tilt in degrees. Defaults derive from the anchor (the panel
980
+ * leans its far corner away): e.g. bottom-right → pitch 7, yaw -9. */
981
+ pitch?: number
982
+ yaw?: number
983
+ /** Push-in time. Default 0.9. */
984
+ duration?: number
985
+ /** Seconds held at the angle before returning. Default 1.2. */
986
+ hold?: number
987
+ /** Return time. Default 0.8; `settle: false` stays in the pose. */
988
+ returnDuration?: number
989
+ settle?: boolean
990
+ /** Sheen sweep across the surface during the push. Default true. */
991
+ sheen?: boolean
992
+ /** Shadow deepens as the panel lifts. Default true. */
993
+ shadow?: boolean
994
+ ease?: string
995
+ }
996
+
997
+ export type LooksKit = {
998
+ light: LightKit
999
+ shade: ShadeKit
1000
+ /** Mount an ambient animated backdrop behind the scene root. */
1001
+ backdrop: (style: BackdropStyle, opts?: BackdropOpts) => BackdropHandle
1002
+ /** The angle-zoom product shot: camera pushes toward one corner of the
1003
+ * target while it tilts in perspective, sheen and shadow responding.
1004
+ * A full round trip by default. */
1005
+ hero: (target: string | Element, opts?: HeroOpts, position?: gsap.Position) => { duration: number }
1006
+ /** Standalone perspective pose: tilt an element (not the stage) and hold.
1007
+ * Tilt back with pitch/yaw 0. */
1008
+ tilt: (
1009
+ target: string | Element,
1010
+ opts?: { pitch?: number; yaw?: number; duration?: number; ease?: string; perspective?: number },
1011
+ position?: gsap.Position
1012
+ ) => { duration: number }
1013
+ /** Camera FOLLOWS an element while it moves: per-frame the tracked
1014
+ * element's live position re-centers the camera at a locked occupancy.
1015
+ * Seek-deterministic — each frame is a pure function of timeline position
1016
+ * (the catch-up from `from` is an eased blend, not stateful smoothing).
1017
+ * AUTHOR IT AFTER the motion it follows, so it renders after the movers. */
1018
+ track: (
1019
+ target: string | Element,
1020
+ opts?: {
1021
+ /** Tracking window in seconds. Default 2. */
1022
+ duration?: number
1023
+ /** Target occupancy as fraction of frame height. Default 0.5. */
1024
+ height?: number
1025
+ /** Fraction of the window spent easing from `from` onto the target.
1026
+ * Default 0.25. */
1027
+ attach?: number
1028
+ /** Camera pose the track blends FROM (what the camera shows when the
1029
+ * window opens). Default full frame. */
1030
+ from?: { scale: number; x: number; y: number }
1031
+ /** After the window: 'hold' keeps the last framing (a later camera call
1032
+ * takes over); 'full' returns to the full frame. Default 'full'. */
1033
+ release?: 'hold' | 'full'
1034
+ },
1035
+ position?: gsap.Position
1036
+ ) => { duration: number }
1037
+ }
1038
+
1039
+ // ─── Sentence — a sentence as a stateful object ──────────────────────────
1040
+
1041
+ export type SentenceHandle = {
1042
+ el: HTMLElement
1043
+ append: (text: string, opts?: { stagger?: number; duration?: number; recenter?: boolean; stress?: boolean }, position?: Position) => { duration: number }
1044
+ swap: (slot: string, alternatives: string[], opts?: { interval?: number; stress?: boolean; align?: 'anchor' | 'recenter' }, position?: Position) => { duration: number }
1045
+ stress: (word: string, opts?: { color?: string; duration?: number }, position?: Position) => { duration: number }
1046
+ morphWord: (from: string, to: string, opts?: { duration?: number }, position?: Position) => { duration: number }
1047
+ chip: (word: string, media: HTMLElement, opts?: { height?: number; radius?: number }, position?: Position) => HTMLElement
1048
+ }
1049
+
1050
+ // ─── Morph — any element becomes any other element ───────────────────────
1051
+
1052
+ export type MorphOpts = {
1053
+ /** 'apple' = expo-out flight (~90% of the change in the first ~15% of the
1054
+ * time, long soft landing). 'spring' = the reference bounce: expo-out
1055
+ * flight, then a LIGHT damped sine rides the same path — calibrated from
1056
+ * the dnyxstudios AE expression (amp .04·v, freq 1.8Hz, decay e^-3t,
1057
+ * eased envelope). One motion, no delayed settle, ~one visible overshoot. */
1058
+ feel?: 'apple' | 'spring'
1059
+ /** Tune the 'spring' bounce. `amp` = first overshoot as a fraction of the
1060
+ * travel (default 0.025 — light), `freq` Hz (default 1.8), `decay` per
1061
+ * second (default 3), `flight` = fraction of `duration` spent on the main
1062
+ * move before the bounce tail (default 0.55). */
1063
+ bounce?: { amp?: number; freq?: number; decay?: number; flight?: number }
1064
+ duration?: number
1065
+ /** Flight curve — ANY GSAP ease works: house names, 'elastic.out(1, 0.4)',
1066
+ * 'back.out(1.8)'… Overshoots extrapolate past the target (kept renderable). */
1067
+ ease?: string
1068
+ /** 0..1 — center of the A→B crossfade along the flight. */
1069
+ handoff?: number
1070
+ /** Width of the crossfade as a fraction of the flight (default 0.3 —
1071
+ * tight; each side shows only near its natural shape). */
1072
+ crossfade?: number
1073
+ /** Arc lift in px (0 disables). Default: auto from travel distance. */
1074
+ arc?: number
1075
+ /** Part choreography (default 'stagger'): the target's direct children
1076
+ * arrive individually inside the landing box; the source's children are
1077
+ * absorbed as it leaves. `false` = containers only. */
1078
+ parts?: 'stagger' | false
1079
+ /** Sub-component matching (default 'auto'): elements tagged
1080
+ * `data-morph="name"` on BOTH sides (any depth) morph INTO each other —
1081
+ * each pair rides its own interpolated box inside the flight, so the
1082
+ * source's sub-component visibly becomes the target's. Untagged direct
1083
+ * children are greedily auto-paired by text overlap. `false` disables. */
1084
+ match?: 'auto' | false
1085
+ /** Landing overshoot on top of a non-spring flight. `true`/default object
1086
+ * for the soft house settle, `{ scale, duration, ease }` to tune (elastic
1087
+ * landings live here), `false` for none. 'spring' feel ignores this — its
1088
+ * ring is already in the flight. */
1089
+ settle?: boolean | { scale?: number; duration?: number; ease?: string }
1090
+ zIndex?: number
1091
+ }
1092
+
533
1093
  // ─── Engine — the direction context you write against ────────────────────
534
1094
 
535
1095
  /** Render directives a scene (or a kit, during the direction) hands to the
@@ -618,6 +1178,25 @@ export type FramingTarget = string | Element | Rect | FramingTarget[]
618
1178
 
619
1179
  export type Framing = { scale: number; x: number; y: number }
620
1180
 
1181
+ export type CameraFocusOpts = {
1182
+ /** Fraction of the composition height occupied by the subject. Default 0.7. */
1183
+ height?: number
1184
+ /** `contain` also caps width; `height` guarantees height and may crop horizontally. */
1185
+ mode?: 'contain' | 'height'
1186
+ /** Width cap used by `contain`. Default 0.9. */
1187
+ maxWidth?: number
1188
+ /** Optional safety ceiling. When hit, exact occupancy cannot be guaranteed. */
1189
+ maxScale?: number
1190
+ offsetX?: number
1191
+ offsetY?: number
1192
+ clamp?: boolean
1193
+ /** Camera travel time. Default 0.8 seconds. */
1194
+ duration?: number
1195
+ /** Camera travel ease. Defaults to the house dolly (`expo.inOut`); pass one
1196
+ * only when the user explicitly asks for a different camera feel. */
1197
+ ease?: string
1198
+ }
1199
+
621
1200
  export type FramingOpts = {
622
1201
  scale?: number // explicit zoom level; skips fitting
623
1202
  fitWidth?: number // target occupies this fraction of the frame width
@@ -631,6 +1210,13 @@ export type FramingOpts = {
631
1210
  export type TypeOpts = {
632
1211
  cps?: number // characters per second
633
1212
  caret?: boolean
1213
+ /** Opt-in invisible tracking head that glides smoothly along the typed
1214
+ * text — constant speed within each visual line, a quick eased drop at
1215
+ * every wrap. Hand it to `track()` or a camera framing; the caret itself
1216
+ * steps per character and wraps, so never track the caret directly.
1217
+ * `width` is the head's scene-px width (default 300); `height` is a
1218
+ * multiple of the line height (default 2.9). */
1219
+ head?: boolean | { width?: number; height?: number }
634
1220
  }
635
1221
 
636
1222
  export type TweenOverride = {
@@ -753,21 +1339,55 @@ export type DirectionContext = {
753
1339
  qa: (selector: string) => HTMLElement[]
754
1340
  camera: {
755
1341
  framing: (target: FramingTarget, opts?: FramingOpts) => Framing
1342
+ focus: (
1343
+ target: FramingTarget,
1344
+ opts?: CameraFocusOpts,
1345
+ position?: gsap.Position
1346
+ ) => Framing
756
1347
  full: () => Framing
1348
+ /** CROP framing: the reference resting shot — the target deliberately
1349
+ * OVERFLOWS the frame (`fill` × cover, default 1.4) and one side hugs a
1350
+ * frame edge (`anchor`: 'left' | 'right' | 'top' | 'bottom' | corners |
1351
+ * {x,y} fraction), the rest bleeding off. Returns a Framing: compose
1352
+ * with camera.set for the CUT-TO-CROP edit (wide→detail is a cut) or
1353
+ * camera.move for a motivated push. A full-frame centered app is an
1354
+ * establishing shot, used once — proof shots default to crop. */
1355
+ crop: (
1356
+ target: FramingTarget,
1357
+ opts?: {
1358
+ fill?: number
1359
+ anchor?:
1360
+ | 'left' | 'right' | 'top' | 'bottom'
1361
+ | 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right'
1362
+ | 'center'
1363
+ | { x: number; y: number }
1364
+ }
1365
+ ) => Framing
757
1366
  set: (framing: Framing) => void
1367
+ /** The house dolly. DEFAULTS: `duration: 0.8`, `ease: 'expo.inOut'` —
1368
+ * a long dwell at both ends and a confident glide through the middle.
1369
+ * These defaults ARE the recommended camera physics: do NOT pass ease
1370
+ * or duration here unless the user explicitly asks for a different
1371
+ * camera feel. The two sanctioned exceptions: a slow ambient drift
1372
+ * (`duration: 2–5, ease: 'none'`) and an instant reframe behind a
1373
+ * covering element (`duration: 0.01`). Anything between 0.05–0.4s is
1374
+ * neither a move nor a cut — avoid it. `ease-in-out-cubic` is for
1375
+ * ELEMENT travel, never the camera. */
758
1376
  move: (
759
1377
  framing: Framing,
760
1378
  vars?: gsap.TweenVars,
761
1379
  position?: gsap.Position
762
1380
  ) => void
763
1381
  }
764
- /** Deterministic, seek-driven typing with a fake caret. */
1382
+ /** Deterministic, seek-driven typing with a fake caret. The caret follows
1383
+ * real layout (mirror-probed), so it wraps with the text in a textarea.
1384
+ * `head` (opt-in) is a smooth glide marker for camera tracking. */
765
1385
  typeInto: (
766
1386
  target: string | HTMLTextAreaElement | HTMLInputElement,
767
1387
  text: string,
768
1388
  opts?: TypeOpts,
769
1389
  position?: gsap.Position
770
- ) => { duration: number; caret: HTMLElement | null }
1390
+ ) => { duration: number; caret: HTMLElement | null; head: HTMLElement | null }
771
1391
  /** Rect the finished text will occupy in `target` (for framing the close-up). */
772
1392
  measureTypedLine: (
773
1393
  target: string | HTMLTextAreaElement | HTMLInputElement,
@@ -896,6 +1516,138 @@ export type DirectionContext = {
896
1516
  * `tl.to(stage, { filter: 'blur(12px)' })` tweens, which produces two
897
1517
  * unsynced rows in the Tweak panel. */
898
1518
  fx: FilterKit
1519
+ /** Interaction actions — the cursor is a STATEFUL ACTOR, not a per-call
1520
+ * sprite. The engine tracks where every cursor is and whether it is on
1521
+ * screen: continuation from the last resting position is the default, and
1522
+ * entry/exit are explicit moments. First action enters from the nearest
1523
+ * frame edge (`enter: 'fade'` materializes near the target instead); the
1524
+ * next action continues from wherever the last one parked; `cursor.exit()`
1525
+ * leaves via the nearest edge. `cursor.click(el)` travels, auto-morphs
1526
+ * arrow→pointer on approach, and squashes cursor AND target (squash derived
1527
+ * from target size). `cursor.drag(payload, to)` carries the payload locked
1528
+ * to the cursor in one tween pair. `then: 'stay'|'exit'|'park'|selector`
1529
+ * says where the cursor goes after an action. `cursor.actor(name, { icon,
1530
+ * color, label })` declares independent named cursors (multi-user scenes —
1531
+ * 'collab' icon carries a Figma-style name chip); two actors animate
1532
+ * concurrently just by overlapping positions. Icons carry REGISTERED
1533
+ * hotspots, so click math cannot drift. Every action defaults to
1534
+ * `ease-in-out-cubic` and takes `duration`. */
1535
+ cursor: CursorKit
1536
+ /** Squash without a cursor — keyboard sends, programmatic actions. Amount
1537
+ * defaults from the element's size (big surfaces squash less). */
1538
+ press: ActionsKit['press']
1539
+ /** Animated scroll of a container until a target is in view (`duration: 0`
1540
+ * sets it instantly for initial state). */
1541
+ scroll: ActionsKit['scroll']
1542
+ /** Declarative hard UI state change on the timeline, seek-reversible:
1543
+ * `ui.set(el, { text?, value?, src?, attrs?, classes?, flash? })`. Instant
1544
+ * by design — a cut is hard; `flash` adds the one animated accent. */
1545
+ ui: ActionsKit['ui']
1546
+ /** Annotation primitives for pre-existing components — the "look here"
1547
+ * vocabulary. `highlight.ring(el)` draws a rounded ring around the element
1548
+ * (follows its corner radius); `circle` is the hand-drawn red-pen loop with
1549
+ * overshoot; `marker` is a highlighter swipe behind the glyphs (multiply
1550
+ * blend, sized from line height); `underline(el, 'sketch'|'straight'|
1551
+ * 'marker')`; `glow` is one soft halo pulse; `spotlight(el)` dims
1552
+ * everything EXCEPT the target behind a feathered cutout — the strongest
1553
+ * "look here" available. All geometry derives from the target; every
1554
+ * highlight takes `persist: 'hold' | seconds` and `release: 'fade' |
1555
+ * 'undraw'`, so nothing loiters. Pairs with the cursor kit: cursor.click +
1556
+ * highlight.ring is the product-tour grammar. */
1557
+ highlight: HighlightKit
1558
+ /** Scene lighting. `light.set({ angle, warmth })` declares THE light once —
1559
+ * sweeps, rims, and cast shadows all derive their direction from it, so
1560
+ * shadows can never disagree. `light.sweep(el)` runs a glint across any
1561
+ * surface; `light.rim(el)` is the premium edge light. */
1562
+ light: LooksKit['light']
1563
+ /** Shading and grade. `shade.cast(el)` — soft shadow offset along the
1564
+ * declared scene light; `shade.vignette()` — one held frame vignette;
1565
+ * `shade.grade('warm-product'|'cool-saas'|'noir')` — named color grade on
1566
+ * the scene content (overlays stay ungraded and legible). */
1567
+ shade: LooksKit['shade']
1568
+ /** Ambient animated backdrop mounted BEHIND the scene root (the scene's
1569
+ * own background must let it show through). Styles: 'gradient-drift',
1570
+ * 'orbs', 'dot-grid'. Presets are tuned to stay alive but sub-salient
1571
+ * (~0.5–2% pixel change per interval); `energy` scales within the budget.
1572
+ * The returned handle's `calm()`/`lift()` duck the backdrop under dense
1573
+ * foreground beats — audio ducking for pixels. */
1574
+ backdrop: LooksKit['backdrop']
1575
+ /** The angle-zoom product shot: the camera pushes toward one corner of the
1576
+ * target (`anchor: 'bottom-right'` = the submit-button corner) WHILE the
1577
+ * panel tilts a few degrees in perspective, a sheen sweeps the surface as
1578
+ * the angle changes, and the shadow deepens. Full round trip by default
1579
+ * (in → hold → settle); `settle: false` stays in the pose. Perspective is
1580
+ * applied to the target only, never the stage. */
1581
+ hero: LooksKit['hero']
1582
+ /** Standalone perspective pose for an element: `tilt(el, { pitch, yaw })`,
1583
+ * tilt back to 0 to flatten. */
1584
+ tilt: LooksKit['tilt']
1585
+ /** Camera FOLLOWS a moving element — per-frame re-centering at a locked
1586
+ * occupancy, seek-deterministic (the catch-up is an eased blend from a
1587
+ * declared start pose, not stateful smoothing). AUTHOR IT AFTER the motion
1588
+ * it follows. `track(el, { duration, height, release: 'full'|'hold' })`. */
1589
+ track: LooksKit['track']
1590
+ /** A sentence as a stateful object. `sentence(el)` takes over the element's
1591
+ * text (word spans) and returns a handle: `.append(words)` — words land
1592
+ * into the held line; `.swap(slot, [...])` — one word cycles in place;
1593
+ * `.stress(word)` — the one-accent-color semantic emphasis; `.morphWord`
1594
+ * ("it!" → "it?"); `.chip(word, media)` — a word slot holds a media chip.
1595
+ * New words are mounted hidden at build (layout stays stable); `recenter`
1596
+ * eases the container instead of live reflow. */
1597
+ sentence: SentenceKit['sentence']
1598
+ /** Universal shape morph: any element becomes any other element, a rect, or
1599
+ * `'frame'`. FLIP box + interpolated rounded-rect clip-path silhouette +
1600
+ * content-clone crossfade (`handoff` sets where). Author the TARGET hidden
1601
+ * (`autoAlpha: 0`); the morph reveals it at arrival. `fromPath`/`toPath`
1602
+ * accept real SVG paths for logo endcards. Export-worker safe. */
1603
+ morph: MorphKit['morph']
1604
+ /** The chip⇄full-bleed round trip: grows the element to cover the frame,
1605
+ * holds, returns home (`back: false` to stay). Scale IS the transition —
1606
+ * the object persists (Apple News+ grammar). */
1607
+ expand: MorphKit['expand']
1608
+ /** Elements arrive from BEHIND THE CAMERA onto the layout: each starts
1609
+ * oversized and offset radially (the whole UI plane blown up past the
1610
+ * lens), soft and translucent, then flies into its exact layout slot,
1611
+ * staggered. `dropIn('[data-panel] > *', { from: 3.2, stagger: 0.1 })`.
1612
+ * The container should already be visible; its CHILDREN drop in. */
1613
+ dropIn: EnsembleKit['dropIn']
1614
+ /** Elements fly in from scattered planar directions ('edges' | 'center' |
1615
+ * 'above') and settle into their exact layout slots — the tile-wall /
1616
+ * place-on-canvas grammar. Ends at exact identity. */
1617
+ assemble: EnsembleKit['assemble']
1618
+ /** Inverse of assemble: elements explode radially out of their layout and
1619
+ * fade. Layout is left clean (hidden, transforms reset). */
1620
+ scatter: EnsembleKit['scatter']
1621
+ /** SHOWPIECE — elements fly ballistically through the frame: tossed 'up'
1622
+ * and falling back out, raining 'down', or 'burst'. Items EXIT. Use on a
1623
+ * story payoff (something launched/generated/released), at most 1–2
1624
+ * showpieces per film — on an ambient beat this is noise. */
1625
+ fountain: EnsembleKit['fountain']
1626
+ /** SHOWPIECE — generated celebration burst (no authored elements):
1627
+ * particles with deterministic ballistics, spin, flutter, fade.
1628
+ * `confetti({ at: '[data-submit]' })`. ONE per film, ONLY on a genuine
1629
+ * payoff beat (shipped / delivered / won). */
1630
+ confetti: EnsembleKit['confetti']
1631
+ /** Gallery belt: the container's row of items scrolls continuously through
1632
+ * a clipped viewport (children duplicated once; the wrap is seamless).
1633
+ * A showcase beat — logos, covers, results — never ambient filler. */
1634
+ river: EnsembleKit['river']
1635
+ /** Bento reflow: tiles in one grid continuously trade space — spans change
1636
+ * and every tile FLIPs to its measured new slot, the grid staying full
1637
+ * while cells resize around each other. Ends on the authored layout. */
1638
+ bento: EnsembleKit['bento']
1639
+ /** Choreographed waiting — the beat AI products actually live in.
1640
+ * `stream.answer(el, text)` arrives word by word (machine writing, no
1641
+ * caret); `stream.skeleton(el)` shimmers placeholders then resolves into
1642
+ * the real content; `stream.status(el, ['Analyzing', 'Searching 3 sources'])`
1643
+ * names the work. All seek-deterministic. */
1644
+ stream: StreamKit
1645
+ /** The impact move. `slam(el)` lands type/cards hard from a perspective
1646
+ * turn; `slam(el, { direction: 'away' })` tips them back out of frame.
1647
+ * DISTORTION BELONGS TO MOTION: 'in' ends at exact identity, 'away' ends
1648
+ * invisible — an element must never come to rest sheared. Pair with
1649
+ * `blur()` across the fast window so the speed reads as smear, not a pop. */
1650
+ slam: ImpactKit['slam']
899
1651
  /** Masks, mattes, and blend composition — the things `clip-path` cannot do.
900
1652
  *
901
1653
  * `mask.reveal(el, shape, opts?, position?)` and `mask.hide(...)` animate