@impractical-ai/motion 0.1.5 → 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.
@@ -0,0 +1,218 @@
1
+ // The live-sentence kit: a sentence is a stateful object, not a static block.
2
+ // Words land into it, slots cycle, one accent color stresses the pivot, words
3
+ // morph, and a word slot can hold a media chip. Evidence: Dropbox Dash,
4
+ // Pitch 2.0, Apple News+ (see references/cards/ + docs/motion-vocabulary-log.md).
5
+ //
6
+ // Determinism: every state change is a tween on the master timeline. New
7
+ // words are mounted at build time (hidden, holding layout space) so seeking
8
+ // is pure; optional `recenter` shifts the container by half the added width
9
+ // instead of re-flowing live.
10
+ import { resolveTarget } from './kits'
11
+
12
+ type Position = number | string | undefined
13
+
14
+ const ENTER_EASE = 'ease-out-quint'
15
+ const MOVE_EASE = 'ease-in-out-cubic'
16
+ const DEFAULT_ACCENT = '#2f6bff'
17
+
18
+ export type SentenceHandle = {
19
+ el: HTMLElement
20
+ append: (text: string, opts?: { stagger?: number; duration?: number; recenter?: boolean; stress?: boolean }, position?: Position) => { duration: number }
21
+ swap: (slot: string, alternatives: string[], opts?: { interval?: number; stress?: boolean; align?: 'anchor' | 'recenter' }, position?: Position) => { duration: number }
22
+ stress: (word: string, opts?: { color?: string; duration?: number }, position?: Position) => { duration: number }
23
+ morphWord: (from: string, to: string, opts?: { duration?: number }, position?: Position) => { duration: number }
24
+ chip: (word: string, media: HTMLElement, opts?: { height?: number; radius?: number }, position?: Position) => HTMLElement
25
+ }
26
+
27
+ export function createSentenceKit(deps: {
28
+ tl: any
29
+ gsap: any
30
+ stage: HTMLElement
31
+ accent?: string
32
+ }) {
33
+ const { tl, gsap, stage } = deps
34
+ const accent = deps.accent ?? DEFAULT_ACCENT
35
+ const at = (position: Position): number => (typeof position === 'number' ? position : tl.duration())
36
+
37
+ const measureWith = (el: HTMLElement): ((s: string) => number) => {
38
+ const style = getComputedStyle(el)
39
+ const ctx = document.createElement('canvas').getContext('2d')!
40
+ ctx.font = `${style.fontStyle} ${style.fontWeight} ${style.fontSize} ${style.fontFamily}`
41
+ return (s) => ctx.measureText(s).width
42
+ }
43
+
44
+ const wordSpan = (word: string): HTMLElement => {
45
+ const span = document.createElement('span')
46
+ span.dataset.word = word
47
+ span.style.cssText = 'display:inline-block;white-space:pre;'
48
+ span.textContent = word
49
+ return span
50
+ }
51
+
52
+ const sentence = (target: string | Element): SentenceHandle => {
53
+ const el = resolveTarget(stage, target, 'sentence')
54
+ // Take over: rebuild existing text as word spans so every word is a target.
55
+ const initial = (el.textContent ?? '').trim()
56
+ el.textContent = ''
57
+ const spans: HTMLElement[] = []
58
+ const mountWords = (text: string): HTMLElement[] => {
59
+ const out: HTMLElement[] = []
60
+ for (const word of text.split(/\s+/).filter(Boolean)) {
61
+ if (spans.length || out.length) {
62
+ const gap = document.createElement('span')
63
+ gap.textContent = ' '
64
+ gap.style.whiteSpace = 'pre'
65
+ el.appendChild(gap)
66
+ }
67
+ const span = wordSpan(word)
68
+ el.appendChild(span)
69
+ out.push(span)
70
+ spans.push(span)
71
+ }
72
+ return out
73
+ }
74
+ if (initial) mountWords(initial)
75
+
76
+ const find = (word: string): HTMLElement | null =>
77
+ spans.find((s) => s.dataset.word === word || s.textContent === word) ?? null
78
+
79
+ const handle: SentenceHandle = {
80
+ el,
81
+
82
+ append: (text, opts = {}, position) => {
83
+ const start = at(position)
84
+ const added = mountWords(text)
85
+ const stagger = opts.stagger ?? 0.14
86
+ const duration = opts.duration ?? 0.45
87
+ const measure = measureWith(el)
88
+ // Hidden from frame 0, holding space — entrance is a pure tween.
89
+ for (const [i, span] of added.entries()) {
90
+ gsap.set(span, { autoAlpha: 0, yPercent: 55 })
91
+ tl.to(span, { autoAlpha: 1, yPercent: 0, duration, ease: ENTER_EASE }, start + i * stagger)
92
+ if (opts.stress) tl.set(span, { color: accent }, start + i * stagger)
93
+ }
94
+ if (opts.recenter) {
95
+ // Visually re-center without live reflow: pre-shift by half the
96
+ // added width, then ease back to rest as the words land.
97
+ const addedWidth = added.reduce((w, s) => w + measure(`${s.textContent} `), 0)
98
+ gsap.set(el, { x: addedWidth / 2 })
99
+ tl.to(el, { x: 0, duration: duration + stagger * added.length, ease: MOVE_EASE }, start)
100
+ }
101
+ return { duration: duration + stagger * Math.max(0, added.length - 1) }
102
+ },
103
+
104
+ swap: (slot, alternatives, opts = {}, position) => {
105
+ const span = find(slot)
106
+ if (!span) return { duration: 0 }
107
+ const start = at(position)
108
+ const interval = opts.interval ?? 0.7
109
+ // 'anchor' (default): the slot reserves the WIDEST alternative up
110
+ // front, so a centered sentence never shifts as words cycle.
111
+ // 'recenter': the slot width animates and the line re-flows live.
112
+ const align = opts.align ?? 'anchor'
113
+ const measure = measureWith(el)
114
+ // The slot becomes a fixed-height window; alternates rise through it.
115
+ const holder = document.createElement('span')
116
+ holder.style.cssText = 'display:inline-block;position:relative;white-space:pre;vertical-align:baseline;'
117
+ span.parentElement?.insertBefore(holder, span)
118
+ holder.appendChild(span)
119
+ span.style.display = 'inline-block'
120
+ const layers: HTMLElement[] = [span]
121
+ for (const alt of alternatives) {
122
+ const layer = wordSpan(alt)
123
+ layer.style.cssText += 'position:absolute;left:0;top:0;'
124
+ if (opts.stress !== false) layer.style.color = accent
125
+ gsap.set(layer, { autoAlpha: 0 })
126
+ holder.appendChild(layer)
127
+ layers.push(layer)
128
+ // Later verbs must be able to find the word that ends up visible.
129
+ spans.push(layer)
130
+ }
131
+ if (opts.stress !== false) span.style.color = accent
132
+ const widths = [measure(span.textContent ?? ''), ...alternatives.map((a) => measure(a))]
133
+ if (align === 'anchor') {
134
+ gsap.set(holder, { width: Math.max(...widths) })
135
+ } else {
136
+ gsap.set(holder, { width: widths[0] })
137
+ }
138
+ for (let i = 1; i < layers.length; i += 1) {
139
+ const t = start + (i - 1) * interval
140
+ tl.to(layers[i - 1], { autoAlpha: 0, yPercent: -50, duration: 0.28, ease: 'ease-in-quad' }, t)
141
+ tl.fromTo(layers[i], { autoAlpha: 0, yPercent: 55 }, { autoAlpha: 1, yPercent: 0, duration: 0.34, ease: ENTER_EASE }, t + 0.06)
142
+ if (align === 'recenter') {
143
+ tl.to(holder, { width: widths[i], duration: 0.3, ease: MOVE_EASE }, t)
144
+ }
145
+ }
146
+ return { duration: (layers.length - 1) * interval + 0.4 }
147
+ },
148
+
149
+ stress: (word, opts = {}, position) => {
150
+ const span = find(word)
151
+ if (!span) return { duration: 0 }
152
+ const duration = opts.duration ?? 0.3
153
+ tl.to(span, { color: opts.color ?? accent, duration, ease: MOVE_EASE }, at(position))
154
+ return { duration }
155
+ },
156
+
157
+ morphWord: (from, to, opts = {}, position) => {
158
+ const span = find(from)
159
+ if (!span) return { duration: 0 }
160
+ const start = at(position)
161
+ const duration = opts.duration ?? 0.32
162
+ // Keep the shared prefix; crossfade only the differing tail, so
163
+ // "it!" → "it?" reads as the punctuation flipping.
164
+ const a = span.textContent ?? ''
165
+ let p = 0
166
+ while (p < a.length && p < to.length && a[p] === to[p]) p += 1
167
+ const prefix = a.slice(0, p)
168
+ span.textContent = ''
169
+ span.dataset.word = to
170
+ const stay = document.createElement('span')
171
+ stay.textContent = prefix
172
+ stay.style.whiteSpace = 'pre'
173
+ const holder = document.createElement('span')
174
+ holder.style.cssText = 'display:inline-block;position:relative;white-space:pre;'
175
+ const oldTail = document.createElement('span')
176
+ oldTail.textContent = a.slice(p)
177
+ oldTail.style.cssText = 'display:inline-block;white-space:pre;'
178
+ const newTail = document.createElement('span')
179
+ newTail.textContent = to.slice(p)
180
+ newTail.style.cssText = 'display:inline-block;white-space:pre;position:absolute;left:0;top:0;'
181
+ gsap.set(newTail, { autoAlpha: 0, yPercent: 45 })
182
+ holder.appendChild(oldTail)
183
+ holder.appendChild(newTail)
184
+ span.appendChild(stay)
185
+ span.appendChild(holder)
186
+ tl.to(oldTail, { autoAlpha: 0, yPercent: -45, duration, ease: 'ease-in-quad' }, start)
187
+ tl.to(newTail, { autoAlpha: 1, yPercent: 0, duration, ease: ENTER_EASE }, start + 0.05)
188
+ return { duration: duration + 0.05 }
189
+ },
190
+
191
+ chip: (word, media, opts = {}, position) => {
192
+ const span = find(word)
193
+ const start = at(position)
194
+ const height = opts.height ?? 1.05
195
+ const chipEl = document.createElement('span')
196
+ chipEl.dataset.chip = ''
197
+ chipEl.style.cssText =
198
+ `display:inline-block;vertical-align:-0.18em;height:${height}em;` +
199
+ `border-radius:${opts.radius ?? 0.28}em;overflow:hidden;position:relative;`
200
+ media.style.cssText += ';display:block;height:100%;width:auto;'
201
+ chipEl.appendChild(media)
202
+ if (span) {
203
+ span.parentElement?.insertBefore(chipEl, span)
204
+ // The word makes room, the chip pops in its place.
205
+ gsap.set(span, { display: 'none' })
206
+ } else {
207
+ el.appendChild(chipEl)
208
+ }
209
+ gsap.set(chipEl, { width: 0, autoAlpha: 0 })
210
+ tl.to(chipEl, { width: 'auto', autoAlpha: 1, duration: 0.4, ease: ENTER_EASE }, start)
211
+ return chipEl
212
+ },
213
+ }
214
+ return handle
215
+ }
216
+
217
+ return { sentence }
218
+ }
@@ -0,0 +1,195 @@
1
+ // Streaming states — the choreographed WAITING that AI products live in.
2
+ // Mined from Dropbox Dash, Pitch 2.0 and Notion AI (3/3 films treat the wait
3
+ // as a beat, not a gap): an answer arrives word by word, placeholders shimmer
4
+ // while work happens, and a status line names what is being done.
5
+ //
6
+ // Determinism: every state is a pure function of timeline position — word
7
+ // reveal runs on a stepped proxy, the shimmer is a tweened background offset,
8
+ // status phases are value tweens. Nothing reads a clock, nothing is random,
9
+ // and every element ends at an exact final state (fully revealed / removed).
10
+ import { resolveTarget } from './kits'
11
+
12
+ type Position = number | string | undefined
13
+
14
+ export type StreamKit = {
15
+ /** An answer arrives WORD BY WORD, LLM-style. The element's text is split
16
+ * into word spans at build time (layout is therefore stable from frame 0);
17
+ * the reveal is a stepped proxy, so seeking anywhere lands on the exact
18
+ * word count for that time. No caret — this is a machine writing, not a
19
+ * person typing (use `typeInto` for a person). */
20
+ answer: (
21
+ target: string | Element,
22
+ text?: string,
23
+ opts?: { wps?: number; duration?: number; rise?: number },
24
+ position?: Position
25
+ ) => { duration: number }
26
+ /** Placeholder lines that shimmer while work happens, then resolve into
27
+ * the real content. Mounts over the target, so the target's own layout is
28
+ * untouched. `lines` defaults to 3; `resolve` is how long the crossfade to
29
+ * real content takes. */
30
+ skeleton: (
31
+ target: string | Element,
32
+ opts?: { lines?: number; duration?: number; resolve?: number; widths?: number[] },
33
+ position?: Position
34
+ ) => { duration: number }
35
+ /** A status line naming the work: 'Analyzing' → 'Searching 3 sources' →
36
+ * 'Writing'. Each phase holds `interval` seconds; the last one stays until
37
+ * the caller hides it. Phases rise in place, so the line never jumps. */
38
+ status: (
39
+ target: string | Element,
40
+ phases: string[],
41
+ opts?: { interval?: number; done?: boolean },
42
+ position?: Position
43
+ ) => { duration: number }
44
+ }
45
+
46
+ export function createStreamKit(deps: {
47
+ tl: any
48
+ gsap: any
49
+ stage: HTMLElement
50
+ }): StreamKit {
51
+ const { tl, gsap, stage } = deps
52
+ const at = (position: Position): number => (typeof position === 'number' ? position : tl.duration())
53
+
54
+ const answer: StreamKit['answer'] = (target, text, opts = {}, position) => {
55
+ const el = resolveTarget(stage, target, 'stream.answer')
56
+ const source = (text ?? el.textContent ?? '').trim()
57
+ const words = source.split(/\s+/).filter(Boolean)
58
+ if (!words.length) return { duration: 0 }
59
+ const wps = opts.wps ?? 9
60
+ const duration = opts.duration ?? words.length / wps
61
+ const rise = opts.rise ?? 6
62
+ const start = at(position)
63
+
64
+ // Words are mounted (and therefore laid out) at build time; only their
65
+ // visibility animates, so nothing reflows mid-stream.
66
+ el.textContent = ''
67
+ const spans: HTMLElement[] = words.map((w, i) => {
68
+ const span = document.createElement('span')
69
+ span.textContent = i === 0 ? w : ` ${w}`
70
+ span.style.cssText = 'display:inline-block;white-space:pre;'
71
+ el.appendChild(span)
72
+ return span
73
+ })
74
+ for (const s of spans) gsap.set(s, { autoAlpha: 0, y: rise })
75
+
76
+ const proxy = { n: 0 }
77
+ let shown = 0
78
+ tl.to(
79
+ proxy,
80
+ {
81
+ n: words.length,
82
+ duration,
83
+ ease: `steps(${words.length})`,
84
+ onUpdate: () => {
85
+ const n = Math.max(0, Math.min(words.length, Math.round(proxy.n)))
86
+ if (n === shown) return
87
+ // Seek-safe: recompute every word's state from n, never increment.
88
+ for (let i = 0; i < words.length; i += 1) {
89
+ gsap.set(spans[i], { autoAlpha: i < n ? 1 : 0, y: i < n ? 0 : rise })
90
+ }
91
+ shown = n
92
+ },
93
+ },
94
+ start
95
+ )
96
+ // Exact end state for cold-seeking export workers.
97
+ tl.set(spans, { autoAlpha: 1, y: 0 }, start + duration)
98
+ return { duration }
99
+ }
100
+
101
+ /** Nearest painted background, so placeholders can pick a contrasting tone.
102
+ * A dark-surface skeleton drawn in black ink is invisible — the bug this
103
+ * exists to prevent. */
104
+ const surfaceIsDark = (el: HTMLElement): boolean => {
105
+ let node: HTMLElement | null = el
106
+ while (node) {
107
+ const bg = getComputedStyle(node).backgroundColor
108
+ const m = bg.match(/rgba?\(([^)]+)\)/)
109
+ if (m) {
110
+ const [r, g, b, a] = m[1].split(',').map((v) => parseFloat(v))
111
+ if (a === undefined || a > 0.05) {
112
+ return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.5
113
+ }
114
+ }
115
+ node = node.parentElement
116
+ }
117
+ return false
118
+ }
119
+
120
+ const skeleton: StreamKit['skeleton'] = (target, opts = {}, position) => {
121
+ const el = resolveTarget(stage, target, 'stream.skeleton')
122
+ const ink = surfaceIsDark(el) ? '255,255,255' : '0,0,0'
123
+ const lines = opts.lines ?? 3
124
+ const duration = opts.duration ?? 1.4
125
+ const resolve = opts.resolve ?? 0.3
126
+ const widths = opts.widths ?? [1, 0.82, 0.64]
127
+ const start = at(position)
128
+
129
+ if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
130
+ const holder = document.createElement('div')
131
+ holder.dataset.skeleton = ''
132
+ holder.style.cssText = 'position:absolute;inset:0;pointer-events:none;'
133
+ const bars: HTMLElement[] = []
134
+ for (let i = 0; i < lines; i += 1) {
135
+ const bar = document.createElement('div')
136
+ bar.style.cssText =
137
+ `position:absolute;left:0;top:${i * 34}px;height:16px;border-radius:8px;` +
138
+ `width:${Math.round((widths[i % widths.length] ?? 0.7) * 100)}%;` +
139
+ // A soft moving gradient — never a 1px pattern (those alias in H.264).
140
+ `background:linear-gradient(90deg, rgba(${ink},0.10) 0%, rgba(${ink},0.22) 45%, rgba(${ink},0.10) 100%);` +
141
+ `background-size:220% 100%;`
142
+ holder.appendChild(bar)
143
+ bars.push(bar)
144
+ }
145
+ el.appendChild(holder)
146
+
147
+ // Real content is hidden behind the skeleton, then crossfaded in.
148
+ const content = Array.from(el.children).filter((c) => c !== holder) as HTMLElement[]
149
+ gsap.set(content, { autoAlpha: 0 })
150
+ gsap.set(holder, { autoAlpha: 1 })
151
+ for (const [i, bar] of bars.entries()) {
152
+ tl.fromTo(
153
+ bar,
154
+ { backgroundPosition: '120% 0' },
155
+ { backgroundPosition: '-120% 0', duration: duration * 0.55, ease: 'none', repeat: 1 },
156
+ start + i * 0.08
157
+ )
158
+ }
159
+ tl.to(holder, { autoAlpha: 0, duration: resolve, ease: 'ease-in-quad' }, start + duration)
160
+ tl.to(content, { autoAlpha: 1, duration: resolve, ease: 'ease-out-quad' }, start + duration)
161
+ return { duration: duration + resolve }
162
+ }
163
+
164
+ const status: StreamKit['status'] = (target, phases, opts = {}, position) => {
165
+ const el = resolveTarget(stage, target, 'stream.status')
166
+ if (!phases.length) return { duration: 0 }
167
+ const interval = opts.interval ?? 1.1
168
+ const start = at(position)
169
+ el.textContent = ''
170
+ if (getComputedStyle(el).position === 'static') el.style.position = 'relative'
171
+ const layers = phases.map((p) => {
172
+ const span = document.createElement('span')
173
+ span.textContent = p
174
+ span.style.cssText = 'position:absolute;left:0;top:0;white-space:pre;'
175
+ el.appendChild(span)
176
+ return span
177
+ })
178
+ // The line keeps its box: the element holds the widest phase.
179
+ el.style.minHeight = '1em'
180
+ gsap.set(layers, { autoAlpha: 0, y: 8 })
181
+ gsap.set(layers[0], { autoAlpha: 1, y: 0 })
182
+ for (let i = 1; i < layers.length; i += 1) {
183
+ const t = start + i * interval
184
+ tl.to(layers[i - 1], { autoAlpha: 0, y: -8, duration: 0.22, ease: 'ease-in-quad' }, t)
185
+ tl.fromTo(layers[i], { autoAlpha: 0, y: 8 }, { autoAlpha: 1, y: 0, duration: 0.26, ease: 'ease-out-quint' }, t + 0.05)
186
+ }
187
+ const total = phases.length * interval
188
+ if (opts.done !== false) {
189
+ tl.to(layers[layers.length - 1], { autoAlpha: 0, duration: 0.22, ease: 'ease-in-quad' }, start + total)
190
+ }
191
+ return { duration: total }
192
+ }
193
+
194
+ return { answer, skeleton, status }
195
+ }
@@ -2,6 +2,7 @@
2
2
  import {
3
3
  ApiContractError,
4
4
  ApiError,
5
+ ApiTransportError,
5
6
  admitMcpCall,
6
7
  delay,
7
8
  invoke,
@@ -12,11 +13,13 @@ import {
12
13
  waitForJobs,
13
14
  withAdmissionReceipt,
14
15
  withMeteredCall
15
- } from "./chunk-7724464Z.js";
16
- import "./chunk-JDN3QHCX.js";
16
+ } from "./chunk-MQKW7NFI.js";
17
+ import "./chunk-Z74SLKEU.js";
18
+ import "./chunk-77HVPD4G.js";
17
19
  export {
18
20
  ApiContractError,
19
21
  ApiError,
22
+ ApiTransportError,
20
23
  admitMcpCall,
21
24
  delay,
22
25
  invoke,
@@ -0,0 +1,43 @@
1
+ #!/usr/bin/env node
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require : typeof Proxy !== "undefined" ? new Proxy(x, {
9
+ get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
10
+ }) : x)(function(x) {
11
+ if (typeof require !== "undefined") return require.apply(this, arguments);
12
+ throw Error('Dynamic require of "' + x + '" is not supported');
13
+ });
14
+ var __commonJS = (cb, mod) => function __require2() {
15
+ return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
16
+ };
17
+ var __export = (target, all) => {
18
+ for (var name in all)
19
+ __defProp(target, name, { get: all[name], enumerable: true });
20
+ };
21
+ var __copyProps = (to, from, except, desc) => {
22
+ if (from && typeof from === "object" || typeof from === "function") {
23
+ for (let key of __getOwnPropNames(from))
24
+ if (!__hasOwnProp.call(to, key) && key !== except)
25
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
26
+ }
27
+ return to;
28
+ };
29
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
30
+ // If the importer is in node compatibility mode or this is not an ESM
31
+ // file that has been converted to a CommonJS file using a Babel-
32
+ // compatible transform (i.e. "__esModule" has not been set), then set
33
+ // "default" to the CommonJS "module.exports" for node compatibility.
34
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
35
+ mod
36
+ ));
37
+
38
+ export {
39
+ __require,
40
+ __commonJS,
41
+ __export,
42
+ __toESM
43
+ };