@adea-ai/ui 0.63.2 → 0.63.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -153,6 +153,28 @@ The toolbar switches theme, accent, typeface and density live, which is the
153
153
  fastest way to see how a component behaves across the catalogue rather than in
154
154
  one theme.
155
155
 
156
+ ## Transcript follow
157
+
158
+ `ConversationSurface` follows streamed text, earlier-row growth, turn collapse
159
+ and pane resizing using the KiroCrew plain-scroller decision contract. A
160
+ deliberate upward scroll releases follow, including small moves near the bottom.
161
+ Returning to the bottom or choosing **Jump to latest** re-arms it; the action
162
+ does not estimate unread messages from pixel distance. Instant pins keep
163
+ self-scroll attribution synchronized. Keyboard jumping returns focus to the
164
+ transcript when its jump control disappears; automatic pins never move focus.
165
+
166
+ `threshold` controls jump-button visibility (80px by default), independently of
167
+ follow intent. `follow={false}` is fully inert: positioning belongs to the host,
168
+ with no observer or mount pin. Re-enabling it explicitly re-arms at the bottom;
169
+ it is not an automatic restore-and-resume flag. An optional `resetKey` also
170
+ re-arms follow when that identity changes. Canonical
171
+ session identity, durable restoration and virtualized transcript windows remain
172
+ application-owned. `ref` and `onScroll` still reach the inner scroller.
173
+
174
+ `bun run test:components` checks the built component in Chromium and WebKit.
175
+ The `SurfaceFollowing` story exercises streaming, collapse and pane resizing;
176
+ source/license/test provenance is recorded in `NOTICE`.
177
+
156
178
  ## Composer keyboard behavior
157
179
 
158
180
  `MessageComposer` preserves native IME candidate commits and suppresses sending
package/dist/NOTICE CHANGED
@@ -49,6 +49,37 @@ Origin UI / coss (AGPL-3.0-or-later) was consulted for visual ideas only. No
49
49
  code, class string, or asset from that project is reproduced here, because its
50
50
  licence is incompatible with this distribution.
51
51
 
52
+ ## Plain transcript follow translated from KiroCrew
53
+
54
+ `packages/ui/src/components/conversation/scroll-follow-core.ts`,
55
+ `scroll-follow.ts` and ConversationSurface substantially translate the plain
56
+ scroller lifecycle in `website/src/app-sdk/useChatScrollFollow.ts` and its pure
57
+ `website/src/hooks/virtualizer/FollowController.ts` decisions, pinned at
58
+ `283e136c0f902e965a535a7c9548c57c7504fed0`.
59
+
60
+ Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved.
61
+ This product includes software developed at Amazon.com, Inc.
62
+ (https://www.amazon.com/). Licensed under the Apache License, Version 2.0.
63
+
64
+ Retained: separate follow intent and jump visibility, synchronous scroll-up race
65
+ fencing, direction-aware re-engagement, self-scroll attribution, DPR tolerance,
66
+ content growth/collapse and viewport shrink/growth distinctions, instant pins,
67
+ explicit disabled/reset behavior and content/viewport observer cleanup. Solid
68
+ ownership replaces React refs/effects. Applications retain identity, durable
69
+ scroll restoration and virtualized row measurement/anchor authority.
70
+
71
+ The jump label follows `website/src/app-sdk/ChatScrollChrome.tsx`'s action-only
72
+ contract; pixel distance cannot establish an unread message count. The existing
73
+ shared button appearance and configurable 80px visibility default are retained.
74
+ Keyboard jumping returns focus to the focusable transcript as its jump control
75
+ disappears; this hardens a donor edge case without moving focus during automatic
76
+ pins. No donor edge-fade styling, React runtime, virtualizer, asset, font or session
77
+ store is copied. Tests translate applicable cases from
78
+ `website/src/test/useChatScrollFollow.test.tsx` and `FollowController.test.ts`;
79
+ Bun/browser integration and deterministic bounded grids replace React/jsdom,
80
+ Vitest and fast-check without adding dependencies. The full Chat product
81
+ composition and application adoption remain separate gates.
82
+
52
83
  ## Composer IME input ownership translated from KiroCrew
53
84
 
54
85
  `packages/ui/src/components/conversation/ime-guard.ts` and its MessageComposer
@@ -10,7 +10,7 @@ import type { ComponentProps, JSX } from 'solid-js';
10
10
  * to the end by an arriving message, and that is the single most common way a
11
11
  * chat surface is made unusable.
12
12
  * - **Offer a way back.** Once the reader is not at the bottom, a control appears
13
- * that returns them and says how much they have missed.
13
+ * that returns them. Pixel distance cannot prove an unread message count.
14
14
  * - **`overscroll-contain`.** The transcript scrolls to its end and stops, rather
15
15
  * than dragging the pane behind it — which in a desktop shell means the whole
16
16
  * window moves.
@@ -18,6 +18,10 @@ import type { ComponentProps, JSX } from 'solid-js';
18
18
  export type ConversationSurfaceProps = ComponentProps<'div'> & {
19
19
  /** How far from the bottom still counts as "at the bottom", in px. */
20
20
  threshold?: number;
21
+ /** Disable automatic positioning while the host owns scroll restoration. */
22
+ follow?: boolean;
23
+ /** Optional conversation identity: changing it re-arms follow at the bottom. */
24
+ resetKey?: string;
21
25
  /** Rendered above the transcript when there is nothing in it. */
22
26
  empty?: JSX.Element;
23
27
  /** A header that scrolls with the transcript, e.g. a day divider or a banner. */
@@ -1 +1 @@
1
- {"version":3,"file":"conversation-surface.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/conversation-surface.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;AAKnD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,wBAAwB,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG;IAC7D,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,iEAAiE;IACjE,KAAK,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;IACnB,iFAAiF;IACjF,MAAM,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;CACrB,CAAA;AAOD,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,eAsElE"}
1
+ {"version":3,"file":"conversation-surface.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/conversation-surface.tsx"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,cAAc,EAAE,GAAG,EAAE,MAAM,UAAU,CAAA;AAMnD;;;;;;;;;;;;;;;GAeG;AACH,MAAM,MAAM,wBAAwB,GAAG,cAAc,CAAC,KAAK,CAAC,GAAG;IAC7D,sEAAsE;IACtE,SAAS,CAAC,EAAE,MAAM,CAAA;IAClB,4EAA4E;IAC5E,MAAM,CAAC,EAAE,OAAO,CAAA;IAChB,gFAAgF;IAChF,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,iEAAiE;IACjE,KAAK,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;IACnB,iFAAiF;IACjF,MAAM,CAAC,EAAE,GAAG,CAAC,OAAO,CAAA;CACrB,CAAA;AAED,wBAAgB,mBAAmB,CAAC,KAAK,EAAE,wBAAwB,eAmElE"}
@@ -1,63 +1,50 @@
1
1
  import { cn } from "../../lib/utils.js";
2
2
  import { Button } from "../ui/button/button.js";
3
- import { className, createComponent, effect, insert, memo, spread, template, use } from "solid-js/web";
3
+ import { createScrollFollow } from "./scroll-follow.js";
4
+ import { className, createComponent, effect, insert, mergeProps, spread, template, use } from "solid-js/web";
4
5
  import { ArrowDown } from "lucide-solid";
5
- import { Show, createSignal, onCleanup, splitProps } from "solid-js";
6
+ import { Show, splitProps } from "solid-js";
6
7
  //#region src/components/conversation/conversation-surface.tsx
7
8
  var _tmpl$ = /*#__PURE__*/ template(`<div class="pointer-events-none absolute inset-x-0 bottom-3 flex justify-center">`);
8
- var _tmpl$2 = /*#__PURE__*/ template(`<div data-slot=conversation-surface><div class="min-h-0 flex-1 overflow-y-auto overscroll-contain">`);
9
+ var _tmpl$2 = /*#__PURE__*/ template(`<div data-slot=conversation-surface><div class="min-h-0 flex-1 overflow-y-auto overscroll-contain"><div data-slot=conversation-content>`);
9
10
  var _tmpl$3 = /*#__PURE__*/ template(`<div class=p-4>`);
10
- /** How far the reader is from the end of the transcript, in px. */
11
- function distanceFromBottom(element) {
12
- return element.scrollHeight - element.scrollTop - element.clientHeight;
13
- }
14
11
  function ConversationSurface(props) {
15
12
  const [local, rest] = splitProps(props, [
16
13
  "class",
17
14
  "threshold",
15
+ "follow",
16
+ "resetKey",
18
17
  "empty",
19
18
  "header",
20
- "children"
19
+ "children",
20
+ "onScroll",
21
+ "ref",
22
+ "tabindex"
21
23
  ]);
22
- const [atBottom, setAtBottom] = createSignal(true);
23
- const [missed, setMissed] = createSignal(0);
24
- let scroller;
25
- const onScroll = () => {
26
- if (!scroller) return;
27
- const distance = distanceFromBottom(scroller);
28
- const threshold = local.threshold ?? 80;
29
- setAtBottom(distance <= threshold);
30
- setMissed(distance <= threshold ? 0 : Math.round(distance / 96));
31
- };
32
- const jumpToLatest = () => {
33
- scroller?.scrollTo({
34
- top: scroller.scrollHeight,
35
- behavior: "smooth"
36
- });
37
- setAtBottom(true);
38
- setMissed(0);
39
- };
40
- const observer = () => {
41
- if (!scroller) return;
42
- const mutation = new MutationObserver(() => {
43
- if (atBottom()) scroller?.scrollTo({ top: scroller.scrollHeight });
44
- else onScroll();
45
- });
46
- mutation.observe(scroller, {
47
- childList: true,
48
- subtree: true
49
- });
50
- onCleanup(() => mutation.disconnect());
24
+ const follow = createScrollFollow({
25
+ enabled: () => local.follow !== false,
26
+ resetKey: () => local.resetKey,
27
+ threshold: () => local.threshold ?? 80
28
+ });
29
+ const onScroll = (event) => {
30
+ follow.onScroll();
31
+ const handler = local.onScroll;
32
+ if (typeof handler === "function") handler(event);
33
+ else if (handler) handler[0](handler[1], event);
51
34
  };
52
35
  return (() => {
53
- var _el$ = _tmpl$2(), _el$2 = _el$.firstChild;
36
+ var _el$ = _tmpl$2(), _el$2 = _el$.firstChild, _el$3 = _el$2.firstChild;
54
37
  _el$2.addEventListener("scroll", onScroll);
55
38
  use((element) => {
56
- scroller = element;
57
- observer();
39
+ follow.bindScroller(element);
40
+ if (typeof local.ref === "function") local.ref(element);
58
41
  }, _el$2);
59
- spread(_el$2, rest, false, true);
60
- insert(_el$2, createComponent(Show, {
42
+ spread(_el$2, mergeProps({ get tabindex() {
43
+ return local.tabindex ?? 0;
44
+ } }, rest), false, true);
45
+ var _ref$ = follow.bindContent;
46
+ typeof _ref$ === "function" ? use(_ref$, _el$3) : follow.bindContent = _el$3;
47
+ insert(_el$3, createComponent(Show, {
61
48
  get when() {
62
49
  return local.header;
63
50
  },
@@ -65,15 +52,15 @@ function ConversationSurface(props) {
65
52
  return local.header;
66
53
  }
67
54
  }), null);
68
- insert(_el$2, createComponent(Show, {
55
+ insert(_el$3, createComponent(Show, {
69
56
  get when() {
70
57
  return local.children;
71
58
  },
72
59
  get fallback() {
73
60
  return (() => {
74
- var _el$4 = _tmpl$3();
75
- insert(_el$4, () => local.empty);
76
- return _el$4;
61
+ var _el$5 = _tmpl$3();
62
+ insert(_el$5, () => local.empty);
63
+ return _el$5;
77
64
  })();
78
65
  },
79
66
  get children() {
@@ -82,30 +69,23 @@ function ConversationSurface(props) {
82
69
  }), null);
83
70
  insert(_el$, createComponent(Show, {
84
71
  get when() {
85
- return !atBottom();
72
+ return !follow.atBottom();
86
73
  },
87
74
  get children() {
88
- var _el$3 = _tmpl$();
89
- insert(_el$3, createComponent(Button, {
75
+ var _el$4 = _tmpl$();
76
+ insert(_el$4, createComponent(Button, {
77
+ type: "button",
90
78
  size: "sm",
91
79
  variant: "secondary",
92
80
  "class": "pointer-events-auto gap-1.5 shadow-md",
93
- onClick: jumpToLatest,
81
+ get onClick() {
82
+ return follow.jump;
83
+ },
94
84
  get children() {
95
- return [createComponent(ArrowDown, {}), createComponent(Show, {
96
- get when() {
97
- return missed() > 0;
98
- },
99
- get fallback() {
100
- return "Jump to latest";
101
- },
102
- get children() {
103
- return [memo(() => missed()), " new"];
104
- }
105
- })];
85
+ return [createComponent(ArrowDown, {}), "Jump to latest"];
106
86
  }
107
87
  }));
108
- return _el$3;
88
+ return _el$4;
109
89
  }
110
90
  }), null);
111
91
  effect(() => className(_el$, cn("relative flex min-h-0 flex-1 flex-col", local.class)));
@@ -1 +1 @@
1
- {"version":3,"file":"conversation-surface.js","names":["ArrowDown","ComponentProps","JSX","Show","createSignal","onCleanup","splitProps","cn","Button","ConversationSurfaceProps","threshold","empty","Element","header","distanceFromBottom","element","HTMLDivElement","scrollHeight","scrollTop","clientHeight","ConversationSurface","props","local","rest","atBottom","setAtBottom","missed","setMissed","scroller","onScroll","distance","Math","round","jumpToLatest","scrollTo","top","behavior","observer","mutation","MutationObserver","observe","childList","subtree","disconnect","_el$","_tmpl$2","_el$2","firstChild","addEventListener","_$use","_$spread","_$insert","_$createComponent","when","children","fallback","_el$4","_tmpl$3","_el$3","_tmpl$","size","variant","onClick","_$memo","_$effect","_$className","class","_$createComponent","_$memo","_$className"],"sources":["../../../src/components/conversation/conversation-surface.tsx"],"sourcesContent":["import { ArrowDown } from 'lucide-solid'\nimport type { ComponentProps, JSX } from 'solid-js'\nimport { Show, createSignal, onCleanup, splitProps } from 'solid-js'\nimport { cn } from '#lib/utils'\nimport { Button } from '../ui/button/button'\n\n/**\n * ConversationSurface.\n *\n * The scrolling transcript. Its job is small and it is the difference between a\n * transcript that stays readable as it grows and one that fights the reader:\n *\n * - **Stick to the bottom.** New messages scroll into view only when the reader is\n * already at the bottom. Someone reading back through history must not be yanked\n * to the end by an arriving message, and that is the single most common way a\n * chat surface is made unusable.\n * - **Offer a way back.** Once the reader is not at the bottom, a control appears\n * that returns them and says how much they have missed.\n * - **`overscroll-contain`.** The transcript scrolls to its end and stops, rather\n * than dragging the pane behind it — which in a desktop shell means the whole\n * window moves.\n */\nexport type ConversationSurfaceProps = ComponentProps<'div'> & {\n /** How far from the bottom still counts as \"at the bottom\", in px. */\n threshold?: number\n /** Rendered above the transcript when there is nothing in it. */\n empty?: JSX.Element\n /** A header that scrolls with the transcript, e.g. a day divider or a banner. */\n header?: JSX.Element\n}\n\n/** How far the reader is from the end of the transcript, in px. */\nfunction distanceFromBottom(element: HTMLDivElement): number {\n return element.scrollHeight - element.scrollTop - element.clientHeight\n}\n\nexport function ConversationSurface(props: ConversationSurfaceProps) {\n const [local, rest] = splitProps(props, ['class', 'threshold', 'empty', 'header', 'children'])\n\n const [atBottom, setAtBottom] = createSignal(true)\n const [missed, setMissed] = createSignal(0)\n let scroller: HTMLDivElement | undefined\n\n const onScroll = () => {\n if (!scroller) return\n const distance = distanceFromBottom(scroller)\n const threshold = local.threshold ?? 80\n setAtBottom(distance <= threshold)\n setMissed(distance <= threshold ? 0 : Math.round(distance / 96))\n }\n\n const jumpToLatest = () => {\n scroller?.scrollTo({ top: scroller.scrollHeight, behavior: 'smooth' })\n setAtBottom(true)\n setMissed(0)\n }\n\n // An arriving message while the reader is at the bottom should follow it. While\n // they are not, it must not.\n const observer = () => {\n if (!scroller) return\n const mutation = new MutationObserver(() => {\n if (atBottom()) scroller?.scrollTo({ top: scroller.scrollHeight })\n else onScroll()\n })\n mutation.observe(scroller, { childList: true, subtree: true })\n onCleanup(() => mutation.disconnect())\n }\n\n return (\n <div\n data-slot=\"conversation-surface\"\n class={cn('relative flex min-h-0 flex-1 flex-col', local.class)}\n >\n <div\n ref={(element) => {\n scroller = element\n observer()\n }}\n onScroll={onScroll}\n class=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\"\n {...rest}\n >\n <Show when={local.header}>{local.header}</Show>\n <Show when={local.children} fallback={<div class=\"p-4\">{local.empty}</div>}>\n {local.children}\n </Show>\n </div>\n\n <Show when={!atBottom()}>\n <div class=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n <Button\n size=\"sm\"\n variant=\"secondary\"\n class=\"pointer-events-auto gap-1.5 shadow-md\"\n onClick={jumpToLatest}\n >\n <ArrowDown />\n <Show when={missed() > 0} fallback={<>Jump to latest</>}>\n {missed()} new\n </Show>\n </Button>\n </div>\n </Show>\n </div>\n )\n}\n"],"mappings":";;;;;;;;;;AAgCA,SAASc,mBAAmBC,SAAiC;CAC3D,OAAOA,QAAQE,eAAeF,QAAQG,YAAYH,QAAQI;AAC5D;AAEA,SAAgBC,oBAAoBC,OAAiC;CACnE,MAAM,CAACC,OAAOC,QAAQjB,WAAWe,OAAO;EAAC;EAAS;EAAa;EAAS;EAAU;CAAU,CAAC;CAE7F,MAAM,CAACG,UAAUC,eAAerB,aAAa,IAAI;CACjD,MAAM,CAACsB,QAAQC,aAAavB,aAAa,CAAC;CAC1C,IAAIwB;CAEJ,MAAMC,iBAAiB;EACrB,IAAI,CAACD,UAAU;EACf,MAAME,WAAWhB,mBAAmBc,QAAQ;EAC5C,MAAMlB,YAAYY,MAAMZ,aAAa;EACrCe,YAAYK,YAAYpB,SAAS;EACjCiB,UAAUG,YAAYpB,YAAY,IAAIqB,KAAKC,MAAMF,WAAW,EAAE,CAAC;CACjE;CAEA,MAAMG,qBAAqB;EACzBL,UAAUM,SAAS;GAAEC,KAAKP,SAASX;GAAcmB,UAAU;EAAS,CAAC;EACrEX,YAAY,IAAI;EAChBE,UAAU,CAAC;CACb;CAIA,MAAMU,iBAAiB;EACrB,IAAI,CAACT,UAAU;EACf,MAAMU,WAAW,IAAIC,uBAAuB;GAC1C,IAAIf,SAAS,GAAGI,UAAUM,SAAS,EAAEC,KAAKP,SAASX,aAAa,CAAC;QAC5DY,SAAS;EAChB,CAAC;EACDS,SAASE,QAAQZ,UAAU;GAAEa,WAAW;GAAMC,SAAS;EAAK,CAAC;EAC7DrC,gBAAgBiC,SAASK,WAAW,CAAC;CACvC;CAEA,cAAA;EAAA,IAAAC,OAAAC,QAAA,GAAAC,QAAAF,KAAAG;EAAAD,MAAAE,iBAAA,UAUgBnB,QAAQ;EAAAoB,KAJZlC,YAAY;GAChBa,WAAWb;GACXsB,SAAS;EACX,GAACS,KAAA;EAAAI,OAAAJ,OAGGvB,MAAI,OAAA,IAAA;EAAA4B,OAAAL,OAAAM,gBAEPjD,MAAI;GAAA,IAACkD,OAAI;IAAA,OAAE/B,MAAMT;GAAM;GAAA,IAAAyC,WAAA;IAAA,OAAGhC,MAAMT;GAAM;EAAA,CAAA,GAAA,IAAA;EAAAsC,OAAAL,OAAAM,gBACtCjD,MAAI;GAAA,IAACkD,OAAI;IAAA,OAAE/B,MAAMgC;GAAQ;GAAA,IAAEC,WAAQ;IAAA,cAAA;KAAA,IAAAC,QAAAC,QAAA;KAAAN,OAAAK,aAAoBlC,MAAMX,KAAK;KAAA,OAAA6C;IAAA,EAAA,CAAA;GAAA;GAAA,IAAAF,WAAA;IAAA,OAChEhC,MAAMgC;GAAQ;EAAA,CAAA,GAAA,IAAA;EAAAH,OAAAP,MAAAQ,gBAIlBjD,MAAI;GAAA,IAACkD,OAAI;IAAA,OAAE,CAAC7B,SAAS;GAAC;GAAA,IAAA8B,WAAA;IAAA,IAAAI,QAAAC,OAAA;IAAAR,OAAAO,OAAAN,gBAElB5C,QAAM;KACLoD,MAAI;KACJC,SAAO;KAAA,SAAA;KAEPC,SAAS7B;KAAY,IAAAqB,WAAA;MAAA,OAAA,CAAAF,gBAEpBpD,WAAS,CAAA,CAAA,GAAAoD,gBACTjD,MAAI;OAAA,IAACkD,OAAI;QAAA,OAAE3B,OAAO,IAAI;OAAC;OAAA,IAAE6B,WAAQ;QAAA,OAAA;OAAA;OAAA,IAAAD,WAAA;QAAA,OAAA,CAAAS,WAC/BrC,OAAO,CAAC,GAAA,MAAA;OAAA;MAAA,CAAA,CAAA;KAAA;IAAA,CAAA,CAAA;IAAA,OAAAgC;GAAA;EAAA,CAAA,GAAA,IAAA;EAAAM,aAAAC,UAAArB,MA3BVrC,GAAG,yCAAyCe,MAAM4C,KAAK,CAAC,CAAA;EAAA,OAAAtB;CAAA,EAAA,CAAA;AAkCrE"}
1
+ {"version":3,"file":"conversation-surface.js","names":["ArrowDown","ComponentProps","JSX","Show","splitProps","cn","Button","createScrollFollow","ConversationSurfaceProps","threshold","follow","resetKey","empty","Element","header","ConversationSurface","props","local","rest","enabled","onScroll","EventHandlerUnion","HTMLDivElement","Event","event","handler","_el$","_tmpl$2","_el$2","firstChild","_el$3","addEventListener","_$use","element","bindScroller","ref","_$spread","_$mergeProps","tabindex","_ref$","bindContent","_$insert","_$createComponent","when","children","fallback","_el$5","_tmpl$3","atBottom","_el$4","_tmpl$","type","size","variant","onClick","jump","_$effect","_$className","class","_$mergeProps","_$use","_$createComponent","_$className"],"sources":["../../../src/components/conversation/conversation-surface.tsx"],"sourcesContent":["import { ArrowDown } from 'lucide-solid'\nimport type { ComponentProps, JSX } from 'solid-js'\nimport { Show, splitProps } from 'solid-js'\nimport { cn } from '#lib/utils'\nimport { Button } from '../ui/button/button'\nimport { createScrollFollow } from './scroll-follow'\n\n/**\n * ConversationSurface.\n *\n * The scrolling transcript. Its job is small and it is the difference between a\n * transcript that stays readable as it grows and one that fights the reader:\n *\n * - **Stick to the bottom.** New messages scroll into view only when the reader is\n * already at the bottom. Someone reading back through history must not be yanked\n * to the end by an arriving message, and that is the single most common way a\n * chat surface is made unusable.\n * - **Offer a way back.** Once the reader is not at the bottom, a control appears\n * that returns them. Pixel distance cannot prove an unread message count.\n * - **`overscroll-contain`.** The transcript scrolls to its end and stops, rather\n * than dragging the pane behind it — which in a desktop shell means the whole\n * window moves.\n */\nexport type ConversationSurfaceProps = ComponentProps<'div'> & {\n /** How far from the bottom still counts as \"at the bottom\", in px. */\n threshold?: number\n /** Disable automatic positioning while the host owns scroll restoration. */\n follow?: boolean\n /** Optional conversation identity: changing it re-arms follow at the bottom. */\n resetKey?: string\n /** Rendered above the transcript when there is nothing in it. */\n empty?: JSX.Element\n /** A header that scrolls with the transcript, e.g. a day divider or a banner. */\n header?: JSX.Element\n}\n\nexport function ConversationSurface(props: ConversationSurfaceProps) {\n const [local, rest] = splitProps(props, [\n 'class',\n 'threshold',\n 'follow',\n 'resetKey',\n 'empty',\n 'header',\n 'children',\n 'onScroll',\n 'ref',\n 'tabindex',\n ])\n // Kobalte provides the control's semantics; transcript follow is a native\n // geometry contract. KiroCrew's plain-scroller controller handles streaming,\n // content collapse and pane resizing without treating them as user consent.\n const follow = createScrollFollow({\n enabled: () => local.follow !== false,\n resetKey: () => local.resetKey,\n threshold: () => local.threshold ?? 80,\n })\n const onScroll: JSX.EventHandlerUnion<HTMLDivElement, Event> = (event) => {\n follow.onScroll()\n const handler = local.onScroll\n if (typeof handler === 'function') handler(event)\n else if (handler) handler[0](handler[1], event)\n }\n\n return (\n <div\n data-slot=\"conversation-surface\"\n class={cn('relative flex min-h-0 flex-1 flex-col', local.class)}\n >\n <div\n ref={(element) => {\n follow.bindScroller(element)\n if (typeof local.ref === 'function') local.ref(element)\n }}\n onScroll={onScroll}\n tabindex={local.tabindex ?? 0}\n class=\"min-h-0 flex-1 overflow-y-auto overscroll-contain\"\n {...rest}\n >\n <div ref={follow.bindContent} data-slot=\"conversation-content\">\n <Show when={local.header}>{local.header}</Show>\n <Show when={local.children} fallback={<div class=\"p-4\">{local.empty}</div>}>\n {local.children}\n </Show>\n </div>\n </div>\n\n <Show when={!follow.atBottom()}>\n <div class=\"pointer-events-none absolute inset-x-0 bottom-3 flex justify-center\">\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"secondary\"\n class=\"pointer-events-auto gap-1.5 shadow-md\"\n onClick={follow.jump}\n >\n <ArrowDown />\n Jump to latest\n </Button>\n </div>\n </Show>\n </div>\n )\n}\n"],"mappings":";;;;;;;;;;AAoCA,SAAgBe,oBAAoBC,OAAiC;CACnE,MAAM,CAACC,OAAOC,QAAQd,WAAWY,OAAO;EACtC;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;EACA;CAAU,CACX;CAID,MAAMN,SAASH,mBAAmB;EAChCY,eAAeF,MAAMP,WAAW;EAChCC,gBAAgBM,MAAMN;EACtBF,iBAAiBQ,MAAMR,aAAa;CACtC,CAAC;CACD,MAAMW,YAA0DI,UAAU;EACxEd,OAAOU,SAAS;EAChB,MAAMK,UAAUR,MAAMG;EACtB,IAAI,OAAOK,YAAY,YAAYA,QAAQD,KAAK;OAC3C,IAAIC,SAASA,QAAQ,EAAE,CAACA,QAAQ,IAAID,KAAK;CAChD;CAEA,cAAA;EAAA,IAAAE,OAAAC,QAAA,GAAAC,QAAAF,KAAAG,YAAAC,QAAAF,MAAAC;EAAAD,MAAAG,iBAAA,UAUgBX,QAAQ;EAAAY,KAJZC,YAAY;GAChBvB,OAAOwB,aAAaD,OAAO;GAC3B,IAAI,OAAOhB,MAAMkB,QAAQ,YAAYlB,MAAMkB,IAAIF,OAAO;EACxD,GAACL,KAAA;EAAAQ,OAAAR,OAAAS,WAAA,EAAA,IAEDC,WAAQ;GAAA,OAAErB,MAAMqB,YAAY;EAAC,EAAA,GAEzBpB,IAAI,GAAA,OAAA,IAAA;EAAA,IAAAqB,QAEE7B,OAAO8B;EAAW,OAAAD,UAAA,aAAAP,IAAAO,OAAAT,KAAA,IAAlBpB,OAAO8B,cAAWV;EAAAW,OAAAX,OAAAY,gBACzBvC,MAAI;GAAA,IAACwC,OAAI;IAAA,OAAE1B,MAAMH;GAAM;GAAA,IAAA8B,WAAA;IAAA,OAAG3B,MAAMH;GAAM;EAAA,CAAA,GAAA,IAAA;EAAA2B,OAAAX,OAAAY,gBACtCvC,MAAI;GAAA,IAACwC,OAAI;IAAA,OAAE1B,MAAM2B;GAAQ;GAAA,IAAEC,WAAQ;IAAA,cAAA;KAAA,IAAAC,QAAAC,QAAA;KAAAN,OAAAK,aAAoB7B,MAAML,KAAK;KAAA,OAAAkC;IAAA,EAAA,CAAA;GAAA;GAAA,IAAAF,WAAA;IAAA,OAChE3B,MAAM2B;GAAQ;EAAA,CAAA,GAAA,IAAA;EAAAH,OAAAf,MAAAgB,gBAKpBvC,MAAI;GAAA,IAACwC,OAAI;IAAA,OAAE,CAACjC,OAAOsC,SAAS;GAAC;GAAA,IAAAJ,WAAA;IAAA,IAAAK,QAAAC,OAAA;IAAAT,OAAAQ,OAAAP,gBAEzBpC,QAAM;KACL6C,MAAI;KACJC,MAAI;KACJC,SAAO;KAAA,SAAA;KAAA,IAEPC,UAAO;MAAA,OAAE5C,OAAO6C;KAAI;KAAA,IAAAX,WAAA;MAAA,OAAA,CAAAF,gBAEnB1C,WAAS,CAAA,CAAA,GAAA,gBAAA;KAAA;IAAA,CAAA,CAAA;IAAA,OAAAiD;GAAA;EAAA,CAAA,GAAA,IAAA;EAAAO,aAAAC,UAAA/B,MA7BTrB,GAAG,yCAAyCY,MAAMyC,KAAK,CAAC,CAAA;EAAA,OAAAhC;CAAA,EAAA,CAAA;AAoCrE"}
@@ -0,0 +1,181 @@
1
+ /** Default distance (px) from the bottom within which `isAtBottom` is true. */
2
+ export declare const DEFAULT_BOTTOM_THRESHOLD = 100;
3
+ /**
4
+ * Tolerance (px) for treating a scroll position as "the same" as a value we
5
+ * wrote programmatically. Covers sub-pixel rounding and 1px momentum overshoot.
6
+ * Must stay small so a deliberate user scroll of even a few px is still seen as
7
+ * a user scroll.
8
+ */
9
+ export declare const SELF_SCROLL_EPSILON = 2;
10
+ /**
11
+ * "At the bottom" tolerance (px) for deciding whether an auto-pin still has
12
+ * work to do. A flat 0.5 is UNDER one device pixel at fractional device-pixel
13
+ * ratios (0.67 CSS px at 150% zoom, 0.8 at 125%): the scroller's resting
14
+ * maximum scrollTop lands on a fractional value, so `|scrollTop - target|`
15
+ * stays just above 0.5 even when the viewport is visually pinned to the
16
+ * bottom — making the pin re-fire on every ResizeObserver tick. Scaling the
17
+ * epsilon to the device pixel (never below 1 CSS px) absorbs that fractional
18
+ * resting error. `devicePixelRatio` is read defensively so a jsdom / SSR
19
+ * environment that leaves it undefined falls back to 1 (→ 1.5px).
20
+ */
21
+ export declare function atBottomEpsilon(): number;
22
+ /** Live scroll geometry snapshot read from the scroller element. */
23
+ export interface ScrollGeom {
24
+ scrollTop: number;
25
+ scrollHeight: number;
26
+ clientHeight: number;
27
+ }
28
+ /** scrollTop that places the viewport exactly at the bottom (never negative). */
29
+ export declare function bottomTarget(geom: ScrollGeom): number;
30
+ /** Pixels between the current scroll position and the bottom. */
31
+ export declare function distanceFromBottom(geom: ScrollGeom): number;
32
+ /** Whether the scroller is within `threshold` px of the bottom. */
33
+ export declare function computeAtBottom(geom: ScrollGeom, threshold: number): boolean;
34
+ /**
35
+ * Recognise a `scroll` event caused by our own programmatic write rather than
36
+ * by the user. `lastWriteTop < 0` means "we have not written this session", so
37
+ * any scroll is treated as the user's.
38
+ */
39
+ export declare function isSelfScroll(scrollTop: number, lastWriteTop: number, epsilon?: number): boolean;
40
+ /**
41
+ * Distance (px) from the true bottom within which a user scroll RE-ENGAGES
42
+ * follow. Deliberately much tighter than DEFAULT_BOTTOM_THRESHOLD: that 100px
43
+ * band drives the jump-to-bottom pill's visibility, and reusing it for follow
44
+ * meant a deliberate 3-99px scroll-up kept `stick` armed — the next content
45
+ * change then yanked the reader back to the bottom. Re-engaging only when the
46
+ * user has returned essentially to the bottom keeps "scrolled up to read"
47
+ * positions belonging to the user.
48
+ */
49
+ export declare const FOLLOW_REENGAGE_PX = 16;
50
+ /**
51
+ * Direction-aware `stick` decision for a *user-initiated* scroll (self-scrolls
52
+ * filtered out by the caller via `isSelfScroll`):
53
+ *
54
+ * 1. At the true bottom (within the DPR-aware epsilon) → follow. This also
55
+ * absorbs the layout engine's clamp: a mid-stream content SHRINK drops
56
+ * scrollTop (which reads as an upward move) but lands exactly at the new
57
+ * bottom — releasing there froze streaming follow for the rest of the
58
+ * turn.
59
+ * 2. Any other upward move → release, regardless of distance from the
60
+ * bottom. The scroll position now belongs to the user; only returning to
61
+ * the bottom (3) re-engages.
62
+ * 3. A genuine DOWNWARD move that arrives within FOLLOW_REENGAGE_PX of the
63
+ * bottom → re-engage. A neutral event inside the band does NOT: that is
64
+ * how content collapsing under a still reader re-armed follow.
65
+ * 4. Otherwise (downward/neutral, still away from the bottom) → keep the
66
+ * previous state.
67
+ *
68
+ * `prevScrollTop < 0` means "no prior observation this session". Direction is
69
+ * unknowable then, so the decision is position-only and CONSERVATIVE: follow
70
+ * only within the re-engage band. Keeping a stale `stick` on an unattributable
71
+ * away-from-bottom scroll is how a reader gets yanked.
72
+ */
73
+ export declare function resolveUserScrollStick(args: {
74
+ stick: boolean;
75
+ followOutput: boolean;
76
+ scrollTop: number;
77
+ prevScrollTop: number;
78
+ geom: ScrollGeom;
79
+ /** Change in the scroller's own height since the previous scroll event.
80
+ *
81
+ * Positive = the viewport GREW (the composer shrank under a deletion, the
82
+ * keyboard closed). That growth lowers the maximum scrollTop, so the engine
83
+ * clamps any reader parked closer to the bottom than the growth — with no
84
+ * application write anywhere. The clamp then arrives here as an ordinary
85
+ * scroll event sitting at distance ~0, which rule 1 below used to read as
86
+ * "the reader came back to the bottom" and re-arm follow for someone who
87
+ * never touched the scroller. The next turn to start then took them to the
88
+ * end. Rule 1 exists to absorb a CONTENT-shrink clamp mid-stream, and content
89
+ * shrink moves `scrollHeight`, not `clientHeight` — so the two are
90
+ * distinguishable, and this is the delta that tells them apart. */
91
+ viewportGrowth?: number;
92
+ }): boolean;
93
+ /** Result of an automatic (RO / append) pin evaluation. */
94
+ export interface AutoPinResult {
95
+ /** Whether to write `el.scrollTop = target` now. */
96
+ pin: boolean;
97
+ /** Next value for `stick` (released to false if the user scrolled up). */
98
+ stick: boolean;
99
+ /** The bottom scrollTop the caller should write when `pin` is true. */
100
+ target: number;
101
+ }
102
+ /**
103
+ * Decide an automatic pin at the moment content changed (RO callback / append
104
+ * layout effect / its follow-up rAF), reading LIVE geometry.
105
+ *
106
+ * - Not sticking → never pin.
107
+ * - Sticking but the user has scrolled up since our last write
108
+ * (`scrollTop < lastWriteTop - epsilon`) → release stick, don't pin.
109
+ * This is the synchronous, race-proof guard.
110
+ * - Otherwise → pin to the bottom (only actually move if not already there).
111
+ *
112
+ * `lastWriteTop < 0` disables the scroll-up guard (used right after a slot
113
+ * switch, before we have written anything this session).
114
+ *
115
+ * `viewportShrink` (px, default 0) is how much the SCROLLER'S OWN BOX has
116
+ * shrunk since that reference was recorded — chrome mounting below the
117
+ * transcript (a queue band, an attachment strip, a tip card), often
118
+ * spring-animated over several frames. Our own shrink inflates
119
+ * `distanceFromBottom` with no user input, so without this allowance the
120
+ * distance guard reads it as "meaningfully away from the bottom". Paired with
121
+ * a content SHRINK in the same commit window — a tail-row remount clamping
122
+ * scrollTop below `lastWriteTop` — that produced a full user-scroll-up
123
+ * signature out of two of our own layout changes: follow released mid
124
+ * animation and the content settled a card-height low. Judging the distance
125
+ * against the box we were last a bottom FOR keeps the guard measuring the
126
+ * user's move rather than our own. Only the shrink's own pixels are forgiven,
127
+ * so a genuine drag inside the same tick still releases.
128
+ */
129
+ export declare function evaluateAutoPin(args: {
130
+ stick: boolean;
131
+ geom: ScrollGeom;
132
+ lastWriteTop: number;
133
+ epsilon?: number;
134
+ viewportShrink?: number;
135
+ /** Is a turn actually producing output right now?
136
+ *
137
+ * Follow means "keep me at the end of a LIVE turn". With nothing running there
138
+ * is no output to follow, so a reader sitting above the bottom is not
139
+ * following — and an automatic pin there is a yank with no cause, reported
140
+ * from a phone as the transcript springing back after scrolling up about a
141
+ * hundred pixels with nothing streaming.
142
+ *
143
+ * Defaults to `true` = assume a run is live, which keeps the behaviour of a
144
+ * caller that has no run signal to give (the app-SDK chat surface). The chat
145
+ * transcript passes the real thing. */
146
+ runActive?: boolean;
147
+ /** Is an anchor restore currently OWNING the scroll position?
148
+ *
149
+ * A restore places the reader at an absolute offset and then re-lands it as
150
+ * measurements arrive. An automatic pin during that window is a second owner
151
+ * writing the same scroller, and the two fight: captured on a phone as
152
+ * `WRITE autopin 3091->4245` answered by `WRITE settle 4245->3091`, twice in
153
+ * 120ms, 1,154px each way. The settle won those rounds, but only because its
154
+ * budget had not expired yet -- which is why the same switch sometimes landed
155
+ * at the bottom and sometimes did not.
156
+ *
157
+ * Released rather than merely skipped, for the reason the idle branch below
158
+ * gives: skipping leaves follow armed, so the next growth yanks the reader
159
+ * from wherever the restore just put them. */
160
+ restoreGate?: boolean;
161
+ /** Has hardware input -- wheel / touch / pointer / a scrolling key -- reached
162
+ * the scroller since we last placed the reader at the bottom?
163
+ *
164
+ * False means the reader has done nothing, so a gap that opened while they
165
+ * rest on our last write was opened by content -- a row settling from its
166
+ * estimate, a code-block stand-in swapping for the highlighted block, the
167
+ * top spacer repricing -- and is a gap WE owe them, not one they chose.
168
+ *
169
+ * The idle rule below cannot tell those apart from distance alone, and it
170
+ * errs toward release, which was invisible wherever the browser's native
171
+ * scroll anchoring quietly carried the reader through the growth. WebKit has
172
+ * no scroll anchoring at all, so on an iPhone every entry into an idle
173
+ * session paid the whole post-pin reprice as a displacement and then had
174
+ * follow released on top of it: the transcript opened a viewport or more
175
+ * above the end with nothing streaming to bring it back.
176
+ *
177
+ * Defaults to `true` = assume the reader may have moved, which is the
178
+ * release-leaning legacy behaviour for a caller that has no input signal. */
179
+ readerMovedSinceWrite?: boolean;
180
+ }): AutoPinResult;
181
+ //# sourceMappingURL=scroll-follow-core.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scroll-follow-core.d.ts","sourceRoot":"","sources":["../../../src/components/conversation/scroll-follow-core.ts"],"names":[],"mappings":"AAqBA,+EAA+E;AAC/E,eAAO,MAAM,wBAAwB,MAAM,CAAA;AAE3C;;;;;GAKG;AACH,eAAO,MAAM,mBAAmB,IAAI,CAAA;AAEpC;;;;;;;;;;GAUG;AACH,wBAAgB,eAAe,IAAI,MAAM,CAQxC;AAED,oEAAoE;AACpE,MAAM,WAAW,UAAU;IACzB,SAAS,EAAE,MAAM,CAAA;IACjB,YAAY,EAAE,MAAM,CAAA;IACpB,YAAY,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,wBAAgB,YAAY,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAErD;AAED,iEAAiE;AACjE,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,UAAU,GAAG,MAAM,CAE3D;AAED,mEAAmE;AACnE,wBAAgB,eAAe,CAAC,IAAI,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,GAAG,OAAO,CAE5E;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAC1B,SAAS,EAAE,MAAM,EACjB,YAAY,EAAE,MAAM,EACpB,OAAO,GAAE,MAA4B,GACpC,OAAO,CAET;AAED;;;;;;;;GAQG;AACH,eAAO,MAAM,kBAAkB,KAAK,CAAA;AAEpC;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,sBAAsB,CAAC,IAAI,EAAE;IAC3C,KAAK,EAAE,OAAO,CAAA;IACd,YAAY,EAAE,OAAO,CAAA;IACrB,SAAS,EAAE,MAAM,CAAA;IACjB,aAAa,EAAE,MAAM,CAAA;IACrB,IAAI,EAAE,UAAU,CAAA;IAChB;;;;;;;;;;;wEAWoE;IACpE,cAAc,CAAC,EAAE,MAAM,CAAA;CACxB,GAAG,OAAO,CA4BV;AAED,2DAA2D;AAC3D,MAAM,WAAW,aAAa;IAC5B,oDAAoD;IACpD,GAAG,EAAE,OAAO,CAAA;IACZ,0EAA0E;IAC1E,KAAK,EAAE,OAAO,CAAA;IACd,uEAAuE;IACvE,MAAM,EAAE,MAAM,CAAA;CACf;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE;IACpC,KAAK,EAAE,OAAO,CAAA;IACd,IAAI,EAAE,UAAU,CAAA;IAChB,YAAY,EAAE,MAAM,CAAA;IACpB,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,cAAc,CAAC,EAAE,MAAM,CAAA;IACvB;;;;;;;;;;4CAUwC;IACxC,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB;;;;;;;;;;;;mDAY+C;IAC/C,WAAW,CAAC,EAAE,OAAO,CAAA;IACrB;;;;;;;;;;;;;;;;;kFAiB8E;IAC9E,qBAAqB,CAAC,EAAE,OAAO,CAAA;CAChC,GAAG,aAAa,CAsDhB"}
@@ -0,0 +1,139 @@
1
+ /**
2
+ * "At the bottom" tolerance (px) for deciding whether an auto-pin still has
3
+ * work to do. A flat 0.5 is UNDER one device pixel at fractional device-pixel
4
+ * ratios (0.67 CSS px at 150% zoom, 0.8 at 125%): the scroller's resting
5
+ * maximum scrollTop lands on a fractional value, so `|scrollTop - target|`
6
+ * stays just above 0.5 even when the viewport is visually pinned to the
7
+ * bottom — making the pin re-fire on every ResizeObserver tick. Scaling the
8
+ * epsilon to the device pixel (never below 1 CSS px) absorbs that fractional
9
+ * resting error. `devicePixelRatio` is read defensively so a jsdom / SSR
10
+ * environment that leaves it undefined falls back to 1 (→ 1.5px).
11
+ */
12
+ function atBottomEpsilon() {
13
+ const dpr = typeof window !== "undefined" && typeof window.devicePixelRatio === "number" && window.devicePixelRatio > 0 ? window.devicePixelRatio : 1;
14
+ return Math.max(1, 1 / dpr + .5);
15
+ }
16
+ /** scrollTop that places the viewport exactly at the bottom (never negative). */
17
+ function bottomTarget(geom) {
18
+ return Math.max(0, geom.scrollHeight - geom.clientHeight);
19
+ }
20
+ /** Pixels between the current scroll position and the bottom. */
21
+ function distanceFromBottom(geom) {
22
+ return geom.scrollHeight - geom.scrollTop - geom.clientHeight;
23
+ }
24
+ /** Whether the scroller is within `threshold` px of the bottom. */
25
+ function computeAtBottom(geom, threshold) {
26
+ return distanceFromBottom(geom) <= threshold;
27
+ }
28
+ /**
29
+ * Recognise a `scroll` event caused by our own programmatic write rather than
30
+ * by the user. `lastWriteTop < 0` means "we have not written this session", so
31
+ * any scroll is treated as the user's.
32
+ */
33
+ function isSelfScroll(scrollTop, lastWriteTop, epsilon = 2) {
34
+ return lastWriteTop >= 0 && Math.abs(scrollTop - lastWriteTop) <= epsilon;
35
+ }
36
+ /**
37
+ * Direction-aware `stick` decision for a *user-initiated* scroll (self-scrolls
38
+ * filtered out by the caller via `isSelfScroll`):
39
+ *
40
+ * 1. At the true bottom (within the DPR-aware epsilon) → follow. This also
41
+ * absorbs the layout engine's clamp: a mid-stream content SHRINK drops
42
+ * scrollTop (which reads as an upward move) but lands exactly at the new
43
+ * bottom — releasing there froze streaming follow for the rest of the
44
+ * turn.
45
+ * 2. Any other upward move → release, regardless of distance from the
46
+ * bottom. The scroll position now belongs to the user; only returning to
47
+ * the bottom (3) re-engages.
48
+ * 3. A genuine DOWNWARD move that arrives within FOLLOW_REENGAGE_PX of the
49
+ * bottom → re-engage. A neutral event inside the band does NOT: that is
50
+ * how content collapsing under a still reader re-armed follow.
51
+ * 4. Otherwise (downward/neutral, still away from the bottom) → keep the
52
+ * previous state.
53
+ *
54
+ * `prevScrollTop < 0` means "no prior observation this session". Direction is
55
+ * unknowable then, so the decision is position-only and CONSERVATIVE: follow
56
+ * only within the re-engage band. Keeping a stale `stick` on an unattributable
57
+ * away-from-bottom scroll is how a reader gets yanked.
58
+ */
59
+ function resolveUserScrollStick(args) {
60
+ const { stick, followOutput, scrollTop, prevScrollTop, geom } = args;
61
+ if (!followOutput) return false;
62
+ const dist = distanceFromBottom(geom);
63
+ const clampedByViewport = (args.viewportGrowth ?? 0) > atBottomEpsilon() && scrollTop <= prevScrollTop + atBottomEpsilon();
64
+ if (dist <= atBottomEpsilon()) return clampedByViewport ? stick : true;
65
+ if (prevScrollTop < 0) return dist <= 16;
66
+ if (scrollTop < prevScrollTop - .5) return false;
67
+ if (scrollTop > prevScrollTop + .5 && dist <= 16) return true;
68
+ return stick;
69
+ }
70
+ /**
71
+ * Decide an automatic pin at the moment content changed (RO callback / append
72
+ * layout effect / its follow-up rAF), reading LIVE geometry.
73
+ *
74
+ * - Not sticking → never pin.
75
+ * - Sticking but the user has scrolled up since our last write
76
+ * (`scrollTop < lastWriteTop - epsilon`) → release stick, don't pin.
77
+ * This is the synchronous, race-proof guard.
78
+ * - Otherwise → pin to the bottom (only actually move if not already there).
79
+ *
80
+ * `lastWriteTop < 0` disables the scroll-up guard (used right after a slot
81
+ * switch, before we have written anything this session).
82
+ *
83
+ * `viewportShrink` (px, default 0) is how much the SCROLLER'S OWN BOX has
84
+ * shrunk since that reference was recorded — chrome mounting below the
85
+ * transcript (a queue band, an attachment strip, a tip card), often
86
+ * spring-animated over several frames. Our own shrink inflates
87
+ * `distanceFromBottom` with no user input, so without this allowance the
88
+ * distance guard reads it as "meaningfully away from the bottom". Paired with
89
+ * a content SHRINK in the same commit window — a tail-row remount clamping
90
+ * scrollTop below `lastWriteTop` — that produced a full user-scroll-up
91
+ * signature out of two of our own layout changes: follow released mid
92
+ * animation and the content settled a card-height low. Judging the distance
93
+ * against the box we were last a bottom FOR keeps the guard measuring the
94
+ * user's move rather than our own. Only the shrink's own pixels are forgiven,
95
+ * so a genuine drag inside the same tick still releases.
96
+ */
97
+ function evaluateAutoPin(args) {
98
+ const { stick, geom, lastWriteTop } = args;
99
+ const epsilon = args.epsilon ?? 2;
100
+ const viewportShrink = Math.max(0, args.viewportShrink ?? 0);
101
+ const runActive = args.runActive ?? true;
102
+ const readerMovedSinceWrite = args.readerMovedSinceWrite ?? true;
103
+ const target = bottomTarget(geom);
104
+ if (args.restoreGate) return {
105
+ pin: false,
106
+ stick: false,
107
+ target
108
+ };
109
+ if (!stick) return {
110
+ pin: false,
111
+ stick: false,
112
+ target
113
+ };
114
+ const restingOnOurWrite = lastWriteTop >= 0 && Math.abs(geom.scrollTop - lastWriteTop) <= epsilon;
115
+ if (!readerMovedSinceWrite && restingOnOurWrite) return {
116
+ pin: distanceFromBottom(geom) > atBottomEpsilon(),
117
+ stick: true,
118
+ target
119
+ };
120
+ if (!runActive && distanceFromBottom(geom) > atBottomEpsilon()) return {
121
+ pin: false,
122
+ stick: false,
123
+ target
124
+ };
125
+ if (lastWriteTop >= 0 && geom.scrollTop < lastWriteTop - epsilon && distanceFromBottom(geom) - viewportShrink > epsilon) return {
126
+ pin: false,
127
+ stick: false,
128
+ target
129
+ };
130
+ return {
131
+ pin: Math.abs(geom.scrollTop - target) > atBottomEpsilon(),
132
+ stick: true,
133
+ target
134
+ };
135
+ }
136
+ //#endregion
137
+ export { atBottomEpsilon, bottomTarget, computeAtBottom, distanceFromBottom, evaluateAutoPin, isSelfScroll, resolveUserScrollStick };
138
+
139
+ //# sourceMappingURL=scroll-follow-core.js.map