diff --git a/src/components/AppLayout.tsx b/src/components/AppLayout.tsx index f3b4f56..63e02a6 100644 --- a/src/components/AppLayout.tsx +++ b/src/components/AppLayout.tsx @@ -1,4 +1,4 @@ -import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { useEffect, useLayoutEffect, useRef, useState, useSyncExternalStore } from 'react' import { useClerk, useOrganization, useOrganizationList, useUser } from '@clerk/react' import { useQuery } from '@tanstack/react-query' import { NavLink, Outlet, useLocation } from 'react-router-dom' @@ -130,6 +130,16 @@ function OrgSwitcher() { } // Remembered per browser; storage can throw in private windows, so it falls back to expanded. +// Between the phone layout and a full desktop, the sidebar would leave too little room for the +// page and the topbar, so it starts as the icon rail there; the toggle still opens it for this visit. +const NARROW = '(min-width: 701px) and (max-width: 1024px)' +function useNarrow() { + return useSyncExternalStore( + notify => { const m = matchMedia(NARROW); m.addEventListener('change', notify); return () => m.removeEventListener('change', notify) }, + () => matchMedia(NARROW).matches, + ) +} + const COLLAPSED_KEY = 'sidebar-collapsed' function readCollapsed() { try { return localStorage.getItem(COLLAPSED_KEY) === '1' } catch { return false } @@ -149,11 +159,17 @@ export function AppLayout() { // In braces: Chrome's scrollTo now returns a Promise, which React would keep as the effect's // cleanup and call on the next route change ("is not a function"), unmounting the whole app. useLayoutEffect(() => { window.scrollTo(0, 0) }, [pathname]) - const [collapsed, setCollapsed] = useState(readCollapsed) - const toggleCollapsed = () => setCollapsed(c => { - try { localStorage.setItem(COLLAPSED_KEY, c ? '0' : '1') } catch { /* keep it for this visit only */ } - return !c - }) + const [saved, setSaved] = useState(readCollapsed) + const narrow = useNarrow() + const [openNarrow, setOpenNarrow] = useState(false) + const collapsed = narrow ? !openNarrow : saved + const toggleCollapsed = () => { + if (narrow) return setOpenNarrow(o => !o) + setSaved(c => { + try { localStorage.setItem(COLLAPSED_KEY, c ? '0' : '1') } catch { /* keep it for this visit only */ } + return !c + }) + } const name = user?.fullName || user?.primaryEmailAddress?.emailAddress || '-' return ( diff --git a/src/styles/theme.css b/src/styles/theme.css index 5c46507..f40f6d8 100644 --- a/src/styles/theme.css +++ b/src/styles/theme.css @@ -428,3 +428,13 @@ dialog::backdrop{background:rgba(16,24,40,.45);backdrop-filter:blur(4px)} } @media(max-width:700px){.sidebar-toggle,.sidebar-help-icon{display:none}} @media(prefers-reduced-motion:reduce){.sidebar,.workspace-main{transition:none}} + +/* Tablet widths: the topbar wraps instead of pushing its pills past the edge, and the page head's + buttons go under the title when they no longer fit beside it */ +@media(min-width:701px) and (max-width:1100px){ + .topbar{height:auto;min-height:64px;padding-top:10px;padding-bottom:10px;flex-wrap:wrap;row-gap:8px} + .topbar-right{flex-wrap:wrap;justify-content:flex-end;gap:8px 10px} + .demo-indicator{display:none} + .page-head{flex-wrap:wrap} + .page-head .head-actions{flex-wrap:wrap} +}