ml-trading-cards / index.html
drbh's picture
drbh HF Staff
feat: tiny app
75dc9a3 verified
<!DOCTYPE html>
<html lang="en" style="background: #120458; position: fixed; width: 100%; height: 100%; overflow: hidden;">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI/ML Card Builder</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700;900&family=Inter:wght@300;400;600;700&display=swap');
:root {
--bg-1: #120458;
--bg-2: #000000;
--bg-3: #1a0933;
--surface-1: #1a1a2e;
--surface-2: #16213e;
--accent-1: #8b45ff;
--accent-2: #ff1493;
--accent-3: #00d4ff;
--accent-warm-1: #ff6b35;
--accent-warm-2: #f7931e;
--text-1: #ffffff;
--text-2: #dddddd;
--text-3: #aaaaaa;
--card-radius: 25px;
--inner-radius: 22px;
--trans-fast: 0.15s ease;
--trans-med: 0.3s ease;
--shadow-outer: 0 25px 50px rgba(0, 0, 0, 0.5);
--shadow-outer-strong: 0 35px 70px rgba(0, 0, 0, 0.7);
--shadow-inner: inset 0 1px 0 rgba(255, 255, 255, 0.1);
--glass: linear-gradient(135deg, rgba(255,255,255,0.1), rgba(255,255,255,0.05));
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', sans-serif;
background: radial-gradient(circle at 20% 80%, var(--bg-1) 0%, var(--bg-2) 50%, var(--bg-3) 100%);
background-attachment: fixed;
min-height: 100vh;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: flex-start;
padding: 20px;
padding-top: 40px;
position: relative;
overflow: auto;
overscroll-behavior: contain;
}
body::before {
content: '';
position: fixed;
top: 50%;
left: 50%;
width: 140vmax;
height: 140vmax;
transform: translate(-50%, -50%) rotate(0deg);
background:
radial-gradient(circle at 25% 25%, rgba(139, 69, 255, 0.1) 0%, transparent 50%),
radial-gradient(circle at 75% 75%, rgba(255, 20, 147, 0.1) 0%, transparent 50%);
animation: float 20s ease-in-out infinite;
z-index: -1;
pointer-events: none;
will-change: transform;
}
@keyframes float {
0%, 100% { transform: translate(-50%, -50%) rotate(0deg); }
50% { transform: translate(-50%, -50%) rotate(180deg); }
}
.container {
display: flex;
gap: 50px;
align-items: flex-start;
max-width: 1200px;
width: 100%;
margin: 0 auto;
justify-content: center;
}
/* Center card when builder is hidden */
.builder-hidden .container { justify-content: center; gap: 0; }
.builder-hidden .controls { display: none; }
/* Floating toggle button */
.builder-toggle {
position: fixed;
bottom: 20px;
right: 20px;
z-index: 1000;
}
.export-toggle {
position: fixed;
bottom: 20px;
right: 140px; /* keep clear of builder toggle */
z-index: 1000;
}
.card {
width: 380px;
height: 650px;
background: linear-gradient(145deg, var(--surface-1), var(--surface-2));
border-radius: var(--card-radius);
padding: 0;
position: relative;
overflow: hidden;
box-shadow:
0 0 0 2px rgba(255, 255, 255, 0.1),
var(--shadow-outer),
var(--shadow-inner);
transform: perspective(1000px) rotateX(5deg);
transition: transform var(--trans-med), box-shadow var(--trans-med);
}
.card:hover {
/* Keep dynamic JS-driven transform; only enhance shadow on hover */
box-shadow:
0 0 0 2px rgba(139, 69, 255, 0.3),
var(--shadow-outer-strong),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.card-border {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: var(--card-radius);
background: linear-gradient(45deg,
var(--accent-warm-1) 0%,
var(--accent-warm-2) 25%,
var(--accent-1) 50%,
var(--accent-2) 75%,
var(--accent-3) 100%);
background-size: 400% 400%;
animation: gradientShift 3s ease-in-out infinite;
padding: 3px;
}
@keyframes gradientShift {
0%, 100% { background-position: 0% 50%; }
50% { background-position: 100% 50%; }
}
.card-inner {
background: linear-gradient(145deg, var(--surface-1), var(--surface-2));
height: 100%;
border-radius: var(--inner-radius);
padding: 50px 25px 25px 25px;
position: relative;
}
.card-rarity {
position: absolute;
top: 8px;
right: 8px;
width: 28px;
height: 28px;
background: linear-gradient(135deg, #ffd700, #ffed4e);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 900;
color: #333;
font-size: 14px;
box-shadow: 0 5px 15px rgba(255, 215, 0, 0.3);
z-index: 1;
}
.card-header {
display: grid;
grid-template-columns: 1fr auto;
align-items: baseline;
gap: 10px;
margin-bottom: 10px;
margin-top: 15px;
}
.card-name {
font-family: 'Orbitron', monospace;
font-size: 20px;
font-weight: 900;
background: linear-gradient(135deg, #ffffff, #a0a0a0);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 1px;
text-shadow: 0 0 20px rgba(255, 255, 255, 0.5);
word-wrap: break-word;
hyphens: auto;
}
.card-title { font-size: 11px; color: var(--accent-1); text-transform: uppercase; letter-spacing: 2px; font-weight: 600; opacity: 0.9; grid-column: 1 / -1; word-wrap: break-word; hyphens: auto; }
.hp { font-family: 'Orbitron', monospace; font-size: 18px; font-weight: 900; color: var(--text-1); display: flex; align-items: baseline; gap: 4px; }
.hp .label { font-size: 11px; opacity: 0.8; }
.type-badge { position: absolute; top: 8px; right: 44px; width: 32px; height: 32px; border-radius: 50%; display: grid; place-items: center; font-weight: 900; color: #111; box-shadow: 0 6px 16px rgba(0,0,0,0.35); border: 2px solid rgba(255,255,255,0.3); background: linear-gradient(135deg, #fff, #ddd); z-index: 2; }
.card-level {
position: absolute;
top: 8px;
left: 8px;
background: linear-gradient(135deg, var(--accent-warm-1), var(--accent-warm-2));
color: white;
padding: 4px 12px;
border-radius: 15px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
}
.artwork-frame {
width: 100%;
height: 200px;
margin: 8px auto 12px;
position: relative;
background: linear-gradient(135deg, #2d1b69, #11998e);
border-radius: 20px;
padding: 15px;
box-shadow:
inset 0 0 20px rgba(0, 0, 0, 0.3),
0 10px 30px rgba(0, 0, 0, 0.3);
}
.artwork-frame::before {
content: '';
position: absolute;
top: -2px;
left: -2px;
right: -2px;
bottom: -2px;
background: linear-gradient(45deg, var(--accent-1), var(--accent-2), var(--accent-3), var(--accent-1));
border-radius: 22px;
z-index: -1;
opacity: 0.7;
}
.shape-container { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; border-radius: 12px; background: var(--glass); backdrop-filter: blur(10px); position: relative; overflow: hidden; }
.art-img { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; display: none; }
.shape {
transition: all 0.5s cubic-bezier(0.34, 1.56, 0.64, 1);
filter: drop-shadow(0 5px 15px rgba(255, 255, 255, 0.3));
}
.shape.circle {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #fff, #f0f0f0);
border-radius: 50%;
}
.shape.square {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #fff, #f0f0f0);
border-radius: 12px;
}
.shape.triangle {
width: 0;
height: 0;
border-left: 40px solid transparent;
border-right: 40px solid transparent;
border-bottom: 70px solid #fff;
filter: drop-shadow(0 5px 15px rgba(255, 255, 255, 0.3));
}
.shape.diamond {
width: 80px;
height: 80px;
background: linear-gradient(135deg, #fff, #f0f0f0);
transform: rotate(45deg);
border-radius: 12px;
}
/* Attacks section */
.attacks { margin-top: 8px; display: grid; gap: 10px; }
.attack-row { background: var(--glass); border: 1px solid rgba(255,255,255,0.12); border-radius: 12px; padding: 10px 12px; }
.attack-head { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 8px; }
.cost { display: flex; gap: 4px; flex-wrap: wrap; }
.atk-name { font-weight: 800; color: var(--text-1); letter-spacing: 0.5px; }
.atk-dmg { font-family: 'Orbitron', monospace; font-weight: 900; color: var(--text-1); }
.atk-text { color: var(--text-2); font-size: 12px; margin-top: 6px; line-height: 1.4; }
.energy { width: 18px; height: 18px; border-radius: 50%; display: grid; place-items: center; font-size: 11px; font-weight: 900; color: #111; border: 1px solid rgba(255,255,255,0.3); box-shadow: 0 2px 6px rgba(0,0,0,0.25); }
/* AI/ML themed energy chips */
.e-generalist { background: linear-gradient(135deg, #f5f5f5, #e0e0e0); }
.e-python { background: linear-gradient(135deg, #3776AB, #FFD43B); }
.e-data { background: linear-gradient(135deg, #34d399, #0ea5e9); }
.e-gpu { background: linear-gradient(135deg, #a7f3d0, #065f46); color: #0a0a0a; }
.e-cloud { background: linear-gradient(135deg, #93c5fd, #e5e7eb); }
.e-research { background: linear-gradient(135deg, #a78bfa, #f472b6); }
.e-math { background: linear-gradient(135deg, #cbd5e1, #64748b); color: #0a0a0a; }
.e-systems { background: linear-gradient(135deg, #f59e0b, #b45309); }
.e-security { background: linear-gradient(135deg, #111827, #ef4444); color: #fafafa; }
.e-mlops { background: linear-gradient(135deg, #10b981, #3b82f6); }
.card-description { background: linear-gradient(135deg, rgba(139, 69, 255, 0.1), rgba(255, 20, 147, 0.1)); border: 1px solid rgba(139, 69, 255, 0.2); padding: 10px; border-radius: 12px; font-size: 12px; color: var(--text-2); line-height: 1.4; text-align: center; font-style: italic; backdrop-filter: blur(10px); margin-top: 10px; }
.card-footer { display: grid; grid-template-columns: repeat(2, 1fr); gap: 10px; margin-top: 10px; }
.footer-box { background: var(--glass); border: 1px solid rgba(255,255,255,0.12); border-radius: 10px; padding: 8px; text-align: center; }
.footer-box .label { font-size: 10px; color: var(--text-3); text-transform: uppercase; letter-spacing: 1px; }
.footer-box .content { margin-top: 6px; display: flex; justify-content: center; gap: 4px; align-items: center; color: var(--text-2); font-weight: 700; word-wrap: break-word; text-align: center; }
.card-watermark {
position: absolute;
bottom: 15px;
right: 15px;
font-family: 'Orbitron', monospace;
font-size: 24px;
font-weight: 900;
color: rgba(255, 255, 255, 0.08);
transform: rotate(-10deg);
pointer-events: none;
user-select: none;
}
.card-info {
position: absolute;
bottom: 8px;
left: 15px;
right: 15px;
display: flex;
justify-content: space-between;
font-family: 'Inter', sans-serif;
font-size: 9px;
color: rgba(255, 255, 255, 0.3);
pointer-events: none;
user-select: none;
}
.card-info span {
letter-spacing: 0.5px;
}
.controls {
background: linear-gradient(145deg, rgba(26, 26, 46, 0.95), rgba(22, 33, 62, 0.95));
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
padding: 30px;
border-radius: 25px;
width: 420px;
box-shadow: 0 25px 50px rgba(0, 0, 0, 0.3);
color: var(--text-1);
}
.controls h3 {
font-family: 'Orbitron', monospace;
margin-bottom: 25px;
color: var(--text-1);
text-align: center;
font-size: 18px;
text-transform: uppercase;
letter-spacing: 1px;
}
.control-group {
margin-bottom: 20px;
}
.control-group label {
display: block;
margin-bottom: 8px;
font-weight: 600;
color: var(--accent-1);
font-size: 12px;
text-transform: uppercase;
letter-spacing: 1px;
}
.control-group input,
.control-group textarea,
.control-group select {
width: 100%;
padding: 12px 15px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
color: var(--text-1);
font-size: 14px;
transition: all 0.3s ease;
backdrop-filter: blur(10px);
}
.control-group input:focus,
.control-group textarea:focus,
.control-group select:focus {
outline: none;
border-color: var(--accent-1);
box-shadow: 0 0 0 3px rgba(139, 69, 255, 0.25);
background: rgba(255, 255, 255, 0.1);
}
.control-group input[type="range"] {
padding: 0;
height: 6px;
background: rgba(255, 255, 255, 0.1);
-webkit-appearance: none;
border-radius: 3px;
}
.control-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 20px;
height: 20px;
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
border-radius: 50%;
cursor: pointer;
box-shadow: 0 5px 15px rgba(139, 69, 255, 0.4);
}
.control-group textarea {
resize: vertical;
height: 80px;
font-family: 'Inter', sans-serif;
}
.shape-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 10px;
margin-top: 8px;
}
.shape-btn {
padding: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: var(--text-1);
border-radius: 12px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 12px;
text-transform: uppercase;
font-weight: 600;
letter-spacing: 1px;
backdrop-filter: blur(10px);
outline: none;
}
.shape-btn:hover {
border-color: var(--accent-1);
background: rgba(139, 69, 255, 0.2);
transform: translateY(-2px);
}
.shape-btn.active {
border-color: var(--accent-1);
background: linear-gradient(135deg, var(--accent-1), var(--accent-2));
color: white;
box-shadow: 0 10px 25px rgba(139, 69, 255, 0.4);
}
.shape-btn:focus-visible {
box-shadow: 0 0 0 3px rgba(139,69,255,0.4);
border-color: var(--accent-1);
}
.range-display {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.range-value {
font-family: 'Orbitron', monospace;
color: var(--accent-1);
font-weight: 700;
font-size: 14px;
}
.helper-row {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
color: var(--text-3);
font-size: 12px;
}
.actions {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-top: 20px;
}
.btn {
padding: 10px 12px;
border-radius: 10px;
border: 1px solid rgba(255,255,255,0.15);
background: rgba(255,255,255,0.06);
color: var(--text-1);
font-weight: 600;
letter-spacing: 0.5px;
cursor: pointer;
transition: transform var(--trans-fast), background var(--trans-fast), border-color var(--trans-fast);
}
.btn:hover { transform: translateY(-1px); border-color: var(--accent-1); }
.btn.primary { background: linear-gradient(135deg, var(--accent-1), var(--accent-2)); border-color: transparent; }
.btn.danger { background: linear-gradient(135deg, #ff5a5f, #ff2d55); border-color: transparent; }
.toggles { display: grid; grid-template-columns: 1fr; gap: 10px; margin-top: 10px; }
.toggle { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--text-2); }
.toggle input { accent-color: var(--accent-1); }
.attack-editor { background: rgba(255,255,255,0.04); border: 1px dashed rgba(255,255,255,0.15); border-radius: 12px; padding: 12px; margin-bottom: 12px; }
.attack-editor .row { display: grid; grid-template-columns: 1fr 100px; gap: 8px; margin-bottom: 8px; }
.attack-editor .row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.attack-editor .row-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 8px; margin-bottom: 8px; }
.energy-palette { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 6px; }
.energy-palette button:focus-visible { outline: 2px solid var(--accent-1); outline-offset: 2px; }
.energy-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 8px; border-radius: 999px; background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.15); color: var(--text-2); font-size: 12px; }
.energy-chip button { background: transparent; border: 0; color: var(--text-2); cursor: pointer; font-size: 14px; }
.toast {
position: fixed;
left: 50%;
bottom: 20px;
transform: translateX(-50%);
background: rgba(0,0,0,0.7);
color: #fff;
padding: 10px 14px;
border-radius: 8px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition: opacity var(--trans-med), transform var(--trans-med);
}
.toast.show { opacity: 1; transform: translateX(-50%) translateY(-4px); }
@media (max-width: 768px) {
.container {
flex-direction: column;
gap: 30px;
}
.card {
transform: none;
}
.controls { width: 100%; max-width: 420px; }
}
/* Reduced motion support */
@media (prefers-reduced-motion: reduce) {
* { animation: none !important; transition: none !important; }
}
body.reduced-motion * { animation: none !important; transition: none !important; }
body.no-glow .card, body.no-glow .shape, body.no-glow .shape-btn.active { box-shadow: none !important; filter: none !important; }
body.high-contrast { --text-2: #f2f2f2; --text-3: #e0e0e0; }
</style>
</head>
<body>
<div class="container">
<div class="card" role="img" aria-label="Card preview for AI/ML engineer">
<div class="card-border">
<div class="card-inner">
<div class="card-rarity" id="cardRarity" aria-hidden="true"></div>
<div class="card-level">XP <span id="cardLevel" aria-live="polite">100</span></div>
<div class="card-header">
<div>
<div class="card-name" id="cardName" aria-live="polite">SMART PERSON</div>
<div class="card-title" id="cardTitle" aria-live="polite">Future Builder</div>
</div>
<div class="hp"><span class="label">HP</span> <span id="hpValue">1337</span></div>
<div class="type-badge" id="typeBadge" title="Stack">Ge</div>
</div>
<div class="artwork-frame">
<div class="shape-container">
<img id="artImage" class="art-img" alt="Card artwork"/>
<div class="shape circle" id="cardShape" aria-label="Avatar shape"></div>
</div>
</div>
<div class="attacks" id="attacks" aria-live="polite"></div>
<div class="card-description" id="cardDescription" aria-live="polite">Builds robust ML systems, from data to deployment.</div>
<div class="card-footer">
<div class="footer-box">
<div class="label">Secret Power</div>
<div class="content" id="secretPowerDisplay">GPU whisper</div>
</div>
<div class="footer-box">
<div class="label">Handle</div>
<div class="content" id="handleDisplay">@user</div>
</div>
</div>
<div class="card-watermark">HF</div>
<div class="card-info">
<span>© 2024 HF</span>
<span id="cardId">001/150</span>
<span>PROMO</span>
</div>
</div>
</div>
</div>
<div class="controls">
<h3>AI/ML Card Builder</h3>
<div class="control-group">
<label for="nameInput">Engineer</label>
<input type="text" id="nameInput" value="Smart Person" maxlength="40" aria-describedby="nameCount" />
<div class="helper-row"><span id="nameCount">0/40</span></div>
</div>
<div class="control-group">
<label for="titleInput">Role</label>
<input type="text" id="titleInput" value="Future Builder" maxlength="40" aria-describedby="titleCount" />
<div class="helper-row"><span id="titleCount">0/40</span></div>
</div>
<div class="control-group">
<label for="hpInput">HP</label>
<input type="number" id="hpInput" min="10" max="9999" step="10" value="1337" />
</div>
<div class="control-group">
<label for="typeInput">Stack</label>
<select id="typeInput"></select>
</div>
<div class="control-group">
<label for="levelInput">Experience</label>
<input type="range" id="levelInput" min="1" max="100" value="100" />
<div class="range-display">
<span>XP</span>
<span class="range-value" id="levelValue">100</span>
</div>
</div>
<div class="control-group">
<label id="shapeLabel">Avatar Shape</label>
<div class="shape-buttons" role="radiogroup" aria-labelledby="shapeLabel">
<button class="shape-btn active" role="radio" aria-checked="true" tabindex="0" data-shape="circle">Circle</button>
<button class="shape-btn" role="radio" aria-checked="false" tabindex="-1" data-shape="square">Square</button>
<button class="shape-btn" role="radio" aria-checked="false" tabindex="-1" data-shape="triangle">Triangle</button>
<button class="shape-btn" role="radio" aria-checked="false" tabindex="-1" data-shape="diamond">Diamond</button>
</div>
</div>
<div class="control-group">
<label for="artInput">Artwork (optional)</label>
<input type="file" id="artInput" accept="image/*" />
</div>
<div class="control-group">
<label>Skills</label>
<div id="attacksEditor"></div>
<button class="btn" id="addAttackBtn" type="button">+ Add Skill</button>
</div>
<div class="control-group">
<label>Identity</label>
<div class="attack-editor">
<div class="row-2">
<div>
<label for="secretPowerInput">Secret Power</label>
<input type="text" id="secretPowerInput" value="GPU whisper" placeholder="Enter symbol/text" maxlength="10" />
</div>
<div>
<label for="handleInput">Handle</label>
<input type="text" id="handleInput" value="@user" placeholder="@username" maxlength="20" />
</div>
</div>
</div>
</div>
<div class="control-group">
<label for="descriptionInput">Bio</label>
<textarea id="descriptionInput" maxlength="160" aria-describedby="descCount">Builds robust ML systems, from data to deployment.</textarea>
<div class="helper-row"><span id="descCount">0/160</span></div>
</div>
<div class="toggles" aria-label="Display options">
<label class="toggle"><input type="checkbox" id="toggleGlow" checked /> Enable glow effects</label>
<label class="toggle"><input type="checkbox" id="toggleMotion" /> Reduced motion</label>
<label class="toggle"><input type="checkbox" id="toggleContrast" /> High contrast text</label>
</div>
<div class="actions">
<button class="btn primary" id="saveBtn">Save</button>
<button class="btn" id="loadBtn">Load</button>
<button class="btn" id="shareBtn">Copy Link</button>
<button class="btn" id="exportBtn">Export PNG</button>
<button class="btn danger" id="resetBtn">Reset</button>
</div>
</div>
</div>
<button class="btn builder-toggle" id="toggleBuilderBtn" type="button" aria-pressed="false" aria-label="Toggle builder panel">Hide Builder</button>
<button class="btn export-toggle" id="exportFloatingBtn" type="button" aria-label="Export card as PNG">Export PNG</button>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
<script>
// Get DOM elements
const cardName = document.getElementById('cardName');
const cardTitle = document.getElementById('cardTitle');
const cardLevel = document.getElementById('cardLevel');
const cardShape = document.getElementById('cardShape');
const cardDescription = document.getElementById('cardDescription');
const hpValue = document.getElementById('hpValue');
const typeBadge = document.getElementById('typeBadge');
const attacksEl = document.getElementById('attacks');
const secretPowerDisplay = document.getElementById('secretPowerDisplay');
const handleDisplay = document.getElementById('handleDisplay');
const artImage = document.getElementById('artImage');
const nameInput = document.getElementById('nameInput');
const titleInput = document.getElementById('titleInput');
const levelInput = document.getElementById('levelInput');
const hpInput = document.getElementById('hpInput');
const typeInput = document.getElementById('typeInput');
const secretPowerInput = document.getElementById('secretPowerInput');
const handleInput = document.getElementById('handleInput');
const artInput = document.getElementById('artInput');
const descriptionInput = document.getElementById('descriptionInput');
const levelValue = document.getElementById('levelValue');
const shapeButtons = document.querySelectorAll('.shape-btn');
const shapeGroup = document.querySelector('.shape-buttons');
const attacksEditor = document.getElementById('attacksEditor');
const addAttackBtn = document.getElementById('addAttackBtn');
const nameCount = document.getElementById('nameCount');
const titleCount = document.getElementById('titleCount');
const descCount = document.getElementById('descCount');
const toggleGlow = document.getElementById('toggleGlow');
const toggleMotion = document.getElementById('toggleMotion');
const toggleContrast = document.getElementById('toggleContrast');
const saveBtn = document.getElementById('saveBtn');
const loadBtn = document.getElementById('loadBtn');
const shareBtn = document.getElementById('shareBtn');
const exportBtn = document.getElementById('exportBtn');
const exportFloatingBtn = document.getElementById('exportFloatingBtn');
const resetBtn = document.getElementById('resetBtn');
const toast = document.getElementById('toast');
const toggleBuilderBtn = document.getElementById('toggleBuilderBtn');
// Update functions
function updateName() {
cardName.textContent = (nameInput.value || 'ENGINEER NAME').toUpperCase();
updateCounters();
}
function updateTitle() {
cardTitle.textContent = titleInput.value || 'ML Engineer';
updateCounters();
}
function updateLevel() {
const level = levelInput.value;
cardLevel.textContent = level;
levelValue.textContent = level;
}
function updateHPType() {
const hp = parseInt(hpInput.value || '0', 10);
hpValue.textContent = isNaN(hp) ? 0 : hp;
const t = typeInput.value || 'generalist';
const map = energyTypes[t] || energyTypes.generalist;
typeBadge.textContent = map.short;
typeBadge.className = 'type-badge';
typeBadge.style.background = map.bg;
renderFooter();
}
function updateDescription() {
cardDescription.textContent = descriptionInput.value || 'Builds robust ML systems, from data to deployment.';
updateCounters();
}
function updateShape(shape, opts = { focus: true }) {
cardShape.className = `shape ${shape}`;
// Update active button
shapeButtons.forEach(btn => {
btn.classList.remove('active');
btn.setAttribute('aria-checked', 'false');
btn.setAttribute('tabindex', '-1');
if (btn.dataset.shape === shape) {
btn.classList.add('active');
btn.setAttribute('aria-checked', 'true');
btn.setAttribute('tabindex', '0');
if (opts.focus) btn.focus({ preventScroll: true });
}
});
}
function updateCounters() {
nameCount.textContent = `${nameInput.value.length}/${nameInput.maxLength}`;
titleCount.textContent = `${titleInput.value.length}/${titleInput.maxLength}`;
descCount.textContent = `${descriptionInput.value.length}/${descriptionInput.maxLength}`;
}
// Energy/type map
const energyTypes = {
generalist: { key: 'generalist', name: 'Generalist', short: 'Ge', class: 'e-generalist', bg: 'linear-gradient(135deg,#f5f5f5,#e0e0e0)' },
python: { key: 'python', name: 'Python', short: 'Py', class: 'e-python', bg: 'linear-gradient(135deg,#3776AB,#FFD43B)' },
data: { key: 'data', name: 'Data', short: 'Ds', class: 'e-data', bg: 'linear-gradient(135deg,#34d399,#0ea5e9)' },
gpu: { key: 'gpu', name: 'GPU', short: 'GPU', class: 'e-gpu', bg: 'linear-gradient(135deg,#a7f3d0,#065f46)' },
cloud: { key: 'cloud', name: 'Cloud', short: 'Cl', class: 'e-cloud', bg: 'linear-gradient(135deg,#93c5fd,#e5e7eb)' },
research: { key: 'research', name: 'Research', short: 'R', class: 'e-research', bg: 'linear-gradient(135deg,#a78bfa,#f472b6)' },
math: { key: 'math', name: 'Math', short: '∑', class: 'e-math', bg: 'linear-gradient(135deg,#cbd5e1,#64748b)' },
systems: { key: 'systems', name: 'Systems', short: 'Sys', class: 'e-systems', bg: 'linear-gradient(135deg,#f59e0b,#b45309)' },
security: { key: 'security', name: 'Security', short: 'Sec', class: 'e-security', bg: 'linear-gradient(135deg,#111827,#ef4444)' },
mlops: { key: 'mlops', name: 'MLOps', short: 'Ops', class: 'e-mlops', bg: 'linear-gradient(135deg,#10b981,#3b82f6)' },
};
function renderFooter() {
secretPowerDisplay.textContent = secretPowerInput.value || '🚀';
handleDisplay.textContent = handleInput.value || '@drbh';
}
function renderAttacks() {
const state = getState();
attacksEl.innerHTML = state.attacks.map(atk => {
const cost = (atk.cost || []).map(k => {
const m = energyTypes[k] || energyTypes.generalist;
return `<span class="energy ${m.class}">${m.short}</span>`;
}).join('');
const dmg = atk.damage ? `<div class="atk-dmg">${atk.damage}</div>` : '';
const text = atk.text ? `<div class="atk-text">${atk.text}</div>` : '';
return `<div class="attack-row"><div class="attack-head"><div class="cost">${cost}</div><div class="atk-name">${atk.name || '—'}</div>${dmg}</div>${text}</div>`;
}).join('');
}
function populateTypeSelects() {
const keys = Object.keys(energyTypes);
// Populate type select
typeInput.innerHTML = keys.map(k => `<option value="${k}">${energyTypes[k].name}</option>`).join('');
if (!typeInput.value) typeInput.value = 'generalist';
}
function buildAttackEditor() {
const state = getState();
attacksEditor.innerHTML = '';
state.attacks.forEach((atk, index) => {
const wrap = document.createElement('div');
wrap.className = 'attack-editor';
const row = document.createElement('div');
row.className = 'row';
row.innerHTML = `
<input type="text" placeholder="Skill name" value="${atk.name || ''}" aria-label="Skill name"/>
<input type="text" placeholder="Impact (e.g. 30+)" value="${atk.damage || ''}" aria-label="Skill impact"/>
`;
const [nameEl, dmgEl] = row.querySelectorAll('input');
nameEl.addEventListener('input', () => { state.attacks[index].name = nameEl.value; applyState(state, { rebuildEditors: false }); setUrlHashFromState(); saveDraftDebounced(); });
dmgEl.addEventListener('input', () => { state.attacks[index].damage = dmgEl.value; applyState(state, { rebuildEditors: false }); setUrlHashFromState(); saveDraftDebounced(); });
const textArea = document.createElement('textarea');
textArea.placeholder = 'Skill details';
textArea.value = atk.text || '';
textArea.addEventListener('input', () => { state.attacks[index].text = textArea.value; applyState(state, { rebuildEditors: false }); setUrlHashFromState(); saveDraftDebounced(); });
const palette = document.createElement('div');
palette.className = 'energy-palette';
palette.innerHTML = Object.keys(energyTypes).map(k => {
const m = energyTypes[k];
return `<button type="button" data-k="${k}" class="energy ${m.class}" title="Add ${m.name}">${m.short}</button>`;
}).join('');
palette.addEventListener('click', (e) => {
const btn = e.target.closest('button[data-k]');
if (!btn) return;
const k = btn.dataset.k;
state.attacks[index].cost = state.attacks[index].cost || [];
state.attacks[index].cost.push(k);
applyState(state, { rebuildEditors: false });
renderChips();
setUrlHashFromState(); saveDraftDebounced();
});
const chips = document.createElement('div');
chips.className = 'energy-palette';
function renderChips() {
const current = state.attacks[index] || { cost: [] };
chips.innerHTML = (current.cost || []).map((k, i) => {
const m = energyTypes[k] || energyTypes.generalist;
return `<span class="energy-chip"><span class="energy ${m.class}">${m.short}</span><button type="button" data-i="${i}">×</button></span>`;
}).join('');
}
chips.addEventListener('click', (e) => {
const b = e.target.closest('button[data-i]');
if (!b) return;
const i = parseInt(b.dataset.i, 10);
state.attacks[index].cost.splice(i, 1);
applyState(state, { rebuildEditors: false });
renderChips();
setUrlHashFromState(); saveDraftDebounced();
});
const actions = document.createElement('div');
actions.style.display = 'flex';
actions.style.justifyContent = 'space-between';
actions.style.marginTop = '8px';
const removeBtn = document.createElement('button');
removeBtn.className = 'btn danger';
removeBtn.type = 'button';
removeBtn.textContent = 'Remove Skill';
removeBtn.addEventListener('click', () => {
state.attacks.splice(index, 1);
applyState(state); setUrlHashFromState(); saveDraftDebounced();
});
actions.appendChild(removeBtn);
wrap.appendChild(row);
wrap.appendChild(textArea);
const label1 = document.createElement('div'); label1.textContent = 'Add tokens:'; label1.style.marginTop = '12px'; label1.style.fontSize='12px'; label1.style.color='var(--text-3)';
wrap.appendChild(label1);
wrap.appendChild(palette);
const label2 = document.createElement('div'); label2.textContent = 'Tokens:'; label2.style.marginTop = '12px'; label2.style.fontSize='12px'; label2.style.color='var(--text-3)';
wrap.appendChild(label2);
wrap.appendChild(chips);
wrap.appendChild(actions);
attacksEditor.appendChild(wrap);
renderChips();
});
}
function showToast(message) {
toast.textContent = message;
toast.classList.add('show');
clearTimeout(showToast._t);
showToast._t = setTimeout(() => toast.classList.remove('show'), 1600);
}
function getState() {
return {
name: nameInput.value,
title: titleInput.value,
level: Number(levelInput.value),
hp: Number(hpInput.value) || 0,
type: typeInput.value,
secretPower: secretPowerInput.value,
handle: handleInput.value,
description: descriptionInput.value,
shape: [...shapeButtons].find(b => b.classList.contains('active'))?.dataset.shape || 'circle',
artwork: artImage?.src && artImage.style.display !== 'none' ? artImage.src : '',
attacks: window.__attacksState || [],
options: {
glow: toggleGlow.checked,
reducedMotion: toggleMotion.checked,
highContrast: toggleContrast.checked
},
ui: {
builderHidden: document.body.classList.contains('builder-hidden')
}
};
}
function applyState(state, options = {}) {
const opts = { rebuildEditors: true, renderAttacks: true, ...options };
if (!state) return;
nameInput.value = state.name ?? nameInput.value;
titleInput.value = state.title ?? titleInput.value;
levelInput.value = state.level ?? levelInput.value;
hpInput.value = state.hp ?? hpInput.value;
typeInput.value = state.type ?? typeInput.value;
secretPowerInput.value = state.secretPower ?? secretPowerInput.value;
handleInput.value = state.handle ?? handleInput.value;
descriptionInput.value = state.description ?? descriptionInput.value;
updateShape(state.shape ?? 'circle', { focus: false });
if (state.artwork) {
artImage.src = state.artwork;
artImage.style.display = 'block';
cardShape.style.display = 'none';
} else {
artImage.removeAttribute('src');
artImage.style.display = 'none';
cardShape.style.display = '';
}
window.__attacksState = Array.isArray(state.attacks) ? state.attacks.slice(0, 4) : [];
if (opts.renderAttacks) renderAttacks();
if (opts.rebuildEditors) buildAttackEditor();
toggleGlow.checked = state.options?.glow ?? true;
toggleMotion.checked = state.options?.reducedMotion ?? false;
toggleContrast.checked = state.options?.highContrast ?? false;
syncBodyOptions();
// Apply UI state (builder visibility)
const hidden = !!state.ui?.builderHidden;
document.body.classList.toggle('builder-hidden', hidden);
if (typeof toggleBuilderBtn !== 'undefined' && toggleBuilderBtn) {
toggleBuilderBtn.textContent = hidden ? 'Show Builder' : 'Hide Builder';
toggleBuilderBtn.setAttribute('aria-pressed', String(hidden));
}
updateName();
updateTitle();
updateLevel();
updateHPType();
updateDescription();
renderFooter();
}
function syncBodyOptions() {
document.body.classList.toggle('no-glow', !toggleGlow.checked);
document.body.classList.toggle('reduced-motion', toggleMotion.checked);
document.body.classList.toggle('high-contrast', toggleContrast.checked);
}
function saveToLocal() {
try {
localStorage.setItem('cardBuilderState', JSON.stringify(getState()));
showToast('Saved');
} catch (e) { showToast('Save failed'); }
}
function loadFromLocal() {
try {
const raw = localStorage.getItem('cardBuilderState');
if (!raw) { showToast('Nothing to load'); return; }
const state = JSON.parse(raw);
applyState(state);
showToast('Loaded');
} catch (e) { showToast('Load failed'); }
}
function setUrlHashFromState() {
try {
const s = getState();
// Do not serialize artwork into URL (too large)
delete s.artwork;
const json = JSON.stringify(s);
const enc = encodeURIComponent(json);
const newHash = `#state=${enc}`;
const newUrl = `${location.pathname}${location.search}${newHash}`;
if (location.hash !== newHash) history.replaceState(null, '', newUrl);
} catch (_) {}
}
function loadStateFromHash() {
if (!location.hash.startsWith('#state=')) return null;
try {
const enc = location.hash.slice('#state='.length);
const json = decodeURIComponent(enc);
return JSON.parse(json);
} catch (_) { return null; }
}
async function exportCardAsPNG() {
const cardEl = document.querySelector('.card');
const rect = cardEl.getBoundingClientRect();
const scale = 2; // Fixed scale to avoid memory issues
try {
// Try html2canvas approach if available
if (typeof html2canvas !== 'undefined') {
// Create a wrapper div with padding to prevent cropping
const wrapper = document.createElement('div');
wrapper.style.padding = '20px';
wrapper.style.display = 'inline-block';
wrapper.style.backgroundColor = 'transparent';
// Clone the card
const cardClone = cardEl.cloneNode(true);
// Fix glow effect for export - text-shadow doesn't work well with transparent text
const nameEl = cardClone.querySelector('.card-name');
if (nameEl) {
nameEl.style.webkitTextFillColor = '#ffffff';
nameEl.style.background = 'none';
nameEl.style.color = '#ffffff';
nameEl.style.textShadow = '0 0 15px rgba(255, 255, 255, 0.8), 0 0 30px rgba(255, 255, 255, 0.4)';
}
// Fix artwork frame background for export - replace complex pseudo-element
const artworkFrame = cardClone.querySelector('.artwork-frame');
if (artworkFrame) {
// Preserve original dimensions
artworkFrame.style.width = '100%';
artworkFrame.style.height = '200px';
artworkFrame.style.position = 'relative';
// Create a simple gradient border that html2canvas can render
artworkFrame.style.background = 'linear-gradient(45deg, #8b45ff, #ff1493, #00d4ff, #8b45ff)';
artworkFrame.style.padding = '4px';
artworkFrame.style.borderRadius = '22px';
// Create inner container for the actual artwork background
const innerFrame = document.createElement('div');
innerFrame.style.background = 'linear-gradient(135deg, #2d1b69, #11998e)';
innerFrame.style.borderRadius = '18px';
innerFrame.style.width = '100%';
innerFrame.style.height = '100%';
innerFrame.style.padding = '15px';
innerFrame.style.boxSizing = 'border-box';
innerFrame.style.position = 'relative';
// Move all children to inner frame
while (artworkFrame.firstChild) {
innerFrame.appendChild(artworkFrame.firstChild);
}
artworkFrame.appendChild(innerFrame);
// Ensure shape container maintains exact dimensions
const shapeContainer = cardClone.querySelector('.shape-container');
if (shapeContainer) {
const originalContainer = cardEl.querySelector('.shape-container');
const computedContainerStyle = window.getComputedStyle(originalContainer);
shapeContainer.style.position = 'relative';
shapeContainer.style.width = '100%';
shapeContainer.style.height = '100%';
shapeContainer.style.overflow = 'hidden';
shapeContainer.style.borderRadius = '12px';
shapeContainer.style.display = 'flex';
shapeContainer.style.alignItems = 'center';
shapeContainer.style.justifyContent = 'center';
// If there's an image, remove the glass background
const hasImage = cardClone.querySelector('.art-img')?.src;
if (hasImage) {
shapeContainer.style.background = 'transparent';
shapeContainer.style.backdropFilter = 'none';
}
}
// Fix artwork image aspect ratio - maintain original dimensions
const artImg = cardClone.querySelector('.art-img');
if (artImg && artImg.src && artImg.style.display !== 'none') {
// Copy computed styles from original to ensure exact match
const originalImg = cardEl.querySelector('.art-img');
const computedStyle = window.getComputedStyle(originalImg);
artImg.style.position = 'absolute';
artImg.style.top = '0';
artImg.style.left = '0';
artImg.style.width = '100%';
artImg.style.height = '100%';
artImg.style.objectFit = computedStyle.objectFit || 'cover';
artImg.style.objectPosition = computedStyle.objectPosition || 'center';
artImg.style.borderRadius = '12px';
artImg.style.display = 'block';
// Ensure the shape container doesn't interfere
const shape = cardClone.querySelector('.shape');
if (shape) {
shape.style.display = 'none';
}
}
}
wrapper.appendChild(cardClone);
// Temporarily add wrapper to document (off-screen to prevent flicker)
wrapper.style.position = 'fixed';
wrapper.style.left = '0';
wrapper.style.top = '0';
wrapper.style.zIndex = '-9999';
wrapper.style.pointerEvents = 'none';
document.body.appendChild(wrapper);
const canvas = await html2canvas(wrapper, {
scale: scale,
useCORS: true,
allowTaint: true,
backgroundColor: null,
scrollX: 0,
scrollY: 0
});
// Remove wrapper
document.body.removeChild(wrapper);
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
a.download = 'card.png';
document.body.appendChild(a);
a.click();
a.remove();
return;
}
} catch (e) {
console.warn('html2canvas failed, falling back to manual canvas approach:', e);
}
// Fallback: Direct canvas drawing with simplified approach
const canvas = document.createElement('canvas');
canvas.width = Math.floor(rect.width * scale);
canvas.height = Math.floor(rect.height * scale);
const ctx = canvas.getContext('2d');
// Set white background
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
// Scale the context
ctx.scale(scale, scale);
// Try to draw the element directly using browser API
try {
// Create a simple representation
ctx.fillStyle = '#333';
ctx.font = '16px Arial';
ctx.fillText('Card export - please use browser screenshot', 10, 30);
ctx.fillText('for better quality', 10, 50);
const dataUrl = canvas.toDataURL('image/png');
const a = document.createElement('a');
a.href = dataUrl;
a.download = 'card.png';
document.body.appendChild(a);
a.click();
a.remove();
} catch (e) {
throw new Error('Canvas export failed: ' + e.message);
}
}
// Event listeners
nameInput.addEventListener('input', () => { updateName(); setUrlHashFromState(); });
titleInput.addEventListener('input', () => { updateTitle(); setUrlHashFromState(); });
levelInput.addEventListener('input', () => { updateLevel(); setUrlHashFromState(); });
hpInput.addEventListener('input', () => { updateHPType(); setUrlHashFromState(); });
typeInput.addEventListener('change', () => { updateHPType(); setUrlHashFromState(); });
secretPowerInput.addEventListener('input', () => { renderFooter(); setUrlHashFromState(); });
handleInput.addEventListener('input', () => { renderFooter(); setUrlHashFromState(); });
descriptionInput.addEventListener('input', () => { updateDescription(); setUrlHashFromState(); });
shapeButtons.forEach((button, idx) => {
button.addEventListener('click', () => {
updateShape(button.dataset.shape);
setUrlHashFromState();
});
});
// Keyboard navigation for radiogroup
shapeGroup.addEventListener('keydown', (e) => {
const currentIndex = [...shapeButtons].findIndex(b => b.getAttribute('aria-checked') === 'true');
if (['ArrowRight', 'ArrowDown'].includes(e.key)) {
e.preventDefault();
const next = (currentIndex + 1) % shapeButtons.length;
shapeButtons[next].click();
} else if (['ArrowLeft', 'ArrowUp'].includes(e.key)) {
e.preventDefault();
const prev = (currentIndex - 1 + shapeButtons.length) % shapeButtons.length;
shapeButtons[prev].click();
} else if ([' ', 'Enter'].includes(e.key)) {
e.preventDefault();
const active = [...shapeButtons][currentIndex] || shapeButtons[0];
active.click();
}
});
// Artwork upload
artInput.addEventListener('change', (e) => {
const f = e.target.files && e.target.files[0];
if (!f) return;
const reader = new FileReader();
reader.onload = () => {
artImage.src = reader.result;
artImage.style.display = 'block';
cardShape.style.display = 'none';
setUrlHashFromState();
saveDraftDebounced();
};
reader.readAsDataURL(f);
});
// Option toggles
toggleGlow.addEventListener('change', () => { syncBodyOptions(); setUrlHashFromState(); });
toggleMotion.addEventListener('change', () => { syncBodyOptions(); setUrlHashFromState(); });
toggleContrast.addEventListener('change', () => { syncBodyOptions(); setUrlHashFromState(); });
// Actions
saveBtn.addEventListener('click', saveToLocal);
loadBtn.addEventListener('click', loadFromLocal);
shareBtn.addEventListener('click', async () => {
setUrlHashFromState();
try {
await navigator.clipboard.writeText(location.href);
showToast('Link copied');
} catch (e) { showToast('Copy failed'); }
});
exportBtn.addEventListener('click', async () => {
try {
await exportCardAsPNG();
showToast('PNG exported');
} catch (e) { console.error(e); showToast('Export failed'); }
});
exportFloatingBtn.addEventListener('click', async () => {
try {
await exportCardAsPNG();
showToast('PNG exported');
} catch (e) { console.error(e); showToast('Export failed'); }
});
resetBtn.addEventListener('click', () => {
if (!confirm('Reset all values?')) return;
nameInput.value = 'Smart Person';
titleInput.value = 'Future Builder';
levelInput.value = 100;
hpInput.value = 1337;
typeInput.value = 'generalist';
secretPowerInput.value = 'GPU whisper';
handleInput.value = '@user';
descriptionInput.value = 'Builds robust ML systems, from data to deployment.';
artImage.removeAttribute('src');
artImage.style.display = 'none';
cardShape.style.display = '';
window.__attacksState = [
{name: 'Code Review', cost: [{type: 'generalist', count: 2}], damage: '42'},
{name: 'Debug Session', cost: [{type: 'generalist', count: 3}], damage: '1337'}
];
attacksEl.innerHTML = '';
attacksEditor.innerHTML = '';
updateShape('circle');
toggleGlow.checked = true;
toggleMotion.checked = false;
toggleContrast.checked = false;
syncBodyOptions();
updateName(); updateTitle(); updateLevel(); updateHPType(); updateDescription(); renderFooter();
setUrlHashFromState();
showToast('Reset');
});
// Show/Hide Builder toggle
toggleBuilderBtn.addEventListener('click', () => {
const isHidden = document.body.classList.toggle('builder-hidden');
toggleBuilderBtn.textContent = isHidden ? 'Show Builder' : 'Hide Builder';
toggleBuilderBtn.setAttribute('aria-pressed', String(isHidden));
setUrlHashFromState();
});
// Initialize card
updateName();
updateTitle();
updateLevel();
updateHPType();
updateDescription();
// Generate random card ID
const cardNum = Math.floor(Math.random() * 150) + 1;
document.getElementById('cardId').textContent = `${String(cardNum).padStart(3, '0')}/150`;
updateCounters();
syncBodyOptions();
populateTypeSelects();
window.__attacksState = window.__attacksState || [
{name: 'Code Review', cost: [{type: 'generalist', count: 2}], damage: '42'},
{name: 'Debug Session', cost: [{type: 'generalist', count: 3}], damage: '1337'}
];
buildAttackEditor();
renderAttacks();
addAttackBtn.addEventListener('click', () => {
const state = getState();
if (!Array.isArray(state.attacks)) state.attacks = [];
if (state.attacks.length >= 4) { showToast('Max 4 skills'); return; }
state.attacks.push({ name: 'New Skill', damage: '', text: '', cost: [] });
applyState(state); setUrlHashFromState(); saveDraftDebounced();
});
function saveDraftDebounced() { clearTimeout(saveDraftDebounced._t); saveDraftDebounced._t = setTimeout(saveToLocal, 300); }
// Load from URL hash or localStorage
const hashState = loadStateFromHash();
if (hashState) {
applyState(hashState);
} else {
try { const local = JSON.parse(localStorage.getItem('cardBuilderState') || 'null'); applyState(local); } catch(_) {}
}
// Add interactive tilt (all directions)
const card = document.querySelector('.card');
let tiltRAF = null;
function setCardTransform(rx, ry, tz = 0) {
card.style.transform = `perspective(1000px) rotateX(${rx}deg) rotateY(${ry}deg) translateZ(${tz}px)`;
}
function handleMove(e) {
if (document.body.classList.contains('reduced-motion')) return;
const rect = card.getBoundingClientRect();
const px = (e.clientX - rect.left) / rect.width; // 0..1
const py = (e.clientY - rect.top) / rect.height; // 0..1
const dx = (px - 0.5) * 2; // -1..1
const dy = (py - 0.5) * 2; // -1..1
const maxTilt = 10; // degrees
const rx = -dy * maxTilt; // invert Y for natural tilt
const ry = dx * maxTilt;
if (tiltRAF) cancelAnimationFrame(tiltRAF);
tiltRAF = requestAnimationFrame(() => setCardTransform(rx, ry));
}
function handleEnter() {
if (document.body.classList.contains('reduced-motion')) return;
card.addEventListener('mousemove', handleMove);
}
function handleLeave() {
card.removeEventListener('mousemove', handleMove);
// Reset to subtle default tilt when leaving
setCardTransform(5, 0, 0);
}
card.addEventListener('mouseenter', handleEnter);
card.addEventListener('mouseleave', handleLeave);
// Initialize default tilt
setCardTransform(5, 0, 0);
</script>
</body>
</html>