@flamingo-stack/openframe-frontend-core 0.0.209 → 0.0.210-snapshot.20260528032637

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.
Files changed (74) hide show
  1. package/dist/{chunk-HZT4YU33.js → chunk-3E5ANY55.js} +49 -26
  2. package/dist/chunk-3E5ANY55.js.map +1 -0
  3. package/dist/{chunk-RFSBDUXD.js → chunk-5BNWGK6D.js} +2 -2
  4. package/dist/{chunk-6RZYJICV.cjs → chunk-P5EE2VJX.cjs} +1 -1
  5. package/dist/chunk-P5EE2VJX.cjs.map +1 -0
  6. package/dist/{chunk-GIKD2LWD.js → chunk-QKFBZLIR.js} +2 -2
  7. package/dist/{chunk-Y7IXGY7T.cjs → chunk-VTUIMMHO.cjs} +24 -24
  8. package/dist/{chunk-Y7IXGY7T.cjs.map → chunk-VTUIMMHO.cjs.map} +1 -1
  9. package/dist/{chunk-SJQLTQC7.cjs → chunk-WI76ZUBE.cjs} +57 -34
  10. package/dist/chunk-WI76ZUBE.cjs.map +1 -0
  11. package/dist/{chunk-S2B3UFVP.cjs → chunk-ZFBLC5GV.cjs} +17 -17
  12. package/dist/{chunk-S2B3UFVP.cjs.map → chunk-ZFBLC5GV.cjs.map} +1 -1
  13. package/dist/{chunk-7L4DWM7P.js → chunk-ZG2YY5E7.js} +1 -1
  14. package/dist/chunk-ZG2YY5E7.js.map +1 -0
  15. package/dist/components/chat/entity-cards/blog-card.d.ts.map +1 -1
  16. package/dist/components/chat/hooks/use-chat-identity.d.ts +3 -3
  17. package/dist/components/chat/hooks/use-chat-identity.d.ts.map +1 -1
  18. package/dist/components/chat/index.cjs +3 -3
  19. package/dist/components/chat/index.js +2 -2
  20. package/dist/components/contact/index.cjs +4 -4
  21. package/dist/components/contact/index.js +3 -3
  22. package/dist/components/features/index.cjs +3 -3
  23. package/dist/components/features/index.js +2 -2
  24. package/dist/components/footer-waitlist-button.d.ts +21 -2
  25. package/dist/components/footer-waitlist-button.d.ts.map +1 -1
  26. package/dist/components/index.cjs +87 -91
  27. package/dist/components/index.cjs.map +1 -1
  28. package/dist/components/index.js +12 -16
  29. package/dist/components/index.js.map +1 -1
  30. package/dist/components/navigation/index.cjs +3 -3
  31. package/dist/components/navigation/index.js +2 -2
  32. package/dist/components/navigation/sticky-section-nav.d.ts.map +1 -1
  33. package/dist/components/tickets/help-center-card.d.ts.map +1 -1
  34. package/dist/components/tickets/index.cjs +139 -98
  35. package/dist/components/tickets/index.cjs.map +1 -1
  36. package/dist/components/tickets/index.js +95 -54
  37. package/dist/components/tickets/index.js.map +1 -1
  38. package/dist/components/tickets/ticket-row.d.ts.map +1 -1
  39. package/dist/components/ui/index.cjs +3 -3
  40. package/dist/components/ui/index.js +2 -2
  41. package/dist/contexts/chat-runtime-context.d.ts +6 -3
  42. package/dist/contexts/chat-runtime-context.d.ts.map +1 -1
  43. package/dist/contexts/index.cjs +2 -2
  44. package/dist/contexts/index.js +1 -1
  45. package/dist/index.cjs +5 -3
  46. package/dist/index.cjs.map +1 -1
  47. package/dist/index.js +4 -2
  48. package/dist/utils/index.cjs +10 -0
  49. package/dist/utils/index.cjs.map +1 -1
  50. package/dist/utils/index.d.ts +1 -0
  51. package/dist/utils/index.d.ts.map +1 -1
  52. package/dist/utils/index.js +10 -1
  53. package/dist/utils/index.js.map +1 -1
  54. package/dist/utils/scroll-into-view.d.ts +63 -0
  55. package/dist/utils/scroll-into-view.d.ts.map +1 -0
  56. package/package.json +1 -1
  57. package/src/components/chat/entity-cards/blog-card.tsx +25 -16
  58. package/src/components/chat/hooks/use-chat-identity.ts +8 -7
  59. package/src/components/footer-waitlist-button.tsx +33 -16
  60. package/src/components/navigation/sticky-section-nav.tsx +6 -4
  61. package/src/components/tickets/help-center-card.tsx +55 -1
  62. package/src/components/tickets/help-center-list.tsx +9 -1
  63. package/src/components/tickets/ticket-detail-drawer.tsx +19 -4
  64. package/src/components/tickets/ticket-row.tsx +30 -19
  65. package/src/contexts/chat-runtime-context.tsx +6 -3
  66. package/src/stories/EmbeddableChat.stories.tsx +1 -1
  67. package/src/utils/index.ts +12 -0
  68. package/src/utils/scroll-into-view.ts +74 -0
  69. package/dist/chunk-6RZYJICV.cjs.map +0 -1
  70. package/dist/chunk-7L4DWM7P.js.map +0 -1
  71. package/dist/chunk-HZT4YU33.js.map +0 -1
  72. package/dist/chunk-SJQLTQC7.cjs.map +0 -1
  73. /package/dist/{chunk-RFSBDUXD.js.map → chunk-5BNWGK6D.js.map} +0 -0
  74. /package/dist/{chunk-GIKD2LWD.js.map → chunk-QKFBZLIR.js.map} +0 -0
@@ -0,0 +1,63 @@
1
+ /**
2
+ * `scrollElementIntoView` — canonical "smooth scroll element to top
3
+ * of viewport, account for sticky chrome, optionally adjust for
4
+ * known layout shifts" helper.
5
+ *
6
+ * Before this util existed, ~3 different call sites in the lib + hub
7
+ * had the same 5-line snippet copy-pasted with subtle differences:
8
+ *
9
+ * - `useUnifiedNav` same-URL re-scroll branch (hub)
10
+ * - `<HelpCenterCard>` click-to-expand (lib, with cross-row
11
+ * layout-shift adjustment)
12
+ * - Future ticket-row / docs anchor scrolls
13
+ *
14
+ * The canonical pattern is `window.scrollTo({top, behavior:'smooth'})`
15
+ * with a pre-computed pixel target — NOT `element.scrollIntoView()`.
16
+ * Pre-computing the target lets the browser run a clean uninterrupted
17
+ * smooth animation to a fixed pixel value. `scrollIntoView` re-targets
18
+ * continuously as the page layout shifts during the animation, which
19
+ * causes visible jitter when content above the target is also moving
20
+ * (a sibling collapsing, an async image loading, …).
21
+ *
22
+ * The `adjustTargetY` callback is the escape hatch for cases where
23
+ * the consumer KNOWS about an upcoming layout shift and can compute
24
+ * the correct FINAL target before the animation starts. Example: in
25
+ * `HelpCenterCard`, clicking row B while row A above is currently
26
+ * expanded — A's drawer collapses simultaneously with B's expansion,
27
+ * shifting B's tile up by A's drawer height. The consumer passes
28
+ * `adjustTargetY: raw => raw - getAboveDrawerHeight()` and the
29
+ * browser smooth-scrolls to the post-collapse position directly.
30
+ */
31
+ export interface ScrollElementIntoViewOptions {
32
+ /** Pixels to subtract from the target element's `top` so it lands
33
+ * BELOW sticky chrome. Defaults to 0. Pass `96` (matches
34
+ * `scroll-mt-24`) for the standard hub header offset. */
35
+ headerOffset?: number;
36
+ /** Scroll animation style. Defaults to `'smooth'`. Use `'instant'`
37
+ * for imperative jumps where animation would feel laggy (deep
38
+ * link land, programmatic focus moves). */
39
+ behavior?: ScrollBehavior;
40
+ /** Optional adjustment applied to the computed pixel target. The
41
+ * callback receives the "raw" Y (`element.top + scrollY -
42
+ * headerOffset`) and returns the FINAL pixel target. Use this
43
+ * when the caller knows about a layout shift that will happen
44
+ * between the call and the animation completing — the browser's
45
+ * smooth-scroll commits to a single pixel value, so providing the
46
+ * post-shift target up front lands the element correctly even as
47
+ * content above it moves. */
48
+ adjustTargetY?: (rawTargetY: number) => number;
49
+ }
50
+ /**
51
+ * Scroll the page so `target` lands at the top of the viewport,
52
+ * accounting for sticky chrome via `headerOffset`. Returns void; the
53
+ * scroll runs async via the browser's smooth-scroll engine.
54
+ *
55
+ * Accepts:
56
+ * - `HTMLElement` — direct reference (most common from `useRef`).
57
+ * - `null` / `undefined` — no-op so callers can pass refs without
58
+ * defensive branching.
59
+ *
60
+ * SSR-safe: short-circuits when `window` is undefined.
61
+ */
62
+ export declare function scrollElementIntoView(target: HTMLElement | null | undefined, options?: ScrollElementIntoViewOptions): void;
63
+ //# sourceMappingURL=scroll-into-view.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"scroll-into-view.d.ts","sourceRoot":"","sources":["../../src/utils/scroll-into-view.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AAEH,MAAM,WAAW,4BAA4B;IAC3C;;8DAE0D;IAC1D,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB;;gDAE4C;IAC5C,QAAQ,CAAC,EAAE,cAAc,CAAA;IACzB;;;;;;;kCAO8B;IAC9B,aAAa,CAAC,EAAE,CAAC,UAAU,EAAE,MAAM,KAAK,MAAM,CAAA;CAC/C;AAED;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CACnC,MAAM,EAAE,WAAW,GAAG,IAAI,GAAG,SAAS,EACtC,OAAO,GAAE,4BAAiC,GACzC,IAAI,CAON"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@flamingo-stack/openframe-frontend-core",
3
- "version": "0.0.209",
3
+ "version": "0.0.210-snapshot.20260528032637",
4
4
  "description": "Shared design system and components for all Flamingo platforms",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -19,6 +19,7 @@
19
19
  */
20
20
 
21
21
  import React, { useState } from 'react'
22
+ import { Eye } from 'lucide-react'
22
23
  import Image from '../../../embed-shims/next-image'
23
24
  import { StatusBadge } from '../../ui/status-badge'
24
25
  import { cn } from '../../../utils/cn'
@@ -235,22 +236,30 @@ export function BlogCard({
235
236
  </p>
236
237
  </div>
237
238
 
238
- {/* Footer: author + date (simplified no hub BlogMeta dep). */}
239
- <div className="mt-auto flex items-center gap-2 text-sm text-ods-text-secondary">
240
- {post.author_avatar ? (
241
- <Image
242
- src={post.author_avatar}
243
- alt={post.author_name || ''}
244
- width={32}
245
- height={32}
246
- className="rounded-full"
247
- unoptimized
248
- />
249
- ) : null}
250
- <span className="truncate">
251
- {post.author_name || 'Anonymous'}
252
- {dateStr ? <> · {dateStr}</> : null}
253
- </span>
239
+ <div className="mt-auto flex items-center justify-between gap-2 text-sm text-ods-text-secondary">
240
+ <div className="flex items-center gap-2 min-w-0">
241
+ {post.author_avatar ? (
242
+ <Image
243
+ src={post.author_avatar}
244
+ alt={post.author_name || ''}
245
+ width={32}
246
+ height={32}
247
+ className="rounded-full shrink-0"
248
+ unoptimized
249
+ />
250
+ ) : null}
251
+ <span className="truncate">
252
+ {post.author_name || 'Anonymous'}
253
+ {dateStr ? <> · {dateStr}</> : null}
254
+ </span>
255
+ </div>
256
+ <div
257
+ className="flex items-center gap-1 shrink-0"
258
+ aria-label={`View count: ${(post.view_count ?? 0).toLocaleString('en-US')} views`}
259
+ >
260
+ <Eye className="w-4 h-4 shrink-0" />
261
+ <span>{(post.view_count ?? 0).toLocaleString('en-US')}</span>
262
+ </div>
254
263
  </div>
255
264
  </div>
256
265
  </a>
@@ -8,8 +8,9 @@
8
8
  * (attachment button, drawer composer, /tickets gate) on chat-side
9
9
  * identity tiers WITHOUT sending a chat message first.
10
10
  *
11
- * Server-side parity: the route at `/api/chat/identity` runs the same
12
- * 3-tier `requireChatAuth` chain the chat itself uses.
11
+ * Server-side parity: the host's identity endpoint (hub default
12
+ * `/api/auth/identity`, override via `runtime.endpoints.identityUrl`)
13
+ * runs the same 3-tier `requireChatAuth` chain the chat itself uses.
13
14
  * `attachmentsEnabled` is computed server-side as
14
15
  * `authTier !== 'anon' AND isSelfScopedSource(source)` — single
15
16
  * source of truth, consumers don't combine the fields themselves.
@@ -27,7 +28,7 @@
27
28
  * 3. The fetch is cheap and short — no perf justification for a
28
29
  * cache layer
29
30
  *
30
- * Endpoint URL: read from `useRequiredChatRuntime().endpoints.chatIdentityUrl`
31
+ * Endpoint URL: read from `useRequiredChatRuntime().endpoints.identityUrl`
31
32
  * so embedded apps with their own reverse-proxy topology can override.
32
33
  */
33
34
 
@@ -37,9 +38,9 @@ import { chatAuthedFetch } from '../utils/chat-authed-fetch'
37
38
  import { getChatProxyAuth } from '../utils/chat-proxy-auth-storage'
38
39
 
39
40
  /**
40
- * Wire-shape for the `/api/chat/identity` route response. Mirrors
41
- * the hub's `ChatIdentityResponse` (in `app/api/chat/identity/route.ts`)
42
- * kept in sync there. Lib-side declaration so the chat panel can
41
+ * Wire-shape for the identity route response. Mirrors the hub's
42
+ * `ChatIdentityResponse` (in `app/api/auth/identity/route.ts`)
43
+ * kept in sync there. Lib-side declaration so the chat panel can
43
44
  * compile without depending on hub-internal types.
44
45
  */
45
46
  export interface ChatIdentityResponse {
@@ -87,7 +88,7 @@ const ANON_DEFAULTS: ChatIdentityResponse = {
87
88
 
88
89
  export function useChatIdentity(): ChatIdentitySurface {
89
90
  const runtime = useRequiredChatRuntime()
90
- const url = runtime.endpoints.chatIdentityUrl
91
+ const url = runtime.endpoints.identityUrl
91
92
  // `getChatProxyAuth()` reads localStorage every render. If the user
92
93
  // pastes bearer creds mid-session (via the `/debug` creds bar),
93
94
  // their email arrives here and the effect's dep changes → refetch.
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
 
3
- import { usePathname, useRouter } from '../embed-shims/next-navigation';
3
+ import { useRouter } from '../embed-shims/next-navigation';
4
+ import { useChatRuntime } from '../contexts/chat-runtime-context';
4
5
  import { useCallback } from 'react';
5
6
  import { OpenFrameLogo } from './icons';
6
7
  import { Button } from './ui/button';
@@ -11,27 +12,43 @@ export interface FooterWaitlistButtonProps {
11
12
 
12
13
  /**
13
14
  * Small wrapper around JoinWaitlistButton for use inside the footer.
14
- * Provides a default click handler that scrolls/focuses the wait-list form
15
- * if the user is already on /waitlist; otherwise navigates to it.
15
+ *
16
+ * Routes through the host's unified-navigation hook
17
+ * (`runtime.navigation.navigate`) when a `ChatRuntimeContext` is
18
+ * mounted — that's the same path EVERY other in-app navigation
19
+ * surface uses (source chips, inline cards, search-autocomplete,
20
+ * action cards). One rule, one decision tree across the whole app.
21
+ * The hub's `HubRuntimeProvider` wires `navigate` to its `useUnifiedNav`
22
+ * helper, so this button picks up cross-platform new-tab decisions,
23
+ * same-URL re-scroll handling, embed-mode short-circuiting, and any
24
+ * future host-side nav rules for free.
25
+ *
26
+ * Falls back to the embed-shim's `router.push` when no runtime is
27
+ * mounted (third-party embedders who haven't set up
28
+ * `ChatRuntimeContext` — the lib stays usable without forcing them
29
+ * to wire the full chat-runtime).
30
+ *
31
+ * Target URL: `/waitlist#top`. `#top` is the canonical "scroll to
32
+ * page top" anchor — the destination page has an explicit
33
+ * `<div id="top">` at the top of `<main>` so native browser anchor
34
+ * scroll works in every browser regardless of the HTML5 magic-anchor
35
+ * behavior.
16
36
  */
17
37
  export function FooterWaitlistButton({ className }: FooterWaitlistButtonProps) {
18
38
  const router = useRouter();
19
- const pathname = usePathname();
39
+ const runtime = useChatRuntime();
20
40
 
21
41
  const handleClick = useCallback(() => {
22
- if (pathname?.startsWith('/waitlist')) {
23
- const anchor = document.getElementById('waitlist-form');
24
- if (anchor) {
25
- anchor.scrollIntoView({ behavior: 'smooth', block: 'center' } as any);
26
- setTimeout(() => {
27
- const input = anchor.querySelector('input[type="email"]') as HTMLInputElement | null;
28
- input?.focus();
29
- }, 400);
30
- return;
31
- }
42
+ const href = '/waitlist#top';
43
+ // Prefer the host's unified-nav callback (hub-wired
44
+ // `useUnifiedNav`). Falls back to the embed-shim's router when
45
+ // the host hasn't provided one.
46
+ if (runtime?.navigation?.navigate) {
47
+ const handled = runtime.navigation.navigate({ href });
48
+ if (handled) return;
32
49
  }
33
- router.push('/waitlist#waitlist-form');
34
- }, [pathname, router]);
50
+ router.push(href);
51
+ }, [router, runtime]);
35
52
 
36
53
  return (
37
54
  <Button
@@ -2,6 +2,7 @@
2
2
 
3
3
  import React, { useEffect, useState, useCallback, useRef } from 'react'
4
4
  import { cn } from '../../utils'
5
+ import { scrollElementIntoView } from '../../utils/scroll-into-view'
5
6
 
6
7
  export interface StickyNavSection {
7
8
  id: string
@@ -97,7 +98,10 @@ export function useSectionNavigation(
97
98
  const isScrollingFromClick = useRef(false)
98
99
  const { offset = 100 } = options || {}
99
100
 
100
- // Handle click - just scroll to the element
101
+ // Handle click - scroll to the element via the canonical helper.
102
+ // The `offset` prop maps to `headerOffset` (sticky chrome above the
103
+ // section nav); same smooth-scroll mechanics every other anchor
104
+ // surface in the app uses.
101
105
  const handleSectionClick = useCallback((sectionId: string) => {
102
106
  const targetElement = document.getElementById(sectionId)
103
107
  if (!targetElement) return
@@ -106,9 +110,7 @@ export function useSectionNavigation(
106
110
  isScrollingFromClick.current = true
107
111
  setActiveSection(sectionId)
108
112
 
109
- // Scroll to element
110
- const top = targetElement.offsetTop - offset
111
- window.scrollTo({ top, behavior: 'smooth' })
113
+ scrollElementIntoView(targetElement, { headerOffset: offset })
112
114
 
113
115
  // Allow scroll spy again after scroll completes
114
116
  setTimeout(() => {
@@ -15,8 +15,10 @@
15
15
  * is a SIBLING of the toggle button, not nested inside it.
16
16
  */
17
17
 
18
+ import { useCallback, useRef } from 'react'
18
19
  import { StatusBadge, type StatusBadgeProps } from '../ui'
19
20
  import { formatRelativeTime } from '../../utils/date-utils'
21
+ import { scrollElementIntoView } from '../../utils/scroll-into-view'
20
22
  import { getStatusColorScheme } from '../chat/utils/agent-status-message'
21
23
  import { DevCardRowContent } from '../shared/dev-section/dev-card-row'
22
24
  import {
@@ -26,6 +28,23 @@ import {
26
28
  import type { AnyTicket } from './types'
27
29
  import { isOptimistic } from './types'
28
30
 
31
+ /** Sticky page-chrome offset, applied two ways from this ONE constant:
32
+ *
33
+ * 1. As `scrollMarginTop` inline style on the wrapper — so any
34
+ * anchor-driven or `scrollIntoView()`-driven scroll (browser
35
+ * `#hash` navigation, Tab-focus into the card) lands BELOW the
36
+ * sticky header.
37
+ * 2. As `headerOffset` passed to `scrollElementIntoView(...)` — for
38
+ * the click-to-expand `window.scrollTo` path, which pre-computes
39
+ * its target pixel and ignores CSS `scroll-margin-top`.
40
+ *
41
+ * Single source of truth: change 96 here and BOTH paths follow. The
42
+ * previous code combined a `scroll-mt-24` (=96px) Tailwind class
43
+ * with this constant — two declarations, one comment binding them,
44
+ * drift hazard. Now there's nothing to keep in sync.
45
+ */
46
+ const STICKY_HEADER_OFFSET_PX = 96
47
+
29
48
  export interface HelpCenterCardProps {
30
49
  ticket: AnyTicket
31
50
  expanded: boolean
@@ -72,6 +91,39 @@ export function HelpCenterCard({
72
91
  const isExpandable = !optimistic
73
92
  const isExpanded = expanded && isExpandable
74
93
 
94
+ // Scroll-on-click — delegates to the canonical `scrollElementIntoView`
95
+ // helper with a cross-row layout-shift `adjustTargetY` callback. The
96
+ // helper owns the smooth-scroll mechanics + sticky-chrome offset; we
97
+ // pass the consumer-specific knowledge ("a sibling drawer above me
98
+ // is about to collapse — subtract its height from the target Y").
99
+ //
100
+ // Cross-row gotcha: if ANOTHER row above this one is currently
101
+ // expanded, its drawer collapses simultaneously with our toggle.
102
+ // The collapse shrinks the page above our row → our final Y is
103
+ // HIGHER than the current `rect.top`. By pre-subtracting the
104
+ // collapsing drawer's height we land at the post-shift position
105
+ // cleanly, without scrollIntoView's mid-animation drift.
106
+ const rowRef = useRef<HTMLDivElement | null>(null)
107
+ const handleClick = useCallback(() => {
108
+ onToggle(ticket.id)
109
+ scrollElementIntoView(rowRef.current, {
110
+ headerOffset: STICKY_HEADER_OFFSET_PX,
111
+ adjustTargetY: (raw) => {
112
+ if (!rowRef.current) return raw
113
+ const expandedDrawer = document.querySelector(
114
+ 'div[id^="help-center-drawer-"]',
115
+ )
116
+ if (!(expandedDrawer instanceof HTMLElement)) return raw
117
+ const drawerRect = expandedDrawer.getBoundingClientRect()
118
+ const myRect = rowRef.current.getBoundingClientRect()
119
+ // Only adjust when the drawer is ABOVE us. Drawers below us
120
+ // don't shift our position when they collapse.
121
+ if (drawerRect.bottom > myRect.top) return raw
122
+ return raw - drawerRect.height
123
+ },
124
+ })
125
+ }, [onToggle, ticket.id])
126
+
75
127
  const rightBadges = (
76
128
  <>
77
129
  <StatusBadge
@@ -93,12 +145,14 @@ export function HelpCenterCard({
93
145
 
94
146
  return (
95
147
  <div
148
+ ref={rowRef}
149
+ style={{ scrollMarginTop: STICKY_HEADER_OFFSET_PX }}
96
150
  className={`border-b border-ods-border last:border-b-0 ${optimistic ? 'opacity-60' : ''}`}
97
151
  aria-busy={optimistic || undefined}
98
152
  >
99
153
  <button
100
154
  type="button"
101
- onClick={isExpandable ? () => onToggle(ticket.id) : undefined}
155
+ onClick={isExpandable ? handleClick : undefined}
102
156
  disabled={!isExpandable}
103
157
  aria-expanded={isExpandable ? isExpanded : undefined}
104
158
  aria-controls={isExpanded ? `help-center-drawer-${ticket.id}` : undefined}
@@ -264,7 +264,15 @@ function HelpCenterListAuthed({
264
264
  />
265
265
  )
266
266
  ) : (
267
- <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full">
267
+ // `overflow-clip` (NOT `overflow-hidden`) — both visually
268
+ // clip the rounded corners, but `hidden` makes the element
269
+ // a "scroll container" per CSSOM spec, which causes
270
+ // `scrollIntoView` calls inside (`<HelpCenterCard>` click
271
+ // handlers) to try scrolling THIS div (can't, overflow
272
+ // hidden) instead of bubbling up to the window. `clip`
273
+ // keeps the visual clip but NOT the scroll-container
274
+ // status, so click-to-scroll actually moves the page.
275
+ <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-clip w-full">
268
276
  {merged.map((ticket) => (
269
277
  <HelpCenterCard
270
278
  key={ticket.id}
@@ -212,9 +212,16 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
212
212
  return (
213
213
  <div className="bg-ods-card border border-ods-border rounded-[6px] overflow-hidden w-full">
214
214
  {/* Customer-authored description + any legacy `---`-joined
215
- comments. Original message gets a special role label; legacy
216
- updates get "Update N" or "Resolution". Timestamp matches the
217
- ticket's creation time when available. */}
215
+ comments. Always rendered ABOVE the engagement timeline as
216
+ "Original message" because the server's intake-burst filter
217
+ (see `filterCustomerVisibleTimeline` in
218
+ `hubspot-conversations-utils.ts`) drops the customer's first
219
+ message from engagements when it was part of the HubSpot
220
+ Custom Channel bot intake — bodyTurns IS the canonical
221
+ original for those tickets. For tickets created without bot
222
+ intake (admin-created, email channel) bodyTurns shows the
223
+ manually-entered description and engagements show subsequent
224
+ replies — same flow, no duplication. */}
218
225
  {bodyTurns.map((turn, i) => {
219
226
  const isResolution = turn.startsWith('[Resolution]')
220
227
  const role =
@@ -315,11 +322,19 @@ function TicketTimelinePanel({ ticket }: { ticket: AnyTicket }) {
315
322
  avatarSrc = undefined
316
323
  }
317
324
 
325
+ // Role label: every engagement is a customer-visible
326
+ // Conversations message (customer ↔ agent on the Custom
327
+ // Channel). There are no internal Notes on this surface
328
+ // anymore — the read path explicitly filters them. So
329
+ // "Reply" for BOTH sides. The previous "Note" label for
330
+ // support bubbles was a legacy artifact from when Notes
331
+ // were rendered and made customers think their support
332
+ // engineer was leaving internal comments on their ticket.
318
333
  return (
319
334
  <ConversationCardRow
320
335
  key={eng.id}
321
336
  author={author}
322
- role={isCustomer ? 'Reply' : 'Note'}
337
+ role="Reply"
323
338
  avatarSrc={avatarSrc}
324
339
  timestamp={eng.createdAt}
325
340
  body={stripAttachmentsPreamble(eng.body ?? '')}
@@ -18,7 +18,7 @@
18
18
  * rendered only when this row is the expanded one.
19
19
  */
20
20
 
21
- import { useEffect, useRef } from 'react'
21
+ import { useCallback, useRef } from 'react'
22
22
  import {
23
23
  Collapsible,
24
24
  CollapsibleContent,
@@ -28,6 +28,7 @@ import {
28
28
  type ChatTicketItemData,
29
29
  } from '../chat/entity-cards/chat-ticket-item'
30
30
  import { formatRelativeTime } from '../../utils/date-utils'
31
+ import { scrollElementIntoView } from '../../utils/scroll-into-view'
31
32
  import {
32
33
  TicketDetailDrawer,
33
34
  type TicketDetailDrawerProps,
@@ -64,25 +65,35 @@ export function TicketRow({
64
65
  // arrived yet, so action targets would be undefined.
65
66
  const optimistic = isOptimistic(ticket)
66
67
 
67
- // Scroll the row's summary tile to the top of the viewport when the
68
- // user expands it. Without this, expanding a row near the bottom of
69
- // the viewport leaves most of the drawer (timeline + composer)
70
- // hidden — the user has to scroll manually to see what they just
71
- // opened.
68
+ // Scroll the clicked card to the top of the viewport. Every click
69
+ // scrolls first-click expansion, same-row re-click, cross-row
70
+ // switch. The clicked card lands at the top.
72
71
  //
73
- // Uses smooth-scroll + `block: 'start'` so the tile lands at the top
74
- // edge. Two RAFs let the Collapsible's height animation start before
75
- // we measure the row position, otherwise the scroll lands at the
76
- // pre-expansion position.
72
+ // Cross-row gotcha: if ANOTHER row above this one is currently
73
+ // expanded, its drawer is about to collapse simultaneously with our
74
+ // toggle. We pre-subtract its height from the target Y so the
75
+ // smooth-scroll lands at the FINAL post-collapse position cleanly.
76
+ // Same pattern as `<HelpCenterCard>` — the only diff is the drawer
77
+ // id prefix (`ticket-drawer-` vs `help-center-drawer-`).
77
78
  const rowRef = useRef<HTMLDivElement | null>(null)
78
- useEffect(() => {
79
- if (!expanded || optimistic) return
80
- requestAnimationFrame(() => {
81
- requestAnimationFrame(() => {
82
- rowRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
83
- })
79
+ const handleClick = useCallback(() => {
80
+ onToggle(ticket.id)
81
+ scrollElementIntoView(rowRef.current, {
82
+ adjustTargetY: (raw) => {
83
+ if (!rowRef.current) return raw
84
+ const expandedDrawer = document.querySelector(
85
+ 'div[id^="ticket-drawer-"]',
86
+ )
87
+ if (!(expandedDrawer instanceof HTMLElement)) return raw
88
+ const drawerRect = expandedDrawer.getBoundingClientRect()
89
+ const myRect = rowRef.current.getBoundingClientRect()
90
+ // Only adjust when the drawer is ABOVE us. Drawers below
91
+ // don't shift our position when they collapse.
92
+ if (drawerRect.bottom > myRect.top) return raw
93
+ return raw - drawerRect.height
94
+ },
84
95
  })
85
- }, [expanded, optimistic])
96
+ }, [onToggle, ticket.id])
86
97
 
87
98
  const tileData: ChatTicketItemData = {
88
99
  id: ticket.id,
@@ -112,14 +123,14 @@ export function TicketRow({
112
123
  }
113
124
 
114
125
  return (
115
- <div ref={rowRef} className="scroll-mt-4">
126
+ <div ref={rowRef} className="scroll-mt-24">
116
127
  <Collapsible
117
128
  open={expanded && !optimistic}
118
129
  className="border-b border-ods-border last:border-b-0"
119
130
  >
120
131
  <ChatTicketItem
121
132
  ticket={tileData}
122
- onClick={optimistic ? undefined : onToggle}
133
+ onClick={optimistic ? undefined : handleClick}
123
134
  aria-expanded={expanded && !optimistic}
124
135
  aria-controls={`ticket-drawer-${ticket.id}`}
125
136
  />
@@ -62,12 +62,15 @@ export interface ChatRuntime {
62
62
  * relative `/api/storage/view/chat-attachments/` is sufficient
63
63
  * (same-origin); embedders supply an absolute hub URL so the
64
64
  * browser can fetch cross-origin.
65
- * - `chatIdentityUrl` — GET endpoint the `useChatIdentity` hook
65
+ * - `identityUrl` — GET endpoint the `useChatIdentity` hook
66
66
  * hits to learn the `{authTier, source, attachmentsEnabled}`
67
- * capability bag for the current session. */
67
+ * capability bag for the current session. Used beyond chat
68
+ * (tickets / contact form / any embedded surface that needs
69
+ * to identify the proxied customer), so the name has no
70
+ * "chat" prefix even though the consuming hook still does. */
68
71
  attachmentUploadUrl: string
69
72
  attachmentViewUrlPrefix: string
70
- chatIdentityUrl: string
73
+ identityUrl: string
71
74
  /** Optional URL prefix for the image proxy (`<prefix>?url=<external>`).
72
75
  * When unset, lib's `getProxiedImageUrl` returns the original URL
73
76
  * unchanged. Hub default: '/api/image-proxy'. Embedders that don't
@@ -35,7 +35,7 @@ function createMockRuntime(): ChatRuntime {
35
35
  buildListUrl: () => null,
36
36
  attachmentUploadUrl: '/__story__/upload',
37
37
  attachmentViewUrlPrefix: '/__story__/view/',
38
- chatIdentityUrl: '/__story__/identity',
38
+ identityUrl: '/__story__/identity',
39
39
  },
40
40
  navigation: {
41
41
  mode: 'embed',
@@ -200,3 +200,15 @@ export { fetchPriorityProp, type FetchPriorityValue } from './fetch-priority'
200
200
  // server-safe (no JSX, no contexts/* imports); imported by route-page
201
201
  // `metadata` exports + the shared `<DevSectionView>` chrome.
202
202
  export * from './dev-sections'
203
+
204
+ // Canonical "smooth scroll element into view with sticky-chrome
205
+ // offset" helper. Single source of truth across the lib + hub for
206
+ // pre-computed-target scrolling — see `scroll-into-view.ts` for the
207
+ // rationale (TL;DR: window.scrollTo({top, behavior:'smooth'}) with a
208
+ // pre-computed pixel value avoids the mid-animation jitter that
209
+ // `element.scrollIntoView()` produces when layout shifts during the
210
+ // scroll).
211
+ export {
212
+ type ScrollElementIntoViewOptions,
213
+ scrollElementIntoView,
214
+ } from './scroll-into-view'
@@ -0,0 +1,74 @@
1
+ /**
2
+ * `scrollElementIntoView` — canonical "smooth scroll element to top
3
+ * of viewport, account for sticky chrome, optionally adjust for
4
+ * known layout shifts" helper.
5
+ *
6
+ * Before this util existed, ~3 different call sites in the lib + hub
7
+ * had the same 5-line snippet copy-pasted with subtle differences:
8
+ *
9
+ * - `useUnifiedNav` same-URL re-scroll branch (hub)
10
+ * - `<HelpCenterCard>` click-to-expand (lib, with cross-row
11
+ * layout-shift adjustment)
12
+ * - Future ticket-row / docs anchor scrolls
13
+ *
14
+ * The canonical pattern is `window.scrollTo({top, behavior:'smooth'})`
15
+ * with a pre-computed pixel target — NOT `element.scrollIntoView()`.
16
+ * Pre-computing the target lets the browser run a clean uninterrupted
17
+ * smooth animation to a fixed pixel value. `scrollIntoView` re-targets
18
+ * continuously as the page layout shifts during the animation, which
19
+ * causes visible jitter when content above the target is also moving
20
+ * (a sibling collapsing, an async image loading, …).
21
+ *
22
+ * The `adjustTargetY` callback is the escape hatch for cases where
23
+ * the consumer KNOWS about an upcoming layout shift and can compute
24
+ * the correct FINAL target before the animation starts. Example: in
25
+ * `HelpCenterCard`, clicking row B while row A above is currently
26
+ * expanded — A's drawer collapses simultaneously with B's expansion,
27
+ * shifting B's tile up by A's drawer height. The consumer passes
28
+ * `adjustTargetY: raw => raw - getAboveDrawerHeight()` and the
29
+ * browser smooth-scrolls to the post-collapse position directly.
30
+ */
31
+
32
+ export interface ScrollElementIntoViewOptions {
33
+ /** Pixels to subtract from the target element's `top` so it lands
34
+ * BELOW sticky chrome. Defaults to 0. Pass `96` (matches
35
+ * `scroll-mt-24`) for the standard hub header offset. */
36
+ headerOffset?: number
37
+ /** Scroll animation style. Defaults to `'smooth'`. Use `'instant'`
38
+ * for imperative jumps where animation would feel laggy (deep
39
+ * link land, programmatic focus moves). */
40
+ behavior?: ScrollBehavior
41
+ /** Optional adjustment applied to the computed pixel target. The
42
+ * callback receives the "raw" Y (`element.top + scrollY -
43
+ * headerOffset`) and returns the FINAL pixel target. Use this
44
+ * when the caller knows about a layout shift that will happen
45
+ * between the call and the animation completing — the browser's
46
+ * smooth-scroll commits to a single pixel value, so providing the
47
+ * post-shift target up front lands the element correctly even as
48
+ * content above it moves. */
49
+ adjustTargetY?: (rawTargetY: number) => number
50
+ }
51
+
52
+ /**
53
+ * Scroll the page so `target` lands at the top of the viewport,
54
+ * accounting for sticky chrome via `headerOffset`. Returns void; the
55
+ * scroll runs async via the browser's smooth-scroll engine.
56
+ *
57
+ * Accepts:
58
+ * - `HTMLElement` — direct reference (most common from `useRef`).
59
+ * - `null` / `undefined` — no-op so callers can pass refs without
60
+ * defensive branching.
61
+ *
62
+ * SSR-safe: short-circuits when `window` is undefined.
63
+ */
64
+ export function scrollElementIntoView(
65
+ target: HTMLElement | null | undefined,
66
+ options: ScrollElementIntoViewOptions = {},
67
+ ): void {
68
+ if (typeof window === 'undefined' || !target) return
69
+ const { headerOffset = 0, behavior = 'smooth', adjustTargetY } = options
70
+ const rawTargetY =
71
+ target.getBoundingClientRect().top + window.scrollY - headerOffset
72
+ const finalY = adjustTargetY ? adjustTargetY(rawTargetY) : rawTargetY
73
+ window.scrollTo({ top: Math.max(0, finalY), behavior })
74
+ }