File size: 25,524 Bytes
092334a 5351cc8 092334a 5351cc8 092334a 5351cc8 092334a 016c754 092334a 016c754 092334a 016c754 092334a 5351cc8 092334a 016c754 092334a 5351cc8 092334a ea2c336 092334a ea2c336 092334a ea2c336 092334a | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 | // ---------------------------------------------------------------------------
// customer-grid / overlayPlacement.ts
// Wave-9 I14 β the overlay positioning ARITHMETIC, extracted from
// OverlaySurface.tsx so it can be proved instead of eyeballed.
//
// Why this file exists. I14 needs a flyout that opens to the RIGHT of the views
// rail, which meant a third `Placement` and a new branch of collision maths.
// Every other overlay on this surface already lands correctly, so a regression
// here is invisible until a user finds it β and the failure mode is the exact
// one this codebase has already paid for: a panel that is PAINTED but sits
// outside the viewport, i.e. unreachable, with every assertion still green
// ([[ui-invisible-to-assertions]]).
//
// Pure module, zero React imports β the same shape mapProjection.ts/
// mapGeometry.ts take for the map's maths, and for the same reason: a gate can
// compile it with tsc and run it under node.
//
// The numbers here are byte-for-byte the ones OverlaySurface computed inline
// before the extraction; only `right-start` is new.
// ---------------------------------------------------------------------------
export type Placement = "bottom-start" | "bottom-end" | "right-start";
export interface PlacementTarget {
left: number;
top: number;
right: number;
bottom: number;
}
export interface PlacementViewport {
left: number;
top: number;
width: number;
height: number;
}
export interface PlacementResult {
left: number;
top: number;
maxWidth: number;
maxHeight: number;
/** Which way the panel actually resolved. Diagnostic only β nothing styles off it β
* but it is what lets a gate assert "it flipped BECAUSE there was no room" rather
* than merely "it ended up somewhere on screen". */
side: "below" | "above" | "right" | "left";
}
/** Keep-away from the viewport edge, and from the anchor. The originals. */
export const OVERLAY_MARGIN = 8;
export const OVERLAY_GAP = 6;
// ------------------------------------------------------- wave-9 I2: the cell hover tip
/** Glide's horizontal padding inside a text cell (8px each side at the default theme). */
export const CELL_TEXT_PAD = 16;
/** Mirrors the `max-width` on `.cg-header-tip` / `.cg-cell-tip`. */
export const TIP_MAX_WIDTH = 280;
/**
* I2 β what a hovered cell should reveal, or `null` when there is nothing to reveal.
*
* The rule that makes this a feature rather than noise: **only text that is actually cut off
* gets a tip.** A tooltip on every cell repeats what the user can already read, trains them to
* ignore tips, and covers the row underneath while doing it. Grid cells never wrap here
* (`allowOverlay: false`, no `allowWrapping`), so "cut off" is a pure width comparison and is
* measured with the same canvas font glide draws with, not estimated from a character count.
*/
export function cellTipText(
text: string,
textPx: number,
cellPx: number
): string | null {
if (!text) return null;
return textPx > Math.max(0, cellPx - CELL_TEXT_PAD) ? text : null;
}
/**
* Keep a floating tip inside the viewport horizontally.
*
* β The EXISTING header tip does not do this β it uses the column's raw x, so a description on
* a right-edge column runs off screen. The cell tip clamps; the header tip is left alone
* because changing it is not in this item and it is somebody's shipped behaviour.
*/
export function tipLeft(
x: number,
viewportWidth: number,
tipWidth: number,
margin = OVERLAY_MARGIN
): number {
const w = Math.min(tipWidth, TIP_MAX_WIDTH);
return Math.round(Math.max(margin, Math.min(x, viewportWidth - margin - w)));
}
// -------------------------------------------------------- wave-9 I3: the header "(i)"
export const INFO_MARK_SIZE = 16;
/** Gap between the (i) and the header-menu slot to its right. */
export const INFO_MARK_GAP = 4;
/** Glide's type-icon footprint before every header label. */
export const HEADER_LABEL_X = 8 + Math.ceil(18 * 1.3); // = 32
/** Clearance the label keeps from the (i). Touching is not overlapping, but it reads as one. */
export const HEADER_LABEL_GAP = 6;
/** Enough title room for at least one normal glyph plus the ellipsis in Glide's header font. */
export const HEADER_MIN_TITLE_SPACE = 30;
/**
* Space kept clear at the LEFT of the (i), including Glide's type-icon footprint and the gap
* between title and mark. Below this the mark is dropped instead of covering the title.
*/
export const INFO_MARK_MIN_LABEL =
HEADER_LABEL_X + HEADER_LABEL_GAP + HEADER_MIN_TITLE_SPACE;
/**
* I3 β where the description "(i)" goes: **right-aligned in the header and vertically centred
* with the field name.**
*
* β SCOUTED FIRST, and glide's own API cannot do it. `overlayIcon` is drawn by
* `drawHeaderInner` at a HARD-CODED `drawX + 9`, `(height - 18) / 2 + 6` β a badge pinned to
* the bottom-right corner of the TYPE icon, on the far LEFT of the header. No prop moves it.
* So the mark is drawn by the `drawHeader` callback instead (glide's supported escape hatch:
* it hands you the ctx, the rect, the menu bounds and the sprite manager, plus a
* `drawContent()` for its own rendering) and `overlayIcon` is no longer set.
*
* `menuSlotWidth` is `menuBounds.width` β the "Β·Β·Β·" button's reserved strip at the right end.
* The (i) sits to the LEFT of that strip **whether or not the menu is currently showing**,
* because the menu only appears on hover and a mark that jumps sideways when you approach it
* is worse than one sitting slightly inboard.
*
* Returns `null` when the column is too narrow to carry the mark without covering its own
* label β a mark on top of the name is not "right-aligned", it is broken.
*/
export function infoMarkRect(
header: { x: number; y: number; width: number; height: number },
menuSlotWidth: number,
size = INFO_MARK_SIZE,
gap = INFO_MARK_GAP
): { x: number; y: number; size: number } | null {
return headerMarkLayout(header, menuSlotWidth, [size], gap)?.[0] ?? null;
}
// -------------------------------------- wave-14 item 15: the GROUP BAR's label vs the freeze
/**
* THE GROUP BAR'S FONT β the ONE copy, read by the cell that paints it
* (`useGetCellContent.groupHeaderCell`'s `themeOverride.baseFontStyle`) AND by the canvas context
* that measures it (`CustomerGrid.measureGroupText`).
*
* β It is SEMIBOLD, and that is the whole reason this constant exists rather than two literals.
* Glide paints a cell with the MERGED theme's `baseFontFull` (`data-grid-render.cells.js:269`),
* so a group bar paints at 600 while the grid's ordinary cell measurer
* (`measureCellText`) is set to plain "13px". Measuring the fit at the lighter weight
* UNDER-truncates β semibold Inter is ~4-6% wider β and the label then overflows into exactly
* the hard mid-glyph `ctx.clip()` that `fitGroupLabel` exists to prevent. Worse as the frozen
* strip gets WIDER, which is the opposite of intuition.
*
* This is the third instance of one bug: a measurement pinned to a weight the renderer no longer
* uses (`measureHeaderText`'s 600 vs the involved header's 700 was the second). Hence one
* constant, no literals, and a gate leg asserting it is not the base cell font.
*/
export const GROUP_HEADER_FONT = "600 13px";
/**
* Glide's own cell padding β the group bar's text does not get the full strip.
*
* Glide insets text by `cellHorizontalPadding` (8) on the LEFT and clips at the rect's right
* edge, so the strictly-usable width is `width - 8`. Reserving 16 (both sides) is DELIBERATELY
* conservative: it makes the label a few pixels shorter than it strictly must be, which is the
* safe direction to be wrong. β Do not let this slack be the thing that hides a font-weight
* mismatch β see `GROUP_HEADER_FONT`.
*/
export const GROUP_LABEL_PAD = 16;
/**
* Item 15 β the collapsible group bar's label, fitted to the FROZEN STRIP.
*
* β Why this has to exist, because it is the non-obvious half of pinning the first column.
* Freezing does NOT make a group bar's `span` vanish: glide SPLITS it, `getSpanBounds` returning
* `[frozenRect, contentRect]`, and paints the row's CONTENTS from the frozen half only (the
* scrollable half sets `skipContents`). But `frozenRect` is exactly the frozen columns' own
* width β substitute `sourceIndex = 0`, `startCol = 0`, `firstNonSticky = 1` and both of its
* loops run zero times β and glide clips to it with `ctx.rect(β¦); ctx.clip()` while `fillText`
* carries no `maxWidth`. So the label is cut MID-GLYPH, with no ellipsis, at the freeze line.
* Before the pin the bar drew across the whole row and this could not happen; the pin is what
* the owner asked for (R8), so the truncation has to become honest rather than the pin reverted.
*
* The COUNT survives and the LABEL is what gives way. The count is the number the reader came
* for and cannot be reconstructed from a prefix; a name usually can. So the label ellipsises and
* `(count)` is always re-appended.
*/
export function fitGroupLabel(
marker: string,
label: string,
count: string,
space: number | null,
measure: (text: string) => number
): string {
const compose = (text: string) => `${marker} ${text} (${count})`;
const full = compose(label);
if (space == null || space <= 0) return full;
if (measure(full) <= space) return full;
// What the label may occupy = the space minus everything in the line that is NOT the label.
const room = space - measure(compose(""));
// No room for a name at all: say the whole thing and let glide clip it. A bar reading
// "βΌ (1,247)" names no group, which is worse than one whose name is cut short.
if (room <= 0) return full;
return compose(fitHeaderTitle(label, room, measure));
}
// ------------------------------------------ wave-14 R11: the header's right-hand MARK STRIP
/** The user-created-field dot (item 1 / R11). Small on purpose: it is an affordance saying
* "this column is yours, not Odoo's", not a category badge. */
export const CUSTOM_MARK_SIZE = 6;
/** Clearance BETWEEN two marks in the strip. */
export const HEADER_MARK_GAP = 6;
/**
* R11 β where the header's right-hand marks go, laid out from the OUTSIDE IN.
*
* There are two of them now: the description "(i)" (wave-9 I3) and the user-created-field dot
* (wave-14 item 1, replacing the yellow header wash the owner killed). They share one strip so
* they can never be drawn on the same pixels, and so the title's truncation reserve accounts for
* however many are actually present.
*
* `sizes` is the slot widths from the right edge inwards β build it with `headerMarkSizes()` so
* the ORDER lives in exactly one place. Slot 0 is adjacent to the menu strip; the (i) keeps it,
* because it is the one a pointer goes looking for and a hover target that moves between columns
* is worse than a passive dot that does.
*
* **All-or-nothing**: if the innermost slot would eat the label, this returns `null` and NOTHING
* is drawn. That is what lets `headerLabelSpace` and `CustomerGrid.drawGridHeader` read the same
* answer from one function β a per-mark decision would let the reserve say "no truncation" while
* the drawer still painted a mark over the name, which is the exact failure the wave-9 comment
* above warns about ([[ui-invisible-to-assertions]]).
*/
export function headerMarkLayout(
header: { x: number; y: number; width: number; height: number },
menuSlotWidth: number,
sizes: readonly number[],
gap = INFO_MARK_GAP
): { x: number; y: number; size: number }[] | null {
if (sizes.length === 0) return null;
const out: { x: number; y: number; size: number }[] = [];
let right = header.x + header.width - menuSlotWidth - gap;
for (const size of sizes) {
const x = right - size;
if (x - header.x < INFO_MARK_MIN_LABEL) return null;
out.push({
x: Math.round(x),
// Vertically centred in the header band β the same band the label's baseline is centred
// in, which is what "centred with the field name" means on a canvas.
y: Math.round(header.y + (header.height - size) / 2),
size,
});
right = x - HEADER_MARK_GAP;
}
return out;
}
/** The strip's ORDER, in one place: (i) outermost, the user-created dot inboard of it. Both the
* truncation reserve and the drawing path read this, so they cannot disagree about how many
* marks a column carries or which is which. */
export function headerMarkSizes(hasInfoMark: boolean, isCustomField: boolean): number[] {
const out: number[] = [];
if (hasInfoMark) out.push(INFO_MARK_SIZE);
if (isCustomField) out.push(CUSTOM_MARK_SIZE);
return out;
}
// ------------------------------------------------- owner item 16: the header title vs the (i)
/**
* Glide's own header metrics, READ OFF THE LIBRARY rather than guessed, and re-stated here
* because the fit below has to agree with the renderer to the pixel.
*
* `data-grid-render.header.js:197-212` drawX = x + cellHorizontalPadding, then
* drawX += ceil(headerIconSize * 1.3) once an `icon`
* is set β and `useGridColumns` sets one on EVERY
* column (the field-type mark, wave-8 I20).
* `:157` menuButtonSize = 30, a CONSTANT: the "Β·Β·Β·" slot is
* the same width on a 90px column and a 600px one.
* `common/styles.js:71-74` cellHorizontalPadding 8 Β· headerIconSize 18 Β·
* headerFontStyle "600 13px".
*
* β `theme.ts` overrides none of the three metrics, so these hold. If it ever does, they move
* together or the label creeps back under the mark.
*/
export const HEADER_MENU_SLOT = 30;
/**
* Item 16 β how much width the header LABEL may occupy before it runs under the (i), or `null`
* when nothing is in its way.
*
* β Derived from `infoMarkRect` rather than from a second copy of the same arithmetic. The mark
* is dropped entirely below `INFO_MARK_MIN_LABEL`, so a re-derived reserve would keep shrinking
* a label that has nothing to collide with β and the two numbers would drift the first time
* either constant moved. ONE geometry source, queried twice.
*
* `null` means "do not truncate": either the field carries no description (no mark is drawn) or
* the column is too narrow for the mark, which `infoMarkRect` already answers by returning null.
* Glide fades the title under the hover menu on its own; that is its behaviour and not this
* item's subject.
*/
export function headerLabelSpace(
width: number,
/**
* Wave-14 R11 β the marks this header carries. `true`/`false` is the pre-R11 shape (the (i)
* alone) and still means exactly what it meant; a `number[]` from `headerMarkSizes()` is the
* general form, and it is what a column with BOTH an (i) and a user-created dot must pass or
* the label gets reserved room for only one of them.
*/
marks: boolean | readonly number[],
menuSlotWidth = HEADER_MENU_SLOT
): number | null {
const sizes = marks === true ? [INFO_MARK_SIZE] : marks === false ? [] : marks;
// The INNERMOST mark is the one the label must clear, and `headerMarkLayout` is all-or-nothing,
// so this is still ONE geometry source queried twice rather than two copies of the arithmetic.
const layout = headerMarkLayout({ x: 0, y: 0, width, height: 32 }, menuSlotWidth, sizes);
if (!layout) return null;
return Math.max(0, layout[layout.length - 1].x - HEADER_LABEL_X - HEADER_LABEL_GAP);
}
/** The ellipsis glyph, as ONE character β "..." is three and measures wider. */
export const ELLIPSIS = "β¦";
/**
* Item 16 β `title` shortened to fit `space`, with an ellipsis, or returned untouched.
*
* `measure` is injected rather than reached for: the only honest measurement is canvas
* `measureText` in glide's own header font ("600 13px"), and a pure function is the half of
* this that a node gate can hold. Measuring at the wrong WEIGHT is the quiet failure β
* semibold 13px is wider than regular 13px, so measuring with the cell font under-truncates
* and the label still slides under the mark.
*
* Binary search over the cut point: `measure` is a canvas call per probe, and a linear walk
* down a 40-character label is 40 of them per column per render.
*
* Never returns a bare ellipsis: if not even one character fits, the caller is better served
* by the untouched title (glide will fade it) than by a header that says nothing at all.
*/
export function fitHeaderTitle(
title: string,
space: number | null,
measure: (text: string) => number
): string {
if (space == null || !title) return title;
if (measure(title) <= space) return title;
const ell = measure(ELLIPSIS);
if (ell > space) return title; // no room to say "there is more" β say it all
let lo = 0; // known to fit
let hi = title.length; // known not to
while (lo < hi) {
const mid = Math.ceil((lo + hi) / 2);
if (measure(title.slice(0, mid)) + ell <= space) lo = mid;
else hi = mid - 1;
}
if (lo <= 0) return title;
// A cut that lands on a trailing space would render as "Last order β¦".
return title.slice(0, lo).trimEnd() + ELLIPSIS;
}
// ------------------------------------------- owner item 19: the hover-only Expand affordance
export const EXPAND_BTN_SIZE = 22;
/** Breathing room between the button and the right edge of the primary cell. */
export const EXPAND_BTN_INSET = 6;
/** A row shorter than this cannot hold the button without touching the rules above and below. */
export const EXPAND_BTN_MIN_ROW = EXPAND_BTN_SIZE + 4;
/** Minimum primary-cell label room; independent of the header's type-icon geometry. */
export const EXPAND_BTN_MIN_LABEL = 46;
/**
* Item 19 β where the row's Expand button sits: **at the right end of the PRIMARY cell**,
* vertically centred on the row (Airtable's placement, and the reason the primary column is
* the frozen one).
*
* `primary` is the first DATA column's rect for that row, straight from glide's
* `DataEditorRef.getBounds(0, row)` β VIEWPORT coordinates, because that is what glide returns
* (`data-grid.js:82-83` adds the canvas's own `getBoundingClientRect()` before returning) and
* what `.cg-header-tip` already positions against. Taking it from glide rather than computing
* it means freeze, horizontal scroll and row-height mode are all handled by the thing that owns
* them.
*
* β The button is a real `<button>` laid over the canvas, so the rect below IS the hit test β
* there is no second copy of this geometry to drift out of step with the drawing, which is the
* failure mode a canvas-drawn affordance has ([[ui-invisible-to-assertions]]).
*
* `null` when the row or the column is too small to carry it: better absent than painted over
* the customer's name in a 28px row.
*/
export function expandButtonRect(
primary: { x: number; y: number; width: number; height: number },
/**
* The grid's own box, in the same viewport space. β NOT optional in practice: the button is
* `position: fixed`, and glide's bounds are VIEWPORT coordinates that follow the primary cell
* WHEREVER it goes β including outside the grid. An unclamped fixed button then paints over
* the chrome, on a row nobody can see: painted, plausible and in the wrong place
* ([[ui-invisible-to-assertions]]).
*
* β RETARGETED wave-14 item 15 (R8). This used to justify itself with "`freezeColumns` is 0
* while a grouping is active, so the primary column scrolls away like any other" β TRUE then,
* FALSE now: the first column is frozen in every mode, because glide splits a group bar's
* span across the freeze boundary rather than losing it (`getSpanBounds`). The VERTICAL case
* is what keeps this parameter load-bearing β a row scrolled under the header or past the
* bottom still reports a rect outside the box. The horizontal legs remain as defence in depth
* (a column drag, a box narrower than the frozen strip), and they are still asserted.
*/
box?: { x: number; y: number; width: number; height: number },
size = EXPAND_BTN_SIZE,
inset = EXPAND_BTN_INSET
): { x: number; y: number; size: number } | null {
if (primary.height < EXPAND_BTN_MIN_ROW) return null;
// It must not eat the whole cell: below this the name is entirely covered by its own control.
if (primary.width < size + inset * 2 + EXPAND_BTN_MIN_LABEL) return null;
const x = Math.round(primary.x + primary.width - inset - size);
const y = Math.round(primary.y + (primary.height - size) / 2);
// Absent rather than clamped: a clamped button would sit at the grid's edge pointing at a row
// whose primary cell is somewhere else entirely, which is a worse answer than no button.
if (box) {
if (x < box.x || x + size > box.x + box.width) return null;
if (y < box.y || y + size > box.y + box.height) return null;
}
return { x, y, size };
}
/**
* Where a panel of `panel` size should sit relative to `target`, inside `viewport`.
*
* The invariant every caller depends on, and the one the gate asserts for all three
* placements: **the returned box is inside the viewport whenever the viewport can hold it
* at all.** A panel larger than the viewport is pinned to the top-left margin rather than
* centred, so its first row stays reachable β a menu whose head is off-screen cannot be
* used even though every item "rendered".
*/
export function computeOverlayPosition(args: {
placement: Placement;
target: PlacementTarget;
/** The panel's DESIRED size, before clamping (scrollWidth/scrollHeight at the call site). */
panel: { width: number; height: number };
viewport: PlacementViewport;
}): PlacementResult {
const { placement, target, panel, viewport } = args;
const margin = OVERLAY_MARGIN;
const gap = OVERLAY_GAP;
const minLeft = viewport.left + margin;
const maxRight = viewport.left + viewport.width - margin;
const minTop = viewport.top + margin;
const maxBottom = viewport.top + viewport.height - margin;
const maxWidth = Math.max(1, maxRight - minLeft);
const width = Math.min(panel.width, maxWidth);
const desiredHeight = panel.height;
let left: number;
let top: number;
let maxHeight: number;
let side: PlacementResult["side"];
if (placement === "right-start") {
// Beside the anchor. Prefer the right; flip left only when the right genuinely cannot
// hold the panel AND the left has more room β the same "pick the roomier side" rule the
// vertical branch uses, turned 90 degrees.
const roomRight = Math.max(0, maxRight - (target.right + gap));
const roomLeft = Math.max(0, target.left - gap - minLeft);
const toRight = width <= roomRight || roomRight >= roomLeft;
side = toRight ? "right" : "left";
const unclampedLeft = toRight ? target.right + gap : target.left - gap - width;
left = Math.min(Math.max(unclampedLeft, minLeft), Math.max(minLeft, maxRight - width));
// Vertically the flyout is bounded by the VIEWPORT, not by the anchor: it opens level
// with the button and slides up only as far as it must to stay on screen. (A rail
// button near the bottom of a short iframe is the case that matters.)
maxHeight = Math.max(1, maxBottom - minTop);
top = Math.min(
Math.max(target.top, minTop),
Math.max(minTop, maxBottom - Math.min(desiredHeight, maxHeight))
);
} else {
const below = Math.max(0, maxBottom - (target.bottom + gap));
const above = Math.max(0, target.top - gap - minTop);
const placeBelow = desiredHeight <= below || below >= above;
side = placeBelow ? "below" : "above";
maxHeight = Math.max(1, placeBelow ? below : above);
const unclampedLeft = placement === "bottom-end" ? target.right - width : target.left;
// The horizontal clamp needs no `Math.max(minLeft, β¦)` on its outer bound, unlike the
// `right-start` branch above: `width` is ALREADY clamped to `maxWidth = maxRight - minLeft`,
// so `maxRight - width >= minLeft` always holds and the min cannot undo the max. Proven the
// only way worth proving it β a mutation that removed the "fix" left every leg green, which
// is what a guard against an impossible state looks like ([[gate-negative-control]]).
left = Math.min(Math.max(unclampedLeft, minLeft), maxRight - width);
// β D-20 (wave 20) β `Math.max(minTop, β¦)` IS load-bearing, and it was missing.
//
// The below-branch had no lower bound, so an anchor ABOVE the viewport put the panel above
// it too β painted, and unreachable. Not hypothetical: it is the null-anchor case D-20 is
// about (a null anchor degrades to a ZERO rect at the DOCUMENT origin, which is above the
// viewport whenever `visualViewport` carries an offset β a pinch-zoomed phone), and it is
// equally a trigger that scrolls out of view with its menu still open. `verify_overlay`'s
// `below-branch-loses-its-top-clamp` control restages exactly this.
top = placeBelow
? Math.max(minTop, Math.min(target.bottom + gap, maxBottom - maxHeight))
: Math.max(minTop, target.top - gap - Math.min(desiredHeight, maxHeight));
}
return {
left: Math.round(left),
top: Math.round(top),
maxWidth: Math.floor(maxWidth),
maxHeight: Math.floor(maxHeight),
side,
};
}
|