|
| 1 | +# Fix: edit chrome cropped at the top of the viewport |
| 2 | + |
| 3 | +**Strand:** bd-pvcnea83 |
| 4 | +**Date:** 2026-06-25 |
| 5 | +**Status:** in progress |
| 6 | + |
| 7 | +## Problem (confirmed in the binary) |
| 8 | + |
| 9 | +Editing the first block of a title-less document (or any block scrolled near the |
| 10 | +viewport top) crops the floating edit chrome. The rich-text toolbar is |
| 11 | +`position:absolute; bottom:100%; margin-bottom:4px` (floats *above* the edit |
| 12 | +box); for a block flush against the top of the scroll area it lands at negative |
| 13 | +`top` (above the iframe viewport, `scrollTop` already 0) and is clipped — only a |
| 14 | +sliver shows. Measured: editor `top:15`, toolbar `top:-15.4 → bottom:11` |
| 15 | +(height 26.4). The inline nesting breadcrumb lives in the toolbar, so it crops |
| 16 | +too. The standalone `BreadcrumbChip` (non-rich blocks at the top) has the same |
| 17 | +top-crop: its geometry sets `top = surfaceTop − chipH`, negative for a top |
| 18 | +surface. |
| 19 | + |
| 20 | +## Approach: collision-aware flip (above → below) |
| 21 | + |
| 22 | +Standard floating-toolbar behavior. When there isn't room above |
| 23 | +(`surfaceTop − chromeHeight − gap < 0`, viewport-relative), render the chrome |
| 24 | +**below** the block instead of above. While editing a top block the chrome then |
| 25 | +sits just under it (briefly over the next block), fully visible; the edited text |
| 26 | +is never covered. Parity-neutral (no change to the rendered/preview document |
| 27 | +spacing). Generalizes correctly to any near-top block, not just the literal |
| 28 | +first one. |
| 29 | + |
| 30 | +Shared pure helper (mirrors the chip's existing `computeChipGeometry` pattern): |
| 31 | + |
| 32 | +```ts |
| 33 | +// editChromeGeometry.ts |
| 34 | +export function shouldPlaceChromeBelow(surfaceTop: number, chromeHeight: number, gap: number): boolean { |
| 35 | + return surfaceTop - chromeHeight - gap < 0; |
| 36 | +} |
| 37 | +``` |
| 38 | + |
| 39 | +- **Toolbar** (`RichTextToolbar`): `useLayoutEffect` measures the |
| 40 | + `.q2-richtext-editor` box's viewport top + the toolbar's height; if it would |
| 41 | + clip above, set a `q2-rt-toolbar-below` class (`top:100%; bottom:auto; |
| 42 | + margin-top:4px`). Guard: skip when `offsetHeight <= 0` (degenerate jsdom rects) |
| 43 | + so the default `above` placement is kept there. |
| 44 | +- **Chip** (`BreadcrumbChip`): in the geometry effect, when |
| 45 | + `shouldPlaceChromeBelow(sRect.top, chipH, GAP)` (and real geometry — |
| 46 | + `sRect.height > 0`), set `top = (sRect.bottom − hostRect.top) + GAP` (below) |
| 47 | + instead of `surfaceTop − chipH` (above). Horizontal left-spill geometry is |
| 48 | + unchanged. |
| 49 | + |
| 50 | +Both compute placement from the *surface's* stable top (not the chrome's flipped |
| 51 | +position), so there is no flip/re-measure loop. |
| 52 | + |
| 53 | +## Tests (TDD) |
| 54 | + |
| 55 | +1. **Unit (RED first):** `editChromeGeometry.test.ts` — `shouldPlaceChromeBelow` |
| 56 | + true for `(15, 26, 4)` (the measured case), false for `(100, 26, 4)`, and |
| 57 | + boundary cases. |
| 58 | +2. **Real-binary e2e:** title-less fixture; edit the first block → |
| 59 | + - rich Para: `.q2-rt-toolbar` has `q2-rt-toolbar-below` and its rect top ≥ 0 |
| 60 | + (not clipped); |
| 61 | + - first block is a code block (non-rich): standalone chip rect top ≥ 0 |
| 62 | + (placed below). |
| 63 | + Follow the established floating-chrome testing pattern (pure unit + real e2e; |
| 64 | + jsdom rects are degenerate, so vertical placement is not asserted in jsdom). |
| 65 | + |
| 66 | +## Notes / known interactions |
| 67 | + |
| 68 | +- The hub-client `q2-preview-breadcrumb-geometry` e2e asserts "chip above |
| 69 | + surface" for a TOP paragraph. That suite is already red (bd-fpys25b0, |
| 70 | + rich-text default-on) and will need its top-block expectation updated to the |
| 71 | + flipped (below) placement when bd-fpys25b0 is addressed. Out of scope here; |
| 72 | + noted on that strand. |
| 73 | + |
| 74 | +## Work items |
| 75 | + |
| 76 | +- [x] `editChromeGeometry.ts` + unit test (`shouldPlaceChromeBelow`, 5 cases) |
| 77 | +- [x] Toolbar: `useLayoutEffect` measure + `q2-rt-toolbar-below` class + CSS |
| 78 | +- [x] Chip: flip `top` below when clipped (guarded on `sRect.height > 0`) |
| 79 | +- [x] preview-renderer unit (505) + integration (515) suites green |
| 80 | +- [x] Real-binary e2e on title-less fixtures (toolbar + chip), |
| 81 | + `q2-preview-spa/e2e/edit-chrome-placement.spec.ts` (2 tests); full spa e2e |
| 82 | + suite green (39). Verified live in Chrome: toolbar flips below (top 48.5, |
| 83 | + uncropped) for the first paragraph; chip flips below (top 83.8, uncropped) |
| 84 | + for a first code block. |
| 85 | +- [x] hub-client build + unit (662) + integration (76) green; |
| 86 | + `cargo xtask verify --skip-hub-build` green (one flaky pampa-oracle spike |
| 87 | + failure on first run, passed on re-run — unrelated to this change) |
| 88 | +- [ ] hub-client/changelog.md (two-commit) — pending commit |
0 commit comments