Refactor legacy UI: performance, accessibility, and styling improvements

Performance:
- Extract geometry calculation logic into useToolPanelGeometry hook with debounced resize handling (150ms)
- Create reusable useLocalStorageState hook for cleaner state persistence
- Optimize hook dependencies and memoization

Code Organization:
- Extract 80+ lines of geometry logic into dedicated hook
- Create useFocusTrap hook for accessibility features
- Add ToolPanelGeometry interface for type safety
- Reduce ToolPanel component complexity

Accessibility:
- Add focus trap to legacy surface for keyboard navigation
- Implement Tab/Shift+Tab cycling within modal
- Respect prefers-reduced-motion for animations

UX Improvements:
- Add smooth exit animation for legacy mode (220ms slide-out)
- Skip animations when reduced motion is preferred
- Tool panel mode preference already persisted via ToolWorkflowContext

Styling:
- Add 27 CSS custom properties to centralize color-mix patterns
- Replace pink accent colors with neutral tones matching UI
- Remove gradient from legacy body background
- Maintain tool item borders and hover effects with neutral colors

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

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Anthony Stirling 2025-10-05 11:29:17 +01:00
parent 667a9b4867
commit 6ea920e9a7
6 changed files with 330 additions and 111 deletions

View File

@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState, useRef } from 'react';
import { ActionIcon, Group, ScrollArea, Switch, Text, Tooltip } from '@mantine/core';
import ViewSidebarRoundedIcon from '@mui/icons-material/ViewSidebarRounded';
import { useTranslation } from 'react-i18next';
@ -6,6 +6,7 @@ import ToolSearch from './toolPicker/ToolSearch';
import LegacyToolList from './LegacyToolList';
import { ToolRegistryEntry } from '../../data/toolsTaxonomy';
import { ToolId } from '../../types/toolId';
import { useFocusTrap } from '../../hooks/tools/useFocusTrap';
import './ToolPanel.css';
interface LegacyToolSurfaceProps {
@ -43,17 +44,36 @@ const LegacyToolSurface = ({
geometry,
}: LegacyToolSurfaceProps) => {
const { t } = useTranslation();
const [isExiting, setIsExiting] = useState(false);
const surfaceRef = useRef<HTMLDivElement>(null);
// Enable focus trap when surface is active
useFocusTrap(surfaceRef, !isExiting);
const handleExit = () => {
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
onExitLegacyMode();
return;
}
setIsExiting(true);
setTimeout(() => {
onExitLegacyMode();
}, 220); // Match animation duration (0.22s)
};
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
onExitLegacyMode();
handleExit();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onExitLegacyMode]);
}, []);
const style = geometry
? {
@ -71,7 +91,10 @@ const LegacyToolSurface = ({
role="region"
aria-label={t('toolPanel.legacy.heading', 'All tools (legacy view)')}
>
<div className="tool-panel__legacy-surface-inner">
<div
ref={surfaceRef}
className={`tool-panel__legacy-surface-inner ${isExiting ? 'tool-panel__legacy-surface-inner--exiting' : ''}`}
>
<header className="tool-panel__legacy-header">
<div className="tool-panel__legacy-heading">
<Text fw={700} size="lg">
@ -86,7 +109,7 @@ const LegacyToolSurface = ({
variant="subtle"
radius="xl"
size="lg"
onClick={onExitLegacyMode}
onClick={handleExit}
aria-label={toggleLabel}
>
<ViewSidebarRoundedIcon fontSize="small" />

View File

@ -1,3 +1,32 @@
/* CSS Custom Properties for Legacy Mode */
.tool-panel__legacy-surface-inner {
--legacy-bg-surface-1: color-mix(in srgb, var(--bg-toolbar) 96%, transparent);
--legacy-bg-surface-2: color-mix(in srgb, var(--bg-background) 90%, transparent);
--legacy-bg-header: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
--legacy-bg-controls-1: color-mix(in srgb, var(--bg-toolbar) 84%, transparent);
--legacy-bg-controls-2: color-mix(in srgb, var(--bg-background) 72%, transparent);
--legacy-bg-body-1: color-mix(in srgb, var(--bg-background) 86%, transparent);
--legacy-bg-body-2: color-mix(in srgb, var(--bg-toolbar) 78%, transparent);
--legacy-bg-group: color-mix(in srgb, var(--bg-toolbar) 82%, transparent);
--legacy-bg-item: color-mix(in srgb, var(--bg-toolbar) 88%, transparent);
--legacy-bg-list-item: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
--legacy-bg-icon-detailed: color-mix(in srgb, var(--bg-muted) 75%, transparent);
--legacy-bg-icon-compact: color-mix(in srgb, var(--bg-muted) 70%, transparent);
--legacy-border-subtle-75: color-mix(in srgb, var(--border-subtle) 75%, transparent);
--legacy-border-subtle-70: color-mix(in srgb, var(--border-subtle) 70%, transparent);
--legacy-border-subtle-65: color-mix(in srgb, var(--border-subtle) 65%, transparent);
--legacy-shadow-primary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, transparent);
--legacy-shadow-secondary: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, transparent);
--legacy-shadow-group: color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, transparent);
--legacy-accent-hover: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
--legacy-accent-selected: color-mix(in srgb, var(--text-primary) 30%, var(--border-subtle));
--legacy-accent-ring: color-mix(in srgb, var(--text-primary) 15%, transparent);
--legacy-accent-list-bg: color-mix(in srgb, var(--text-primary) 8%, var(--bg-toolbar));
--legacy-accent-list-border: color-mix(in srgb, var(--text-primary) 20%, var(--border-subtle));
--legacy-text-icon: color-mix(in srgb, var(--text-primary) 90%, var(--text-muted));
--legacy-text-icon-compact: color-mix(in srgb, var(--text-primary) 88%, var(--text-muted));
}
.tool-panel {
position: relative;
transition: width 0.3s ease, max-width 0.3s ease;
@ -62,17 +91,21 @@
background:
linear-gradient(
140deg,
color-mix(in srgb, var(--bg-toolbar) 96%, transparent),
color-mix(in srgb, var(--bg-background) 90%, transparent)
var(--legacy-bg-surface-1),
var(--legacy-bg-surface-2)
)
padding-box;
border: 1px solid color-mix(in srgb, var(--border-subtle) 75%, transparent);
border: 1px solid var(--legacy-border-subtle-75);
box-shadow:
0 24px 64px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.55)) 25%, transparent),
0 6px 18px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.35)) 30%, transparent);
0 24px 64px var(--legacy-shadow-primary),
0 6px 18px var(--legacy-shadow-secondary);
backdrop-filter: blur(18px);
overflow: hidden;
animation: tool-panel-legacy-slide 0.28s ease forwards;
animation: tool-panel-legacy-slide-in 0.28s ease forwards;
}
.tool-panel__legacy-surface-inner--exiting {
animation: tool-panel-legacy-slide-out 0.22s ease forwards;
}
.tool-panel__legacy-header {
@ -81,10 +114,10 @@
align-items: flex-start;
gap: 1rem;
padding: 1.25rem 1.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent);
border-bottom: 1px solid var(--legacy-border-subtle-70);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-toolbar) 86%, transparent),
var(--legacy-bg-header),
transparent 85%
);
}
@ -100,11 +133,11 @@
align-items: center;
gap: 1rem;
padding: 1rem 1.75rem;
border-bottom: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent);
border-bottom: 1px solid var(--legacy-border-subtle-70);
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-toolbar) 84%, transparent),
color-mix(in srgb, var(--bg-background) 72%, transparent)
var(--legacy-bg-controls-1),
var(--legacy-bg-controls-2)
);
}
@ -115,11 +148,7 @@
.tool-panel__legacy-body {
flex: 1;
min-height: 0;
background: linear-gradient(
180deg,
color-mix(in srgb, var(--bg-background) 86%, transparent),
color-mix(in srgb, var(--bg-toolbar) 78%, transparent)
);
background: transparent;
}
.tool-panel__legacy-scroll {
@ -149,9 +178,9 @@
margin: 0 0 1.5rem;
padding: 0.65rem 0.75rem 1rem;
border-radius: 1rem;
background: color-mix(in srgb, var(--bg-toolbar) 82%, transparent);
border: 1px solid color-mix(in srgb, var(--border-subtle) 65%, transparent);
box-shadow: 0 14px 32px color-mix(in srgb, var(--shadow-color, rgba(15, 23, 42, 0.45)) 18%, transparent);
background: var(--legacy-bg-group);
border: 1px solid var(--legacy-border-subtle-65);
box-shadow: 0 14px 32px var(--legacy-shadow-group);
break-inside: avoid;
backdrop-filter: blur(10px);
}
@ -179,9 +208,9 @@
gap: 0.75rem;
align-items: flex-start;
padding: 0.85rem 0.95rem;
border: 1px solid color-mix(in srgb, var(--border-subtle) 70%, transparent);
border: 1px solid var(--legacy-border-subtle-70);
border-radius: 0.95rem;
background: color-mix(in srgb, var(--bg-toolbar) 88%, transparent);
background: var(--legacy-bg-item);
backdrop-filter: blur(6px);
cursor: pointer;
transition: transform 0.2s ease, border-color 0.2s ease, box-shadow 0.2s ease;
@ -190,7 +219,7 @@
}
.tool-panel__legacy-item:focus-visible {
outline: 2px solid var(--accent-primary, var(--mantine-color-pink-6));
outline: 2px solid var(--legacy-accent-selected);
outline-offset: 3px;
}
@ -202,13 +231,13 @@
.tool-panel__legacy-item:hover:not([aria-disabled="true"]):not(:disabled) {
transform: translateY(-2px);
border-color: color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 38%, var(--border-subtle));
border-color: var(--legacy-accent-hover);
box-shadow: var(--shadow-xl, 0 18px 34px rgba(15, 23, 42, 0.14));
}
.tool-panel__legacy-item--selected {
border-color: color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 52%, var(--border-subtle));
box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 28%, transparent);
border-color: var(--legacy-accent-selected);
box-shadow: 0 0 0 2px var(--legacy-accent-ring);
}
.tool-panel__legacy-item--detailed {
@ -222,8 +251,8 @@
width: 2.75rem;
height: 2.75rem;
border-radius: 0.75rem;
background: color-mix(in srgb, var(--bg-muted) 75%, transparent);
color: color-mix(in srgb, var(--text-primary) 90%, var(--text-muted));
background: var(--legacy-bg-icon-detailed);
color: var(--legacy-text-icon);
flex-shrink: 0;
}
@ -265,7 +294,7 @@
border-radius: 0.65rem;
cursor: pointer;
transition: background 0.2s ease, transform 0.2s ease;
background: color-mix(in srgb, var(--bg-toolbar) 86%, transparent);
background: var(--legacy-bg-list-item);
border: 1px solid transparent;
width: 100%;
box-sizing: border-box;
@ -279,8 +308,8 @@
.tool-panel__legacy-list-item:hover:not([aria-disabled="true"]):not(:disabled),
.tool-panel__legacy-list-item--selected {
background: color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 22%, var(--bg-toolbar));
border-color: color-mix(in srgb, var(--accent-primary, var(--mantine-color-pink-6)) 30%, var(--border-subtle));
background: var(--legacy-accent-list-bg);
border-color: var(--legacy-accent-list-border);
}
.tool-panel__legacy-list-item--selected {
@ -294,8 +323,8 @@
width: 2.1rem;
height: 2.1rem;
border-radius: 0.6rem;
background: color-mix(in srgb, var(--bg-muted) 70%, transparent);
color: color-mix(in srgb, var(--text-primary) 88%, var(--text-muted));
background: var(--legacy-bg-icon-compact);
color: var(--legacy-text-icon-compact);
flex-shrink: 0;
}
@ -314,7 +343,7 @@
gap: 0.75rem;
}
@keyframes tool-panel-legacy-slide {
@keyframes tool-panel-legacy-slide-in {
from {
transform: translateX(-6%) scaleX(0.85);
opacity: 0;
@ -325,6 +354,32 @@
}
}
@keyframes tool-panel-legacy-slide-out {
from {
transform: translateX(0) scaleX(1);
opacity: 1;
}
to {
transform: translateX(-6%) scaleX(0.85);
opacity: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.tool-panel__legacy-surface-inner {
animation: none !important;
}
.tool-panel__mode-toggle {
transition: none;
}
.tool-panel__legacy-item,
.tool-panel__legacy-list-item {
transition: none;
}
}
@media (max-width: 1440px) {
.tool-panel__legacy-content {
padding-inline: 1.5rem;

View File

@ -1,4 +1,4 @@
import { useEffect, useMemo, useState, useLayoutEffect } from 'react';
import { useMemo } from 'react';
import { useRainbowThemeContext } from '../shared/RainbowThemeProvider';
import { useToolWorkflow } from '../../contexts/ToolWorkflowContext';
import ToolPicker from './ToolPicker';
@ -14,6 +14,8 @@ import ViewSidebarRoundedIcon from '@mui/icons-material/ViewSidebarRounded';
import DashboardCustomizeRoundedIcon from '@mui/icons-material/DashboardCustomizeRounded';
import { useTranslation } from 'react-i18next';
import LegacyToolSurface from './LegacyToolSurface';
import { useToolPanelGeometry } from '../../hooks/tools/useToolPanelGeometry';
import { useLocalStorageState } from '../../hooks/tools/useLocalStorageState';
import './ToolPanel.css';
// No props needed - component uses context
@ -42,81 +44,15 @@ export default function ToolPanel() {
const isLegacyMode = toolPanelMode === 'legacy';
const legacyExpanded = isLegacyMode && leftPanelView === 'toolPicker' && !isMobile;
const [legacyGeometry, setLegacyGeometry] = useState<{ left: number; top: number; width: number; height: number } | null>(null);
const LEGACY_DESCRIPTION_STORAGE_KEY = 'legacyToolDescriptions';
const [showLegacyDescriptions, setShowLegacyDescriptions] = useState(() => {
if (typeof window === 'undefined') {
return true;
}
const stored = window.localStorage.getItem(LEGACY_DESCRIPTION_STORAGE_KEY);
if (stored === null) {
return true;
}
return stored === 'true';
// Use custom hooks for state management
const [showLegacyDescriptions, setShowLegacyDescriptions] = useLocalStorageState('legacyToolDescriptions', true);
const legacyGeometry = useToolPanelGeometry({
enabled: legacyExpanded,
toolPanelRef,
quickAccessRef,
});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(LEGACY_DESCRIPTION_STORAGE_KEY, String(showLegacyDescriptions));
}, [showLegacyDescriptions]);
useLayoutEffect(() => {
if (!legacyExpanded) {
setLegacyGeometry(null);
return;
}
const panelEl = toolPanelRef.current;
if (!panelEl) {
setLegacyGeometry(null);
return;
}
const rightRailEl = () => document.querySelector('[data-sidebar="right-rail"]') as HTMLElement | null;
const updateGeometry = () => {
const rect = panelEl.getBoundingClientRect();
const rail = rightRailEl();
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
const height = Math.max(rect.height, window.innerHeight - rect.top);
setLegacyGeometry({
left: rect.left,
top: rect.top,
width,
height,
});
};
updateGeometry();
const handleResize = () => updateGeometry();
window.addEventListener('resize', handleResize);
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => updateGeometry());
resizeObserver.observe(panelEl);
if (quickAccessRef.current) {
resizeObserver.observe(quickAccessRef.current);
}
const rail = rightRailEl();
if (rail) {
resizeObserver.observe(rail);
}
}
return () => {
window.removeEventListener('resize', handleResize);
resizeObserver?.disconnect();
};
}, [legacyExpanded, quickAccessRef, toolPanelRef]);
const toggleLabel = isLegacyMode
? t('toolPanel.toggle.sidebar', 'Switch to sidebar mode')
: t('toolPanel.toggle.legacy', 'Switch to legacy mode');

View File

@ -0,0 +1,74 @@
import { useEffect, RefObject } from 'react';
const FOCUSABLE_ELEMENTS = [
'a[href]',
'button:not([disabled])',
'input:not([disabled])',
'select:not([disabled])',
'textarea:not([disabled])',
'[tabindex]:not([tabindex="-1"])',
].join(', ');
export function useFocusTrap(containerRef: RefObject<HTMLElement>, enabled: boolean = true) {
useEffect(() => {
if (!enabled || !containerRef.current) {
return;
}
const container = containerRef.current;
const getFocusableElements = () =>
Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_ELEMENTS));
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Tab') {
return;
}
const focusableElements = getFocusableElements();
if (focusableElements.length === 0) {
return;
}
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
const activeElement = document.activeElement as HTMLElement;
// Check if focus is within the container
if (!container.contains(activeElement)) {
event.preventDefault();
firstElement.focus();
return;
}
// Shift + Tab (backwards)
if (event.shiftKey) {
if (activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
}
// Tab (forwards)
else {
if (activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
};
// Focus first element on mount
const focusableElements = getFocusableElements();
if (focusableElements.length > 0) {
// Small delay to ensure the element is fully rendered
setTimeout(() => {
focusableElements[0]?.focus();
}, 100);
}
document.addEventListener('keydown', handleKeyDown);
return () => {
document.removeEventListener('keydown', handleKeyDown);
};
}, [containerRef, enabled]);
}

View File

@ -0,0 +1,30 @@
import { useState, useEffect } from 'react';
export function useLocalStorageState<T>(key: string, defaultValue: T): [T, (value: T) => void] {
const [state, setState] = useState<T>(() => {
if (typeof window === 'undefined') {
return defaultValue;
}
const stored = window.localStorage.getItem(key);
if (stored === null) {
return defaultValue;
}
try {
return JSON.parse(stored) as T;
} catch {
return defaultValue;
}
});
useEffect(() => {
if (typeof window === 'undefined') {
return;
}
window.localStorage.setItem(key, JSON.stringify(state));
}, [key, state]);
return [state, setState];
}

View File

@ -0,0 +1,101 @@
import { useLayoutEffect, useState, RefObject } from 'react';
export interface ToolPanelGeometry {
left: number;
top: number;
width: number;
height: number;
}
interface UseToolPanelGeometryOptions {
enabled: boolean;
toolPanelRef: RefObject<HTMLDivElement>;
quickAccessRef: RefObject<HTMLDivElement>;
}
export function useToolPanelGeometry({
enabled,
toolPanelRef,
quickAccessRef,
}: UseToolPanelGeometryOptions) {
const [geometry, setGeometry] = useState<ToolPanelGeometry | null>(null);
useLayoutEffect(() => {
if (!enabled) {
setGeometry(null);
return;
}
const panelEl = toolPanelRef.current;
if (!panelEl) {
setGeometry(null);
return;
}
const rightRailEl = () => document.querySelector('[data-sidebar="right-rail"]') as HTMLElement | null;
let timeoutId: number | null = null;
const updateGeometry = () => {
// Debounce: clear any pending update
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
// Schedule update after 150ms of inactivity
timeoutId = window.setTimeout(() => {
const rect = panelEl.getBoundingClientRect();
const rail = rightRailEl();
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
left: rect.left,
top: rect.top,
width,
height,
});
timeoutId = null;
}, 150);
};
// Initial geometry calculation (no debounce)
const rect = panelEl.getBoundingClientRect();
const rail = rightRailEl();
const rightOffset = rail ? Math.max(0, window.innerWidth - rail.getBoundingClientRect().left) : 0;
const width = Math.max(360, window.innerWidth - rect.left - rightOffset);
const height = Math.max(rect.height, window.innerHeight - rect.top);
setGeometry({
left: rect.left,
top: rect.top,
width,
height,
});
const handleResize = () => updateGeometry();
window.addEventListener('resize', handleResize);
let resizeObserver: ResizeObserver | null = null;
if (typeof ResizeObserver !== 'undefined') {
resizeObserver = new ResizeObserver(() => updateGeometry());
resizeObserver.observe(panelEl);
if (quickAccessRef.current) {
resizeObserver.observe(quickAccessRef.current);
}
const rail = rightRailEl();
if (rail) {
resizeObserver.observe(rail);
}
}
return () => {
if (timeoutId !== null) {
window.clearTimeout(timeoutId);
}
window.removeEventListener('resize', handleResize);
resizeObserver?.disconnect();
};
}, [enabled, quickAccessRef, toolPanelRef]);
return geometry;
}