diff --git a/CODESTYLE.md b/CODESTYLE.md index 1756a95f..1333df2c 100644 --- a/CODESTYLE.md +++ b/CODESTYLE.md @@ -1069,7 +1069,48 @@ Stated as a rule and practised: Coverage is chosen by risk, not by line count — the criterion used here is "a bug in this is invisible until somebody notices mail is in the wrong place." -### 9.5 Suite configuration is strict +### 9.5 An assertion must survive the fixture growing + +Count what the action *changed*, not what the database happens to hold. + +```php +// Brittle: has to be edited every time the seeded mailbox gains a thread, +// and says nothing about the behaviour under test. +self::assertCount(13, $threads); + +// Durable: a baseline, then the delta the action caused. +$before = count($repository->findBy(['account' => $account])); +$this->deliver($scenario); +self::assertCount($before + 1, $repository->findBy(['account' => $account])); +``` + +Where there is no "before" — a freshly provisioned user, say — assert the **relation** that holds +whatever the fixture's size: + +```php +// Every message is its own thread EXCEPT the conversation, whose turns collapse. +self::assertCount( + count($messages) - (count(DemoMailbox::CONVERSATION) - 1), + $threads, +); +``` + +This applies to anything a shared fixture owns — messages, threads, labels, mailboxes, filters, +accounts, calendars. `seed-mail` seeds four threads today; a test asserting `toHaveCount(4)` is +asserting that number, not the feature, and it will fail the day somebody seeds a fifth for an +unrelated reason. The failure will be in a spec whose subject has nothing to do with the change, +which is the expensive kind. + +Two absolutes remain correct and should not be rewritten: + +- **Absence.** `assertCount(0, $crawler->filter('form'))` — zero *is* the claim, and it does not + drift. +- **A fixture the test owns.** A test that creates two events and asserts two is self-contained; a + baseline there would be ceremony. + +The distinction is ownership: count freely what your own test made, never what a shared seeder did. + +### 9.6 Suite configuration is strict ```xml failOnDeprecation="true" @@ -1079,7 +1120,7 @@ failOnWarning="true" Plus `restrictNotices` / `restrictWarnings` on the source block. A warning is a failure. -### 9.6 Browser tests +### 9.7 Browser tests Separate directory (`tests/e2e/`), excluded from the PHPUnit suite explicitly rather than by relying on file extensions. The conventions that matter: diff --git a/assets/controllers/mail/label_menu_controller.js b/assets/controllers/mail/label_menu_controller.js index b0266523..7ab9cc12 100644 --- a/assets/controllers/mail/label_menu_controller.js +++ b/assets/controllers/mail/label_menu_controller.js @@ -36,12 +36,19 @@ export default class extends Controller { this._close(); } else { this.panelTarget.classList.remove("hidden"); + this._place(); // Bulk mode: the menu is shared by whatever is selected, so what it // shows has to be read from the selection each time it opens. this._syncFromSelection(); document.addEventListener("click", this._boundClose, { capture: true }); + + // A fixed panel does not travel with the thing it belongs to, so it + // is dismissed rather than followed. Capture, because the pane it + // sits in scrolls and a scroll there does not reach the window. + window.addEventListener("scroll", this._boundClose, { capture: true }); + window.addEventListener("resize", this._boundClose); } } @@ -155,5 +162,80 @@ export default class extends Controller { _close() { this.panelTarget.classList.add("hidden"); document.removeEventListener("click", this._boundClose, { capture: true }); + window.removeEventListener("scroll", this._boundClose, { capture: true }); + window.removeEventListener("resize", this._boundClose); + + // Back to the stylesheet's own sizing, or the next open would start + // from wherever this one finished. + for (const property of ["top", "bottom", "maxHeight"]) { + this.panelTarget.style[property] = ""; + } } + + /** + * Keep the panel inside whatever is clipping it. + * + * The reading pane is `overflow-hidden`, so a panel hanging below the + * toolbar was sliced off at the pane's edge — and could not be scrolled to, + * because the thing clipping it is not the thing that scrolls. + * + * The obvious fix is `position: fixed`, and it does not work here. A fixed + * element is positioned against the nearest ancestor carrying a + * `transform`, `filter` or `backdrop-filter` rather than against the + * viewport, and the boxed layout gives the panes a backdrop blur — so the + * panel stayed inside the same box, now with coordinates measured from it. + * Measured, not assumed: it reported `top: 125px` inline and rendered at + * 210, ending 80px below a 420px viewport. + * + * So it stays where it is and is made to fit. The height is capped to the + * room between the button and the bottom of the clipping box, and the menu + * flips above the button when there is more room there. It already scrolls + * internally, so a short pane gets a short menu rather than a cut-off one. + */ + _place() { + const button = this.element.querySelector("button"); + + if (button === null) { + return; + } + + const panel = this.panelTarget; + const box = this._clipperFor(panel); + const rect = button.getBoundingClientRect(); + const margin = 8; + + const below = box.bottom - rect.bottom - margin; + const above = rect.top - box.top - margin; + const flip = below < 140 && above > below; + + panel.style.maxHeight = `${Math.max(96, Math.floor(flip ? above : below))}px`; + + if (flip) { + panel.style.top = "auto"; + panel.style.bottom = "100%"; + } else { + panel.style.top = ""; + panel.style.bottom = ""; + } + } + + /** + * The box the panel must stay inside: the nearest ancestor that clips. + * + * Falls back to the viewport, which is the right answer for the list + * toolbar — nothing between it and the document clips, and there the menu + * was never the problem. + */ + _clipperFor(panel) { + for (let node = panel.parentElement; node !== null; node = node.parentElement) { + const style = getComputedStyle(node); + + if (/hidden|clip|auto|scroll/.test(style.overflowY + style.overflow)) { + return node.getBoundingClientRect(); + } + } + + return { top: 0, bottom: window.innerHeight }; + } + } diff --git a/assets/styles/app.css b/assets/styles/app.css index 54da13eb..1f9636dd 100644 --- a/assets/styles/app.css +++ b/assets/styles/app.css @@ -2728,6 +2728,22 @@ height: 100dvh; } + /* The demo bar is `fixed` at the bottom, so it sits OVER the panes rather + than shortening them — deliberately, because a banner in the flow costs + every page a strip of reading height. The cost is that the last row of a + scroller ends up behind it, which on a phone is the row you were reaching + for. Scrollers get that much padding back, so their content can always be + scrolled clear of the bar. + + On the class rather than a media query: the bar exists only in demo mode, + and on a desktop the last row is just as covered — it is simply less + often the one you wanted. */ + body.has-demo-bar [data-mail--mail-pane-target="list"], + body.has-demo-bar #message-list, + body.has-demo-bar .demo-bar-clearance { + padding-bottom: 3.5rem; + } + body { /* viewport-fit=cover puts us under the notch and home indicator. */ padding: env(safe-area-inset-top) env(safe-area-inset-right) diff --git a/src/Service/Demo/DemoMailbox.php b/src/Service/Demo/DemoMailbox.php index 1b7718b7..09c23b96 100644 --- a/src/Service/Demo/DemoMailbox.php +++ b/src/Service/Demo/DemoMailbox.php @@ -9,6 +9,7 @@ use App\Domain\Enum\Mail\LabelRole; use App\Domain\Helper\MessageIdHelper; use App\Entity\Mail\Account; use App\Entity\Calendar\CalendarEvent; +use App\Entity\Label\Label; use App\Entity\Mail\Message; use App\Entity\User\User; use App\Repository\Calendar\CalendarEventRepository; @@ -159,6 +160,32 @@ final readonly class DemoMailbox . '
You are reading a demo. ' . 'Nobody is subscribed to anything.
', ], + [ + // HTML, and deliberately NOT a newsletter. The newsletter below + // carries List-Unsubscribe, which puts it in Promotions — correct, + // and it means the only formatted mail in the mailbox sat behind a + // tab nobody clicks, so the reading pane looked as though it could + // only render plain text. This one is from a person, stays in + // Primary, and is the first thing a visitor opens. + 'subject' => 'Your booking at Hotel Aare — what to expect', + 'fromName' => 'Hotel Aare', 'fromAddress' => 'reservations@hotelaare.example', + 'label' => 'Travel', 'unread' => true, 'hoursAgo' => 5, + 'body' => "Room 214, two nights, breakfast included.\n\n" + . "Arrival from 15:00. The side door takes the same key after 22:00.", + 'html' => 'Confirmation 4471-B · 12–14 September
' + . '| Room | 214, lake side |
| Nights | Two |
| Breakfast | Included, 07:00–10:00 |
| Arrival | Any time after 15:00 |
' + . 'Coming in after 22:00? Reply here and we will leave the key in the side door.
' + . 'This is demo data. ' + . 'No such booking exists.