# Production Deployment Checklist: Experimental Navigation (Radial Menu & Nonlinear UX) > **Version:** 1.0.0 | **Last Updated:** 2025 | **Target:** Experimental/Avant-garde navigation systems > > âš ī¸ Experimental UX patterns carry higher risk — this checklist is non-negotiable before production. --- ## Quick Reference | Section | Items | Priority | |---|---|---| | Performance Audit | 12 items | 🔴 Critical | | Accessibility Audit | 14 items | 🔴 Critical | | Mobile/Touch Audit | 10 items | 🟠 High | | Browser Compatibility | 11 items | 🟠 High | | User Testing | 9 items | 🟡 Required | **Sign-off required:** Lead Dev + Design + QA before merge to `main`. --- ## Section 1 — Performance Audit > Radial menus and nonlinear UX rely heavily on transforms, animations, and scroll-based effects. Poor performance = broken experience. Target: **Lighthouse Performance â‰Ĩ 90** on mobile. --- ### 1.1 Lighthouse Baseline - [ ] **Run Lighthouse in incognito, throttled to "Mobile" preset** *Why:* Extensions and cached assets skew scores. Mobile throttling reflects real-world conditions for experimental UX which is heavier than conventional navigation. ```bash # CLI audit — no browser extensions, reproducible in CI npx lighthouse https://your-domain.com \ --preset=perf \ --emulated-form-factor=mobile \ --throttling-method=simulate \ --output=json \ --output-path=./reports/lighthouse-$(date +%Y%m%d).json # Quick score check cat ./reports/lighthouse-*.json | \ node -e "const d=require('fs').readFileSync('/dev/stdin','utf8'); \ const r=JSON.parse(d); \ console.log('Performance:', r.categories.performance.score * 100)" ``` - [ ] **Performance score â‰Ĩ 90 on mobile, â‰Ĩ 95 on desktop** *Why:* Radial menus with CSS transforms + JS orchestration easily drop to 60-70 without optimization. Set the bar high before launch. ```bash # Automated assertion in CI (fail build if below threshold) npx lighthouse-ci autorun \ --collect.url=https://your-domain.com \ --assert.assertions.performance=["error", {"minScore": 0.9}] ``` - [ ] **Verify no render-blocking resources tied to navigation scripts** *Why:* Navigation JS loading synchronously blocks FCP — users see broken layout before menu initializes. ```html ``` --- ### 1.2 CSS Transform Optimization - [ ] **All animated elements use `transform` and `opacity` ONLY — no layout-triggering properties** *Why:* Animating `top`, `left`, `width`, `margin` triggers layout recalculation on every frame. Radial menu with 8 items animating `top/left` = 8 × 60fps layout thrashes = jank. ```css /* ❌ WRONG — triggers layout + paint */ .radial-item { transition: top 0.3s, left 0.3s, opacity 0.3s; } /* ✅ CORRECT — compositor-only, no layout */ .radial-item { transition: transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease; } /* Radial positioning via transform, not top/left */ .radial-item:nth-child(1) { --angle: 0deg; --radius: 120px; transform: rotate(var(--angle)) translateX(var(--radius)) rotate(calc(-1 * var(--angle))); } ``` ```javascript // Verify in DevTools console — should show "Composite" not "Layout" // Open DevTools > Rendering > Paint flashing // Trigger menu open — NO green flashing on radial items = ✅ // Programmatic check const items = document.querySelectorAll('.radial-item'); items.forEach(el => { const styles = getComputedStyle(el); const animatedProps = styles.transition; const hasLayoutProps = /top|left|right|bottom|width|height|margin|padding/.test(animatedProps); console.assert(!hasLayoutProps, `❌ Layout property in transition: ${el.className}`); }); ``` - [ ] **Verify GPU layer promotion is intentional — no layer explosion** *Why:* Excessive `will-change` or `translateZ(0)` creates too many GPU layers, consuming VRAM and causing compositing overhead. Especially bad on low-end mobile. ```javascript // DevTools > Layers panel — count layers before/after menu open // Expected: < 20 new layers created when menu opens // Chrome DevTools command // Open: chrome://flags/#show-composited-layer-borders // Then inspect: Layers tab in DevTools // Check layer count programmatically (rough estimate) const promoted = [...document.querySelectorAll('*')].filter(el => { const style = getComputedStyle(el); return style.willChange !== 'auto' || style.transform !== 'none' || style.position === 'fixed'; }); console.log(`Promoted elements: ${promoted.length}`); // Should be < 30 total, alert if > 50 ``` --- ### 1.3 `will-change` Audit - [ ] **`will-change` applied only to elements about to animate, removed after animation** *Why:* Static `will-change: transform` on all menu items wastes GPU memory 24/7 even when menu is closed. On mobile with 2GB RAM, this causes browser to drop frames elsewhere. ```javascript // ❌ WRONG — permanent will-change // CSS: .radial-item { will-change: transform; } // ✅ CORRECT — dynamic will-change lifecycle class RadialMenu { open() { // Apply BEFORE animation starts this.items.forEach(item => { item.style.willChange = 'transform, opacity'; }); // Animate this.animateIn(); } onAnimationComplete() { // Remove AFTER animation ends — critical! this.items.forEach(item => { item.style.willChange = 'auto'; }); } close() { this.items.forEach(item => { item.style.willChange = 'transform, opacity'; }); this.animateOut().then(() => { this.items.forEach(item => { item.style.willChange = 'auto'; }); }); } } ``` - [ ] **Audit existing `will-change` declarations in codebase** *Why:* Accumulated technical debt — developers add `will-change` to fix jank, forget to remove it. ```bash # Find all will-change in CSS/SCSS/styled-components grep -r "will-change" ./src --include="*.css" --include="*.scss" \ --include="*.ts" --include="*.tsx" --include="*.js" \ -n --color=always # Count occurrences grep -r "will-change" ./src | grep -v "will-change: auto" | wc -l # Target: 0 static will-change declarations in CSS files # All should be dynamically applied via JS ``` --- ### 1.4 Locomotive Scroll / Lerp Performance - [ ] **Locomotive Scroll lerp value validated: 0.05–0.15 range** *Why:* Lerp value controls smoothing lag. Too low (0.02) = sluggish, high input latency perception. Too high (0.5) = no smoothing benefit, defeats purpose. Default 0.1 is baseline but tune per device. ```javascript // locomotive-scroll config validation const scroll = new LocomotiveScroll({ el: document.querySelector('[data-scroll-container]'), smooth: true, lerp: 0.1, // ← Validate this value multiplier: 1, smartphone: { smooth: false, // ← CRITICAL: disable on mobile (see mobile section) lerp: 0.1 }, tablet: { smooth: true, lerp: 0.08 // Slightly more responsive on tablet } }); // Assertion console.assert(scroll.options.lerp >= 0.05 && scroll.options.lerp <= 0.15, `âš ī¸ Lerp value ${scroll.options.lerp} outside recommended range`); ``` - [ ] **Verify Locomotive Scroll does not conflict with radial menu fixed positioning** *Why:* Locomotive uses `transform: translateY()` on scroll container, which creates a new stacking context. Fixed-positioned radial menu gets trapped inside transformed parent — common bug. ```javascript // Test: open radial menu while page is mid-scroll // Expected: menu stays in viewport center, doesn't scroll with content // Diagnostic const menuEl = document.querySelector('.radial-menu'); const scrollContainer = document.querySelector('[data-scroll-container]'); // Check if menu is child of scroll container (it shouldn't be) const isTrapped = scrollContainer.contains(menuEl); console.assert(!isTrapped, '❌ Radial menu is child of scroll container — will be transformed by Locomotive'); // Fix: move menu to body level // document.body.appendChild(menuEl); // Verify stacking context isn't breaking fixed position let parent = menuEl.parentElement; while (parent) { const style = getComputedStyle(parent); const hasTransform = style.transform !== 'none'; const hasFilter = style.filter !== 'none'; if (hasTransform || hasFilter) { console.warn(`âš ī¸ Stacking context parent: ${parent.tagName}.${parent.className}`); } parent = parent.parentElement; } ``` - [ ] **RAF loop does not run when Locomotive is idle** *Why:* Locomotive's requestAnimationFrame loop runs continuously by default, consuming CPU even when nothing is scrolling. On battery-powered devices, this drains battery and causes thermal throttling. ```javascript // Check if custom RAF loops are properly cancelled // DevTools > Performance > Record idle state for 5 seconds // Expected: < 5% CPU usage when page is not being interacted with // If using custom lerp loop, implement idle detection class SmoothScroll { #rafId = null; #isScrolling = false; #idleTimeout = null; startLoop() { if (this.#rafId) return; // Prevent duplicate loops this.#rafId = requestAnimationFrame(this.tick.bind(this)); } stopLoop() { if (this.#rafId) { cancelAnimationFrame(this.#rafId); this.#rafId = null; } } onScroll() { this.#isScrolling = true; this.startLoop(); clearTimeout(this.#idleTimeout); this.#idleTimeout = setTimeout(() => { this.#isScrolling = false; // Stop RAF when scroll has settled if (Math.abs(this.current - this.target) < 0.1) { this.stopLoop(); } }, 150); } } ``` --- ## Section 2 — Accessibility Audit > Experimental navigation must not exclude users. A radial menu that only works with a mouse is a lawsuit waiting to happen. WCAG 2.1 AA minimum — target AAA where possible. --- ### 2.1 Keyboard Navigation - [ ] **Radial menu fully operable with keyboard alone — no mouse required** *Why:* ~7% of users rely on keyboard navigation. Radial menus are inherently spatial — keyboard users can't "point" to items. Must implement logical tab order or arrow key navigation. ```javascript // Keyboard navigation implementation for radial menu class RadialMenuKeyboard { constructor(menu) { this.menu = menu; this.items = [...menu.querySelectorAll('[role="menuitem"]')]; this.currentIndex = 0; menu.addEventListener('keydown', this.handleKeydown.bind(this)); } handleKeydown(e) { switch(e.key) { case 'ArrowRight': case 'ArrowDown': e.preventDefault(); this.navigate(1); break; case 'ArrowLeft': case 'ArrowUp': e.preventDefault(); this.navigate(-1); break; case 'Enter': case ' ': e.preventDefault(); this.activate(); break; case 'Escape': e.preventDefault(); this.close(); // Return focus to trigger element — critical! this.triggerElement.focus(); break; case 'Tab': // Trap focus within open menu e.preventDefault(); this.navigate(e.shiftKey ? -1 : 1); break; } } navigate(direction) { this.currentIndex = (this.currentIndex + direction + this.items.length) % this.items.length; this.items[this.currentIndex].focus(); } } // Test with axe-core // npm install axe-core import axe from 'axe-core'; axe.run(document.querySelector('.radial-menu')).then(results => { if (results.violations.length > 0) { console.error('Accessibility violations:', results.violations); } }); ``` - [ ] **Focus is trapped inside open radial menu and restored on close** *Why:* Without focus trap, Tab key exits the menu while it's visually open, creating a disconnect between visual state and keyboard position. Users lose orientation. ```javascript // Focus trap utility function createFocusTrap(container) { const focusableSelectors = [ 'a[href]', 'button:not([disabled])', 'input:not([disabled])', 'select:not([disabled])', 'textarea:not([disabled])', '[tabindex]:not([tabindex="-1"])', '[role="menuitem"]' ].join(', '); const getFocusable = () => [...container.querySelectorAll(focusableSelectors)]; function handleTab(e) { if (e.key !== 'Tab') return; const focusable = getFocusable(); const first = focusable[0]; const last = focusable[focusable.length - 1]; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } } return { activate: () => { container.addEventListener('keydown', handleTab); getFocusable()[0]?.focus(); }, deactivate: () => container.removeEventListener('keydown', handleTab) }; } // Verify focus trap is working // Manual test: Tab through all menu items — should cycle, not escape menu ``` - [ ] **Visible focus indicators on all interactive elements — not just `:focus` but `:focus-visible`** *Why:* Experimental designs often remove focus rings for aesthetics (`outline: none`). This makes keyboard navigation invisible — users can't tell where they are. ```css /* ❌ NEVER DO THIS */ * { outline: none; } *:focus { outline: none; } /* ✅ CORRECT — enhance, don't remove */ .radial-item:focus-visible { outline: 3px solid var(--color-focus, #0066ff); outline-offset: 4px; /* Custom focus for radial items */ box-shadow: 0 0 0 3px rgba(0, 102, 255, 0.3); } /* For radial geometry, consider custom focus indicator */ .radial-item:focus-visible::after { content: ''; position: absolute; inset: -4px; border-radius: 50%; border: 3px solid var(--color-focus); animation: pulse-focus 1s infinite; } ``` ```bash # Automated check for outline removal grep -r "outline.*none\|outline:.*0" ./src --include="*.css" --include="*.scss" \ --include="*.ts" --include="*.tsx" | \ grep -v "focus-visible\|focus-within\|:focus" | \ grep -v "\/\/" # Should return 0 results ``` --- ### 2.2 ARIA Roles & Labels - [ ] **Radial menu has correct ARIA role structure: `menu`, `menuitem`, `menubar` as appropriate** *Why:* Screen readers need semantic context to announce navigation. Without roles, a div-based radial menu is announced as generic content — users have no idea it's a navigation menu. ```html ``` - [ ] **Live region announces navigation state changes to screen readers** *Why:* When nonlinear navigation loads content via AJAX/SPA routing, screen readers don't know page changed. Users think nothing happened. ```html ``` - [ ] **Test with actual screen readers: VoiceOver (macOS/iOS) and NVDA (Windows)** *Why:* axe-core catches structural issues but not experiential ones. Only a real screen reader reveals whether the radial menu makes sense when heard, not seen. ```bash # VoiceOver macOS test commands # Enable: Cmd + F5 # Navigate to menu trigger: Tab # Expected announcement: "Open navigation menu, button, collapsed" # Open menu: Space/Enter # Expected: "Main navigation, menu" # Navigate items: Arrow keys # Expected: "About, menu item, 1 of 5" # NVDA Windows (free) # Download: https://www.nvaccess.org/download/ # Enable: Ctrl + Alt + N # Forms mode: F (navigate interactive elements) # Same test as above # Automated with jest-axe # npm install jest-axe import { axe, toHaveNoViolations } from 'jest-axe'; expect.extend(toHaveNoViolations); test('radial menu has no accessibility violations', async () => { render(); const results = await axe(document.body); expect(results).toHaveNoViolations(); }); ``` --- ### 2.3 `prefers-reduced-motion` - [ ] **ALL animations disabled or replaced with instant transitions when `prefers-reduced-motion: reduce`** *Why:* Users with vestibular disorders, epilepsy, or motion sensitivity can experience nausea, migraines, or seizures from animated radial menus. This is a medical necessity, not a preference. ```css /* Base animations for radial menu */ .radial-item { transition: transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1), opacity 0.3s ease; } .radial-menu { animation: menu-bloom 0.5s ease forwards; } /* Locomotive scroll-based parallax */ [data-scroll-speed] { transition: transform 0.1s linear; } /* OVERRIDE — instant transitions for reduced motion */ @media (prefers-reduced-motion: reduce) { /* Remove ALL transitions */ *, *::before, *::after { animation-duration: 0.01ms !important; animation-iteration-count: 1 !important; transition-duration: 0.01ms !important; scroll-behavior: auto !important; } /* Specifically disable Locomotive Scroll smooth scrolling */ [data-scroll-container] { overflow: auto !important; transform: none !important; } /* Radial menu: appear instantly */ .radial-menu { animation: none; } .radial-item { transition: none; /* Items appear at final position immediately */ } /* Disable parallax */ [data-scroll-speed] { transform: none !important; } } ``` ```javascript // JS-side reduced motion check — for JS-driven animations const REDUCED_MOTION = window.matchMedia('(prefers-reduced-motion: reduce)'); function getAnimationConfig() { if (REDUCED_MOTION.matches) { return { duration: 0, delay: 0, easing: 'linear', // Still provide visual feedback, just instant }; } return { duration: 400, delay: (index) => index * 50, easing: 'cubic-bezier(0.34, 1.56, 0.64, 1)', }; } // Listen for changes (user can change setting while page is open) REDUCED_MOTION.addEventListener('change', () => { // Re-initialize animations with new config initAnimations(getAnimationConfig()); }); // Verify in browser console console.log('Reduced motion:', window.matchMedia('(prefers-reduced-motion: reduce)').matches); ``` - [ ] **Locomotive Scroll disabled entirely under `prefers-reduced-motion`** *Why:* Locomotive's smooth scrolling adds artificial lag between user input and page movement — this exact sensation triggers motion sickness in sensitive users. ```javascript // Conditionally initialize Locomotive function initScroll() { const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches; if (prefersReduced) { // Native scroll — no JS overhead document.documentElement.style.scrollBehavior = 'auto'; console.log('â„šī¸ Smooth scroll disabled: prefers-reduced-motion'); return null; } const scroll = new LocomotiveScroll({ el: document.querySelector('[data-scroll-container]'), smooth: true, lerp: 0.1, }); return scroll; } // Test: Enable reduced motion in macOS System Preferences > Accessibility // Then verify: No scroll smoothing, no parallax, no menu bloom animation ``` --- ### 2.4 Screen Reader Compatibility - [ ] **Nonlinear page transitions don't break screen reader document order** *Why:* SPA transitions that reorder DOM elements confuse screen readers — the virtual cursor position becomes invalid. Users get lost or hear repeated content. ```javascript // After any SPA navigation/transition function afterPageTransition(newPageElement) { // 1. Set focus to new page heading const heading = newPageElement.querySelector('h1, [role="heading"]'); if (heading) { heading.tabIndex = -1; heading.focus(); // Remove tabindex after focus (don't pollute tab order) heading.addEventListener('blur', () => heading.removeAttribute('tabindex'), { once: true }); } // 2. Update document title document.title = newPageElement.dataset.pageTitle || document.title; // 3. Announce to live region announceNavigation(document.title); // 4. Ensure new content is in natural DOM order // Check: content shouldn't be after footer in DOM but visually before it validateDOMOrder(newPageElement); } function validateDOMOrder(container) { const main = container.querySelector('main, [role="main"]'); const footer = document.querySelector('footer, [role="contentinfo"]'); if (main && footer) { const mainPos = main.compareDocumentPosition(footer); const isMainBeforeFooter = mainPos & Node.DOCUMENT_POSITION_FOLLOWING; console.assert(isMainBeforeFooter, 'âš ī¸ DOM order issue: main content appears after footer in DOM'); } } ``` --- ## Section 3 — Mobile/Touch Audit > Radial menus on mobile are high-risk: thumb reach, touch accuracy, and OS-level conflicts (iOS bounce, Android back gesture) can break the experience entirely. --- ### 3.1 Touch Target Sizing - [ ] **Every radial menu item has minimum 44×44px touch target (Apple HIG) / 48×48dp (Material Design)** *Why:* The average adult fingertip is 8-10mm. At 96dpi, 44px ≈ 11.6mm — the minimum for reliable touch accuracy. Smaller targets cause mistaps, frustration, and users abandoning navigation. ```css /* Radial menu item — visual size may differ from touch target */ .radial-item { /* Visual presentation */ width: 32px; height: 32px; border-radius: 50%; /* EXTEND touch target without changing layout */ position: relative; } .radial-item::before { content: ''; position: absolute; /* Extend touch area to 44px minimum on all sides */ inset: calc(-1 * max(0px, (44px - 100%) / 2)); /* Or explicit: */ top: -6px; right: -6px; bottom: -6px; left: -6px; /* Debug: background: rgba(255,0,0,0.2); */ } /* Alternative: padding approach */ .radial-item-link { display: flex; align-items: center; justify-content: center; min-width: 44px; min-height: 44px; padding: 6px; } ``` ```javascript // Automated touch target audit function auditTouchTargets(selector = '[role="menuitem"], a, button') { const MIN_SIZE = 44; const elements = document.querySelectorAll(selector); const violations = []; elements.forEach(el => { const rect = el.getBoundingClientRect(); if (rect.width < MIN_SIZE || rect.height < MIN_SIZE) { violations.push({ element: el, width: Math.round(rect.width), height: Math.round(rect.height), text: el.textContent.trim().slice(0, 30) }); } }); if (violations.length > 0) { console.table(violations.map(v => ({ text: v.text, width: v.width, height: v.height, issue: `${v.width < MIN_SIZE ? `width ${v.width}px` : ''} ${v.height < MIN_SIZE ? `height ${v.height}px` : ''}`.trim() }))); console.error(`❌ ${violations.length} touch target violations found`); } else { console.log(`✅ All ${elements.length} touch targets meet 44px minimum`); } return violations; } // Run after menu opens document.querySelector('.radial-trigger').addEventListener('click', () => { setTimeout(() => auditTouchTargets('.radial-item'), 100); }); ``` - [ ] **Radial menu center trigger is reachable in thumb zone on mobile** *Why:* For a bottom-center radial trigger on 6-inch phone, bottom 40% is the comfortable thumb zone. A top-left hamburger forces uncomfortable grip shifts. ```javascript // Check trigger position relative to thumb zone function checkThumbZone() { const trigger = document.querySelector('.radial-trigger'); const rect = trigger.getBoundingClientRect(); const viewportHeight = window.innerHeight; // Thumb zone: bottom 50% of screen (rough heuristic) const thumbZoneTop = viewportHeight * 0.5; const isInThumbZone = rect.top >= thumbZoneTop; console.log({ triggerBottom: Math.round(rect.bottom), viewportHeight: Math.round(viewportHeight), fromBottom: Math.round(viewportHeight - rect.bottom), inThumbZone: isInThumbZone }); if (!isInThumbZone) { console.warn('âš ī¸ Radial trigger is in awkward reach zone on mobile'); } } // Run on mobile viewport if (window.innerWidth < 768) checkThumbZone(); ``` --- ### 3.2 JavaScript Fallback for Non-Touch Scenarios - [ ] **Navigation works without JS — core links accessible in `