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.
This commit is contained in:
sha
2026-02-21 13:53:46 +02:00
commit 899d9d7c13
59 changed files with 1765 additions and 0 deletions
+58
View File
@@ -0,0 +1,58 @@
/* ════════════════════════════════════════════════════════════
gradient.css — animated background gradient + rotation
════════════════════════════════════════════════════════════ */
/* ── Register animatable CSS custom property (Houdini) ───── */
/* Allows smooth CSS animation of the gradient angle. */
/* Fallback: browsers without @property support still show */
/* the gradient, just without animated rotation. */
@property --gradient-angle {
syntax: '<angle>';
inherits: false;
initial-value: 135deg;
}
/* ── Keyframe: position-based flow (always active) ───────── */
/* Creates a fluid "breathing" motion by shifting the large */
/* (300%) background across the viewport. */
@keyframes gradientFlow {
0% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
100% { background-position: 0% 50%; }
}
/* ── Keyframe: clockwise angle rotation (toggle-gated) ───── */
/* Adds 360° to the starting angle for one full CW rotation. */
@keyframes gradientRotate {
from { --gradient-angle: 135deg; }
to { --gradient-angle: 495deg; }
}
/* ── Background application ──────────────────────────────── */
body {
background: linear-gradient(
var(--gradient-angle),
var(--color-1) 0%,
var(--color-2) 50%,
var(--color-1) 100%
);
background-size: 300% 300%;
/*
* Both animations are always declared here.
* gradientRotate starts PAUSED so it costs nothing until
* the user enables rotation — then JS adds .gradient-rotating
* which flips its play-state to running.
* This avoids restarting gradientFlow when the class toggles.
*/
animation:
gradientFlow var(--anim-duration) ease-in-out infinite,
gradientRotate var(--rotation-duration) linear infinite;
animation-play-state: running, paused;
}
/* ── Rotation enabled ────────────────────────────────────── */
body.gradient-rotating {
animation-play-state: running, running;
}
+72
View File
@@ -0,0 +1,72 @@
/* ════════════════════════════════════════════════════════════
icons.css — evolution container and floating icon entities
════════════════════════════════════════════════════════════ */
/* ── Evolution stage ─────────────────────────────────────── */
/*
* Fixed full-viewport layer that holds all icon entities.
* pointer-events: none so it never blocks page interaction.
*/
.evolution-container {
position: fixed;
inset: 0;
z-index: 10;
pointer-events: none;
overflow: hidden; /* clip any entity that overshoots during bounce */
}
/* ── Icon entity — outer (physics position) ──────────────── */
/*
* JS writes: el.style.transform = 'translate(Xpx, Ypx)' each frame.
* Size is fixed; position is driven entirely by the JS physics loop.
* will-change: transform hints the browser to promote this to its own
* compositor layer for smooth, layout-free animation.
*/
.icon-entity {
position: absolute;
top: 0;
left: 0;
width: 24px;
height: 24px;
will-change: transform;
/* colour is set inline by Entity.mount() and inherited by the SVG */
}
/* ── Icon entity — inner (appear scale animation) ────────── */
/*
* CSS `scale` is a standalone transform property — independent from the
* parent's `transform: translate()`. This means the appear transition
* runs without interfering with the per-frame position updates above.
*
* Easing: cubic-bezier(0.34, 1.56, 0.64, 1) is a spring-style curve
* that overshoots slightly, giving the icon a satisfying "pop" entry.
*/
.icon-entity__body {
width: 100%;
height: 100%;
/* Start as an invisible dot */
scale: 0;
opacity: 0;
transition:
scale 0.6s cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 0.25s ease;
/* Subtle shadow so icons are readable over any gradient colour */
filter: drop-shadow(0 1px 6px rgba(0, 0, 0, 0.35));
}
/* Alive state — triggered by JS setting data-state="alive" */
.icon-entity__body[data-state="alive"] {
scale: 1;
opacity: 1;
}
/* ── SVG inside the body ─────────────────────────────────── */
.icon-entity__body svg {
width: 100%;
height: 100%;
display: block;
/* SVGs use currentColor — colour is inherited from .icon-entity */
}
+106
View File
@@ -0,0 +1,106 @@
/* ════════════════════════════════════════════════════════════
main.css — reset, design tokens, global layout, settings btn
════════════════════════════════════════════════════════════ */
/* ── Reset ───────────────────────────────────────────────── */
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── Design Tokens ───────────────────────────────────────── */
:root {
/* Gradient colors — overridden immediately by JS on load */
--color-1: #1a7040;
--color-2: #4db8c4;
/* Animation timing — overridden by JS (default = speed 5) */
--anim-duration: 19s;
--rotation-duration: 22s;
/* Modal surface */
--modal-surface: rgba(10, 10, 14, 0.82);
--modal-border: rgba(255, 255, 255, 0.08);
--modal-divider: rgba(255, 255, 255, 0.06);
/* Text */
--text-hi: rgba(255, 255, 255, 0.92);
--text-lo: rgba(255, 255, 255, 0.42);
/* Misc surface tints */
--surface-tint: rgba(255, 255, 255, 0.10);
/* Font */
--font-mono: 'DM Mono', 'Courier New', monospace;
}
/* ── Body ────────────────────────────────────────────────── */
body {
min-height: 100dvh;
font-family: var(--font-mono);
overflow: hidden;
}
/* ── Page content area (grows with future features) ──────── */
.page-main {
position: relative;
z-index: 1;
min-height: 100dvh;
display: flex;
align-items: center;
justify-content: center;
}
/* ── Settings Button ─────────────────────────────────────── */
.settings-btn {
position: fixed;
top: 22px;
right: 26px;
z-index: 100;
/* Strip all button decoration */
appearance: none;
background: transparent;
border: none;
outline: none;
padding: 0;
cursor: pointer;
-webkit-tap-highlight-color: transparent;
/* Shape & layout */
width: 38px;
height: 38px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
/* Typography */
font-family: var(--font-mono);
font-size: 1.05rem;
font-weight: 500;
line-height: 1;
letter-spacing: -0.02em;
color: rgba(255, 255, 255, 0.65);
transition:
color 0.18s ease,
background 0.18s ease;
}
.settings-btn:hover {
color: rgba(255, 255, 255, 0.95);
background: rgba(255, 255, 255, 0.14);
}
.settings-btn:active {
color: #fff;
background: rgba(255, 255, 255, 0.22);
}
/* Focus-visible ring for keyboard nav */
.settings-btn:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.5);
outline-offset: 2px;
}
+335
View File
@@ -0,0 +1,335 @@
/* ════════════════════════════════════════════════════════════
modal.css — overlay, panel, and all settings controls
════════════════════════════════════════════════════════════ */
/* ── Overlay ─────────────────────────────────────────────── */
.modal-overlay {
position: fixed;
inset: 0;
z-index: 200;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.28);
backdrop-filter: blur(6px);
-webkit-backdrop-filter: blur(6px);
/* Hidden by default */
opacity: 0;
pointer-events: none;
transition: opacity 0.26s ease;
}
.modal-overlay.modal-visible {
opacity: 1;
pointer-events: all;
}
/* ── Panel ───────────────────────────────────────────────── */
.modal {
width: 310px;
background: var(--modal-surface);
border: 1px solid var(--modal-border);
border-radius: 18px;
overflow: hidden;
/* Glass blur effect */
backdrop-filter: blur(48px) saturate(1.5);
-webkit-backdrop-filter: blur(48px) saturate(1.5);
box-shadow:
0 32px 80px rgba(0, 0, 0, 0.45),
0 2px 12px rgba(0, 0, 0, 0.30);
/* Entry animation: rises and scales into place */
transform: translateY(18px) scale(0.96);
transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1);
will-change: transform;
}
.modal-overlay.modal-visible .modal {
transform: translateY(0) scale(1);
}
/* ── Header ──────────────────────────────────────────────── */
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px 15px;
border-bottom: 1px solid var(--modal-divider);
}
.modal-title {
font-family: var(--font-mono);
font-size: 0.65rem;
font-weight: 400;
letter-spacing: 0.18em;
text-transform: lowercase;
color: var(--text-lo);
user-select: none;
}
.modal-close {
appearance: none;
background: transparent;
border: none;
outline: none;
cursor: pointer;
padding: 0;
-webkit-tap-highlight-color: transparent;
width: 26px;
height: 26px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.15rem;
line-height: 1;
color: var(--text-lo);
transition:
color 0.18s ease,
background 0.18s ease;
}
.modal-close:hover {
color: var(--text-hi);
background: var(--surface-tint);
}
.modal-close:focus-visible {
outline: 2px solid rgba(255, 255, 255, 0.4);
outline-offset: 2px;
}
/* ── Body ────────────────────────────────────────────────── */
.modal-body {
padding: 20px;
display: flex;
flex-direction: column;
gap: 22px;
}
/* ── Setting group (label + control) ─────────────────────── */
.setting-group {
display: flex;
flex-direction: column;
gap: 10px;
}
.setting-label {
font-family: var(--font-mono);
font-size: 0.60rem;
font-weight: 400;
letter-spacing: 0.15em;
text-transform: lowercase;
color: var(--text-lo);
user-select: none;
}
/* ══════════════════════════════════════════════════════════
Color Picker
══════════════════════════════════════════════════════════ */
.color-input-wrapper {
display: flex;
align-items: center;
gap: 14px;
}
.color-picker {
width: 52px;
height: 30px;
padding: 3px;
border: 1px solid var(--modal-border);
border-radius: 8px;
background: transparent;
cursor: pointer;
outline: none;
transition: border-color 0.18s ease;
}
.color-picker:hover {
border-color: rgba(255, 255, 255, 0.22);
}
/* Remove inner chrome swatch border on WebKit */
.color-picker::-webkit-color-swatch-wrapper { padding: 0; }
.color-picker::-webkit-color-swatch {
border: none;
border-radius: 5px;
}
.color-picker::-moz-color-swatch {
border: none;
border-radius: 5px;
}
.color-value {
font-family: var(--font-mono);
font-size: 0.72rem;
letter-spacing: 0.06em;
color: var(--text-hi);
}
/* ══════════════════════════════════════════════════════════
Speed Slider
══════════════════════════════════════════════════════════ */
.slider-wrapper {
display: flex;
align-items: center;
gap: 10px;
}
.slider-label {
font-family: var(--font-mono);
font-size: 0.56rem;
letter-spacing: 0.09em;
color: var(--text-lo);
user-select: none;
min-width: 26px;
}
.slider-label:last-child {
text-align: right;
}
/* Reset */
.slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
background: transparent;
border: none;
outline: none;
cursor: pointer;
height: 18px; /* tap target height */
display: flex;
align-items: center;
}
/* Track — WebKit */
.slider::-webkit-slider-runnable-track {
height: 2px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.15);
}
/* Thumb — WebKit */
.slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
margin-top: -6px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.88);
cursor: pointer;
transition:
transform 0.15s ease,
background 0.15s ease;
}
.slider::-webkit-slider-thumb:hover {
transform: scale(1.35);
background: #fff;
}
/* Track — Firefox */
.slider::-moz-range-track {
height: 2px;
border-radius: 1px;
background: rgba(255, 255, 255, 0.15);
}
/* Thumb — Firefox */
.slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.88);
border: none;
cursor: pointer;
}
/* ══════════════════════════════════════════════════════════
Toggle Switch
══════════════════════════════════════════════════════════ */
.toggle-wrapper {
display: flex;
align-items: center;
gap: 10px;
}
.toggle-label {
font-family: var(--font-mono);
font-size: 0.56rem;
letter-spacing: 0.09em;
color: var(--text-lo);
user-select: none;
}
/* The <label> wrapping the whole toggle */
.toggle {
display: inline-flex;
cursor: pointer;
position: relative;
}
/* Hide native checkbox, keep it accessible */
.toggle input[type="checkbox"] {
position: absolute;
opacity: 0;
width: 0;
height: 0;
pointer-events: none;
}
/* Track pill */
.toggle-track {
position: relative;
display: block;
width: 42px;
height: 24px;
border-radius: 12px;
background: rgba(255, 255, 255, 0.12);
border: 1px solid rgba(255, 255, 255, 0.08);
transition:
background 0.28s ease,
border-color 0.28s ease;
}
/* Checked: brighten track */
.toggle input:checked + .toggle-track {
background: rgba(255, 255, 255, 0.34);
border-color: rgba(255, 255, 255, 0.20);
}
/* Thumb dot */
.toggle-thumb {
position: absolute;
top: 4px;
left: 4px;
width: 14px;
height: 14px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.82);
transition:
transform 0.28s cubic-bezier(0.16, 1, 0.3, 1),
background 0.28s ease;
}
/* Checked: slide thumb to the right */
.toggle input:checked + .toggle-track .toggle-thumb {
transform: translateX(18px);
background: #fff;
}
/* Focus ring on the label for keyboard nav */
.toggle input:focus-visible + .toggle-track {
outline: 2px solid rgba(255, 255, 255, 0.45);
outline-offset: 2px;
}