From 994d0a9c0d4516c8229fe664ff1020d432276825 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Paul=20L=C3=BCtzner?= Date: Mon, 28 Sep 2026 16:37:33 +0200 Subject: [PATCH] fix(sidebar): the open row no longer fades in from black on every load Every sidebar row went out with no colour class, and ui--sidebar gave the open one bg-accent-soft and text-accent (and every other row text-ink-muted) on connect. By then the browser had already styled the page, and the rows carry transition-colors for their hover, so on every full load and every Turbo visit the open row faded in over 150ms from the inherited black and a transparent pill, and the rest faded from black to their muted ink. Measured in headless Chromium against the test stack: app.css loaded at ~155ms, first paint at ~200ms with the rows black, the controller's pass at ~250ms, transitionrun on every visible row in the next frame. The stylesheet order was never the cause, and neither was the per-frame sampler: plain transitionrun listeners see the same thing. The server now renders the state. SidebarRowStateExtension restates ui--sidebar#_matches against the main request, and every link row in the sidebar, the account list and an expanded account's folders takes its classes from sidebar_row_state(). The controller's pass is then a no-op on everything the server drew, the first style the browser resolves is the final one, and the hover transitions are untouched (still ~130ms in and out). The pass stays for frame navigations and for rows that arrive later. After: the open row's first sample is already its final colour, and no sidebar row runs a transition on load, on a Turbo visit or in the mobile drawer. Two transitions remain at load and neither is a sidebar row: the Mercure dot, which is neutral until the stream reports, by design, and the topbar calendar toggle, which is still rendered as "mail" and corrected on connect. SidebarRowStateTest pins the rendered state, the matching rule, and that the class lists agree with the controller's. Co-Authored-By: Claude Opus 5.5 --- assets/controllers/ui/sidebar_controller.js | 10 ++ src/Twig/SidebarRowStateExtension.php | 123 +++++++++++++++++ templates/_partials/_account_list.html.twig | 10 +- templates/_partials/_sidebar.html.twig | 67 +++++---- templates/mail/_account_folder_rows.html.twig | 10 +- tests/Controller/SidebarRowStateTest.php | 128 ++++++++++++++++++ 6 files changed, 319 insertions(+), 29 deletions(-) create mode 100644 src/Twig/SidebarRowStateExtension.php create mode 100644 tests/Controller/SidebarRowStateTest.php diff --git a/assets/controllers/ui/sidebar_controller.js b/assets/controllers/ui/sidebar_controller.js index b2af56fc..46b767f9 100644 --- a/assets/controllers/ui/sidebar_controller.js +++ b/assets/controllers/ui/sidebar_controller.js @@ -4,6 +4,12 @@ import { WRITE_EVENT } from "../../mail_writes.js"; // `is-active` carries no colour of its own — it is the hook the sidebar's // Gmail-style pill shape hangs off in app.css. +// +// The server renders these same two sets on every row it draws (OPEN and +// CLOSED in App\Twig\SidebarRowStateExtension), so on those rows the pass in +// connect() changes nothing. Keep the lists identical: a row the server drew +// one way and this pass redraws the other is a row that fades on every load, +// from whatever it was painted as. SidebarRowStateTest compares the two files. const ACTIVE_CLASSES = ["is-active", "bg-accent-soft", "text-accent", "font-medium"]; // A parent of the open row. Its own class rather than the active set: the // trail should read as "you are in here", not as a second selection. @@ -667,6 +673,10 @@ export default class extends Controller { * compare matched nothing and those rows never highlighted at all. The * account has to agree too — the same label under another account is a * different view, and both rows lighting up says the wrong thing. + * + * Restated in PHP as SidebarRowStateExtension::isOpen(), which decides the + * rows the server renders. A change here that is not made there is a row + * the two disagree about, and it fades on every load. */ _matches(link) { const target = new URL(link.href, window.location.origin); diff --git a/src/Twig/SidebarRowStateExtension.php b/src/Twig/SidebarRowStateExtension.php new file mode 100644 index 00000000..65cc16fa --- /dev/null +++ b/src/Twig/SidebarRowStateExtension.php @@ -0,0 +1,123 @@ +rowState(...)), + ]; + } + + /** + * The classes for the row whose link points at $href. + */ + public function rowState(string $href): string + { + return true === $this->isOpen($href) ? self::OPEN : self::CLOSED; + } + + public function isOpen(string $href): bool + { + $request = $this->requestStack->getMainRequest(); + $target = parse_url($href); + + if (null === $request || false === $target || false === isset($target['path'])) { + return false; + } + + // What `window.location.pathname` is to the controller: base URL + // included, and still percent-encoded — which is how path() writes the + // href, so the two compare like for like. + $path = $request->getBaseUrl() . $request->getPathInfo(); + + if ($path !== $target['path'] && false === str_starts_with($path, $target['path'] . '/')) { + return false; + } + + parse_str($target['query'] ?? '', $query); + + $wanted = $query['account'] ?? null; + + if (null === $wanted) { + return true; + } + + // all() rather than get(): get() throws on `?account[]=`, and a + // malformed query string must not be able to take the sidebar down. + $current = $request->query->all()['account'] ?? null; + + return is_string($current) && $wanted === $current; + } +} diff --git a/templates/_partials/_account_list.html.twig b/templates/_partials/_account_list.html.twig index e7409066..a837cccc 100644 --- a/templates/_partials/_account_list.html.twig +++ b/templates/_partials/_account_list.html.twig @@ -26,13 +26,17 @@ other sidebar row — a rounded-lg box on this wrapper was the last square highlight left in the sidebar. It has to be the wrapper and not the anchor: on the anchor the pill stops before the chevron and - cuts the row in half. #} -