PageSourceSearch

https://startupkit.app/assets/controllers/freshly_arrived_controller-d87e76c7.js

js startupkit.app collected 2026-10-06 04:56:31 UTC 3,460 bytes, 84 lines download raw bytes

1import { Controller } from "@hotwired/stimulus"
2
3// Adds a brief amber glow to a thread card if it lands in the DOM via a
4// Turbo Stream broadcast (i.e. AFTER the user navigated to the page), so
5// live updates announce themselves without yanking the viewport.
6//
7// Pairs with `scroll: :preserve` on the morph: scroll stays put, but the
8// card the user wasn't looking at gets a peripheral-vision flash. The
9// animation is automatically suppressed for users with
10// `prefers-reduced-motion: reduce` (see animation.css).
11//
12// Detection: `turbo:visit` stamps the page-load timestamp before any
13// controller on the next page connects. That event fires for cold loads
14// and Drive navigations (J/K hops) but NOT for live morph refreshes —
15// so a controller whose connect() runs more than FRESH_THRESHOLD_MS after
16// the stamp must have been inserted by the cable. No per-card server
17// state, no morph-event listeners.
18//
19// Limitation: a card whose DOM id survives the morph but whose inner
20// content was rewritten in place (the `refresh_from_mail!` case for the
21// selected reply) will NOT trigger this — Stimulus doesn't reconnect
22// surviving elements. That edge case needs `turbo:morph-element` work;
23// shipped separately if it proves load-bearing in real use.
24//
25// HTML usage:
26//   <div id="<%= dom_id(reply) %>" data-controller="freshly-arrived">
27//     ...card contents...
28//   </div>
29//
30// Values, targets, classes: none — the controller is purely behavioural.
31
32// Morph cycles complete in well under 300ms on a healthy connection;
33// 1000ms is a comfortable safety margin so a slow LCP can't accidentally
34// flag every card on the cold load as "freshly arrived".
35const FRESH_THRESHOLD_MS = 1000
36
37// Symbol-keyed off `window` so we don't pollute the enumerable global
38// namespace (Object.keys(window) won't surface this) while still sharing
39// the timestamp across every controller instance on the page.
40const STAMP_KEY = Symbol.for("kit.freshly_arrived.stamp")
41const LISTENER_KEY = Symbol.for("kit.freshly_arrived.listener_attached")
42
43if (typeof window !== "undefined") {
44  if (window[STAMP_KEY] === undefined) {
45    window[STAMP_KEY] = performance.now()
46  }
47  // Guard prevents duplicate listeners during HMR / module re-evaluation.
48  // The listener is intentionally module-singleton — every freshly-arrived
49  // controller on the page reads the same stamp.
50  if (!window[LISTENER_KEY]) {
51    window[LISTENER_KEY] = true
52    document.addEventListener("turbo:visit", () => {
53      window[STAMP_KEY] = performance.now()
54    })
55  }
56}
57
58export default class extends Controller {
59  connect() {
60    const stamp = window[STAMP_KEY]
61    if (stamp === undefined) return
62    if (performance.now() - stamp < FRESH_THRESHOLD_MS) return
63
64    this.element.classList.add("animate-fresh-arrival")
65    this.element.addEventListener(
66      "animationend",
67      this.#handleAnimationEnd,
68      { once: true }
69    )
70  }
71
72  disconnect() {
73    // If the element is morphed out before the 2.4s animation completes,
74    // strip the class so the listener — even though it's `{ once: true }`
75    // and would auto-remove on fire — has nothing left to fire on. Belt
76    // and suspenders against detached-node work.
77    this.element.classList.remove("animate-fresh-arrival")
78    this.element.removeEventListener("animationend", this.#handleAnimationEnd)
79  }
80
81  #handleAnimationEnd = () => {
82    this.element.classList.remove("animate-fresh-arrival")
83  }
84}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.