aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--CLAUDE.md70
-rw-r--r--aero.css46
-rw-r--r--aero.js26
-rwxr-xr-xenter.html1
-rw-r--r--index.js1
5 files changed, 143 insertions, 1 deletions
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 0000000..c8c8c62
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,70 @@
+# CLAUDE.md
+
+This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
+
+## What this is
+
+A static personal website at `fun.tylerhoang.xyz` styled as a retro desktop OS. There is no build system, no framework, no package manager. Everything is plain HTML/CSS/JS served directly by a web server.
+
+## Development
+
+Preview locally with any static server:
+
+```bash
+python3 -m http.server 8080
+# or
+npx serve .
+```
+
+Syntax-check JS without running it:
+
+```bash
+node --check aero.js
+node --check index.js
+node --check articles.js
+```
+
+There are no tests and no lint step.
+
+## Architecture
+
+### Entry flow
+
+`enter.html` → welcome splash/gate → user clicks "enter" → `index.html` main desktop
+
+Both pages load `aero.css` and `aero.js`. `index.html` additionally loads `articles.js` then `index.js`.
+
+### Shared library: `aero.js`
+
+Exports everything under `window.Aero`:
+
+- **Background fields**: `spawnBubbles(host, count, speed)`, `spawnShards(host, count, kind, speed)` — generate DOM elements for the ambient animated background. Called once on mount; animation is pure CSS. Both fields are always in the DOM; CSS shows only the active-theme one.
+- **Theme API**: `getTheme()`, `setTheme(t)`, `mountThemeSwitcher()`, `initTheme()` — source of truth is `document.body[data-theme]` = `"aero"` | `"chrome"`, persisted in `localStorage["tyler.theme"]`. A tiny inline script at the top of `<body>` applies the saved theme before paint.
+- **Window management**: `makeDraggable(el, handle)`, `makeResizable(el, options)` — used for all `.win` elements in `index.html`.
+- **UI helpers**: `counterHTML()`, `nowPlayingHTML()`, `animateEq()`, `musicToggleHTML()`, `makeClouds()`, `sparkleCursor()`
+- **API calls**: `fetchLastFm()`, `fetchFilms()`, `fetchVisitorCount()`, `fetchReelMouthFeed()`
+
+### Stylesheets
+
+- `aero.css` — the entire design system. Aero (Frutiger) styles are the base; `body[data-theme="chrome"]` overrides apply the Y2K liquid-metal treatment. All `!important` on chrome overrides is intentional — the base styles set defaults that chrome must override.
+- `index.css` — desktop-specific layout (`.desk`, `.win`, `.taskbar`, `.icons`, `.icon`, etc.)
+
+### Content
+
+- `articles.js` — defines the `ARTICLES` array. Each article is `{ slug, title, date, tag, excerpt, body }` where `body` is an HTML string. The faux browser in `index.html` renders these client-side — there is no server routing for articles.
+
+### Server-side (PHP)
+
+- `counter.php` — visitor counter backed by a flat file (`counter.dat`), deduplicated by 24h cookie.
+- `podcast.php` — RSS proxy for the Reel Mouth podcast feed (Anchor/Spotify), returns `{ art, episodes }`.
+
+### Asset conventions
+
+- Colors: OKLCH throughout. Do not convert to hex/rgb.
+- Fonts: loaded from Google Fonts in each HTML `<head>`. Aero uses Plus Jakarta Sans + IBM Plex Mono + Caveat. Chrome uses Audiowide + Michroma + Space Grotesk + IBM Plex Mono.
+- Images: `img/wallpaper.png` (aero), `img/wallpaper-chrome.png` (chrome), static assets in `img/static/`.
+- Music: `/mus/` — theme-specific tracks swapped live on theme change.
+
+### Adding an article
+
+Add an entry to the `ARTICLES` array in `articles.js`. The browser window in the desktop renders it automatically — no routing changes needed.
diff --git a/aero.css b/aero.css
index 0781382..28322a1 100644
--- a/aero.css
+++ b/aero.css
@@ -66,6 +66,46 @@ html, body {
100% { transform: translate(var(--drift, 30px), -110vh) scale(1.05); opacity: 0; }
}
+/* SHARDS (chrome theme counterpart to bubbles) */
+.shard-field { position: absolute; inset: 0; pointer-events: none; overflow: hidden; z-index: 0; display: none; }
+.chrome-shard {
+ position: absolute; bottom: -170px;
+ background: linear-gradient(135deg,
+ oklch(96% 0.02 240) 0%,
+ oklch(78% 0.04 280) 30%,
+ oklch(45% 0.06 290) 52%,
+ oklch(72% 0.04 240) 75%,
+ oklch(92% 0.02 240) 100%);
+ filter:
+ drop-shadow(0 0 8px oklch(60% 0.20 290 / 0.55))
+ drop-shadow(0 4px 12px oklch(5% 0.03 270 / 0.7));
+ animation: shard-rise linear infinite;
+}
+.chrome-shard.shard { clip-path: polygon(50% 0%, 70% 38%, 56% 100%, 30% 64%); }
+.chrome-shard.blade { clip-path: polygon(50% 0%, 57% 50%, 50% 100%, 43% 50%); }
+.chrome-shard::before {
+ content: "";
+ position: absolute; inset: 0;
+ background: linear-gradient(115deg,
+ transparent 20%,
+ oklch(99% 0.02 200 / 0.65) 42%,
+ oklch(92% 0.08 320 / 0.35) 50%,
+ transparent 68%);
+ background-size: 200% 200%;
+ animation: shard-streak 3s ease-in-out infinite alternate;
+}
+@keyframes shard-rise {
+ 0% { transform: translate(0, 0) rotate(var(--rot0, 0deg)); opacity: 0; }
+ 8% { opacity: 0.95; }
+ 90% { opacity: 0.75; }
+ 100% { transform: translate(var(--drift, 0px), -130vh) rotate(var(--rot1, 120deg)); opacity: 0; }
+}
+@keyframes shard-streak {
+ 0% { background-position: 200% 0%; }
+ 100% { background-position: -20% 0%; }
+}
+body[data-theme="chrome"] .shard-field { display: block; }
+
/* GLOSSY AQUA BUTTON */
.aqua {
position: relative; display: inline-flex; align-items: center; gap: 6px;
@@ -1834,3 +1874,9 @@ body[data-theme="aero"] .mark::after {
pointer-events: none;
opacity: 0.8;
}
+
+/* Disable ambient motion for users who prefer reduced motion */
+@media (prefers-reduced-motion: reduce) {
+ .bubble-field, .shard-field { display: none !important; }
+ .sparkle { display: none !important; }
+}
diff --git a/aero.js b/aero.js
index 5699dd7..f20861e 100644
--- a/aero.js
+++ b/aero.js
@@ -17,6 +17,30 @@ function spawnBubbles(host, count = 22) {
host.appendChild(f);
}
+function spawnShards(host, count = 30, kind = 'shard', speed = 1) {
+ const f = document.createElement('div');
+ f.className = 'shard-field';
+ for (let i = 0; i < count; i++) {
+ const s = document.createElement('div');
+ s.className = 'chrome-shard ' + kind;
+ const w = kind === 'blade' ? 8 + Math.random() * 14 : 16 + Math.random() * 34;
+ const h = kind === 'blade' ? 64 + Math.random() * 130 : 36 + Math.random() * 92;
+ s.style.width = w + 'px';
+ s.style.height = h + 'px';
+ s.style.left = Math.random() * 100 + '%';
+ s.style.setProperty('--drift', ((Math.random() - 0.5) * 170) + 'px');
+ const rot0 = Math.round(Math.random() * 360);
+ const spin = (Math.random() < 0.5 ? -1 : 1) * (110 + Math.random() * 250);
+ s.style.setProperty('--rot0', rot0 + 'deg');
+ s.style.setProperty('--rot1', (rot0 + spin) + 'deg');
+ s.style.animationDuration = ((18 + Math.random() * 16) / speed) + 's';
+ s.style.animationDelay = (-Math.random() * 30) + 's';
+ f.appendChild(s);
+ }
+ host.appendChild(f);
+ return f;
+}
+
function makeClouds(host) {
const svg = `<svg viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice" style="width:100%;height:100%;">
<defs>
@@ -270,7 +294,7 @@ async function fetchReelMouthFeed(limit = 6) {
}
window.Aero = {
- spawnBubbles, makeClouds, sparkleCursor, makeDraggable, makeResizable,
+ spawnBubbles, spawnShards, makeClouds, sparkleCursor, makeDraggable, makeResizable,
counterHTML, nowPlayingHTML, animateEq, musicToggleHTML,
// theme api
getTheme, setTheme, mountThemeSwitcher, initTheme, THEMES,
diff --git a/enter.html b/enter.html
index 38f8146..170da73 100755
--- a/enter.html
+++ b/enter.html
@@ -216,6 +216,7 @@
<script>
Aero.initTheme();
Aero.spawnBubbles(document.getElementById('bub-stage'), 26);
+ Aero.spawnShards(document.getElementById('bub-stage'), 30, 'shard', 1.5);
Aero.sparkleCursor();
Aero.mountThemeSwitcher();
</script>
diff --git a/index.js b/index.js
index 28d22e6..06d1f61 100644
--- a/index.js
+++ b/index.js
@@ -2,6 +2,7 @@
const desk = document.getElementById('desk');
Aero.makeClouds(document.getElementById('clouds'));
Aero.spawnBubbles(desk, 24);
+ Aero.spawnShards(desk, 30, 'shard', 1.5);
Aero.sparkleCursor();
Aero.mountThemeSwitcher();