phase 0.1.0 → 0.3.0

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
@@ -238,15 +238,15 @@ createLoop({
238
238
 
239
239
  #### Loop options
240
240
 
241
- | Option | Type | Default | Description |
242
- | --------------- | ----------------------------------- | ------------ | ----------------------------------------- |
243
- | `target` | `Element` | required | Element to observe for visibility |
244
- | `onTick` | `(frame: FrameState) => void` | required | Called each frame while running |
245
- | `fps` | `number` | — | Cap frames per second |
246
- | `reducedMotion` | `'pause' \| 'complete' \| 'ignore'` | `'pause'` | Behavior when user prefers reduced motion |
247
- | `degraded` | `'throttle' \| 'pause' \| 'ignore'` | `'throttle'` | Behavior when quality degrades |
248
- | `degradedFps` | `number` | `30` | FPS cap in degraded throttle mode |
249
- | `onPhaseChange` | `(phase, reason) => void` | — | Called on every phase transition |
241
+ | Option | Type | Default | Description |
242
+ | --------------- | ----------------------------------- | ------------ | ------------------------------------------------------------- |
243
+ | `target` | `Element \| Document` | required | Element to observe for visibility, or `document` for the page |
244
+ | `onTick` | `(frame: FrameState) => void` | required | Called each frame while running |
245
+ | `fps` | `number` | — | Cap frames per second |
246
+ | `reducedMotion` | `'pause' \| 'complete' \| 'ignore'` | `'pause'` | Behavior when user prefers reduced motion |
247
+ | `degraded` | `'throttle' \| 'pause' \| 'ignore'` | `'throttle'` | Behavior when quality degrades |
248
+ | `degradedFps` | `number` | `30` | FPS cap in degraded throttle mode |
249
+ | `onPhaseChange` | `(phase, reason) => void` | — | Called on every phase transition |
250
250
 
251
251
  ### createTicker
252
252
 
@@ -337,7 +337,7 @@ Pause priority is `reduced-motion` > `sight` > `manual`.
337
337
 
338
338
  Reports what fraction of an element is currently visible in the viewport (0–1), via the shared IntersectionObserver pool. Zero forced reflows, zero extra observers. Ideal for reveal/opacity effects.
339
339
 
340
- > **Visibility ratio, not scroll offset.** This reports `intersectionRatio` (how much of an element is visible in the viewport), which plateaus for tall elements once they fill it. For a scroll container's _own_ offset (scrollbars, carousels) use [`createScroll`](#createscroll); for CSS-declarative scroll-linked animation use the native `ScrollTimeline` API; for spring/gesture scroll use `motion`.
340
+ > **Visibility ratio, not scroll offset.** This reports `intersectionRatio` (how much of an element is visible in the viewport), which plateaus for tall elements once they fill it. For a scroll container's _own_ offset (scrollbars, carousels, or the page via `target: 'page'` on the hook) use [`createScroll`](#createscroll); for CSS-declarative scroll-linked animation use the native `ScrollTimeline` API; for spring/gesture scroll use `motion`.
341
341
 
342
342
  ```ts
343
343
  import { createScrollProgress } from 'phase';
@@ -359,17 +359,17 @@ The `steps` option controls threshold granularity. Default `20` generates 21 eve
359
359
 
360
360
  #### ScrollProgress options
361
361
 
362
- | Option | Type | Default | Description |
363
- | ------------ | ----------------------------- | -------- | ---------------------------------- |
364
- | `target` | `Element` | required | Element to observe |
365
- | `onProgress` | `(ratio: number) => void` | required | Called at each threshold crossing |
366
- | `steps` | `number` | `20` | Number of evenly-spaced thresholds |
367
- | `root` | `Element \| Document \| null` | — | IO root element |
368
- | `rootMargin` | `string` | — | IO root margin |
362
+ | Option | Type | Default | Description |
363
+ | ------------ | ----------------------------- | -------- | ---------------------------------------------- |
364
+ | `target` | `Element \| Document` | required | Element to observe, or `document` for the page |
365
+ | `onProgress` | `(ratio: number) => void` | required | Called at each threshold crossing |
366
+ | `steps` | `number` | `20` | Number of evenly-spaced thresholds |
367
+ | `root` | `Element \| Document \| null` | — | IO root element |
368
+ | `rootMargin` | `string` | — | IO root margin |
369
369
 
370
370
  ### createScroll
371
371
 
372
- Tracks a scroll container's offset and progress. Reads `scrollLeft`/`scrollTop` once per rAF frame and reads the reflow-heavy geometry (`scrollWidth`/`clientWidth`) only on resize or explicit `measure()`, never on the scroll path. Auto-pauses off-screen via the shared IntersectionObserver pool. This is to `scroll` + `scrollWidth` what `createPointer` is to `pointermove` + `getBoundingClientRect`.
372
+ Tracks a scroll container's offset and progress. Reads `scrollLeft`/`scrollTop` once per rAF frame and reads the reflow-heavy geometry (`scrollWidth`/`clientWidth`) only on a coalesced resize or an explicit `measure()`, never on the scroll path. Auto-pauses off-screen via the shared IntersectionObserver pool. This is to `scroll` + `scrollWidth` what `createPointer` is to `pointermove` + `getBoundingClientRect`.
373
373
 
374
374
  > **Scroll offset, not visibility ratio.** This reports the element's own scroll position (for scrollbars, carousels, position indicators). For _how much of an element is in the viewport_, use [`createScrollProgress`](#createscrollprogress); for CSS-declarative scroll-linked animation, use the native `ScrollTimeline` API.
375
375
 
@@ -401,7 +401,7 @@ scroll.stop();
401
401
 
402
402
  | Option | Type | Default | Description |
403
403
  | --------------------- | ------------------------------ | --------- | -------------------------------------------------- |
404
- | `target` | `Element` | required | Scroll container to track |
404
+ | `target` | `Element \| Document` | required | Scroll container, or `document` for the page |
405
405
  | `onScroll` | `(state: ScrollState) => void` | required | Called once per rAF frame with position + progress |
406
406
  | `onPhaseChange` | `(phase, reason) => void` | — | Called on phase transitions |
407
407
  | `visibility` | `'pause' \| 'ignore'` | `'pause'` | Pause tracking when off-screen, or ignore |
@@ -410,6 +410,8 @@ scroll.stop();
410
410
 
411
411
  The options type is `CreateScrollOptions` (`ScrollOptions` is a `lib.dom` global and must not be shadowed).
412
412
 
413
+ Pass `document` to track the page scroller. Offsets and geometry then come from `document.scrollingElement`, and since the page is never off-screen, `visibility: 'pause'` reacts to tab visibility alone and creates no `IntersectionObserver`. Use it for scroll progress bars, condensing headers, and scroll-to-top affordances instead of a bare `window` scroll listener.
414
+
413
415
  ### createThrottle
414
416
 
415
417
  Frame-aligned, visibility-aware throttle for event-driven work below frame rate (socket emits, worker messaging, expensive recompute). Leading calls fire synchronously; a pending trailing call fires with the latest value on the first animation frame at or past `interval`. Nothing is scheduled while the trigger is idle or the document is hidden.
@@ -1209,13 +1211,14 @@ Every error includes a machine-readable `code` and an actionable message.
1209
1211
  import { PhaseError, isPhaseError } from 'phase';
1210
1212
  ```
1211
1213
 
1212
- | Code | Trigger |
1213
- | ------------------ | --------------------------------------------------- |
1214
- | `server_context` | Calling a browser-only primitive during SSR |
1215
- | `no_target` | Passing a null or undefined `target` to a primitive |
1216
- | `invalid_duration` | `useTween` duration is zero, negative, or NaN |
1217
- | `ticker_stopped` | Calling `start`/`resume` on a stopped ticker |
1218
- | `missing_context` | `<Swap.State>` used outside `<Swap>` |
1214
+ | Code | Trigger |
1215
+ | -------------------- | --------------------------------------------------- |
1216
+ | `server_context` | Calling a browser-only primitive during SSR |
1217
+ | `no_target` | Passing a null or undefined `target` to a primitive |
1218
+ | `conflicting_target` | Passing both `ref` and `target` to a hook |
1219
+ | `invalid_duration` | `useTween` duration is zero, negative, or NaN |
1220
+ | `ticker_stopped` | Calling `start`/`resume` on a stopped ticker |
1221
+ | `missing_context` | `<Swap.State>` used outside `<Swap>` |
1219
1222
 
1220
1223
  ## Relationship to View Transitions
1221
1224
 
@@ -1233,34 +1236,34 @@ Minimal footprint is a core promise (see [Why phase](#why-phase)). Every export
1233
1236
  | ------------------------- | ----------------: |
1234
1237
  | **Core** | |
1235
1238
  | `createTicker` | 847 B |
1236
- | `createSight` | 970 B |
1237
- | `createLifecycle` | 1.48 kB |
1238
- | `createLoop` | 2.6 kB |
1239
- | `createScrollProgress` | 865 B |
1239
+ | `createSight` | 1.01 kB |
1240
+ | `createLifecycle` | 1.52 kB |
1241
+ | `createLoop` | 2.65 kB |
1242
+ | `createScrollProgress` | 869 B |
1240
1243
  | `createRenderState` | 490 B |
1241
1244
  | `createDevicePixelRatio` | 544 B |
1242
1245
  | `createMutation` | 1.17 kB |
1243
1246
  | `createPointer` | 1.26 kB |
1244
- | `createScroll` | 1.46 kB |
1245
- | `createThrottle` | 659 B |
1246
- | `createDebounce` | 557 B |
1247
+ | `createScroll` | 1.56 kB |
1248
+ | `createThrottle` | 660 B |
1249
+ | `createDebounce` | 558 B |
1247
1250
  | `whenIdle` | 409 B |
1248
1251
  | `prefersReducedMotion` | 101 B |
1249
1252
  | **Ease** | |
1250
1253
  | `ease (all)` | 210 B |
1251
1254
  | **React** | |
1252
- | `useLoop` | 2.85 kB |
1253
- | `useLifecycle` | 1.68 kB |
1254
- | `useSight` | 1.18 kB |
1255
- | `useCanvas` | 3.47 kB |
1256
- | `useMutation` | 1.37 kB |
1257
- | `usePointer` | 1.48 kB |
1258
- | `useScroll` | 1.72 kB |
1259
- | `useThrottledCallback` | 797 B |
1260
- | `useDebouncedCallback` | 687 B |
1261
- | `useTween` | 655 B |
1255
+ | `useLoop` | 2.98 kB |
1256
+ | `useLifecycle` | 1.81 kB |
1257
+ | `useSight` | 1.33 kB |
1258
+ | `useCanvas` | 3.51 kB |
1259
+ | `useMutation` | 1.36 kB |
1260
+ | `usePointer` | 1.47 kB |
1261
+ | `useScroll` | 1.9 kB |
1262
+ | `useThrottledCallback` | 804 B |
1263
+ | `useDebouncedCallback` | 696 B |
1264
+ | `useTween` | 680 B |
1262
1265
  | `usePresence` | 591 B |
1263
- | `useScrollProgress` | 999 B |
1266
+ | `useScrollProgress` | 991 B |
1264
1267
  | `useSize` | 378 B |
1265
1268
  | `useContainerQuery` | 384 B |
1266
1269
  | `useMediaQuery` | 246 B |
@@ -1269,12 +1272,12 @@ Minimal footprint is a core promise (see [Why phase](#why-phase)). Every export
1269
1272
  | `useSyncedRef` | 22 B |
1270
1273
  | `useStableCallback` | 39 B |
1271
1274
  | `Presence` | 741 B |
1272
- | `WhenVisible` | 1.43 kB |
1273
- | `WhenIdle` | 593 B |
1275
+ | `WhenVisible` | 1.57 kB |
1276
+ | `WhenIdle` | 596 B |
1274
1277
  | `Defer` | 86 B |
1275
- | `useIdle` | 435 B |
1276
- | `useWhenIdle` | 446 B |
1277
- | `useRenderState` | 521 B |
1278
+ | `useIdle` | 414 B |
1279
+ | `useWhenIdle` | 445 B |
1280
+ | `useRenderState` | 515 B |
1278
1281
  | `Swap` | 1.12 kB |
1279
1282
 
1280
1283
  <!-- SIZE-TABLE:END -->
@@ -56,6 +56,13 @@ function noTargetError(fn) {
56
56
  fix: "Pass a mounted Element, or use the React hook which manages the ref."
57
57
  });
58
58
  }
59
+ function conflictingTargetError(fn) {
60
+ throw new PhaseError(`${fn}() received both ref and target.`, {
61
+ code: "conflicting_target",
62
+ reason: "A tracker has one anchor.",
63
+ fix: "Pass one: ref for an element, target: 'page' for the page."
64
+ });
65
+ }
59
66
  function invalidDurationError(fn, value) {
60
67
  throw new PhaseError(`${fn}() received an invalid duration: ${value}`, {
61
68
  code: "invalid_duration",
@@ -206,6 +213,19 @@ function createTicker(options) {
206
213
  };
207
214
  }
208
215
  //#endregion
216
+ //#region src/core/_internal/dom/index.ts
217
+ const DOCUMENT_NODE = 9;
218
+ /**
219
+ * Discriminates a page target from an element target.
220
+ *
221
+ * Uses `nodeType` rather than a property check: `document.scrollingElement` is
222
+ * absent in some test DOMs, so feature detection would silently classify a
223
+ * Document as an Element.
224
+ */
225
+ function isDocument(target) {
226
+ return target.nodeType === DOCUMENT_NODE;
227
+ }
228
+ //#endregion
209
229
  //#region src/core/_internal/pool/io-pool.ts
210
230
  const pool$1 = /* @__PURE__ */ new Map();
211
231
  /**
@@ -278,6 +298,9 @@ const createPoolEntry$1 = (options) => {
278
298
  * and the element is within the viewport. Uses a shared IntersectionObserver
279
299
  * pool. Multiple `createSight` calls with the same options share one observer.
280
300
  *
301
+ * Pass `document` to anchor to the page instead. There is no viewport test to
302
+ * make, so no observer is created and `phase` follows document visibility.
303
+ *
281
304
  * @example
282
305
  * const sight = createSight({
283
306
  * target: el,
@@ -296,8 +319,9 @@ function createSight(options) {
296
319
  let _phase = "unknown";
297
320
  let _reason = "initial";
298
321
  let stopped = false;
322
+ const isPage = isDocument(target);
299
323
  let documentVisible = !document.hidden;
300
- let elementInView = false;
324
+ let elementInView = isPage;
301
325
  function recompute(trigger) {
302
326
  if (stopped) return;
303
327
  const prev = _phase;
@@ -322,7 +346,15 @@ function createSight(options) {
322
346
  }
323
347
  document.addEventListener("visibilitychange", onVisibilityChange);
324
348
  window.addEventListener("pageshow", onPageShow);
325
- const unobserveIO = observeIntersection({
349
+ let unobserveIO;
350
+ if (isPage) try {
351
+ recompute("initial");
352
+ } catch (error) {
353
+ document.removeEventListener("visibilitychange", onVisibilityChange);
354
+ window.removeEventListener("pageshow", onPageShow);
355
+ throw error;
356
+ }
357
+ else unobserveIO = observeIntersection({
326
358
  element: target,
327
359
  onIntersect: onIntersection,
328
360
  ...intersectionOptions
@@ -334,7 +366,7 @@ function createSight(options) {
334
366
  unlinkAbort?.();
335
367
  document.removeEventListener("visibilitychange", onVisibilityChange);
336
368
  window.removeEventListener("pageshow", onPageShow);
337
- unobserveIO();
369
+ unobserveIO?.();
338
370
  _phase = "hidden";
339
371
  _reason = "initial";
340
372
  }
@@ -1251,11 +1283,22 @@ function getObserver() {
1251
1283
  * This is to `scroll` + `scrollWidth` what `createPointer` is to `pointermove`
1252
1284
  * + `getBoundingClientRect`: the layout read is batched off the hot path so the
1253
1285
  * per-scroll work is a single cheap position read plus math on cached geometry.
1286
+ *
1287
+ * Pass `document` to track the page. Offsets and geometry then come from
1288
+ * `document.scrollingElement`, and because the page is always in view,
1289
+ * `visibility: 'pause'` reacts to tab visibility alone rather than an
1290
+ * `IntersectionObserver`.
1254
1291
  */
1255
1292
  function createScroll(options) {
1256
1293
  if (typeof document === "undefined") serverContextError("createScroll");
1257
1294
  const { target, onScroll, onPhaseChange, visibility = "pause", intersectionOptions, signal } = options;
1258
1295
  if (!target) noTargetError("createScroll");
1296
+ let pageDoc;
1297
+ let scroller;
1298
+ if (isDocument(target)) {
1299
+ pageDoc = target;
1300
+ scroller = target.scrollingElement ?? target.documentElement;
1301
+ } else scroller = target;
1259
1302
  let _phase = "paused";
1260
1303
  let _reason = "initial";
1261
1304
  let stopped = false;
@@ -1271,6 +1314,7 @@ function createScroll(options) {
1271
1314
  };
1272
1315
  let rafId = 0;
1273
1316
  let dirty = false;
1317
+ let geometryDirty = false;
1274
1318
  let listenersAttached = false;
1275
1319
  let unobserveRO;
1276
1320
  function setPhase(phase, reason) {
@@ -1281,31 +1325,39 @@ function createScroll(options) {
1281
1325
  }
1282
1326
  function computePosition() {
1283
1327
  const { maxX, maxY } = _state;
1284
- const x = target.scrollLeft;
1285
- const y = target.scrollTop;
1328
+ const x = scroller.scrollLeft;
1329
+ const y = scroller.scrollTop;
1286
1330
  _state.x = x < 0 ? 0 : x > maxX ? maxX : x;
1287
1331
  _state.y = y < 0 ? 0 : y > maxY ? maxY : y;
1288
1332
  _state.progressX = maxX > 0 ? _state.x / maxX : 0;
1289
1333
  _state.progressY = maxY > 0 ? _state.y / maxY : 0;
1290
1334
  }
1291
- function measure() {
1292
- if (stopped || !listenersAttached) return;
1293
- const scrollWidth = target.scrollWidth;
1294
- const clientWidth = target.clientWidth;
1295
- const scrollHeight = target.scrollHeight;
1296
- const clientHeight = target.clientHeight;
1335
+ function readGeometry() {
1336
+ const scrollWidth = scroller.scrollWidth;
1337
+ const clientWidth = scroller.clientWidth;
1338
+ const scrollHeight = scroller.scrollHeight;
1339
+ const clientHeight = scroller.clientHeight;
1297
1340
  const maxX = scrollWidth - clientWidth;
1298
1341
  const maxY = scrollHeight - clientHeight;
1299
1342
  _state.maxX = maxX > 0 ? maxX : 0;
1300
1343
  _state.maxY = maxY > 0 ? maxY : 0;
1301
1344
  _state.visibleX = maxX > 0 ? clientWidth / scrollWidth : 1;
1302
1345
  _state.visibleY = maxY > 0 ? clientHeight / scrollHeight : 1;
1346
+ }
1347
+ function measure() {
1348
+ if (stopped || !listenersAttached) return;
1349
+ geometryDirty = false;
1350
+ readGeometry();
1303
1351
  computePosition();
1304
1352
  onScroll(_state);
1305
1353
  }
1306
1354
  function flush() {
1307
1355
  rafId = 0;
1308
- if (!dirty || stopped) return;
1356
+ if (stopped || !dirty && !geometryDirty) return;
1357
+ if (geometryDirty) {
1358
+ geometryDirty = false;
1359
+ readGeometry();
1360
+ }
1309
1361
  dirty = false;
1310
1362
  computePosition();
1311
1363
  onScroll(_state);
@@ -1320,6 +1372,7 @@ function createScroll(options) {
1320
1372
  rafId = 0;
1321
1373
  }
1322
1374
  dirty = false;
1375
+ geometryDirty = false;
1323
1376
  }
1324
1377
  function onScrollEvent() {
1325
1378
  if (stopped) return;
@@ -1327,14 +1380,16 @@ function createScroll(options) {
1327
1380
  scheduleFlush();
1328
1381
  }
1329
1382
  function onROResize() {
1330
- if (stopped) return;
1331
- measure();
1383
+ if (stopped || !listenersAttached) return;
1384
+ geometryDirty = true;
1385
+ scheduleFlush();
1332
1386
  }
1333
1387
  function attachListeners() {
1334
1388
  if (listenersAttached) return;
1335
1389
  listenersAttached = true;
1336
1390
  target.addEventListener("scroll", onScrollEvent, { passive: true });
1337
- unobserveRO = observeResize(target, onROResize);
1391
+ unobserveRO = observeResize(scroller, onROResize);
1392
+ if (pageDoc) window.addEventListener("resize", onROResize);
1338
1393
  measure();
1339
1394
  }
1340
1395
  function detachListeners() {
@@ -1343,11 +1398,12 @@ function createScroll(options) {
1343
1398
  target.removeEventListener("scroll", onScrollEvent);
1344
1399
  unobserveRO?.();
1345
1400
  unobserveRO = void 0;
1401
+ if (pageDoc) window.removeEventListener("resize", onROResize);
1346
1402
  cancelFlush();
1347
1403
  }
1348
1404
  let cleanupVisibility;
1349
1405
  if (visibility === "pause") {
1350
- let elementInView = false;
1406
+ let elementInView = pageDoc !== void 0;
1351
1407
  let documentVisible = !document.hidden;
1352
1408
  function recompute() {
1353
1409
  if (stopped) return;
@@ -1371,8 +1427,8 @@ function createScroll(options) {
1371
1427
  }
1372
1428
  document.addEventListener("visibilitychange", onVisChange);
1373
1429
  window.addEventListener("pageshow", onPageShow);
1374
- const unobserveIO = observeIntersection({
1375
- element: target,
1430
+ const unobserveIO = pageDoc ? void 0 : observeIntersection({
1431
+ element: scroller,
1376
1432
  onIntersect: (entry) => {
1377
1433
  elementInView = entry.isIntersecting;
1378
1434
  recompute();
@@ -1382,8 +1438,15 @@ function createScroll(options) {
1382
1438
  cleanupVisibility = () => {
1383
1439
  document.removeEventListener("visibilitychange", onVisChange);
1384
1440
  window.removeEventListener("pageshow", onPageShow);
1385
- unobserveIO();
1441
+ unobserveIO?.();
1386
1442
  };
1443
+ try {
1444
+ recompute();
1445
+ } catch (error) {
1446
+ cleanupVisibility();
1447
+ detachListeners();
1448
+ throw error;
1449
+ }
1387
1450
  } else {
1388
1451
  setPhase("tracking", "started");
1389
1452
  attachListeners();
@@ -1616,6 +1679,6 @@ function createDebounce(options) {
1616
1679
  };
1617
1680
  }
1618
1681
  //#endregion
1619
- export { missingContextError as C, isPhaseError as S, linkAbortSignal as T, subscribeMediaQuery as _, createPointer as a, PhaseError as b, prefersReducedMotion as c, subscribeDpr as d, createRenderState as f, readMediaQuery as g, createLifecycle as h, observeResize as i, whenIdle as l, createLoop as m, createThrottle as n, createMutation as o, createScrollProgress as p, createScroll as r, REDUCED_MOTION_QUERY as s, createDebounce as t, readDpr as u, createSight as v, serverContextError as w, invalidDurationError as x, createTicker as y };
1682
+ export { isPhaseError as C, linkAbortSignal as E, invalidDurationError as S, serverContextError as T, subscribeMediaQuery as _, createPointer as a, PhaseError as b, prefersReducedMotion as c, subscribeDpr as d, createRenderState as f, readMediaQuery as g, createLifecycle as h, observeResize as i, whenIdle as l, createLoop as m, createThrottle as n, createMutation as o, createScrollProgress as p, createScroll as r, REDUCED_MOTION_QUERY as s, createDebounce as t, readDpr as u, createSight as v, missingContextError as w, conflictingTargetError as x, createTicker as y };
1620
1683
 
1621
- //# sourceMappingURL=debounce-BX3NrBak.js.map
1684
+ //# sourceMappingURL=debounce-BA-WszUy.js.map