export type AnchorSide = "top" | "bottom"; export type AnchorPlacement = { left: number; top: number; side: AnchorSide; /** * How tall the panel may be on the chosen side. * * The caller MUST apply this. Returning a coordinate alone is not enough to * keep the never-covers rule: a panel taller than the room available cannot * be placed clear of its anchor at any coordinate, so the only honest * outcomes are to shrink it or to cover the thing it is about. It shrinks. */ maxHeight: number; }; export type AnchorInput = { /** The element the panel is about. Viewport coordinates. */ anchor: DOMRect; /** The panel's own size. */ panel: { width: number; height: number; }; viewport: { width: number; height: number; }; /** Breathing room between panel and anchor, and panel and viewport edge. */ gap?: number; margin?: number; /** * Which side the panel is on right now, when it is already placed. * * Supplied so a re-placement can prefer to stay put. See `SIDE_HYSTERESIS`. */ current?: AnchorSide; }; /** * How much better the other side must be before the panel jumps to it, in px. * * Without this, "the side with more room wins" decides on a single pixel: an * anchor near the middle of the viewport can have 345px below and 346px above, * and then a two-pixel scroll swaps the panel from one side of the element to * the other. Following an anchor should look like following it, not like the * panel teleporting around it. */ export declare const SIDE_HYSTERESIS = 64; /** * Where the panel goes, in viewport coordinates. * * Below the anchor by preference, above when below does not fit, and when * NEITHER fits, the side with more room wins and the panel is **clamped to * that room** via `maxHeight`. An earlier version left the clamping to the * caller, which meant it did not happen: adding a composer to the anchored * panel grew it past both sides' room and it went straight through its own * anchor (measured 2026-08-05). A rule enforced by a comment is not enforced. * * The trade this makes is explicit: on a cramped viewport the panel gets * short and scrolls internally rather than covering the element it is about. * A short panel is worse than a tall one; a panel hiding the pricing tier it * is explaining is worse than both. * * Horizontally the panel is aligned to the anchor's left edge and then shifted, * never flipped, to stay on screen. Shifting keeps the visual connection to * the anchor; flipping horizontally would break it for no gain, because there * is no "other side" of a horizontal relationship the way there is vertically. */ export declare function placeAnchored(input: AnchorInput): AnchorPlacement; /** * True when a placement would overlap the element it is about. * * Exported because it is the shape's invariant rather than an implementation * detail: it is what the tests assert, and what a future change to the * placement maths has to keep satisfying. */ export declare function overlapsAnchor(placement: AnchorPlacement, input: AnchorInput): boolean; /** * Track an anchor until told to stop. * * Scroll and resize both move an anchor relative to the viewport, and a panel * that does not follow is worse than one that never anchored, it ends up * pointing at nothing. `scroll` is captured so it fires for scrollable * ancestors too, not just the window: an anchor inside an overflow container is * the common case on a pricing page, and a window-only listener silently misses * it. */ export declare function trackAnchor(getRects: () => AnchorInput | null, apply: (placement: AnchorPlacement) => void): () => void;