diff --git a/.nojekyll b/.nojekyll new file mode 100644 index 0000000..e69de29 diff --git a/README.md b/README.md index eafd9d2..c8df3ea 100644 --- a/README.md +++ b/README.md @@ -1 +1,61 @@ -# everythingcomes \ No newline at end of file +# Todo llega · Everything comes + +A minimal, contemplative landing page. A single phrase — *"Todo llega"* (Everything comes) — cycles through 18 languages, each one drifting softly into view like petals in the wind. + +## Stack + +- **HTML5** — semantic structure, accessible live regions +- **CSS3** — custom properties, `clamp()`, smooth transitions, `prefers-reduced-motion` +- **Vanilla JavaScript** — text rotation, sakura canvas animation, Atropos init +- **[Atropos.js](https://atroposjs.com/)** (CDN only) — subtle 3-D parallax on the hero card + +No build step. No framework. No bundler. + +## Files + +``` +index.html — page structure and semantic markup +styles.css — all visual styles, transitions, and responsive rules +main.js — phrase rotation, sakura petal animation, Atropos initialisation +README.md — this file +``` + +## Run locally + +Open `index.html` directly in any modern browser — no server required. + +For a more complete environment (avoids CORS quirks with some browsers), serve it locally: + +```bash +# Python +python3 -m http.server 8080 + +# Node.js (no install needed) +npx serve . +``` + +Then visit `http://localhost:8080`. + +## Deploy to GitHub Pages + +The repository includes a `.nojekyll` file so GitHub Pages skips Jekyll processing and serves the static files as-is. + +1. Push the repository to GitHub (the `main` branch). +2. Go to **Settings → Pages** in your repository. +3. Under **Build and deployment**, set **Source** to **Deploy from a branch**. +4. Choose **Branch: `main`** and folder **`/ (root)`**, then click **Save**. +5. After a moment the site will be live at `https://.github.io//`. + +No build step, no configuration files — the repository root is the site root. + +## Design notes + +| Detail | Value | +|---|---| +| Background | `#f7f3ee` warm off-white | +| Typography | Georgia serif, weight 400 | +| Phrase transition | 1000 ms ease-in fade-out + 1400 ms ease-out fade-in, 0.25 em vertical drift | +| Sakura petals | 52 canvas ellipses, `requestAnimationFrame` | +| Parallax tilt | Atropos.js, 6° max rotation, no shadow/highlight | +| Accessibility | `aria-live="polite"` on rotating text, `aria-hidden` on canvas | +| Reduced motion | Instant text swap, petals disabled | \ No newline at end of file diff --git a/index.html b/index.html new file mode 100644 index 0000000..9f6c7a2 --- /dev/null +++ b/index.html @@ -0,0 +1,70 @@ + + + + + + + Todo llega · Everything comes + + + + + + + + + + + +
+ +
+
+
+
+ + +
+

Todo llega

+
+ +

· everything comes ·

+ +
+
+
+
+
+ + + + + + + diff --git a/main.js b/main.js new file mode 100644 index 0000000..8c54e51 --- /dev/null +++ b/main.js @@ -0,0 +1,218 @@ +// ============================================================ +// main.js — Todo llega · Everything comes +// ============================================================ + +// ── Translations (18 languages) ────────────────────────────── +const PHRASES = [ + { text: 'Todo llega', lang: 'es' }, // Spanish + { text: 'Everything comes', lang: 'en' }, // English + { text: 'Tout arrive', lang: 'fr' }, // French + { text: 'Tutto arriva', lang: 'it' }, // Italian + { text: 'Tudo chega', lang: 'pt' }, // Portuguese + { text: 'Alles kommt', lang: 'de' }, // German + { text: 'Всё приходит', lang: 'ru' }, // Russian + { text: '全てが訪れる', lang: 'ja' }, // Japanese + { text: '一切都会来', lang: 'zh' }, // Chinese (Simplified) + { text: 'كل شيء يأتي', lang: 'ar', dir: 'rtl' }, // Arabic + { text: 'Όλα έρχονται', lang: 'el' }, // Greek + { text: 'सब कुछ आता है', lang: 'hi' }, // Hindi + { text: '모든 것이 온다', lang: 'ko' }, // Korean + { text: 'Her şey gelir', lang: 'tr' }, // Turkish + { text: 'Allt kommer', lang: 'sv' }, // Swedish + { text: 'Alles komt', lang: 'nl' }, // Dutch + { text: 'הכל מגיע', lang: 'he', dir: 'rtl' }, // Hebrew + { text: 'Wszystko przychodzi', lang: 'pl' }, // Polish +]; + +// How long (ms) each phrase stays fully visible after its fade-in completes. +const DISPLAY_DURATION = 4500; + +// Duration (ms) of the fade-out step (exit). Keep in sync with --fade-out-dur in styles.css. +const FADE_OUT = 1000; + +// Duration (ms) of the fade-in step (entrance). Keep in sync with --fade-in-dur in styles.css. +const FADE_IN = 1400; + +// ── Reduced-motion preference ───────────────────────────────── +const prefersReducedMotion = window.matchMedia( + '(prefers-reduced-motion: reduce)' +).matches; + +// ───────────────────────────────────────────────────────────── +// TEXT ROTATION +// ───────────────────────────────────────────────────────────── + +const phraseEl = document.getElementById('rotating-text'); +let currentIndex = 0; + +/** + * Transition to `phrase`, then fire `onComplete` when fully visible. + * + * The sequence: + * 1. Add `is-out` → CSS ease-in transition: fade out + drift up (FADE_OUT ms) + * 2. Swap text while invisible + * 3. Add `is-reset` → instant snap to below-centre (transition: none) + * 4. Remove `is-reset` → CSS ease-out transition: fade in + drift up (FADE_IN ms) + * 5. Fire `onComplete` + * + * Separate durations for out/in make the exit feel crisp and the + * entrance feel slow and deliberate — more poetic, less carousel-like. + */ +function transitionToPhrase(phrase, onComplete) { + if (prefersReducedMotion) { + // Instant swap — no motion + phraseEl.textContent = phrase.text; + phraseEl.setAttribute('lang', phrase.lang); + phrase.dir + ? phraseEl.setAttribute('dir', phrase.dir) + : phraseEl.removeAttribute('dir'); + if (onComplete) onComplete(); + return; + } + + // ① Fade out (upward, ease-in via CSS) + phraseEl.classList.add('is-out'); + + setTimeout(() => { + // ② Swap content while element is invisible + phraseEl.textContent = phrase.text; + phraseEl.setAttribute('lang', phrase.lang); + phrase.dir + ? phraseEl.setAttribute('dir', phrase.dir) + : phraseEl.removeAttribute('dir'); + + // ③ Snap to "below centre" without any transition + phraseEl.classList.remove('is-out'); + phraseEl.classList.add('is-reset'); + + // Force the browser to commit the reset state before removing the class + void phraseEl.offsetHeight; + + // ④ Remove reset → base ease-out transition animates up into place + phraseEl.classList.remove('is-reset'); + + if (onComplete) setTimeout(onComplete, FADE_IN); + }, FADE_OUT); +} + +/** Advance to the next phrase and schedule the one after it. */ +function showNextPhrase() { + currentIndex = (currentIndex + 1) % PHRASES.length; + transitionToPhrase(PHRASES[currentIndex], () => { + setTimeout(showNextPhrase, DISPLAY_DURATION); + }); +} + +// Start the rotation after the initial phrase has been on screen long enough to read +setTimeout(showNextPhrase, DISPLAY_DURATION); + +// ───────────────────────────────────────────────────────────── +// SAKURA PETAL ANIMATION +// ───────────────────────────────────────────────────────────── + +const canvas = document.getElementById('sakura-canvas'); +const ctx = canvas.getContext('2d'); + +/** Resize canvas to fill the viewport. */ +function resizeCanvas() { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; +} + +resizeCanvas(); +window.addEventListener('resize', resizeCanvas); + +// Fewer petals for reduced-motion; zero disables the loop entirely. +const petalCount = prefersReducedMotion ? 0 : 52; + +/** + * Create one petal with randomised physics and appearance. + * @param {number|null} startY Spawn Y position; null = random on-screen. + */ +function createPetal(startY = null) { + return { + x: Math.random() * canvas.width, + y: startY !== null ? startY : Math.random() * canvas.height, + size: 3 + Math.random() * 4.5, // ellipse half-width (px) + speedY: 0.35 + Math.random() * 0.55, // downward drift per frame + speedX: (Math.random() - 0.5) * 0.4, // base lateral drift + angle: Math.random() * Math.PI * 2, // current rotation (radians) + rotSpeed: (Math.random() - 0.5) * 0.025, // rotation increment per frame + wobble: Math.random() * Math.PI * 2, // sinusoidal oscillation phase + wobbleFreq: 0.012 + Math.random() * 0.010, // oscillation frequency + wobbleAmp: 0.5 + Math.random() * 0.8, // oscillation amplitude (px) + opacity: 0.45 + Math.random() * 0.45, + hue: 335 + Math.random() * 15, // 335–350° pink-rose + sat: 45 + Math.random() * 40, // saturation % + lit: 68 + Math.random() * 16, // lightness % + }; +} + +// Initialise the petal pool; scatter Y positions across the screen at start. +const petals = Array.from({ length: petalCount }, () => createPetal()); + +/** + * Draw a single petal as a slightly eccentric, rotated ellipse. + * The eccentricity (0.52 ratio) gives it a leaf-like silhouette. + */ +function drawPetal(p) { + ctx.save(); + ctx.translate(p.x, p.y); + ctx.rotate(p.angle); + ctx.globalAlpha = p.opacity; + ctx.fillStyle = `hsl(${p.hue}, ${p.sat}%, ${p.lit}%)`; + ctx.beginPath(); + ctx.ellipse(0, 0, p.size, p.size * 0.52, 0, 0, Math.PI * 2); + ctx.fill(); + ctx.restore(); +} + +/** Animation loop — advances every petal and repaints the canvas. */ +function animatePetals() { + ctx.clearRect(0, 0, canvas.width, canvas.height); + + for (const p of petals) { + // Apply wind-like sinusoidal oscillation to horizontal drift + p.wobble += p.wobbleFreq; + p.x += p.speedX + Math.sin(p.wobble) * p.wobbleAmp; + p.y += p.speedY; + p.angle += p.rotSpeed; + + // Wrap horizontally so petals re-enter from the opposite edge + if (p.x < -20) p.x = canvas.width + 15; + else if (p.x > canvas.width + 20) p.x = -15; + + // Recycle petals that have fallen below the viewport + if (p.y > canvas.height + 15) { + Object.assign(p, createPetal(-Math.random() * 50)); + } + + drawPetal(p); + } + + requestAnimationFrame(animatePetals); +} + +if (petalCount > 0) { + animatePetals(); +} + +// ───────────────────────────────────────────────────────────── +// ATROPOS.JS PARALLAX +// ───────────────────────────────────────────────────────────── + +/** + * Initialise the subtle 3-D tilt effect on the hero card. + * Low rotation limits keep the effect contemplative, not distracting. + */ +if (typeof Atropos !== 'undefined') { + Atropos({ + el: '#hero-atropos', + activeOffset: 25, + shadowScale: 1, // no extra shadow size + rotateXMax: 6, // degrees — gentle tilt + rotateYMax: 6, + shadow: false, // shadow disabled for a cleaner look + highlight: false, // no specular highlight sheen + }); +} diff --git a/styles.css b/styles.css new file mode 100644 index 0000000..e532446 --- /dev/null +++ b/styles.css @@ -0,0 +1,182 @@ +/* ============================================================ + styles.css — Todo llega · Everything comes + ============================================================ */ + +/* ── Custom properties ──────────────────────────────────────── */ +:root { + --color-bg: #f7f3ee; /* warm off-white, like washi paper */ + --color-text: #1a1510; /* warm near-black */ + --color-muted: #9a8878; /* soft rose-brown for the subtitle */ + + /* Serif stack — no external font dependency */ + --font-serif: Georgia, 'Palatino Linotype', 'Book Antiqua', Palatino, serif; + + /* Fluid type scale */ + --phrase-size: clamp(2rem, 5.5vw, 4.8rem); + --sub-size: clamp(0.68rem, 1.3vw, 0.875rem); + + /* Transition timing (keep in sync with FADE_OUT / FADE_IN in main.js) */ + --fade-out-dur: 1000ms; /* exit — slightly quicker so the old phrase doesn't linger too long */ + --fade-in-dur: 1400ms; /* entrance — slow, deliberate, the phrase settles into place */ + --ease-in: cubic-bezier(0.4, 0, 1, 1); /* accelerates away — departure */ + --ease-out: cubic-bezier(0, 0, 0.2, 1); /* decelerates in — arrival */ +} + +/* ── Reset ───────────────────────────────────────────────────── */ +*, *::before, *::after { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +/* ── Base ────────────────────────────────────────────────────── */ +html, body { + height: 100%; + overflow: hidden; +} + +body { + background-color: var(--color-bg); + color: var(--color-text); + font-family: var(--font-serif); + display: flex; + align-items: center; + justify-content: center; +} + +/* ── Background canvas ──────────────────────────────────────── */ +#sakura-canvas { + position: fixed; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; /* events pass through to the card below */ + z-index: 0; +} + +/* ── Hero wrapper ────────────────────────────────────────────── */ +.hero { + position: relative; + z-index: 1; + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100vh; /* fallback */ + height: 100dvh; /* accounts for mobile browser chrome */ +} + +/* ── Atropos container ───────────────────────────────────────── */ +/* + Atropos positions its inner div absolutely (inset: 0), so the + root element MUST carry explicit dimensions. +*/ +#hero-atropos { + width: min(640px, 92vw); + height: clamp(220px, 38vh, 340px); +} + +/* ── Card surface (lives inside .atropos-inner) ──────────────── */ +.hero__card { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 1.5rem; + padding: + clamp(1.5rem, 4vw, 3rem) + clamp(2rem, 7vw, 5.5rem); + text-align: center; + + /* Semi-transparent so petals remain visible around the card */ + background: rgba(247, 243, 238, 0.80); + backdrop-filter: blur(3px); + -webkit-backdrop-filter: blur(3px); + + border-radius: 3px; + border: 1px solid rgba(154, 136, 120, 0.14); +} + +/* ── Text container ──────────────────────────────────────────── */ +.hero__text-container { + /* Fixed full-width centre-align prevents any horizontal shift when + a phrase changes length or switches to an RTL script (Arabic, Hebrew). */ + width: 100%; + text-align: center; + /* Small vertical padding absorbs the translateY shift during transition */ + overflow: hidden; + padding: 0.4em 0; +} + +/* ── Rotating phrase ─────────────────────────────────────────── */ +.hero__phrase { + font-size: var(--phrase-size); + font-weight: 400; + letter-spacing: 0.03em; + line-height: 1.15; + color: var(--color-text); + + /* Visible, centred default state */ + opacity: 1; + transform: translateY(0); + + /* Fade-in / entrance: slow deceleration — phrase settles gently */ + transition: + opacity var(--fade-in-dur) var(--ease-out), + transform var(--fade-in-dur) var(--ease-out); + + will-change: opacity, transform; +} + +/* ① Leaving — fade out and drift upward. + Override transition so the exit uses a tighter ease-in curve. */ +.hero__phrase.is-out { + opacity: 0; + transform: translateY(-0.25em); + transition: + opacity var(--fade-out-dur) var(--ease-in), + transform var(--fade-out-dur) var(--ease-in); +} + +/* + ② Reset — instantly position the new text below centre so it + can drift upward into place. `transition: none` makes this + state snap in with zero animation. +*/ +.hero__phrase.is-reset { + opacity: 0; + transform: translateY(0.25em); + transition: none; +} + +/* ── Subtitle ────────────────────────────────────────────────── */ +.hero__subtext { + font-size: var(--sub-size); + letter-spacing: 0.28em; + color: var(--color-muted); + font-style: italic; + user-select: none; +} + +/* ── Responsive ──────────────────────────────────────────────── */ +@media (max-width: 480px) { + .hero__phrase { + /* Allow long phrases (e.g. Polish) to wrap gracefully */ + white-space: normal; + word-break: break-word; + hyphens: auto; + } +} + +/* ── Reduced-motion override ─────────────────────────────────── */ +@media (prefers-reduced-motion: reduce) { + .hero__phrase, + .hero__phrase.is-out, + .hero__phrase.is-reset { + transition: none !important; + opacity: 1 !important; + transform: none !important; + } +}