card-maker/script.js
chris fb03a99f2c Initial commit — Card Studio printable card generator
Features: quarter-fold and flat 4-up layouts, 8 themes, 12 presets,
font picker, text alignment, text size sliders, background/accent/text
colour pickers, message page (left/right) toggle, A4/Letter paper size,
tabbed sidebar UI, responsive mobile layout, localStorage persistence,
and Beach Party Balloons branding support.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-16 21:33:49 -04:00

641 lines
37 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const PX = 96; // px per inch at screen
const LOGO = "https://beachpartyballoons.com/assets/logo/BeachPartyBalloons-logo.webp";
const FOLD = { w: 11*PX, h: 8.5*PX };
const FLAT = { w: 8.5*PX, h: 11*PX };
const MM = 96 / 25.4; // px per mm at screen
const PAPER_DIMS = {
letter: FLAT,
a4: { w: Math.round(210*MM), h: Math.round(297*MM) }
};
/* ---- Theme decorations (inline SVG, scale to fill their face) ---- */
function deco(theme, variant){
switch(theme.id){
case 'confetti':{
const c=['#FF5A7A','#FFC94D','#4DB6FF','#7BD389','#B07CFF'];
let s='';
const pts=[[8,10,0],[20,22,1],[33,8,2],[46,18,3],[60,9,4],[72,20,0],[86,12,1],[92,28,2],
[6,80,3],[18,90,4],[30,78,0],[44,88,1],[58,82,2],[70,92,3],[84,84,4],[94,76,0]];
pts.forEach((p,i)=>{
const col=c[p[2]];
if(i%3===0) s+=`<rect x="${p[0]}" y="${p[1]}" width="2.4" height="2.4" rx=".5" transform="rotate(${i*25} ${p[0]} ${p[1]})" fill="${col}"/>`;
else if(i%3===1) s+=`<circle cx="${p[0]}" cy="${p[1]}" r="1.4" fill="${col}"/>`;
else s+=`<path d="M${p[0]} ${p[1]} l2 3 l-4 0 z" fill="${col}"/>`;
});
return svg(s);
}
case 'gold':{
const a=theme.vars['--c-accent'];
const fr=`<rect x="4" y="6" width="92" height="88" fill="none" stroke="${a}" stroke-width=".5"/>
<rect x="6" y="9" width="88" height="82" fill="none" stroke="${a}" stroke-width="1.1"/>`;
const fl=(x,y,r)=>`<g transform="translate(${x} ${y}) rotate(${r})" fill="none" stroke="${a}" stroke-width=".7">
<path d="M0 0 q7 0 9 6 q-6 -1 -9 -6"/><path d="M0 0 q0 7 6 9 q-1 -6 -6 -9"/><circle cx="0" cy="0" r=".9" fill="${a}"/></g>`;
return svg(fr+fl(12,15,0)+fl(88,15,90)+fl(12,85,-90)+fl(88,85,180));
}
case 'botanical':{
const a=theme.vars['--c-accent'];
// leaves-outline-with-vines-svgrepo-com.svg path (viewBox 0 0 136.492 136.492)
const vp=`M121.036,78.928c-13.526-14.684-30.95-14.207-47.129-12.3c-0.375-0.216-0.802-0.34-1.266-0.292c-4.219,0.458-9.566-0.227-11.18-6.275c-0.937-3.562,1.187-7.235,4.725-8.174c2.624-0.709,5.332,0.878,6.027,3.493c0.491,1.867-0.622,3.789-2.476,4.282c-0.681,0.171-1.35,0.026-1.891-0.324c0.562-1.081,0.304-2.426-0.701-3.169c-1.118-0.849-2.7-0.612-3.536,0.511c-0.01,0.016-0.01,0.04-0.021,0.056c-0.768,0.496-1.179,1.419-0.937,2.354c0.947,3.541,4.622,5.69,8.182,4.757c4.158-1.103,6.643-5.39,5.545-9.563c-1.308-4.915-6.381-7.852-11.295-6.576c-5.843,1.553-9.336,7.586-7.796,13.461c0.793,2.987,2.386,5.323,4.588,6.966c-1.965,0.227-3.913,0.433-5.838,0.572c-19.478,1.506-30.855,1.822-38.333-9.207c-2.354-3.473-3.483-7.528-3.369-11.687c1.782,4.688,5.679,8.248,11.625,10.563c2.742,1.071,5.276,1.592,7.744,1.592l0,0c9.671,0,17.133-7.992,26.757-21.808c4.422-6.357,10.167-11.962,23.838-18.686c0.143-0.069,0.227-0.209,0.349-0.312c0.079-0.066,0.152-0.124,0.227-0.2c0.132-0.129,0.248-0.259,0.337-0.427c0.158-0.269,0.285-0.567,0.296-0.917c0.011-0.356-0.09-0.691-0.232-1c-0.037-0.097-0.1-0.15-0.143-0.232c-0.142-0.232-0.332-0.422-0.554-0.577c-0.053-0.037-0.068-0.098-0.115-0.114C83.399,15.051,58.057,0,35.935,0C21.926,0,12.332,5.957,7.417,17.708c-1.155,2.777-1.139,7.863,1.179,11.826c1.339,2.299,3.298,3.899,5.695,4.822c-1.804,2.716-3.13,5.746-3.789,8.976c-1.345,6.555-0.056,13.165,3.639,18.592c9.255,13.634,24.542,12.458,42.245,11.093c3.995-0.316,8.092-0.834,12.229-1.382c17.494-2.265,35.588-4.609,49.272,10.22c2.352,2.562,4.155,5.416,5.437,8.438c-0.005,0.116-0.047,0.211-0.042,0.327c0.69,10.515-3.617,17.323-12.145,19.147c-6.603,1.439-13.157-2.8-14.57-9.402c-1.076-5.073,2.151-10.072,7.208-11.148c1.852-0.385,3.724-0.053,5.311,0.971c1.588,1.023,2.685,2.605,3.074,4.472c0.27,1.234,0.026,2.474-0.563,3.565c-0.228-0.338-0.448-0.686-0.786-0.949c-1.756-1.461-4.366-1.203-5.822,0.563c-1.45,1.772-1.196,4.372,0.575,5.827c0.459,0.369,0.98,0.623,1.524,0.781c0.501,0.506,1.223,0.785,1.972,0.633c2.5-0.544,4.625-2.015,6.007-4.166c1.382-2.131,1.851-4.684,1.312-7.178c-0.643-2.99-2.409-5.537-4.967-7.193c-2.548-1.65-5.585-2.188-8.554-1.555c-7.378,1.565-12.103,8.869-10.531,16.284c1.683,7.763,8.569,13.089,16.211,13.089c1.149,0,2.325-0.111,3.49-0.364c6.318-1.351,10.827-4.941,13.337-10.235c-0.881,8.849-5.864,17.254-13.711,23.087c-5.759,4.282-12.852,6.075-19.955,5.021c-7.093-1.045-13.373-4.794-17.655-10.558c-0.712-0.955-2.062-1.149-3.029-0.443c-0.957,0.718-1.157,2.067-0.448,3.027c4.976,6.687,12.258,11.048,20.5,12.266c1.535,0.222,3.075,0.332,4.609,0.332c6.66,0,13.109-2.125,18.558-6.175c8.88-6.598,14.512-16.189,15.456-26.325C130.538,94.806,127.469,85.904,121.036,78.928z M20.945,7.966c2.17-1.284,4.627-2.226,7.338-2.824L23.637,24.83c-1.748,1.031-3.33,2.159-4.653,3.436l2.02-19.672C21.03,8.372,20.982,8.171,20.945,7.966z M39.099,54.288L20.252,33.816c1.152-1.205,2.399-2.31,3.813-3.22c0.432-0.274,0.704-0.691,0.854-1.139l19.765,21.065c-1.936,1.745-3.76,3.024-5.532,3.847C39.131,54.346,39.125,54.314,39.099,54.288z M33.705,55.653c-1.917,0-3.937-0.427-6.173-1.289c-4.777-1.87-7.873-4.602-9.191-8.124c-1.113-2.964-0.81-6.041-0.243-8.385L34.411,55.58C34.179,55.59,33.942,55.653,33.705,55.653L33.705,55.653z M47.734,47.466L28.589,27.061c1.859-0.864,3.889-1.653,6.088-2.339l17.65,17.216C50.704,44.055,49.174,45.895,47.734,47.466z M46.632,5.487c2.305,0.448,4.591,0.988,6.842,1.627L44.99,17.858c-1.695,0.309-3.386,0.633-5.044,1.016l6.61-13.115C46.606,5.672,46.601,5.579,46.632,5.487z M63.982,10.676l-6.265,5.218c-0.103,0.087-0.156,0.197-0.243,0.306c-2.041,0.189-4.124,0.427-6.215,0.704l6.637-8.419C60.003,9.176,62.028,9.906,63.982,10.676z M74.524,15.312c-2.816,0.037-6.146,0.135-9.774,0.338l3.746-3.114C70.71,13.5,72.751,14.441,74.524,15.312z M68.702,23.723l-3.932-3.736c3.629-0.216,7.069-0.335,10.181-0.361C72.573,21.018,70.526,22.383,68.702,23.723z M61.324,30.209l-9.746-8.996c2.468-0.33,4.936-0.588,7.38-0.805l6.315,6.015C63.771,27.715,62.466,28.978,61.324,30.209z M54.927,38.449L39.502,23.406c2.133-0.52,4.343-0.968,6.61-1.358l12.411,11.449c-0.567,0.741-1.107,1.463-1.605,2.193C56.222,36.69,55.584,37.536,54.927,38.449z M42.237,4.733l-7.862,15.593c-1.999,0.598-3.905,1.273-5.714,2.025l4.213-17.874c0.997-0.077,1.991-0.161,3.045-0.161C37.997,4.316,40.109,4.467,42.237,4.733z M12.31,27.369c-1.622-2.781-1.577-6.426-0.917-7.992c1.263-3.024,2.943-5.537,4.952-7.652l-1.843,17.969C13.615,29.112,12.882,28.34,12.31,27.369z`;
const vine=(tx,ty,r,s,op=0.3)=>`<g transform="translate(${tx},${ty}) rotate(${r}) scale(${s})" fill="${a}" fill-opacity="${op}"><path d="${vp}"/></g>`;
// Bottom-left and top-right opposing corners (vine SVG is 136.5×136.5, scaled to ~55 units)
return svg(vine(0,45,0,0.4) + vine(100,55,180,0.4));
}
case 'watercolor':{
const cols = ['#A389D4', '#7DADE2', '#E27DA1', '#7DE2C9', '#B87DE2', '#7D96E2'];
const blob = (x, y, rx, ry, r, col, op) => `<ellipse cx="${x}" cy="${y}" rx="${rx}" ry="${ry}" transform="rotate(${r} ${x} ${y})" fill="${col}" fill-opacity="${op}"/>`;
let s = '';
// Top-Left Cluster
s += blob(5, 5, 35, 28, 30, cols[0], 0.15);
s += blob(18, 8, 25, 20, -15, cols[1], 0.12);
s += blob(10, 22, 28, 22, 45, cols[2], 0.1);
s += blob(25, 15, 18, 15, 10, cols[4], 0.08);
// Top-Right Cluster
s += blob(95, 5, 32, 25, -30, cols[3], 0.14);
s += blob(82, 12, 24, 20, 20, cols[5], 0.12);
s += blob(90, 25, 20, 18, -45, cols[0], 0.09);
// Bottom-Left Cluster
s += blob(5, 95, 38, 24, 15, cols[1], 0.16);
s += blob(22, 88, 28, 18, -10, cols[4], 0.11);
s += blob(12, 80, 22, 16, 35, cols[3], 0.13);
// Bottom-Right Cluster
s += blob(95, 95, 34, 28, 45, cols[2], 0.17);
s += blob(78, 88, 25, 22, -25, cols[5], 0.12);
s += blob(88, 75, 20, 18, 15, cols[1], 0.1);
s += blob(98, 82, 15, 12, -10, cols[0], 0.08);
// Edge and Middle Washes
s += blob(50, 5, 45, 14, 0, cols[4], 0.08);
s += blob(50, 95, 50, 18, 5, cols[0], 0.07);
s += blob(5, 50, 12, 35, 0, cols[2], 0.06);
s += blob(95, 50, 14, 40, 0, cols[3], 0.06);
return svg(s);
}
case 'balloon':{
const cols=['#5A9AFF','#7AB5FF','#A0CFFF'];
const bal=(x,y,col,sc,r)=>`<g transform="translate(${x} ${y}) rotate(${r}) scale(${sc})">
<path d="M0 12 Q 2 25 -2 35" stroke="${col}" stroke-width=".4" fill="none" opacity=".6"/>
<ellipse cx="0" cy="0" rx="7" ry="9" fill="${col}"/>
<path d="M0 9 l-1.5 2 h3 z" fill="${col}"/>
<ellipse cx="-2.5" cy="-3.5" rx="1.8" ry="2.5" fill="#fff" fill-opacity=".3" transform="rotate(-20 -2.5 -3.5)"/>
</g>`;
// Group of balloons tied together
const group=`<g transform="translate(50 65)">
${bal(-8,-12,cols[0],1.2,10)}${bal(8,-10,cols[1],1.1,-10)}${bal(0,-20,cols[2],1.3,0)}
<path d="M-5 18 Q 0 25 0 35 M 5 18 Q 0 25 0 35 M 0 10 Q 0 25 0 35" stroke="#ccc" stroke-width=".3" fill="none"/>
</g>`;
// Floating sparkles
let sparkles='';
for(let i=0;i<12;i++) sparkles+=`<circle cx="${Math.random()*100}" cy="${Math.random()*100}" r="${0.4+Math.random()*0.8}" fill="${cols[i%3]}" fill-opacity=".4"/>`;
return svg(sparkles + group);
}
case 'kraft':{
const a=theme.vars['--c-accent'];
const border=`<rect x="5" y="6" width="90" height="88" fill="none" stroke="${a}" stroke-width=".8" stroke-dasharray="2 1.6"/>`;
const sprig=`<g transform="translate(50 16)" stroke="${a}" stroke-width=".9" fill="none" stroke-linecap="round">
<path d="M-9 0 H9"/><path d="M-9 0 q-2 -3 -5 -3"/><path d="M9 0 q2 -3 5 -3"/><circle cx="0" cy="0" r="1.4" fill="${a}"/></g>`;
return svg(border+sprig);
}
case 'flowers':{
const a=theme.vars['--c-accent'];
const img=(href,x,y,w,h,op=1)=>`<image href="${href}" x="${x}" y="${y}" width="${w}" height="${h}" opacity="${op}"/>`;
const fg='assets/flower-green-svgrepo-com(1).svg';
const fl='assets/flower-leaf-2-svgrepo-com.svg';
const fb='assets/flower-bouquet-svgrepo-com(2).svg';
// Accent petals scattered near centre
let s='';
[[22,38],[78,35],[50,12],[50,86],[15,65],[85,62]].forEach(([x,y])=>{
s+=`<circle cx="${x}" cy="${y}" r="1.8" fill="${a}" opacity=".18"/>`;
});
s+=img(fb,8,18,84,72,0.07); // faint bouquet watermark behind text
s+=img(fg,-8,-8,52,52); // flower top-left
s+=img(fg,56,56,52,52); // flower bottom-right
s+=img(fl,54,-8,52,52,0.85); // leaf top-right
s+=img(fl,-8,56,52,52,0.85); // leaf bottom-left
return svg(s);
}
case 'retro':{
const a=theme.vars['--c-accent'];
const cy='#00EEFF';
let s='';
// Pixel stars
[[6,4],[19,7],[31,3],[47,9],[62,5],[77,8],[91,4],[12,15],[55,12],[83,14],
[3,25],[38,28],[70,23],[96,18],[25,35],[85,32]].forEach(([x,y],i)=>{
const col=i%3===0?cy:i%3===1?a:'#FFFFFF';
s+=`<rect x="${x}" y="${y}" width="${i%4===0?1.5:1}" height="${i%4===0?1.5:1}" fill="${col}" opacity=".6"/>`;
});
// Synthwave horizon sun
const hz=70;
s+=`<ellipse cx="50" cy="${hz}" rx="16" ry="8" fill="${a}" opacity=".2"/>`;
s+=`<ellipse cx="50" cy="${hz}" rx="11" ry="5.5" fill="${a}" opacity=".28"/>`;
[0,2.4,4.8].forEach(dy=>{ s+=`<rect x="34" y="${hz-1.2+dy}" width="32" height=".9" fill="#0D0A1E" opacity=".85"/>`; });
// Perspective grid lines
for(let i=0;i<=6;i++)
s+=`<line x1="50" y1="${hz}" x2="${(i/6)*100}" y2="100" stroke="${a}" stroke-width=".4" opacity=".4"/>`;
for(let i=1;i<=4;i++){
const t=i/4, y=hz+t*(100-hz), hw=t*50;
s+=`<line x1="${50-hw}" y1="${y}" x2="${50+hw}" y2="${y}" stroke="${a}" stroke-width=".4" opacity=".4"/>`;
}
// Space invader sprite (top-center, each pixel = 1.8 units)
const ps=1.8, ox=50-9*ps/2, oy=5;
[[0,0,1,0,0,0,1,0,0],
[0,0,0,1,1,1,0,0,0],
[0,1,1,1,1,1,1,1,0],
[1,1,0,1,1,1,0,1,1],
[1,1,1,1,1,1,1,1,1],
[0,1,0,0,0,0,0,1,0],
[1,0,1,0,0,0,1,0,1],
].forEach((row,r)=>row.forEach((on,c)=>{
if(on) s+=`<rect x="${ox+c*ps}" y="${oy+r*ps}" width="${ps-.2}" height="${ps-.2}" fill="${cy}"/>`;
}));
// Neon corner brackets
const BW=6, BT=1.2;
s+=`<rect x="3" y="3" width="${BW}" height="${BT}" fill="${cy}"/>`;
s+=`<rect x="3" y="3" width="${BT}" height="${BW}" fill="${cy}"/>`;
s+=`<rect x="${97-BW}" y="3" width="${BW}" height="${BT}" fill="${cy}"/>`;
s+=`<rect x="${97-BT}" y="3" width="${BT}" height="${BW}" fill="${cy}"/>`;
s+=`<rect x="3" y="${97-BT}" width="${BW}" height="${BT}" fill="${cy}"/>`;
s+=`<rect x="3" y="${97-BW}" width="${BT}" height="${BW}" fill="${cy}"/>`;
s+=`<rect x="${97-BW}" y="${97-BT}" width="${BW}" height="${BT}" fill="${cy}"/>`;
s+=`<rect x="${97-BT}" y="${97-BW}" width="${BT}" height="${BW}" fill="${cy}"/>`;
return svg(s);
}
}
return svg('');
}
function svg(inner){
return `<svg viewBox="0 0 100 100" preserveAspectRatio="none" width="100%" height="100%" style="position:absolute;inset:0">${inner}</svg>`;
}
/* ---- Themes ---- */
const THEMES = [
{ id:'confetti', name:'Confetti', vars:{ '--c-bg':'#FFFBF2','--c-ink':'#2A2438','--c-accent':'#FF5A7A', '--c-titleFont':"'Fredoka', sans-serif",'--c-titleWeight':'600', '--c-titleSize':'48px','--c-titleLS':'-.01em','--c-titleCase':'none', '--c-bodyFont':"'Quicksand', sans-serif",'--c-subSize':'17px', }, chipBg:'#FFFBF2', chipInk:'#FF5A7A', chipGlyph:'★' },
{ id:'gold', name:'Midnight Gold', vars:{ '--c-bg':'#12243F','--c-ink':'#EDE3CB','--c-accent':'#C9A24B', '--c-titleFont':"'Cinzel', serif",'--c-titleWeight':'700', '--c-titleSize':'40px','--c-titleLS':'.08em','--c-titleCase':'uppercase', '--c-bodyFont':"'Lora', serif",'--c-subSize':'14px', '--c-subLS':'.22em','--c-subCase':'uppercase','--c-subWeight':'500', }, chipBg:'#12243F', chipInk:'#C9A24B', chipGlyph:'❧' },
{ id:'botanical', name:'Botanical', vars:{ '--c-bg':'#FDFCF9','--c-ink':'#2D362E','--c-accent':'#6B8E6B', '--c-titleFont':"'Playfair Display', serif",'--c-titleWeight':'700', '--c-titleSize':'46px','--c-titleLS':'0.02em','--c-titleCase':'none', '--c-bodyFont':"'Lora', serif",'--c-subSize':'18px','--c-subStyle':'italic', }, chipBg:'#FDFCF9', chipInk:'#6B8E6B', chipGlyph:'❀' },
{ id:'watercolor', name:'Watercolor', vars:{ '--c-bg':'#FCF9FF','--c-ink':'#3D2B52','--c-accent':'#A389D4', '--c-titleFont':"'Dancing Script', cursive",'--c-titleWeight':'700', '--c-titleSize':'52px','--c-titleLS':'0','--c-titleCase':'none', '--c-bodyFont':"'Raleway', sans-serif",'--c-subSize':'16px', '--c-subLS':'.05em','--c-subWeight':'500', }, chipBg:'#FCF9FF', chipInk:'#A389D4', chipGlyph:'༄' },
{ id:'balloon', name:'Balloons', vars:{ '--c-bg':'#F7FBFF','--c-ink':'#2A4B6B','--c-accent':'#5A9AFF', '--c-titleFont':"'Fredoka', sans-serif",'--c-titleWeight':'600', '--c-titleSize':'48px','--c-titleLS':'-.01em', '--c-bodyFont':"'Quicksand', sans-serif",'--c-subSize':'17px', }, chipBg:'#F7FBFF', chipInk:'#5A9AFF', chipGlyph:'🎈' },
{ id:'kraft', name:'Kraft Note', vars:{ '--c-bg':'#D7C3A3','--c-ink':'#41301F','--c-accent':'#8A5A38', '--c-titleFont':"'Caveat', cursive",'--c-titleWeight':'700', '--c-titleSize':'42px','--c-titleLS':'0', '--c-bodyFont':"monospace",'--c-subSize':'16px','--c-subLS':'0.04em', }, chipBg:'#D7C3A3', chipInk:'#8A5A38', chipGlyph:'✶' },
{ id:'flowers', name:'Garden Blooms', vars:{ '--c-bg':'#FEFCF6','--c-ink':'#273A1C','--c-accent':'#D4517A', '--c-titleFont':"'Dancing Script', cursive",'--c-titleWeight':'700', '--c-titleSize':'56px','--c-titleLS':'0.01em','--c-titleCase':'none', '--c-bodyFont':"'Lora', serif",'--c-subSize':'17px','--c-subStyle':'italic', }, chipBg:'#FEFCF6', chipInk:'#D4517A', chipGlyph:'✿' },
{ id:'retro', name:'Retro Arcade', vars:{ '--c-bg':'#0D0A1E','--c-ink':'#00EEFF','--c-accent':'#FF1177', '--c-titleFont':"'Press Start 2P', monospace",'--c-titleWeight':'400', '--c-titleSize':'20px','--c-titleLS':'0.04em','--c-titleCase':'uppercase', '--c-bodyFont':"monospace",'--c-subSize':'10px','--c-subLS':'0.12em','--c-subCase':'uppercase','--c-subWeight':'400', }, chipBg:'#0D0A1E', chipInk:'#FF1177', chipGlyph:'👾' },
];
const PRESETS = [
{ name:"Birthday", title:"Happy Birthday", sub:"Wishing you a wonderful day", msg:"Hope your day is filled with joy, laughter, and a little extra cake.\n\nWith love,", font:"Fredoka, sans-serif" },
{ name:"Thank You", title:"Thank You", sub:"with heartfelt gratitude", msg:"Thank you so much for your kindness — it truly meant the world to me.\n\nWarmly,", font:"Dancing Script, cursive" },
{ name:"Congrats", title:"Congratulations!", sub:"on your wonderful news", msg:"So thrilled for you and everything youve accomplished. Heres to what comes next!", font:"Montserrat, sans-serif" },
{ name:"Invitation", title:"Youre Invited", sub:"Saturday · 4:00 PM", msg:"Join us to celebrate!\n\nPlease RSVP by [date]\n[Address]", font:"Cinzel, serif" },
{ name:"Thinking of You", title:"Thinking of You", sub:"sending warm wishes", msg:"Just a little note to let you know youre in my thoughts today.", font:"Lora, serif" },
{ name:"Get Well", title:"Get Well Soon", sub:"feel better", msg:"Wishing you rest, comfort, and a speedy recovery.", font:"Caveat, cursive" },
{ name:"Anniversary", title:"Happy Anniversary", sub:"celebrating you both", msg:"Wishing you another year filled with love, laughter, and beautiful memories together.\n\nWith love,", font:"Playfair Display, serif" },
{ name:"Baby Shower", title:"Baby Shower", sub:"a new little one is on the way!", msg:"Wishing you all the joy and wonder of welcoming your new arrival.\n\nWith love,", font:"Quicksand, sans-serif" },
{ name:"Sympathy", title:"With Deepest Sympathy", sub:"in your time of loss", msg:"Please know that you are in my thoughts and heart during this difficult time.\n\nWith love,", font:"Lora, serif" },
{ name:"Graduation", title:"Congratulations, Graduate!", sub:"the world is yours", msg:"So proud of everything youve accomplished. This is just the beginning of an incredible journey!\n\nWith love,", font:"Montserrat, sans-serif" },
{ name:"Valentines", title:"Happy Valentines Day", sub:"with all my love", msg:"Every day with you is a gift. Thank you for being my favorite person.\n\nAll my love,", font:"Dancing Script, cursive" },
{ name:"Holiday", title:"Happy Holidays", sub:"wishing you joy & peace", msg:"May this season bring warmth, laughter, and all the things that make your heart happy.\n\nWith love,", font:"Cinzel, serif" },
];
/* ---- State ---- */
const state = {
style:'quarter', paper:'letter', theme:THEMES[0], title:'Happy Birthday', sub:'Wishing you a wonderful day',
msg:'Hope your day is filled with joy, laughter, and a little extra cake.\n\nWith love,', back:'',
logo:true, logoPos:'back', inside:false, guide:true, accent:null, text:null, bg:null, fontOverride:'',
doubleSided: false, activeSide: 'outside',
titleScale: 1, bodyScale: 1, align: 'center', msgSide: 'right'
};
/* ---- State Persistence ---- */
function saveState(){
const data = { ...state, themeId: state.theme.id };
delete data.theme; // Don't store the whole object reference
localStorage.setItem('cardStudioState', JSON.stringify(data));
}
function loadState(){
const saved = localStorage.getItem('cardStudioState');
if(!saved) return;
try {
const data = JSON.parse(saved);
const theme = THEMES.find(t => t.id === data.themeId) || THEMES[0];
Object.assign(state, data, { theme });
syncUI();
} catch(e) { console.error("Failed to load state", e); }
}
function syncUI(){
$('#fTitle').value = state.title;
$('#fSub').value = state.sub;
$('#fMsg').value = state.msg;
$('#fBack').value = state.back;
$('#fFont').value = state.fontOverride;
const ts = Math.round((state.titleScale || 1) * 100);
const bs = Math.round((state.bodyScale || 1) * 100);
$('#fTitleScale').value = ts; $('#fTitleScaleVal').textContent = ts + '%';
$('#fBodyScale').value = bs; $('#fBodyScaleVal').textContent = bs + '%';
$('#cInside').checked = state.inside;
$('#cDouble').checked = state.doubleSided;
$('#cGuide').checked = state.guide;
$('#cLogo').checked = state.logo;
$('#cAccent').value = toHex(state.accent || state.theme.vars['--c-accent']);
$('#cText').value = toHex(state.text || state.theme.vars['--c-ink']);
// Update segments
document.querySelectorAll('#paperSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.paper === (state.paper || 'letter')));
document.querySelectorAll('#styleSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.style === state.style));
document.querySelectorAll('#logoSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.pos === state.logoPos));
document.querySelectorAll('#sideSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.side === state.activeSide));
document.querySelectorAll('#alignSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.align === (state.align || 'center')));
document.querySelectorAll('#msgSideSeg button').forEach(b => b.setAttribute('aria-pressed', b.dataset.msgside === (state.msgSide || 'right')));
$('#msgSideWrap').style.display = state.style === 'quarter' ? '' : 'none';
document.querySelectorAll('.swatch').forEach(s => s.setAttribute('aria-pressed', s.dataset.id === state.theme.id));
// Visibility logic
const isFolded = (state.style === 'folded'); // Legacy support
if (state.style === 'folded') state.style = 'quarter'; // Migration
$('#optGroup').style.display = (state.style === 'folded') ? 'flex' : 'none'; // Only for legacy folded
$('#printGroup').style.display = (state.inside && state.style === 'folded') ? '' : 'none';
$('#sideSelector').style.display = state.doubleSided ? 'none' : '';
$('#logoPosWrap').style.display = (state.logo && state.style !== 'flat') ? '' : 'none';
document.body.classList.toggle('manual-flip', !state.doubleSided && state.inside && state.style === 'folded');
const notes={
quarter:'<b>Quarter-fold:</b> one portrait sheet, folded twice, makes a small 4.25 × 5.5" card — front, back, and inside message all print on this one sheet.',
flat:'<b>Flat:</b> four identical cards on one portrait sheet. Cut along the dashed lines — no folding. Great for invitations and thank-you cards.'
};
$('#styleNote').innerHTML = notes[state.style] || notes.quarter;
}
/* ---- Build controls ---- */
const $ = s=>document.querySelector(s);
const sheets = $('#sheets');
const swWrap=$('#swatches');
THEMES.forEach(t=>{
const b=document.createElement('button');
b.className='swatch';b.type='button';b.dataset.id=t.id;
b.setAttribute('aria-pressed', t.id===state.theme.id);
b.innerHTML=`<span class="chip" style="background:${t.chipBg}"><span style="color:${t.chipInk}">${t.chipGlyph}</span></span><span class="nm">${t.name}</span>`;
b.addEventListener('click',()=>{
state.theme=t; state.accent=null; state.text=null; syncColorInputs();
document.querySelectorAll('.swatch').forEach(s=>s.setAttribute('aria-pressed', s.dataset.id===t.id));
render();
});
swWrap.appendChild(b);
});
const pWrap=$('#presets');
PRESETS.forEach(p=>{
const b=document.createElement('button');
b.className='preset';b.type='button';b.textContent=p.name;
b.addEventListener('click',()=>{
state.title=p.title;state.sub=p.sub;state.msg=p.msg;
if(p.font) {
state.fontOverride=p.font;
$('#fFont').value=p.font;
}
$('#fTitle').value=p.title;$('#fSub').value=p.sub;$('#fMsg').value=p.msg;
render();
});
pWrap.appendChild(b);
});
$('#fTitle').addEventListener('input',e=>{state.title=e.target.value;render();});
$('#fSub').addEventListener('input',e=>{state.sub=e.target.value;render();});
$('#fMsg').addEventListener('input',e=>{state.msg=e.target.value;render();});
$('#msgSideSeg').addEventListener('click',e=>{
const b=e.target.closest('button');
if(!b) return;
state.msgSide=b.dataset.msgside;
document.querySelectorAll('#msgSideSeg button').forEach(x=>x.setAttribute('aria-pressed', x===b));
render();
});
$('#fBack').addEventListener('input',e=>{state.back=e.target.value;render();});
$('#fFont').addEventListener('change',e=>{state.fontOverride=e.target.value;render();});
$('#alignSeg').addEventListener('click',e=>{
const b=e.target.closest('button');
if(!b) return;
state.align=b.dataset.align;
document.querySelectorAll('#alignSeg button').forEach(x=>x.setAttribute('aria-pressed', x===b));
render();
});
$('#fTitleScale').addEventListener('input',e=>{
state.titleScale = e.target.value / 100;
$('#fTitleScaleVal').textContent = e.target.value + '%';
render();
});
$('#fBodyScale').addEventListener('input',e=>{
state.bodyScale = e.target.value / 100;
$('#fBodyScaleVal').textContent = e.target.value + '%';
render();
});
$('#cInside').addEventListener('change',e=>{
state.inside=e.target.checked;
$('#printGroup').style.display = (state.inside && state.style==='folded') ? '' : 'none';
render();
});
$('#cDouble').addEventListener('change',e=>{
state.doubleSided=e.target.checked;
$('#sideSelector').style.display = state.doubleSided ? 'none' : '';
document.body.classList.toggle('manual-flip', !state.doubleSided && state.inside);
render();
});
$('#sideSeg').addEventListener('click', e => {
const b = e.target.closest('button');
if(!b) return;
state.activeSide = b.dataset.side;
document.querySelectorAll('#sideSeg button').forEach(x => x.setAttribute('aria-pressed', x === b));
render();
});
$('#paperSeg').addEventListener('click',e=>{
const b=e.target.closest('button');
if(!b) return;
state.paper=b.dataset.paper;
document.querySelectorAll('#paperSeg button').forEach(x=>x.setAttribute('aria-pressed', x===b));
render();
});
$('#styleSeg').addEventListener('click',e=>{
const btn=e.target.closest('button');
if(!btn) return;
state.style=btn.dataset.style;
document.querySelectorAll('#styleSeg button').forEach(b=>b.setAttribute('aria-pressed', b===btn));
const s=state.style;
const notes={
quarter:'<b>Quarter-fold:</b> one portrait sheet, folded twice, makes a small 4.25 × 5.5" card — front, back, and inside message all print on this one sheet.',
flat:'<b>Flat:</b> four identical cards on one portrait sheet. Cut along the dashed lines — no folding. Great for invitations and thank-you cards.'
};
$('#styleNote').innerHTML = notes[s];
const isFolded = (s === 'folded');
$('#printGroup').style.display = (state.inside && isFolded) ? '' : 'none';
const cut = (s==='flat');
$('#guideOptLabel').firstChild.textContent = cut ? 'Show cut lines when printing' : 'Show fold lines when printing';
$('#guideOptSub').textContent = cut ? 'Faint dashed lines to trim along.' : 'Faint dashed creases to fold against.';
$('#msgLabel').textContent = (s==='flat') ? 'Card message' : 'Inside message';
$('#msgHint').textContent = s==='flat' ? 'Shown on each flat card.' : s==='quarter'? 'Printed inside the small card (same sheet).' : 'Used on the inside page (enable below) — and shown on flat cards.';
$('#msgSideWrap').style.display = s === 'quarter' ? '' : 'none';
syncLogoUI();
render();
});
$('#cGuide').addEventListener('change',e=>{state.guide=e.target.checked;render();});
function syncLogoUI(){
$('#logoPosWrap').style.display = (state.logo && state.style!=='flat') ? '' : 'none';
}
$('#cLogo').addEventListener('change',e=>{state.logo=e.target.checked;syncLogoUI();render();});
$('#logoSeg').addEventListener('click',e=>{
const b=e.target.closest('button');
if(!b) return;
state.logoPos=b.dataset.pos;
document.querySelectorAll('#logoSeg button').forEach(x=>x.setAttribute('aria-pressed',x===b));
render();
});
function syncColorInputs(){
const t=state.theme.vars;
$('#cAccent').value = toHex(state.accent || t['--c-accent']);
$('#cText').value = toHex(state.text || t['--c-ink']);
$('#cBg').value = toHex(state.bg || t['--c-bg']);
}
function toHex(c){
if(/^#([0-9a-f]{6})$/i.test(c)) return c;
return '#000000';
}
$('#cAccent').addEventListener('input',e=>{state.accent=e.target.value;render();});
$('#cText').addEventListener('input',e=>{state.text=e.target.value;render();});
$('#cBg').addEventListener('input',e=>{state.bg=e.target.value;render();});
$('#resetColors').addEventListener('click',()=>{state.accent=null;state.text=null;state.bg=null;syncColorInputs();render();});
$('#printBtn').addEventListener('click',()=>window.print());
window.addEventListener('beforeprint',()=>{
document.querySelectorAll('.sheet').forEach(s=>s.style.transform='none');
// Hide images still loading — Chrome hangs on "Preparing preview" if any image fetch is pending
document.querySelectorAll('.sheet img').forEach(img=>{if(!img.complete)img.style.display='none';});
});
window.addEventListener('afterprint',()=>{
document.querySelectorAll('.sheet img').forEach(img=>img.style.display='');
fit();
});
/* ---- Rendering ---- */
function faceVars(){
const v={...state.theme.vars};
if(state.accent) v['--c-accent']=state.accent;
if(state.text) v['--c-ink']=state.text;
if(state.bg) v['--c-bg']=state.bg;
const itemsMap = { left:'flex-start', center:'center', right:'flex-end' };
v['--c-align'] = state.align || 'center';
v['--c-items'] = itemsMap[state.align || 'center'];
if(state.fontOverride) v['--c-titleFont']=state.fontOverride;
const ts = state.titleScale || 1;
if(ts !== 1){
const base = parseFloat(v['--c-titleSize'] || '40px');
v['--c-titleSize'] = (base * ts).toFixed(1) + 'px';
}
const bs = state.bodyScale || 1;
if(bs !== 1){
const subBase = parseFloat(v['--c-subSize'] || '16px');
v['--c-subSize'] = (subBase * bs).toFixed(1) + 'px';
const msgBase = parseFloat(v['--c-msgSize'] || '15px');
v['--c-msgSize'] = (msgBase * bs).toFixed(1) + 'px';
}
return Object.entries(v).map(([k,val])=>`${k}:${val}`).join(';');
}
function esc(s){return (s||'').replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
function logoImg(cls){
return `<div class="${cls}"><img src="${LOGO}" alt="Beach Party Balloons" onerror="this.parentNode.style.display='none'"></div>`;
}
function coverFace(){
const front = state.logo && state.logoPos==='front';
return `<div class="face" style="${faceVars()}">
<div class="deco">${deco(state.theme,'cover')}</div>
<div class="content">
${state.title?`<h2 class="ttl">${esc(state.title)}</h2>`:''}
${state.sub?`<p class="sub">${esc(state.sub)}</p>`:''}
${front?logoImg('logo-sm'):''}
</div>
</div>`;
}
function backFace(){
const back = state.logo && state.logoPos==='back';
return `<div class="face" style="${faceVars()};background:var(--c-bg)">
<div class="deco" style="opacity:.5">${deco(state.theme,'back')}</div>
${back?`<div class="logo-back">${logoImg('')}${state.back?`<div class="backmark" style="position:static">${esc(state.back)}</div>`:''}</div>`
:(state.back?`<div class="backmark">${esc(state.back)}</div>`:'')}
</div>`;
}
function insideFace(side){
const hasMsg = (side === (state.msgSide || 'right'));
if(hasMsg){
return `<div class="face inside-r" style="${faceVars()}">
<div class="content"><p class="msg">${esc(state.msg)}</p></div>
</div>`;
}
return `<div class="face inside-l" style="${faceVars()}"></div>`;
}
function flatCell(){
return `<div class="cell"><div class="face" style="${faceVars()}">
<div class="deco">${deco(state.theme,'cover')}</div>
<div class="content">
${state.title?`<h2 class="ttl">${esc(state.title)}</h2>`:''}
${state.sub?`<p class="sub">${esc(state.sub)}</p>`:''}
${state.msg?`<p class="msg">${esc(state.msg)}</p>`:''}
${state.logo?logoImg('logo-sm'):''}
</div>
</div></div>`;
}
function render(){
let html='';
if(state.style==='folded'){
const manual = !state.doubleSided && state.inside;
const isOutside = !manual || state.activeSide === 'outside';
const isInside = !manual || state.activeSide === 'inside';
if(isOutside){
const outside = `<div class="sheet folded">
<div class="panel left">${backFace()}</div>
<div class="panel right">${coverFace()}</div>
<div class="${guideClass()} v" style="left:50%"><span class="gtxt">fold</span></div>
</div>`;
html += `<div class="sheetHolder ${manual && state.activeSide==='outside' ? 'active-print' : ''}">${holderLabel(state.inside?'Outside (front &amp; back)':'Card sheet — fold in half', outside)}</div>`;
}
if(state.inside && isInside){
const inside = `<div class="sheet folded second-sheet ${state.inside ? '' : 'no-double-sided'}">
<div class="panel left">${insideFace('left')}</div>
<div class="panel right">${insideFace('right')}</div>
<div class="${guideClass()} v" style="left:50%"><span class="gtxt">fold</span></div>
</div>`;
html += `<div class="sheetHolder ${manual && state.activeSide==='inside' ? 'active-print' : ''}">${holderLabel('Inside (message)', inside)}</div>`;
}
} else if(state.style==='quarter'){
const g = `<div class="qgrid">
<div class="qcell rot">${insideFace('left')}</div>
<div class="qcell rot">${insideFace('right')}</div>
<div class="qcell">${backFace()}</div>
<div class="qcell">${coverFace()}</div>
</div>`;
const labels = `<div class="qlabels no-print">
<span>Inside ↧ (prints upside-down)</span><span>Message ↧</span>
<span>Back</span><span>Front cover</span>
</div>`;
const sheet = `<div class="sheet quarter">
${g}
<div class="${guideClass()} v" style="left:50%"></div>
<div class="${guideClass()} h" style="top:50%"></div>
${labels}
</div>`;
html += `<div class="sheetHolder active-print">${holderLabel('Small card — fold in half, then half again', sheet)}</div>`;
} else {
const flat = `<div class="sheet flat">
<div class="grid4">${flatCell()}${flatCell()}${flatCell()}${flatCell()}</div>
<div class="${guideClass()} v" style="left:50%"></div>
<div class="${guideClass()} h" style="top:50%"></div>
</div>`;
html += `<div class="sheetHolder active-print">${holderLabel('4 cards — cut to separate', flat)}</div>`;
}
sheets.innerHTML = html;
updatePageStyle();
fit();
saveState();
}
function holderLabel(label, inner){
return `<div class="sheetLabel">${label}</div>${inner}`;
}
const guideClass = ()=> 'guide ' + (state.guide ? '' : 'noprint-guide');
let pageStyleEl=document.createElement('style');document.head.appendChild(pageStyleEl);
function updatePageStyle(){
const land = state.style==='folded';
const orient = land ? 'landscape' : 'portrait';
const pageSize = state.paper === 'a4' ? 'A4' : 'Letter';
const sheetW = state.paper === 'a4' ? '210mm' : '8.5in';
const sheetH = state.paper === 'a4' ? '297mm' : '11in';
pageStyleEl.textContent = `@page{ size: ${pageSize} ${orient}; margin:0; }
.sheet.flat,.sheet.quarter{ width:${sheetW}; height:${sheetH}; }`;
}
function fit(){
const dim = PAPER_DIMS[state.paper || 'letter'];
const wrap = document.getElementById('previewWrap');
if(!wrap) return;
// Use a more robust width measurement
const isMobile = window.innerWidth <= 960;
const w = (isMobile ? window.innerWidth : wrap.clientWidth) || 360;
const h = wrap.clientHeight || (window.innerHeight * 0.5);
const style = window.getComputedStyle(wrap);
const padW = (parseFloat(style.paddingLeft)||0) + (parseFloat(style.paddingRight)||0);
const padH = (parseFloat(style.paddingTop)||0) + (parseFloat(style.paddingBottom)||0);
// Leave a bit more breathing room on mobile
const margin = isMobile ? 24 : 12;
const availW = Math.max(40, w - padW - margin);
const availH = Math.max(40, h - padH - margin);
let scale = Math.min(1, availW / dim.w, availH / dim.h);
if (isNaN(scale) || scale <= 0) scale = 0.1;
document.querySelectorAll('.sheetHolder').forEach(holder => {
const sheet = holder.querySelector('.sheet');
if(sheet) sheet.style.transform = `scale(${scale})`;
holder.style.width = Math.floor(dim.w * scale) + 'px';
holder.style.height = Math.floor(dim.h * scale) + 'px';
});
}
window.addEventListener('resize',fit);
window.addEventListener('load',fit);
window.addEventListener('orientationchange',()=>setTimeout(fit,200));
if('ResizeObserver' in window){
new ResizeObserver(fit).observe(document.getElementById('previewWrap'));
}
/* ---- Tabs ---- */
document.getElementById('tabBar').addEventListener('click', e => {
const btn = e.target.closest('.tab');
if (!btn) return;
const target = btn.dataset.tab;
document.querySelectorAll('.tab').forEach(t => {
t.classList.toggle('active', t === btn);
t.setAttribute('aria-selected', t === btn);
});
document.querySelectorAll('.tab-panel').forEach(p => {
p.classList.toggle('active', p.dataset.tab === target);
});
requestAnimationFrame(fit);
});
loadState();
syncColorInputs();
syncLogoUI();
render();
requestAnimationFrame(fit);