diff options
| -rw-r--r-- | aero.css | 201 | ||||
| -rw-r--r-- | aero.js | 43 | ||||
| -rwxr-xr-x | index.html | 496 |
3 files changed, 644 insertions, 96 deletions
@@ -1029,3 +1029,204 @@ body[data-theme="chrome"] .enter, body[data-theme="chrome"] .start { cursor: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='22' viewBox='0 0 22 22'><defs><linearGradient id='c' x1='0' y1='0' x2='0' y2='1'><stop offset='0' stop-color='%23f4f6ff'/><stop offset='0.45' stop-color='%23808aa8'/><stop offset='0.55' stop-color='%232f3450'/><stop offset='1' stop-color='%23dbe2f0'/></linearGradient></defs><circle cx='11' cy='11' r='10' fill='none' stroke='%23c6a8ec' stroke-width='1' opacity='0.9'/><circle cx='11' cy='11' r='8' fill='url(%23c)' stroke='%23181a30' stroke-width='0.7'/><ellipse cx='8.6' cy='7.4' rx='2.8' ry='1.8' fill='white' opacity='0.95'/><circle cx='11' cy='11' r='1.4' fill='%23ffd4ff' opacity='1'/></svg>") 11 11, pointer; } + +/* ================================================================ + CHROME THEME — Resize handles + ================================================================ */ +body[data-theme="chrome"] .rs-se { + background-image: + linear-gradient(135deg, transparent 0 38%, oklch(80% 0.18 290 / 0.85) 38% 48%, transparent 48% 60%, oklch(82% 0.16 200 / 0.85) 60% 70%, transparent 70%, oklch(85% 0.18 320 / 0.85) 82% 92%, transparent 92%) !important; + opacity: 0.8; +} +body[data-theme="chrome"] .rs-se:hover { + filter: drop-shadow(0 0 6px oklch(70% 0.20 290 / 0.9)) !important; + opacity: 1; +} + +/* ================================================================ + CHROME THEME — Browser ("Internet" window) + ================================================================ */ + +body[data-theme="chrome"] .browser-toolbar { + background-image: + repeating-linear-gradient(0deg, + oklch(99% 0 0 / 0.05) 0px, oklch(99% 0 0 / 0.05) 1px, + transparent 1px, transparent 3px), + linear-gradient(180deg, + oklch(92% 0.01 240) 0%, + oklch(78% 0.01 240) 50%, + oklch(86% 0.01 240) 100%) !important; + border-bottom: 1px solid oklch(28% 0.03 250) !important; + box-shadow: + inset 0 1px 0 oklch(99% 0 0 / 0.7), + inset 0 -1px 0 oklch(99% 0.05 200 / 0.25); + position: relative; +} +body[data-theme="chrome"] .browser-toolbar::after { + content: ""; + position: absolute; left: 0; right: 0; bottom: -1px; height: 1px; + background: linear-gradient(90deg, + transparent 0%, + oklch(75% 0.18 200 / 0.7) 20%, + oklch(75% 0.18 290 / 0.8) 50%, + oklch(75% 0.18 320 / 0.7) 80%, + transparent 100%); + pointer-events: none; +} + +body[data-theme="chrome"] .nav-btn { + background: linear-gradient(180deg, + oklch(96% 0.01 240) 0%, + oklch(72% 0.02 245) 50%, + oklch(40% 0.03 250) 51%, + oklch(78% 0.02 240) 100%) !important; + border: 1px solid oklch(28% 0.03 250) !important; + color: oklch(15% 0.02 250) !important; + box-shadow: + inset 0 1px 0 oklch(99% 0 0 / 0.85), + inset 0 -1px 0 oklch(99% 0.05 200 / 0.25), + 0 1px 3px oklch(5% 0.03 270 / 0.5) !important; +} +body[data-theme="chrome"] .nav-btn:hover { filter: brightness(1.08) saturate(1.1); } + +body[data-theme="chrome"] .url-bar { + background: linear-gradient(180deg, + oklch(14% 0.04 260) 0%, + oklch(18% 0.04 260) 100%) !important; + border: 1px solid oklch(28% 0.04 260) !important; + box-shadow: + inset 0 1px 3px oklch(2% 0.02 270 / 0.7), + inset 0 -1px 0 oklch(75% 0.18 290 / 0.18), + 0 0 8px oklch(60% 0.18 290 / 0.12) !important; + font-family: "IBM Plex Mono", monospace !important; + font-size: 11px !important; + color: oklch(80% 0.04 245) !important; + letter-spacing: 0.3px; +} +body[data-theme="chrome"] .url-bar .lock { color: oklch(78% 0.16 145) !important; filter: drop-shadow(0 0 4px oklch(80% 0.18 145 / 0.6)); } +body[data-theme="chrome"] .url-bar .url-scheme { color: oklch(55% 0.08 280) !important; } +body[data-theme="chrome"] .url-bar .url-host { color: oklch(85% 0.14 290) !important; text-shadow: 0 0 6px oklch(70% 0.20 290 / 0.55); font-weight: 500 !important; } +body[data-theme="chrome"] .url-bar .url-path { color: oklch(82% 0.06 220) !important; } +body[data-theme="chrome"] .url-bar .caret { background: oklch(85% 0.18 200) !important; box-shadow: 0 0 6px oklch(75% 0.20 200); } + +body[data-theme="chrome"] .nav-btn#br-go { + font-family: "Michroma", sans-serif !important; + font-size: 9px !important; + letter-spacing: 1.5px; + text-transform: uppercase; +} + +body[data-theme="chrome"] .browser-bookmarks { + background: linear-gradient(180deg, oklch(28% 0.03 250) 0%, oklch(18% 0.03 255) 100%) !important; + border-bottom: 1px solid oklch(12% 0.03 260) !important; + box-shadow: + inset 0 1px 0 oklch(60% 0.04 250 / 0.4), + inset 0 -1px 0 oklch(2% 0.02 270 / 0.6); +} +body[data-theme="chrome"] .bm { + color: oklch(75% 0.04 245) !important; + font-family: "Michroma", sans-serif !important; + font-size: 9px !important; + letter-spacing: 1.2px; + text-transform: uppercase; + border: 1px solid transparent !important; +} +body[data-theme="chrome"] .bm:hover { background: oklch(35% 0.05 270 / 0.5) !important; border-color: oklch(60% 0.12 290 / 0.5) !important; color: oklch(95% 0.04 280) !important; } +body[data-theme="chrome"] .bm.active { + background: linear-gradient(180deg, oklch(45% 0.10 290 / 0.6), oklch(28% 0.08 280 / 0.6)) !important; + border-color: oklch(70% 0.18 290 / 0.7) !important; + color: oklch(96% 0.04 290) !important; + box-shadow: inset 0 1px 0 oklch(85% 0.14 290 / 0.4), 0 0 8px oklch(70% 0.18 290 / 0.4); + text-shadow: 0 0 6px oklch(80% 0.18 290 / 0.6); +} +body[data-theme="chrome"] .bm .favicon { box-shadow: inset 0 1px 0 oklch(99% 0 0 / 0.4), 0 0 4px oklch(70% 0.18 290 / 0.4); } +body[data-theme="chrome"] .bm.home .favicon { background: linear-gradient(135deg, oklch(85% 0.18 290), oklch(45% 0.16 290) 50%, oklch(72% 0.14 320)) !important; } + +body[data-theme="chrome"] .browser-page { + background: + radial-gradient(120% 60% at 100% 0%, oklch(28% 0.10 290 / 0.45) 0%, transparent 55%), + radial-gradient(80% 50% at 0% 100%, oklch(25% 0.10 220 / 0.4) 0%, transparent 55%), + linear-gradient(180deg, oklch(10% 0.03 265) 0%, oklch(7% 0.02 270) 100%) !important; + color: oklch(85% 0.02 245) !important; + font-family: "Space Grotesk", "Segoe UI", sans-serif !important; +} +body[data-theme="chrome"] .idx-hero, +body[data-theme="chrome"] .art-page { position: relative; } +body[data-theme="chrome"] .idx-hero::after, +body[data-theme="chrome"] .art-page::after { + content: ""; + position: absolute; inset: 0; + background: repeating-linear-gradient(0deg, oklch(99% 0 0 / 0.025) 0px, oklch(99% 0 0 / 0.025) 1px, transparent 1px, transparent 3px); + pointer-events: none; +} +body[data-theme="chrome"] .browser-page::-webkit-scrollbar-track { background: linear-gradient(to right, oklch(8% 0.02 270), oklch(14% 0.03 265)) !important; } +body[data-theme="chrome"] .browser-page::-webkit-scrollbar-thumb { background: linear-gradient(to right, oklch(60% 0.04 250), oklch(35% 0.03 255)) !important; border: 2px solid oklch(10% 0.03 265) !important; box-shadow: 0 0 4px oklch(70% 0.18 290 / 0.5); } + +body[data-theme="chrome"] .idx-hero { + background: + radial-gradient(80% 100% at 100% 0%, oklch(40% 0.18 290 / 0.35) 0%, transparent 60%), + radial-gradient(60% 80% at 0% 100%, oklch(35% 0.14 200 / 0.3) 0%, transparent 60%), + linear-gradient(180deg, oklch(14% 0.04 265) 0%, oklch(10% 0.03 270) 100%) !important; + border-bottom: 1px solid oklch(30% 0.06 280 / 0.6) !important; + padding: 38px 40px 28px !important; +} +body[data-theme="chrome"] .idx-eyebrow { font-family: "Michroma", sans-serif !important; font-size: 9px !important; letter-spacing: 3px !important; color: oklch(75% 0.14 290) !important; text-shadow: 0 0 6px oklch(70% 0.18 290 / 0.4); } +body[data-theme="chrome"] .idx-title { font-family: "Audiowide", "Space Grotesk", sans-serif !important; font-weight: 400 !important; font-size: 36px !important; letter-spacing: -0.5px !important; color: oklch(96% 0.02 240) !important; text-shadow: 0 1px 0 oklch(99% 0 0 / 0.15), 0 2px 12px oklch(40% 0.18 290 / 0.5); } +body[data-theme="chrome"] .idx-title em { + font-family: "Audiowide", sans-serif !important; font-style: italic; + background: linear-gradient(135deg, oklch(85% 0.18 200) 0%, oklch(85% 0.18 290) 50%, oklch(85% 0.18 350) 100%) !important; + background-size: 200% 200% !important; + -webkit-background-clip: text !important; background-clip: text !important; + -webkit-text-fill-color: transparent !important; color: transparent !important; + animation: chromeShimmer 6s linear infinite; +} +body[data-theme="chrome"] .idx-sub { font-family: "Space Grotesk", sans-serif !important; font-style: normal !important; font-size: 13px !important; color: oklch(70% 0.04 250) !important; } +body[data-theme="chrome"] .idx-list { padding: 24px 40px 48px !important; } +body[data-theme="chrome"] .idx-art { border-bottom: 1px solid oklch(28% 0.04 270 / 0.6) !important; padding: 20px 0 !important; position: relative; z-index: 1; } +body[data-theme="chrome"] .idx-art:hover { padding-left: 10px !important; } +body[data-theme="chrome"] .idx-art:hover::before { content: ""; position: absolute; left: 0; top: 12px; bottom: 12px; width: 2px; background: linear-gradient(180deg, oklch(80% 0.18 200), oklch(80% 0.18 290), oklch(80% 0.18 350)); box-shadow: 0 0 8px oklch(70% 0.18 290); } +body[data-theme="chrome"] .idx-art-num { font-family: "IBM Plex Mono", monospace !important; font-weight: 500 !important; color: oklch(55% 0.10 280) !important; text-shadow: 0 0 8px oklch(50% 0.16 290 / 0.5); } +body[data-theme="chrome"] .idx-art-title { font-family: "Audiowide", "Space Grotesk", sans-serif !important; font-weight: 400 !important; font-size: 17px !important; letter-spacing: -0.1px !important; color: oklch(94% 0.02 245) !important; } +body[data-theme="chrome"] .idx-art:hover .idx-art-title { color: oklch(90% 0.14 290) !important; text-shadow: 0 0 10px oklch(70% 0.18 290 / 0.55); text-decoration-color: oklch(70% 0.18 290 / 0.7) !important; } +body[data-theme="chrome"] .idx-art-excerpt { font-family: "Space Grotesk", sans-serif !important; font-size: 13px !important; color: oklch(68% 0.04 250) !important; } +body[data-theme="chrome"] .idx-art-meta { font-family: "IBM Plex Mono", monospace !important; font-size: 10px !important; color: oklch(60% 0.06 270) !important; } +body[data-theme="chrome"] .idx-art-tag { font-family: "Michroma", sans-serif !important; font-size: 8px !important; letter-spacing: 1.5px; text-transform: uppercase; background: linear-gradient(180deg, oklch(28% 0.08 290 / 0.6), oklch(18% 0.06 280 / 0.6)) !important; color: oklch(85% 0.12 290) !important; border: 1px solid oklch(55% 0.14 290 / 0.5); box-shadow: inset 0 1px 0 oklch(70% 0.16 290 / 0.3), 0 0 6px oklch(50% 0.16 290 / 0.3); padding: 2px 7px !important; } + +body[data-theme="chrome"] .art-page { padding: 44px 60px 64px !important; max-width: 640px; } +body[data-theme="chrome"] .art-back { font-family: "Michroma", sans-serif !important; font-size: 9px !important; letter-spacing: 2.5px !important; color: oklch(72% 0.14 290) !important; text-shadow: 0 0 5px oklch(60% 0.18 290 / 0.4); } +body[data-theme="chrome"] .art-back:hover { color: oklch(88% 0.16 290) !important; text-shadow: 0 0 8px oklch(70% 0.20 290 / 0.7); } +body[data-theme="chrome"] .art-eyebrow { font-family: "Michroma", sans-serif !important; font-size: 9px !important; letter-spacing: 3px !important; color: oklch(75% 0.14 200) !important; text-shadow: 0 0 5px oklch(60% 0.18 200 / 0.4); } +body[data-theme="chrome"] .art-title { font-family: "Audiowide", "Space Grotesk", sans-serif !important; font-weight: 400 !important; font-size: 30px !important; line-height: 1.2 !important; letter-spacing: -0.3px !important; color: oklch(97% 0.02 240) !important; text-shadow: 0 1px 0 oklch(99% 0 0 / 0.15), 0 2px 10px oklch(40% 0.18 290 / 0.5); margin: 0 0 16px !important; } +body[data-theme="chrome"] .art-title em { + font-family: "Audiowide", sans-serif !important; font-style: italic; + background: linear-gradient(135deg, oklch(85% 0.18 200) 0%, oklch(85% 0.18 290) 50%, oklch(85% 0.18 350) 100%) !important; + background-size: 200% 200% !important; + -webkit-background-clip: text !important; background-clip: text !important; + -webkit-text-fill-color: transparent !important; color: transparent !important; + animation: chromeShimmer 6s linear infinite; +} +body[data-theme="chrome"] .art-byline { font-family: "IBM Plex Mono", monospace !important; font-style: normal !important; font-size: 11px !important; color: oklch(60% 0.06 270) !important; border-bottom-color: oklch(28% 0.04 270 / 0.6) !important; letter-spacing: 0.3px; } +body[data-theme="chrome"] .art-body { font-family: "Space Grotesk", sans-serif !important; font-size: 14.5px !important; line-height: 1.78 !important; color: oklch(82% 0.02 250) !important; } +body[data-theme="chrome"] .art-body p:first-of-type::first-letter { + font-family: "Audiowide", sans-serif !important; font-size: 64px !important; line-height: 0.85 !important; padding: 8px 12px 0 0 !important; + background: linear-gradient(135deg, oklch(85% 0.18 200) 0%, oklch(85% 0.18 290) 50%, oklch(85% 0.18 350) 100%) !important; + background-size: 200% 200% !important; + -webkit-background-clip: text !important; background-clip: text !important; + -webkit-text-fill-color: transparent !important; color: transparent !important; + filter: drop-shadow(0 0 8px oklch(70% 0.18 290 / 0.6)); + animation: chromeShimmer 6s linear infinite; +} +body[data-theme="chrome"] .art-body em { color: oklch(88% 0.10 285) !important; font-style: italic; } +body[data-theme="chrome"] .art-body h2 { font-family: "Audiowide", sans-serif !important; font-weight: 400 !important; font-size: 18px !important; color: oklch(94% 0.04 270) !important; letter-spacing: 0 !important; margin: 36px 0 14px !important; text-shadow: 0 0 8px oklch(50% 0.16 290 / 0.3); } +body[data-theme="chrome"] .art-body blockquote { border-left: 2px solid transparent !important; border-image: linear-gradient(180deg, oklch(80% 0.18 200), oklch(80% 0.18 290), oklch(80% 0.18 350)) 1; color: oklch(85% 0.06 280) !important; font-style: italic; background: linear-gradient(90deg, oklch(20% 0.06 280 / 0.35) 0%, transparent 100%); padding: 12px 0 12px 20px !important; margin: 28px 0 !important; } +body[data-theme="chrome"] .art-body a, +body[data-theme="chrome"] .art-body .ilink { color: oklch(82% 0.14 290) !important; text-decoration-color: oklch(70% 0.18 290 / 0.6) !important; text-shadow: 0 0 4px oklch(60% 0.18 290 / 0.4); } +body[data-theme="chrome"] .art-body a:hover, +body[data-theme="chrome"] .art-body .ilink:hover { color: oklch(92% 0.16 290) !important; text-shadow: 0 0 8px oklch(70% 0.20 290 / 0.7); } +body[data-theme="chrome"] .art-body code { font-family: "IBM Plex Mono", monospace !important; background: linear-gradient(180deg, oklch(14% 0.04 270), oklch(10% 0.03 270)) !important; color: oklch(85% 0.14 200) !important; border: 1px solid oklch(28% 0.06 270); padding: 1px 6px !important; box-shadow: inset 0 1px 0 oklch(99% 0.05 200 / 0.06); } +body[data-theme="chrome"] .art-foot { font-family: "IBM Plex Mono", monospace !important; font-size: 11px !important; color: oklch(60% 0.06 270) !important; border-top: 1px solid oklch(28% 0.04 270 / 0.6) !important; margin-top: 48px !important; padding-top: 22px !important; } +body[data-theme="chrome"] .art-foot .more { color: oklch(82% 0.14 290) !important; text-shadow: 0 0 5px oklch(60% 0.18 290 / 0.4); } + +body[data-theme="chrome"] .browser-status { background: linear-gradient(180deg, oklch(85% 0.01 240) 0%, oklch(65% 0.02 245) 50%, oklch(78% 0.01 240) 100%) !important; border-top: 1px solid oklch(28% 0.03 250) !important; color: oklch(15% 0.02 250) !important; font-family: "Michroma", sans-serif !important; font-size: 9px !important; letter-spacing: 1.2px; text-transform: uppercase; box-shadow: inset 0 1px 0 oklch(99% 0 0 / 0.5); } +body[data-theme="chrome"] .browser-status .progress { background: oklch(15% 0.04 265) !important; border: 1px solid oklch(28% 0.04 265) !important; box-shadow: inset 0 1px 2px oklch(2% 0.02 270 / 0.7); } +body[data-theme="chrome"] .browser-status .progress-bar { background: linear-gradient(180deg, oklch(85% 0.18 200) 0%, oklch(70% 0.18 290) 50%, oklch(80% 0.18 320) 100%) !important; box-shadow: inset 0 1px 0 oklch(99% 0.05 200 / 0.5), 0 0 6px oklch(70% 0.18 290 / 0.8); } @@ -83,6 +83,47 @@ function makeDraggable(el, handle) { window.addEventListener('mouseup', () => { dragging = false; }); } +function makeResizable(el, options = {}) { + const minW = options.minW || 280; + const minH = options.minH || 180; + const handles = ['e', 's', 'se']; + handles.forEach(mode => { + const h = document.createElement('div'); + h.className = 'rs rs-' + mode + ' no-drag'; + el.appendChild(h); + h.addEventListener('mousedown', (e) => startResize(e, mode)); + }); + let mode = null, sx = 0, sy = 0, sw = 0, sh = 0; + function startResize(e, m) { + e.preventDefault(); e.stopPropagation(); + mode = m; + sx = e.clientX; sy = e.clientY; + const r = el.getBoundingClientRect(); + sw = r.width; sh = r.height; + // pin current size as inline so first move doesn't snap + el.style.width = sw + 'px'; + el.style.height = sh + 'px'; + el.classList.add('resized'); + el.style.zIndex = (++window.__zTop || (window.__zTop = 100)); + document.body.classList.add('rs-cursor-' + m); + window.addEventListener('mousemove', onMove); + window.addEventListener('mouseup', onUp); + } + function onMove(e) { + if (!mode) return; + const dx = e.clientX - sx; + const dy = e.clientY - sy; + if (mode.includes('e')) el.style.width = Math.max(minW, sw + dx) + 'px'; + if (mode.includes('s')) el.style.height = Math.max(minH, sh + dy) + 'px'; + } + function onUp() { + mode = null; + document.body.classList.remove('rs-cursor-e', 'rs-cursor-s', 'rs-cursor-se'); + window.removeEventListener('mousemove', onMove); + window.removeEventListener('mouseup', onUp); + } +} + function counterHTML(val = 42137, label = "visitors") { const digits = String(val).padStart(7, '0').split('').map(d => `<div class="d">${d}</div>`).join(''); return `<div class="counter"><div class="digits">${digits}</div><div class="label">${label}</div></div>`; @@ -228,7 +269,7 @@ async function fetchReelMouthFeed(limit = 6) { } window.Aero = { - spawnBubbles, makeClouds, sparkleCursor, makeDraggable, + spawnBubbles, makeClouds, sparkleCursor, makeDraggable, makeResizable, counterHTML, nowPlayingHTML, animateEq, musicToggleHTML, bindMusicToggle, // theme api getTheme, setTheme, mountThemeSwitcher, initTheme, THEMES, @@ -41,6 +41,37 @@ .icon:hover .glyph { transform: translateY(-2px) scale(1.04); transition: transform 200ms; } .win { position: absolute; min-width: 320px; z-index: 50; } + /* When a window has been resized, it switches to a flex column so the + body becomes a scroll region — keeps content from getting clipped */ + .win.resized { + display: flex; flex-direction: column; + overflow: hidden; + border-radius: 18px; + } + .win.resized .body { + flex: 1; min-height: 0; + overflow-y: auto; + border-radius: 0 0 18px 18px; + } + /* The browser window is already a flex column internally; let it fill */ + .win.resized > .browser { flex: 1; min-height: 0; } + /* RESIZE HANDLES */ + .rs { position: absolute; z-index: 5; } + .rs-e { right: -3px; top: 14px; bottom: 18px; width: 8px; cursor: ew-resize; } + .rs-s { left: 14px; right: 18px; bottom: -3px; height: 8px; cursor: ns-resize; } + .rs-se { + right: 0; bottom: 0; width: 22px; height: 22px; + cursor: nwse-resize; + background-image: + linear-gradient(135deg, transparent 0 38%, rgba(40,80,140,0.85) 38% 48%, transparent 48% 60%, rgba(40,80,140,0.75) 60% 70%, transparent 70%, rgba(40,80,140,0.6) 82% 92%, transparent 92%); + border-radius: 0 0 18px 0; + opacity: 0.65; + transition: opacity 150ms, filter 150ms; + } + .rs-se:hover { opacity: 1; filter: drop-shadow(0 0 4px rgba(120,170,220,0.8)); } + body.rs-cursor-e, body.rs-cursor-e * { cursor: ew-resize !important; } + body.rs-cursor-s, body.rs-cursor-s * { cursor: ns-resize !important; } + body.rs-cursor-se, body.rs-cursor-se * { cursor: nwse-resize !important; } .win .titlebar { height: 32px; padding: 0 12px; display: flex; align-items: center; gap: 8px; border-radius: 18px 18px 0 0; @@ -114,68 +145,139 @@ .film-rating .star.half { background: linear-gradient(90deg, oklch(72% 0.16 60) 50%, oklch(80% 0.04 230) 50%); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; } .film-note { font-size: 11px; opacity: 0.75; font-style: italic; } - /* ===== BROWSER / ARTICLE READER ===== */ + /* ============= FAUX BROWSER ============= */ + .browser { display: flex; flex-direction: column; height: 100%; } + .browser .titlebar { border-bottom: 1px solid rgba(0,0,0,0.18); } .browser-toolbar { display: flex; align-items: center; gap: 8px; - padding: 6px 12px; - background: rgba(255,255,255,0.22); - border-bottom: 1px solid rgba(200,220,240,0.45); + padding: 8px 12px; + background: linear-gradient(to bottom, oklch(96% 0.005 220), oklch(86% 0.015 225) 50%, oklch(78% 0.025 230)); + border-bottom: 1px solid oklch(60% 0.04 230); + box-shadow: inset 0 -1px 0 rgba(255,255,255,0.5); } - .browser-url-bar { - flex: 1; height: 22px; padding: 0 10px; - border-radius: 11px; - background: rgba(255,255,255,0.62); border: 1px solid rgba(180,200,220,0.45); - font: 400 11px 'IBM Plex Mono', monospace; color: oklch(30% 0.05 230); - display: flex; align-items: center; overflow: hidden; - white-space: nowrap; text-overflow: ellipsis; + .nav-btn { + width: 28px; height: 24px; border-radius: 12px; + display: inline-flex; align-items: center; justify-content: center; + background: linear-gradient(to bottom, oklch(99% 0.005 220), oklch(88% 0.015 225) 50%, oklch(74% 0.03 230)); + border: 1px solid oklch(55% 0.05 230); + color: oklch(35% 0.05 235); font-size: 13px; line-height: 1; + cursor: pointer; + box-shadow: inset 0 1px 0 rgba(255,255,255,0.95), 0 1px 2px rgba(40,80,140,0.25); + transition: transform 100ms, box-shadow 100ms; } + .nav-btn:hover { transform: translateY(-0.5px); } + .nav-btn:active { transform: translateY(0.5px); box-shadow: inset 0 1px 3px rgba(40,80,140,0.4); } + .nav-btn[disabled] { opacity: 0.35; cursor: default; transform: none !important; } + .nav-btn.refresh.spinning svg { animation: spin 700ms linear; } + @keyframes spin { to { transform: rotate(360deg); } } + .url-bar { + flex: 1; height: 26px; display: flex; align-items: center; gap: 6px; + padding: 0 10px; + background: linear-gradient(to bottom, oklch(99% 0.003 220), oklch(94% 0.008 220)); + border: 1px solid oklch(60% 0.04 230); + border-radius: 13px; + box-shadow: inset 0 1px 2px rgba(40,80,140,0.18); + font: 12px "IBM Plex Mono", "Courier New", monospace; + color: oklch(28% 0.05 235); + overflow: hidden; min-width: 0; + } + .url-bar .lock { font-size: 10px; color: oklch(58% 0.16 145); flex-shrink: 0; } + .url-bar .url-scheme { opacity: 0.55; } + .url-bar .url-host { color: oklch(30% 0.08 240); font-weight: 600; } + .url-bar .url-path { opacity: 0.85; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .url-bar .caret { width: 1px; height: 14px; background: oklch(40% 0.08 240); animation: blink 1s steps(2) infinite; } + @keyframes blink { 50% { opacity: 0; } } .browser-bookmarks { - display: flex; gap: 5px; padding: 5px 12px; - background: rgba(255,255,255,0.15); - border-bottom: 1px solid rgba(200,220,240,0.35); - flex-wrap: wrap; + display: flex; align-items: center; gap: 2px; + padding: 4px 10px; + background: linear-gradient(to bottom, oklch(94% 0.01 220), oklch(86% 0.02 225)); + border-bottom: 1px solid oklch(62% 0.04 230); + font-size: 11px; + overflow-x: auto; scrollbar-width: none; + } + .browser-bookmarks::-webkit-scrollbar { display: none; } + .bm { + display: inline-flex; align-items: center; gap: 5px; + padding: 3px 8px; border-radius: 8px; + color: oklch(28% 0.06 235); white-space: nowrap; cursor: pointer; + border: 1px solid transparent; + transition: background 120ms, border-color 120ms; } - .browser-bm { - height: 20px; padding: 0 10px; - border-radius: 10px; border: 1px solid rgba(180,200,220,0.4); - background: rgba(255,255,255,0.55); - font: 600 10px 'Plus Jakarta Sans', sans-serif; color: oklch(30% 0.08 230); - cursor: pointer; letter-spacing: 0.3px; - transition: background 150ms, border-color 150ms; + .bm:hover { background: rgba(255,255,255,0.6); border-color: rgba(255,255,255,0.95); } + .bm.active { background: linear-gradient(to bottom, rgba(255,255,255,0.7), rgba(200,230,255,0.4)); border-color: rgba(120,170,220,0.5); } + .bm .favicon { width: 12px; height: 12px; border-radius: 3px; box-shadow: inset 0 1px 0 rgba(255,255,255,0.6); } + .bm.home .favicon { background: linear-gradient(135deg, oklch(85% 0.10 220), oklch(55% 0.14 235)); } + .browser-page { + flex: 1; min-height: 0; overflow-y: auto; + background: oklch(98% 0.004 220); + color: oklch(20% 0.03 240); + font: 14px "Georgia", "Times New Roman", serif; + line-height: 1.65; + padding: 0; + position: relative; } - .browser-bm:hover { background: rgba(255,255,255,0.88); } - .browser-bm.active { background: rgba(255,255,255,0.92); border-color: oklch(55% 0.12 220); color: oklch(22% 0.10 230); } - .browser-body { - overflow-y: auto; max-height: 440px; - padding: 16px 20px; border-radius: 0 0 18px 18px; + .browser-page::-webkit-scrollbar { width: 12px; } + .browser-page::-webkit-scrollbar-track { background: linear-gradient(to right, oklch(92% 0.01 220), oklch(96% 0.005 220)); } + .browser-page::-webkit-scrollbar-thumb { background: linear-gradient(to right, oklch(82% 0.03 225), oklch(70% 0.05 230)); border-radius: 6px; border: 2px solid oklch(94% 0.008 220); } + .browser-status { + height: 22px; padding: 0 12px; + display: flex; align-items: center; justify-content: space-between; + background: linear-gradient(to bottom, oklch(90% 0.01 220), oklch(80% 0.02 225)); + border-top: 1px solid oklch(60% 0.04 230); + font: 11px "Segoe UI", Tahoma, sans-serif; + color: oklch(35% 0.05 235); + border-radius: 0 0 18px 18px; } - /* article typography inside the browser */ - .browser-body h1 { font-size: 17px; font-weight: 700; margin: 0 0 10px; color: oklch(24% 0.08 230); } - .browser-body h2 { font-size: 14px; font-weight: 700; margin: 16px 0 6px; color: oklch(27% 0.08 230); border-bottom: 1px solid rgba(120,160,200,0.3); padding-bottom: 4px; } - .browser-body h3 { font-size: 13px; font-weight: 600; margin: 12px 0 5px; color: oklch(29% 0.07 230); } - .browser-body h4 { font-size: 12px; font-weight: 600; margin: 10px 0 4px; color: oklch(31% 0.06 230); font-style: italic; } - .browser-body p { margin: 0 0 9px; font-size: 12px; line-height: 1.65; color: oklch(22% 0.04 240); } - .browser-body ul, .browser-body ol { padding-left: 18px; margin: 0 0 9px; } - .browser-body li { font-size: 12px; line-height: 1.7; color: oklch(22% 0.04 240); } - .browser-body img { max-width: 160px; border-radius: 8px; margin: 6px 0; display: block; box-shadow: 0 2px 8px rgba(40,80,140,0.15); } - .browser-body figure { margin: 8px 0; } - .browser-body figcaption { font-size: 10px; opacity: 0.65; margin-top: 3px; } - .browser-body a { color: oklch(42% 0.16 255); text-decoration: none; } - .browser-body a:hover { text-decoration: underline; } - .browser-body pre, .browser-body .body > p { font: 12px 'IBM Plex Mono', monospace; line-height: 1.65; white-space: pre-wrap; color: oklch(22% 0.04 240); } - .browser-body center { text-align: center; } - /* chrome theme overrides for browser */ - body[data-theme="chrome"] .browser-url-bar { background: rgba(195,202,215,0.5); border-color: oklch(50% 0.04 250); color: oklch(14% 0.03 250); } - body[data-theme="chrome"] .browser-bookmarks { background: rgba(195,202,215,0.15); border-bottom-color: oklch(60% 0.03 250); } - body[data-theme="chrome"] .browser-bm { font-family: 'Michroma', sans-serif; font-size: 8px; text-transform: uppercase; letter-spacing: 1px; border-color: oklch(55% 0.04 250); background: linear-gradient(to bottom, oklch(92% 0.01 240), oklch(78% 0.02 240)); color: oklch(18% 0.04 250); } - body[data-theme="chrome"] .browser-bm:hover { background: linear-gradient(to bottom, oklch(97% 0.01 240), oklch(86% 0.02 240)); } - body[data-theme="chrome"] .browser-bm.active { background: linear-gradient(to bottom, oklch(99% 0.01 240), oklch(90% 0.02 240)); border-color: oklch(40% 0.10 280); } - body[data-theme="chrome"] .browser-body h1, - body[data-theme="chrome"] .browser-body h2, - body[data-theme="chrome"] .browser-body h3, - body[data-theme="chrome"] .browser-body h4 { font-family: 'Michroma', sans-serif; text-transform: uppercase; letter-spacing: 0.5px; } - body[data-theme="chrome"] .browser-body p, - body[data-theme="chrome"] .browser-body li { font-family: 'Space Grotesk', sans-serif; } + .browser-status .progress { + flex: 1; max-width: 80px; height: 8px; margin: 0 8px; + background: oklch(82% 0.02 225); border-radius: 4px; + border: 1px solid oklch(65% 0.04 230); + overflow: hidden; box-shadow: inset 0 1px 2px rgba(40,80,140,0.15); + } + .browser-status .progress-bar { + height: 100%; width: 100%; + background: linear-gradient(to bottom, oklch(82% 0.13 220), oklch(58% 0.14 235)); + box-shadow: inset 0 1px 0 rgba(255,255,255,0.65); + } + /* index page */ + .idx-hero { + padding: 32px 40px 24px; + background: + radial-gradient(60% 80% at 100% 0%, oklch(88% 0.10 215 / 0.5) 0%, transparent 60%), + linear-gradient(to bottom, oklch(94% 0.03 220), oklch(98% 0.004 220)); + border-bottom: 1px solid oklch(88% 0.02 220); + } + .idx-eyebrow { font: 500 10px "IBM Plex Mono", monospace; letter-spacing: 2px; text-transform: uppercase; color: oklch(45% 0.10 230); margin-bottom: 8px; } + .idx-title { font: 700 38px/1.05 "Georgia", "Times New Roman", serif; color: oklch(22% 0.06 235); letter-spacing: -1px; margin: 0 0 10px; } + .idx-title em { font-style: italic; font-weight: 400; color: oklch(45% 0.12 220); } + .idx-sub { font: italic 14px/1.5 "Georgia", serif; color: oklch(40% 0.04 235); max-width: 440px; margin: 0; } + .idx-list { padding: 16px 40px 40px; } + .idx-art { display: grid; grid-template-columns: 60px 1fr auto; gap: 18px; padding: 18px 0; border-bottom: 1px solid oklch(90% 0.015 220); cursor: pointer; transition: padding 120ms; } + .idx-art:last-child { border-bottom: 0; } + .idx-art:hover { padding-left: 6px; } + .idx-art:hover .idx-art-title { color: oklch(40% 0.16 230); text-decoration: underline; text-decoration-color: oklch(70% 0.13 220 / 0.5); text-underline-offset: 3px; } + .idx-art-num { font: 600 28px/1 "IBM Plex Mono", monospace; color: oklch(80% 0.04 225); padding-top: 4px; } + .idx-art-title { font: 700 18px/1.25 "Georgia", serif; color: oklch(22% 0.06 235); margin: 0 0 4px; letter-spacing: -0.2px; } + .idx-art-excerpt { font: 13px/1.55 "Georgia", serif; color: oklch(40% 0.04 235); margin: 4px 0 0; } + .idx-art-meta { font: 11px "IBM Plex Mono", monospace; color: oklch(55% 0.06 230); text-align: right; padding-top: 4px; line-height: 1.6; white-space: nowrap; } + .idx-art-tag { display: inline-block; padding: 1px 6px; border-radius: 3px; background: oklch(92% 0.04 220); color: oklch(40% 0.10 230); font-size: 10px; text-transform: lowercase; letter-spacing: 0.5px; } + /* article page */ + .art-page { padding: 36px 56px 60px; max-width: 620px; margin: 0 auto; } + .art-back { display: inline-flex; align-items: center; gap: 6px; font: 500 11px "IBM Plex Mono", monospace; color: oklch(45% 0.10 230); text-decoration: none; margin-bottom: 24px; cursor: pointer; letter-spacing: 1px; text-transform: uppercase; } + .art-back:hover { color: oklch(35% 0.16 230); } + .art-eyebrow { font: 500 10px "IBM Plex Mono", monospace; letter-spacing: 2px; text-transform: uppercase; color: oklch(55% 0.10 220); margin-bottom: 12px; } + .art-title { font: 700 32px/1.15 "Georgia", "Times New Roman", serif; color: oklch(20% 0.06 235); letter-spacing: -0.6px; margin: 0 0 14px; } + .art-byline { font: italic 13px/1.5 "Georgia", serif; color: oklch(48% 0.04 235); margin: 0 0 32px; padding-bottom: 16px; border-bottom: 1px solid oklch(88% 0.015 220); } + .art-body { font-size: 15px; line-height: 1.75; color: oklch(22% 0.03 240); } + .art-body p { margin: 0 0 18px; text-wrap: pretty; } + .art-body p:first-of-type::first-letter { font-size: 56px; font-weight: 700; float: left; line-height: 0.9; padding: 4px 8px 0 0; color: oklch(40% 0.14 220); font-family: "Georgia", serif; } + .art-body em { color: oklch(35% 0.10 230); } + .art-body a, .art-body .ilink { color: oklch(40% 0.16 230); text-decoration: underline; text-decoration-color: oklch(70% 0.13 220 / 0.4); text-underline-offset: 2px; cursor: pointer; } + .art-body h2 { font: 700 18px "Georgia", serif; color: oklch(22% 0.06 235); margin: 32px 0 12px; letter-spacing: -0.2px; } + .art-body blockquote { margin: 24px 0; padding: 0 0 0 18px; border-left: 3px solid oklch(78% 0.10 220); font-style: italic; color: oklch(38% 0.06 230); } + .art-body code { font: 13px "IBM Plex Mono", monospace; background: oklch(94% 0.01 220); padding: 1px 5px; border-radius: 3px; color: oklch(35% 0.10 230); } + .art-foot { margin-top: 40px; padding-top: 20px; border-top: 1px solid oklch(88% 0.015 220); font: 12px "IBM Plex Mono", monospace; color: oklch(55% 0.06 230); display: flex; justify-content: space-between; align-items: center; } + .art-foot .more { color: oklch(40% 0.16 230); cursor: pointer; } </style> </head> <body> @@ -193,8 +295,8 @@ <div class="icon" data-open="servers"><div class="glyph" style="background: var(--icon-green)">🖥</div><div class="label">My Servers</div></div> <div class="icon" data-open="podcast"><div class="glyph" style="background: var(--icon-silver)">🎙</div><div class="label">REEL MOUTH</div></div> <div class="icon" data-open="films"><div class="glyph" style="background: var(--icon-pink)">🎞</div><div class="label">Films</div></div> + <div class="icon" data-open="browser"><div class="glyph" style="background: var(--icon-blue)">🌐</div><div class="label">Internet</div></div> <div class="icon" data-open="guestbook"><div class="glyph" style="background: var(--icon-blue)">✉</div><div class="label">Contact</div></div> - <div class="icon" data-open="browser"><div class="glyph" style="background: var(--icon-silver)">🌐</div><div class="label">Articles</div></div> </div> <!-- WINDOWS --> @@ -376,19 +478,50 @@ </div> </div> - <!-- BROWSER / ARTICLE READER --> - <div class="win glass" id="w-browser" style="left: 200px; top: 80px; width: 580px; display: none;"> - <div class="titlebar"> - <div class="dots"><div class="dot r no-drag" onclick="this.closest('.win').style.display='none'"></div><div class="dot y"></div><div class="dot g"></div></div> - <div class="browser-url-bar no-drag" id="browser-url">about:blank</div> - </div> - <div class="browser-bookmarks no-drag"> - <button class="browser-bm no-drag" data-src="articles/library.html" data-title="Personal Library">📚 library</button> - <button class="browser-bm no-drag" data-src="articles/music.html" data-title="Music List">🎵 music list</button> - <button class="browser-bm no-drag" data-src="articles/software.html" data-title="Software & Hardware">💻 software</button> - </div> - <div class="body browser-body" id="browser-content"> - <div style="opacity:0.5;text-align:center;padding:40px 0;font-size:12px;">← click a bookmark to load an article</div> + <!-- BROWSER --> + <div class="win glass" id="w-browser" style="left: 280px; top: 40px; width: 680px; height: 560px; display: none;"> + <div class="browser"> + <div class="titlebar"> + <div class="dots"> + <div class="dot r no-drag" onclick="this.closest('.win').style.display='none'"></div> + <div class="dot y"></div> + <div class="dot g"></div> + </div> + <span id="br-title">fun.tylerhoang.xyz — Notes</span> + </div> + <div class="browser-toolbar no-drag"> + <button class="nav-btn" id="br-back" title="Back" disabled> + <svg width="11" height="11" viewBox="0 0 11 11"><path d="M7.5 1.5 L3 5.5 L7.5 9.5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg> + </button> + <button class="nav-btn" id="br-fwd" title="Forward" disabled> + <svg width="11" height="11" viewBox="0 0 11 11"><path d="M3.5 1.5 L8 5.5 L3.5 9.5" stroke="currentColor" stroke-width="1.8" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg> + </button> + <button class="nav-btn refresh" id="br-reload" title="Reload"> + <svg width="12" height="12" viewBox="0 0 12 12"><path d="M9.5 6 A3.5 3.5 0 1 1 8.5 3.4 M9.8 1.5 L9.8 4 L7.3 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/></svg> + </button> + <button class="nav-btn" id="br-home" title="Home"> + <svg width="12" height="12" viewBox="0 0 12 12"><path d="M2 6 L6 2.5 L10 6 L10 10 L7.5 10 L7.5 7 L4.5 7 L4.5 10 L2 10 Z" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linejoin="round"/></svg> + </button> + <div class="url-bar"> + <span class="lock">🔒</span> + <span class="url-scheme">https://</span><span class="url-host" id="br-host">fun.tylerhoang.xyz</span><span class="url-path" id="br-path">/articles</span> + <span class="caret"></span> + </div> + <button class="nav-btn" id="br-go" title="Go" style="width: auto; padding: 0 10px;">Go</button> + </div> + <div class="browser-bookmarks"> + <div class="bm home" data-go="/articles"><div class="favicon"></div>Notes</div> + <div class="bm" data-go="/articles/nextcloud-and-me"><div class="favicon" style="background: linear-gradient(135deg, oklch(85% 0.14 140), oklch(50% 0.16 150));"></div>self-hosting</div> + <div class="bm" data-go="/articles/learning-cherokee"><div class="favicon" style="background: linear-gradient(135deg, oklch(85% 0.10 350), oklch(55% 0.16 340));"></div>jazz</div> + <div class="bm" data-go="/articles/six-tries-bun-bo-hue"><div class="favicon" style="background: linear-gradient(135deg, oklch(85% 0.14 60), oklch(55% 0.16 35));"></div>cooking</div> + <div class="bm" data-go="/articles/stalker-and-waiting"><div class="favicon" style="background: linear-gradient(135deg, oklch(75% 0.06 280), oklch(35% 0.06 260));"></div>film</div> + </div> + <div class="browser-page" id="br-page"></div> + <div class="browser-status"> + <span id="br-status">Done</span> + <div class="progress" id="br-progress" style="display:none;"><div class="progress-bar"></div></div> + <span>🔒 fun.tylerhoang.xyz · 100%</span> + </div> </div> </div> @@ -421,6 +554,7 @@ document.querySelectorAll('.win').forEach(w => { Aero.makeDraggable(w, w.querySelector('.titlebar')); + Aero.makeResizable(w, { minW: 300, minH: 220 }); }); document.querySelectorAll('.icon').forEach(ic => { @@ -597,35 +731,207 @@ }); } - // browser article loader - function browserLoad(src) { - const content = document.getElementById('browser-content'); - const urlBar = document.getElementById('browser-url'); - document.querySelectorAll('.browser-bm').forEach(b => b.classList.toggle('active', b.dataset.src === src)); - urlBar.textContent = 'fun.tylerhoang.xyz/' + src; - content.innerHTML = '<div style="opacity:0.5;text-align:center;padding:30px 0;font-size:12px;">loading…</div>'; - fetch(src) - .then(r => { if (!r.ok) throw new Error(r.status); return r.text(); }) - .then(html => { - const doc = new DOMParser().parseFromString(html, 'text/html'); - // strip stylesheets, scripts, old styles - doc.querySelectorAll('link[rel="stylesheet"], style, script').forEach(el => el.remove()); - // strip Bootstrap navbar component (the nav bar itself, not the page wrapper) - doc.querySelectorAll('div.navbar').forEach(el => el.remove()); - // strip blink tags, preserving inner HTML (colored spans etc.) - doc.querySelectorAll('blink').forEach(el => { - el.replaceWith(...Array.from(el.childNodes)); - }); - content.innerHTML = doc.body.innerHTML; - content.scrollTop = 0; - }) - .catch(() => { - content.innerHTML = '<div style="opacity:0.5;text-align:center;padding:30px 0;font-size:12px;">couldn\'t load article</div>'; - }); + /* ============= FAUX BROWSER ============= + Articles live in the ARTICLES array below. + Each `body` is plain HTML — use <p>, <h2>, <blockquote>, <em>, <code>, + and <span class="ilink" data-go="/articles/slug"> for in-page links. + ============================================== */ + const ARTICLES = [ + { + slug: 'nextcloud-and-me', + title: 'Why I run my own Nextcloud (and you probably should too)', + date: '2026-04-12', + tag: 'self-hosting', + excerpt: 'After my fourth Dropbox price hike, I bought a $180 mini-PC and never looked back. A field report.', + body: ` + <p>For about six years I paid Dropbox roughly the cost of a nice dinner every month to store photos I'd never look at again. Then one Tuesday in March, an email came: prices going up <em>again</em>. I closed the tab, opened a new one, ordered a Beelink SER5 off Amazon, and started reading the Nextcloud install docs.</p> + <p>What I learned, over the next 96 hours: self-hosting in 2026 is not what it was in 2016. The tooling has caught up with the dream. <code>apt install nextcloud-aio</code>, a TLS cert from Let's Encrypt that renews itself, a dyndns entry I update with a four-line cron job, and suddenly I have my own private cloud. The whole stack draws about 12 watts at idle.</p> + <h2>What I actually use it for</h2> + <p>The boring answer is: file sync. Photos from my phone, documents I'm working on, the world's most over-engineered shopping list shared with Trinh. The interesting answer is that having a server means I keep <em>finding</em> things to put on it — a Forgejo for git mirrors, a Jellyfin for movies I own on blu-ray, a tiny Postgres I use for film-diary metadata.</p> + <blockquote>The point isn't to escape the cloud. The point is that the cloud should be a tool I pick up, not a landlord.</blockquote> + <p>Will it pay for itself? At my Dropbox rate, the hardware breaks even around month nine. But that's not really the math that matters. The math that matters is that the machine sits on a shelf above my monitor and quietly does what I tell it.</p> + <p>If you want to start, start small. A Raspberry Pi 5 and a USB drive will get you 80% of the way. The other 20% is patience and reading <span class="ilink" data-go="/articles/learning-cherokee">man pages on a Saturday morning</span>, which honestly I enjoy.</p> + `, + }, + { + slug: 'learning-cherokee', + title: 'Learning <em>Cherokee</em> at 23', + date: '2026-03-28', + tag: 'jazz', + excerpt: 'Bud Powell\'s Cherokee is famously fast. I\'ve been practicing it at 60 bpm for two months. Some notes.', + body: ` + <p>Bud Powell recorded <em>Cherokee</em> at something like 320 beats per minute. I can play it cleanly at 64. I tell myself this is progress.</p> + <p>The thing nobody warns you about with bebop is that the tempo is the easy part. You can put a metronome on 320 and just <em>hold on</em> to it for a chorus, fudging the notes that don't fit under your fingers. What you can't fake is the <em>line</em> — the way Bud connects an A-section descent through the bridge's modulation up a half-step, and somehow the listener feels the bridge before it arrives.</p> + <h2>The bridge</h2> + <p>Eight bars in B major, dropped into the middle of a tune in B♭. It's a famously sadistic chord change for a saxophone player. For a pianist, it just means thinking in five sharps instead of two flats — annoying but survivable.</p> + <p>My teacher, who is 71 and has played this tune more times than I have eaten dinner, told me to stop practicing the bridge separately. "You'll never make it sound like part of the song," she said. "Play it from the top, slow, every time." So that's what I do. Six minutes per chorus at 64 bpm. I have become very patient with my left hand.</p> + <h2>What I'm learning</h2> + <p>That speed is a side effect of clarity, not the other way around. That the metronome is a witness, not a coach. And that if I can play <em>Cherokee</em> at 200 by my 25th birthday, I will buy myself a very nice steak.</p> + `, + }, + { + slug: 'six-tries-bun-bo-hue', + title: 'Six tries at my mom\'s bún bò huế', + date: '2026-02-14', + tag: 'cooking', + excerpt: 'The broth is the whole game. Here is what I got wrong, and the one thing I finally got right.', + body: ` + <p>My mom doesn't write recipes down. When I ask her how much shrimp paste, she says "a spoon." When I ask which spoon, she sighs.</p> + <p>I have now made bún bò huế six times. The first three were unservable. The fourth was edible but flat. The fifth I overcorrected the lemongrass and it tasted like a candle. The sixth, last Sunday, made Trinh stop scrolling on her phone and say, unprompted, "this is good." I am framing the apron.</p> + <h2>The mistakes</h2> + <p>Attempt one: I used beef stock from a carton. My mother would disown me if she knew. The broth has to be built from the bone — beef shank and pork hock, simmered for at least four hours, skimmed obsessively. You can taste a corner cut. The whole thing is corners.</p> + <p>Attempt three: not enough sả. Lemongrass should bruise your nose when you open the pot. I was being polite with it. Bún bò is not a polite soup.</p> + <h2>The thing I got right</h2> + <p>Annatto oil. Heat the seeds in neutral oil until the oil turns the color of a sunset, then strain. Add a tablespoon to the finished broth right before serving. It is the difference between a soup that <em>looks</em> like bún bò and a soup that <em>is</em> bún bò.</p> + <p>I still can't make it like she does. But I'm closer now than I was in February, and that's the whole project really. <span class="ilink" data-go="/articles/stalker-and-waiting">Patience as a recipe.</span></p> + `, + }, + { + slug: 'stalker-and-waiting', + title: 'Stalker and the unbearable lightness of waiting', + date: '2026-01-30', + tag: 'film', + excerpt: 'Tarkovsky\'s 162-minute walk into a haunted room. A defense of slow cinema from someone with a short attention span.', + body: ` + <p>I have never been good at waiting. I check my phone in elevators. I read the menu before I sit down. So it surprised me — surprises me still — how much I love <em>Stalker</em>, a movie in which approximately nothing happens for the better part of three hours.</p> + <p>The plot, such as it is: three men walk through a forbidden zone toward a room that allegedly grants wishes. They argue. They get tired. They sit on damp stones and look at each other. There is no chase, no twist, no payoff in the way modern cinema teaches us to expect a payoff.</p> + <h2>What Tarkovsky is doing</h2> + <p>He is teaching you to wait. Not as a virtue — Tarkovsky is not a moralist — but as a <em>mode</em>. The camera holds on a face until the face stops performing and starts simply <em>being</em>. The Zone refuses to be interesting on a normal cinematic schedule. By the time the men reach the room, you have spent so long with their breathing that you understand, without being told, why they don't go in.</p> + <blockquote>The room is not the point. The walk is the point. The room is just what gives the walk its shape.</blockquote> + <h2>Why it matters now</h2> + <p>We live in the most aggressive attention economy in human history. Every interface is designed to deliver a payoff before you can decide whether you wanted one. <em>Stalker</em> is the opposite. It says: I have nothing to sell you. Sit with me for three hours and we will see what your mind does when it isn't being fed.</p> + <p>My mind did this: it stopped checking what time it was. By the last hour I had forgotten there was a time at all. Whatever Tarkovsky is, he is a better drug than my phone.</p> + `, + }, + ]; + + const ARTICLE_INDEX = Object.fromEntries(ARTICLES.map(a => [a.slug, a])); + const HOST = 'fun.tylerhoang.xyz'; + const browserHistory = { stack: [], idx: -1 }; + + function fmtDate(iso) { + const d = new Date(iso); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).toLowerCase(); + } + + function renderIndex() { + const items = ARTICLES.map((a, i) => ` + <div class="idx-art" data-go="/articles/${a.slug}"> + <div class="idx-art-num">${String(i + 1).padStart(2, '0')}</div> + <div> + <div class="idx-art-title">${a.title}</div> + <div class="idx-art-excerpt">${a.excerpt}</div> + </div> + <div class="idx-art-meta"> + <div>${fmtDate(a.date)}</div> + <div><span class="idx-art-tag">${a.tag}</span></div> + </div> + </div> + `).join(''); + return ` + <div class="idx-hero"> + <div class="idx-eyebrow">/notes — Tyler Hoang</div> + <h1 class="idx-title">Things I've been <em>thinking about</em>.</h1> + <p class="idx-sub">Half-finished essays from a banker who'd rather be playing piano. New ones land when they land — usually monthly, never on a schedule.</p> + </div> + <div class="idx-list">${items}</div> + `; } - document.querySelectorAll('.browser-bm').forEach(bm => { - bm.addEventListener('click', () => browserLoad(bm.dataset.src)); + + function renderArticle(slug) { + const a = ARTICLE_INDEX[slug]; + if (!a) return `<div class="art-page"><h1 class="art-title">404 — not found</h1><p>That page isn't here.</p><span class="art-back" data-go="/articles">← back to notes</span></div>`; + const i = ARTICLES.findIndex(x => x.slug === slug); + const next = ARTICLES[(i + 1) % ARTICLES.length]; + return ` + <article class="art-page"> + <span class="art-back" data-go="/articles">← all notes</span> + <div class="art-eyebrow">${a.tag} · ${fmtDate(a.date)}</div> + <h1 class="art-title">${a.title}</h1> + <div class="art-byline">by Tyler Hoang · ~${Math.max(2, Math.round(a.body.replace(/<[^>]+>/g,'').split(/\s+/).length / 200))} min read</div> + <div class="art-body">${a.body}</div> + <div class="art-foot"> + <span>↑ thanks for reading</span> + <span class="more" data-go="/articles/${next.slug}">next: ${next.title.replace(/<[^>]+>/g,'')} →</span> + </div> + </article> + `; + } + + function pathToContent(path) { + if (path === '/articles' || path === '/articles/') return renderIndex(); + const m = path.match(/^\/articles\/([a-z0-9-]+)$/); + if (m) return renderArticle(m[1]); + return `<div class="art-page"><h1 class="art-title">404</h1><p>No page at <code>${path}</code>.</p><span class="art-back" data-go="/articles">← back</span></div>`; + } + + function updateChrome(path) { + document.getElementById('br-host').textContent = HOST; + document.getElementById('br-path').textContent = path; + const isArt = /^\/articles\/[a-z0-9-]+$/.test(path); + const slug = isArt ? path.split('/').pop() : null; + const titleTxt = isArt && ARTICLE_INDEX[slug] + ? `${ARTICLE_INDEX[slug].title.replace(/<[^>]+>/g,'')} — fun.tylerhoang.xyz` + : 'Notes — fun.tylerhoang.xyz'; + document.getElementById('br-title').textContent = titleTxt; + document.querySelectorAll('.browser-bookmarks .bm').forEach(bm => { + bm.classList.toggle('active', bm.dataset.go === path); + }); + document.getElementById('br-back').disabled = browserHistory.idx <= 0; + document.getElementById('br-fwd').disabled = browserHistory.idx >= browserHistory.stack.length - 1; + } + + function loadPath(path, pushHist = true) { + const page = document.getElementById('br-page'); + const status = document.getElementById('br-status'); + const progress = document.getElementById('br-progress'); + status.textContent = `Contacting fun.tylerhoang.xyz…`; + progress.style.display = 'block'; + progress.querySelector('.progress-bar').style.width = '20%'; + setTimeout(() => { + progress.querySelector('.progress-bar').style.width = '70%'; + status.textContent = `Reading ${path}…`; + }, 80); + setTimeout(() => { + page.innerHTML = pathToContent(path); + page.scrollTop = 0; + if (pushHist) { + browserHistory.stack = browserHistory.stack.slice(0, browserHistory.idx + 1); + browserHistory.stack.push(path); + browserHistory.idx = browserHistory.stack.length - 1; + } + updateChrome(path); + progress.querySelector('.progress-bar').style.width = '100%'; + setTimeout(() => { + progress.style.display = 'none'; + progress.querySelector('.progress-bar').style.width = '0%'; + status.textContent = 'Done'; + }, 120); + }, 220); + } + + // delegated click handler — anything with data-go inside the browser navigates + document.getElementById('w-browser').addEventListener('click', (e) => { + const t = e.target.closest('[data-go]'); + if (t) { e.preventDefault(); loadPath(t.dataset.go); } + }); + document.getElementById('br-back').addEventListener('click', () => { + if (browserHistory.idx > 0) { browserHistory.idx--; loadPath(browserHistory.stack[browserHistory.idx], false); } + }); + document.getElementById('br-fwd').addEventListener('click', () => { + if (browserHistory.idx < browserHistory.stack.length - 1) { browserHistory.idx++; loadPath(browserHistory.stack[browserHistory.idx], false); } + }); + document.getElementById('br-home').addEventListener('click', () => loadPath('/articles')); + document.getElementById('br-go').addEventListener('click', () => { + loadPath(document.getElementById('br-path').textContent.trim() || '/articles'); + }); + document.getElementById('br-reload').addEventListener('click', (e) => { + const btn = e.currentTarget; + btn.classList.remove('spinning'); void btn.offsetWidth; btn.classList.add('spinning'); + loadPath(browserHistory.stack[browserHistory.idx] || '/articles', false); }); + // initial page load + loadPath('/articles'); // fetch films Aero.fetchFilms() |
