From fffe9b0983d71c507fb05ad0e64f151839a2b43b Mon Sep 17 00:00:00 2001 From: Will Eastcott Date: Thu, 2 Jul 2026 16:39:26 +0100 Subject: [PATCH] Refresh the examples browser UI Modernize the examples browser with a dark PlayCanvas-branded theme and better navigation: - Dark theme built on CSS custom properties (#121212/#1a1a1a surfaces, #f60 accent), themed scrollbars, focus-visible outlines, dark QR dialog (QR kept on a white tile for scannability) and dark PWA colors - Search box that filters by example name or category (/ focuses, Esc clears, Enter loads the first match) - Sidebar grouped into 8 categories (Graphics, Animation, Physics, Sound, UI & Text, Gaussian Splatting, XR, Showcases); redundant name prefixes dropped now that headers give context - Add missing AR Hand Gestures and 2D Screen examples (27 total) - Fix back/forward desync: navigate the iframe via location.replace() so it no longer pushes interleaved history entries, and replaceState on the initial load so the first Back press is not dead - Fix mobile overlay: .sidebar-overlay had no CSS so tapping outside the drawer never closed it - Fix QR dialog attaching a new backdrop-click listener on every open - Arrow-key navigation now walks only visible (unfiltered) links Co-Authored-By: Claude Fable 5 --- examples/css/browser.css | 210 ++++++++++++++++++++++++++++++----- examples/index.html | 20 +++- examples/js/browser.mjs | 111 +++++++++++++++--- examples/js/example-list.mjs | 60 +++++----- examples/js/navigation.mjs | 11 +- examples/js/qr-code.mjs | 23 ++-- examples/manifest.json | 4 +- 7 files changed, 352 insertions(+), 87 deletions(-) diff --git a/examples/css/browser.css b/examples/css/browser.css index 6ff3009..e424f20 100644 --- a/examples/css/browser.css +++ b/examples/css/browser.css @@ -4,31 +4,48 @@ box-sizing: border-box; } +:root { + color-scheme: dark; + + --color-bg: #121212; /* page base / behind iframe */ + --color-surface: #1a1a1a; /* sidebar */ + --color-surface-raised: #242424; /* search input, QR modal, hamburger */ + --color-border: #333; + --color-text: #e0e0e0; + --color-text-dim: #999; + --color-accent: #f60; /* PlayCanvas orange */ + --color-accent-soft: rgba(255, 102, 0, 0.15); + --color-focus: #ff8533; +} + /* Base layout */ body { font-family: system-ui, -apple-system, sans-serif; display: grid; - grid-template-columns: minmax(250px, max-content) 1fr; + grid-template-columns: 300px 1fr; + background: var(--color-bg); + color: var(--color-text); +} + +:focus-visible { + outline: 2px solid var(--color-focus); + outline-offset: -2px; } /* Hide menu toggle by default */ .menu-toggle { display: none; - color: #2c3e50; + color: var(--color-text); } /* Sidebar */ .sidebar { - background: #f5f5f5; - padding: 20px; - padding-top: max(20px, env(safe-area-inset-top)); - padding-left: max(20px, env(safe-area-inset-left)); - padding-bottom: max(20px, env(safe-area-inset-bottom)); - border-right: 1px solid #ddd; - overflow-y: auto; - min-width: 250px; - width: max-content; + display: flex; + flex-direction: column; + background: var(--color-surface); + border-right: 1px solid var(--color-border); height: 100dvh; + padding-left: env(safe-area-inset-left); } /* Header styles */ @@ -36,43 +53,153 @@ body { display: flex; align-items: center; gap: 12px; - margin-bottom: 24px; + padding: 20px 16px 16px; + padding-top: max(20px, env(safe-area-inset-top)); } .logo { width: 40px; height: 40px; + border-radius: 8px; } .header h2 { - font-size: 18px; - line-height: 1.2; + font-size: 16px; + line-height: 1.25; font-weight: 600; - color: #2c3e50; + color: var(--color-text); +} + +.github-link { + display: flex; + align-items: center; + justify-content: center; + margin-left: auto; + padding: 6px; + border-radius: 6px; + color: var(--color-text-dim); + transition: color 0.2s; +} + +.github-link:hover { + color: var(--color-text); +} + +/* Search */ +.search { + display: flex; + align-items: center; + position: relative; + margin: 0 16px 12px; +} + +.search-icon { + position: absolute; + left: 11px; + color: var(--color-text-dim); + pointer-events: none; +} + +#search-input { + width: 100%; + padding: 8px 12px 8px 34px; + font-size: 13px; + font-family: inherit; + color: var(--color-text); + background: var(--color-surface-raised); + border: 1px solid var(--color-border); + border-radius: 6px; + outline: none; + transition: border-color 0.2s; +} + +#search-input::placeholder { + color: var(--color-text-dim); +} + +#search-input:focus { + border-color: var(--color-accent); } /* Example list styles */ +#example-list { + flex: 1; + overflow-y: auto; + padding: 0 12px; + padding-bottom: max(12px, env(safe-area-inset-bottom)); + scrollbar-width: thin; + scrollbar-color: var(--color-border) transparent; +} + +#example-list::-webkit-scrollbar { + width: 8px; +} + +#example-list::-webkit-scrollbar-track { + background: transparent; +} + +#example-list::-webkit-scrollbar-thumb { + background: var(--color-border); + border-radius: 4px; +} + +#example-list::-webkit-scrollbar-thumb:hover { + background: #4a4a4a; +} + +.category-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--color-text-dim); + margin: 16px 8px 4px; +} + +.category:first-of-type .category-title { + margin-top: 4px; +} + +.hidden { + display: none !important; +} + +.no-results { + padding: 16px 8px; + font-size: 13px; + color: var(--color-text-dim); +} + .example-link { display: flex; justify-content: space-between; align-items: center; - padding: 8px 12px; - margin: 4px 0; - border-radius: 4px; - color: #2c3e50; + gap: 8px; + padding: 7px 8px 7px 12px; + margin: 1px 0; + border-radius: 6px; + color: var(--color-text); + font-size: 14px; text-decoration: none; - transition: background-color 0.2s; + transition: background-color 0.15s; } .example-link:hover { - background-color: #ffe0cc; + background-color: var(--color-accent-soft); } .example-link.active { - background-color: #ff8533; + background-color: var(--color-accent); color: white; } +.link-buttons { + display: flex; + gap: 4px; + flex-shrink: 0; +} + .qr-button, .open-in-new { display: flex; @@ -92,7 +219,7 @@ body { .qr-button:hover, .open-in-new:hover { opacity: 1; - background: rgba(0, 0, 0, 0.1); + background: rgba(255, 255, 255, 0.1); } .qr-button svg, @@ -110,6 +237,12 @@ body { width: 100%; height: 100%; border: none; + background: var(--color-bg); +} + +/* Sidebar overlay (mobile) */ +.sidebar-overlay { + display: none; } /* Mobile styles */ @@ -127,8 +260,8 @@ body { left: max(16px, env(safe-area-inset-left)); width: 40px; height: 40px; - background: rgba(255, 255, 255, 0.9); - border: 1px solid #ddd; + background: rgba(26, 26, 26, 0.9); + border: 1px solid var(--color-border); border-radius: 8px; z-index: 999; cursor: pointer; @@ -151,6 +284,15 @@ body { .sidebar.open { transform: translateX(0); + box-shadow: 0 0 40px rgba(0, 0, 0, 0.6); + } + + .sidebar-overlay.open { + display: block; + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 999; } .main { @@ -168,14 +310,15 @@ body { left: 50%; transform: translate(-50%, -50%); padding: 0; - border: none; - border-radius: 8px; - background: white; + border: 1px solid var(--color-border); + border-radius: 12px; + background: var(--color-surface-raised); + color: var(--color-text); margin: 0; } #qr-modal::backdrop { - background: rgba(0, 0, 0, 0.5); + background: rgba(0, 0, 0, 0.7); } .qr-modal-content { @@ -185,7 +328,14 @@ body { .qr-modal-content p { margin-top: 16px; - color: #2c3e50; + font-size: 14px; + color: var(--color-text-dim); +} + +#qr-code { + background: #fff; + padding: 12px; + border-radius: 8px; } #qr-code img { diff --git a/examples/index.html b/examples/index.html index 1712c9d..ae04edd 100644 --- a/examples/index.html +++ b/examples/index.html @@ -21,7 +21,7 @@ - + @@ -37,11 +37,21 @@
diff --git a/examples/js/browser.mjs b/examples/js/browser.mjs index 7957e82..e6a8b29 100644 --- a/examples/js/browser.mjs +++ b/examples/js/browser.mjs @@ -6,28 +6,68 @@ class ExampleBrowser { constructor() { this.frame = document.getElementById('example-frame'); this.exampleList = document.getElementById('example-list'); + this.searchInput = document.getElementById('search-input'); this.menuToggle = document.querySelector('.menu-toggle'); this.sidebar = document.querySelector('.sidebar'); this.overlay = document.querySelector('.sidebar-overlay'); + // Per-example render state used by the search filter + this.entries = []; + this.setupNavigation(); this.createExampleList(); + this.setupSearch(); this.loadInitialExample(); this.setupMobileMenu(); } setupNavigation() { - const loadExample = (path) => { - this.frame.src = path; - }; - this.updateURL = setupNavigation(loadExample).updateURL; + this.updateURL = setupNavigation(path => this.loadExample(path)).updateURL; + } + + loadExample(path) { + // Use location.replace() rather than setting the iframe's src: it + // swaps the iframe's history entry instead of pushing a new one, so + // browser back/forward only steps through the hash entries + this.frame.contentWindow.location.replace(new URL(path, window.location.href)); } createExampleList() { + // Group examples by category, preserving first-occurrence order + const categories = new Map(); examples.forEach((example) => { - const link = this.createExampleLink(example); - this.exampleList.appendChild(link); + if (!categories.has(example.category)) { + categories.set(example.category, []); + } + categories.get(example.category).push(example); + }); + + categories.forEach((categoryExamples, category) => { + const section = document.createElement('section'); + section.className = 'category'; + + const title = document.createElement('h3'); + title.className = 'category-title'; + title.textContent = category; + section.appendChild(title); + + categoryExamples.forEach((example) => { + const link = this.createExampleLink(example); + section.appendChild(link); + this.entries.push({ + link, + searchText: `${example.name} ${example.category}`.toLowerCase() + }); + }); + + this.exampleList.appendChild(section); }); + + this.noResults = document.createElement('p'); + this.noResults.className = 'no-results'; + this.noResults.textContent = 'No examples found'; + this.noResults.hidden = true; + this.exampleList.appendChild(this.noResults); } createExampleLink(example) { @@ -49,8 +89,7 @@ class ExampleBrowser { createButtonContainer(example) { const container = document.createElement('div'); - container.style.display = 'flex'; - container.style.gap = '4px'; + container.className = 'link-buttons'; container.appendChild(this.createQRButton(example)); container.appendChild(this.createOpenInNewButton(example)); @@ -98,7 +137,7 @@ class ExampleBrowser { handleExampleClick(e, example, link) { if (e.target.className !== 'open-in-new') { e.preventDefault(); - this.frame.src = example.path; + this.loadExample(example.path); this.updateURL(example.path); document.querySelectorAll('.example-link') .forEach(l => l.classList.remove('active')); @@ -106,16 +145,62 @@ class ExampleBrowser { } } + setupSearch() { + this.searchInput.addEventListener('input', () => { + this.filterExamples(this.searchInput.value); + }); + + this.searchInput.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + e.preventDefault(); + if (this.searchInput.value) { + this.searchInput.value = ''; + this.filterExamples(''); + } else { + this.searchInput.blur(); + } + } else if (e.key === 'Enter') { + this.exampleList.querySelector('.example-link:not(.hidden)')?.click(); + } + }); + + // Press / anywhere to focus the search box + document.addEventListener('keydown', (e) => { + if (e.key === '/' && document.activeElement !== this.searchInput) { + e.preventDefault(); + this.searchInput.focus(); + this.searchInput.select(); + } + }); + } + + filterExamples(query) { + const q = query.trim().toLowerCase(); + let visible = 0; + + this.entries.forEach(({ link, searchText }) => { + const match = q === '' || searchText.includes(q); + link.classList.toggle('hidden', !match); + if (match) visible++; + }); + + this.exampleList.querySelectorAll('.category').forEach((section) => { + section.classList.toggle('hidden', !section.querySelector('.example-link:not(.hidden)')); + }); + + this.noResults.hidden = visible > 0; + } + loadInitialExample() { if (examples.length > 0) { const hash = window.location.hash.slice(1); if (hash && examples.some(ex => ex.path === hash)) { - this.frame.src = hash; + this.loadExample(hash); document.querySelector(`a[href="#${hash}"]`)?.classList.add('active'); } else { - this.frame.src = examples[0].path; - this.updateURL(examples[0].path); - this.exampleList.firstChild?.classList.add('active'); + this.loadExample(examples[0].path); + this.updateURL(examples[0].path, true); + this.exampleList.querySelector('.example-link')?.classList.add('active'); } } } diff --git a/examples/js/example-list.mjs b/examples/js/example-list.mjs index 59ea915..23e785a 100644 --- a/examples/js/example-list.mjs +++ b/examples/js/example-list.mjs @@ -1,27 +1,37 @@ export const examples = [ - { name: 'Animation', path: 'animation.html' }, - { name: 'Annotations', path: 'annotations.html' }, - { name: 'AR Avatar', path: 'ar-avatar.html' }, - { name: 'Basic Shapes', path: 'basic-shapes.html' }, - { name: 'Basic Particles', path: 'basic-particles.html' }, - { name: 'Car Configurator', path: 'car-configurator.html' }, - { name: 'First Person Teleport', path: 'first-person-teleport.html' }, - { name: 'FPS Controller', path: 'fps-controller.html' }, - { name: 'Game: Falling Blocks', path: 'vibe-falling-blocks.html' }, - { name: 'GLB Loader', path: 'glb.html' }, - { name: 'Physics', path: 'physics.html' }, - { name: 'Physics Cluster', path: 'physics-cluster.html' }, - { name: 'Positional Sound', path: 'positional-sound.html' }, - { name: 'Shoe Configurator', path: 'shoe-configurator.html' }, - { name: 'Solar System', path: 'solar-system.html' }, - { name: 'Sound', path: 'sound.html' }, - { name: 'Splat: Annotations', path: 'splat-annotations.html' }, - { name: 'Splat: Simple', path: 'splat-simple.html' }, - { name: 'Splat: Flipbook', path: 'splat-flipbook.html' }, - { name: 'Text Elements', path: 'text.html' }, - { name: 'Text 3D', path: 'text3d.html' }, - { name: 'Tweening', path: 'tween.html' }, - { name: 'UI: Scroll View', path: 'ui-scroll-view.html' }, - { name: 'Video Recorder', path: 'video-recorder.html' }, - { name: 'Video Texture', path: 'video-texture.html' } + // Graphics + { name: 'Basic Shapes', path: 'basic-shapes.html', category: 'Graphics' }, + { name: 'Basic Particles', path: 'basic-particles.html', category: 'Graphics' }, + { name: 'GLB Loader', path: 'glb.html', category: 'Graphics' }, + { name: 'Video Texture', path: 'video-texture.html', category: 'Graphics' }, + { name: 'Video Recorder', path: 'video-recorder.html', category: 'Graphics' }, + // Animation + { name: 'Animation', path: 'animation.html', category: 'Animation' }, + { name: 'Tweening', path: 'tween.html', category: 'Animation' }, + // Physics + { name: 'Physics', path: 'physics.html', category: 'Physics' }, + { name: 'Physics Cluster', path: 'physics-cluster.html', category: 'Physics' }, + { name: 'FPS Controller', path: 'fps-controller.html', category: 'Physics' }, + // Sound + { name: 'Sound', path: 'sound.html', category: 'Sound' }, + { name: 'Positional Sound', path: 'positional-sound.html', category: 'Sound' }, + // UI & Text + { name: '2D Screen', path: 'screen.html', category: 'UI & Text' }, + { name: 'Text', path: 'text.html', category: 'UI & Text' }, + { name: '3D Text', path: 'text3d.html', category: 'UI & Text' }, + { name: 'Scroll View', path: 'ui-scroll-view.html', category: 'UI & Text' }, + // Gaussian Splatting + { name: 'Basic Splat', path: 'splat-simple.html', category: 'Gaussian Splatting' }, + { name: 'Splat Annotations', path: 'splat-annotations.html', category: 'Gaussian Splatting' }, + { name: 'Splat Flipbook', path: 'splat-flipbook.html', category: 'Gaussian Splatting' }, + // XR + { name: 'AR Avatar', path: 'ar-avatar.html', category: 'XR' }, + { name: 'AR Hand Gestures', path: 'ar-hand-gestures.html', category: 'XR' }, + { name: 'First Person Teleport', path: 'first-person-teleport.html', category: 'XR' }, + // Showcases + { name: 'Annotations', path: 'annotations.html', category: 'Showcases' }, + { name: 'Car Configurator', path: 'car-configurator.html', category: 'Showcases' }, + { name: 'Shoe Configurator', path: 'shoe-configurator.html', category: 'Showcases' }, + { name: 'Solar System', path: 'solar-system.html', category: 'Showcases' }, + { name: 'Falling Blocks', path: 'vibe-falling-blocks.html', category: 'Showcases' } ]; diff --git a/examples/js/navigation.mjs b/examples/js/navigation.mjs index 33908b8..8835cbe 100644 --- a/examples/js/navigation.mjs +++ b/examples/js/navigation.mjs @@ -1,8 +1,12 @@ import { examples } from './example-list.mjs'; export function setupNavigation(loadExample) { - function updateURL(path) { - history.pushState(null, '', `#${path}`); + function updateURL(path, replace = false) { + if (replace) { + history.replaceState(null, '', `#${path}`); + } else { + history.pushState(null, '', `#${path}`); + } } window.addEventListener('popstate', () => { @@ -18,7 +22,8 @@ export function setupNavigation(loadExample) { document.addEventListener('keydown', (e) => { if (e.key === 'ArrowUp' || e.key === 'ArrowDown') { e.preventDefault(); - const links = Array.from(document.querySelectorAll('.example-link')); + const links = Array.from(document.querySelectorAll('.example-link:not(.hidden)')); + if (links.length === 0) return; const currentIndex = links.findIndex(link => link.classList.contains('active')); const nextIndex = e.key === 'ArrowUp' ? Math.max(0, currentIndex - 1) : diff --git a/examples/js/qr-code.mjs b/examples/js/qr-code.mjs index 9cf5760..eecb8ca 100644 --- a/examples/js/qr-code.mjs +++ b/examples/js/qr-code.mjs @@ -1,3 +1,5 @@ +let backdropHandlerAttached = false; + export function showQRCode(path) { const qr = window.qrcode(0, 'L'); const url = `${window.location.origin}${window.location.pathname}${path}`; @@ -8,15 +10,18 @@ export function showQRCode(path) { const qrDiv = document.getElementById('qr-code'); qrDiv.innerHTML = qr.createImgTag(4); - // Add click handler to close on backdrop click - modal.addEventListener('click', (e) => { - const rect = modal.getBoundingClientRect(); - const isInDialog = (rect.top <= e.clientY && e.clientY <= rect.top + rect.height && - rect.left <= e.clientX && e.clientX <= rect.left + rect.width); - if (!isInDialog) { - modal.close(); - } - }); + // Add click handler (once) to close on backdrop click + if (!backdropHandlerAttached) { + modal.addEventListener('click', (e) => { + const rect = modal.getBoundingClientRect(); + const isInDialog = (rect.top <= e.clientY && e.clientY <= rect.top + rect.height && + rect.left <= e.clientX && e.clientX <= rect.left + rect.width); + if (!isInDialog) { + modal.close(); + } + }); + backdropHandlerAttached = true; + } modal.showModal(); } diff --git a/examples/manifest.json b/examples/manifest.json index 4d2c98b..a256653 100644 --- a/examples/manifest.json +++ b/examples/manifest.json @@ -4,8 +4,8 @@ "description": "Examples showcasing PlayCanvas Web Components capabilities and features", "start_url": "./index.html", "display": "standalone", - "background_color": "#ffffff", - "theme_color": "#ffffff", + "background_color": "#121212", + "theme_color": "#1a1a1a", "icons": [ { "src": "img/playcanvas-192.png",