Unverified Commit 32a2a5bc by Paul-Louis NECH Committed by GitHub

feat(cosmicfest): rebuild /cosmicfest as v2.67Hz, entanglement-themed invite (#7)

Full redesign of the standalone invite page for the 2026 edition (20-23 August,
Labenne-Ocean, jour J samedi 22). Concept: intrication (entanglement) - two
states, one system, communicating across distance. Dark OKLCH design system with
a paired cyan/magenta accent language, an EntanglementField canvas (starfield +
mirrored particle pairs + 67Hz interference waveform), Archivo (wdth) + Martian
Mono, replacing the v0 light theme.

- new EntanglementField signature canvas (DPR-capped, mobile-scaled, full
  prefers-reduced-motion static path)
- sections: hero, concept, jour-J lineup, sejour, hebergement, transport,
  packing, v0 souvenirs (lifted audio embeds + accessible photo lightbox), RSVP
- countdown retargeted to 2026-08-22, hydration-safe placeholder
- lightbox renders full-size via plain img with managed focus
- add PRODUCT.md / DESIGN.md design context at repo root

Verified: clean SSG build (6.3 kB), WCAG AA contrast, zero console errors,
correct SSR + reduced-motion paths.
parent 5fd1374e
# Design
> Visual system for the **cosmicfest_v2.67Hz** micro-site. Standalone from the main site's
> Syne identity — cosmicfest has its own fonts and tokens (`next/styles/cosmicfest.module.css`).
## Theme
Dark, electric, cosmic-laboratory. Scene: a beachside night, an oscilloscope glowing in the
dark, two particles entangled across the sand. Not space-opera purple — a deep cosmic ink with
two living accents (cyan + magenta) that always appear as a pair, the way entangled states do,
resolving to white phosphor where they meet. One warm amber spark for the summer/sun nod.
Color strategy: **Full palette** (committed dark ground + a deliberate dual-accent system).
## Color (OKLCH)
| Role | Token | Value | Notes |
|---|---|---|---|
| Ground | `--cf-bg` | `oklch(0.15 0.03 275)` | cosmic ink, faint violet-blue |
| Surface | `--cf-surface` | `oklch(0.19 0.035 278)` | raised panels |
| Surface alt | `--cf-surface-2` | `oklch(0.23 0.04 280)` | hover / nested ground |
| Border | `--cf-border` | `oklch(1 0 0 / 0.10)` | hairlines |
| Ink | `--cf-ink` | `oklch(0.97 0.01 270)` | primary text, ~14:1 on bg |
| Muted | `--cf-muted` | `oklch(0.80 0.02 272)` | secondary, ≥4.5:1 on bg |
| **Signal (state A)** | `--cf-cyan` | `oklch(0.84 0.14 200)` | first entangled accent |
| **Entangled (state B)** | `--cf-magenta` | `oklch(0.72 0.21 350)` | second entangled accent |
| Phosphor | `--cf-white` | `oklch(0.99 0 0)` | stars, the "meeting point" |
| Spark (sun) | `--cf-amber` | `oklch(0.82 0.15 75)` | rare warm accent, the 67Hz/sun |
The two accents are a **system**: use them together (a cyan label paired with a magenta value,
a gradient *between* them reserved only for the literal entanglement motif — never gradient text).
## Typography
Voice words: electric, precise, oscillating. Reflex-rejects avoided (no Space Mono/Grotesk,
Montserrat, Orbitron, Inter).
- **Display**`Archivo Expanded` (700–900, wide). The wordmark + section heads shout like a
concert poster. `letter-spacing` ≥ -0.02em; `text-wrap: balance`.
- **Body**`Archivo` (400–600). Same superfamily as display = cohesion. Logistics prose,
≤70ch, line-height 1.6 (light-on-dark needs the air).
- **Mono**`Martian Mono` (400–600). The lab instrument: frequencies, dates, coordinates,
the "67Hz", entanglement labels, countdown digits. Earned, not costume.
Loaded via `next/font/google`, exposed as CSS vars on the page container.
## Components
- **Hero canvas** (`EntanglementField`): full-bleed `<canvas>` — drifting starfield, ~6–10
entangled particle *pairs* (two points joined by a faint line, mirroring each other's motion
across the viewport), and a 67Hz sine/interference waveform along the lower third. Decorative,
behind content, `prefers-reduced-motion` → single static frame.
- **Wordmark**: `cosmicfest` (display) + `v2.67Hz` (mono badge). The `.67Hz` is the hook.
- **Lineup rows**: not cards — a typographic list, each act = name (display) · role (mono) ·
the artist's real name in parens. Cyan/magenta alternating per row.
- **Logistics blocks**: scannable definition-style groups (séjour, hébergement, transport,
à prévoir) with mono labels + body values. Generous, not walls.
- **Souvenirs v0**: ParVagues Bandcamp/SoundCloud embeds + a small photo strip with an
accessible lightbox (lifted + restyled from v0).
- **Countdown**: mono digits counting to 2026-08-22T00:00 (jour J). Retargeted from v0's June auto-calc.
- **CTA**: mailto `cosmic@nech.pl`, "réponds à l'invitation" — verb + object.
## Motion
- Hero canvas: continuous, GPU-cheap (transform/opacity on canvas paint; rAF loop).
- Section reveals: staggered fade+rise, ease-out-expo, enhancing an already-visible default
(never gating visibility). Entangled-pair micro-motion on lineup hover.
- All motion has a `prefers-reduced-motion: reduce` path (crossfade/instant, static canvas).
## Layout
Single long scroll, deliberate pacing. Fluid `clamp()` spacing. Asymmetry allowed for the hero.
Mobile-first; logistics reflow to single column; canvas stays performant on phones (capped DPR,
fewer particles).
# Product
> Scope: this file currently centers on the **cosmicfest** micro-site (`next/pages/cosmicfest/`),
> the active brand surface. The wider repo is a personal portfolio (dunbar, parvagues, hydras,
> talks, poems); each section is its own micro-brand. See `CLAUDE.md` for the repo architecture.
## Register
brand
## Users
Friends and fellow travellers invited to cosmicfest, a private, invitation-only music +
creative-coding gathering at Labenne-Océan (French Atlantic coast). They land on the page
from a personal message, on phone or laptop, deciding whether to come and needing the
practical details (dates, lineup, lodging, transport, what to pack) to say yes. Mostly
French-speaking. The job to be done: feel the vibe, get the logistics, RSVP.
## Product Purpose
A single-page invitation for **cosmicfest_v2.67Hz** (20–23 August 2026, jour J Saturday 22).
It exists to make people *want* to come and to give them everything they need to commit.
Not a ticketed event page: no commerce, no signup funnel. Success = the right people reply
to `cosmic@nech.pl` before 1 August, and the page itself feels like the festival.
## Brand Personality
Quantum, wild, connected. The organizing metaphor is **intrication** (entanglement): two
states forming one system, communicating instantly across distance — the festival as people
entangled on a shared 67Hz frequency. Voice is playful-cosmic French, self-aware
("cosmicpipoo"), warm not corporate. Cerebral physics wink over a sun-and-surf summer
underneath. Emotional goal: anticipation, belonging, a little awe.
## Anti-references
- Generic "cosmic" = deep-space purple gradient + scattered stars + Orbitron. The first reflex; banned.
- SaaS event-landing template: hero-metric blocks, identical card grids, eyebrow kickers on every section.
- Eventbrite / Luma ticketing-page utilitarianism. This is an invitation, not a checkout.
- Cream/warm-neutral "editorial restraint." Wrong register entirely; this page is dark and electric.
- Mono-everything as costume. Mono is earned here (livecoding/frequency), but display carries the shout.
## Design Principles
1. **The medium is the message.** Entanglement isn't just copy — it's the palette (two accents
that always travel together), the motion (paired particles mirroring across distance), the arc
(v2 entangle → v3 decohere). The physics is felt, not explained.
2. **One signature, done extraordinarily.** A live starfield + entangled-pairs + 67Hz waveform
canvas is the centerpiece. Everything else stays disciplined so it can shine.
3. **Logistics are hospitality.** Dense practical info (lodging, transport, packing) is part of the
warmth, not an afterthought. Make it scannable and generous, never a wall.
4. **Invitation, not transaction.** No tickets, no forms-by-default. A mailto and a countdown.
"Sur invitation. Riders, come to the storm."
5. **Earn the strangeness.** Brand register rewards a POV. Commit to dark + electric + physics-weird.
## Accessibility & Inclusion
WCAG AA target. Body text ≥4.5:1 on the dark cosmic ground; large/display ≥3:1. The signature
canvas is decorative — it must never gate content, and it must have a full
`prefers-reduced-motion: reduce` fallback (static render, no animation loop). Keyboard-operable
lightbox and CTA. French primary; clear, plain phrasing for logistics.
import { useState, useEffect } from 'react';
import styles from '../../styles/cosmicfest.module.css'; // Assuming you might want specific styles
const Countdown = ({ targetDate }) => {
const calculateTimeLeft = () => {
const difference = +new Date(targetDate) - +new Date();
let timeLeft = {};
if (difference > 0) {
timeLeft = {
jours: Math.floor(difference / (1000 * 60 * 60 * 24)),
heures: Math.floor((difference / (1000 * 60 * 60)) % 24),
minutes: Math.floor((difference / 1000 / 60) % 60),
secondes: Math.floor((difference / 1000) % 60),
};
} else {
timeLeft = { jours: 0, heures: 0, minutes: 0, secondes: 0 };
}
return timeLeft;
import styles from '../../styles/cosmicfest.module.css';
const LABELS = { jours: 'jours', heures: 'heures', minutes: 'min', secondes: 'sec' };
function computeLeft(targetDate) {
const diff = +new Date(targetDate) - Date.now();
if (diff <= 0) return null;
return {
jours: Math.floor(diff / 86400000),
heures: Math.floor((diff / 3600000) % 24),
minutes: Math.floor((diff / 60000) % 60),
secondes: Math.floor((diff / 1000) % 60),
};
}
const [timeLeft, setTimeLeft] = useState(calculateTimeLeft());
export default function Countdown({ targetDate }) {
// undefined = not yet mounted (SSR / first paint) → neutral "--" placeholder, so the
// server never wrongly claims the event is happening now.
// null = actually expired → the celebratory message.
// object = live remaining time.
const [left, setLeft] = useState(undefined);
useEffect(() => {
const timer = setTimeout(() => {
setTimeLeft(calculateTimeLeft());
}, 1000);
return () => clearTimeout(timer);
});
const timerComponents = [];
Object.keys(timeLeft).forEach((interval) => {
if (!timeLeft[interval] && timeLeft[interval] !== 0) { // handles case where initial timeLeft is empty
return;
}
timerComponents.push(
<span key={interval} style={{ margin: '0 0.5rem', textAlign: 'center' }}>
<div style={{ fontSize: '2.5rem', fontWeight: 'bold' }}>{timeLeft[interval]}</div>
<div style={{ fontSize: '0.8rem', textTransform: 'uppercase' }}>{interval}</div>
</span>
setLeft(computeLeft(targetDate));
const id = setInterval(() => setLeft(computeLeft(targetDate)), 1000);
return () => clearInterval(id);
}, [targetDate]);
if (left === null) {
return (
<div className={styles.countdown} aria-live="polite">
<span className={styles.countDone}>c&rsquo;est l&rsquo;heure. on s&rsquo;intrique. 🌀</span>
</div>
);
});
}
return (
<div className={styles.countdown} style={{ display: 'flex', justifyContent: 'center', alignItems: 'baseline' }}>
{timerComponents.length ? timerComponents : <span>c'est l'heure!</span>}
<div className={styles.countdown} aria-label="compte à rebours jusqu'au jour J">
{Object.keys(LABELS).map((unit) => (
<span key={unit} className={styles.countUnit}>
<span className={styles.countNum}>
{left ? String(left[unit]).padStart(2, '0') : '--'}
</span>
<span className={styles.countLabel}>{LABELS[unit]}</span>
</span>
))}
</div>
);
};
export default Countdown;
}
import { useEffect, useRef } from 'react';
/**
* EntanglementField — the cosmicfest_v2.67Hz signature canvas.
*
* Three layers, one idea (intrication):
* 1. a drifting starfield (the cosmos)
* 2. entangled particle PAIRS — each pair is two points mirrored across the field's
* center, brightening in perfect sync no matter the distance between them (the link)
* 3. a 67Hz interference waveform along the lower third (cyan + magenta crossing into white)
*
* Decorative only: sits behind content, never gates it. Honours prefers-reduced-motion by
* painting a single static frame (no rAF loop). Capped DPR + area-scaled counts for phones.
*/
const CYAN = 'oklch(0.84 0.14 200)';
const MAGENTA = 'oklch(0.72 0.21 350)';
const WHITE = 'oklch(0.99 0 0)';
export default function EntanglementField({ className }) {
const canvasRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
if (!ctx) return;
const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
let W = 0;
let H = 0;
let dpr = 1;
let stars = [];
let pairs = [];
let raf = null;
let running = true;
const rand = (a, b) => a + Math.random() * (b - a);
function build() {
const rect = canvas.getBoundingClientRect();
W = Math.max(1, rect.width);
H = Math.max(1, rect.height);
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = Math.round(W * dpr);
canvas.height = Math.round(H * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
const area = W * H;
const starCount = Math.min(Math.round(area / 7000), 240);
const pairCount = W < 640 ? 5 : W < 1100 ? 8 : 11;
stars = Array.from({ length: starCount }, () => ({
x: Math.random() * W,
y: Math.random() * H,
r: rand(0.3, 1.4),
base: rand(0.15, 0.7),
tw: rand(0.6, 2.2),
ph: rand(0, Math.PI * 2),
}));
// Each pair lives in the top-left quadrant; its twin is the mirror through center.
pairs = Array.from({ length: pairCount }, () => ({
x: rand(0.08, 0.46) * W,
y: rand(0.06, 0.62) * H,
ox: rand(8, 26),
oy: rand(8, 26),
speed: rand(0.18, 0.5),
ph: rand(0, Math.PI * 2),
link: rand(0.5, 1.2), // sync frequency of the shared brightness pulse
}));
}
function drawWave(t) {
// 67Hz rendered as two interfering travelling waves; where they cross, white.
const yBase = H * 0.74;
const amp = Math.min(H * 0.06, 46);
const step = Math.max(4, Math.round(W / 220));
const waves = [
{ color: CYAN, k: 0.018, w: 1.0, off: 0, a: 0.5 },
{ color: MAGENTA, k: 0.013, w: -1.35, off: Math.PI / 3, a: 0.5 },
];
ctx.save();
ctx.globalCompositeOperation = 'lighter';
ctx.lineWidth = 1.6;
for (const wv of waves) {
ctx.beginPath();
for (let x = 0; x <= W; x += step) {
const y =
yBase +
Math.sin(x * wv.k + t * wv.w + wv.off) * amp +
Math.sin(x * wv.k * 2.7 + t * wv.w * 0.6) * amp * 0.28;
x === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y);
}
ctx.globalAlpha = wv.a;
ctx.strokeStyle = wv.color;
ctx.stroke();
}
ctx.restore();
}
function frame(now) {
if (!running) return;
const t = now / 1000;
ctx.clearRect(0, 0, W, H);
// 1 — starfield
for (const s of stars) {
const a = reduce ? s.base : s.base * (0.55 + 0.45 * Math.sin(t * s.tw + s.ph));
ctx.globalAlpha = a;
ctx.fillStyle = WHITE;
ctx.beginPath();
ctx.arc(s.x, s.y, s.r, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
// 2 — entangled pairs
const cx = W / 2;
const cy = H / 2;
for (const p of pairs) {
const wob = reduce ? 0 : t * p.speed;
const ax = p.x + Math.cos(wob + p.ph) * p.ox;
const ay = p.y + Math.sin(wob * 1.3 + p.ph) * p.oy;
// twin: mirror through the field's center (instant correlation across distance)
const bx = 2 * cx - ax;
const by = 2 * cy - ay;
// shared brightness pulse — both ends flare in the same instant
const pulse = reduce ? 0.6 : 0.45 + 0.55 * Math.pow((Math.sin(t * p.link + p.ph) + 1) / 2, 2);
// the link
ctx.save();
ctx.globalCompositeOperation = 'lighter';
const grad = ctx.createLinearGradient(ax, ay, bx, by);
grad.addColorStop(0, CYAN);
grad.addColorStop(0.5, WHITE);
grad.addColorStop(1, MAGENTA);
ctx.strokeStyle = grad;
ctx.globalAlpha = 0.06 + 0.1 * pulse;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(ax, ay);
ctx.lineTo(bx, by);
ctx.stroke();
// the two states
ctx.globalAlpha = 0.55 + 0.45 * pulse;
const r = 2 + 1.6 * pulse;
ctx.fillStyle = CYAN;
ctx.beginPath();
ctx.arc(ax, ay, r, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = MAGENTA;
ctx.beginPath();
ctx.arc(bx, by, r, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
ctx.globalAlpha = 1;
// 3 — waveform
drawWave(reduce ? 0 : t);
if (!reduce) raf = requestAnimationFrame(frame);
}
let resizeTimer = null;
function onResize() {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
build();
if (reduce) frame(0);
}, 150);
}
function onVisibility() {
if (document.hidden) {
running = false;
if (raf) cancelAnimationFrame(raf);
} else if (!reduce) {
running = true;
raf = requestAnimationFrame(frame);
}
}
build();
if (reduce) {
frame(0); // single static frame, no loop
} else {
raf = requestAnimationFrame(frame);
}
window.addEventListener('resize', onResize);
document.addEventListener('visibilitychange', onVisibility);
return () => {
running = false;
if (raf) cancelAnimationFrame(raf);
clearTimeout(resizeTimer);
window.removeEventListener('resize', onResize);
document.removeEventListener('visibilitychange', onVisibility);
};
}, []);
return <canvas ref={canvasRef} className={className} aria-hidden="true" />;
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment