From 60549a8a6081d1bc1f5fa9b870ed5cc43c461b13 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Mon, 27 Apr 2026 11:51:29 +0000 Subject: [PATCH] feat(react-ui): page-width archetype system + mobile/tablet nav polish Replace the universal max-width:1200px cap on .page with a four-tier archetype system (narrow 760, medium 1080, default 1600, wide unbounded) selected per page based on what its UX actually wants. Data/table pages fill ultrawide displays; forms cap at reading width; tabbed feature surfaces breathe. Mobile/tablet: - New 640/1024 breakpoint split. Tablets (640-1023) get a persistent 52px icon rail; below 640 keeps the slide-off drawer. - Drawer polish: body-scroll lock, Escape to close, focus moves into the drawer on open and back to the hamburger on close, aria-hidden + inert on main while open. - Mobile top bar carries hamburger + theme toggle + account avatar (44x44 touch targets) so theme/account aren't trapped in the drawer. - Page-level reflow on phones: page-header column-stacks, filter chips scroll horizontally, tables go edge-to-edge, OperationsBar overflows rather than wrapping. Honors prefers-reduced-motion. Manage > Models: drop the toggle column; Enable/Disable joins the per-row Actions menu alongside Stop/Pin/Edit/Logs/Delete for consistency with the other action verbs. Page-width tokens live in theme.css so future tuning is one line. Removes 7 inline maxWidth workarounds from page roots. Signed-off-by: Ettore Di Giacinto Assisted-by: Claude Code:claude-opus-4-7 [Edit] [Bash] --- core/http/react-ui/src/App.css | 310 ++++++++++++++---- core/http/react-ui/src/App.jsx | 70 +++- core/http/react-ui/src/components/Sidebar.jsx | 28 +- core/http/react-ui/src/pages/Account.jsx | 4 +- core/http/react-ui/src/pages/AgentCreate.jsx | 4 +- .../react-ui/src/pages/AgentJobDetails.jsx | 6 +- core/http/react-ui/src/pages/AgentJobs.jsx | 6 +- core/http/react-ui/src/pages/AgentStatus.jsx | 2 +- .../react-ui/src/pages/AgentTaskDetails.jsx | 6 +- core/http/react-ui/src/pages/Agents.jsx | 2 +- core/http/react-ui/src/pages/BackendLogs.jsx | 4 +- core/http/react-ui/src/pages/Backends.jsx | 2 +- .../react-ui/src/pages/CollectionDetails.jsx | 2 +- core/http/react-ui/src/pages/Collections.jsx | 2 +- core/http/react-ui/src/pages/FineTune.jsx | 2 +- core/http/react-ui/src/pages/ImportModel.jsx | 2 +- core/http/react-ui/src/pages/Manage.jsx | 19 +- core/http/react-ui/src/pages/ModelEditor.jsx | 6 +- core/http/react-ui/src/pages/Models.jsx | 2 +- .../react-ui/src/pages/NodeBackendLogs.jsx | 4 +- core/http/react-ui/src/pages/Nodes.jsx | 6 +- core/http/react-ui/src/pages/NotFound.jsx | 2 +- core/http/react-ui/src/pages/P2P.jsx | 6 +- core/http/react-ui/src/pages/Quantize.jsx | 2 +- core/http/react-ui/src/pages/Settings.jsx | 6 +- core/http/react-ui/src/pages/SkillEdit.jsx | 4 +- core/http/react-ui/src/pages/Skills.jsx | 4 +- core/http/react-ui/src/pages/Talk.jsx | 2 +- core/http/react-ui/src/pages/Traces.jsx | 2 +- core/http/react-ui/src/pages/Usage.jsx | 4 +- core/http/react-ui/src/pages/Users.jsx | 2 +- core/http/react-ui/src/theme.css | 22 ++ 32 files changed, 413 insertions(+), 132 deletions(-) diff --git a/core/http/react-ui/src/App.css b/core/http/react-ui/src/App.css index 2dcd2e5f0..d556abe5e 100644 --- a/core/http/react-ui/src/App.css +++ b/core/http/react-ui/src/App.css @@ -133,6 +133,55 @@ .mobile-title { font-weight: 600; color: var(--color-text-primary); + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mobile-header-actions { + display: flex; + align-items: center; + gap: 4px; + margin-left: auto; +} + +.mobile-header-btn { + background: none; + border: none; + color: var(--color-text-primary); + font-size: 1.05rem; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 44px; + min-height: 44px; + padding: 10px; + border-radius: var(--radius-full); + transition: background var(--duration-fast) var(--ease-default); +} +.mobile-header-btn:hover { + background: var(--color-bg-hover); +} +.mobile-header-btn:focus-visible { + outline: 2px solid var(--color-focus-ring); + outline-offset: 2px; +} + +.mobile-header-avatar { + padding: 4px; +} +.mobile-header-avatar img { + width: 32px; + height: 32px; + border-radius: 50%; + object-fit: cover; + display: block; +} +.mobile-header-avatar .fa-user-circle { + font-size: 1.5rem; } /* Sidebar */ @@ -827,15 +876,22 @@ color: var(--color-text-muted); } -/* Common page styles */ +/* Common page styles — width archetype is opt-in via modifiers. + Default cap fits 9-column data tables on ultrawide displays without + feeling untethered. Add .page--narrow for forms / single-record edit + views, .page--wide for full-bleed (chat shells, log streams). */ .page { padding: var(--spacing-xl) var(--spacing-xl) var(--spacing-2xl); - max-width: 1200px; - margin: 0 auto; width: 100%; + max-width: var(--page-max, var(--page-max-default)); + margin: 0 auto; animation: fadeIn var(--duration-normal) var(--ease-default); } +.page--narrow { --page-max: var(--page-max-narrow); } +.page--medium { --page-max: var(--page-max-medium); } +.page--wide { --page-max: var(--page-max-wide); } + .page-header { margin-bottom: var(--spacing-xl); } @@ -3855,7 +3911,7 @@ select.input { grid-template-columns: minmax(320px, 420px) 1fr; gap: var(--spacing-lg); padding: var(--spacing-xl); - max-width: 1280px; + max-width: var(--page-max-default); margin: 0 auto; width: 100%; align-items: start; @@ -4070,66 +4126,112 @@ select.input { color: var(--color-danger); } -/* Responsive */ +/* ============================================================ + Responsive + ------------------------------------------------------------ + Three viewport tiers, expressed as cascading media queries: + + * desktop (≥1024px) — full sidebar, content margin-left = sidebar-width + * tablet (640–1023) — 52px icon rail; tap hamburger to overlay-expand + * mobile (<640px) — sidebar slides off-screen behind a top-bar drawer + + Touch-target minimums apply across both tablet and mobile via the + first (max-width: 1023px) block. + ============================================================ */ + +/* Touch-friendly sizing + shared layout simplifications (tablet + mobile) */ @media (max-width: 1023px) { - .main-content, - .sidebar-is-collapsed .main-content { - margin-left: 0; + .hamburger-btn { + min-width: 44px; + min-height: 44px; + padding: 10px 12px; + display: inline-flex; + align-items: center; + justify-content: center; } - .mobile-header { - display: flex; - } - - .sidebar { - transform: translateX(-100%); - width: var(--sidebar-width); - } - - .sidebar.collapsed { - width: var(--sidebar-width); - } - - .sidebar.open { - transform: translateX(0); + .nav-item { + min-height: 44px; } .sidebar-close-btn { - display: block; + min-width: 44px; + min-height: 44px; + align-items: center; + justify-content: center; + padding: 10px; } - .sidebar-collapse-btn { - display: none; + .mobile-header { + min-height: 56px; } - .sidebar.collapsed .nav-label, - .sidebar.collapsed .nav-external, - .sidebar.collapsed .sidebar-section-title { - display: unset; + /* Layouts that need to collapse to single-column on any narrow viewport */ + .chat-sidebar { display: none; } + .chat-settings-drawer { + width: 100%; + max-width: 100%; + } + .media-layout { grid-template-columns: 1fr; } + .media-controls { position: static; } + .page { padding: var(--spacing-md); } + + /* The desktop collapse chevron is desktop-only — tablets auto-rail, + mobile uses the hamburger. */ + .sidebar-collapse-btn { display: none; } +} + +/* Tablet (640–1023): persistent icon rail; tap hamburger to overlay-expand */ +@media (max-width: 1023px) and (min-width: 640px) { + .main-content, + .sidebar-is-collapsed .main-content { + margin-left: var(--sidebar-width-collapsed); } - .sidebar.collapsed .sidebar-logo-link { - display: block; + .mobile-header { display: none; } + + .sidebar { + width: var(--sidebar-width-collapsed); + transform: translateX(0); + } + .sidebar.collapsed { width: var(--sidebar-width-collapsed); } + + /* Apply collapsed visuals while not pinned-open. These mirror the + existing .sidebar.collapsed desktop rules so we re-use one look. */ + .sidebar:not(.open) .nav-label, + .sidebar:not(.open) .nav-external, + .sidebar:not(.open) .sidebar-section-title, + .sidebar:not(.open) .sidebar-section-chevron { display: none; } + .sidebar:not(.open) .sidebar-logo-link { display: none; } + .sidebar:not(.open) .sidebar-logo-icon { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + } + .sidebar:not(.open) .sidebar-header { justify-content: center; } + .sidebar:not(.open) .nav-item { + justify-content: center; + padding: 8px 0; + border-left-width: 2px; + } + .sidebar:not(.open) .nav-icon { width: auto; font-size: 1rem; } + .sidebar:not(.open) .sidebar-footer { + justify-content: center; + flex-direction: column; + gap: var(--spacing-xs); + } + .sidebar:not(.open) .sidebar-user-name, + .sidebar:not(.open) .sidebar-logout-btn { display: none; } + + /* Pinned open: overlay the full sidebar on top of content */ + .sidebar.open { + width: var(--sidebar-width); + box-shadow: var(--shadow-md); } - .sidebar.collapsed .sidebar-logo-icon { - display: none; - } - - .sidebar.collapsed .nav-item { - justify-content: flex-start; - padding: 6px var(--spacing-sm); - border-left-width: 3px; - } - - .sidebar.collapsed .nav-icon { - width: 18px; - font-size: 0.85rem; - } - - .sidebar.collapsed .sidebar-header { - justify-content: space-between; - } + .sidebar-close-btn { display: none; } + .sidebar.open .sidebar-close-btn { display: flex; } .sidebar-overlay { display: block; @@ -4138,26 +4240,110 @@ select.input { background: rgba(0, 0, 0, 0.5); z-index: 40; } +} - .chat-sidebar { - display: none; +/* Mobile (<640): sidebar slides off-screen as a drawer */ +@media (max-width: 639px) { + .main-content, + .sidebar-is-collapsed .main-content { + margin-left: 0; } - .chat-settings-drawer { - width: 100%; - max-width: 100%; + .mobile-header { display: flex; } + + .sidebar { + transform: translateX(-100%); + width: var(--sidebar-width); + } + .sidebar.collapsed { width: var(--sidebar-width); } + .sidebar.open { transform: translateX(0); } + + .sidebar-close-btn { display: flex; } + + /* When opened on mobile, even if the .collapsed class is present + from desktop preference, force the expanded look — drawer always + shows full labels. */ + .sidebar.collapsed .nav-label, + .sidebar.collapsed .nav-external, + .sidebar.collapsed .sidebar-section-title { display: unset; } + .sidebar.collapsed .sidebar-logo-link { display: block; } + .sidebar.collapsed .sidebar-logo-icon { display: none; } + .sidebar.collapsed .nav-item { + justify-content: flex-start; + padding: 10px var(--spacing-md); + border-left-width: 3px; + } + .sidebar.collapsed .nav-icon { + width: 18px; + font-size: 0.85rem; + } + .sidebar.collapsed .sidebar-header { justify-content: space-between; } + + .sidebar-overlay { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 40; + } +} + +/* Mobile reflow polish — phone-only (<640) layout adjustments for + page chrome that was designed flex-row on desktop. */ +@media (max-width: 639px) { + /* Page header: stack title block + inline-action cluster vertically */ + .page-header { + flex-direction: column; + align-items: stretch; + gap: var(--spacing-md); } - .media-layout { - grid-template-columns: 1fr; + /* Filter chip rows scroll horizontally instead of wrapping into walls */ + .filter-bar { + flex-wrap: nowrap; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .filter-bar::-webkit-scrollbar { display: none; } + .filter-btn { flex-shrink: 0; } + + .search-bar { min-width: 0; } + + /* Tables go edge-to-edge; offsetting against .page padding gives full + bleed without changing the table layout itself. */ + .table-container { + border-radius: 0; + border-left: 0; + border-right: 0; + margin-inline: calc(-1 * var(--spacing-md)); } - .media-controls { - position: static; + /* Operations toasts: scroll horizontally instead of wrapping */ + .operations-bar { + overflow-x: auto; + flex-wrap: nowrap; + -webkit-overflow-scrolling: touch; } + .operation-item { flex-shrink: 0; } + .operation-text { + max-width: 60vw; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } +} - .page { - padding: var(--spacing-md); +/* Reduced motion — disable non-essential transitions for users who + request it. Keeps focus/state changes accessible without animation. */ +@media (prefers-reduced-motion: reduce) { + .sidebar, + .page-transition, + .operations-bar, + .page, + .main-content { + transition: none !important; + animation: none !important; } } @@ -4939,7 +5125,7 @@ select.input { .biometrics-page { padding: var(--spacing-xl); - max-width: 1320px; + max-width: var(--page-max-wide); margin: 0 auto; width: 100%; animation: fadeIn var(--duration-normal) var(--ease-default); diff --git a/core/http/react-ui/src/App.jsx b/core/http/react-ui/src/App.jsx index d646f1749..8a25d2f0b 100644 --- a/core/http/react-ui/src/App.jsx +++ b/core/http/react-ui/src/App.jsx @@ -1,9 +1,11 @@ -import { useState, useEffect } from 'react' -import { Outlet, useLocation } from 'react-router-dom' +import { useState, useEffect, useRef } from 'react' +import { Outlet, useLocation, useNavigate } from 'react-router-dom' import Sidebar from './components/Sidebar' import OperationsBar from './components/OperationsBar' import { ToastContainer, useToast } from './components/Toast' import { systemApi } from './utils/api' +import { useTheme } from './contexts/ThemeContext' +import { useAuth } from './context/AuthContext' const COLLAPSED_KEY = 'localai_sidebar_collapsed' @@ -15,6 +17,10 @@ export default function App() { const { toasts, addToast, removeToast } = useToast() const [version, setVersion] = useState('') const location = useLocation() + const navigate = useNavigate() + const { theme, toggleTheme } = useTheme() + const { authEnabled, user } = useAuth() + const hamburgerRef = useRef(null) const isChatRoute = location.pathname.match(/\/chat(\/|$)/) || location.pathname.match(/\/agents\/[^/]+\/chat/) useEffect(() => { @@ -34,26 +40,80 @@ export default function App() { window.scrollTo(0, 0) }, [location.pathname]) + // Drawer polish: lock body scroll, close on Escape, return focus to the + // hamburger when the drawer closes. Only engages when the drawer is open; + // desktop and tablet rail mode are unaffected. + useEffect(() => { + if (!sidebarOpen) return + const prevOverflow = document.body.style.overflow + document.body.style.overflow = 'hidden' + const onKey = (e) => { if (e.key === 'Escape') setSidebarOpen(false) } + window.addEventListener('keydown', onKey) + return () => { + document.body.style.overflow = prevOverflow + window.removeEventListener('keydown', onKey) + // Restore focus to the trigger so keyboard users land back where + // they invoked the drawer from. + hamburgerRef.current?.focus() + } + }, [sidebarOpen]) + const layoutClasses = [ 'app-layout', isChatRoute ? 'app-layout-chat' : '', sidebarCollapsed ? 'sidebar-is-collapsed' : '', ].filter(Boolean).join(' ') + const showAvatar = authEnabled && user + const accountLabel = user?.name || user?.email || 'Account' + return (
setSidebarOpen(false)} /> -
+
- {/* Mobile header */} + {/* Mobile header — primary actions reachable without opening the + drawer. Hamburger is the only way to expand the nav on phones; + theme toggle and account avatar are mirrored from the sidebar + footer so they remain one tap away. */}