DIY — Soldering Spark Effect
A dark PCB workbench where the cursor becomes a soldering iron. Press and hold to throw a burst of orange-white sparks that arc under adjustable gravity, drop molten blobs that cool from bright yellow to dull grey, and fill six joint pads with real solder fillets. Sliders drive spark density, heat colour temperature and gravity; a trail toggle adds smoke ghosting. Arrow keys move the tip and space solders, and reduced motion falls back to a static glow.
MCP
Code
:root {
--blueprint: #0f2a4a;
--blueprint-d: #0a1e36;
--blueprint-l: #16385f;
--grid: rgba(140, 180, 220, 0.14);
--grid-major: rgba(140, 180, 220, 0.26);
--orange: #ff6b35;
--orange-d: #e0521f;
--orange-soft: #ffe3d6;
--kraft: #d9c7a7;
--kraft-l: #efe6d4;
--ink: #1c2733;
--ink-2: #3d4a57;
--muted: #6b7885;
--bg: #f4f1ea;
--white: #ffffff;
--line: rgba(28, 39, 51, 0.12);
--line-2: rgba(28, 39, 51, 0.2);
--ok: #2f9e6f;
--warn: #d98a2b;
--danger: #d4503e;
--r-sm: 6px;
--r-md: 12px;
--r-lg: 18px;
--sh-sm: 0 1px 2px rgba(15, 42, 74, 0.08), 0 2px 8px rgba(15, 42, 74, 0.06);
--sh-md: 0 4px 18px rgba(15, 42, 74, 0.12);
--sh-lg: 0 18px 48px rgba(10, 30, 54, 0.22);
}
*, *::before, *::after { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
font-family: Inter, system-ui, -apple-system, "Segoe UI", sans-serif;
font-size: 15px;
line-height: 1.5;
color: var(--ink);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: var(--blueprint);
background-image:
linear-gradient(var(--grid-major) 1px, transparent 1px),
linear-gradient(90deg, var(--grid-major) 1px, transparent 1px),
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px),
radial-gradient(120% 90% at 50% 0%, var(--blueprint-l) 0%, var(--blueprint) 45%, var(--blueprint-d) 100%);
background-size: 80px 80px, 80px 80px, 8px 8px, 8px 8px, 100% 100%;
}
.mono { font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace; }
.shell {
max-width: 1240px;
margin: 0 auto;
padding: 24px 20px 40px;
}
/* ---------- topbar ---------- */
.topbar {
display: flex;
flex-wrap: wrap;
gap: 14px;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
border-radius: var(--r-lg);
background: linear-gradient(180deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.04));
border: 1px solid rgba(217, 199, 167, 0.28);
backdrop-filter: blur(6px);
box-shadow: var(--sh-lg);
}
.brand { display: flex; align-items: center; gap: 12px; }
.brand-mark {
display: grid;
place-items: center;
width: 44px; height: 44px;
border-radius: var(--r-md);
background: rgba(10, 30, 54, 0.55);
border: 1px solid rgba(217, 199, 167, 0.3);
}
.brand strong {
display: block;
color: var(--kraft-l);
font-size: 16px;
font-weight: 800;
letter-spacing: -0.01em;
}
.brand small {
display: block;
color: rgba(217, 199, 167, 0.72);
font-size: 12px;
font-family: "JetBrains Mono", monospace;
letter-spacing: 0.04em;
}
.topbar-meta { display: flex; flex-wrap: wrap; gap: 8px; }
.chip {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 6px 11px;
font-size: 12px;
font-weight: 600;
border-radius: 999px;
color: var(--kraft-l);
background: rgba(10, 30, 54, 0.5);
border: 1px solid rgba(217, 199, 167, 0.22);
}
.chip .dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--orange);
box-shadow: 0 0 0 3px rgba(255, 107, 53, 0.22);
animation: pulse 2.4s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
/* ---------- layout ---------- */
.grid {
display: grid;
grid-template-columns: minmax(0, 1fr) 320px;
gap: 20px;
margin-top: 20px;
align-items: start;
}
.stage-card, .panel {
background: var(--bg);
border-radius: var(--r-lg);
border: 1px solid rgba(217, 199, 167, 0.35);
box-shadow: var(--sh-lg);
overflow: hidden;
}
.stage-head { padding: 18px 20px 12px; }
.stage-head h1 {
margin: 0 0 4px;
font-size: 21px;
font-weight: 800;
letter-spacing: -0.02em;
}
.stage-head p { margin: 0; color: var(--muted); font-size: 13.5px; }
kbd {
font-family: "JetBrains Mono", monospace;
font-size: 11.5px;
padding: 1px 6px;
border-radius: var(--r-sm);
background: var(--white);
border: 1px solid var(--line-2);
border-bottom-width: 2px;
color: var(--ink);
}
/* ---------- stage ---------- */
.stage-wrap {
position: relative;
margin: 0 16px;
border-radius: var(--r-md);
overflow: hidden;
background: #0a1410;
border: 1px solid rgba(28, 39, 51, 0.5);
cursor: none;
touch-action: none;
}
.board {
display: block;
width: 100%;
height: auto;
aspect-ratio: 900 / 560;
}
.board:focus-visible {
outline: 3px solid var(--orange);
outline-offset: -3px;
}
.iron {
position: absolute;
top: 0; left: 0;
width: 120px; height: 120px;
pointer-events: none;
transform: translate(-14px, -92px);
filter: drop-shadow(0 8px 12px rgba(0, 0, 0, 0.55));
opacity: 0;
transition: opacity 0.18s ease;
}
.iron.on { opacity: 1; }
.iron .tip-hot { opacity: 0.35; transition: opacity 0.15s ease; }
.iron.hot .tip-hot { opacity: 1; }
.stage-badge {
position: absolute;
top: 12px; left: 12px;
padding: 5px 10px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.12em;
border-radius: var(--r-sm);
color: var(--kraft-l);
background: rgba(10, 20, 16, 0.7);
border: 1px solid rgba(217, 199, 167, 0.28);
}
.stage-badge.hot {
color: #fff2e6;
background: rgba(224, 82, 31, 0.85);
border-color: rgba(255, 227, 214, 0.5);
}
.stage-badge.done {
color: #eafff5;
background: rgba(47, 158, 111, 0.9);
border-color: rgba(234, 255, 245, 0.5);
}
/* ---------- stage foot ---------- */
.stage-foot {
display: flex;
align-items: flex-end;
gap: 16px;
padding: 16px 20px 20px;
}
.progress { flex: 1; min-width: 0; }
.progress-label {
display: flex;
justify-content: space-between;
align-items: baseline;
font-size: 12.5px;
color: var(--muted);
margin-bottom: 6px;
}
.progress-label strong { color: var(--ink); font-size: 13px; }
.progress-track {
height: 9px;
border-radius: 999px;
background: rgba(28, 39, 51, 0.1);
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 999px;
background: linear-gradient(90deg, var(--warn), var(--orange));
transition: width 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.progress-fill.full { background: linear-gradient(90deg, var(--ok), #52c795); }
/* ---------- buttons ---------- */
.btn {
font: inherit;
font-size: 13.5px;
font-weight: 600;
padding: 9px 16px;
border-radius: var(--r-md);
border: 1px solid var(--line-2);
background: var(--white);
color: var(--ink);
cursor: pointer;
transition: transform 0.14s ease, box-shadow 0.14s ease, background 0.14s ease;
box-shadow: var(--sh-sm);
}
.btn:hover { transform: translateY(-1px); box-shadow: var(--sh-md); background: var(--kraft-l); }
.btn:active { transform: translateY(0); }
.btn:focus-visible { outline: 3px solid var(--orange); outline-offset: 2px; }
.btn-ghost { background: transparent; box-shadow: none; }
/* ---------- panel ---------- */
.panel { padding: 18px; }
.panel-title {
margin: 0 0 14px;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--muted);
font-family: "JetBrains Mono", monospace;
}
.control { padding: 12px 0; border-top: 1px dashed var(--line); }
.control:first-of-type { border-top: 0; padding-top: 0; }
.control label {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
font-size: 13.5px;
font-weight: 600;
margin-bottom: 8px;
}
.val {
font-size: 12px;
font-weight: 700;
color: var(--orange-d);
padding: 2px 7px;
border-radius: var(--r-sm);
background: var(--orange-soft);
}
.hint { margin: 6px 0 0; font-size: 12px; color: var(--muted); }
input[type="range"] {
-webkit-appearance: none;
appearance: none;
width: 100%;
height: 6px;
border-radius: 999px;
background: rgba(28, 39, 51, 0.14);
outline: none;
cursor: pointer;
}
input[type="range"].range-heat {
background: linear-gradient(90deg, #b9c4cf, #ffd9a0 55%, var(--orange) 100%);
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px; height: 18px;
border-radius: 50%;
background: var(--white);
border: 3px solid var(--orange);
box-shadow: var(--sh-sm);
transition: transform 0.12s ease;
}
input[type="range"]::-webkit-slider-thumb:hover { transform: scale(1.14); }
input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px;
border-radius: 50%;
background: var(--white);
border: 3px solid var(--orange);
}
input[type="range"]:focus-visible { outline: 3px solid var(--orange); outline-offset: 4px; }
.control-row { display: flex; align-items: center; gap: 14px; justify-content: space-between; }
.control-row-label { font-size: 13.5px; font-weight: 600; }
.switch {
flex: 0 0 auto;
width: 48px; height: 28px;
border-radius: 999px;
border: 1px solid var(--line-2);
background: rgba(28, 39, 51, 0.14);
position: relative;
cursor: pointer;
transition: background 0.2s ease;
padding: 0;
}
.switch .knob {
position: absolute;
top: 3px; left: 3px;
width: 20px; height: 20px;
border-radius: 50%;
background: var(--white);
box-shadow: var(--sh-sm);
transition: transform 0.2s cubic-bezier(0.22, 1, 0.36, 1);
}
.switch[aria-checked="true"] { background: var(--orange); border-color: var(--orange-d); }
.switch[aria-checked="true"] .knob { transform: translateX(20px); }
.switch:focus-visible { outline: 3px solid var(--orange); outline-offset: 3px; }
/* ---------- readouts ---------- */
.readouts {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 8px;
margin: 14px 0;
}
.readout {
padding: 9px 8px;
border-radius: var(--r-md);
background: var(--white);
border: 1px solid var(--line);
text-align: center;
}
.readout-k { display: block; font-size: 9.5px; letter-spacing: 0.1em; color: var(--muted); }
.readout-v { display: block; font-size: 16px; font-weight: 700; color: var(--ink); }
/* ---------- joint list ---------- */
.joint-list { display: grid; gap: 6px; }
.joint-row {
display: flex;
align-items: center;
gap: 9px;
padding: 7px 10px;
border-radius: var(--r-md);
background: var(--white);
border: 1px solid var(--line);
font-size: 12.5px;
transition: background 0.2s ease, border-color 0.2s ease;
}
.joint-row.done { background: rgba(47, 158, 111, 0.1); border-color: rgba(47, 158, 111, 0.35); }
.joint-id {
font-family: "JetBrains Mono", monospace;
font-weight: 700;
font-size: 11.5px;
color: var(--ink-2);
min-width: 42px;
}
.joint-name { flex: 1; min-width: 0; color: var(--ink-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.joint-badge {
font-family: "JetBrains Mono", monospace;
font-size: 9.5px;
font-weight: 700;
letter-spacing: 0.08em;
padding: 2px 7px;
border-radius: 999px;
background: rgba(28, 39, 51, 0.08);
color: var(--muted);
}
.joint-row.done .joint-badge { background: var(--ok); color: #fff; }
/* ---------- notice ---------- */
.notice {
margin-top: 14px;
padding: 11px 13px;
border-radius: var(--r-md);
background: var(--kraft-l);
border: 1px solid rgba(217, 199, 167, 0.9);
border-left: 4px solid var(--orange);
}
.notice strong { font-size: 12.5px; }
.notice p { margin: 3px 0 0; font-size: 12px; color: var(--ink-2); }
/* ---------- footer ---------- */
.footer {
display: flex;
flex-wrap: wrap;
gap: 10px;
justify-content: space-between;
align-items: center;
margin-top: 18px;
padding: 12px 18px;
border-radius: var(--r-lg);
font-size: 12px;
color: rgba(217, 199, 167, 0.78);
background: rgba(10, 30, 54, 0.5);
border: 1px solid rgba(217, 199, 167, 0.2);
}
.footer .mono { letter-spacing: 0.1em; font-weight: 700; color: var(--kraft); }
/* ---------- toast ---------- */
.toast-wrap {
position: fixed;
left: 50%;
bottom: 26px;
transform: translateX(-50%);
display: grid;
gap: 8px;
z-index: 50;
pointer-events: none;
}
.toast {
padding: 11px 18px;
border-radius: 999px;
font-size: 13.5px;
font-weight: 600;
color: var(--kraft-l);
background: rgba(10, 30, 54, 0.94);
border: 1px solid rgba(255, 107, 53, 0.6);
box-shadow: var(--sh-lg);
animation: toastIn 0.28s cubic-bezier(0.22, 1, 0.36, 1);
}
.toast.ok { border-color: rgba(47, 158, 111, 0.8); }
.toast.out { animation: toastOut 0.3s ease forwards; }
@keyframes toastIn {
from { opacity: 0; transform: translateY(14px) scale(0.96); }
to { opacity: 1; transform: none; }
}
@keyframes toastOut {
to { opacity: 0; transform: translateY(10px); }
}
/* ---------- responsive ---------- */
@media (max-width: 980px) {
.grid { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 520px) {
.shell { padding: 14px 12px 28px; }
.topbar { padding: 12px; }
.brand-mark { width: 38px; height: 38px; }
.stage-head { padding: 14px 14px 10px; }
.stage-head h1 { font-size: 18px; }
.stage-wrap { margin: 0 10px; }
.stage-foot { flex-direction: column; align-items: stretch; padding: 14px; gap: 12px; }
.stage-foot .btn { width: 100%; }
.panel { padding: 14px; }
.readouts { grid-template-columns: repeat(3, 1fr); gap: 6px; }
.readout-v { font-size: 14px; }
.iron { transform: translate(-10px, -84px) scale(0.8); transform-origin: 14px 92px; }
.toast { font-size: 12.5px; }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.001ms !important;
}
}/* Fenwick & Sale — Solder Bench 03
Canvas soldering-iron cursor effect: sparks, heat glow, cooling solder blobs. */
(function () {
"use strict";
var canvas = document.getElementById("board");
var ctx = canvas.getContext("2d");
var iron = document.getElementById("iron");
var stageWrap = canvas.parentElement;
var badge = document.getElementById("stageBadge");
var densityEl = document.getElementById("density");
var tempEl = document.getElementById("temp");
var gravityEl = document.getElementById("gravity");
var trailEl = document.getElementById("trailToggle");
var clearBtn = document.getElementById("clearBtn");
var densityVal = document.getElementById("densityVal");
var tempVal = document.getElementById("tempVal");
var gravityVal = document.getElementById("gravityVal");
var pCountEl = document.getElementById("pCount");
var bCountEl = document.getElementById("bCount");
var fpsEl = document.getElementById("fps");
var jointCountEl = document.getElementById("jointCount");
var progressFill = document.getElementById("progressFill");
var jointListEl = document.getElementById("jointList");
var toastWrap = document.getElementById("toastWrap");
var W = canvas.width, H = canvas.height;
var MAX_PARTICLES = 900;
var MAX_BLOBS = 90;
var HOLD_MS = 900; // hold time to complete a joint
var reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
/* ---------- joints ---------- */
var JOINTS = [
{ id: "J1", ref: "R14", name: "1k5 pull-up resistor", x: 170, y: 150, legDir: "up" },
{ id: "J2", ref: "C09", name: "100nF decoupling cap", x: 380, y: 118, legDir: "up" },
{ id: "J3", ref: "D03", name: "1N4148 signal diode", x: 610, y: 165, legDir: "right" },
{ id: "J4", ref: "Q02", name: "BC547 transistor base", x: 250, y: 380, legDir: "down" },
{ id: "J5", ref: "U01", name: "DIP-8 op-amp pin 3", x: 505, y: 415, legDir: "down" },
{ id: "J6", ref: "K07", name: "Screw terminal live", x: 735, y: 350, legDir: "right" }
];
JOINTS.forEach(function (j) { j.done = false; j.charge = 0; });
/* ---------- state ---------- */
var particles = [];
var blobs = [];
var tip = { x: W * 0.5, y: H * 0.55 };
var pointerInside = false;
var hot = false;
var keyboardMode = false;
var completed = 0;
var celebrated = false;
/* ---------- helpers ---------- */
function toast(msg, kind) {
var el = document.createElement("div");
el.className = "toast" + (kind ? " " + kind : "");
el.textContent = msg;
toastWrap.appendChild(el);
setTimeout(function () {
el.classList.add("out");
setTimeout(function () { el.remove(); }, 320);
}, 2600);
}
function heatMix() { return Number(tempEl.value) / 100; } // 0 cool silver → 1 hot orange
// Blend cool silver → hot orange based on the temperature slider.
function heatColor(t, lightness, alpha) {
var m = heatMix();
var h = 24 * m + 205 * (1 - m); // 205 (steel blue) → 24 (orange)
var s = 95 * m + 18 * (1 - m);
var l = lightness;
return "hsla(" + h.toFixed(0) + "," + s.toFixed(0) + "%," + l.toFixed(0) + "%," + alpha + ")";
}
function rand(a, b) { return a + Math.random() * (b - a); }
/* ---------- board rendering (static layer, cached) ---------- */
var board = document.createElement("canvas");
board.width = W; board.height = H;
drawBoard(board.getContext("2d"));
function drawBoard(c) {
var g = c.createLinearGradient(0, 0, W, H);
g.addColorStop(0, "#123024");
g.addColorStop(0.5, "#0d2419");
g.addColorStop(1, "#081711");
c.fillStyle = g;
c.fillRect(0, 0, W, H);
// solder-mask speckle
c.globalAlpha = 0.05;
for (var i = 0; i < 700; i++) {
c.fillStyle = i % 3 === 0 ? "#8fffcf" : "#000";
c.fillRect(Math.random() * W, Math.random() * H, 2, 2);
}
c.globalAlpha = 1;
// copper traces
c.lineCap = "round";
c.lineJoin = "round";
c.strokeStyle = "rgba(196,142,62,0.55)";
c.lineWidth = 5;
var traces = [
[[60, 470], [60, 150], [170, 150], [300, 150], [300, 90], [380, 90], [380, 118]],
[[170, 150], [170, 250], [250, 250], [250, 380]],
[[610, 165], [700, 165], [700, 260], [820, 260]],
[[505, 415], [505, 490], [300, 490], [300, 470], [60, 470]],
[[735, 350], [640, 350], [640, 415], [505, 415]],
[[380, 118], [470, 118], [470, 210], [610, 210], [610, 165]],
[[820, 90], [820, 200], [860, 200]]
];
traces.forEach(function (pts) {
c.beginPath();
c.moveTo(pts[0][0], pts[0][1]);
for (var k = 1; k < pts.length; k++) c.lineTo(pts[k][0], pts[k][1]);
c.stroke();
});
// via holes
c.fillStyle = "rgba(196,142,62,0.5)";
for (var v = 0; v < 26; v++) {
var vx = 40 + Math.random() * (W - 80), vy = 40 + Math.random() * (H - 80);
c.beginPath(); c.arc(vx, vy, 3.2, 0, Math.PI * 2); c.fill();
c.fillStyle = "rgba(8,23,17,0.9)";
c.beginPath(); c.arc(vx, vy, 1.4, 0, Math.PI * 2); c.fill();
c.fillStyle = "rgba(196,142,62,0.5)";
}
// silkscreen component outlines
c.strokeStyle = "rgba(226,240,232,0.42)";
c.lineWidth = 1.6;
c.font = "600 11px 'JetBrains Mono', monospace";
c.fillStyle = "rgba(226,240,232,0.55)";
JOINTS.forEach(function (j) {
var bx = j.x, by = j.y;
var ox = j.legDir === "right" ? 62 : 0;
var oy = j.legDir === "up" ? -62 : j.legDir === "down" ? 62 : 0;
c.strokeRect(bx + ox - 22, by + oy - 12, 44, 24);
c.beginPath();
c.moveTo(bx, by);
c.lineTo(bx + ox * 0.55, by + oy * 0.55);
c.strokeStyle = "rgba(190,198,205,0.75)";
c.lineWidth = 3;
c.stroke();
c.strokeStyle = "rgba(226,240,232,0.42)";
c.lineWidth = 1.6;
c.fillText(j.ref, bx + ox - 20, by + oy + 26);
});
}
function drawPads(c) {
JOINTS.forEach(function (j) {
// annular ring
c.beginPath();
c.arc(j.x, j.y, 15, 0, Math.PI * 2);
c.fillStyle = j.done ? "rgba(178,190,200,0.95)" : "rgba(150,110,50,0.9)";
c.fill();
c.strokeStyle = j.done ? "rgba(230,240,245,0.9)" : "rgba(226,240,232,0.35)";
c.lineWidth = 2;
c.stroke();
if (j.done) {
// solder fillet: cone of cooled tin
var fg = c.createRadialGradient(j.x - 4, j.y - 5, 1, j.x, j.y, 16);
fg.addColorStop(0, "#f2f6f8");
fg.addColorStop(0.5, "#b9c4cc");
fg.addColorStop(1, "#7d8891");
c.beginPath();
c.moveTo(j.x - 16, j.y + 9);
c.quadraticCurveTo(j.x, j.y - 17, j.x + 16, j.y + 9);
c.closePath();
c.fillStyle = fg;
c.fill();
} else {
// hole
c.beginPath();
c.arc(j.x, j.y, 5, 0, Math.PI * 2);
c.fillStyle = "#08170f";
c.fill();
// charge ring
if (j.charge > 0.01) {
c.beginPath();
c.arc(j.x, j.y, 19, -Math.PI / 2, -Math.PI / 2 + Math.PI * 2 * Math.min(1, j.charge));
c.strokeStyle = heatColor(1, 62, 0.95);
c.lineWidth = 4;
c.lineCap = "round";
c.stroke();
}
}
});
}
/* ---------- particles & blobs ---------- */
function emit(n) {
for (var i = 0; i < n && particles.length < MAX_PARTICLES; i++) {
var a = rand(-Math.PI, 0) + rand(-0.4, 0.4);
var sp = rand(40, 320);
particles.push({
x: tip.x + rand(-3, 3),
y: tip.y + rand(-3, 3),
vx: Math.cos(a) * sp,
vy: Math.sin(a) * sp,
life: rand(0.35, 1.15),
age: 0,
r: rand(0.8, 2.4)
});
}
}
function addBlob(x, y) {
if (blobs.length >= MAX_BLOBS) blobs.shift();
blobs.push({ x: x + rand(-4, 4), y: y + rand(-4, 4), r: rand(3, 7), age: 0, life: rand(1.6, 2.6) });
}
/* ---------- loop ---------- */
var last = performance.now();
var fpsAcc = 0, fpsFrames = 0, blobTimer = 0;
function frame(now) {
var dt = Math.min(0.05, (now - last) / 1000);
last = now;
// fps readout
fpsAcc += dt; fpsFrames++;
if (fpsAcc >= 0.5) {
fpsEl.textContent = Math.round(fpsFrames / fpsAcc);
fpsAcc = 0; fpsFrames = 0;
}
var trails = trailEl.getAttribute("aria-checked") === "true" && !reduceMotion;
if (trails) {
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(board, 0, 0);
// re-draw faded previous frame is expensive; instead we fade particle alpha
} else {
ctx.globalCompositeOperation = "source-over";
ctx.drawImage(board, 0, 0);
}
drawPads(ctx);
// --- cooling solder blobs ---
ctx.globalCompositeOperation = "source-over";
for (var b = blobs.length - 1; b >= 0; b--) {
var bl = blobs[b];
bl.age += dt;
var k = bl.age / bl.life;
if (k >= 1) { blobs.splice(b, 1); continue; }
// bright yellow → dull grey
var hue = 48 - 8 * k;
var sat = 100 * (1 - k);
var lig = 78 - 34 * k;
ctx.beginPath();
ctx.arc(bl.x, bl.y, bl.r * (1 - 0.15 * k), 0, Math.PI * 2);
ctx.fillStyle = "hsla(" + hue + "," + sat.toFixed(0) + "%," + lig.toFixed(0) + "%,0.95)";
ctx.fill();
if (k < 0.5) {
ctx.beginPath();
ctx.arc(bl.x, bl.y, bl.r * 2.6, 0, Math.PI * 2);
ctx.fillStyle = "hsla(42,100%,60%," + (0.18 * (1 - k * 2)).toFixed(3) + ")";
ctx.fill();
}
}
// --- heat glow at tip ---
if (pointerInside || keyboardMode) {
var gr = ctx.createRadialGradient(tip.x, tip.y, 0, tip.x, tip.y, hot ? 92 : 46);
gr.addColorStop(0, heatColor(1, 72, hot ? 0.5 : 0.24));
gr.addColorStop(0.45, heatColor(1, 55, hot ? 0.2 : 0.09));
gr.addColorStop(1, heatColor(1, 45, 0));
ctx.globalCompositeOperation = "lighter";
ctx.beginPath();
ctx.arc(tip.x, tip.y, hot ? 92 : 46, 0, Math.PI * 2);
ctx.fillStyle = gr;
ctx.fill();
ctx.globalCompositeOperation = "source-over";
}
// --- particles ---
if (!reduceMotion) {
if (hot) {
emit(Math.round(Number(densityEl.value) * dt * 60));
blobTimer += dt;
if (blobTimer > 0.09) { blobTimer = 0; addBlob(tip.x, tip.y); }
}
var g = Number(gravityEl.value) / 100 * 900;
ctx.globalCompositeOperation = "lighter";
for (var i = particles.length - 1; i >= 0; i--) {
var p = particles[i];
p.age += dt;
if (p.age >= p.life) { particles.splice(i, 1); continue; }
p.vy += g * dt;
p.vx *= 0.995;
p.x += p.vx * dt;
p.y += p.vy * dt;
if (p.y > H - 2 && p.vy > 0) { p.y = H - 2; p.vy *= -0.32; p.vx *= 0.6; }
var t = 1 - p.age / p.life;
var alpha = (trails ? t * t : t) * 0.95;
ctx.beginPath();
ctx.arc(p.x, p.y, p.r * (0.4 + t * 0.6), 0, Math.PI * 2);
ctx.fillStyle = t > 0.75
? "rgba(255,252,240," + alpha.toFixed(3) + ")"
: heatColor(t, 50 + t * 30, alpha.toFixed(3));
ctx.fill();
if (trails) {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(p.x - p.vx * dt * 3, p.y - p.vy * dt * 3);
ctx.strokeStyle = heatColor(t, 60, (alpha * 0.5).toFixed(3));
ctx.lineWidth = p.r * 0.7;
ctx.stroke();
}
}
ctx.globalCompositeOperation = "source-over";
} else if (pointerInside || keyboardMode) {
// static glow fallback already drawn above
ctx.beginPath();
ctx.arc(tip.x, tip.y, hot ? 12 : 6, 0, Math.PI * 2);
ctx.fillStyle = heatColor(1, 78, 0.9);
ctx.fill();
}
// --- joint charging ---
if (hot) {
JOINTS.forEach(function (j) {
if (j.done) return;
var d = Math.hypot(j.x - tip.x, j.y - tip.y);
if (d < 26) {
j.charge += (dt * 1000) / HOLD_MS;
if (j.charge >= 1) completeJoint(j);
} else {
j.charge = Math.max(0, j.charge - dt * 0.9);
}
});
} else {
JOINTS.forEach(function (j) { if (!j.done) j.charge = Math.max(0, j.charge - dt * 1.2); });
}
pCountEl.textContent = particles.length;
bCountEl.textContent = blobs.length;
requestAnimationFrame(frame);
}
function completeJoint(j) {
j.done = true;
j.charge = 1;
completed++;
for (var i = 0; i < 10; i++) addBlob(j.x, j.y);
if (!reduceMotion) emit(60);
renderJoints();
jointCountEl.textContent = completed;
var pct = (completed / JOINTS.length) * 100;
progressFill.style.width = pct + "%";
progressFill.classList.toggle("full", completed === JOINTS.length);
badge.textContent = "JOINT " + j.id + " OK";
badge.className = "stage-badge mono done";
if (completed === JOINTS.length && !celebrated) {
celebrated = true;
toast("All 6 of 6 joints soldered — board ready for continuity test.", "ok");
} else {
toast(j.ref + " " + j.name + " — joint set.", "ok");
}
}
/* ---------- joint list UI ---------- */
function renderJoints() {
jointListEl.innerHTML = "";
JOINTS.forEach(function (j) {
var row = document.createElement("div");
row.className = "joint-row" + (j.done ? " done" : "");
row.innerHTML =
'<span class="joint-id">' + j.id + " · " + j.ref + "</span>" +
'<span class="joint-name">' + j.name + "</span>" +
'<span class="joint-badge">' + (j.done ? "SET" : "OPEN") + "</span>";
jointListEl.appendChild(row);
});
}
/* ---------- pointer ---------- */
function toLocal(e) {
var r = canvas.getBoundingClientRect();
return {
x: (e.clientX - r.left) * (W / r.width),
y: (e.clientY - r.top) * (H / r.height)
};
}
function moveIron() {
var r = canvas.getBoundingClientRect();
var sx = r.width / W;
iron.style.transform = "translate(" + (tip.x * sx - 14) + "px," + (tip.y * (r.height / H) - 92) + "px)";
}
function setHot(v) {
hot = v;
iron.classList.toggle("hot", v);
if (v) {
badge.textContent = "IRON DOWN · " + Math.round(280 + heatMix() * 120) + " °C";
badge.className = "stage-badge mono hot";
} else if (completed < JOINTS.length) {
badge.textContent = "READY";
badge.className = "stage-badge mono";
}
}
stageWrap.addEventListener("pointerenter", function () {
pointerInside = true;
iron.classList.add("on");
});
stageWrap.addEventListener("pointerleave", function () {
pointerInside = false;
iron.classList.remove("on");
setHot(false);
});
stageWrap.addEventListener("pointermove", function (e) {
var p = toLocal(e);
tip.x = Math.max(0, Math.min(W, p.x));
tip.y = Math.max(0, Math.min(H, p.y));
keyboardMode = false;
pointerInside = true;
iron.classList.add("on");
moveIron();
});
stageWrap.addEventListener("pointerdown", function (e) {
e.preventDefault();
var p = toLocal(e);
tip.x = p.x; tip.y = p.y;
pointerInside = true;
iron.classList.add("on");
moveIron();
setHot(true);
if (stageWrap.setPointerCapture && e.pointerId != null) {
try { stageWrap.setPointerCapture(e.pointerId); } catch (err) { /* noop */ }
}
});
window.addEventListener("pointerup", function () { setHot(false); });
window.addEventListener("pointercancel", function () { setHot(false); });
window.addEventListener("resize", moveIron);
/* ---------- keyboard ---------- */
var KEYS = { ArrowUp: [0, -1], ArrowDown: [0, 1], ArrowLeft: [-1, 0], ArrowRight: [1, 0] };
canvas.addEventListener("focus", function () {
keyboardMode = true;
iron.classList.add("on");
moveIron();
});
canvas.addEventListener("blur", function () {
keyboardMode = false;
iron.classList.remove("on");
setHot(false);
});
canvas.addEventListener("keydown", function (e) {
if (KEYS[e.key]) {
e.preventDefault();
var step = e.shiftKey ? 6 : 22;
tip.x = Math.max(0, Math.min(W, tip.x + KEYS[e.key][0] * step));
tip.y = Math.max(0, Math.min(H, tip.y + KEYS[e.key][1] * step));
keyboardMode = true;
moveIron();
} else if (e.key === " " || e.key === "Spacebar") {
e.preventDefault();
if (!hot) setHot(true);
}
});
canvas.addEventListener("keyup", function (e) {
if (e.key === " " || e.key === "Spacebar") setHot(false);
});
/* ---------- controls ---------- */
densityEl.addEventListener("input", function () { densityVal.textContent = densityEl.value; });
tempEl.addEventListener("input", function () {
tempVal.textContent = Math.round(220 + heatMix() * 200) + " °C";
});
gravityEl.addEventListener("input", function () {
gravityVal.textContent = (Number(gravityEl.value) / 100).toFixed(2);
});
trailEl.addEventListener("click", function () {
var on = trailEl.getAttribute("aria-checked") === "true";
trailEl.setAttribute("aria-checked", String(!on));
toast(!on ? "Trail persistence on — sparks leave smoke ghosting." : "Trail persistence off.");
});
clearBtn.addEventListener("click", function () {
particles.length = 0;
blobs.length = 0;
completed = 0;
celebrated = false;
JOINTS.forEach(function (j) { j.done = false; j.charge = 0; });
jointCountEl.textContent = "0";
progressFill.style.width = "0%";
progressFill.classList.remove("full");
badge.textContent = "READY";
badge.className = "stage-badge mono";
renderJoints();
toast("Board cleared — six joints reopened.");
});
/* ---------- init ---------- */
renderJoints();
tempVal.textContent = Math.round(220 + heatMix() * 200) + " °C";
gravityVal.textContent = (Number(gravityEl.value) / 100).toFixed(2);
moveIron();
if (reduceMotion) {
badge.textContent = "STATIC MODE";
toast("Reduced motion detected — sparks disabled, static heat glow only.");
}
requestAnimationFrame(function (t) { last = t; requestAnimationFrame(frame); });
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fenwick & Sale — Solder Bench 03</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="shell">
<header class="topbar">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="26" height="26">
<path d="M4 27l4-1 1-4 13-13 3 3L12 25l-4 1z" fill="#ff6b35"/>
<path d="M22 6l4-4 4 4-4 4z" fill="#d9c7a7"/>
</svg>
</span>
<span>
<strong>Fenwick & Sale</strong>
<small>Repair Bench · Station 03</small>
</span>
</div>
<div class="topbar-meta">
<span class="chip"><i class="dot" aria-hidden="true"></i> Iron 340 °C</span>
<span class="chip mono">PCB-1147-B</span>
<span class="chip mono">Rev 2.4</span>
</div>
</header>
<main class="grid">
<section class="stage-card" aria-label="Soldering practice board">
<div class="stage-head">
<h1>Solder the six joints</h1>
<p>Press and hold the iron tip over a pad. Keyboard: arrow keys to move, <kbd>Space</kbd> to solder.</p>
</div>
<div class="stage-wrap">
<canvas id="board" class="board" width="900" height="560"
tabindex="0" role="application"
aria-label="Soldering board. Use arrow keys to move the iron tip and space to solder a joint.">
</canvas>
<div class="iron" id="iron" aria-hidden="true">
<svg viewBox="0 0 120 120" width="120" height="120">
<defs>
<linearGradient id="gBody" x1="0" y1="0" x2="1" y2="1">
<stop offset="0" stop-color="#4a5663"/>
<stop offset="1" stop-color="#1c2733"/>
</linearGradient>
<linearGradient id="gTip" x1="0" y1="1" x2="1" y2="0">
<stop offset="0" stop-color="#ffd9a0"/>
<stop offset="0.5" stop-color="#c98a4b"/>
<stop offset="1" stop-color="#8a6236"/>
</linearGradient>
</defs>
<g transform="translate(2,2)">
<rect x="46" y="8" width="24" height="52" rx="9" fill="url(#gBody)" transform="rotate(-40 58 34)"/>
<rect x="49" y="30" width="17" height="12" rx="4" fill="#ff6b35" transform="rotate(-40 58 34)"/>
<path d="M40 58 L24 82 L14 96 L22 88 L46 70 Z" fill="url(#gTip)"/>
<circle class="tip-hot" cx="16" cy="94" r="4" fill="#ffb56b"/>
</g>
</svg>
</div>
<div class="stage-badge mono" id="stageBadge">READY</div>
</div>
<div class="stage-foot">
<div class="progress" role="group" aria-label="Joints completed">
<div class="progress-label">
<span>Joints completed</span>
<strong class="mono"><span id="jointCount">0</span> / 6</strong>
</div>
<div class="progress-track"><div class="progress-fill" id="progressFill" style="width:0%"></div></div>
</div>
<button class="btn btn-ghost" id="clearBtn" type="button">Clear board</button>
</div>
</section>
<aside class="panel" aria-label="Iron controls">
<h2 class="panel-title">Station controls</h2>
<div class="control">
<label for="density">Spark density <span class="val mono" id="densityVal">18</span></label>
<input id="density" type="range" min="4" max="40" value="18" step="1" />
<p class="hint">Particles emitted per frame while the tip is hot.</p>
</div>
<div class="control">
<label for="temp">Heat temperature <span class="val mono" id="tempVal">340 °C</span></label>
<input id="temp" type="range" min="0" max="100" value="62" step="1" class="range-heat" />
<p class="hint">Cool silver alloy through to hot orange flux burn.</p>
</div>
<div class="control">
<label for="gravity">Gravity <span class="val mono" id="gravityVal">0.42</span></label>
<input id="gravity" type="range" min="0" max="120" value="42" step="1" />
<p class="hint">How fast sparks arc back down to the board.</p>
</div>
<div class="control control-row">
<div>
<span class="control-row-label">Trail persistence</span>
<p class="hint">Leaves smoke ghosting between frames.</p>
</div>
<button class="switch" id="trailToggle" type="button" role="switch" aria-checked="true">
<span class="knob"></span>
</button>
</div>
<div class="readouts">
<div class="readout">
<span class="readout-k mono">PARTICLES</span>
<span class="readout-v mono" id="pCount">0</span>
</div>
<div class="readout">
<span class="readout-k mono">FPS</span>
<span class="readout-v mono" id="fps">—</span>
</div>
<div class="readout">
<span class="readout-k mono">BLOBS</span>
<span class="readout-v mono" id="bCount">0</span>
</div>
</div>
<div class="joint-list" id="jointList" aria-live="polite"></div>
<div class="notice">
<strong>Flux note</strong>
<p>Rosin core 60/40, 0.8 mm. Wipe the tip on the brass wool between joints — do not file it.</p>
</div>
</aside>
</main>
<footer class="footer">
<span class="mono">STEALTHIS · BENCH KIT</span>
<span>Illustrative UI only — follow real safety guidance when working with tools and electronics.</span>
</footer>
</div>
<div class="toast-wrap" id="toastWrap" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Soldering Spark Effect
The stage is a dark green solder-mask board drawn entirely in canvas — copper traces, via holes, silkscreen outlines and six through-hole pads with component legs, labelled R14, C09, D03, Q02, U01 and K07. The native cursor is hidden and replaced by an SVG soldering iron whose tip tracks the pointer; a soft radial heat glow follows it, widening and brightening the moment the iron goes down.
Pressing and holding (or dragging on touch) emits a capped burst of sparks that launch upward, arc back down under the gravity slider, and bounce once off the bottom edge before fading. Each hot frame also drops a molten solder blob that cools from bright yellow through amber to dull grey over roughly two seconds. Holding the tip over a pad fills a charge ring; once it closes, the pad gains a proper solder fillet, the joint row flips from OPEN to SET, the progress bar advances and a toast fires — with a distinct message at six of six.
The control panel exposes spark density, heat colour temperature from cool silver to hot orange, gravity, a trail-persistence toggle that adds streak tails and smoke ghosting, and a clear-board button that reopens every joint. Live readouts show particle count, blob count and measured FPS.
How this look is built
The static board is rasterised once into an offscreen canvas and blitted each frame, so the per-frame cost is only particles, blobs and the tip glow — far cheaper than re-stroking every trace. Sparks are drawn with globalCompositeOperation = "lighter" so overlapping hot pixels bloom toward white naturally instead of needing a blur pass, and both spark and glow colours are generated from a single HSL interpolation driven by the temperature slider, which is why one control repaints the entire effect. The loop is requestAnimationFrame with clamped delta timing so gravity stays consistent across refresh rates, and the array is hard-capped at 900 particles. Watch two things: additive blending is fill-rate bound, so keep radii small on mobile, and prefers-reduced-motion is checked up front to skip emission entirely and render a static glow instead.
Illustrative UI only — always follow real safety guidance when working with tools, electronics, or repairs.