Split the gallery into sections with a divider, start the colored set

Generators now live in gen/<section>/<subject>/<action>.js; the section
folder picks where a tile shows in the gallery and isn't part of the URL.
The first 50 are in gen/bw, a dashed divider follows, then gen/color.

First colored ones: fox/sleep, pig/oink, duck/swim, whale/spout,
turtle/walk, owl/blink, crab/dance, butterfly/flutter, dino/stomp,
jellyfish/pulse.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 20:45:17 +00:00
co-authored by Claude Opus 5.5
parent 8842194c3c
commit ab7e0dc0df
77 changed files with 275 additions and 8 deletions
+24
View File
@@ -0,0 +1,24 @@
// butterfly fluttering in a little loop, purple and pink wings with yellow spots
const{sin,cos,PI,abs}=Math,T=2*PI
export const frames=20,delay=60
const UP='#b197fc',LO='#faa2c1',SP='#ffd43b',BD='#7048e8'
export default(c,t)=>{
const x=128+20*sin(t*T),y=128+12*sin(2*T*t),r=.12*cos(t*T),s=.22+.78*abs(cos(3*PI*t))
const W=(d,f,col)=>{c.save();c.translate(x,y);c.rotate(r);c.scale(d*s,1);c.beginPath();f();c.restore();c.fillStyle=col;c.fill();c.stroke()}
for(const d of[-1,1]){
c.lineWidth=7
W(d,()=>{c.moveTo(0,4);c.bezierCurveTo(50,10,80,44,58,74);c.bezierCurveTo(40,92,6,62,0,12)},LO)
W(d,()=>{c.moveTo(0,-6);c.bezierCurveTo(30,-72,100,-62,86,-14);c.bezierCurveTo(80,6,30,6,0,0)},UP)
c.lineWidth=4
W(d,()=>c.ellipse(58,-30,13,13,0,0,T),SP)
W(d,()=>c.ellipse(34,-14,6,6,0,0,T),'#fff')
W(d,()=>c.ellipse(40,46,9,9,0,0,T),SP)
}
c.save();c.translate(x,y);c.rotate(r)
for(const d of[-1,1])for(const[w,st]of[[8,'#111'],[3,'#fff']])c.lineWidth=w,c.strokeStyle=st,c.beginPath(),c.moveTo(d*4,-36),c.quadraticCurveTo(d*10,-62,d*22,-66),c.stroke()
c.lineWidth=4;c.strokeStyle='#111';c.fillStyle='#fff';for(const d of[-1,1])c.beginPath(),c.arc(d*22,-66,4.5,0,T),c.fill(),c.stroke()
c.lineWidth=6;c.fillStyle=BD;c.beginPath();c.ellipse(0,8,8,36,0,0,T);c.fill();c.stroke()
c.beginPath();c.arc(0,-34,11,0,T);c.fill();c.stroke()
c.fillStyle='#fff';for(const d of[-1,1])c.beginPath(),c.arc(d*4,-35,2.2,0,T),c.fill()
c.restore()
}
+27
View File
@@ -0,0 +1,27 @@
// crab side-stepping back and forth with its claws up, snapping to the beat
const{sin,cos,PI}=Math,T=2*PI
export const frames=20,delay=70
const R='#ff6b6b',SP='#ffa8a8',BL='#e03131'
export default(c,t)=>{
const x=128+22*sin(t*T),sn=(1+cos(4*T*t))/2,b=2*sin(4*T*t)
const D=(w,col,f)=>{for(const[l,s]of[[w,'#111'],[w-8,col]])c.lineWidth=l,c.strokeStyle=s,c.beginPath(),f(),c.stroke();c.lineWidth=7;c.strokeStyle='#111'}
for(const d of[-1,1])for(let k=0;k<3;k++){
const lift=4*sin(4*T*t+k*2+(d>0?PI:0))
D(14,R,()=>{c.moveTo(x+d*(34+k*10),176);c.lineTo(x+d*(62+k*11),160+lift);c.lineTo(x+d*(72+k*12),206+(k==1?2:0))})
}
for(const d of[-1,1]){
const cx=x+d*72,cy=92+b
D(18,R,()=>{c.moveTo(x+d*40,156);c.quadraticCurveTo(x+d*72,150,cx,cy+18)})
const m=.12+.4*sn,a=-PI/2-d*.35
c.fillStyle=R;c.beginPath();c.moveTo(cx,cy);c.arc(cx,cy,26,a+m,a-m+T);c.closePath();c.fill();c.stroke()
}
for(const d of[-1,1])D(12,R,()=>{c.moveTo(x+d*16,150);c.lineTo(x+d*20,110+b)})
c.fillStyle=R;c.beginPath();c.ellipse(x,166,62,40,0,0,T);c.fill();c.stroke()
c.fillStyle=SP;for(const[dx,dy,r]of[[-30,-18,6],[24,-22,5],[36,-6,4],[-40,-2,4]])c.beginPath(),c.arc(x+dx,166+dy,r,0,T),c.fill()
for(const d of[-1,1]){
c.fillStyle='#fff';c.beginPath();c.arc(x+d*20,104+b,12,0,T);c.fill();c.stroke()
c.fillStyle='#111';c.beginPath();c.arc(x+d*20+d*2,105+b,5.5,0,T);c.fill()
}
c.lineWidth=5;c.beginPath();c.arc(x,170,12,.2*PI,.8*PI);c.stroke()
c.fillStyle=BL;for(const d of[-1,1])c.beginPath(),c.ellipse(x+d*30,176,7,4,0,0,T),c.fill()
}
+29
View File
@@ -0,0 +1,29 @@
// little dinosaur stomping in place, roaring each time a foot lands and kicking up dust
const{sin,cos,PI,max}=Math,T=2*PI
export const frames=16,delay=80
const G='#69db7c',BE='#d8f5a2',SP='#ffa94d',BL='#ffa8a8'
export default(c,t)=>{
const u=(t*2)%1,y=-5*sin(PI*u),la=t<.5?18*sin(2*PI*t):0,lb=t>=.5?18*sin(2*PI*(t-.5)):0,roar=u<.3,tw=.12*sin(T*t)
const tail=()=>{c.moveTo(78,132+y);c.quadraticCurveTo(40,150+y,14,196+y+20*tw);c.quadraticCurveTo(54,190+y,90,184+y)}
for(const[x,l]of[[98,la],[146,lb]]){c.fillStyle=G;c.beginPath();c.roundRect(x-17,170+y,34,58-y-l,12);c.fill();c.stroke()
c.lineWidth=4;c.beginPath();c.moveTo(x+6,226-l);c.lineTo(x+6,218-l);c.moveTo(x-4,226-l);c.lineTo(x-4,218-l);c.stroke();c.lineWidth=7}
if(u<.25)for(const d of[-1,1]){const fx=t<.5?98:146,s=(1-u/.25);c.fillStyle='#fff';c.beginPath();c.arc(fx+d*(30+18*(1-s)),222,8*s+2,0,T);c.fill();c.stroke()}
c.fillStyle=SP
for(const[x,yy,a]of[[64,126,-.9],[90,104,-.5],[118,96,-.1],[146,98,.2]])c.save(),c.translate(x,yy+y),c.rotate(a),c.beginPath(),c.moveTo(-12,8),c.lineTo(0,-18),c.lineTo(12,8),c.closePath(),c.fill(),c.stroke(),c.restore()
const head=()=>c.ellipse(186,90+y,44,32,-.08,0,T),body=()=>c.ellipse(120,150+y,60,50,0,0,T),neck=()=>{c.moveTo(146,134+y);c.lineTo(172,104+y)}
c.lineWidth=14;c.beginPath();tail();c.stroke();for(const f of[head,body])c.beginPath(),f(),c.stroke()
c.lineWidth=58;c.beginPath();neck();c.stroke()
c.fillStyle=c.strokeStyle=G;c.lineWidth=44;c.beginPath();neck();c.stroke()
c.beginPath();tail();c.fill();for(const f of[head,body])c.beginPath(),f(),c.fill()
c.strokeStyle='#111';c.lineWidth=7
c.save();c.beginPath();body();c.clip();c.fillStyle=BE;c.beginPath();c.ellipse(140,184+y,42,40,0,0,T);c.fill();c.lineWidth=5;c.stroke()
c.lineWidth=3;for(const yy of[166,180,194])c.beginPath(),c.moveTo(112,yy+y),c.lineTo(170,yy+y),c.stroke();c.restore()
for(const[w,s]of[[16,'#111'],[8,G]])c.lineWidth=w,c.strokeStyle=s,c.beginPath(),c.moveTo(160,148+y),c.lineTo(176,160+y+(roar?-8:0)),c.stroke()
c.lineWidth=7;c.strokeStyle='#111'
c.fillStyle='#111';c.beginPath();c.arc(190,80+y,6,0,T);c.fill();c.beginPath();c.arc(222,82+y,2.5,0,T);c.fill()
c.fillStyle='#fff';c.beginPath();c.arc(188,78+y,2.2,0,T);c.fill()
c.fillStyle=BL;c.beginPath();c.ellipse(176,98+y,8,5,0,0,T);c.fill()
if(roar){c.fillStyle='#111';c.beginPath();c.moveTo(194,104+y);c.quadraticCurveTo(212,124+y,228,100+y);c.closePath();c.fill()
c.fillStyle='#fff';for(const x of[202,214])c.beginPath(),c.moveTo(x,104+y),c.lineTo(x+4,111+y),c.lineTo(x+8,103+y),c.fill()}
else{c.lineWidth=5;c.beginPath();c.moveTo(196,104+y);c.quadraticCurveTo(212,110+y,226,100+y);c.stroke()}
}
+23
View File
@@ -0,0 +1,23 @@
// rubber duck bobbing and rocking on little rolling waves
const{sin,PI}=Math,T=2*PI
export const frames=20,delay=80
const Y='#ffd43b',YD='#fab005',O='#ff922b',WA='#74c0fc',BL='#ffa8a8'
export default(c,t)=>{
const b=5*sin(t*T),r=.07*sin(t*T+1)
c.save();c.translate(128,170+b);c.rotate(r);c.translate(-128,-170)
c.fillStyle=Y
c.beginPath();c.moveTo(186,140);c.lineTo(214,118);c.lineTo(206,156);c.closePath();c.fill();c.stroke()
c.beginPath();c.ellipse(136,158,72,44,0,0,T);c.fill();c.stroke()
c.fillStyle=YD;c.beginPath();c.moveTo(122,150);c.quadraticCurveTo(162,128,190,152);c.quadraticCurveTo(160,180,122,150);c.fill();c.lineWidth=5;c.stroke();c.lineWidth=7
c.fillStyle=Y;c.beginPath();c.arc(98,100,38,0,T);c.fill();c.stroke()
c.lineWidth=5;c.beginPath();c.moveTo(100,62);c.quadraticCurveTo(96,48,108,46);c.stroke();c.lineWidth=7
c.fillStyle=O;c.beginPath();c.ellipse(60,110,22,11,-.1,0,T);c.fill();c.stroke()
c.lineWidth=4;c.beginPath();c.moveTo(42,111);c.lineTo(72,110);c.stroke()
c.fillStyle='#111';c.beginPath();c.arc(94,92,6,0,T);c.fill()
c.fillStyle='#fff';c.beginPath();c.arc(92,90,2.2,0,T);c.fill()
c.fillStyle=BL;c.beginPath();c.ellipse(112,112,8,5,0,0,T);c.fill()
c.restore()
const wy=x=>194+7*sin(x/22-t*T)
c.lineWidth=7;c.fillStyle=WA;c.beginPath();c.moveTo(22,244);c.quadraticCurveTo(8,244,8,230);for(let x=8;x<=248;x+=4)c.lineTo(x,wy(x));c.lineTo(248,230);c.quadraticCurveTo(248,244,234,244);c.closePath();c.fill();c.stroke()
c.strokeStyle='#fff';c.lineWidth=5;for(const x0 of[40,150,210])c.beginPath(),c.moveTo(x0,wy(x0)+18),c.lineTo(x0+16,wy(x0+16)+18),c.stroke()
}
+25
View File
@@ -0,0 +1,25 @@
// fox curled up asleep on its own tail, breathing slowly, a sleep bubble swelling and shrinking at its nose
const{sin,PI}=Math,T=2*PI
export const frames=24,delay=90
const O='#ff922b',W='#fff',IN='#ffd8a8',BU='#d0ebff',BL='#ffa8a8'
export default(c,t)=>{
const b=sin(t*T),y=-3*b
const F=(col,f)=>{c.fillStyle=col;c.beginPath();f();c.fill();c.stroke()}
F(O,()=>c.ellipse(148,166+y*.5,72,50+2*b,0,0,T))
c.save();c.translate(214,206);c.rotate(.03*sin(2*T*t));c.translate(-214,-206)
c.beginPath();c.ellipse(132,206,84,24,-.04,0,T);c.fillStyle=O;c.fill()
c.save();c.clip();c.fillStyle=W;c.beginPath();c.ellipse(52,206,34,32,0,0,T);c.fill();c.lineWidth=5;c.stroke();c.restore()
c.beginPath();c.ellipse(132,206,84,24,-.04,0,T);c.stroke();c.restore()
for(const[a,b2,tp]of[[[58,140],[84,116],[52,98]],[[94,116],[118,138],[122,100]]]){
F(O,()=>{c.moveTo(a[0],a[1]+y);c.lineTo(tp[0],tp[1]+y);c.lineTo(b2[0],b2[1]+y);c.closePath()})
c.fillStyle=IN;c.beginPath();c.moveTo(a[0]+(b2[0]-a[0])*.3,a[1]+(b2[1]-a[1])*.3+y);c.lineTo(tp[0]+(a[0]+b2[0]-2*tp[0])*.2,tp[1]+12+y);c.lineTo(a[0]+(b2[0]-a[0])*.7,a[1]+(b2[1]-a[1])*.7+y);c.closePath();c.fill()
}
F(O,()=>c.arc(88,152+y,38,0,T))
F(W,()=>c.ellipse(62,166+y,26,16,-.15,0,T))
c.fillStyle='#111';c.beginPath();c.ellipse(38,162+y,7,6,0,0,T);c.fill()
c.lineWidth=5;for(const x of[78,104])c.beginPath(),c.arc(x,144+y,7,.15*PI,.85*PI),c.stroke()
c.fillStyle=BL;c.beginPath();c.ellipse(104,166+y,8,5,0,0,T);c.fill()
const r=4+12*(1+b)/2
c.lineWidth=4;c.fillStyle=BU;c.beginPath();c.arc(30-r*.4,148+y-r*.5,r,0,T);c.fill();c.stroke()
c.fillStyle=W;c.beginPath();c.arc(26-r*.6,144+y-r*.8,r*.25,0,T);c.fill()
}
+21
View File
@@ -0,0 +1,21 @@
// pink jellyfish pulsing its bell, drifting up on each squeeze, tentacles trailing and bubbles rising
const{sin,cos,PI}=Math,T=2*PI
export const frames=20,delay=80
const PK='#faa2c1',LT='#ffdeeb',DK='#f06595',BU='#d0ebff'
export default(c,t)=>{
const s=sin(t*T),y=92-10*s,rx=66*(1-.12*s),ry=56*(1+.1*s),bot=y+ry*.55
c.lineWidth=4;for(const[x0,o]of[[46,0],[210,.5],[196,.2]]){const p=(t+o)%1;c.fillStyle=BU;c.beginPath();c.arc(x0+6*sin(p*T*2),240-p*230,3+5*p,0,T);c.fill();c.stroke()}
const D=(w,col,f)=>{for(const[l,st]of[[w,'#111'],[w-8,col]])c.lineWidth=l,c.strokeStyle=st,c.beginPath(),f(),c.stroke()}
for(const k of[-1.5,-.5,.5,1.5])D(11,PK,()=>{for(let v=0;v<=1;v+=.05)c.lineTo(128+k*rx*.42+9*v*sin(v*7-t*T+k),bot+v*112)})
for(const k of[-1,0,1])D(24,LT,()=>{for(let v=0;v<=1;v+=.05)c.lineTo(128+k*20+12*v*sin(v*6-t*T+k*2),bot-4+v*80)})
c.lineWidth=7;c.strokeStyle='#111';c.fillStyle=PK
c.beginPath();c.ellipse(128,bot,rx,ry*1.4,0,PI,T)
const n=6;for(let k=0;k<n;k++){const x=128+rx-(k+.5)*2*rx/n;c.arc(x,bot,rx/n,0,PI)}
c.closePath();c.fill();c.stroke()
c.strokeStyle='#fff';c.lineWidth=6;c.beginPath();c.ellipse(128,bot,rx*.78,ry*1.1,0,PI*1.18,PI*1.4);c.stroke()
c.fillStyle=LT;for(const[dx,dy,r]of[[30,-60,6],[44,-38,4],[-6,-74,5]])c.beginPath(),c.arc(128+dx*rx/66,bot+dy*ry/56,r,0,T),c.fill()
c.strokeStyle='#111';c.lineWidth=5
for(const d of[-1,1])c.beginPath(),c.arc(128+d*20,bot-22,6,PI*1.15,PI*1.85),c.stroke()
c.beginPath();c.arc(128,bot-14,6,.15*PI,.85*PI);c.stroke()
c.fillStyle=DK;for(const d of[-1,1])c.beginPath(),c.ellipse(128+d*36,bot-14,8,5,0,0,T),c.fill()
}
+28
View File
@@ -0,0 +1,28 @@
// owl perched on a branch, rocking its head side to side and blinking its big eyes
const{sin,cos,PI}=Math,T=2*PI
export const frames=20,delay=90
const BR='#9c6644',BE='#f3d5b5',FA='#ede0d4',WG='#7f5539',Y='#ffd43b',O='#ff922b',BRANCH='#8d6346',LF='#69db7c'
export default(c,t,i)=>{
const shut=i==8||i==9,a=.1*sin(t*T)
c.fillStyle=BRANCH;c.beginPath();c.roundRect(14,206,228,20,10);c.fill();c.stroke()
c.fillStyle=LF;for(const[x,r]of[[34,-.5],[226,.5]])c.beginPath(),c.ellipse(x,198,16,8,r,0,T),c.fill(),c.stroke()
c.fillStyle=O;for(const x of[108,148])c.beginPath(),c.ellipse(x,208,14,7,0,0,T),c.fill(),c.stroke()
c.translate(128,206);c.rotate(a);c.translate(-128,-206)
c.fillStyle=BR
for(const d of[-1,1])c.beginPath(),c.moveTo(128+d*52,66),c.lineTo(128+d*66,30),c.lineTo(128+d*24,56),c.closePath(),c.fill(),c.stroke()
c.beginPath();c.ellipse(128,128,70,78,0,0,T);c.fill();c.stroke()
c.fillStyle=BE;c.beginPath();c.ellipse(128,160,44,40,0,0,T);c.fill();c.lineWidth=5;c.stroke()
c.lineWidth=4;for(const[x,y]of[[112,148],[136,146],[124,166],[148,164],[108,172]])c.beginPath(),c.arc(x,y-6,7,.2*PI,.8*PI),c.stroke()
c.lineWidth=7;c.fillStyle=WG
for(const d of[-1,1])c.beginPath(),c.ellipse(128+d*62,150,16,40,-d*(.2+.08*sin(2*T*t)),0,T),c.fill(),c.stroke()
c.fillStyle=FA;for(const d of[-1,1])c.beginPath(),c.arc(128+d*27,98,31,0,T),c.fill(),c.stroke()
for(const d of[-1,1]){
const x=128+d*27
if(shut){c.lineWidth=5;c.beginPath();c.arc(x,94,14,.15*PI,.85*PI);c.stroke();c.lineWidth=7;continue}
c.fillStyle='#fff';c.beginPath();c.arc(x,98,21,0,T);c.fill();c.lineWidth=5;c.stroke()
c.fillStyle=Y;c.beginPath();c.arc(x+d*-2,99,13,0,T);c.fill()
c.fillStyle='#111';c.beginPath();c.arc(x+d*-2,99,7,0,T);c.fill()
c.fillStyle='#fff';c.beginPath();c.arc(x+d*-2-3,95,3,0,T);c.fill();c.lineWidth=7
}
c.fillStyle=O;c.lineWidth=5;c.beginPath();c.moveTo(119,122);c.lineTo(137,122);c.lineTo(128,140);c.closePath();c.fill();c.stroke()
}
+25
View File
@@ -0,0 +1,25 @@
// pig bobbing its head, ears flapping, snout scrunching up with each oink
const{sin,cos,PI,max}=Math,T=2*PI
export const frames=20,delay=70
const P='#ffc9d6',S='#ff8fab',D='#c2255c',BL='#ffa8c5'
export default(c,t)=>{
const k=max(0,sin(2*T*t)),y=-6*k,sx=1+.1*k,sy=1-.16*k
if(k>.6)for(const d of[-1,1])for(const a of[-.35,0,.35]){
const ang=(d<0?PI:0)+d*a,x0=128+cos(ang)*98,y0=150+y+sin(ang)*72
for(const[w,s]of[[11,'#111'],[4,'#fff']])c.lineWidth=w,c.strokeStyle=s,c.beginPath(),c.moveTo(x0,y0),c.lineTo(x0+cos(ang)*18,y0+sin(ang)*14),c.stroke()
}
c.lineWidth=7;c.strokeStyle='#111'
for(const d of[-1,1]){
c.save();c.translate(128+d*54,80+y);c.rotate(d*(.55+.25*sin(2*T*t+1)))
c.fillStyle=P;c.beginPath();c.moveTo(-24,14);c.lineTo(0,-36);c.lineTo(24,14);c.closePath();c.fill();c.stroke()
c.fillStyle=S;c.beginPath();c.moveTo(-12,8);c.lineTo(0,-20);c.lineTo(12,8);c.closePath();c.fill()
c.restore()
}
c.fillStyle=P;c.beginPath();c.ellipse(128,138+y,84,74,0,0,T);c.fill();c.stroke()
c.fillStyle='#111';for(const d of[-1,1])c.beginPath(),c.ellipse(128+d*30,114+y,7,9,0,0,T),c.fill()
c.fillStyle='#fff';for(const d of[-1,1])c.beginPath(),c.arc(128+d*30-2,110+y,2.5,0,T),c.fill()
c.fillStyle=BL;for(const d of[-1,1])c.beginPath(),c.ellipse(128+d*50,150+y,13,8,0,0,T),c.fill()
c.fillStyle=S;c.beginPath();c.ellipse(128,154+y,34*sx,24*sy,0,0,T);c.fill();c.stroke()
c.fillStyle=D;for(const d of[-1,1])c.beginPath(),c.ellipse(128+d*12*sx,154+y,5,8*sy,0,0,T),c.fill()
c.lineWidth=5;c.beginPath();c.arc(128,186+y,10,.2*PI,.8*PI);c.stroke()
}
+28
View File
@@ -0,0 +1,28 @@
// turtle plodding along in place: legs stepping in diagonal pairs, head bobbing, shell rocking
const{sin,cos,PI,abs,max}=Math,T=2*PI
export const frames=16,delay=80
const SH='#51cf66',RIM='#2f9e44',SK='#ffe066',BL='#ffa8a8'
export default(c,t)=>{
const y=-2*abs(sin(T*t)),hb=3*sin(2*T*t)
for(const[x,ph]of[[84,0],[110,PI],[150,PI],[176,0]]){
const l=max(0,sin(T*t+ph))*9,s=6*cos(T*t+ph)
c.fillStyle=SK;c.beginPath();c.roundRect(x-13+s,170+y-l,26,46,12);c.fill();c.stroke()
}
c.fillStyle=SK;c.beginPath();c.moveTo(58,170+y);c.lineTo(34,184+y);c.lineTo(60,186+y);c.closePath();c.fill();c.stroke()
for(const[w,s]of[[34,'#111'],[20,SK]])c.lineWidth=w,c.strokeStyle=s,c.beginPath(),c.moveTo(184,172+y),c.lineTo(204,148+y+hb),c.stroke()
c.lineWidth=7;c.strokeStyle='#111'
c.fillStyle=RIM;c.beginPath();c.roundRect(48,160+y,164,22,11);c.fill();c.stroke()
c.fillStyle=SH;c.beginPath();c.moveTo(56,166+y);c.arc(130,166+y,74,PI,0);c.closePath();c.fill();c.stroke()
c.save();c.beginPath();c.moveTo(56,166+y);c.arc(130,166+y,74,PI,0);c.closePath();c.clip()
c.lineWidth=5;c.beginPath()
const hx=(a,r)=>[130+cos(a)*r,124+y+sin(a)*r]
for(let k=0;k<=6;k++){const[x,yy]=hx(k*PI/3,24);k?c.lineTo(x,yy):c.moveTo(x,yy)}
for(const a of[PI,PI*4/3,PI*5/3,0]){const[x,yy]=hx(a,24),[x2,y2]=hx(a,90);c.moveTo(x,yy);c.lineTo(x2,a==PI||a==0?160+y:y2)}
c.stroke();c.restore();c.lineWidth=7
c.beginPath();c.moveTo(56,166+y);c.arc(130,166+y,74,PI,0);c.closePath();c.stroke()
c.fillStyle=SK;c.beginPath();c.arc(210,140+y+hb,25,0,T);c.fill();c.stroke()
c.fillStyle='#111';c.beginPath();c.arc(218,132+y+hb,5.5,0,T);c.fill()
c.fillStyle='#fff';c.beginPath();c.arc(216,130+y+hb,2,0,T);c.fill()
c.lineWidth=4;c.beginPath();c.arc(214,144+y+hb,9,.1*PI,.6*PI);c.stroke()
c.fillStyle=BL;c.beginPath();c.ellipse(204,146+y+hb,6,4,0,0,T);c.fill()
}
+28
View File
@@ -0,0 +1,28 @@
// smiling whale bobbing along, tail flapping, water spouting out of its blowhole
const{sin,PI}=Math,T=2*PI
export const frames=24,delay=70
const B='#4dabf7',BE='#d0ebff',WA='#a5d8ff',BL='#faa2c1'
const body=c=>{c.beginPath();c.moveTo(36,162);c.bezierCurveTo(32,96,150,78,204,146);c.bezierCurveTo(180,198,64,218,36,162)}
export default(c,t)=>{
const y=4*sin(t*T)
c.lineWidth=4
for(const d of[-1,0,1])for(let k=0;k<3;k++){
const p=(t*2+k/3)%1,x=110+d*40*p,yy=92+y-(d?60:78)*4*p*(1-p)
c.fillStyle=WA;c.beginPath();c.arc(x,yy,7-p*3,0,T);c.fill();c.stroke()
}
const h=(1+sin(2*T*t))/2
c.fillStyle=WA;c.beginPath();c.moveTo(102,100+y);c.quadraticCurveTo(100,70+y,110,52+y-14*h);c.quadraticCurveTo(122,70+y,118,100+y);c.fill();c.stroke()
c.lineWidth=7
c.save();c.translate(196,150+y);c.rotate(-.55+.2*sin(2*T*t))
c.fillStyle=B;c.beginPath();c.moveTo(-8,8);c.quadraticCurveTo(10,-20,22,-48);c.quadraticCurveTo(30,-26,52,-30);c.quadraticCurveTo(34,-6,-8,8);c.fill();c.stroke();c.restore()
c.translate(0,y)
body(c);c.fillStyle=B;c.fill()
c.save();body(c);c.clip();c.fillStyle=BE;c.beginPath();c.ellipse(112,214,96,40,-.05,0,T);c.fill();c.stroke()
c.lineWidth=3;for(const yy of[192,202])c.beginPath(),c.moveTo(56,yy),c.quadraticCurveTo(110,yy+10,170,yy-8),c.stroke();c.restore()
body(c);c.stroke()
c.fillStyle=B;c.beginPath();c.ellipse(112,180,22,11,.5,0,T);c.fill();c.stroke()
c.fillStyle='#111';c.beginPath();c.arc(72,138,6,0,T);c.fill()
c.fillStyle='#fff';c.beginPath();c.arc(70,136,2.2,0,T);c.fill()
c.lineWidth=5;c.beginPath();c.moveTo(42,160);c.quadraticCurveTo(66,170,92,160);c.stroke()
c.fillStyle=BL;c.beginPath();c.ellipse(90,148,9,5,0,0,T);c.fill()
}
+1
View File
@@ -22,6 +22,7 @@ figcaption a{color:var(--mute);text-decoration:none;margin:0 4px}
figcaption a:hover{color:var(--fg);text-decoration:underline} figcaption a:hover{color:var(--fg);text-decoration:underline}
#bg{position:absolute;top:16px;right:16px;width:28px;height:28px;padding:0;border:2px solid var(--fg);border-radius:50%;background:linear-gradient(90deg,var(--fg) 50%,var(--bg) 50%);cursor:pointer} #bg{position:absolute;top:16px;right:16px;width:28px;height:28px;padding:0;border:2px solid var(--fg);border-radius:50%;background:linear-gradient(90deg,var(--fg) 50%,var(--bg) 50%);cursor:pointer}
img{width:100%;height:100%;object-fit:contain} img{width:100%;height:100%;object-fit:contain}
hr{grid-column:1/-1;border:0;border-top:3px dashed var(--mute);margin:24px 0}
@media(max-width:480px){main{grid-template-columns:repeat(3,1fr);gap:10px}} @media(max-width:480px){main{grid-template-columns:repeat(3,1fr);gap:10px}}
</style> </style>
</head> </head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 101 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 178 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 168 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 152 KiB

+7 -3
View File
@@ -1,4 +1,5 @@
// renders gen/<subject>/<action>.js into public/<subject>/<action>.{webp,gif} // renders gen/<section>/<subject>/<action>.js into public/<subject>/<action>.{webp,gif}
// the section folder (bw, color) only picks where it shows in the gallery, it isn't part of the url
// npm run gen renders only what's missing (published files never change by accident) // npm run gen renders only what's missing (published files never change by accident)
// npm run gen -- cat/blink re-renders the named ones // npm run gen -- cat/blink re-renders the named ones
import{createCanvas}from'@napi-rs/canvas' import{createCanvas}from'@napi-rs/canvas'
@@ -8,8 +9,11 @@ import{dirname,resolve}from'path'
import{pathToFileURL as url}from'url' import{pathToFileURL as url}from'url'
const S=256,redo=process.argv.slice(2) const S=256,redo=process.argv.slice(2)
const walk=d=>rd(d,{withFileTypes:true}).flatMap(e=>e.isDirectory()?walk(`${d}/${e.name}`):`${d}/${e.name}`) const walk=d=>rd(d,{withFileTypes:true}).flatMap(e=>e.isDirectory()?walk(`${d}/${e.name}`):`${d}/${e.name}`)
for(const f of walk('gen').filter(f=>f.endsWith('.js')).sort()){ const files=walk('gen').filter(f=>f.endsWith('.js')).sort(),ids=files.map(f=>f.split('/').slice(2).join('/').slice(0,-3))
const id=f.slice(4,-3),o=`public/${id}` const dup=ids.find((d,i)=>ids.indexOf(d)!=i)
if(dup)throw Error(`${dup} is in more than one section`)
for(const[k,f]of files.entries()){
const id=ids[k],o=`public/${id}`
if(!redo.includes(id)&&ex(o+'.webp')&&ex(o+'.gif'))continue if(!redo.includes(id)&&ex(o+'.webp')&&ex(o+'.gif'))continue
const{default:draw,frames=16,delay=80}=await import(url(resolve(f))) const{default:draw,frames=16,delay=80}=await import(url(resolve(f)))
const px=[...Array(frames)].map((_,i)=>{ const px=[...Array(frames)].map((_,i)=>{
+9 -5
View File
@@ -1,21 +1,25 @@
// fills <!--gallery--> in index.html from whatever is in public/ // fills <!--gallery--> in index.html from whatever is in public/
// one tile per name: shows the .webp (or the .gif if that's all there is) and links every format // one tile per name: shows the .webp (or the .gif if that's all there is) and links every format
// tiles are split into sections by the gen/<section>/ folder they come from, with a divider between
// hand-made files that have no gen file go in the first section
// tile images get ?v=<hash> so browsers drop old copies when a file changes, links stay clean for hotlinking // tile images get ?v=<hash> so browsers drop old copies when a file changes, links stay clean for hotlinking
import{readdirSync as rd,readFileSync as rf}from'fs' import{readdirSync as rd,readFileSync as rf}from'fs'
import{createHash}from'crypto' import{createHash}from'crypto'
const sections=['bw','color']
const hidden=['cat/tail.gif'] // same drawing as cat/wag.gif, kept so old links still work const hidden=['cat/tail.gif'] // same drawing as cat/wag.gif, kept so old links still work
const walk=d=>rd(d,{withFileTypes:true}).flatMap(e=>e.isDirectory()?walk(`${d}/${e.name}`):`${d}/${e.name}`) const walk=d=>rd(d,{withFileTypes:true}).flatMap(e=>e.isDirectory()?walk(`${d}/${e.name}`):`${d}/${e.name}`)
const dims=(b,x)=>x=='gif'?[b.readUInt16LE(6),b.readUInt16LE(8)]:[b.readUIntLE(24,3)+1,b.readUIntLE(27,3)+1] const dims=(b,x)=>x=='gif'?[b.readUInt16LE(6),b.readUInt16LE(8)]:[b.readUIntLE(24,3)+1,b.readUIntLE(27,3)+1]
const gallery=()=>{ const gallery=()=>{
const g={} const sec={},g=Object.fromEntries(sections.map(s=>[s,{}]))
for(const f of walk('gen').filter(f=>f.endsWith('.js'))){const[,s,...r]=f.split('/');sec[r.join('/').slice(0,-3)]=s}
for(const f of walk('public').filter(f=>/\.(webp|gif)$/.test(f)).sort()){ for(const f of walk('public').filter(f=>/\.(webp|gif)$/.test(f)).sort()){
const p=f.slice(7),x=p.split('.').pop() const p=f.slice(7),x=p.split('.').pop(),id=p.slice(0,-x.length-1)
if(!hidden.includes(p))(g[p.slice(0,-x.length-1)]??={})[x]=f if(!hidden.includes(p))(g[sec[id]??sections[0]][id]??={})[x]=f
} }
return Object.entries(g).map(([id,fm])=>{ return sections.map(s=>Object.entries(g[s]).map(([id,fm])=>{
const x=fm.webp?'webp':'gif',b=rf(fm[x]),[w,h]=dims(b,x),v=createHash('md5').update(b).digest('hex').slice(0,8) const x=fm.webp?'webp':'gif',b=rf(fm[x]),[w,h]=dims(b,x),v=createHash('md5').update(b).digest('hex').slice(0,8)
const links=['webp','gif'].filter(k=>fm[k]).map(k=>`<a href="/${id}.${k}">${k}</a>`).join(' ') const links=['webp','gif'].filter(k=>fm[k]).map(k=>`<a href="/${id}.${k}">${k}</a>`).join(' ')
return`<figure><a href="/${id}.${x}"><img src="/${id}.${x}?v=${v}" alt="${id.replace('/',' ')}" width="${w}" height="${h}" loading="lazy" decoding="async"></a><figcaption>${links}</figcaption></figure>` return`<figure><a href="/${id}.${x}"><img src="/${id}.${x}?v=${v}" alt="${id.replace('/',' ')}" width="${w}" height="${h}" loading="lazy" decoding="async"></a><figcaption>${links}</figcaption></figure>`
}).join('\n') }).join('\n')).filter(Boolean).join('\n<hr>\n')
} }
export default{plugins:[{name:'gallery',transformIndexHtml:h=>h.replace('<!--gallery-->',gallery())}]} export default{plugins:[{name:'gallery',transformIndexHtml:h=>h.replace('<!--gallery-->',gallery())}]}