commit 899d9d7c13e85b99cae8edc73943230098c2809a Author: sHa Date: Sat Feb 21 13:53:46 2026 +0200 feat: implement evolution and gradient systems with settings modal - Add EvolutionController to manage spawning and physics of icons. - Introduce GradientController for animated gradient backgrounds. - Create iconLoader for fetching and caching SVG icons. - Implement modal functionality for settings adjustments. - Connect UI controls to controllers via SettingsController. - Add CSS styles for gradient animations, icons, and modal. - Create utility functions for color manipulation in gradients. diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2a8999e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,230 @@ +# devpage — Agent Context File + +This file is a compact reference for AI agents working on this project. +Read it before making changes. It covers purpose, architecture, patterns, and current state. + +--- + +## What this project is + +A personal developer page — a single-page app with an animated gradient background +and a floating icon "evolution" system. Icons spawn at random intervals, drift +around the screen with chaotic physics, and bounce off viewport edges. +A settings modal lets the user tune the gradient colour, animation speed, icon +movement speed, and gradient rotation. + +**It is intentionally a growing project.** The base page and evolution system are +done; future work will extend icon variety, add icon interactions, and build out +the main content area. + +--- + +## Tech constraints + +- Vanilla JS — no framework, no bundler, no build step. +- Pure ES modules (`type="module"` in HTML). All imports use relative paths. +- Requires a local HTTP server — `fetch()` calls fail on `file://`. + Run with `npx serve .` or `python3 -m http.server` then open `localhost`. +- Browser target: modern evergreen (CSS `@property`, `backdrop-filter`, `scale` + as standalone property, private class fields all required). + +--- + +## File map + +``` +index.html ← HTML shell + all CSS links + module entry point +src/ + js/ + main.js ← Entry point. Instantiates all controllers, boots them. + constants.js ← Single source of truth for all numeric/flag defaults. + gradient.js ← GradientController. Drives CSS vars on . + modal.js ← ModalController. Open/close, focus trap, Escape dismiss. + settings.js ← SettingsController. UI → controller bridge (no own state). + evolution.js ← EvolutionController. Spawn scheduler + rAF physics loop. + entity.js ← Entity class. One floating icon: physics + DOM lifecycle. + iconLoader.js ← fetch() + Map cache for SVGs. normalizeSvg() strips attrs. + utils/ + colorUtils.js ← hexToHSL, hslToHex, deriveGradientPair. + styles/ + main.css ← Base reset, typography (DM Mono), settings button. + gradient.css ← @property --gradient-angle, keyframes, body animation. + modal.css ← Glass morphism overlay, controls, toggle switch. + icons.css ← .evolution-container, .icon-entity, appear transition. + data/ + icons.json ← Icon registry: groups, per-icon metadata, type colours, spawn config. + icons/ + *.svg ← 34 SVG icons (Solar icon set, 24×24 viewBox, currentColor). +``` + +--- + +## Architecture + +### Controller pattern + +Each domain has one class. No shared mutable globals. + +``` +main.js + ├── GradientController gradient.js state: color, speed, rotating + ├── ModalController modal.js state: open/closed, trigger ref + ├── SettingsController settings.js no state — reads DOM, forwards to others + └── EvolutionController evolution.js state: entities[], moveSpeed, timers + └── Entity[] entity.js state: x, y, vx, vy, alive, DOM refs +``` + +### Data flow + +``` +User interaction (slider/picker/toggle) + → settings.js listener + → gradient.setColor() / gradient.setSpeed() / evolution.setMoveSpeed() + → CSS custom property on OR evolution speed multiplier updated live +``` + +### CSS custom properties (set on `` by GradientController) + +``` +--color-1 first gradient colour (derived from user's base colour) +--color-2 second gradient colour (hue-shifted companion) +--anim-duration animation duration in seconds (maps speed 1-10 → ~32s-3.5s) +--gradient-angle registered @property , animated by gradientRotate keyframe +``` + +--- + +## Key patterns and why + +### Two-div entity structure +Each entity uses an outer div for JS position (`transform: translate()`) and an +inner div for the CSS appear animation (`scale` standalone property). Separating +them prevents JS transform writes from interrupting the CSS transition. + +```html +
← JS sets transform: translate(x, y) every frame +
← CSS transition: scale 0→1 on [data-state="alive"] + +
+
+``` + +### Appear animation trigger +`bodyEl.dataset.state = 'spawning'` is set synchronously before mount. +`dataset.state = 'alive'` is set two rAF ticks later. This guarantees the +browser has painted the initial `scale: 0` state before the transition fires. + +### Rotation animation without restart +Both `gradientFlow` and `gradientRotate` animations are always declared on ``. +Rotation starts with `animation-play-state: paused`. Toggling `.gradient-rotating` +class switches it to `running`. This avoids restarting `gradientFlow` on toggle. + +### Edge bounce +`Math.abs()` trick ensures correct direction regardless of penetration depth: +```js +if (x - h <= 0) { x = h; vx = Math.abs(vx); } // left wall → go right +if (x + h >= vw) { x = vw - h; vx = -Math.abs(vx); } // right wall → go left +``` + +### Colour derivation +`deriveGradientPair(hex)` in `colorUtils.js` produces a two-colour gradient from +one base colour using HSL math. Colour 1: same hue, deeper (sat ×1.15, light ×0.62). +Colour 2: hue +35°, lighter (sat ×0.88, light ×1.48, max 78). + +### SVG loading +`iconLoader.js` fetches each SVG once via `fetch()` and caches in a `Map`. +`normalizeSvg()` strips: XML declaration, HTML comments, `width=` attr, `height=` attr. +This lets CSS control icon size. All icons use `fill="currentColor"` (except +`display-line-duotone.svg` which uses `stroke="currentColor"`). + +### icons.json loaded at runtime +`import … assert { type: 'json' }` has inconsistent browser support without a bundler. +`EvolutionController` loads `icons.json` via `fetch()` inside `#loadIconsData()`. + +--- + +## Defaults (source of truth: `src/js/constants.js`) + +```js +GRADIENT_COLOR: '#4d22b3' // deep purple +GRADIENT_SPEED: 2 // slider value 1-10 +GRADIENT_ROTATION: false +SPAWN_DELAY_MIN: 3_000 // ms before first/next icon spawns +SPAWN_DELAY_MAX: 20_000 // ms +ICON_SIZE: 24 // px +ICON_HALF: 12 // px, used in edge bounce math +MOVE_SPEED: 5 // slider default; multiplier = sliderValue / MOVE_SPEED +BASE_SPEED: 0.8 // px/frame at multiplier 1.0 +MAX_SPEED_FACTOR: 2.5 // max speed = BASE_SPEED × MAX_SPEED_FACTOR +APPEAR_DURATION: 600 // ms, scale 0→1 CSS transition +DRIFT_CHANCE: 0.02 // probability per frame of a velocity kick +DRIFT_MAGNITUDE: 0.25 // max |Δv| per kick +``` + +--- + +## Icon inventory + +34 SVG files in `src/icons/`. 9 are registered in `src/data/icons.json`. +The other 25 are present on disk but not yet wired into the evolution system. + +### Registered in icons.json + +| filename | label | type | group | +|----------------------------------|-------------|---------|----------| +| dna-bold-duotone.svg | DNA | good | biology | +| bug-bold-duotone.svg | Bug | bad | tech | +| buildings-3-bold-duotone.svg | Buildings | good | economy | +| chat-round-dots-bold-duotone.svg | Chat | neutral | social | +| chat-round-money-bold-duotone.svg| Chat Money | neutral | economy | +| chat-round-unread-bold-duotone.svg| Unread Chat| neutral | social | +| database-bold-duotone.svg | Database | good | tech | +| delivery-bold-duotone.svg | Delivery | good | economy | +| display-line-duotone.svg | Display | neutral | tech | + +**Note:** `display-line-duotone.svg` uses `stroke` not `fill`. Tinting via `color` +CSS property works, but the visual style differs from the fill-based icons. + +### On disk, not yet in icons.json (25 files) + +android-old, angular, app-store, apple-brand, claude, code-1, docker, donut-bold-duotone, +face-scan-square-bold-duotone, filters-bold-duotone, gamepad-bold-duotone, git, github, +go, javascript, kubernetes, laravel, mysql, nodejs, open-ai, php, postgresql, python, +signal-app, vs-code + +To register any of these: add an entry to `icons.icons` in `icons.json` following the +existing schema `{ "label": "…", "type": "good|bad|neutral", "group": "…" }`. +Create a new group in `icons.groups` if needed. + +--- + +## Current spawn behaviour + +Only `dna-bold-duotone` spawns. The icon is set in `icons.json → spawn.initial`. +`EvolutionController.#spawnEntity()` always uses `spawn.initial` — it does not yet +pick from the full icon registry. This is intentional: the spawn logic is the next +thing to extend. + +--- + +## Settings modal controls + +| Element ID | Type | Default | Wired to | +|--------------------|---------|---------|-------------------------------------| +| `colorPicker` | color | #4d22b3 | `gradient.setColor(hex)` | +| `speedSlider` | range 1-10 | 2 | `gradient.setSpeed(n)` | +| `moveSpeedSlider` | range 1-10 | 5 | `evolution.setMoveSpeed(n)` | +| `rotationToggle` | checkbox| false | `gradient.toggleRotation(bool)` | + +--- + +## Known gaps / next steps + +1. **Spawn variety** — `#spawnEntity` always spawns `spawn.initial`. Extend to pick + randomly from `icons.icons` (or weighted by type) to get diverse entities. +2. **icons.json completeness** — 25 icons on disk are unregistered. Add them to + unlock their use in spawning. +3. **Icon interactions** — no collision detection between entities yet. +4. **Main content area** — `
` is empty. Reserved for content. +5. **Entity cap** — no maximum entity count; they accumulate indefinitely. +6. **Persistence** — settings reset on page reload. No localStorage yet. diff --git a/index.html b/index.html new file mode 100644 index 0000000..51f8aab --- /dev/null +++ b/index.html @@ -0,0 +1,103 @@ + + + + + + + devpage + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + diff --git a/src/data/icons.json b/src/data/icons.json new file mode 100644 index 0000000..799e2f2 --- /dev/null +++ b/src/data/icons.json @@ -0,0 +1,32 @@ +{ + "_comment": "Icon registry for the evolution system. Each icon has a type (good/bad/neutral) and belongs to a group. Types define the visual colour tint. Spawn config controls initial entity creation.", + + "groups": [ + { "id": "biology", "label": "Biology", "icons": ["dna-bold-duotone"] }, + { "id": "tech", "label": "Technology", "icons": ["database-bold-duotone", "display-line-duotone", "bug-bold-duotone"] }, + { "id": "social", "label": "Social", "icons": ["chat-round-dots-bold-duotone", "chat-round-unread-bold-duotone"] }, + { "id": "economy", "label": "Economy", "icons": ["chat-round-money-bold-duotone", "delivery-bold-duotone", "buildings-3-bold-duotone"] } + ], + + "icons": { + "dna-bold-duotone": { "label": "DNA", "type": "good", "group": "biology" }, + "bug-bold-duotone": { "label": "Bug", "type": "bad", "group": "tech" }, + "buildings-3-bold-duotone": { "label": "Buildings", "type": "good", "group": "economy" }, + "chat-round-dots-bold-duotone": { "label": "Chat", "type": "neutral", "group": "social" }, + "chat-round-money-bold-duotone": { "label": "Chat Money", "type": "neutral", "group": "economy" }, + "chat-round-unread-bold-duotone": { "label": "Unread Chat", "type": "neutral", "group": "social" }, + "database-bold-duotone": { "label": "Database", "type": "good", "group": "tech" }, + "delivery-bold-duotone": { "label": "Delivery", "type": "good", "group": "economy" }, + "display-line-duotone": { "label": "Display", "type": "neutral", "group": "tech" } + }, + + "types": { + "good": { "label": "Beneficial", "color": "#a8ffb8" }, + "bad": { "label": "Harmful", "color": "#ffb0a8" }, + "neutral": { "label": "Neutral", "color": "#c4d8ff" } + }, + + "spawn": { + "initial": "dna-bold-duotone" + } +} diff --git a/src/icons/android-old.svg b/src/icons/android-old.svg new file mode 100644 index 0000000..c0adc69 --- /dev/null +++ b/src/icons/android-old.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/angular.svg b/src/icons/angular.svg new file mode 100644 index 0000000..2198417 --- /dev/null +++ b/src/icons/angular.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/app-store.svg b/src/icons/app-store.svg new file mode 100644 index 0000000..29cf009 --- /dev/null +++ b/src/icons/app-store.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/apple-brand.svg b/src/icons/apple-brand.svg new file mode 100644 index 0000000..55ccaf1 --- /dev/null +++ b/src/icons/apple-brand.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/avocado.svg b/src/icons/avocado.svg new file mode 100644 index 0000000..ced355e --- /dev/null +++ b/src/icons/avocado.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/bacteria.svg b/src/icons/bacteria.svg new file mode 100644 index 0000000..d9f08f2 --- /dev/null +++ b/src/icons/bacteria.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/bootstrap-5.svg b/src/icons/bootstrap-5.svg new file mode 100644 index 0000000..68f0048 --- /dev/null +++ b/src/icons/bootstrap-5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/bug.svg b/src/icons/bug.svg new file mode 100644 index 0000000..3ad459a --- /dev/null +++ b/src/icons/bug.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/buildings-3-bold-duotone.svg b/src/icons/buildings-3-bold-duotone.svg new file mode 100644 index 0000000..ed4a0e4 --- /dev/null +++ b/src/icons/buildings-3-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/chat-round-dots-bold-duotone.svg b/src/icons/chat-round-dots-bold-duotone.svg new file mode 100644 index 0000000..bb234e6 --- /dev/null +++ b/src/icons/chat-round-dots-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/chat-round-money-bold-duotone.svg b/src/icons/chat-round-money-bold-duotone.svg new file mode 100644 index 0000000..86dc2cd --- /dev/null +++ b/src/icons/chat-round-money-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/chat-round-unread-bold-duotone.svg b/src/icons/chat-round-unread-bold-duotone.svg new file mode 100644 index 0000000..a2e711e --- /dev/null +++ b/src/icons/chat-round-unread-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/claude.svg b/src/icons/claude.svg new file mode 100644 index 0000000..c709a4b --- /dev/null +++ b/src/icons/claude.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/code-1.svg b/src/icons/code-1.svg new file mode 100644 index 0000000..a9ec204 --- /dev/null +++ b/src/icons/code-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/css3.svg b/src/icons/css3.svg new file mode 100644 index 0000000..4986e47 --- /dev/null +++ b/src/icons/css3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/database-bold-duotone.svg b/src/icons/database-bold-duotone.svg new file mode 100644 index 0000000..ef253ca --- /dev/null +++ b/src/icons/database-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/delivery-bold-duotone.svg b/src/icons/delivery-bold-duotone.svg new file mode 100644 index 0000000..24714ca --- /dev/null +++ b/src/icons/delivery-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/display-line-duotone.svg b/src/icons/display-line-duotone.svg new file mode 100644 index 0000000..cdfaad9 --- /dev/null +++ b/src/icons/display-line-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/dna-bold-duotone.svg b/src/icons/dna-bold-duotone.svg new file mode 100644 index 0000000..c183a5c --- /dev/null +++ b/src/icons/dna-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/docker.svg b/src/icons/docker.svg new file mode 100644 index 0000000..0e77fe8 --- /dev/null +++ b/src/icons/docker.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/donut-bold-duotone.svg b/src/icons/donut-bold-duotone.svg new file mode 100644 index 0000000..6d40495 --- /dev/null +++ b/src/icons/donut-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/face-scan-square-bold-duotone.svg b/src/icons/face-scan-square-bold-duotone.svg new file mode 100644 index 0000000..eea9472 --- /dev/null +++ b/src/icons/face-scan-square-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/filters-bold-duotone.svg b/src/icons/filters-bold-duotone.svg new file mode 100644 index 0000000..85b49f2 --- /dev/null +++ b/src/icons/filters-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/gamepad-bold-duotone.svg b/src/icons/gamepad-bold-duotone.svg new file mode 100644 index 0000000..0b9e311 --- /dev/null +++ b/src/icons/gamepad-bold-duotone.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/git.svg b/src/icons/git.svg new file mode 100644 index 0000000..56a4226 --- /dev/null +++ b/src/icons/git.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/github.svg b/src/icons/github.svg new file mode 100644 index 0000000..fd5be01 --- /dev/null +++ b/src/icons/github.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/go.svg b/src/icons/go.svg new file mode 100644 index 0000000..b4f537f --- /dev/null +++ b/src/icons/go.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/html5.svg b/src/icons/html5.svg new file mode 100644 index 0000000..ef83010 --- /dev/null +++ b/src/icons/html5.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/javascript.svg b/src/icons/javascript.svg new file mode 100644 index 0000000..1a9b39e --- /dev/null +++ b/src/icons/javascript.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/kubernetes.svg b/src/icons/kubernetes.svg new file mode 100644 index 0000000..12c9f17 --- /dev/null +++ b/src/icons/kubernetes.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/laravel.svg b/src/icons/laravel.svg new file mode 100644 index 0000000..aa66abc --- /dev/null +++ b/src/icons/laravel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/mysql.svg b/src/icons/mysql.svg new file mode 100644 index 0000000..7157090 --- /dev/null +++ b/src/icons/mysql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/nodejs.svg b/src/icons/nodejs.svg new file mode 100644 index 0000000..19684a0 --- /dev/null +++ b/src/icons/nodejs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/open-ai.svg b/src/icons/open-ai.svg new file mode 100644 index 0000000..a95ad9c --- /dev/null +++ b/src/icons/open-ai.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/php.svg b/src/icons/php.svg new file mode 100644 index 0000000..1bd3404 --- /dev/null +++ b/src/icons/php.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/postgresql.svg b/src/icons/postgresql.svg new file mode 100644 index 0000000..812a78d --- /dev/null +++ b/src/icons/postgresql.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/python.svg b/src/icons/python.svg new file mode 100644 index 0000000..6121b08 --- /dev/null +++ b/src/icons/python.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/react.svg b/src/icons/react.svg new file mode 100644 index 0000000..1990bc4 --- /dev/null +++ b/src/icons/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/signal-app.svg b/src/icons/signal-app.svg new file mode 100644 index 0000000..bee38bc --- /dev/null +++ b/src/icons/signal-app.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/svelte.svg b/src/icons/svelte.svg new file mode 100644 index 0000000..b85b099 --- /dev/null +++ b/src/icons/svelte.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/virus-filled.svg b/src/icons/virus-filled.svg new file mode 100644 index 0000000..e1a3f5a --- /dev/null +++ b/src/icons/virus-filled.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/vs-code.svg b/src/icons/vs-code.svg new file mode 100644 index 0000000..2a63372 --- /dev/null +++ b/src/icons/vs-code.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/icons/vuejs.svg b/src/icons/vuejs.svg new file mode 100644 index 0000000..74d7179 --- /dev/null +++ b/src/icons/vuejs.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/js/constants.js b/src/js/constants.js new file mode 100644 index 0000000..9c7304f --- /dev/null +++ b/src/js/constants.js @@ -0,0 +1,43 @@ +/** + * constants.js + * Single source of truth for all application defaults and tuneable values. + * Import from here — never hardcode magic numbers elsewhere. + */ + +export const DEFAULTS = { + + // ── Gradient background ──────────────────────────────────── + /** Base colour the gradient is derived from */ + GRADIENT_COLOR: '#4d22b3', + /** Flow animation speed, slider 1–10 */ + GRADIENT_SPEED: 2, + /** Clockwise rotation off by default */ + GRADIENT_ROTATION: true, + + // ── Icon evolution ───────────────────────────────────────── + /** Min ms before the first / next icon spawns */ + SPAWN_DELAY_MIN: 3_000, + /** Max ms before the first / next icon spawns */ + SPAWN_DELAY_MAX: 20_000, + + /** Final rendered icon size in px */ + ICON_SIZE: 24, + /** Half of ICON_SIZE — used for edge/collision math */ + ICON_HALF: 12, + + /** Movement speed slider default (1–10) */ + MOVE_SPEED: 5, + /** Base px-per-frame at MOVE_SPEED */ + BASE_SPEED: 0.8, + /** Hard cap: max speed = BASE_SPEED × this factor */ + MAX_SPEED_FACTOR: 2.5, + + /** ms for dot → full-icon grow animation */ + APPEAR_DURATION: 600, + + /** Probability per frame of a random velocity "kick" (chaotic drift) */ + DRIFT_CHANCE: 0.02, + /** Max |Δv| applied on each drift kick */ + DRIFT_MAGNITUDE: 0.25, + +}; diff --git a/src/js/entity.js b/src/js/entity.js new file mode 100644 index 0000000..413c425 --- /dev/null +++ b/src/js/entity.js @@ -0,0 +1,157 @@ +/** + * entity.js + * A single icon living on screen — owns its own physics state and DOM node. + * + * Physics model: + * - Constant velocity with small random "drift kicks" each frame for + * organic, chaotic-feeling motion (not perfectly straight lines). + * - Edge detection: velocity component is reflected (±abs) on contact, + * ensuring the bounce always sends the icon back into the viewport. + * + * DOM structure: + *
← position via JS transform + *
← scale animation via CSS transition + * ← injected SVG, sized by CSS + *
+ *
+ * + * Separating the position element from the scale element avoids any + * conflict between JS-driven transform updates and CSS transitions. + */ + +import { loadIcon } from './iconLoader.js'; +import { DEFAULTS } from './constants.js'; + +export class Entity { + // ── Physics ────────────────────────────────────────────── + /** @type {number} */ x; + /** @type {number} */ y; + /** @type {number} */ vx; + /** @type {number} */ vy; + + // ── Identity ───────────────────────────────────────────── + /** @type {string} */ name; + /** @type {string} */ type; + /** @type {string} */ color; + /** @type {boolean} */ alive = true; + + // ── DOM ─────────────────────────────────────────────────── + /** @type {HTMLElement|null} */ el = null; + /** @type {HTMLElement|null} */ bodyEl = null; + + /** + * @param {{ name: string, type: string, color: string, + * x: number, y: number, vx: number, vy: number }} config + */ + constructor({ name, type, color, x, y, vx, vy }) { + this.name = name; + this.type = type; + this.color = color; + this.x = x; this.y = y; + this.vx = vx; this.vy = vy; + } + + // ── Public API ───────────────────────────────────────────── + + /** + * Build the DOM element, fetch the SVG, attach to container. + * Triggers the dot → icon appear animation after mount. + * + * @param {HTMLElement} container + */ + async mount(container) { + // Outer div: physics position handle + this.el = document.createElement('div'); + this.el.className = 'icon-entity'; + + // Inner div: CSS scale transition target + this.bodyEl = document.createElement('div'); + this.bodyEl.className = 'icon-entity__body'; + this.bodyEl.dataset.state = 'spawning'; + + // Inject SVG + try { + this.bodyEl.innerHTML = await loadIcon(this.name); + } catch (err) { + console.warn(`[Entity] Could not load icon "${this.name}":`, err); + this.alive = false; + return; + } + + this.el.appendChild(this.bodyEl); + container.appendChild(this.el); + + // Set initial colour and position + this.el.style.color = this.color; + this._applyTransform(); + + // Two rAF ticks ensure the browser has painted the spawning state + // before adding the 'alive' class, guaranteeing the CSS transition fires. + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (this.bodyEl) this.bodyEl.dataset.state = 'alive'; + }); + }); + } + + /** + * Advance physics by one frame. + * Call this inside your rAF loop. + * + * @param {number} speedMultiplier scales BASE_SPEED (e.g. slider / 5) + */ + update(speedMultiplier) { + if (!this.el || !this.alive) return; + + // Move + this.x += this.vx * speedMultiplier; + this.y += this.vy * speedMultiplier; + + // Bounce off viewport edges + const { ICON_HALF: h } = DEFAULTS; + const vw = window.innerWidth; + const vh = window.innerHeight; + + if (this.x - h <= 0) { this.x = h; this.vx = Math.abs(this.vx); } + if (this.x + h >= vw) { this.x = vw - h; this.vx = -Math.abs(this.vx); } + if (this.y - h <= 0) { this.y = h; this.vy = Math.abs(this.vy); } + if (this.y + h >= vh) { this.y = vh - h; this.vy = -Math.abs(this.vy); } + + // Chaotic drift: small random velocity kick, applied with low probability + if (Math.random() < DEFAULTS.DRIFT_CHANCE) { + this.vx += (Math.random() - 0.5) * DEFAULTS.DRIFT_MAGNITUDE; + this.vy += (Math.random() - 0.5) * DEFAULTS.DRIFT_MAGNITUDE; + + // Clamp to max speed so drift can't accelerate indefinitely + const spd = Math.hypot(this.vx, this.vy); + const max = DEFAULTS.BASE_SPEED * DEFAULTS.MAX_SPEED_FACTOR; + if (spd > max) { + this.vx = (this.vx / spd) * max; + this.vy = (this.vy / spd) * max; + } + } + + this._applyTransform(); + } + + /** Remove this entity from the DOM and mark it as dead. */ + destroy() { + this.alive = false; + this.el?.remove(); + this.el = null; + this.bodyEl = null; + } + + // ── Private ──────────────────────────────────────────────── + + /** + * Write the current (x, y) position to the DOM via transform. + * Using transform keeps this on the compositor thread (no layout). + * We offset by ICON_HALF so (x, y) represents the icon's centre point. + */ + _applyTransform() { + if (!this.el) return; + const { ICON_HALF: h } = DEFAULTS; + this.el.style.transform = `translate(${this.x - h}px, ${this.y - h}px)`; + } +} diff --git a/src/js/evolution.js b/src/js/evolution.js new file mode 100644 index 0000000..cb154f0 --- /dev/null +++ b/src/js/evolution.js @@ -0,0 +1,152 @@ +/** + * evolution.js + * Orchestrates the icon evolution system: + * + * 1. Spawning — after a random interval (SPAWN_DELAY_MIN … SPAWN_DELAY_MAX), + * a dna-bold-duotone icon appears at a random viewport position. + * After each spawn the timer resets for the next one. + * + * 2. Physics loop — a single rAF loop drives all live entities each frame. + * + * 3. Speed control — setMoveSpeed(1–10) scales entity velocity in real time. + * + * The icon to spawn first is declared in icons.json under spawn.initial. + * All icon metadata (type, colour) is also read from icons.json. + */ + +import { Entity } from './entity.js'; +import { preloadIcons } from './iconLoader.js'; +import { DEFAULTS } from './constants.js'; + +const ICONS_DATA_URL = 'src/data/icons.json'; + +export class EvolutionController { + /** @type {Entity[]} */ #entities = []; + /** @type {HTMLElement|null} */ #container = null; + /** @type {number} */ #moveSpeed = DEFAULTS.MOVE_SPEED; + /** @type {ReturnType|null} */ #spawnTimer = null; + /** @type {number|null} */ #animFrame = null; + /** @type {boolean} */ #running = false; + /** @type {object|null} */ #iconsData = null; + + // ── Public API ───────────────────────────────────────────── + + /** + * Initialise: load icon data, warm SVG cache, kick off spawning & loop. + * + * @param {HTMLElement} container All entity DOM nodes are appended here. + */ + async init(container) { + this.#container = container; + + this.#iconsData = await this.#loadIconsData(); + + // Warm the SVG cache for the initial icon so first spawn is instant + const initialName = this.#iconsData.spawn.initial; + preloadIcons([initialName]); + + this.#scheduleNextSpawn(); + this.#startLoop(); + } + + /** + * Update the movement speed multiplier used by all entities. + * Maps slider value (1–10) to a physics multiplier around 1.0 at speed 5. + * + * @param {number} speed integer 1–10 + */ + setMoveSpeed(speed) { + this.#moveSpeed = speed; + } + + /** Stop all timers and the rAF loop; remove all entities from the DOM. */ + stop() { + this.#running = false; + if (this.#spawnTimer !== null) clearTimeout(this.#spawnTimer); + if (this.#animFrame !== null) cancelAnimationFrame(this.#animFrame); + this.#entities.forEach(e => e.destroy()); + this.#entities = []; + } + + /** Read-only access to current entity count (useful for debugging). */ + get entityCount() { return this.#entities.length; } + + // ── Spawning ──────────────────────────────────────────────── + + /** + * Schedule the next spawn after a random delay within the configured range. + * Self-resets after each spawn so the process continues indefinitely. + */ + #scheduleNextSpawn() { + const min = DEFAULTS.SPAWN_DELAY_MIN; + const max = DEFAULTS.SPAWN_DELAY_MAX; + const delay = min + Math.random() * (max - min); + + this.#spawnTimer = setTimeout(async () => { + await this.#spawnEntity(); + this.#scheduleNextSpawn(); + }, delay); + } + + /** Create and mount one icon entity at a random viewport position. */ + async #spawnEntity() { + if (!this.#container || !this.#iconsData) return; + + const name = this.#iconsData.spawn.initial; + const iconMeta = this.#iconsData.icons[name]; + const typeMeta = this.#iconsData.types[iconMeta.type]; + + // Random spawn position — keep a margin so the icon starts fully on-screen + const margin = DEFAULTS.ICON_SIZE * 2; + const vw = window.innerWidth; + const vh = window.innerHeight; + const x = margin + Math.random() * (vw - margin * 2); + const y = margin + Math.random() * (vh - margin * 2); + + // Random initial direction, normalised to BASE_SPEED + const angle = Math.random() * Math.PI * 2; + const vx = Math.cos(angle) * DEFAULTS.BASE_SPEED; + const vy = Math.sin(angle) * DEFAULTS.BASE_SPEED; + + const entity = new Entity({ + name, type: iconMeta.type, color: typeMeta.color, + x, y, vx, vy, + }); + + await entity.mount(this.#container); + + if (entity.alive) { + this.#entities.push(entity); + } + } + + // ── Physics loop ──────────────────────────────────────────── + + #startLoop() { + this.#running = true; + + const tick = () => { + if (!this.#running) return; + + // speedMultiplier: normalised so that slider=5 → multiplier=1.0 + const multiplier = this.#moveSpeed / DEFAULTS.MOVE_SPEED; + + for (const entity of this.#entities) { + entity.update(multiplier); + } + + this.#animFrame = requestAnimationFrame(tick); + }; + + this.#animFrame = requestAnimationFrame(tick); + } + + // ── Data loading ──────────────────────────────────────────── + + /** Fetch and return the icons.json configuration. */ + async #loadIconsData() { + const res = await fetch(ICONS_DATA_URL); + if (!res.ok) throw new Error(`[EvolutionController] Failed to load ${ICONS_DATA_URL}`); + return res.json(); + } +} diff --git a/src/js/gradient.js b/src/js/gradient.js new file mode 100644 index 0000000..32a8205 --- /dev/null +++ b/src/js/gradient.js @@ -0,0 +1,80 @@ +/** + * gradient.js + * Controls the animated gradient background. + * Owns all gradient state and applies it via CSS custom properties on . + */ + +import { deriveGradientPair } from '../utils/colorUtils.js'; + +/** Root element — CSS custom properties live here */ +const ROOT = document.documentElement; + +/** + * Speed → animation duration mapping. + * speed 1 → ~32 s (very slow, meditative) + * speed 5 → ~19 s (default, comfortable) + * speed 10 → ~3.5 s (fast, energetic) + * + * @param {number} speed integer 1–10 + * @returns {number} duration in seconds + */ +function speedToDuration(speed) { + return Math.round(35 - speed * 3.15); +} + +export class GradientController { + /** @type {string} */ #color = '#4d22b3'; + /** @type {number} */ #speed = 2; + /** @type {boolean} */ #rotating = false; + + /** + * Initialise gradient with a base colour. + * Call once on app start. + * + * @param {string} hex + */ + init(hex) { + this.setColor(hex); + this.setSpeed(this.#speed); + } + + /** + * Set the base gradient colour. + * Automatically derives the companion colour and updates the CSS vars. + * + * @param {string} hex + */ + setColor(hex) { + this.#color = hex; + const [c1, c2] = deriveGradientPair(hex); + ROOT.style.setProperty('--color-1', c1); + ROOT.style.setProperty('--color-2', c2); + } + + /** + * Set animation speed (1 = slowest, 10 = fastest). + * Updates --anim-duration on the root element. + * + * @param {number} speed integer 1–10 + */ + setSpeed(speed) { + this.#speed = speed; + ROOT.style.setProperty('--anim-duration', `${speedToDuration(speed)}s`); + } + + /** + * Enable or disable the clockwise rotation animation. + * Toggles the `.gradient-rotating` class on . + * + * @param {boolean} enabled + */ + toggleRotation(enabled) { + this.#rotating = enabled; + document.body.classList.toggle('gradient-rotating', enabled); + } + + // ── Read-only state accessors ────────────────────────────── + get color() { return this.#color; } + get speed() { return this.#speed; } + get rotating() { return this.#rotating; } +} diff --git a/src/js/iconLoader.js b/src/js/iconLoader.js new file mode 100644 index 0000000..022b36c --- /dev/null +++ b/src/js/iconLoader.js @@ -0,0 +1,63 @@ +/** + * iconLoader.js + * Fetches SVG icon files on demand and caches them in memory. + * Returns normalised SVG strings ready for innerHTML injection. + * + * Note: requires a local HTTP server — fetch() does not work on file:// URLs. + */ + +const ICONS_PATH = 'src/icons'; + +/** @type {Map} */ +const cache = new Map(); + +/** + * Normalise an SVG string for safe inline use: + * - Strip the XML declaration (breaks innerHTML injection) + * - Remove fixed width / height attributes (sizing is handled by CSS) + * - Add a neutral aria-hidden so inline SVGs don't pollute the a11y tree + * + * @param {string} raw + * @returns {string} + */ +function normalizeSvg(raw) { + return raw + .replace(/<\?xml[^>]*\?>\s*/i, '') // remove XML declaration + .replace(//g, '') // remove XML comments + .replace(/\s+width="[^"]*"/, '') // remove fixed width + .replace(/\s+height="[^"]*"/, '') // remove fixed height + .replace(/