1/* Vue 3 owns navigation, project filters, motion preference and the portrait gallery. */ 2(() => { 3 'use strict'; 4 if (!window.Vue) return; // The semantic document and links remain useful if JS is unavailable. 5 const { createApp, ref, computed, watch, nextTick, onMounted, onBeforeUnmount } = Vue; 6 const skillCatalog = [...document.querySelectorAll('.skill-card')].map(card => ({ 7 id: card.dataset.skillId, 8 category: card.dataset.skillCategory, 9 search: `${card.dataset.skillId} ${card.textContent}`.toLowerCase(), 10 })); 11 const portraits = [ 12 { id: 'studio', title: 'The studio', src: './assets/jeremy-studio.webp', alt: 'Full-body AI editorial portrait of Jeremy Schoemaker in a dark studio' }, 13 { id: 'workspace', title: 'The workshop', src: './assets/jeremy-workspace.webp', alt: 'Full-body AI editorial portrait of Jeremy Schoemaker in a developer workspace' }, 14 { id: 'speaking', title: 'On stage', src: './assets/jeremy-conference.webp', alt: 'Full-body AI editorial portrait of Jeremy Schoemaker with a microphone on a stage' }, 15 { id: 'city', title: 'The city', src: './assets/jeremy-city.webp', alt: 'Full-body AI editorial portrait of Jeremy Schoemaker in the city' }, 16 ]; 17 const app = createApp({ 18 setup() { 19 const media = window.matchMedia('(prefers-reduced-motion: reduce)'); 20 const menuOpen = ref(false), activeSection = ref('home'), scrollProgress = ref(0); 21 const motionEnabled = ref(!media.matches), filter = ref('all'), filterStatus = ref(''); 22 const skillQuery = ref(''), skillCategory = ref('all'); 23 const visibleSkills = computed(() => { 24 const terms = skillQuery.value.trim().toLowerCase().split(/\s+/).filter(Boolean); 25 return skillCatalog.filter(skill => (skillCategory.value === 'all' || skill.category === skillCategory.value) && terms.every(term => skill.search.includes(term))); 26 }); 27 const skillStatus = computed(() => `${visibleSkills.value.length} of ${skillCatalog.length} skills`); 28 const showSkill = id => visibleSkills.value.some(skill => skill.id === id); 29 function clearSkillFilters() { skillQuery.value = ''; skillCategory.value = 'all'; } 30 watch([skillQuery, skillCategory], () => nextTick(() => document.querySelectorAll('.skill-card').forEach(card => card.classList.add('is-visible')))); 31 const selectedPortraitId = ref('city'), favorites = ref([]), comparison = ref([]); 32 const galleryMode = ref('preview'), galleryStatus = ref(''), portraitDialog = ref(null); 33 const selectedPortrait = computed(() => portraits.find(photo => photo.id === selectedPortraitId.value) || portraits[3]); 34 const dialogPortraits = computed(() => galleryMode.value === 'compare' ? comparison.value.map(id => portraits.find(photo => photo.id === id)).filter(Boolean) : [selectedPortrait.value]); 35 let world, flare, revealObserver, scrollFrame, previousFocus, acquisitionChart, chartObserver, courseChart, courseChartObserver; 36 const listeners = new AbortController(); 37 const signal = listeners.signal; 38 39 const showProject = categories => filter.value === 'all' || categories.split(' ').includes(filter.value); 40 function setFilter(value) { 41 filter.value = value; 42 const count = [...document.querySelectorAll('.project-card')].filter(card => showProject(card.dataset.projectCategories || '')).length; 43 const category = { all: '', ai: 'AI and agent ', platform: 'platform ', games: 'game ' }[value] || ''; 44 filterStatus.value = `Showing ${count} ${category}project${count === 1 ? '' : 's'}.`; 45 nextTick(() => document.querySelectorAll('.project-card').forEach(card => card.classList.add('is-visible'))); 46 } 47 function toggleMenu() { 48 menuOpen.value = !menuOpen.value; 49 if (menuOpen.value) nextTick(() => document.querySelector('#mobile-nav a')?.focus()); 50 } 51 function closeMenu(returnFocus = false) { 52 menuOpen.value = false; 53 if (returnFocus) nextTick(() => document.querySelector('.menu-toggle')?.focus()); 54 } 55 function handleMobileNav(event) { 56 const link = event.target.closest('a'); 57 if (!link) return; 58 closeMenu(); 59 nextTick(() => { 60 const heading = document.querySelector(link.getAttribute('href'))?.querySelector('h1, h2'); 61 if (heading) { heading.setAttribute('tabindex', '-1'); heading.focus({ preventScroll: true }); } 62 }); 63 } 64 function toggleMotion() { motionEnabled.value = !motionEnabled.value; } 65 watch(menuOpen, open => {
66 document.body.classList.toggle('menu-open', open); 67 const main = document.querySelector('main'), footer = document.querySelector('footer'); 68 if (main) main.inert = open; 69 if (footer) footer.inert = open; 70 }); 71 watch(motionEnabled, enabled => { 72 document.documentElement.classList.toggle('motion-paused', !enabled); 73 world?.setMotion(enabled); 74 flare?.setMotion(enabled); 75 if (acquisitionChart) acquisitionChart.setOption({ animation: enabled }); 76 try { sessionStorage.setItem('shoemoney-motion', enabled ? 'on' : 'off'); } catch { /* storage is optional */ } 77 }); 78 function toggleFavorite(id) { 79 favorites.value = favorites.value.includes(id) ? favorites.value.filter(value => value !== id) : [...favorites.value, id]; 80 galleryStatus.value = favorites.value.includes(id) ? 'Portrait saved to your favorites on this device.' : 'Portrait removed from your favorites.'; 81 try { localStorage.setItem('shoemoney-portrait-favorites', JSON.stringify(favorites.value)); } catch { galleryStatus.value = 'Favorite selected for this visit.'; } 82 } 83 function toggleCompare(id) { 84 if (comparison.value.includes(id)) comparison.value = comparison.value.filter(value => value !== id); 85 else if (comparison.value.length < 2) comparison.value = [...comparison.value, id]; 86 else { galleryStatus.value = 'Two portraits are selected. Remove one before adding another.'; return; } 87 galleryStatus.value = comparison.value.length === 2 ? 'Two portraits selected. Open Compare to see them side by side.' : 'Choose another portrait to compare.'; 88 } 89 async function openGallery(mode) { 90 if (mode === 'compare' && comparison.value.length !== 2) return; 91 galleryMode.value = mode; 92 previousFocus = document.activeElement; 93 await nextTick(); 94 portraitDialog.value?.showModal(); 95 document.body.classList.add('dialog-open'); 96 } 97 function closeGallery() { portraitDialog.value?.close(); } 98 function afterDialogClose() { document.body.classList.remove('dialog-open'); previousFocus?.focus?.(); } 99 function closeOnBackdrop(event) { if (event.target === portraitDialog.value) closeGallery(); } 100 function updateScroll() { 101 scrollFrame = null; 102 const max = document.documentElement.scrollHeight - innerHeight; 103 scrollProgress.value = max > 0 ? Math.min(100, scrollY / max * 100) : 0; 104 const sections = [...document.querySelectorAll('main .section-anchor')]; 105 let current = 'home'; 106 for (const section of sections) if (section.getBoundingClientRect().top <= 150) current = section.id; 107 if (innerHeight + scrollY >= document.documentElement.scrollHeight - 10) current = 'contact'; 108 activeSection.value = current; 109 } 110 onMounted(() => { 111 document.documentElement.classList.add('js-ready'); 112 try { 113 if (!media.matches && sessionStorage.getItem('shoemoney-motion') === 'off') motionEnabled.value = false; 114 const saved = JSON.parse(localStorage.getItem('shoemoney-portrait-favorites') || '[]'); 115 if (Array.isArray(saved)) favorites.value = saved.filter(id => portraits.some(photo => photo.id === id)); 116 } catch { /* Local storage is not required. */ } 117 document.documentElement.classList.toggle('motion-paused', !motionEnabled.value); 118 const canvas = document.querySelector('#world-canvas'); 119 if (canvas && window.ShoeMoneyWorld) world = new window.ShoeMoneyWorld(canvas, { motion: motionEnabled.value }); 120 if (window.ShoeMoneyFlare) flare = new window.ShoeMoneyFlare({ motion: motionEnabled.value }); 121 revealObserver = new IntersectionObserver(entries => entries.forEach(entry => { 122 if (entry.isIntersecting) { entry.target.classList.add('is-visible'); revealObserver.unobserve(entry.target); } 123 }), { threshold: .07, rootMargin: '0px 0px 30px 0px' }); 124 document.querySelectorAll('.reveal').forEach(element => revealObserver.observe(element)); 125 media.addEventListener('change', event => { motionEnabled.value = !event.matches; }, { signal }); 126 window.addEventListener('scroll', () => { if (!scrollFrame) scrollFrame = requestAnimationFrame(updateScroll); }, { signal, passive: true }); 127 window.addEventListener('resize', () => { if (innerWidth > 1280) closeMenu(); updateScroll(); }, { signal, passive: true }); 128 window.addEventListener('keydown', event => { 129 if (!menuOpen.value) return; 130 if (event.key === 'Escape') { event.preventDefault(); closeMenu(true); return; } 131 if (event.key === 'Tab') { 132 const items = [document.querySelector('.menu-toggle'), ...document.querySelectorAll('#mobile-nav a')]; 133 const first = items[0], last = items[items.length - 1]; 134 if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } 135 else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } 136 } 137 }, { signal }); 138 document.querySelectorAll('[data-tilt-art]').forEach(art => { 139 const frame = art.querySelector('.portrait-frame'); 140 art.addEventListener('pointermove', event => { 141 if (!motionEnabled.value || event.pointerType !== 'mouse') return; 142 const bounds = art.getBoundingClientRect(), x = (event.clientX - bounds.left) / bounds.width - .5, y = (event.clientY - bounds.top) / boun
142ds.height - .5; 143 frame.style.transform = `rotate(2deg) rotateY(${x * 7}deg) rotateX(${-y * 5}deg)`; 144 }, { signal, passive: true }); 145 art.addEventListener('pointerleave', () => { frame.style.transform = ''; }, { signal, passive: true }); 146 }); 147 // No renderer or animation survives page suspension; BFCache restores a fresh instance. 148 window.addEventListener('pagehide', () => { world?.destroy(); flare?.destroy(); }, { signal }); 149 window.addEventListener('pageshow', event => { 150 if (event.persisted) { 151 world = new window.ShoeMoneyWorld(document.querySelector('#world-canvas'), { motion: motionEnabled.value }); 152 flare = new window.ShoeMoneyFlare({ motion: motionEnabled.value }); 153 } 154 }, { signal }); 155 const courseElement = document.querySelector('#course-rating-chart'); 156 if (courseElement && window.echarts) { 157 courseChart = echarts.init(courseElement, null, { renderer: 'svg' }); 158 courseChart.setOption({ 159 animation: false, 160 textStyle: { fontFamily: 'DM Sans, Arial, sans-serif', fontSize: 18 }, 161 grid: { left: 12, right: 12, top: 10, bottom: 35 }, 162 tooltip: { 163 trigger: 'item', confine: true, backgroundColor: '#111410', borderColor: '#75ead7', 164 textStyle: { color: '#f3f2e8', fontSize: 20 }, 165 formatter: () => '3.9 out of 5<br>191 course ratings', 166 }, 167 xAxis: { type: 'value', min: 0, max: 5, interval: 1, 168 axisLabel: { fontSize: 18, color: '#c1c9bd' }, axisLine: { show: false }, axisTick: { show: false }, 169 splitLine: { show: false }, 170 }, 171 yAxis: { type: 'category', data: ['Course rating'], show: false }, 172 series: [{ type: 'bar', data: [3.9], barWidth: 30, showBackground: true, 173 backgroundStyle: { color: '#ffffff15', borderRadius: 6 }, 174 itemStyle: { color: '#75ead7', borderRadius: 6 }, 175 emphasis: { itemStyle: { color: '#d7ff3f' } }, 176 }], 177 }); 178 courseChartObserver = new ResizeObserver(() => courseChart.resize()); 179 courseChartObserver.observe(courseElement); 180 } 181 const chartElement = document.querySelector('#acquisition-chart'); 182 if (chartElement && window.echarts) { 183 acquisitionChart = echarts.init(chartElement, null, { renderer: 'svg' }); 184 const chartOption = () => ({ 185 animation: motionEnabled.value, 186 animationDuration: 900, 187 textStyle: { fontFamily: 'DM Sans, Arial, sans-serif', fontSize: 18 }, 188 aria: { enabled: true, decal: { show: false }, label: { description: "Acquisition values in US dollars: AuctionAds, acquired by MediaWhiz in 2007 for 17 million dollars. PAR Program, acquired by GoSocial in 2015 for 12 million dollars." } }, 189 grid: { left: 135, right: 68, top: 20, bottom: 76 }, 190 tooltip: { 191 trigger: 'axis', confine: true, backgroundColor: '#111410', borderColor: '#d7ff3f', 192 textStyle: { color: '#f3f2e8', fontSize: 20 }, 193 axisPointer: { type: 'shadow', shadowStyle: { color: '#11141008' } }, 194 formatter: params => { 195 const item = params[0], isAuction = item.dataIndex === 0; 196 return (isAuction ? 'AuctionAds · 2007' : 'PAR Program · 2015') + '<br><b>$' + item.value + ' million</b><br>Acquired by ' + (isAuction ? 'MediaWhiz' : 'GoSocial'); 197 }, 198 }, 199 xAxis: { type: 'value', min: 0, max: 20, interval: chartElement.clientWidth < 500 ? 10 : 5, 200 name: 'Acquisition value · USD millions', nameLocation: 'middle', nameGap: 40, 201 nameTextStyle: { fontSize: 18, color: '#4e5549' }, 202 axisLabel: { fontSize: 18, color: '#4e5549' }, axisLine: { show: false }, axisTick: { show: false }, 203 splitLine: { lineStyle: { color: '#11141015' } }, 204 }, 205 yAxis: { type: 'category', inverse: true, data: ['AuctionAds\n2007', 'PAR Program\n2015'],
206 axisLine: { show: false }, axisTick: { show: false }, 207 axisLabel: { fontSize: 18, lineHeight: 26, color: '#111410', margin: 15 }, 208 }, 209 series: [{ type: 'bar', barWidth: 40, 210 data: [{ value: 17, itemStyle: { color: '#4d653b' } }, { value: 12, itemStyle: { color: '#617b54' } }], 211 itemStyle: { borderRadius: [0, 5, 5, 0] }, 212 emphasis: { itemStyle: { color: '#334925' } }, 213 label: { show: true, position: 'right', formatter: '${c}M', fontSize: 21, fontWeight: 600, color: '#111410', distance: 10 }, 214 }], 215 }); 216 acquisitionChart.setOption(chartOption()); 217 const chartDetails = document.querySelector('.chart-data'); 218 if (chartDetails) chartDetails.open = false; 219 chartObserver = new ResizeObserver(() => { 220 acquisitionChart.resize(); 221 acquisitionChart.setOption({ xAxis: { interval: chartElement.clientWidth < 500 ? 10 : 5 } }); 222 }); 223 chartObserver.observe(chartElement); 224 } 225 updateScroll(); 226 }); 227 onBeforeUnmount(() => { 228 listeners.abort(); revealObserver?.disconnect(); chartObserver?.disconnect(); acquisitionChart?.dispose(); courseChartObserver?.disconnect(); courseChart?.dispose(); cancelAnimationFrame(scrollFrame); world?.destroy(); flare?.destroy(); 229 document.body.classList.remove('menu-open', 'dialog-open'); 230 }); 231 return { menuOpen, activeSection, scrollProgress, motionEnabled, filter, filterStatus, skillQuery, skillCategory, visibleSkills, skillStatus, showSkill, clearSkillFilters, portraits, selectedPortraitId, selectedPortrait, favorites, comparison, galleryMode, galleryStatus, portraitDialog, dialogPortraits, showProject, setFilter, toggleMenu, handleMobileNav, toggleMotion, toggleFavorite, toggleCompare, openGallery, closeGallery, afterDialogClose, closeOnBackdrop }; 232 }, 233 }); 234 // Fallback labels are real HTML for no-JS readers. Vue's runtime compiler requires 235 // v-text targets to start empty, so remove only those labels immediately at mount. 236 document.querySelectorAll('#app [v-text]').forEach(element => { element.textContent = ''; }); 237 app.mount('#app'); 238})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.