How to Create a Generative Aurora Wallpaper Maker Using HTML, CSS and JavaScript

How to Create a Generative Aurora Wallpaper Maker in HTML, CSS and JavaScript

Most generative art demos are impressive to look at and frustrating to learn from, because the interesting part β€” the randomness β€” is usually the least documented part of the code. Stellix Aurora is my attempt at the opposite: a small generative wallpaper tool where every random decision is deliberate, repeatable, and explainable. Type a word, get a sky. Type the same word again next month, get the exact same sky.

Here is how it came together.

Randomness needs a leash

The first instinct when building something generative is to sprinkle Math.random() everywhere. Resist it. Real Math.random() cannot be seeded, which means you can never reproduce a result, never let someone share a favourite composition by sharing a word, and never write a test that checks your own output.

The fix is a seeded pseudo-random number generator β€” a small function that takes a starting number and produces a stream of values that looks random but is completely determined by that starting number. Mulberry32 is a good choice: it is a dozen lines of bit-shuffling that passes reasonably for randomness in visual work, runs fast, and needs no library. Give it the same seed twice and it hands back the identical sequence of numbers both times, forever.

That single property changes the whole feel of the tool. A “Shuffle” button just picks a new random seed. A text box that turns a typed word into a seed β€” by hashing the characters with something like FNV-1a β€” means “sunset”, typed by anyone, on any computer, paints the exact same sky. The randomness is real, but it is on a leash.

Separate the recipe from the drawing

Here is a mistake worth avoiding: computing random values inline while you draw. If you do that, resizing the canvas or changing one slider silently reshuffles everything else, because the random calls happen in a different order or count than before.

Instead, build a “recipe” once, up front: call the seeded generator the exact number of times needed to describe every ribbon, every star, every position β€” and store the results in a plain object. Drawing then becomes a pure read of that object; it never calls the random generator itself. Resize the window, export at 4K, animate a hundred frames β€” the recipe never changes, so the composition never drifts.

A ribbon is a handful of points and a curve

An aurora ribbon looks organic, but it doesn’t need a physics simulation. Pick three or four random control values between minus one and one, spread them evenly across the width of the canvas, and walk across the width interpolating smoothly between them using a cosine-based ease rather than a straight line. That cosine easing is the one trick worth knowing: it makes the transition between control points curve instead of kink, which is the difference between “hand-drawn wave” and “jagged zigzag.”

Turn that single line of points into a filled ribbon by tracing it once with an offset upward for the top edge, then tracing the same points backward with an offset downward for the bottom edge, and closing the path. One curve, walked twice, becomes a ribbon with real thickness.

Colour blending is what makes it glow

A flat-coloured ribbon looks like a sticker. A glowing one uses two techniques together.

First, fill the ribbon with a gradient that fades to fully transparent at both ends rather than a solid colour, so it has no hard edges β€” it simply dissolves into the background. Second, and this matters more than it sounds like it should, set the canvas’s composite operation to “screen” before painting the ribbons. Screen blending lightens wherever colours overlap instead of covering one with the other, which is exactly how real light behaves when two glowing things cross paths. Two ribbons of different colours crossing under normal blending just look like one occluding the other; under screen blending, the overlap glows brighter than either ribbon alone, and that’s the effect that reads as “aurora” rather than “coloured shapes.”

A soft canvas blur filter over the whole ribbon, scaled relative to the canvas size so it looks consistent at any resolution, finishes the glow.

Small details that sell the illusion

Scatter a few dozen stars as normalised positions β€” fractions between zero and one rather than fixed pixels β€” so they land in sensible places at any canvas size. Animate each one’s opacity with a sine wave offset by a random phase per star, and they twinkle independently instead of pulsing in unison, which would look mechanical.

A very light film-grain layer β€” a scattering of single, semi-transparent white pixels redrawn fresh every frame β€” breaks up the smoothness of a gradient-and-blur image just enough to stop it looking like plastic. Keep the opacity low; grain should be felt more than seen.

Offer more than one shape

Ribbons flowing sideways is one composition, but variety keeps the tool interesting. A “veil” mode reuses the same ribbon code with a slower, smaller drift, so it reads as a still curtain of light rather than a moving current. A “burst” mode throws out the ribbons entirely and instead draws several soft radial gradients from one shared point, growing in radius and shifting through the palette β€” closer to a supernova than an aurora, and worth the few extra lines it costs.

Both reuse the same seeded recipe and the same colour and blending techniques; only the arrangement of shapes on screen changes.

Exporting a still from a moving scene

The screen version animates continuously, but an export needs one specific frame, at a specific resolution, saved as a file.

Reuse every drawing function exactly as they are, but point them at a second, offscreen canvas sized for the export β€” 1920 by 1200 for a normal wallpaper, 3840 by 2400 for a crisp 4K one β€” rather than the one visible on screen. Because the recipe is already fixed, the exported image matches what was on screen; only the resolution changes. Convert that canvas to a PNG blob and trigger a download. No server, no upload, no size limit beyond what the person’s own browser can handle.

What makes it feel like a finished tool, not a demo

Show the seed on screen, as a short readable code, so a favourite result can be written down and recreated later. Let sliders for ribbon count, softness and grain feel immediate β€” recompute the recipe when the count changes, since it affects how many ribbons exist, but let softness and grain apply live during drawing without touching the recipe at all, since they only change how existing shapes are rendered. And respect people who prefer stillness: check for reduced-motion preferences and skip the animation loop for them, showing one static frame instead.

None of the individual pieces here are advanced. What makes the result feel considered is the discipline of keeping randomness deterministic, separating what is decided once from what is drawn every frame, and reaching for screen blending instead of settling for flat colour. Put those three ideas together and a few hundred lines of canvas code start to look like something worth looking at.

<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Stellix Aurora β€” Coding Stellix</title>
<meta name="description" content="Stellix Aurora by Coding Stellix β€” generate glowing animated aurora wallpapers from a seed. Pick a palette, shuffle the shape, export a still PNG at any resolution. Works offline in one HTML file." />
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Jost:wght@300;400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet">
<style>
:root{
  --bg:#07060d;
  --panel:rgba(255,255,255,.05);
  --stroke:rgba(255,255,255,.11);
  --soft:rgba(255,255,255,.045);
  --ink:#f1eefc;
  --muted:#a79fc4;
  --faint:#6e6690;
  --shadow:0 24px 60px rgba(0,0,0,.55);
}
html[data-theme="light"]{
  --bg:#f3f1fb;
  --panel:#ffffff;
  --stroke:rgba(30,20,60,.11);
  --soft:rgba(30,20,60,.04);
  --ink:#181229;
  --muted:#615a7d;
  --faint:#9992b3;
  --shadow:0 20px 50px rgba(40,30,90,.14);
}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:'Jost',sans-serif;background:var(--bg);color:var(--ink);min-height:100vh;
  padding:16px 14px 34px;transition:background .3s,color .3s}
.wrap{max-width:1220px;margin:0 auto;display:flex;flex-direction:column;gap:14px}

header{display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:11px}
.mark{width:40px;height:40px;border-radius:12px;flex:none;display:grid;place-items:center;
  background:linear-gradient(140deg,#7c3aed,#06b6d4,#f472b6);background-size:200% 200%;
  animation:markshift 6s ease infinite;box-shadow:0 8px 24px rgba(124,58,237,.35)}
@keyframes markshift{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
.mark svg{width:21px;height:21px}
.brand b{display:block;font-size:.97rem;font-weight:600;line-height:1.15}
.brand small{display:block;font-size:.66rem;letter-spacing:.22em;text-transform:uppercase;color:var(--muted)}
.icon-btn{width:42px;height:42px;border-radius:12px;border:1px solid var(--stroke);background:var(--panel);
  color:var(--ink);cursor:pointer;display:grid;place-items:center;transition:transform .16s,border-color .16s}
.icon-btn:hover{transform:translateY(-2px);border-color:#a78bfa}
.icon-btn:focus-visible{outline:2px solid #a78bfa;outline-offset:3px}
.icon-btn svg{width:19px;height:19px}

h1.page{font-size:clamp(1.6rem,5vw,2.2rem);font-weight:700;letter-spacing:-.03em}
.sub{color:var(--muted);font-size:.88rem;font-weight:300;margin-top:2px}

.layout{display:grid;grid-template-columns:1fr 320px;gap:14px;align-items:start}
.stagewrap{background:var(--panel);border:1px solid var(--stroke);border-radius:18px;padding:14px;box-shadow:var(--shadow)}
.stage{position:relative;border-radius:13px;overflow:hidden;background:#000;aspect-ratio:16/10}
#cv{display:block;width:100%;height:100%}
.stage .seedtag{position:absolute;left:12px;bottom:12px;font-family:'Space Mono',monospace;font-size:.72rem;
  color:rgba(255,255,255,.75);background:rgba(0,0,0,.35);backdrop-filter:blur(6px);border-radius:8px;padding:5px 10px;
  border:1px solid rgba(255,255,255,.14)}

.card{background:var(--panel);border:1px solid var(--stroke);border-radius:16px;padding:14px;box-shadow:var(--shadow)}
.card + .card{margin-top:12px}
.card h2{font-size:.65rem;letter-spacing:.2em;text-transform:uppercase;color:var(--muted);font-weight:600;margin-bottom:10px}

.btn{font-family:'Jost',sans-serif;font-size:.85rem;font-weight:500;min-height:42px;padding:9px 15px;border-radius:11px;
  border:1px solid var(--stroke);background:var(--panel);color:var(--ink);cursor:pointer;
  display:inline-flex;align-items:center;justify-content:center;gap:7px;transition:transform .16s,border-color .16s}
.btn:hover{transform:translateY(-2px);border-color:#a78bfa}
.btn:focus-visible{outline:2px solid #a78bfa;outline-offset:3px}
.btn.primary{background:linear-gradient(120deg,#7c3aed,#06b6d4);border-color:transparent;color:#fff;font-weight:600;
  box-shadow:0 10px 28px rgba(124,58,237,.35)}
.btn svg{width:15px;height:15px}
.row{display:flex;gap:8px;flex-wrap:wrap}
.row .btn{flex:1;min-width:110px}

.pals{display:flex;flex-direction:column;gap:7px}
.pal{display:flex;align-items:center;gap:9px;border:1px solid var(--stroke);border-radius:11px;padding:8px 10px;
  background:var(--soft);cursor:pointer;transition:.15s}
.pal:hover{border-color:#a78bfa}
.pal.on{border-color:var(--ink);box-shadow:inset 0 0 0 1px var(--ink)}
.pal .sw{display:flex;border-radius:7px;overflow:hidden;width:56px;height:22px;flex:none}
.pal .sw i{flex:1;display:block}
.pal b{font-size:.78rem;font-weight:500;color:var(--muted)}
.pal.on b{color:var(--ink)}

.chips{display:flex;flex-wrap:wrap;gap:6px}
.chip{font-size:.76rem;border:1px solid var(--stroke);background:var(--soft);border-radius:9px;padding:6px 11px;
  cursor:pointer;color:var(--muted);transition:.15s;user-select:none}
.chip:hover{border-color:#a78bfa;color:var(--ink)}
.chip.on{background:var(--ink);color:var(--bg);border-color:var(--ink)}

label{display:block;font-size:.63rem;letter-spacing:.09em;text-transform:uppercase;color:var(--muted);margin-bottom:4px;font-weight:500}
input[type=text]{width:100%;font-family:'Space Mono',monospace;font-size:.86rem;color:var(--ink);
  background:var(--soft);border:1px solid var(--stroke);border-radius:10px;padding:9px 11px}
input[type=text]:focus{outline:none;border-color:#a78bfa}
input[type=range]{width:100%;accent-color:#a78bfa}
.rowlab{display:flex;justify-content:space-between;align-items:center;margin-top:9px}
.rowlab span{font-family:'Space Mono',monospace;font-size:.7rem;color:var(--faint)}
.field{margin-top:9px}

.msg{font-size:.77rem;color:var(--muted);font-weight:300;line-height:1.6}
footer{text-align:center;font-size:.75rem;color:var(--muted);font-weight:300;border-top:1px solid var(--stroke);padding-top:13px}
footer b{color:var(--ink);font-weight:600}

@media (max-width:920px){ .layout{grid-template-columns:1fr} .side{order:2} }
@media (prefers-reduced-motion:reduce){ #cv{animation:none} .mark{animation:none} }
</style>
</head>
<body>
<div class="wrap">

  <header>
    <div class="brand">
      <div class="mark">
        <svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <path d="M3 17c3-7 6-9 9-9s6 2 9 9"/><path d="M3 12c3-5 6-6.5 9-6.5s6 1.5 9 6.5"/>
        </svg>
      </div>
      <div><b>Coding Stellix</b><small>Aurora</small></div>
    </div>
    <button class="icon-btn" id="themeBtn" aria-label="Switch to light mode" title="Switch theme">
      <svg id="themeIcon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
        <circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>
      </svg>
    </button>
  </header>

  <div>
    <h1 class="page">Aurora Wallpaper</h1>
    <p class="sub">Every seed paints a different sky. Shuffle until one feels right, then save it.</p>
  </div>

  <div class="layout">
    <div class="main">
      <div class="stagewrap">
        <div class="stage"><canvas id="cv"></canvas><span class="seedtag" id="seedTag"></span></div>
        <div class="row" style="margin-top:12px">
          <button class="btn primary" id="shuffleBtn">
            <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12a9 9 0 0 1 15.5-6.2L21 8"/><path d="M21 3v5h-5"/><path d="M21 12a9 9 0 0 1-15.5 6.2L3 16"/><path d="M3 21v-5h5"/></svg>
            Shuffle
          </button>
          <button class="btn" id="pngBtn">Download PNG</button>
          <button class="btn" id="hdBtn">Download 4K</button>
        </div>
        <p class="msg" id="statusMsg" style="margin-top:9px"></p>
      </div>
    </div>

    <div class="side">
      <div class="card">
        <h2>Palette</h2>
        <div class="pals" id="palBox"></div>
      </div>
      <div class="card">
        <h2>Shape</h2>
        <div class="chips" id="shapeBox"></div>
        <div class="field">
          <div class="rowlab"><label for="bandRange">Ribbons</label><span id="bandVal">5</span></div>
          <input type="range" id="bandRange" min="2" max="9" value="5">
        </div>
        <div class="field">
          <div class="rowlab"><label for="softRange">Softness</label><span id="softVal">60</span></div>
          <input type="range" id="softRange" min="10" max="100" value="60">
        </div>
        <div class="field">
          <div class="rowlab"><label for="grainRange">Grain</label><span id="grainVal">18</span></div>
          <input type="range" id="grainRange" min="0" max="60" value="18">
        </div>
      </div>
      <div class="card">
        <h2>Seed</h2>
        <div class="field">
          <input type="text" id="seedIn" placeholder="type any word...">
        </div>
        <div class="row" style="margin-top:9px">
          <button class="btn" id="useSeedBtn">Use this seed</button>
        </div>
        <p class="msg" style="margin-top:9px">The same seed always paints the same sky β€” share the word, not the file.</p>
      </div>
    </div>
  </div>

  <footer>Built by <b>Coding Stellix</b></footer>
</div>

<script>
(function(){
"use strict";

/* =========================================================
   PURE HELPERS START β€” colour maths and the seeded RNG,
   kept free of the DOM and canvas so they can be tested
   ========================================================= */

/* deterministic PRNG: the same seed always produces the same stream */
function rng(seed){
  let a=seed>>>0;
  return function(){
    a+=0x6D2B79F5;
    let t=a;
    t=Math.imul(t^(t>>>15), t|1);
    t^=t+Math.imul(t^(t>>>7), t|61);
    return ((t^(t>>>14))>>>0)/4294967296;
  };
}
function seedFromText(text){
  let h=2166136261>>>0;
  const s=String(text||'');
  for(let i=0;i<s.length;i++){ h^=s.charCodeAt(i); h=Math.imul(h,16777619); }
  return h>>>0;
}
function randomSeed(){ return (Math.random()*4294967296)>>>0; }

function hexToRgb(hex){
  const h=hex.replace('#','');
  const n=parseInt(h.length===3?h.split('').map(c=>c+c).join(''):h,16);
  return {r:(n>>16)&255, g:(n>>8)&255, b:n&255};
}
function rgbToHex(r,g,b){
  const c=v=>Math.max(0,Math.min(255,Math.round(v))).toString(16).padStart(2,'0');
  return '#'+c(r)+c(g)+c(b);
}
/* mix two hex colours, t=0 gives a, t=1 gives b */
function lerpColor(a,b,t){
  const A=hexToRgb(a), B=hexToRgb(b);
  return rgbToHex(A.r+(B.r-A.r)*t, A.g+(B.g-A.g)*t, A.b+(B.b-A.b)*t);
}
/* relative luminance, used to decide readable overlay text */
function luminance(hex){
  const {r,g,b}=hexToRgb(hex);
  return (0.299*r+0.587*g+0.114*b)/255;
}

/* one smooth ribbon path across the canvas: an array of {x,y} points
   built from a handful of random control points and a cosine easing
   between them, so it always reads as a single flowing curve */
function ribbonPoints(seedFn,width,height,steps){
  const n=Math.max(4,steps||24);
  const baseY=height*(0.15+seedFn()*0.6);
  const amp=height*(0.08+seedFn()*0.22);
  const ctrl=[];
  const knots=3+Math.floor(seedFn()*3);
  for(let i=0;i<=knots;i++) ctrl.push(seedFn()*2-1);
  const pts=[];
  for(let i=0;i<=n;i++){
    const t=i/n;
    const pos=t*knots;
    const k0=Math.min(knots,Math.floor(pos));
    const k1=Math.min(knots,k0+1);
    const f=pos-k0;
    const ease=(1-Math.cos(f*Math.PI))/2;
    const v=ctrl[k0]+(ctrl[k1]-ctrl[k0])*ease;
    pts.push({ x:t*width, y:baseY+v*amp });
  }
  return pts;
}

/* build the full deterministic "recipe" for a sky: every number the
   renderer needs, so the same seed + options always draws the same thing */
function buildRecipe(seedNum,opts){
  const seedFn=rng(seedNum);
  const bands=Math.max(1,opts.bands||5);
  const ribbons=[];
  for(let i=0;i<bands;i++){
    ribbons.push({
      colorT: (i+0.5)/bands,          // where along the palette this ribbon sits
      widthFrac: 0.10+seedFn()*0.16,
      alpha: 0.35+seedFn()*0.35,
      seedOffset: Math.floor(seedFn()*1e9),
      drift: seedFn()*2-1
    });
  }
  const stars=[];
  const starCount=40+Math.floor(seedFn()*70);
  for(let i=0;i<starCount;i++){
    stars.push({ x:seedFn(), y:seedFn()*0.6, r:0.4+seedFn()*1.3, tw:seedFn() });
  }
  return { ribbons:ribbons, stars:stars, starCount:starCount };
}
/* ========================= PURE HELPERS END ========================= */

const $=id=>document.getElementById(id);
const cv=$('cv'), ctx=cv.getContext('2d');

const PALETTES=[
  {n:'Aurora Violet', c:['#05030f','#2a0e6b','#7c3aed','#06b6d4','#e0f2fe']},
  {n:'Solar Flare',   c:['#08040a','#3a0d47','#c026d3','#f97316','#fde68a']},
  {n:'Emerald Night', c:['#02100c','#03301f','#0f9d76','#5eead4','#ecfeff']},
  {n:'Rose Nebula',   c:['#0b0410','#4a0e3d','#db2777','#f472b6','#ffe4f1']},
  {n:'Deep Ocean',    c:['#02040c','#0b2a52','#0891b2','#38bdf8','#e0f7ff']},
  {n:'Wildfire',      c:['#080302','#3f0d0d','#dc2626','#f59e0b','#fef3c7']}
];
const SHAPES=[
  {k:'ribbons', n:'Ribbons'},
  {k:'veil',    n:'Veil'},
  {k:'burst',   n:'Burst'}
];

const S={
  seed: seedFromText('coding stellix'),
  pal: 0,
  shape:'ribbons',
  bands:5,
  soft:60,
  grain:18,
  recipe:null
};

/* ---------------- sizing ---------------- */
let DPR=1, CW=960, CH=600;
function resize(){
  const rect=cv.parentElement.getBoundingClientRect();
  DPR=Math.min(window.devicePixelRatio||1,2);
  CW=Math.max(240,Math.round(rect.width));
  CH=Math.max(150,Math.round(rect.height));
  cv.width=CW*DPR; cv.height=CH*DPR;
  ctx.setTransform(DPR,0,0,DPR,0,0);
  draw();
}

/* ---------------- palette / controls ---------------- */
function renderPalettes(){
  $('palBox').innerHTML=PALETTES.map((p,i)=>
    '<div class="pal'+(S.pal===i?' on':'')+'" data-i="'+i+'" tabindex="0">'+
    '<span class="sw">'+p.c.map(c=>'<i style="background:'+c+'"></i>').join('')+'</span>'+
    '<b>'+p.n+'</b></div>').join('');
  $('palBox').querySelectorAll('.pal').forEach(el=>{
    const go=()=>{ S.pal=+el.dataset.i; renderPalettes(); draw(); };
    el.onclick=go;
    el.onkeydown=e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); go(); } };
  });
}
function renderShapes(){
  $('shapeBox').innerHTML=SHAPES.map(s=>
    '<span class="chip'+(S.shape===s.k?' on':'')+'" data-k="'+s.k+'" tabindex="0">'+s.n+'</span>').join('');
  $('shapeBox').querySelectorAll('.chip').forEach(el=>{
    const go=()=>{ S.shape=el.dataset.k; renderShapes(); draw(); };
    el.onclick=go;
    el.onkeydown=e=>{ if(e.key==='Enter'||e.key===' '){ e.preventDefault(); go(); } };
  });
}

/* ---------------- rendering ---------------- */
function paletteColors(){ return PALETTES[S.pal].c; }
function colorAt(t){
  const cols=paletteColors();
  const n=cols.length-1;
  const pos=Math.max(0,Math.min(1,t))*n;
  const i=Math.min(n-1,Math.floor(pos));
  return lerpColor(cols[i],cols[i+1],pos-i);
}

function paintSky(c,w,h){
  const cols=paletteColors();
  const g=c.createLinearGradient(0,0,0,h);
  g.addColorStop(0,cols[0]);
  g.addColorStop(0.55,cols[1]);
  g.addColorStop(1,cols[0]);
  c.fillStyle=g; c.fillRect(0,0,w,h);
}

function paintStars(c,w,h,stars,twinkle){
  c.save();
  stars.forEach(s=>{
    const a=0.35+0.5*Math.abs(Math.sin(twinkle*2+s.tw*10));
    c.globalAlpha=a;
    c.fillStyle='#ffffff';
    c.beginPath();
    c.arc(s.x*w, s.y*h, s.r, 0, Math.PI*2);
    c.fill();
  });
  c.restore();
}

function paintRibbon(c,w,h,ribbon,recipeSeed,shape,softness,tOffset){
  const seedFn=rng((recipeSeed+ribbon.seedOffset)>>>0);
  const pts=ribbonPoints(seedFn,w,h,28);
  const drift=tOffset*ribbon.drift*w*0.02;

  const grad=c.createLinearGradient(0,0,w,0);
  const col=colorAt(ribbon.colorT);
  grad.addColorStop(0, hexA(col,0));
  grad.addColorStop(0.5, hexA(col,ribbon.alpha));
  grad.addColorStop(1, hexA(col,0));

  c.save();
  c.filter='blur('+(softness*0.01*h*0.10).toFixed(1)+'px)';
  c.globalCompositeOperation='screen';
  c.beginPath();
  const bw=h*ribbon.widthFrac;
  pts.forEach((p,i)=>{
    const x=p.x+drift, y=p.y-bw/2;
    if(i===0) c.moveTo(x,y); else c.lineTo(x,y);
  });
  for(let i=pts.length-1;i>=0;i--){
    const x=pts[i].x+drift, y=pts[i].y+bw/2;
    c.lineTo(x,y);
  }
  c.closePath();
  c.fillStyle=grad;
  c.fill();
  c.restore();
}

function paintBurst(c,w,h,recipeSeed,pal,softness,t){
  const seedFn=rng(recipeSeed>>>0);
  const cx=w*(0.3+seedFn()*0.4), cy=h*(0.3+seedFn()*0.3);
  const rings=5+Math.floor(seedFn()*4);
  c.save();
  c.globalCompositeOperation='screen';
  c.filter='blur('+(softness*0.01*h*0.08).toFixed(1)+'px)';
  for(let i=0;i<rings;i++){
    const rt=i/rings;
    const r=w*(0.08+rt*0.5)+Math.sin(t*1.4+i)*w*0.01;
    const col=colorAt(rt);
    const g=c.createRadialGradient(cx,cy,0,cx,cy,r);
    g.addColorStop(0,hexA(col,0.5-rt*0.35));
    g.addColorStop(1,hexA(col,0));
    c.fillStyle=g;
    c.beginPath(); c.arc(cx,cy,r,0,Math.PI*2); c.fill();
  }
  c.restore();
}

function hexA(hex,a){
  const {r,g,b}=hexToRgb(hex);
  return 'rgba('+r+','+g+','+b+','+Math.max(0,Math.min(1,a))+')';
}

function paintGrain(c,w,h,amount){
  if(amount<=0) return;
  const n=Math.round(amount*4);
  c.save();
  c.globalAlpha=Math.min(0.16,amount/260);
  c.fillStyle='#ffffff';
  for(let i=0;i<n*10;i++){
    c.fillRect(Math.random()*w, Math.random()*h, 1, 1);
  }
  c.restore();
}

let t0=performance.now();
function draw(now){
  const t=((now||performance.now())-t0)/1000;
  ctx.clearRect(0,0,CW,CH);
  paintSky(ctx,CW,CH);

  if(S.shape==='burst'){
    paintBurst(ctx,CW,CH,S.seed,PALETTES[S.pal],S.soft,t*0.3);
  } else {
    S.recipe.ribbons.forEach((r,i)=>{
      const off = S.shape==='veil' ? Math.sin(t*0.15+i)*0.3 : Math.sin(t*0.22+i*1.3)*0.5+t*0.02;
      paintRibbon(ctx,CW,CH,r,S.seed,S.shape,S.soft,off);
    });
  }
  paintStars(ctx,CW,CH,S.recipe.stars,t);
  paintGrain(ctx,CW,CH,S.grain);
}

let raf=null;
function loop(now){ raf=requestAnimationFrame(loop); draw(now); }

function rebuild(){
  S.recipe=buildRecipe(S.seed,{bands:S.bands});
  $('seedTag').textContent='seed '+S.seed.toString(36);
}

/* ---------------- controls ---------------- */
$('bandRange').addEventListener('input',e=>{ S.bands=+e.target.value; $('bandVal').textContent=S.bands; rebuild(); });
$('softRange').addEventListener('input',e=>{ S.soft=+e.target.value; $('softVal').textContent=S.soft; });
$('grainRange').addEventListener('input',e=>{ S.grain=+e.target.value; $('grainVal').textContent=S.grain; });

$('shuffleBtn').onclick=()=>{ S.seed=randomSeed(); $('seedIn').value=''; rebuild(); $('statusMsg').textContent='New seed painted.'; };
$('useSeedBtn').onclick=()=>{
  const v=$('seedIn').value.trim();
  if(!v){ $('statusMsg').textContent='Type a word first.'; return; }
  S.seed=seedFromText(v); rebuild();
  $('statusMsg').textContent='Painted from "'+v+'".';
};
$('seedIn').addEventListener('keydown',e=>{ if(e.key==='Enter') $('useSeedBtn').click(); });

function exportPNG(scaleW,scaleH){
  const out=document.createElement('canvas');
  out.width=scaleW; out.height=scaleH;
  const oc=out.getContext('2d');
  const savedCW=CW, savedCH=CH;
  // draw one static frame at the export resolution, reusing the same recipe
  const t=performance.now();
  const tmpCW=CW, tmpCH=CH;
  CW=scaleW; CH=scaleH;
  paintSky(oc,CW,CH);
  if(S.shape==='burst'){ paintBurst(oc,CW,CH,S.seed,PALETTES[S.pal],S.soft,0.4); }
  else { S.recipe.ribbons.forEach((r,i)=>paintRibbon(oc,CW,CH,r,S.seed,S.shape,S.soft,0.15*i)); }
  paintStars(oc,CW,CH,S.recipe.stars,0.6);
  paintGrain(oc,CW,CH,S.grain);
  CW=tmpCW; CH=tmpCH;
  out.toBlob(b=>{
    if(!b){ $('statusMsg').textContent='This browser blocked the export.'; return; }
    const url=URL.createObjectURL(b);
    const a=document.createElement('a');
    a.href=url; a.download='stellix-aurora-'+S.seed.toString(36)+'-'+scaleW+'x'+scaleH+'.png';
    document.body.appendChild(a); a.click();
    setTimeout(()=>{ URL.revokeObjectURL(url); a.remove(); },500);
    $('statusMsg').textContent='Saved at '+scaleW+' x '+scaleH+'.';
  },'image/png');
}
$('pngBtn').onclick=()=>exportPNG(1920,1200);
$('hdBtn').onclick=()=>exportPNG(3840,2400);

const themeBtn=$('themeBtn'), themeIcon=$('themeIcon');
const MOON='<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>';
const SUN='<circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M2 12h2M20 12h2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M19.1 4.9l-1.4 1.4M6.3 17.7l-1.4 1.4"/>';
themeBtn.onclick=()=>{
  const light=document.documentElement.getAttribute('data-theme')==='light';
  document.documentElement.setAttribute('data-theme',light?'dark':'light');
  themeIcon.innerHTML=light?SUN:MOON;
  themeBtn.setAttribute('aria-label',light?'Switch to light mode':'Switch to dark mode');
};

window.addEventListener('resize',resize);
if(window.ResizeObserver){ try{ new ResizeObserver(resize).observe(cv.parentElement); }catch(e){} }

/* ---------------- boot ---------------- */
renderPalettes(); renderShapes(); rebuild(); resize();
requestAnimationFrame(loop);

window.__stellixAurora={rng:rng,seedFromText:seedFromText,hexToRgb:hexToRgb,rgbToHex:rgbToHex,
  lerpColor:lerpColor,luminance:luminance,ribbonPoints:ribbonPoints,buildRecipe:buildRecipe};
})();
</script>
</body>
</html>

Leave a Reply

Your email address will not be published. Required fields are marked *

SHARE:-

Trending Post

Latest Post