mirror of
https://github.com/mudler/LocalAI.git
synced 2026-04-29 03:24:49 -04:00
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:
@@ -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);
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -164,7 +164,7 @@ export default function CollectionDetails() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page page--narrow">
|
||||
<style>{`
|
||||
.collection-detail-upload-form {
|
||||
display: flex;
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function Collections() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page page--wide">
|
||||
<style>{`
|
||||
.collections-create-bar {
|
||||
display: flex;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 }}>
|
||||
|
||||
@@ -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)' }} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)' }} />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user