Improve mobile swipe tabs with native scroll-snap

- Rewrite SwipeTabs to use CSS scroll-snap for smoother transitions
- Add GPU acceleration hints for fluid animations
- Use native scrolling behavior instead of manual touch handling
- Add swipe-tabs--snap and swipe-tabs--static variants
- Improve height transitions and layout containment
- Update dimension variables for better spacing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2025-12-21 17:08:50 +01:00
parent fc605f03c9
commit abd8f75efc
10 changed files with 552 additions and 112 deletions

View File

@@ -0,0 +1,326 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import type { CSSProperties, ReactNode } from 'react';
type SwipeTabsProps<T extends string> = {
tabs: readonly T[];
activeTab: T;
onTabChange: (tab: T) => void;
renderPanel: (tab: T) => ReactNode;
className?: string;
panelClassName?: string;
renderWindow?: number;
threshold?: number;
directionRatio?: number;
respectScrollableTargets?: boolean;
};
const SCROLL_END_DELAY = 120;
const COARSE_POINTER_QUERY = '(pointer: coarse)';
const NO_HOVER_QUERY = '(hover: none)';
const FALLBACK_WIDTH_QUERY = '(max-width: 1024px)';
const getSwipeEnabled = () => {
if (typeof window === 'undefined' || !window.matchMedia) return false;
const coarsePointer = window.matchMedia(COARSE_POINTER_QUERY).matches;
const noHover = window.matchMedia(NO_HOVER_QUERY).matches;
if (coarsePointer || noHover) return true;
const supportsTouch = ('ontouchstart' in window)
|| (typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0);
if (!supportsTouch) return false;
return window.matchMedia(FALLBACK_WIDTH_QUERY).matches;
};
export function SwipeTabs<T extends string>({
tabs,
activeTab,
onTabChange,
renderPanel,
className,
panelClassName,
renderWindow = 0,
}: SwipeTabsProps<T>) {
const containerRef = useRef<HTMLDivElement | null>(null);
const panelRefs = useRef(new Map<T, HTMLDivElement>());
const activeIndex = tabs.indexOf(activeTab);
const safeActiveIndex = activeIndex >= 0 ? activeIndex : 0;
const maxIndex = Math.max(0, tabs.length - 1);
const [containerWidth, setContainerWidth] = useState(0);
const [containerHeight, setContainerHeight] = useState<number | null>(null);
const [isScrolling, setIsScrolling] = useState(false);
const [scrollPosition, setScrollPosition] = useState(0);
const [swipeEnabled, setSwipeEnabled] = useState(() => getSwipeEnabled());
const scrollEndTimer = useRef<number | null>(null);
const scrollRaf = useRef<number | null>(null);
const scrollPositionRef = useRef(0);
const isUserScrolling = useRef(false);
const hasMounted = useRef(false);
const lastReportedIndex = useRef(safeActiveIndex);
const prefersReducedMotion = useMemo(() => {
if (typeof window === 'undefined' || !window.matchMedia) return true;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}, []);
useEffect(() => {
if (typeof window === 'undefined' || !window.matchMedia) return;
const pointerMql = window.matchMedia(COARSE_POINTER_QUERY);
const hoverMql = window.matchMedia(NO_HOVER_QUERY);
const widthMql = window.matchMedia(FALLBACK_WIDTH_QUERY);
const update = () => setSwipeEnabled(getSwipeEnabled());
update();
const addListener = (mql: MediaQueryList) => {
if (mql.addEventListener) {
mql.addEventListener('change', update);
return;
}
mql.addListener(update);
};
const removeListener = (mql: MediaQueryList) => {
if (mql.removeEventListener) {
mql.removeEventListener('change', update);
return;
}
mql.removeListener(update);
};
addListener(pointerMql);
addListener(hoverMql);
addListener(widthMql);
return () => {
removeListener(pointerMql);
removeListener(hoverMql);
removeListener(widthMql);
};
}, []);
useEffect(() => {
if (!containerRef.current) return;
const element = containerRef.current;
const updateSize = () => {
const styles = window.getComputedStyle(element);
const paddingX = parseFloat(styles.paddingLeft) + parseFloat(styles.paddingRight);
const width = element.clientWidth - (Number.isFinite(paddingX) ? paddingX : 0);
setContainerWidth(width > 0 ? width : element.clientWidth);
};
updateSize();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateSize);
return () => window.removeEventListener('resize', updateSize);
}
const observer = new ResizeObserver(updateSize);
observer.observe(element);
return () => observer.disconnect();
}, []);
useEffect(() => {
const panel = panelRefs.current.get(activeTab);
if (!panel) {
setContainerHeight(null);
return;
}
const updateHeight = () => setContainerHeight(panel.offsetHeight);
updateHeight();
if (typeof ResizeObserver === 'undefined') {
window.addEventListener('resize', updateHeight);
return () => window.removeEventListener('resize', updateHeight);
}
const observer = new ResizeObserver(updateHeight);
observer.observe(panel);
return () => observer.disconnect();
}, [activeTab]);
useEffect(() => {
return () => {
if (scrollEndTimer.current !== null) {
window.clearTimeout(scrollEndTimer.current);
}
if (scrollRaf.current !== null) {
cancelAnimationFrame(scrollRaf.current);
}
};
}, []);
useEffect(() => {
lastReportedIndex.current = safeActiveIndex;
}, [safeActiveIndex]);
const scrollToIndex = useCallback((index: number, behavior: ScrollBehavior) => {
const container = containerRef.current;
if (!container || containerWidth <= 0) return;
const targetLeft = index * containerWidth;
if (Math.abs(container.scrollLeft - targetLeft) <= 1) return;
container.scrollTo({ left: targetLeft, behavior });
}, [containerWidth]);
const scheduleScrollPositionUpdate = useCallback(() => {
if (scrollRaf.current !== null) return;
scrollRaf.current = window.requestAnimationFrame(() => {
scrollRaf.current = null;
setScrollPosition(scrollPositionRef.current);
});
}, []);
const handleScroll = useCallback(() => {
if (!swipeEnabled) return;
const container = containerRef.current;
if (!container || containerWidth <= 0) return;
if (!isUserScrolling.current) {
isUserScrolling.current = true;
setIsScrolling(true);
}
const rawIndex = container.scrollLeft / containerWidth;
scrollPositionRef.current = rawIndex;
scheduleScrollPositionUpdate();
const roundedIndex = Math.round(rawIndex);
const clampedIndex = Math.max(0, Math.min(roundedIndex, maxIndex));
if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) {
lastReportedIndex.current = clampedIndex;
onTabChange(tabs[clampedIndex]);
}
if (scrollEndTimer.current !== null) {
window.clearTimeout(scrollEndTimer.current);
}
scrollEndTimer.current = window.setTimeout(() => {
const rawIndex = container.scrollLeft / containerWidth;
const nextIndex = Math.round(rawIndex);
const clampedIndex = Math.max(0, Math.min(nextIndex, maxIndex));
const targetLeft = clampedIndex * containerWidth;
if (Math.abs(container.scrollLeft - targetLeft) > 1) {
container.scrollTo({ left: targetLeft, behavior: 'auto' });
}
if (tabs[clampedIndex] && clampedIndex !== lastReportedIndex.current) {
lastReportedIndex.current = clampedIndex;
onTabChange(tabs[clampedIndex]);
}
scrollPositionRef.current = targetLeft / containerWidth;
scheduleScrollPositionUpdate();
isUserScrolling.current = false;
setIsScrolling(false);
}, SCROLL_END_DELAY);
}, [activeTab, containerWidth, maxIndex, onTabChange, scheduleScrollPositionUpdate, swipeEnabled, tabs]);
useLayoutEffect(() => {
if (!swipeEnabled) return;
const container = containerRef.current;
if (!container || containerWidth <= 0) return;
if (isUserScrolling.current) return;
const behavior: ScrollBehavior = hasMounted.current && !prefersReducedMotion ? 'smooth' : 'auto';
scrollToIndex(safeActiveIndex, behavior);
hasMounted.current = true;
const currentPosition = container.scrollLeft / containerWidth;
scrollPositionRef.current = currentPosition;
setScrollPosition(currentPosition);
}, [containerWidth, prefersReducedMotion, safeActiveIndex, scrollToIndex, swipeEnabled]);
const visibleIndexes = useMemo(() => {
const indexes = new Set<number>();
if (!swipeEnabled) {
if (safeActiveIndex >= 0) {
indexes.add(safeActiveIndex);
}
return indexes;
}
const centers = new Set<number>();
centers.add(safeActiveIndex);
if (containerWidth <= 0) {
centers.add(0);
}
if (containerWidth > 0 && Number.isFinite(scrollPosition)) {
const base = Math.floor(scrollPosition);
const next = Math.ceil(scrollPosition);
centers.add(base);
centers.add(next);
}
centers.forEach((center) => {
for (let offset = -renderWindow; offset <= renderWindow; offset += 1) {
const index = center + offset;
if (index >= 0 && index < tabs.length) {
indexes.add(index);
}
}
});
return indexes;
}, [containerWidth, renderWindow, safeActiveIndex, scrollPosition, swipeEnabled, tabs.length]);
const containerStyle: CSSProperties = {
height: !isScrolling && containerHeight ? `${containerHeight}px` : 'auto',
transition: !isScrolling && containerHeight ? 'height 160ms ease-out' : 'none',
};
if (!swipeEnabled) {
return (
<div
ref={containerRef}
className={`swipe-tabs swipe-tabs--static${className ? ` ${className}` : ''}`}
style={containerStyle}
>
<div className="swipe-tabs-track">
<div className="swipe-tabs-panel" aria-hidden={false}>
<div
className={`swipe-tabs-panel-inner${panelClassName ? ` ${panelClassName}` : ''}`}
ref={(node) => {
if (node) {
panelRefs.current.set(activeTab, node);
} else {
panelRefs.current.delete(activeTab);
}
}}
>
{renderPanel(activeTab)}
</div>
</div>
</div>
</div>
);
}
return (
<div
ref={containerRef}
className={`swipe-tabs swipe-tabs--snap${className ? ` ${className}` : ''}${isScrolling ? ' is-scrolling' : ''}`}
style={containerStyle}
onScroll={handleScroll}
>
<div className="swipe-tabs-track">
{tabs.map((tab, index) => {
const shouldRender = visibleIndexes.has(index);
return (
<div key={tab} className="swipe-tabs-panel" aria-hidden={tab !== activeTab}>
<div
className={`swipe-tabs-panel-inner${panelClassName ? ` ${panelClassName}` : ''}`}
ref={(node) => {
if (node) {
panelRefs.current.set(tab, node);
} else {
panelRefs.current.delete(tab);
}
}}
>
{shouldRender ? renderPanel(tab) : null}
</div>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -4,6 +4,7 @@ import { useTranslation } from '../contexts/LanguageContext';
import { useSidebar } from '../contexts/SidebarContext';
import GeneralTab from '../components/admin/GeneralTab';
import UsersTab from '../components/admin/UsersTab';
import { SwipeTabs } from '../components/SwipeTabs';
import '../styles/AdminPanel.css';
type TabId = 'general' | 'users';
@@ -13,6 +14,8 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
const { t } = useTranslation();
const { toggleMobileMenu } = useSidebar();
const [activeTab, setActiveTab] = useState<TabId>(initialTab);
const tabs: TabId[] = ['general', 'users'];
const renderPanel = (tab: TabId) => (tab === 'general' ? <GeneralTab /> : <UsersTab />);
if (!currentUser?.is_superuser) {
return null;
@@ -46,10 +49,14 @@ export default function AdminPanel({ initialTab = 'general' }: { initialTab?: Ta
</div>
</div>
<div className="admin-tab-content">
{activeTab === 'general' && <GeneralTab />}
{activeTab === 'users' && <UsersTab />}
</div>
<SwipeTabs
className="admin-tab-swipe"
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
renderPanel={renderPanel}
panelClassName="admin-tab-content swipe-panel-content"
/>
</main>
);
}

View File

@@ -5,6 +5,7 @@ import { useSidebar } from '../../contexts/SidebarContext';
import { useModules, TOGGLEABLE_MODULES } from '../../contexts/ModulesContext';
import type { ModuleId } from '../../contexts/ModulesContext';
import Feature1Tab from '../../components/admin/Feature1Tab';
import { SwipeTabs } from '../../components/SwipeTabs';
import '../../styles/AdminPanel.css';
type TabId = 'config' | 'dashboard' | 'feature1' | 'feature2' | 'feature3' | 'search' | 'notifications';
@@ -281,6 +282,8 @@ export default function Features() {
return position === 'bottom';
});
const tabIds = ['config', ...topOrderModules, ...bottomOrderModules] as TabId[];
const renderConfigTab = () => {
return (
<div className="theme-tab-content">
@@ -380,11 +383,11 @@ export default function Features() {
);
};
const renderTabContent = () => {
const renderTabContent = (tabId: TabId) => {
if (!hasInitialized || isLoading) {
return <div className="loading">Loading...</div>;
}
switch (activeTab) {
switch (tabId) {
case 'config':
return renderConfigTab();
case 'dashboard':
@@ -501,9 +504,14 @@ export default function Features() {
</div>
</div>
<div className="admin-tab-content">
{renderTabContent()}
</div>
<SwipeTabs
className="admin-tab-swipe"
tabs={tabIds}
activeTab={activeTab}
onTabChange={handleTabChange}
renderPanel={renderTabContent}
panelClassName="admin-tab-content swipe-panel-content"
/>
</main>
);
}

View File

@@ -6,6 +6,7 @@ import { useAuth } from '../../contexts/AuthContext';
import { useSidebar } from '../../contexts/SidebarContext';
import type { SidebarMode } from '../../contexts/SidebarContext';
import { ChromePicker, HuePicker } from 'react-color';
import { SwipeTabs } from '../../components/SwipeTabs';
import '../../styles/ThemeSettings.css';
type ThemeTab = 'colors' | 'appearance' | 'preview' | 'advanced';
@@ -43,6 +44,7 @@ export default function ThemeSettings() {
const { user } = useAuth();
const { toggleMobileMenu, sidebarMode, setSidebarMode } = useSidebar();
const isAdmin = user?.is_superuser || false;
const tabs: ThemeTab[] = isAdmin ? ['colors', 'appearance', 'preview', 'advanced'] : ['colors', 'appearance', 'preview'];
const [tooltip, setTooltip] = useState<{ text: string; left: number; visible: boolean }>({
text: '',
left: 0,
@@ -262,70 +264,10 @@ export default function ThemeSettings() {
setCustomColors({});
};
return (
<main className="main-content theme-settings-root">
{/* Tab Tooltip */}
{tooltip.visible && (
<div
className="admin-tab-tooltip"
style={{ left: tooltip.left }}
>
{tooltip.text}
</div>
)}
{/* Modern Tab Navigation */}
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.theme.title}</span>
</div>
<div className="admin-tabs-divider"></div>
<button
className={`admin-tab-btn ${activeTab === 'colors' ? 'active' : ''}`}
onClick={() => setActiveTab('colors')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.colorsTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">color_lens</span>
<span>{t.theme.colorsTab}</span>
</button>
<button
className={`admin-tab-btn ${activeTab === 'appearance' ? 'active' : ''}`}
onClick={() => setActiveTab('appearance')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.appearanceTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">tune</span>
<span>{t.theme.appearanceTab}</span>
</button>
<button
className={`admin-tab-btn ${activeTab === 'preview' ? 'active' : ''}`}
onClick={() => setActiveTab('preview')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.previewTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">visibility</span>
<span>{t.theme.previewTab}</span>
</button>
{isAdmin && (
<button
className={`admin-tab-btn ${activeTab === 'advanced' ? 'active' : ''}`}
onClick={() => setActiveTab('advanced')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.advancedTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">code</span>
<span>{t.theme.advancedTab}</span>
</button>
)}
</div>
</div>
<div className="admin-tab-content">
{activeTab === 'colors' && (
const renderPanel = (tab: ThemeTab) => {
switch (tab) {
case 'colors':
return (
<div className="theme-tab-content">
<div className="theme-section">
<div className="section-header">
@@ -394,12 +336,11 @@ export default function ThemeSettings() {
</div>
</div>
</div>
)}
{activeTab === 'appearance' && (
);
case 'appearance':
return (
<div className="theme-tab-content">
<div className="appearance-grid">
{/* Border Radius Section */}
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.borderRadius}</h3>
@@ -426,7 +367,6 @@ export default function ThemeSettings() {
</div>
</div>
{/* Sidebar Style Section */}
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.sidebarStyle}</h3>
@@ -453,7 +393,6 @@ export default function ThemeSettings() {
</div>
</div>
{/* Sidebar Mode Section */}
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.admin.sidebarMode}</h3>
@@ -480,7 +419,6 @@ export default function ThemeSettings() {
</div>
</div>
{/* Density Section */}
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.density}</h3>
@@ -508,7 +446,6 @@ export default function ThemeSettings() {
</div>
</div>
{/* Font Family Section */}
<div className="theme-section">
<div className="section-header">
<h3 className="section-title">{t.theme.fontFamily}</h3>
@@ -535,9 +472,9 @@ export default function ThemeSettings() {
</div>
</div>
</div>
)}
{activeTab === 'preview' && (
);
case 'preview':
return (
<div className="theme-tab-content">
<div className="theme-section">
<div className="section-header">
@@ -579,9 +516,10 @@ export default function ThemeSettings() {
</div>
</div>
</div>
)}
{activeTab === 'advanced' && isAdmin && (
);
case 'advanced':
if (!isAdmin) return null;
return (
<div className="theme-tab-content">
<div className="theme-section">
<div className="section-header">
@@ -593,7 +531,6 @@ export default function ThemeSettings() {
</div>
<div className="advanced-colors-grid" style={{ marginTop: '2rem' }}>
{/* Light Theme Colors */}
<div className="color-theme-section">
<h3 className="color-theme-title">{t.theme.lightThemeColors}</h3>
<div className="color-controls-list">
@@ -648,7 +585,6 @@ export default function ThemeSettings() {
</div>
</div>
{/* Dark Theme Colors */}
<div className="color-theme-section">
<h3 className="color-theme-title">{t.theme.darkThemeColors}</h3>
<div className="color-controls-list">
@@ -705,9 +641,83 @@ export default function ThemeSettings() {
</div>
</div>
</div>
)}
);
default:
return null;
}
};
return (
<main className="main-content theme-settings-root">
{/* Tab Tooltip */}
{tooltip.visible && (
<div
className="admin-tab-tooltip"
style={{ left: tooltip.left }}
>
{tooltip.text}
</div>
)}
{/* Modern Tab Navigation */}
<div className="page-tabs-container">
<div className="page-tabs-slider">
<button className="mobile-menu-btn" onClick={toggleMobileMenu} aria-label={t.theme.toggleMenu}>
<span className="material-symbols-outlined">menu</span>
</button>
<div className="page-title-section">
<span className="page-title-text">{t.theme.title}</span>
</div>
<div className="admin-tabs-divider"></div>
<button
className={`admin-tab-btn ${activeTab === 'colors' ? 'active' : ''}`}
onClick={() => setActiveTab('colors')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.colorsTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">color_lens</span>
<span>{t.theme.colorsTab}</span>
</button>
<button
className={`admin-tab-btn ${activeTab === 'appearance' ? 'active' : ''}`}
onClick={() => setActiveTab('appearance')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.appearanceTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">tune</span>
<span>{t.theme.appearanceTab}</span>
</button>
<button
className={`admin-tab-btn ${activeTab === 'preview' ? 'active' : ''}`}
onClick={() => setActiveTab('preview')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.previewTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">visibility</span>
<span>{t.theme.previewTab}</span>
</button>
{isAdmin && (
<button
className={`admin-tab-btn ${activeTab === 'advanced' ? 'active' : ''}`}
onClick={() => setActiveTab('advanced')}
onMouseEnter={(e) => handleTabMouseEnter(t.theme.advancedTab, e)}
onMouseLeave={handleTabMouseLeave}
>
<span className="material-symbols-outlined">code</span>
<span>{t.theme.advancedTab}</span>
</button>
)}
</div>
</div>
<SwipeTabs
className="admin-tab-swipe"
tabs={tabs}
activeTab={activeTab}
onTabChange={setActiveTab}
renderPanel={renderPanel}
panelClassName="admin-tab-content swipe-panel-content"
/>
{/* Color Picker Popup */}
{colorPickerState && (
<div

View File

@@ -1,5 +1,5 @@
.api-keys-root .page-content {
max-width: var(--container-lg);
max-width: var(--page-max-width);
}
/* Section Layout - matches theme-section spacing */

View File

@@ -80,10 +80,6 @@
border-color: rgba(var(--color-accent-rgb), 0.18);
}
.analytics-panel .section-title {
margin: 0 0 var(--space-3) 0;
}
.mini-chart {
display: flex;
flex-direction: column;

View File

@@ -175,7 +175,7 @@ body {
/* Standard Section Title */
.section-title {
margin: 0 0 1rem 0;
margin: 0 0 var(--section-title-gap) 0;
font-size: 0.8rem;
font-weight: 600;
text-transform: uppercase;
@@ -185,7 +185,11 @@ body {
/* Standard Section Header */
.section-header {
margin-bottom: 1.5rem;
margin-bottom: var(--section-title-gap);
}
.section-header .section-title {
margin-bottom: 0;
}
/* Standard Page Section */
@@ -253,7 +257,7 @@ body {
flex: 1;
padding: var(--page-padding-y) var(--page-padding-x);
width: 100%;
max-width: 1600px;
max-width: var(--page-max-width);
margin: 0 auto;
}
@@ -267,7 +271,7 @@ body {
/* Admin tab content (for tabbed interfaces) */
.admin-tab-content {
padding: var(--page-padding-y) var(--page-padding-x);
max-width: 1600px;
max-width: var(--page-max-width);
margin: 0 auto;
width: 100%;
}
@@ -478,18 +482,96 @@ body {
/* ========== TAB CONTENT ANIMATIONS ========== */
/* Prevent layout shift when switching tabs */
.admin-tab-content {
.admin-tab-content:not(.swipe-tabs):not(.swipe-panel-content) {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 0.25s ease;
}
.admin-tab-content>div {
.admin-tab-content:not(.swipe-tabs):not(.swipe-panel-content)>div {
opacity: 0;
animation: tabFadeIn 0.25s ease forwards;
min-height: 0;
}
.admin-tab-swipe {
width: 100%;
max-width: none;
margin: 0;
}
/* Swipeable tab panels */
.swipe-tabs {
position: relative;
background: var(--color-bg-main);
touch-action: pan-y;
}
.swipe-tabs-track {
display: flex;
width: 100%;
}
.swipe-tabs-panel {
width: 100%;
min-height: 1px;
background: var(--color-bg-main);
}
.swipe-tabs--static {
overflow: hidden;
}
.swipe-tabs--static .swipe-tabs-panel {
flex: 0 0 100%;
overflow: hidden;
}
.swipe-tabs--snap {
overflow-x: auto;
overflow-y: hidden;
touch-action: pan-x pan-y;
scroll-snap-type: x mandatory;
overscroll-behavior-x: contain;
-webkit-overflow-scrolling: touch;
scrollbar-width: none;
-ms-overflow-style: none;
}
.swipe-tabs--snap::-webkit-scrollbar {
display: none;
}
.swipe-tabs--snap .swipe-tabs-panel {
flex: 0 0 100%;
overflow: hidden;
contain: paint;
scroll-snap-align: start;
scroll-snap-stop: always;
}
.swipe-tabs-panel-inner {
width: 100%;
}
.admin-tab-content.swipe-tabs {
display: block;
}
.admin-tab-content.swipe-tabs>div {
opacity: 1;
animation: none;
}
.swipe-panel-content {
display: block;
}
.swipe-panel-content>div {
opacity: 1;
animation: none;
}
@keyframes tabFadeIn {
0% {
opacity: 0;

View File

@@ -13,10 +13,6 @@
font-size: 0.95rem;
}
.settings-page-root .page-content {
max-width: 800px;
}
/* Settings Sections - no card, just spacing */
.settings-section {
margin-bottom: 3rem;

View File

@@ -68,6 +68,11 @@
transition: grid-template-rows 0.25s ease;
}
/* Extra side spacing just for the theme editor content */
.theme-settings-root .admin-tab-content .theme-tab-content {
padding: 0 calc(var(--page-padding-x) * 0.5);
}
/* Smooth transition between tabs */
.theme-tab-content>div {
opacity: 0;
@@ -1155,6 +1160,10 @@
padding: var(--page-padding-y-tablet) var(--page-padding-x-tablet);
}
.theme-settings-root .admin-tab-content .theme-tab-content {
padding: 0 calc(var(--page-padding-x-tablet) * 0.5);
}
.color-grid-enhanced {
grid-template-columns: repeat(3, 1fr);
gap: 1.25rem;
@@ -1182,6 +1191,10 @@
padding: var(--page-padding-y-mobile) var(--page-padding-x-mobile);
}
.theme-settings-root .admin-tab-content .theme-tab-content {
padding: 0 calc(var(--page-padding-x-mobile) * 0.5);
}
.theme-section {
margin-bottom: 2rem;
}

View File

@@ -51,15 +51,15 @@
--sidebar-mobile-width: 280px;
/* Page Layout Spacing */
--page-padding-x: 2rem;
--page-padding-x: 2.5rem;
/* Horizontal padding for page content */
--page-padding-y: 2rem;
--page-padding-y: 2.5rem;
/* Vertical padding for page content */
--page-padding-x-tablet: 1.5rem;
--page-padding-y-tablet: 1.5rem;
--page-padding-x-mobile: 1rem;
--page-padding-y-mobile: 1rem;
--page-max-width: 1400px;
--page-padding-x-tablet: 2rem;
--page-padding-y-tablet: 2rem;
--page-padding-x-mobile: 1.25rem;
--page-padding-y-mobile: 1.25rem;
--page-max-width: 1200px;
/* Maximum content width */
/* Container Widths */
@@ -113,6 +113,8 @@
/* increased from 1.25rem */
--section-gap-sm: 1.125rem;
/* increased from 0.875rem */
--section-title-gap: var(--section-gap);
/* Title-to-content spacing for section headers */
/* Semantic Spacing - Elements */
--element-gap: 0.375rem;