@phis/ui 0.3.0 → 0.4.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.
Files changed (121) hide show
  1. package/components/cms/phi-cms-layout-renderer.js +1 -1
  2. package/components/controls/phi-collection-header-control.js +1 -1
  3. package/components/controls/phi-collection-view-control.js +2 -1
  4. package/components/controls/phi-drawer-control-adapter.d.ts +1 -1
  5. package/components/controls/phi-drawer-control-adapter.js +2 -2
  6. package/components/controls/phi-image-control.d.ts +6 -1
  7. package/components/controls/phi-image-control.js +23 -4
  8. package/components/controls/phi-lightbox-image-control-adapter.d.ts +2 -0
  9. package/components/controls/phi-lightbox-image-control-adapter.js +11 -0
  10. package/components/controls/phi-lightbox-image-control.d.ts +3 -2
  11. package/components/controls/phi-lightbox-image-control.js +7 -3
  12. package/components/controls/phi-modal-control-adapter.d.ts +1 -1
  13. package/components/controls/phi-modal-control-adapter.js +5 -2
  14. package/components/controls/phi-overlay-control-contract.d.ts +5 -0
  15. package/components/controls/phi-pagination-control-adapter.d.ts +2 -0
  16. package/components/controls/phi-pagination-control-adapter.js +15 -0
  17. package/components/controls/phi-pagination-control.d.ts +2 -1
  18. package/components/controls/phi-pagination-control.js +11 -7
  19. package/components/controls/phi-table-control.js +1 -1
  20. package/components/controls/phi-timeline-control-adapter.d.ts +2 -0
  21. package/components/controls/phi-timeline-control-adapter.js +28 -0
  22. package/components/controls/phi-timeline-control.d.ts +3 -1
  23. package/components/controls/phi-timeline-control.js +8 -22
  24. package/components/controls/phi-tour-control.js +25 -7
  25. package/components/controls/phi-tour-viewport.d.ts +36 -0
  26. package/components/controls/phi-tour-viewport.js +86 -0
  27. package/components/controls/phi-zoom-image-overlay.d.ts +17 -0
  28. package/components/controls/phi-zoom-image-overlay.js +58 -0
  29. package/components/layouts/layout-definitions.d.ts +0 -18
  30. package/components/layouts/layout-definitions.js +7 -1
  31. package/components/layouts/plugins/carousel-layout-plugin.js +2 -1
  32. package/components/media/aspect-ratios.d.ts +14 -0
  33. package/components/media/aspect-ratios.js +13 -0
  34. package/components/motion/phi-sequence-run.d.ts +27 -0
  35. package/components/motion/phi-sequence-run.js +87 -0
  36. package/components/motion/phi-sequence-viewport.d.ts +16 -0
  37. package/components/motion/phi-sequence-viewport.js +97 -34
  38. package/components/overlays/phi-overlay-container-client.js +5 -2
  39. package/components/runtime/runtime-visual-anchors.d.ts +13 -2
  40. package/components/runtime/runtime-visual-anchors.js +49 -3
  41. package/components/widgets/config/image-variant-options.d.ts +3 -5
  42. package/components/widgets/config/image-variant-options.js +11 -11
  43. package/components/widgets/config/sequence-settings.d.ts +75 -0
  44. package/components/widgets/config/sequence-settings.js +173 -0
  45. package/components/widgets/label-sets/content-entries.d.ts +9 -0
  46. package/components/widgets/label-sets/content-entries.js +17 -0
  47. package/components/widgets/label-types/gallery.d.ts +2 -10
  48. package/components/widgets/label-types/sequence.d.ts +18 -0
  49. package/components/widgets/label-types/sequence.js +1 -0
  50. package/helpers/cms-layout-defaults.js +9 -2
  51. package/helpers/cms-region-keys.d.ts +1 -1
  52. package/helpers/motion.d.ts +7 -0
  53. package/helpers/motion.js +7 -0
  54. package/package.json +1 -1
  55. package/plugins/runtime-modules/asset/data-providers.d.ts +4 -0
  56. package/plugins/runtime-modules/asset/data-providers.js +1 -0
  57. package/plugins/runtime-modules/asset/definition.d.ts +4 -0
  58. package/plugins/runtime-modules/asset/presets.d.ts +53 -1
  59. package/plugins/runtime-modules/asset/presets.js +16 -12
  60. package/plugins/runtime-modules/asset/tour.d.ts +44 -0
  61. package/plugins/runtime-modules/asset/tour.js +53 -0
  62. package/plugins/runtime-modules/builder/clients/inspector-config-field.js +42 -18
  63. package/plugins/runtime-modules/builder/clients/inspector-image-field.js +2 -2
  64. package/plugins/runtime-modules/builder/clients/workspace-scope-boundary.js +10 -0
  65. package/plugins/runtime-modules/builder/controller/workspace-controller.js +18 -3
  66. package/plugins/runtime-modules/builder/plugin-meta-labels.js +8 -0
  67. package/plugins/runtime-modules/builder/presets.js +5 -3
  68. package/plugins/runtime-modules/builder/tour.d.ts +316 -0
  69. package/plugins/runtime-modules/builder/tour.js +337 -0
  70. package/plugins/runtime-modules/builder/trees/phi-builder-inspector-area-overlay-tree.server.js +8 -1
  71. package/plugins/runtime-modules/builder/widgets/structure-region/built-in.js +3 -2
  72. package/plugins/runtime-modules/core/widgets/collection-view/client.js +10 -0
  73. package/plugins/runtime-modules/core/widgets/description/config.d.ts +25 -1
  74. package/plugins/runtime-modules/core/widgets/entry-detail/config.d.ts +2 -0
  75. package/plugins/runtime-modules/core/widgets/entry-detail/config.js +8 -1
  76. package/plugins/runtime-modules/core/widgets/entry-header/config.d.ts +3 -0
  77. package/plugins/runtime-modules/core/widgets/entry-header/config.js +8 -1
  78. package/plugins/runtime-modules/core/widgets/entry-list/client.d.ts +4 -1
  79. package/plugins/runtime-modules/core/widgets/entry-list/client.js +3 -2
  80. package/plugins/runtime-modules/core/widgets/entry-list/config.d.ts +392 -93
  81. package/plugins/runtime-modules/core/widgets/entry-list/config.js +21 -3
  82. package/plugins/runtime-modules/core/widgets/entry-list/server.js +2 -0
  83. package/plugins/runtime-modules/core/widgets/gallery/authoring.js +62 -10
  84. package/plugins/runtime-modules/core/widgets/gallery/client.d.ts +6 -1
  85. package/plugins/runtime-modules/core/widgets/gallery/client.js +7 -79
  86. package/plugins/runtime-modules/core/widgets/gallery/config.d.ts +337 -144
  87. package/plugins/runtime-modules/core/widgets/gallery/config.js +52 -113
  88. package/plugins/runtime-modules/core/widgets/gallery/defaults.d.ts +15 -0
  89. package/plugins/runtime-modules/core/widgets/gallery/defaults.js +20 -0
  90. package/plugins/runtime-modules/core/widgets/image/client.js +1 -1
  91. package/plugins/runtime-modules/core/widgets/image/config.d.ts +29 -4
  92. package/plugins/runtime-modules/core/widgets/image/config.js +19 -2
  93. package/plugins/runtime-modules/core/widgets/image/editor.js +1 -1
  94. package/plugins/runtime-modules/core/widgets/result/config.d.ts +25 -1
  95. package/plugins/runtime-modules/core/widgets/simple-text/config.d.ts +25 -1
  96. package/plugins/runtime-modules/core/widgets/table/config.d.ts +79 -3
  97. package/plugins/runtime-modules/core/widgets/tour-button/client.js +1 -1
  98. package/plugins/runtime-modules/revisions/presets.d.ts +53 -2
  99. package/plugins/runtime-modules/revisions/presets.js +16 -12
  100. package/plugins/runtime-modules/revisions/tour.d.ts +43 -0
  101. package/plugins/runtime-modules/revisions/tour.js +51 -0
  102. package/plugins/runtime-modules/theme/presets.d.ts +36 -4
  103. package/plugins/runtime-modules/theme/tour.d.ts +45 -11
  104. package/plugins/runtime-modules/theme/tour.js +39 -8
  105. package/plugins/runtime-modules/tour/controller/address.js +2 -2
  106. package/plugins/runtime-modules/tour/controller/client.js +43 -2
  107. package/plugins/runtime-modules/tour/controller/definition.js +2 -1
  108. package/server-helpers/cms-request.js +1 -0
  109. package/server-helpers/page-tour.d.ts +10 -3
  110. package/server-helpers/page-tour.js +25 -12
  111. package/types/cms-config.d.ts +3 -1
  112. package/types/cms-config.js +3 -3
  113. package/types/cms-plugins.d.ts +31 -4
  114. package/types/tour-controller.d.ts +10 -0
  115. package/types/tour-controller.js +13 -0
  116. package/types/tours.d.ts +27 -25
  117. package/types/tours.js +9 -23
  118. package/types/visual-anchors.d.ts +15 -0
  119. package/types/visual-anchors.js +12 -0
  120. package/types.d.ts +2 -0
  121. package/types.js +4 -1
@@ -0,0 +1,58 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useRef, useSyncExternalStore } from "react";
4
+ import { createPortal } from "react-dom";
5
+ import { usePhiConfig } from "../root/phi-config-provider";
6
+ const subscribeNever = () => () => { };
7
+ /** Ant Design's `zIndexPopupBase`, the layer its Modal stands on; the Phi token set does not carry it. */
8
+ const PHI_ZOOM_OVERLAY_Z_INDEX = 1000;
9
+ /**
10
+ * A picture scaled to the viewport over the page, and nothing else: no toolbar, no zoom steps, no
11
+ * rotation -- that is the lightbox's (`PhiLightboxImageControl`). A click anywhere closes it, as does
12
+ * Escape, and the focus goes back to where it was.
13
+ *
14
+ * On `document.body`, at the layer a Modal stands on, for the reason `PhiFloatButtonPortal` gives: a
15
+ * picture's slot and Region open stacking contexts of their own, and an overlay left inside them would be
16
+ * painted under the Header. The server draws nothing; it opens only on a click.
17
+ */
18
+ export function PhiZoomImageOverlay({ open, src, alt, title, onClose }) {
19
+ const { token } = usePhiConfig();
20
+ const mounted = useSyncExternalStore(subscribeNever, () => true, () => false);
21
+ const overlayRef = useRef(null);
22
+ useEffect(() => {
23
+ if (!open)
24
+ return undefined;
25
+ const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null;
26
+ overlayRef.current?.focus();
27
+ const closeOnEscape = (event) => {
28
+ if (event.key === "Escape")
29
+ onClose();
30
+ };
31
+ document.addEventListener("keydown", closeOnEscape);
32
+ return () => {
33
+ document.removeEventListener("keydown", closeOnEscape);
34
+ previousFocus?.focus();
35
+ };
36
+ }, [onClose, open]);
37
+ if (!mounted || !open)
38
+ return null;
39
+ return createPortal(_jsx("div", { ref: overlayRef, role: "dialog", "aria-modal": "true", "aria-label": alt || title, tabIndex: -1,
40
+ /*
41
+ * The portal leaves the DOM, not the React tree: a click here would bubble on to the picture's box,
42
+ * which opens it again. It stops at the overlay.
43
+ */
44
+ onClick: (event) => {
45
+ event.stopPropagation();
46
+ onClose();
47
+ }, onKeyDown: (event) => event.stopPropagation(), style: {
48
+ position: "fixed",
49
+ inset: 0,
50
+ zIndex: PHI_ZOOM_OVERLAY_Z_INDEX,
51
+ display: "flex",
52
+ alignItems: "center",
53
+ justifyContent: "center",
54
+ background: token.colorBgMask,
55
+ cursor: "zoom-out",
56
+ outline: "none",
57
+ }, children: _jsx("img", { alt: alt, src: src, title: title, style: { display: "block", width: "100vw", height: "100dvh", objectFit: "contain" } }) }), document.body);
58
+ }
@@ -429,15 +429,6 @@ export declare const PHI_CAROUSEL_LAYOUT_DEFINITION: {
429
429
  step: number;
430
430
  prefix: string;
431
431
  options?: undefined;
432
- } | {
433
- key: string;
434
- type: "string";
435
- label: string;
436
- min?: undefined;
437
- max?: undefined;
438
- options?: undefined;
439
- step?: undefined;
440
- prefix?: undefined;
441
432
  } | {
442
433
  key: string;
443
434
  type: "boolean";
@@ -1132,15 +1123,6 @@ export declare const PHI_CORE_LAYOUT_DEFINITIONS: readonly [{
1132
1123
  step: number;
1133
1124
  prefix: string;
1134
1125
  options?: undefined;
1135
- } | {
1136
- key: string;
1137
- type: "string";
1138
- label: string;
1139
- min?: undefined;
1140
- max?: undefined;
1141
- options?: undefined;
1142
- step?: undefined;
1143
- prefix?: undefined;
1144
1126
  } | {
1145
1127
  key: string;
1146
1128
  type: "boolean";
@@ -7,6 +7,7 @@ import { PHI_RENDERABLE_BLOCK_DEFAULT_ANCHOR } from "../../helpers/renderable-bl
7
7
  import { PHI_LAYOUT_PADDING_FIELDS } from "../../helpers/layout-padding-field";
8
8
  import { PHI_SIGNAL_VALUE_SCHEMAS } from "../../types/signals";
9
9
  import { PHI_CORE_RUNTIME_DATA_PROVIDER_KEYS } from "../../constants/core-data-provider-keys";
10
+ import { PHI_SPACING_SCALE_KEYS } from "../widgets/config/spacing-options";
10
11
  const PHI_COLLAPSIBLE_SLOT_OPTIONS = PHI_CMS_COLLAPSIBLE_LAYOUT_SLOTS.map((slot) => ({
11
12
  value: slot.key,
12
13
  label: slot.label,
@@ -358,7 +359,12 @@ export const PHI_CAROUSEL_LAYOUT_DEFINITION = {
358
359
  label: "Easing",
359
360
  options: [...PHI_MOTION_EASING_FIELD_OPTIONS],
360
361
  },
361
- { key: "slotGap", type: "string", label: "Slot Gap" },
362
+ {
363
+ key: "slotGap",
364
+ type: "choice",
365
+ label: "Slot Gap",
366
+ options: PHI_SPACING_SCALE_KEYS.map((key) => ({ value: key, label: key })),
367
+ },
362
368
  {
363
369
  key: "controls",
364
370
  type: "choice",
@@ -6,11 +6,12 @@ import { PhiCarouselLayout } from "../phi-carousel-layout";
6
6
  import { definePhiLayoutRenderers } from "../layout-plugin-renderers";
7
7
  import { resolvePhiLayoutSlotMeta } from "../layout-slot-meta";
8
8
  import { PHI_CAROUSEL_LAYOUT_DEFINITION } from "../layout-definitions";
9
+ import { resolvePhiSpacingScaleValue } from "../../widgets/config/spacing-options";
9
10
  function resolvePhiCarouselSlotKeys() {
10
11
  return PHI_CMS_CAROUSEL_LAYOUT_SLOTS.map((slot) => slot.key);
11
12
  }
12
13
  export const PHI_CAROUSEL_LAYOUT_PLUGIN = {
13
14
  ...PHI_CAROUSEL_LAYOUT_DEFINITION,
14
15
  parseConfig: parsePhiCmsCarouselLayoutConfig,
15
- ...definePhiLayoutRenderers(({ node, config, renderSequentialSlotChildren }, renderMode) => (_jsx(PhiCarouselLayout, { blockId: node.id, layoutKind: "carousel", slots: renderSequentialSlotChildren(node), slotKeys: resolvePhiCarouselSlotKeys(), slotMeta: resolvePhiLayoutSlotMeta(node, PHI_CMS_CAROUSEL_LAYOUT_SLOTS), renderMode: renderMode, defaultActiveSlotKey: config.defaultActiveSlotKey, visibleSlots: config.visibleSlots, windowAnchor: config.windowAnchor, transition: config.transition, transitionDurationMs: config.transitionDurationMs, transitionEasing: config.transitionEasing, slotGap: config.slotGap, controls: config.controls, loop: config.loop, autoplayMs: config.autoplayMs, lookahead: config.lookahead, slotAnchor: resolvePhiLayoutAnchor(config.anchor, PHI_CAROUSEL_LAYOUT_DEFINITION.defaultAnchor), editSlotAnchor: resolvePhiLayoutAnchor(config.anchor, PHI_CAROUSEL_LAYOUT_DEFINITION.defaultAnchor), zIndex: config.zIndex, surface: config.surface, padding: config.padding, paddingTop: config.paddingTop, paddingRight: config.paddingRight, paddingBottom: config.paddingBottom, paddingLeft: config.paddingLeft, labelEnd: config.labelEnd, size: config.size, minSize: config.minSize, maxSize: config.maxSize, collapsedSizeHint: config.collapsedSizeHint }, `layout-${node.id}`))),
16
+ ...definePhiLayoutRenderers(({ node, config, renderSequentialSlotChildren }, renderMode) => (_jsx(PhiCarouselLayout, { blockId: node.id, layoutKind: "carousel", slots: renderSequentialSlotChildren(node), slotKeys: resolvePhiCarouselSlotKeys(), slotMeta: resolvePhiLayoutSlotMeta(node, PHI_CMS_CAROUSEL_LAYOUT_SLOTS), renderMode: renderMode, defaultActiveSlotKey: config.defaultActiveSlotKey, visibleSlots: config.visibleSlots, windowAnchor: config.windowAnchor, transition: config.transition, transitionDurationMs: config.transitionDurationMs, transitionEasing: config.transitionEasing, slotGap: resolvePhiSpacingScaleValue(config.slotGap ?? "none"), controls: config.controls, loop: config.loop, autoplayMs: config.autoplayMs, lookahead: config.lookahead, slotAnchor: resolvePhiLayoutAnchor(config.anchor, PHI_CAROUSEL_LAYOUT_DEFINITION.defaultAnchor), editSlotAnchor: resolvePhiLayoutAnchor(config.anchor, PHI_CAROUSEL_LAYOUT_DEFINITION.defaultAnchor), zIndex: config.zIndex, surface: config.surface, padding: config.padding, paddingTop: config.paddingTop, paddingRight: config.paddingRight, paddingBottom: config.paddingBottom, paddingLeft: config.paddingLeft, labelEnd: config.labelEnd, size: config.size, minSize: config.minSize, maxSize: config.maxSize, collapsedSizeHint: config.collapsedSizeHint }, `layout-${node.id}`))),
16
17
  };
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The proportions a frame for pictures is offered in, stored as `width:height`.
3
+ *
4
+ * A closed list rather than a text box: a ratio somebody types is a ratio nobody checks, and the few a
5
+ * page actually needs are the ones a camera, a screen and a print already use.
6
+ */
7
+ export declare const PHI_MEDIA_ASPECT_RATIOS: readonly ["16:9", "3:2", "4:3", "1:1", "3:4", "2:3", "9:16", "21:9"];
8
+ export type PhiMediaAspectRatio = (typeof PHI_MEDIA_ASPECT_RATIOS)[number];
9
+ export declare const PHI_MEDIA_ASPECT_RATIO_OPTIONS: {
10
+ value: "16:9" | "3:2" | "4:3" | "1:1" | "3:4" | "2:3" | "9:16" | "21:9";
11
+ label: "16:9" | "3:2" | "4:3" | "1:1" | "3:4" | "2:3" | "9:16" | "21:9";
12
+ }[];
13
+ /** The ratio as CSS `aspect-ratio` writes it. */
14
+ export declare function resolvePhiMediaAspectRatioCss(ratio: PhiMediaAspectRatio): string;
@@ -0,0 +1,13 @@
1
+ /**
2
+ * The proportions a frame for pictures is offered in, stored as `width:height`.
3
+ *
4
+ * A closed list rather than a text box: a ratio somebody types is a ratio nobody checks, and the few a
5
+ * page actually needs are the ones a camera, a screen and a print already use.
6
+ */
7
+ export const PHI_MEDIA_ASPECT_RATIOS = ["16:9", "3:2", "4:3", "1:1", "3:4", "2:3", "9:16", "21:9"];
8
+ export const PHI_MEDIA_ASPECT_RATIO_OPTIONS = PHI_MEDIA_ASPECT_RATIOS.map((ratio) => ({ value: ratio, label: ratio }));
9
+ /** The ratio as CSS `aspect-ratio` writes it. */
10
+ export function resolvePhiMediaAspectRatioCss(ratio) {
11
+ const [width, height] = ratio.split(":");
12
+ return `${width} / ${height}`;
13
+ }
@@ -0,0 +1,27 @@
1
+ import { type CSSProperties, type ReactNode } from "react";
2
+ import { type PhiSequenceSettings } from "../widgets/config/sequence-settings";
3
+ import type { PhiSequenceControlLabels } from "../widgets/label-types/sequence";
4
+ /** How many items stand in the window: never more than there are, never fewer than one. */
5
+ export declare function resolvePhiSequenceRunSpan(visibleCount: number | undefined, itemCount: number): number;
6
+ /**
7
+ * A run of items shown a few at a time, and the two things the browser will not do for us.
8
+ *
9
+ * The first is knowing which item is next. Native lazy loading decides from an element's distance to
10
+ * the viewport, so on a fade -- where every item sits in the same place -- it loads all of them at once,
11
+ * and on a slide it loads the next one as it arrives, during the transition. So the window says what
12
+ * loads: what is mounted is wanted, and an item that loads anything should load it eagerly.
13
+ *
14
+ * The second is stopping: `usePhiSequenceAutoplay` holds every way to decline movement nobody asked
15
+ * for, and the Pause button is the one a finger has, since a finger neither hovers nor tabs. Where
16
+ * the window stops -- the last window rather than the last multiple of the window -- is the Carousel
17
+ * Layout's list of stops (`phi-carousel-pages.ts`), read by the arrows, the dots and the timer alike.
18
+ *
19
+ * What the items are is the caller's: the Gallery's pictures, the Entries list's cards.
20
+ */
21
+ export declare function PhiSequenceRun({ items, settings, labels, itemStyle, }: {
22
+ /** Keyed by the caller; the run neither wraps nor rekeys them. */
23
+ items: readonly ReactNode[];
24
+ settings: PhiSequenceSettings | undefined;
25
+ labels: PhiSequenceControlLabels;
26
+ itemStyle?: CSSProperties;
27
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,87 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useMemo, useState } from "react";
4
+ import { usePhiConfig } from "../root/phi-config-provider";
5
+ import { PhiButtonControl } from "../controls/phi-button-control";
6
+ import { resolvePhiCarouselCurrentPage, resolvePhiCarouselPageStarts, resolvePhiCarouselStep, } from "../layouts/phi-carousel-pages";
7
+ import { PhiIcon } from "../shell/phi-icon";
8
+ import { resolvePhiSpacingScaleValue } from "../widgets/config/spacing-options";
9
+ import { PHI_SEQUENCE_SETTINGS_DEFAULTS, } from "../widgets/config/sequence-settings";
10
+ import { PhiSequenceViewport, resolvePhiSequenceWindowStart } from "./phi-sequence-viewport";
11
+ import { usePhiSequenceAutoplay } from "./use-phi-sequence-autoplay";
12
+ import { usePhiSequenceGestures } from "./use-phi-sequence-gestures";
13
+ /** The dots' label, `%1` the first and `%2` the last item a page shows. */
14
+ function formatPageLabel(template, from, to) {
15
+ return template.replace("%1", String(from)).replace("%2", String(to));
16
+ }
17
+ /** How many items stand in the window: never more than there are, never fewer than one. */
18
+ export function resolvePhiSequenceRunSpan(visibleCount, itemCount) {
19
+ return Math.min(visibleCount ?? PHI_SEQUENCE_SETTINGS_DEFAULTS.visibleCount, Math.max(itemCount, 1));
20
+ }
21
+ /**
22
+ * A run of items shown a few at a time, and the two things the browser will not do for us.
23
+ *
24
+ * The first is knowing which item is next. Native lazy loading decides from an element's distance to
25
+ * the viewport, so on a fade -- where every item sits in the same place -- it loads all of them at once,
26
+ * and on a slide it loads the next one as it arrives, during the transition. So the window says what
27
+ * loads: what is mounted is wanted, and an item that loads anything should load it eagerly.
28
+ *
29
+ * The second is stopping: `usePhiSequenceAutoplay` holds every way to decline movement nobody asked
30
+ * for, and the Pause button is the one a finger has, since a finger neither hovers nor tabs. Where
31
+ * the window stops -- the last window rather than the last multiple of the window -- is the Carousel
32
+ * Layout's list of stops (`phi-carousel-pages.ts`), read by the arrows, the dots and the timer alike.
33
+ *
34
+ * What the items are is the caller's: the Gallery's pictures, the Entries list's cards.
35
+ */
36
+ export function PhiSequenceRun({ items, settings, labels, itemStyle, }) {
37
+ const { token } = usePhiConfig();
38
+ const defaults = PHI_SEQUENCE_SETTINGS_DEFAULTS;
39
+ const loop = settings?.loop ?? defaults.loop;
40
+ const controls = settings?.controls ?? defaults.controls;
41
+ const autoplayMs = settings?.autoplayMs ?? defaults.autoplayMs;
42
+ const itemCount = items.length;
43
+ const [activeIndex, setActiveIndex] = useState(0);
44
+ const span = resolvePhiSequenceRunSpan(settings?.visibleCount, itemCount);
45
+ const pageStarts = useMemo(() => resolvePhiCarouselPageStarts(itemCount, span), [itemCount, span]);
46
+ const lastStart = pageStarts[pageStarts.length - 1] ?? 0;
47
+ const step = useCallback((direction) => {
48
+ setActiveIndex((current) => resolvePhiCarouselStep(pageStarts, current, direction, loop));
49
+ }, [loop, pageStarts]);
50
+ const advance = useCallback(() => step(1), [step]);
51
+ const autoplay = usePhiSequenceAutoplay({ intervalMs: autoplayMs, active: itemCount > span, advance });
52
+ // A step somebody took: the next automatic one is a whole interval away again.
53
+ const stepByHand = useCallback((direction) => {
54
+ step(direction);
55
+ autoplay.restart();
56
+ }, [autoplay, step]);
57
+ const gestures = usePhiSequenceGestures({ step: stepByHand });
58
+ if (itemCount === 0) {
59
+ return null;
60
+ }
61
+ const windowStart = resolvePhiSequenceWindowStart({
62
+ activeIndex,
63
+ visibleCount: span,
64
+ itemCount,
65
+ anchor: "start",
66
+ });
67
+ const pageCount = pageStarts.length;
68
+ const currentPage = resolvePhiCarouselCurrentPage(pageStarts, windowStart);
69
+ const atStart = windowStart <= 0;
70
+ const atEnd = windowStart >= lastStart;
71
+ // Two disabled arrows over a single item say nothing; the dots already know to stay away.
72
+ const showArrows = (controls === "arrows" || controls === "both") && itemCount > span;
73
+ const showDots = (controls === "dots" || controls === "both") && pageCount > 1;
74
+ return (_jsxs("div", { role: "region", "aria-roledescription": "carousel", "aria-label": labels.region, style: { display: "flex", flexDirection: "column", gap: token.marginXS, width: "100%", minWidth: 0, ...gestures.style }, ...autoplay.holdHandlers, ...gestures.handlers, children: [_jsxs("div", { style: { position: "relative", minWidth: 0 }, "aria-live": autoplay.running ? "off" : "polite", children: [_jsx(PhiSequenceViewport, { items: [...items], activeIndex: activeIndex, visibleCount: span, anchor: "start", transition: settings?.transition ?? defaults.transition, ...(settings?.durationMs === undefined ? {} : { durationMs: settings.durationMs }), ...(settings?.easing === undefined ? {} : { easing: settings.easing }), lookahead: settings?.lookahead ?? defaults.lookahead, mountPolicy: settings?.mountPolicy ?? defaults.mountPolicy, gap: resolvePhiSpacingScaleValue(settings?.gap ?? defaults.gap), itemStyle: itemStyle }), showArrows ? (_jsxs(_Fragment, { children: [_jsx("div", { style: { position: "absolute", insetBlockStart: "50%", insetInlineStart: token.marginXS, transform: "translateY(-50%)" }, children: _jsx(PhiButtonControl, { icon: _jsx(PhiIcon, { name: "left", size: "inherit" }), ariaLabel: labels.previous, shape: "circle", disabled: atStart && !loop, onClick: () => stepByHand(-1) }) }), _jsx("div", { style: { position: "absolute", insetBlockStart: "50%", insetInlineEnd: token.marginXS, transform: "translateY(-50%)" }, children: _jsx(PhiButtonControl, { icon: _jsx(PhiIcon, { name: "right", size: "inherit" }), ariaLabel: labels.next, shape: "circle", disabled: atEnd && !loop, onClick: () => stepByHand(1) }) })] })) : null, autoplay.offered ? (_jsx("div", { style: { position: "absolute", insetBlockEnd: token.marginXS, insetInlineEnd: token.marginXS }, children: _jsx(PhiButtonControl, { icon: _jsx(PhiIcon, { name: autoplay.stopped ? "play-circle" : "pause-circle", size: "inherit" }), ariaLabel: autoplay.stopped ? labels.play : labels.pause, ariaPressed: autoplay.stopped, shape: "circle", onClick: autoplay.toggleStopped }) })) : null] }), showDots ? (_jsx("div", { role: "group", "aria-label": labels.pages, style: { display: "flex", justifyContent: "center", gap: token.marginXXS }, children: Array.from({ length: pageCount }, (_, page) => (_jsx("button", { type: "button", "aria-label": formatPageLabel(labels.page, (pageStarts[page] ?? 0) + 1, Math.min((pageStarts[page] ?? 0) + span, itemCount)), "aria-current": page === currentPage || undefined, onClick: () => {
75
+ setActiveIndex(pageStarts[page] ?? 0);
76
+ autoplay.restart();
77
+ }, style: {
78
+ width: page === currentPage ? token.controlHeightXS : token.marginXS,
79
+ height: token.marginXS,
80
+ padding: 0,
81
+ border: "none",
82
+ cursor: "pointer",
83
+ borderRadius: token.borderRadiusSM,
84
+ background: page === currentPage ? token.colorPrimary : token.colorFill,
85
+ transition: `width ${token.motionDurationMid}, background ${token.motionDurationMid}`,
86
+ } }, page))) })) : null] }));
87
+ }
@@ -1,6 +1,22 @@
1
1
  import { type CSSProperties, type ReactNode } from "react";
2
2
  import { type PhiMotionEasing, type PhiSequenceAnchor, type PhiSequenceTransition } from "../../helpers/motion";
3
3
  import { type PhiCmsMountPolicy } from "../../types/cms-mount-policy";
4
+ /**
5
+ * A window onto a sequence, and the movement from one window to the next.
6
+ *
7
+ * This is the piece a Stack, a Carousel and an image gallery have in common, and it is deliberately
8
+ * the thinnest of the three layers: it takes already-rendered children, an index and a shape, and it
9
+ * knows nothing about slots, signals, the Builder or where the pictures came from. Whoever owns the
10
+ * index -- a signal channel, a pair of arrows, a timer -- owns it outside.
11
+ *
12
+ * Two geometries, chosen by the transition, because the two ways of showing a sequence are genuinely
13
+ * different acts. `slide` lays the items out side by side on a track and moves the track: the
14
+ * neighbours exist next to the window and arrive from the direction they were in. `fade`, `diagonal`
15
+ * and `none` keep the window where it is and exchange what stands in its places -- the Stack's way.
16
+ * A fade crosses the leaving items over into the coming ones; a diagonal leaves the old ones standing
17
+ * and slides each new one over its place from the corner it comes from, below and after it going
18
+ * forward, above and before it going back. Nothing travels sideways.
19
+ */
4
20
  export type PhiSequenceViewportProps = {
5
21
  items: readonly ReactNode[];
6
22
  /**
@@ -1,26 +1,8 @@
1
1
  "use client";
2
- import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useState } from "react";
4
- import { clampPhiSequenceTransitionMs, } from "../../helpers/motion";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useEffect, useLayoutEffect, useRef, useState } from "react";
4
+ import { clampPhiSequenceTransitionMs, PHI_SEQUENCE_DEFAULT_DURATION_MS, PHI_SEQUENCE_DEFAULT_EASING, } from "../../helpers/motion";
5
5
  import { shouldPhiCmsContentStayMounted, } from "../../types/cms-mount-policy";
6
- /**
7
- * A window onto a sequence, and the movement from one window to the next.
8
- *
9
- * This is the piece a Stack, a Carousel and an image gallery have in common, and it is deliberately
10
- * the thinnest of the three layers: it takes already-rendered children, an index and a shape, and it
11
- * knows nothing about slots, signals, the Builder or where the pictures came from. Whoever owns the
12
- * index -- a signal channel, a pair of arrows, a timer -- owns it outside.
13
- *
14
- * Two geometries, chosen by the transition, because the two ways of showing a sequence are genuinely
15
- * different acts. `slide` and `diagonal` lay the items out side by side on a track and move the track:
16
- * the neighbours exist next to the window and arrive from the direction they were in. `fade` and
17
- * `none` put the window's items in flow and cross-fade the ones that are leaving over the top, which
18
- * is what the Stack has always done -- the items do not travel, they exchange places.
19
- */
20
- /** How far a diagonal leaves the horizontal, as a share of the travel it is already making. */
21
- const DIAGONAL_RISE = 0.35;
22
- /** What a caller who says nothing gets: long enough to read as movement, short enough to ignore. */
23
- const DEFAULT_SEQUENCE_DURATION_MS = 320;
24
6
  function toCssLength(value) {
25
7
  if (value === undefined)
26
8
  return "0px";
@@ -41,7 +23,7 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
41
23
  // whole number is a caller's mistake, and the type says so rather than this line rescuing it.
42
24
  const span = Math.min(visibleCount, Math.max(itemCount, 1));
43
25
  const start = resolvePhiSequenceWindowStart({ activeIndex, visibleCount: span, itemCount, anchor });
44
- const onTrack = transition === "slide" || transition === "diagonal";
26
+ const onTrack = transition === "slide";
45
27
  /*
46
28
  * Which items have ever been inside the window, which is what `lazy-keep` keeps.
47
29
  *
@@ -91,9 +73,9 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
91
73
  const resolvedDurationMs = reducedMotion || transition === "none"
92
74
  ? 0
93
75
  : durationMs === undefined
94
- ? DEFAULT_SEQUENCE_DURATION_MS
76
+ ? PHI_SEQUENCE_DEFAULT_DURATION_MS
95
77
  : clampPhiSequenceTransitionMs(durationMs);
96
- const resolvedEasing = easing ?? "ease-in-out";
78
+ const resolvedEasing = easing ?? PHI_SEQUENCE_DEFAULT_EASING;
97
79
  const gapLength = toCssLength(gap);
98
80
  const slice = span === 1
99
81
  ? "100%"
@@ -112,9 +94,70 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
112
94
  mountedIndices.add(index);
113
95
  }
114
96
  }
115
- const trackTransform = onTrack
116
- ? `translate3d(calc(-1 * ${start} * ${step}), ${transition === "diagonal" ? `calc(-1 * ${start} * ${step} * ${DIAGONAL_RISE})` : "0px"}, 0)`
117
- : undefined;
97
+ const trackTransform = onTrack ? `translate3d(calc(-1 * ${start} * ${step}), 0px, 0)` : undefined;
98
+ /*
99
+ * The exchange between two windows off the track, played after the new window is laid out and before
100
+ * it is painted, so the first frame already starts where the movement does.
101
+ *
102
+ * At the tail the last window overlaps the one before it -- five pictures three at a time go one to
103
+ * three, then three to five -- so an item can be in both windows at another place. It cannot leave
104
+ * where it was and arrive where it goes as one element, and moving it at once read as the one picture
105
+ * that changed without a transition. So it arrives at its new place like any other, and a copy of it
106
+ * stands in its old place until the leaving window is let go.
107
+ */
108
+ const leavingStart = onTrack ? null : fadeState.outgoingStart;
109
+ const movedIndices = leavingStart === null
110
+ ? []
111
+ : Array.from({ length: span }, (_, offset) => leavingStart + offset)
112
+ // In the window it left and in the one it enters: a new start puts it at another place.
113
+ .filter((index) => index >= start && index < start + span);
114
+ const itemElements = useRef([]);
115
+ const trackElement = useRef(null);
116
+ const previousStart = useRef(start);
117
+ const runningAnimations = useRef([]);
118
+ useLayoutEffect(() => {
119
+ const from = previousStart.current;
120
+ previousStart.current = start;
121
+ if (from === start || onTrack || resolvedDurationMs === 0)
122
+ return;
123
+ /*
124
+ * A step taken while the last one is still moving -- a click on top of autoplay, an interval as
125
+ * short as the movement -- starts from a window that has arrived. Left running, the half-arrived
126
+ * items became the leaving ones over nothing, because what stood under them was let go.
127
+ */
128
+ for (const animation of runningAnimations.current)
129
+ animation.finish();
130
+ runningAnimations.current = [];
131
+ const timing = { duration: resolvedDurationMs, easing: resolvedEasing };
132
+ const windowIndices = Array.from({ length: span }, (_, offset) => start + offset);
133
+ const moved = windowIndices.filter((index) => index >= from && index < from + span);
134
+ const copyOf = (index) => trackElement.current?.querySelector(`[data-phi-sequence-leaving="${index}"]`);
135
+ if (transition === "fade") {
136
+ for (const index of moved) {
137
+ // Not held at the end: the item's own opacity is 1 already, and a held animation would outlast
138
+ // the next fade that wants to take it away.
139
+ const arriving = itemElements.current[index]?.animate?.([{ opacity: 0 }, { opacity: 1 }], timing);
140
+ // The copy is held at nothing until it goes, which is when the leaving window is let go.
141
+ const leaving = copyOf(index)?.animate?.([{ opacity: 1 }, { opacity: 0 }], { ...timing, fill: "forwards" });
142
+ runningAnimations.current.push(...[arriving, leaving].filter((animation) => animation !== undefined));
143
+ }
144
+ return;
145
+ }
146
+ if (transition === "diagonal") {
147
+ /*
148
+ * A whole place's width and height, as a percentage of the item itself: every new item starts one
149
+ * box off its place, so each comes in from outside the window's corner, whatever its proportion.
150
+ */
151
+ const offset = start > from ? "100%" : "-100%";
152
+ const animations = windowIndices.flatMap((index) => {
153
+ const animation = itemElements.current[index]?.animate?.([{ translate: `${offset} ${offset}` }, { translate: "0 0" }], timing);
154
+ return animation ? [animation] : [];
155
+ });
156
+ runningAnimations.current = animations;
157
+ // Nothing fades here, so no transition ends to say so: the window is let go when the last item is in.
158
+ void Promise.all(animations.map((animation) => animation.finished)).then(() => setFadeState((current) => (current.start === start ? { ...current, outgoingStart: null } : current)), () => undefined);
159
+ }
160
+ }, [start, span, onTrack, transition, resolvedDurationMs, resolvedEasing]);
118
161
  const renderedItems = items.map((item, index) => {
119
162
  const visible = index >= start && index < start + span;
120
163
  const outgoing = !visible && fadeState.outgoingStart !== null &&
@@ -129,7 +172,8 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
129
172
  ? { flex: `0 0 ${slice}`, minWidth: 0 }
130
173
  : {
131
174
  ...(visible
132
- ? { flex: `0 0 ${slice}`, minWidth: 0 }
175
+ // Above what leaves, so a diagonal's new items cover the old ones they slide over.
176
+ ? { flex: `0 0 ${slice}`, minWidth: 0, position: "relative", zIndex: 1 }
133
177
  : {
134
178
  position: "absolute",
135
179
  insetBlockStart: 0,
@@ -139,17 +183,26 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
139
183
  : 0,
140
184
  width: slice,
141
185
  }),
142
- opacity: visible ? 1 : 0,
143
- transition: resolvedDurationMs > 0
186
+ // A diagonal's leaving items stand until the new ones cover them; a fade's go out.
187
+ opacity: visible || (outgoing && transition === "diagonal" && resolvedDurationMs > 0) ? 1 : 0,
188
+ transition: resolvedDurationMs > 0 && transition === "fade"
144
189
  ? `opacity ${resolvedDurationMs}ms ${resolvedEasing}`
145
190
  : undefined,
146
191
  };
192
+ const ref = (element) => {
193
+ itemElements.current[index] = element;
194
+ };
147
195
  if (!mounted && !visible && !outgoing) {
148
- return _jsx("div", { "aria-hidden": true, style: { ...box, pointerEvents: "none" } }, index);
196
+ return _jsx("div", { ref: ref, "aria-hidden": true, style: { ...box, pointerEvents: "none" } }, index);
149
197
  }
150
- return (_jsx("div", { "data-phi-sequence-item-state": visible ? "visible" : outgoing ? "outgoing" : "waiting", inert: !visible, "aria-hidden": !visible || undefined, style: { ...box, ...itemStyle }, children: mounted ? (renderItem ? renderItem(item, { index, visible }) : item) : null }, index));
198
+ return (_jsx("div", { ref: ref, "data-phi-sequence-item-state": visible ? "visible" : outgoing ? "outgoing" : "waiting", inert: !visible, "aria-hidden": !visible || undefined, style: { ...box, ...itemStyle }, children: mounted ? (renderItem ? renderItem(item, { index, visible }) : item) : null }, index));
151
199
  });
152
- return (_jsx("div", { className: className, "data-phi-sequence-transition": transition, style: { position: "relative", overflow: "hidden", minWidth: 0, ...style }, children: _jsx("div", { style: {
200
+ return (_jsx("div", { className: className, "data-phi-sequence-transition": transition,
201
+ /*
202
+ * Its own stacking context: the items it raises over the leaving ones are raised inside it, never
203
+ * over the arrows and buttons a caller lays over the viewport.
204
+ */
205
+ style: { position: "relative", overflow: "hidden", isolation: "isolate", minWidth: 0, ...style }, children: _jsxs("div", { ref: trackElement, style: {
153
206
  position: "relative",
154
207
  display: "flex",
155
208
  alignItems: "stretch",
@@ -177,5 +230,15 @@ export function PhiSequenceViewport({ items, activeIndex, visibleCount = 1, anch
177
230
  : {}),
178
231
  }, onTransitionEnd: fadeState.outgoingStart === null
179
232
  ? undefined
180
- : () => setFadeState((current) => ({ ...current, outgoingStart: null })), children: renderedItems }) }));
233
+ : () => setFadeState((current) => ({ ...current, outgoingStart: null })), children: [renderedItems, leavingStart === null ? null : movedIndices.map((index) => (_jsx("div", { "data-phi-sequence-leaving": index, "aria-hidden": true, inert: true, style: {
234
+ position: "absolute",
235
+ insetBlockStart: 0,
236
+ insetBlockEnd: 0,
237
+ insetInlineStart: `calc(${index - leavingStart} * ${step})`,
238
+ width: slice,
239
+ // Under the window's own items, which arrive over it.
240
+ zIndex: 0,
241
+ pointerEvents: "none",
242
+ ...itemStyle,
243
+ }, children: items[index] }, `leaving-${index}`)))] }) }));
181
244
  }
@@ -12,6 +12,7 @@ import { emitPhiSignalCapability, usePhiSignalEmitter, PhiSignalIdentityProvider
12
12
  import { usePhiSignalListener } from "../runtime/runtime-signal-bus";
13
13
  import { registerPhiSignalInstance } from "../runtime/runtime-signal-registry";
14
14
  import { usePhiSignalRuntimePartition } from "../runtime/runtime-signal-partition";
15
+ import { usePhiVisualAnchor } from "../runtime/runtime-visual-anchors";
15
16
  import { PhiModalControl } from "../controls/phi-modal-control";
16
17
  import { PhiDrawerControl } from "../controls/phi-drawer-control";
17
18
  import { PhiAlertControl } from "../controls/phi-alert-control";
@@ -72,6 +73,8 @@ export function PhiOverlayContainerClient({ overlayId, overlayType, config: rawC
72
73
  const openedAtPathname = useRef(null);
73
74
  const pathname = usePhiPathname();
74
75
  const emitSignal = usePhiSignalEmitter(receiver);
76
+ // The Overlay's panel, found by a Tour under the Overlay's own address while it is drawn.
77
+ const panelRef = usePhiVisualAnchor(receiver);
75
78
  const loadZones = usePhiOverlayZonesLoaderIfAny();
76
79
  const [loadedZones, setLoadedZones] = useState(null);
77
80
  const listenRoutes = useMemo(() => config.signalRoutes?.listens ?? [], [config.signalRoutes?.listens]);
@@ -261,7 +264,7 @@ export function PhiOverlayContainerClient({ overlayId, overlayType, config: rawC
261
264
  */
262
265
  const surfaceStyle = { ...resolvePhiCmsContainerSurface(config).style, padding: 0 };
263
266
  if (overlayType === "drawer") {
264
- return (_jsxs(_Fragment, { children: [zoneControllers, _jsx(PhiDrawerControl, { open: open, title: runtimeTitle, header: renderZone(zones?.header), body: renderZone(zones?.body), footer: renderZone(zones?.footer), closable: behaviour.closable, keyboard: behaviour.keyboard, mask: behaviour.mask, mountPolicy: config.mountPolicy, placement: config.placement, size: config.size, maxSize: config.maxSize, resizable: behaviour.resizable, push: behaviour.push, containerStyle: surfaceStyle, onDismiss: requestClose })] }));
267
+ return (_jsxs(_Fragment, { children: [zoneControllers, _jsx(PhiDrawerControl, { open: open, title: runtimeTitle, header: renderZone(zones?.header), body: renderZone(zones?.body), footer: renderZone(zones?.footer), closable: behaviour.closable, keyboard: behaviour.keyboard, mask: behaviour.mask, mountPolicy: config.mountPolicy, placement: config.placement, size: config.size, maxSize: config.maxSize, resizable: behaviour.resizable, push: behaviour.push, containerStyle: surfaceStyle, onDismiss: requestClose, panelRef: panelRef })] }));
265
268
  }
266
- return (_jsxs(_Fragment, { children: [zoneControllers, _jsx(PhiModalControl, { open: open, title: runtimeTitle, header: renderZone(zones?.header), body: renderZone(zones?.body == null ? null : _jsx(PhiDialogBodyProvider, { children: zones.body })), footer: renderZone(zones?.footer), closable: behaviour.closable, keyboard: behaviour.keyboard, mask: behaviour.mask, mountPolicy: config.mountPolicy, centered: behaviour.centered, controlSize: runtimeControlSize ?? config.controlSize, size: runtimeSize, width: config.width, containerStyle: surfaceStyle, onDismiss: requestClose })] }));
269
+ return (_jsxs(_Fragment, { children: [zoneControllers, _jsx(PhiModalControl, { open: open, title: runtimeTitle, header: renderZone(zones?.header), body: renderZone(zones?.body == null ? null : _jsx(PhiDialogBodyProvider, { children: zones.body })), footer: renderZone(zones?.footer), closable: behaviour.closable, keyboard: behaviour.keyboard, mask: behaviour.mask, mountPolicy: config.mountPolicy, centered: behaviour.centered, controlSize: runtimeControlSize ?? config.controlSize, size: runtimeSize, width: config.width, containerStyle: surfaceStyle, onDismiss: requestClose, panelRef: panelRef })] }));
267
270
  }
@@ -1,4 +1,4 @@
1
- import { type PhiVisualAnchorAddress } from "../../types/tours";
1
+ import { type PhiVisualAnchorAddress } from "../../types/visual-anchors";
2
2
  import { type PhiSignalRuntimePartition } from "./runtime-signal-partition";
3
3
  /**
4
4
  * One registration. The element is reached through a getter rather than held as a value, so whoever reads
@@ -16,7 +16,9 @@ export type PhiVisualAnchorEntry = {
16
16
  export declare function registerPhiVisualAnchor(partition: PhiSignalRuntimePartition, entry: PhiVisualAnchorEntry): () => void;
17
17
  /**
18
18
  * The element an address is drawn as, in this partition and nowhere else, or `null` when it is not there
19
- * to point at: not mounted, mounted twice, detached, or rendered without a box (hidden, `display: none`).
19
+ * to point at: not mounted, mounted twice, detached, rendered without a box (hidden, `display: none`), or
20
+ * with a box of no area -- a slot that hides itself because it has nothing to show still lays out at zero
21
+ * height, and a Tour pointing at it pointed at nothing.
20
22
  */
21
23
  export declare function readPhiVisualAnchor(partition: PhiSignalRuntimePartition, address: PhiVisualAnchorAddress): HTMLElement | null;
22
24
  /**
@@ -28,6 +30,15 @@ export declare function waitForPhiVisualAnchor(partition: PhiSignalRuntimePartit
28
30
  timeoutMs: number;
29
31
  signal?: AbortSignal;
30
32
  }): Promise<HTMLElement | null>;
33
+ /**
34
+ * Waits until a found target stops moving -- a Drawer sliding in, a slot opening -- and answers whether it
35
+ * did before `timeoutMs`. Whoever points at a target measures it once: measured mid-motion, the pointer
36
+ * stays where the target was on its way, off screen for a Drawer that is still coming in.
37
+ */
38
+ export declare function waitForPhiVisualAnchorToSettle(element: HTMLElement, { timeoutMs, signal }: {
39
+ timeoutMs: number;
40
+ signal?: AbortSignal;
41
+ }): Promise<boolean>;
31
42
  /**
32
43
  * Registers the element a ref lands on as the visual anchor of `address` in the surrounding partition.
33
44
  *
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { useCallback, useEffect, useRef } from "react";
3
- import { PHI_VISUAL_ANCHOR_PART_ATTRIBUTE } from "../../types/tours";
3
+ import { PHI_VISUAL_ANCHOR_PART_ATTRIBUTE } from "../../types/visual-anchors";
4
4
  import { usePhiSignalRuntimePartition } from "./runtime-signal-partition";
5
5
  function notify(partition) {
6
6
  for (const subscriber of partition.visualAnchorSubscribers) {
@@ -33,7 +33,9 @@ export function registerPhiVisualAnchor(partition, entry) {
33
33
  }
34
34
  /**
35
35
  * The element an address is drawn as, in this partition and nowhere else, or `null` when it is not there
36
- * to point at: not mounted, mounted twice, detached, or rendered without a box (hidden, `display: none`).
36
+ * to point at: not mounted, mounted twice, detached, rendered without a box (hidden, `display: none`), or
37
+ * with a box of no area -- a slot that hides itself because it has nothing to show still lays out at zero
38
+ * height, and a Tour pointing at it pointed at nothing.
37
39
  */
38
40
  export function readPhiVisualAnchor(partition, address) {
39
41
  const entries = partition.visualAnchors.get(address);
@@ -41,7 +43,8 @@ export function readPhiVisualAnchor(partition, address) {
41
43
  if (!element || !element.isConnected || element.getClientRects().length === 0) {
42
44
  return null;
43
45
  }
44
- return element;
46
+ const box = element.getBoundingClientRect();
47
+ return box.width > 0 && box.height > 0 ? element : null;
45
48
  }
46
49
  /** How often a wait looks again for a target that is registered but not yet laid out. */
47
50
  const WAIT_POLL_INTERVAL_MS = 100;
@@ -76,6 +79,49 @@ export function waitForPhiVisualAnchor(partition, address, { timeoutMs, signal }
76
79
  signal?.addEventListener("abort", abort, { once: true });
77
80
  });
78
81
  }
82
+ /**
83
+ * How many frames in a row a target has to stand still before it counts as placed. More than two: an
84
+ * opening slot or Drawer often holds still for a frame or two before its motion starts, and measured
85
+ * then, the pointer is fixed to where it started.
86
+ */
87
+ const SETTLE_STILL_FRAMES = 8;
88
+ /**
89
+ * Waits until a found target stops moving -- a Drawer sliding in, a slot opening -- and answers whether it
90
+ * did before `timeoutMs`. Whoever points at a target measures it once: measured mid-motion, the pointer
91
+ * stays where the target was on its way, off screen for a Drawer that is still coming in.
92
+ */
93
+ export function waitForPhiVisualAnchorToSettle(element, { timeoutMs, signal }) {
94
+ return new Promise((resolve) => {
95
+ let last = "";
96
+ let still = 0;
97
+ let frame = 0;
98
+ const finish = (settled) => {
99
+ window.cancelAnimationFrame(frame);
100
+ window.clearTimeout(timeout);
101
+ signal?.removeEventListener("abort", abort);
102
+ resolve(settled);
103
+ };
104
+ const abort = () => finish(false);
105
+ const look = () => {
106
+ const box = element.getBoundingClientRect();
107
+ const next = `${box.x},${box.y},${box.width},${box.height}`;
108
+ still = next === last ? still + 1 : 0;
109
+ last = next;
110
+ if (still >= SETTLE_STILL_FRAMES) {
111
+ finish(true);
112
+ return;
113
+ }
114
+ frame = window.requestAnimationFrame(look);
115
+ };
116
+ const timeout = window.setTimeout(() => finish(false), timeoutMs);
117
+ signal?.addEventListener("abort", abort, { once: true });
118
+ if (signal?.aborted) {
119
+ finish(false);
120
+ return;
121
+ }
122
+ frame = window.requestAnimationFrame(look);
123
+ });
124
+ }
79
125
  /**
80
126
  * Registers the element a ref lands on as the visual anchor of `address` in the surrounding partition.
81
127
  *