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 <mudler@localai.io>
Assisted-by: Claude Code:claude-opus-4-7 [Edit] [Bash]
This commit is contained in:
Ettore Di Giacinto
2026-04-27 11:51:29 +00:00
parent 54728e292f
commit 60549a8a60
32 changed files with 413 additions and 132 deletions

View File

@@ -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 (6401023) — 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 (6401023): 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);

View File

@@ -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 (
<div className={layoutClasses}>
<Sidebar isOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<main className="main-content">
<main className="main-content" {...(sidebarOpen ? { 'aria-hidden': 'true', inert: '' } : {})}>
<OperationsBar />
{/* 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. */}
<header className="mobile-header">
<button
ref={hamburgerRef}
className="hamburger-btn"
onClick={() => setSidebarOpen(true)}
aria-label="Open menu"
aria-expanded={sidebarOpen}
aria-controls="app-sidebar"
>
<i className="fas fa-bars" />
<i className="fas fa-bars" aria-hidden="true" />
</button>
<span className="mobile-title">LocalAI</span>
<div className="mobile-header-actions">
<button
type="button"
className="mobile-header-btn"
onClick={toggleTheme}
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
>
<i className={`fas ${theme === 'dark' ? 'fa-sun' : 'fa-moon'}`} aria-hidden="true" />
</button>
{showAvatar && (
<button
type="button"
className="mobile-header-btn mobile-header-avatar"
onClick={() => navigate('/app/account')}
aria-label={`Account: ${accountLabel}`}
title={accountLabel}
>
{user.avatarUrl ? (
<img src={user.avatarUrl} alt="" />
) : (
<i className="fas fa-user-circle" aria-hidden="true" />
)}
</button>
)}
</div>
</header>
<div className="main-content-inner">
<div className="page-transition" key={location.pathname}>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from 'react'
import { useState, useEffect, useRef } from 'react'
import { NavLink, useNavigate, useLocation } from 'react-router-dom'
import ThemeToggle from './ThemeToggle'
import { useAuth } from '../context/AuthContext'
@@ -107,11 +107,22 @@ export default function Sidebar({ isOpen, onClose }) {
const { isAdmin, authEnabled, user, logout, hasFeature } = useAuth()
const navigate = useNavigate()
const location = useLocation()
const closeBtnRef = useRef(null)
useEffect(() => {
fetch(apiUrl('/api/features')).then(r => r.json()).then(setFeatures).catch(() => {})
}, [])
// Move focus into the drawer when opened on mobile/tablet so keyboard
// and screen-reader users land inside the dialog. Targeting the close
// button avoids hijacking the visual focus to a nav item the user may
// not have meant to activate.
useEffect(() => {
if (!isOpen) return
const id = window.requestAnimationFrame(() => closeBtnRef.current?.focus())
return () => window.cancelAnimationFrame(id)
}, [isOpen])
// Auto-expand section containing the active route
useEffect(() => {
for (const section of sections) {
@@ -168,7 +179,11 @@ export default function Sidebar({ isOpen, onClose }) {
<>
{isOpen && <div className="sidebar-overlay" onClick={onClose} />}
<aside className={`sidebar ${isOpen ? 'open' : ''} ${collapsed ? 'collapsed' : ''}`}>
<aside
id="app-sidebar"
className={`sidebar ${isOpen ? 'open' : ''} ${collapsed ? 'collapsed' : ''}`}
aria-label="Primary navigation"
>
{/* Logo */}
<div className="sidebar-header">
<a href="./" className="sidebar-logo-link">
@@ -177,8 +192,13 @@ export default function Sidebar({ isOpen, onClose }) {
<a href="./" className="sidebar-logo-icon" title="LocalAI">
<img src={apiUrl('/static/logo.png')} alt="LocalAI" className="sidebar-logo-icon-img" />
</a>
<button className="sidebar-close-btn" onClick={onClose} aria-label="Close menu">
<i className="fas fa-times" />
<button
ref={closeBtnRef}
className="sidebar-close-btn"
onClick={onClose}
aria-label="Close menu"
>
<i className="fas fa-times" aria-hidden="true" />
</button>
</div>

View File

@@ -403,7 +403,7 @@ export default function Account() {
if (!authEnabled) {
return (
<div className="page">
<div className="page page--narrow">
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-user-gear" /></div>
<h2 className="empty-state-title">Account unavailable</h2>
@@ -418,7 +418,7 @@ export default function Account() {
const visibleTabs = isLocal ? TABS : TABS.filter(t => t.id !== 'security')
return (
<div className="page account-page">
<div className="page page--narrow account-page">
{/* Header */}
<div className="page-header">
<h1 className="page-title">Account</h1>

View File

@@ -812,14 +812,14 @@ export default function AgentCreate() {
if (loading) {
return (
<div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<div className="page page--narrow" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
</div>
)
}
return (
<div className="page">
<div className="page page--narrow">
<style>{`
.agent-form-container {
display: flex;

View File

@@ -162,9 +162,9 @@ export default function AgentJobDetails() {
return rendered
}
if (loading) return <div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (loading) return <div className="page page--narrow" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (!job) return (
<div className="page">
<div className="page page--narrow">
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-search" /></div>
<h2 className="empty-state-title">Job not found</h2>
@@ -177,7 +177,7 @@ export default function AgentJobDetails() {
const traces = Array.isArray(job.traces) ? job.traces : []
return (
<div className="page" style={{ maxWidth: 900 }}>
<div className="page page--narrow">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">Job Details</h1>

View File

@@ -213,7 +213,7 @@ export default function AgentJobs() {
// Wizard: no models installed
if (!loading && models.length === 0) {
return (
<div className="page">
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">Agent Jobs</h1>
<p className="page-subtitle">Manage agent tasks and automated workflows</p>
@@ -240,7 +240,7 @@ export default function AgentJobs() {
// Wizard: models but no MCP
if (!loading && models.length > 0 && !hasMCPModels && tasks.length === 0) {
return (
<div className="page">
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">Agent Jobs</h1>
<p className="page-subtitle">Manage agent tasks and automated workflows</p>
@@ -273,7 +273,7 @@ export default function AgentJobs() {
}
return (
<div className="page">
<div className="page page--wide">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">Agent Jobs</h1>

View File

@@ -260,7 +260,7 @@ export default function AgentStatus() {
const tree = buildTree(observables)
return (
<div className="page">
<div className="page page--wide">
<style>{`
.as-card {
background: var(--color-bg-secondary);

View File

@@ -159,12 +159,12 @@ export default function AgentTaskDetails() {
const formatDate = (d) => d ? new Date(d).toLocaleString() : '-'
if (loading) return <div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (loading) return <div className="page page--narrow" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
// View mode
if (!isNew && !isEdit) {
return (
<div className="page" style={{ maxWidth: 900 }}>
<div className="page page--narrow">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div>
<h1 className="page-title">{task.name || 'Task Details'}</h1>
@@ -306,7 +306,7 @@ export default function AgentTaskDetails() {
// Edit/Create form
return (
<div className="page" style={{ maxWidth: 900 }}>
<div className="page page--narrow">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<h1 className="page-title">{isNew ? 'Create Task' : 'Edit Task'}</h1>
<button className="btn btn-secondary btn-sm" onClick={() => navigate('/app/agent-jobs')}>

View File

@@ -136,7 +136,7 @@ export default function Agents() {
}
return (
<div className="page">
<div className="page page--wide">
<style>{`
.agents-import-input { display: none; }
.agents-toolbar {

View File

@@ -149,7 +149,7 @@ function BackendLogsDetail({ modelId }) {
}
return (
<div className="page">
<div className="page page--wide">
<div className="page-header">
<div>
<h1 className="page-title" style={{ marginBottom: 0 }}>
@@ -283,7 +283,7 @@ export default function BackendLogs() {
// No model specified — redirect to System page
return (
<div className="page">
<div className="page page--wide">
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-terminal" /></div>
<h2 className="empty-state-title">No model selected</h2>

View File

@@ -315,7 +315,7 @@ export default function Backends() {
)
return (
<div className="page">
<div className="page page--wide">
{/* Target-node banner: when this gallery is scoped to one node via
?target=<id> (entered from /app/nodes), show the scope clearly and
give a fast way to clear it. Visually a primary-tinted strip so the

View File

@@ -164,7 +164,7 @@ export default function CollectionDetails() {
}
return (
<div className="page">
<div className="page page--narrow">
<style>{`
.collection-detail-upload-form {
display: flex;

View File

@@ -89,7 +89,7 @@ export default function Collections() {
}
return (
<div className="page">
<div className="page page--wide">
<style>{`
.collections-create-bar {
display: flex;

View File

@@ -1039,7 +1039,7 @@ export default function FineTune() {
}
return (
<div className="page">
<div className="page page--wide">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">Fine-Tuning <span className="badge badge-warning" style={{ fontSize: '0.45em', verticalAlign: 'middle' }}>Experimental</span></h1>

View File

@@ -804,7 +804,7 @@ export default function ImportModel() {
)
return (
<div className="page" style={{ maxWidth: '900px' }}>
<div className="page page--narrow">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 'var(--spacing-sm)' }}>
<div>
<h1 className="page-title">Import New Model</h1>

View File

@@ -2,7 +2,6 @@ import { useState, useEffect, useCallback } from 'react'
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom'
import ResourceMonitor from '../components/ResourceMonitor'
import ConfirmDialog from '../components/ConfirmDialog'
import Toggle from '../components/Toggle'
import NodeDistributionChip from '../components/NodeDistributionChip'
import FilterBar from '../components/FilterBar'
import GalleryLoader from '../components/GalleryLoader'
@@ -44,7 +43,7 @@ const USE_CASES = [
// Number of columns the expandable detail row spans, per tab. Kept as
// constants so adding/removing a column doesn't silently break the colSpan.
const MODELS_COLSPAN = 8 // chevron, toggle, icon, name, status, backend, use cases, actions
const MODELS_COLSPAN = 7 // chevron, icon, name, status, backend, use cases, actions
// formatInstalledAt renders an installed_at timestamp as a short relative/abs
// string suitable for dense tables. Returns the raw value if parsing fails so
@@ -427,7 +426,7 @@ export default function Manage() {
const updatesCount = Object.keys(upgrades).length
return (
<div className="page">
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">System</h1>
<p className="page-subtitle">Manage installed models and backends</p>
@@ -547,9 +546,6 @@ export default function Manage() {
<thead>
<tr>
<th style={{ width: 30 }}></th>
<th style={{ width: 36 }}>
<span className="visually-hidden">Enabled</span>
</th>
<th style={{ width: 64 }}></th>
<th>Model</th>
<th>Status</th>
@@ -583,13 +579,6 @@ export default function Manage() {
)}
>
<ChevronCell expanded={isExpanded} />
<StopPropagationCell>
<Toggle
checked={!model.disabled}
onChange={() => handleToggleModel(model.id, model.disabled)}
disabled={togglingModels.has(model.id)}
/>
</StopPropagationCell>
<IconCell icon={enriched?.icon} fallback="fa-brain" alt="" />
<td>
<div style={{ display: 'flex', flexDirection: 'column', minWidth: 0 }}>
@@ -655,6 +644,10 @@ export default function Manage() {
ariaLabel={`Actions for ${model.id}`}
triggerLabel={`Actions for ${model.id}`}
items={[
{ key: 'toggle', icon: model.disabled ? 'fa-toggle-on' : 'fa-toggle-off',
label: model.disabled ? 'Enable model' : 'Disable model',
onClick: () => handleToggleModel(model.id, model.disabled),
disabled: togglingModels.has(model.id) },
{ key: 'stop', icon: 'fa-stop', label: 'Stop model',
onClick: () => handleStopModel(model.id), hidden: !isRunning },
{ key: 'pin', icon: 'fa-thumbtack',

View File

@@ -382,11 +382,11 @@ export default function ModelEditor() {
const loading = metaLoading || configLoading
const showTemplateSelector = isCreateMode && !selectedTemplate
if (loading) return <div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (metaError) return <div className="page"><div className="empty-state"><p className="empty-state-text">Failed to load config metadata: {metaError}</p></div></div>
if (loading) return <div className="page page--medium" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (metaError) return <div className="page page--medium"><div className="empty-state"><p className="empty-state-text">Failed to load config metadata: {metaError}</p></div></div>
return (
<div className="page" style={{ maxWidth: 1000, padding: 0 }}>
<div className="page page--medium" style={{ padding: 0 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',

View File

@@ -177,7 +177,7 @@ export default function Models() {
}
return (
<div className="page">
<div className="page page--wide">
<div className="page-header" style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<div>
<h1 className="page-title">Install Models</h1>

View File

@@ -126,7 +126,7 @@ export default function NodeBackendLogs() {
if (!nodeId || !modelId) {
return (
<div className="page">
<div className="page page--wide">
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-terminal" /></div>
<h2 className="empty-state-title">No node/model selected</h2>
@@ -140,7 +140,7 @@ export default function NodeBackendLogs() {
}
return (
<div className="page">
<div className="page page--wide">
<div className="page-header">
<div>
<h1 className="page-title" style={{ marginBottom: 0 }}>

View File

@@ -417,7 +417,7 @@ export default function Nodes() {
if (loading) {
return (
<div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<div className="page page--wide" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<LoadingSpinner size="lg" />
</div>
)
@@ -426,7 +426,7 @@ export default function Nodes() {
// Disabled state
if (!enabled) {
return (
<div className="page">
<div className="page page--wide">
<div style={{ textAlign: 'center', padding: 'var(--spacing-xl) 0' }}>
<i className="fas fa-network-wired" style={{ fontSize: '3rem', color: 'var(--color-primary)', marginBottom: 'var(--spacing-md)' }} />
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>
@@ -529,7 +529,7 @@ export default function Nodes() {
const pending = filteredNodes.filter(n => n.status === 'pending').length
return (
<div className="page">
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">
<i className="fas fa-network-wired" style={{ marginRight: 'var(--spacing-sm)' }} />

View File

@@ -4,7 +4,7 @@ export default function NotFound() {
const navigate = useNavigate()
return (
<div className="page">
<div className="page page--narrow">
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-compass" /></div>
<h1 className="empty-state-title" style={{ fontSize: '3rem' }}>404</h1>

View File

@@ -170,7 +170,7 @@ export default function P2P() {
if (loading) {
return (
<div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<div className="page page--narrow" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<LoadingSpinner size="lg" />
</div>
)
@@ -179,7 +179,7 @@ export default function P2P() {
// ── P2P Disabled ──
if (!enabled) {
return (
<div className="page">
<div className="page page--narrow">
<div style={{ textAlign: 'center', padding: 'var(--spacing-xl) 0' }}>
<i className="fas fa-network-wired" style={{ fontSize: '3rem', color: 'var(--color-primary)', marginBottom: 'var(--spacing-md)' }} />
<h1 style={{ fontSize: '1.5rem', fontWeight: 600, marginBottom: 'var(--spacing-sm)' }}>
@@ -292,7 +292,7 @@ export default function P2P() {
const mlxTotal = stats.mlx_workers?.total ?? 0
return (
<div className="page">
<div className="page page--narrow">
<div className="page-header">
<h1 className="page-title">
<i className="fas fa-circle-nodes" style={{ marginRight: 'var(--spacing-sm)' }} />

View File

@@ -283,7 +283,7 @@ export default function Quantize() {
const effectiveQuantType = useCustomQuant ? customQuantType : quantType
return (
<div className="page quantize-page">
<div className="page page--narrow quantize-page">
<div className="page-header quantize-page__header">
<div>
<h1 className="page-title">

View File

@@ -98,14 +98,14 @@ export default function Settings() {
return () => container.removeEventListener('scroll', onScroll)
}, [loading])
if (loading) return <div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (!settings) return <div className="page"><div className="empty-state"><p className="empty-state-text">Settings not available</p></div></div>
if (loading) return <div className="page page--medium" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}><LoadingSpinner size="lg" /></div>
if (!settings) return <div className="page page--medium"><div className="empty-state"><p className="empty-state-text">Settings not available</p></div></div>
const isDirty = settings && initialSettings && JSON.stringify(settings) !== JSON.stringify(initialSettings)
const watchdogEnabled = settings.watchdog_idle_enabled || settings.watchdog_busy_enabled
return (
<div className="page" style={{ maxWidth: 1000, padding: 0 }}>
<div className="page page--medium" style={{ padding: 0 }}>
{/* Header */}
<div style={{
display: 'flex', alignItems: 'center', justifyContent: 'space-between',

View File

@@ -344,7 +344,7 @@ export default function SkillEdit() {
if (loading) {
return (
<div className="page" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<div className="page page--narrow" style={{ display: 'flex', justifyContent: 'center', padding: 'var(--spacing-xl)' }}>
<i className="fas fa-spinner fa-spin" style={{ fontSize: '2rem', color: 'var(--color-primary)' }} />
</div>
)
@@ -357,7 +357,7 @@ export default function SkillEdit() {
]
return (
<div className="page" style={{ maxWidth: 960 }}>
<div className="page page--narrow">
<style>{`
.skilledit-back-link {
display: inline-flex;

View File

@@ -204,7 +204,7 @@ export default function Skills() {
if (unavailable) {
return (
<div className="page">
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">Skills</h1>
<p className="page-subtitle">Skills service is not available or the index is rebuilding. Try again in a moment.</p>
@@ -219,7 +219,7 @@ export default function Skills() {
}
return (
<div className="page">
<div className="page page--wide">
<style>{`
.skills-header-actions {
display: flex;

View File

@@ -443,7 +443,7 @@ export default function Talk() {
// ── Render ──
return (
<div className="page" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div className="page page--narrow" style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ width: '100%', maxWidth: '48rem' }}>
<div style={{ textAlign: 'center', marginBottom: 'var(--spacing-lg)' }}>
<h1 className="page-title">Talk</h1>

View File

@@ -380,7 +380,7 @@ export default function Traces() {
}
return (
<div className="page">
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">Traces</h1>
<p className="page-subtitle">View logged API requests, responses, and backend operations</p>

View File

@@ -679,7 +679,7 @@ export default function Usage() {
if (!authEnabled) {
return (
<div className="page">
<div className="page page--wide">
<div className="empty-state">
<div className="empty-state-icon"><i className="fas fa-chart-bar" /></div>
<h2 className="empty-state-title">Usage tracking unavailable</h2>
@@ -705,7 +705,7 @@ export default function Usage() {
const monoCell = { fontFamily: 'var(--font-mono)', fontSize: '0.8125rem' }
return (
<div className="page">
<div className="page page--wide">
<div className="page-header" style={{ marginBottom: 'var(--spacing-sm)' }}>
<h1 className="page-title">Usage</h1>
<p className="page-subtitle">API token usage statistics</p>

View File

@@ -794,7 +794,7 @@ export default function Users() {
const isSelf = (u) => currentUser && (u.id === currentUser.id || u.email === currentUser.email)
return (
<div className="page">
<div className="page page--wide">
<div className="page-header">
<h1 className="page-title">Users</h1>
<p className="page-subtitle">Manage registered users, roles, and invites</p>

View File

@@ -142,6 +142,19 @@
--sidebar-width-collapsed: 52px;
--color-toggle-off: #2f3644;
--color-toggle-on: var(--color-primary);
/* Page-width archetypes — applied via .page / .page--narrow /
.page--medium / .page--wide. Default is wide-enough for ops/data
tables; medium suits two-column app pages (sticky nav + form);
narrow caps reading width. */
--page-max-narrow: 760px;
--page-max-medium: 1080px;
--page-max-default: 1600px;
--page-max-wide: none;
/* Responsive breakpoints — kept here so JS can read them via getComputedStyle. */
--bp-mobile: 640px;
--bp-tablet: 1024px;
}
[data-theme="light"] {
@@ -232,4 +245,13 @@
--color-toggle-off: #c3cad6;
--color-toggle-on: var(--color-primary);
/* Page-width archetypes — see :root block for usage. */
--page-max-narrow: 760px;
--page-max-medium: 1080px;
--page-max-default: 1600px;
--page-max-wide: none;
--bp-mobile: 640px;
--bp-tablet: 1024px;
}