From d15bbb891b69d7c5d79f964a2c6aee5b68cf40ee Mon Sep 17 00:00:00 2001 From: Peter Zenger Date: Thu, 23 Jul 2026 12:30:33 -0700 Subject: [PATCH 1/7] Full-screen page mode for SQL and RPCN editors, console-owned layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Footer pins to the viewport bottom on short pages (CSS flex chain in standalone, measured min-height in embedded) and keeps centering to the content column; bottom padding 8px -> 16px. - Topics and security-tab pages drop ListLayout's forced min-h-screen (min-h-0 override), removing large dead whitespace. - Embedded Console cancels the Cloud UI host gutters with measured negative margins and owns its page gutter (px-12) — deploy-order-safe with cloud-ui removing its p-10 later. - New expanded-page mode: data-page-expanded on (utils/page-expanded) + useExpandedPageMode hook release every shell's horizontal constraints via global CSS while the page stays in document flow, footer below. The SQL studio's fixed-overlay fullscreen is replaced by this in-flow mode, and the RPCN pipeline editor gains the same toggle; both place the shared ExpandedPageToggle at the top-right of their work surface, clear of Save. - /sql becomes a normal route; new breadcrumbOnlyHeader staticData flag keeps the app header breadcrumb-only for pages with their own title bar. Co-Authored-By: Claude Fable 5 --- frontend/src/app.tsx | 2 + frontend/src/components/layout/footer.tsx | 83 ++++- frontend/src/components/layout/header.tsx | 9 +- .../pages/rp-connect/pipeline/index.tsx | 104 +++++-- .../rp-connect/pipeline/pipeline-header.tsx | 15 +- .../tabs/permissions-list-tab-new.tsx | 2 +- .../pages/security/tabs/roles-tab-new.tsx | 2 +- .../pages/security/tabs/users-tab-new.tsx | 2 +- .../components/pages/sql/sql-workspace.tsx | 294 ++---------------- .../pages/topics/topic-list-new.tsx | 2 +- .../components/ui/expanded-page-toggle.tsx | 34 ++ frontend/src/federation/federated-routes.tsx | 81 ++++- frontend/src/hooks/use-expanded-page-mode.ts | 85 +++++ frontend/src/index.scss | 20 +- frontend/src/routes/__root.tsx | 7 +- frontend/src/routes/sql.tsx | 4 +- frontend/src/utils/fullscreen-routes.test.tsx | 15 +- frontend/src/utils/page-expanded.ts | 33 ++ 18 files changed, 459 insertions(+), 335 deletions(-) create mode 100644 frontend/src/components/ui/expanded-page-toggle.tsx create mode 100644 frontend/src/hooks/use-expanded-page-mode.ts create mode 100644 frontend/src/utils/page-expanded.ts diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 2bcbd140a0..21eec82b8e 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -110,6 +110,8 @@ declare module '@tanstack/react-router' { icon?: LucideIcon; /** Render the route with minimal chrome (no page header/footer/padding). */ fullscreen?: boolean; + /** Route has its own title bar: the app header shows only the breadcrumb row. */ + breadcrumbOnlyHeader?: boolean; } } diff --git a/frontend/src/components/layout/footer.tsx b/frontend/src/components/layout/footer.tsx index ddaa52f8bf..1b271c32ce 100644 --- a/frontend/src/components/layout/footer.tsx +++ b/frontend/src/components/layout/footer.tsx @@ -11,6 +11,7 @@ import { useLocation, useMatchRoute } from '@tanstack/react-router'; import { GitHubIcon, SlackIcon, TwitterIcon } from 'components/icons'; +import { useLayoutEffect, useRef } from 'react'; import { isEmbedded } from '../../config'; import env, { getBuildDate, IsCI, IsDev } from '../../utils/env'; @@ -54,17 +55,91 @@ export const VersionInfo = () => { ); }; +/** Distance from the document top to the element, scroll-independent. */ +const measureDocumentTop = (target: HTMLElement): number => { + let top = 0; + for (let el: HTMLElement | null = target; el; ) { + top += el.offsetTop; + el = el.offsetParent instanceof HTMLElement ? el.offsetParent : null; + } + return top; +}; + +/** Space the ancestors (up to and including ) keep below the element. */ +const measureReservedBelow = (target: HTMLElement): number => { + let reserved = 0; + for (let el: HTMLElement | null = target; el && el !== document.documentElement; el = el.parentElement) { + const style = getComputedStyle(el); + reserved += Number.parseFloat(style.marginBottom) || 0; + if (el !== target) { + reserved += (Number.parseFloat(style.paddingBottom) || 0) + (Number.parseFloat(style.borderBottomWidth) || 0); + } + } + return reserved; +}; + +/** + * Stretch the layout to the viewport bottom so the footer's `margin-top: auto` pins it + * there on short pages. Measured rather than expressed as a CSS flex chain because in + * embedded mode the wrappers above `#mainLayout` belong to the host app. + */ +const stretchLayoutToViewportBottom = (layoutEl: HTMLElement) => { + const viewportHeight = document.documentElement.clientHeight; + const minHeight = Math.round(viewportHeight - measureDocumentTop(layoutEl) - measureReservedBelow(layoutEl)); + const value = minHeight > 0 ? `${minHeight}px` : ''; + if (layoutEl.style.minHeight !== value) { + layoutEl.style.minHeight = value; + } +}; + +/** Pins the footer to the viewport bottom on short pages by stretching `#mainLayout`. */ +const useBottomPinnedFooter = (hidden: boolean) => { + const footerRef = useRef(null); + + useLayoutEffect(() => { + const layoutEl = hidden ? null : footerRef.current?.closest('#mainLayout'); + if (!layoutEl) { + return; + } + + const update = () => stretchLayoutToViewportBottom(layoutEl); + + update(); + const observer = new ResizeObserver(update); + observer.observe(document.documentElement); + observer.observe(layoutEl); + // The layout's margins are adjusted after mount in embedded mode (useCancelHostGutters); + // the MutationObserver re-measures on that style write. update() is idempotent. + const mutationObserver = new MutationObserver(update); + mutationObserver.observe(layoutEl, { attributeFilter: ['style'], attributes: true }); + window.addEventListener('resize', update); + return () => { + observer.disconnect(); + mutationObserver.disconnect(); + window.removeEventListener('resize', update); + layoutEl.style.minHeight = ''; + }; + }, [hidden]); + + return footerRef; +}; + export const AppFooter = () => { const location = useLocation(); const matchRoute = useMatchRoute(); const isAgentPage = matchRoute({ to: '/agents/$id' }); // Hide footer on AI agent inspector tab + let hidden = false; if (isAgentPage) { const searchParams = new URLSearchParams(location.searchStr ?? ''); - if (searchParams.get('tab') === 'inspector') { - return null; - } + hidden = searchParams.get('tab') === 'inspector'; + } + + const footerRef = useBottomPinnedFooter(hidden); + + if (hidden) { + return null; } const gitHub = (link: string, title: string) => ( @@ -76,7 +151,7 @@ export const AppFooter = () => { ); return ( -