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,
  };
}