@uniweb/kit 0.12.3 → 0.13.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniweb/kit",
3
- "version": "0.12.3",
3
+ "version": "0.13.1",
4
4
  "description": "Standard component library for Uniweb foundations",
5
5
  "type": "module",
6
6
  "exports": {
@@ -44,8 +44,8 @@
44
44
  "shiki": "^3.0.0",
45
45
  "tailwind-merge": "^3.6.0",
46
46
  "temml": "^0.13.2",
47
- "@uniweb/core": "^0.10.1",
48
47
  "@uniweb/semantic-parser": "^1.2.3",
48
+ "@uniweb/core": "^0.11.0",
49
49
  "@uniweb/scene": "^0.1.3"
50
50
  },
51
51
  "peerDependencies": {
@@ -41,7 +41,16 @@ export { useFormValues, valueAt } from './useFormValues.js'
41
41
 
42
42
  // Site tracking — one event stream.
43
43
  // `block.track(name, data)` is the common case and needs no hook; these cover
44
- // events with no block in hand, the consent gate, and opt-in scroll reporting.
44
+ // events with no block in hand, and the consent gate a banner sets.
45
+ //
46
+ // ⛔ `useScrollDepth` was here until 2026-08-19 and is RETIRED, not moved. It
47
+ // measured the DOCUMENT, so two long sections on one page reported the same
48
+ // number and any section added above silently rebased the series — and it had
49
+ // zero callers, so it never emitted anywhere. On pages composed of sections,
50
+ // `section_view` is the same signal with names attached. The case it looked
51
+ // like it served is `useReadingDepth` below, which measures one element.
45
52
  export { useTracker } from './useTracker.js'
46
53
  export { useTrackingConsent } from './useTrackingConsent.js'
47
- export { useScrollDepth } from './useScrollDepth.js'
54
+ // Reading depth through ONE long section — the foundation is the only party
55
+ // that knows a section is long-form, which is why it is opt-in and lives here.
56
+ export { useReadingDepth } from './useReadingDepth.js'
@@ -0,0 +1,115 @@
1
+ /**
2
+ * useReadingDepth — how far through ONE long section a visitor actually read.
3
+ *
4
+ * ```jsx
5
+ * export default function Article({ content, block }) {
6
+ * const ref = useRef(null)
7
+ * useReadingDepth({ ref, block })
8
+ * return <article ref={ref}>{…}</article>
9
+ * }
10
+ * ```
11
+ *
12
+ * ⭐ **Why this is kit's and not the runtime's.** The runtime emits what the
13
+ * SITE OWNER buys — it cannot know that one section is five thousand words of
14
+ * prose and another is a row of logos. **Only the foundation knows a section is
15
+ * long-form reading**, which is exactly the criterion for living here: declared
16
+ * by the component that has the knowledge, and tree-shaken to nothing for every
17
+ * foundation that never asks.
18
+ *
19
+ * ⛔ **This is NOT page scroll depth, and the difference is the whole point.**
20
+ * A page-scoped version measures the document, so on a page holding two long
21
+ * articles both would report the same number, and adding any section above
22
+ * silently rebases the series. This measures **the element's own box**, so it
23
+ * keeps meaning the same thing when the page around it changes.
24
+ *
25
+ * ⚖️ **It complements `section_view` rather than duplicating it.** That event
26
+ * fires once, when the section becomes half visible — it says the reader
27
+ * *arrived*. This says how far they got, which for a long article is the only
28
+ * question worth asking.
29
+ *
30
+ * ⛔ **No guard is needed at the call site.** With no tracking destination the
31
+ * report is a silent no-op — and the listener is never attached either, so an
32
+ * unconfigured site pays nothing for the call.
33
+ *
34
+ * @module @uniweb/kit/hooks/useReadingDepth
35
+ */
36
+
37
+ import { useEffect } from 'react'
38
+ import { getUniweb } from '@uniweb/core'
39
+
40
+ const MILESTONES = [25, 50, 75, 100]
41
+ const THROTTLE_MS = 200
42
+
43
+ /**
44
+ * How far the viewport has advanced through this element, 0-100.
45
+ *
46
+ * Measured against the element's own box: 0 when its top first reaches the
47
+ * bottom of the viewport, 100 once its bottom has. An element shorter than the
48
+ * viewport reports 100 as soon as it is fully on screen, which is correct —
49
+ * there was nothing further to read.
50
+ */
51
+ function depthOf(el) {
52
+ const rect = el.getBoundingClientRect()
53
+ if (rect.height <= 0) return 0
54
+ const seen = window.innerHeight - rect.top
55
+ return Math.max(0, Math.min(100, Math.round((seen / rect.height) * 100)))
56
+ }
57
+
58
+ /**
59
+ * @param {Object} options
60
+ * @param {{current: HTMLElement|null}} options.ref - the element to measure
61
+ * @param {Object} [options.block] - report through the block, so the section
62
+ * type, its instance id and the page path ride along for free
63
+ * @param {string} [options.event='read_depth']
64
+ */
65
+ export function useReadingDepth({ ref, block, event = 'read_depth' } = {}) {
66
+ useEffect(() => {
67
+ const el = ref?.current
68
+ const tracking = getUniweb()?.tracking
69
+ // ⛔ `isEnabled()`, NOT `arms()`. A foundation's own events are never gated
70
+ // by the site's `tracking.emit` or a host's list — the registry is open, and
71
+ // a client-side allowlist over them would export one collector's policy to
72
+ // every host. `emit` decides what the RUNTIME arms; this is not that.
73
+ if (!el || !tracking?.isEnabled?.()) return
74
+
75
+ const reported = new Set()
76
+ let lastCheck = 0
77
+
78
+ const report = (depth) => {
79
+ // Prefer the block: it attaches `section`, `section_id` and `path`, so an
80
+ // article's depth is attributable to the section it was read in.
81
+ if (block?.track) block.track(event, { depth })
82
+ else tracking.track(event, { depth })
83
+ }
84
+
85
+ const onScroll = () => {
86
+ const now = Date.now()
87
+ if (now - lastCheck < THROTTLE_MS) return
88
+ lastCheck = now
89
+
90
+ const depth = depthOf(el)
91
+ for (const milestone of MILESTONES) {
92
+ if (depth >= milestone && !reported.has(milestone)) {
93
+ reported.add(milestone)
94
+ report(milestone)
95
+ }
96
+ }
97
+ }
98
+
99
+ onScroll() // an element already fully read at mount reports immediately
100
+ window.addEventListener('scroll', onScroll, { passive: true })
101
+ // Resize changes the element's height and the viewport at once, so a
102
+ // milestone can be crossed without any scrolling at all.
103
+ window.addEventListener('resize', onScroll, { passive: true })
104
+
105
+ return () => {
106
+ window.removeEventListener('scroll', onScroll)
107
+ window.removeEventListener('resize', onScroll)
108
+ }
109
+ // The reported set is a local of this effect, so a re-run IS the reset —
110
+ // no ref to remember to clear, which is how the retired page-level hook
111
+ // shipped a bug where every page after the first reported nothing.
112
+ }, [ref, block, event])
113
+ }
114
+
115
+ export default useReadingDepth
package/src/index.js CHANGED
@@ -95,11 +95,11 @@ export {
95
95
  useFormValues,
96
96
  valueAt,
97
97
  // Site tracking. `block.track(name, data)` is the common case and needs no
98
- // hook — these are for events with no block in hand, the consent gate a
99
- // banner sets, and opt-in scroll reporting.
98
+ // hook — these are for events with no block in hand and the consent gate a
99
+ // banner sets. Scroll depth is the runtime's now; see hooks/index.js.
100
100
  useTracker,
101
101
  useTrackingConsent,
102
- useScrollDepth
102
+ useReadingDepth
103
103
  } from './hooks/index.js'
104
104
 
105
105
  // ============================================================================
@@ -1,97 +0,0 @@
1
- /**
2
- * useScrollDepth — report how far down the page a visitor got.
3
- *
4
- * ```jsx
5
- * useScrollDepth() // 25 / 50 / 75 / 100, once each per page
6
- * ```
7
- *
8
- * ⭐ **Opt-in, and in kit rather than the runtime, on purpose.** The runtime
9
- * auto-emits only what requires runtime privilege — a page view needs the
10
- * router, so nothing else can emit it. Scroll depth needs nothing but the
11
- * window, so it is a foundation's choice and lives where tree-shaking can drop
12
- * it for the foundations that never call it.
13
- *
14
- * *(It previously lived in `@uniweb/runtime` as an unexported, unreachable file
15
- * that reported to a metric baked into the transport. The milestones are now an
16
- * ordinary event, which is both smaller and more general.)*
17
- *
18
- * ⛔ **No guard is needed.** With no tracking destination the report is a
19
- * silent no-op — but the scroll listener is skipped too, so an unconfigured
20
- * site pays nothing for the call.
21
- *
22
- * @module @uniweb/kit/hooks/useScrollDepth
23
- */
24
-
25
- import { useEffect, useRef } from 'react'
26
- import { getUniweb } from '@uniweb/core'
27
- import { useRouting } from './useRouting.js'
28
-
29
- const MILESTONES = [25, 50, 75, 100]
30
-
31
- /** @returns {number} 0-100; 100 when the page fits in the viewport */
32
- function getScrollDepth() {
33
- const scrollTop = window.scrollY
34
- const docHeight = document.documentElement.scrollHeight - window.innerHeight
35
- if (docHeight <= 0) return 100
36
- return Math.min(100, Math.round((scrollTop / docHeight) * 100))
37
- }
38
-
39
- /**
40
- * @param {Object} [options]
41
- * @param {boolean} [options.enabled=true]
42
- * @param {number} [options.throttleMs=200]
43
- * @param {string} [options.event='scroll_depth'] - override the event name
44
- */
45
- export function useScrollDepth(options = {}) {
46
- const { enabled = true, throttleMs = 200, event = 'scroll_depth' } = options
47
-
48
- const lastCheck = useRef(0)
49
- const reported = useRef(new Set())
50
-
51
- // ⛔ **Keyed on the path, or "once each per page" is not true.** The milestone
52
- // set lives in a ref, so without this the effect runs once at mount and the
53
- // set is never cleared again — a second page reports nothing at all. That bites
54
- // exactly where this hook is most naturally called: once, in a layout
55
- // component, which persists across SPA navigation (`PageRenderer` does not
56
- // remount — the router declares a single catch-all route). Calling it in every
57
- // section would have hidden the bug and multiplied the events instead.
58
- //
59
- // ⭐ `pathname` rather than a route object, deliberately: it is the same
60
- // boundary `usePageView` emits on, so a `scroll_depth` always pairs with the
61
- // `page_view` it belongs to. `useRouting` returns a default location when there
62
- // is no Router, so this stays SSG-safe.
63
- const { useLocation } = useRouting()
64
- const { pathname } = useLocation()
65
-
66
- useEffect(() => {
67
- const tracking = getUniweb()?.tracking
68
- // Skip the listener entirely when there is nowhere to report — the calls
69
- // would be no-ops, but the scroll handler would still run on every frame.
70
- if (!enabled || !tracking?.isEnabled?.()) return
71
-
72
- reported.current.clear()
73
- // Reset the throttle too: a navigation within the throttle window would
74
- // otherwise make the immediate check below return early and miss the fold.
75
- lastCheck.current = 0
76
-
77
- const handleScroll = () => {
78
- const now = Date.now()
79
- if (now - lastCheck.current < throttleMs) return
80
- lastCheck.current = now
81
-
82
- const depth = getScrollDepth()
83
- for (const milestone of MILESTONES) {
84
- if (depth >= milestone && !reported.current.has(milestone)) {
85
- reported.current.add(milestone)
86
- tracking.track(event, { depth: milestone })
87
- }
88
- }
89
- }
90
-
91
- handleScroll() // a page that fits the viewport is already at 100
92
- window.addEventListener('scroll', handleScroll, { passive: true })
93
- return () => window.removeEventListener('scroll', handleScroll)
94
- }, [enabled, throttleMs, event, pathname])
95
- }
96
-
97
- export default useScrollDepth