DIY — Gear Assembly Loader
A blueprint-bench loading component built from procedurally generated SVG gears. A geometry function derives tooth profiles from module and tooth count so any pair meshes correctly, then five gears seat onto their pins with a spring-settle intro and turn in sync at true ratios from one animation loop. Ships three demos — a hero gear train, a compact three-gear inline spinner, and a determinate arc variant — plus tooth-count, speed, direction, exploded-spacing and brass/steel/copper finish controls.
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-1:0 1px 2px rgba(28,39,51,.06), 0 4px 14px rgba(28,39,51,.06);
--sh-2:0 10px 34px rgba(10,30,54,.16);
--spin:0deg;
}
*,*::before,*::after{box-sizing:border-box}
html,body{margin:0}
body{
background:var(--bg); color:var(--ink); line-height:1.5;
font-family:Inter,system-ui,sans-serif;
-webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale;
}
.mono{font-family:"JetBrains Mono",ui-monospace,monospace}
.wrap{width:min(1140px,100% - 2rem);margin-inline:auto}
h1,h2,h3{margin:0;line-height:1.15;letter-spacing:-.02em}
p{margin:0}
.muted{color:var(--muted)}
.sr-only{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
:where(a,button,input,[tabindex]):focus-visible{
outline:3px solid var(--orange); outline-offset:2px; border-radius:var(--r-sm);
}
/* ---------- topbar ---------- */
.topbar{
background:var(--blueprint-d); color:#e9f0f7; border-bottom:3px solid var(--orange);
position:sticky; top:0; z-index:20;
}
.topbar-in{display:flex;align-items:center;gap:1rem;padding:.7rem 0;flex-wrap:wrap}
.brand{display:flex;align-items:center;gap:.65rem;margin-right:auto}
.brand-mark{display:grid;place-items:center;width:44px;height:44px;border-radius:var(--r-sm);
background:rgba(255,255,255,.06);border:1px solid rgba(255,255,255,.12)}
.brand-txt b{display:block;font-size:.95rem;letter-spacing:.14em}
.brand-txt small{display:block;font-size:.7rem;color:#9fb4c9;font-family:"JetBrains Mono",monospace}
.topnav{display:flex;gap:.25rem;flex-wrap:wrap}
.topnav a{color:#c3d3e2;text-decoration:none;font-size:.82rem;font-weight:600;
padding:.4rem .7rem;border-radius:999px}
.topnav a:hover{background:rgba(255,255,255,.09);color:#fff}
.pill{font-size:.68rem;padding:.3rem .6rem;border-radius:999px;background:rgba(47,158,111,.18);
color:#8fe3c0;border:1px solid rgba(47,158,111,.4);letter-spacing:.08em}
/* ---------- hero ---------- */
.hero{display:grid;grid-template-columns:minmax(260px,.85fr) 1.15fr;gap:2rem;
align-items:center;padding:3rem 0 2rem}
.tag{display:inline-block;font-size:.68rem;letter-spacing:.16em;color:var(--orange-d);
background:var(--orange-soft);border:1px solid rgba(224,82,31,.28);
padding:.3rem .6rem;border-radius:999px;margin-bottom:.9rem}
.hero-copy h1{font-size:clamp(2rem,4.4vw,3.1rem);font-weight:800}
.hero-copy p{margin-top:.85rem;color:var(--ink-2);max-width:44ch}
.hero-stats{display:flex;gap:1.4rem;margin-top:1.4rem;flex-wrap:wrap}
.stat span{display:block;font-size:1.25rem;font-weight:700;color:var(--blueprint)}
.stat small{color:var(--muted);font-size:.72rem;text-transform:uppercase;letter-spacing:.09em}
.bench{border-radius:var(--r-lg);overflow:hidden;box-shadow:var(--sh-2);
border:1px solid rgba(10,30,54,.5);background:var(--blueprint)}
.bench-head,.bench-foot{display:flex;justify-content:space-between;align-items:center;gap:.6rem;
padding:.55rem .9rem;font-size:.7rem;letter-spacing:.1em;color:#a9c0d6;
background:var(--blueprint-d)}
.bench-foot{border-top:1px solid rgba(140,180,220,.16);flex-wrap:wrap}
.badge{font-family:"JetBrains Mono",monospace;font-size:.65rem;padding:.22rem .55rem;border-radius:999px;
background:rgba(255,107,53,.16);color:#ffb193;border:1px solid rgba(255,107,53,.4)}
.badge[data-state="run"]{background:rgba(47,158,111,.16);color:#8fe3c0;border-color:rgba(47,158,111,.45)}
.stage{
position:relative;
background-color:var(--blueprint);
background-image:
linear-gradient(var(--grid) 1px,transparent 1px),
linear-gradient(90deg,var(--grid) 1px,transparent 1px),
linear-gradient(var(--grid-major) 1px,transparent 1px),
linear-gradient(90deg,var(--grid-major) 1px,transparent 1px),
radial-gradient(circle at 50% 42%,var(--blueprint-l),var(--blueprint-d) 78%);
background-size:8px 8px,8px 8px,80px 80px,80px 80px,100% 100%;
}
.stage svg{display:block;width:100%;height:auto}
/* gear rendering */
.gear{transform-box:fill-box;transform-origin:center;
transform:rotate(calc(var(--spin) * var(--k,1)));}
.gear-node{transition:transform .18s ease}
.gear-body{stroke:rgba(10,20,32,.55);stroke-width:1.1}
.gear-rim{fill:none;stroke:rgba(255,255,255,.28);stroke-width:1}
.gear-hub{fill:rgba(10,26,44,.72);stroke:rgba(255,255,255,.22)}
.gear-spoke{stroke:rgba(10,26,44,.45);stroke-width:5;stroke-linecap:round}
.pin{fill:rgba(255,107,53,.9)}
.pin-halo{fill:none;stroke:rgba(255,107,53,.35);stroke-width:1;stroke-dasharray:3 4}
.axis{stroke:rgba(140,180,220,.35);stroke-width:1;stroke-dasharray:6 6}
.glabel{font-family:"JetBrains Mono",monospace;font-size:9px;fill:#a9c0d6;letter-spacing:.08em}
/* Placement is on .gear-pos (SVG transform attribute); this CSS transform is
only the seating animation, so the two never fight over the same element. */
.gear-node{
transform-box:fill-box;
transform-origin:center;
transform:translate(var(--tx,0px),var(--ty,0px)) scale(var(--sc,1));
opacity:var(--op,1);
}
.seating .gear-node{transition:transform .78s cubic-bezier(.18,1.5,.4,1),opacity .4s ease}
/* ---------- panel / controls ---------- */
.panel{background:var(--white);border:1px solid var(--line);border-radius:var(--r-lg);
padding:1.4rem;box-shadow:var(--sh-1);margin-top:1.6rem}
.panel h2{font-size:1.05rem;margin-bottom:1rem}
.controls{display:grid;grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:1.1rem}
.ctl label,.ctl .lbl{display:flex;justify-content:space-between;gap:.5rem;align-items:baseline;
font-size:.82rem;font-weight:600;color:var(--ink-2);margin-bottom:.5rem}
.ctl label b{color:var(--blueprint);font-size:.8rem}
.ctl small{display:block;margin-top:.35rem;color:var(--muted);font-size:.72rem}
input[type=range]{width:100%;appearance:none;height:6px;border-radius:999px;
background:linear-gradient(90deg,var(--orange),var(--orange-d)) no-repeat,
rgba(28,39,51,.12);
background-size:var(--fill,50%) 100%;}
input[type=range]::-webkit-slider-thumb{appearance:none;width:20px;height:20px;border-radius:50%;
background:var(--white);border:2px solid var(--orange-d);box-shadow:var(--sh-1);cursor:grab}
input[type=range]::-moz-range-thumb{width:18px;height:18px;border-radius:50%;
background:var(--white);border:2px solid var(--orange-d);cursor:grab}
input[type=range]:active::-webkit-slider-thumb{cursor:grabbing;transform:scale(1.07)}
.segs{display:flex;background:var(--kraft-l);border:1px solid var(--line);border-radius:999px;padding:3px}
.seg{flex:1;border:0;background:transparent;font:600 .78rem Inter,sans-serif;color:var(--ink-2);
padding:.42rem .3rem;border-radius:999px;cursor:pointer;transition:.16s}
.seg:hover{color:var(--ink)}
.seg.is-on{background:var(--blueprint);color:#fff;box-shadow:var(--sh-1)}
.btn-row{display:flex;gap:.5rem;flex-wrap:wrap}
.btn{border:1px solid var(--line-2);background:var(--white);color:var(--ink);
font:600 .82rem Inter,sans-serif;padding:.55rem .9rem;border-radius:var(--r-sm);cursor:pointer;
display:inline-flex;align-items:center;gap:.5rem;transition:.16s}
.btn:hover{border-color:var(--blueprint);transform:translateY(-1px)}
.btn:active{transform:translateY(0)}
.btn.primary{background:var(--orange);border-color:var(--orange-d);color:#fff}
.btn.primary:hover{background:var(--orange-d)}
.btn.ghost{background:var(--kraft-l)}
.btn[aria-pressed="true"]{background:var(--blueprint);border-color:var(--blueprint);color:#fff}
.btn[disabled]{opacity:.55;cursor:not-allowed;transform:none}
/* ---------- cards ---------- */
.grid-2{display:grid;grid-template-columns:1fr 1fr;gap:1.4rem;margin:1.6rem 0 2.4rem}
.card{background:var(--white);border:1px solid var(--line);border-radius:var(--r-lg);
padding:1.3rem;box-shadow:var(--sh-1)}
.card-h{display:flex;justify-content:space-between;align-items:center;gap:.6rem;margin-bottom:.6rem}
.card-h h3{font-size:1rem}
.chip{font-size:.64rem;letter-spacing:.1em;padding:.24rem .55rem;border-radius:999px;
background:var(--kraft-l);border:1px solid var(--line);color:var(--ink-2)}
.inline-row{display:flex;align-items:center;gap:.8rem;margin:1rem 0 1.1rem;flex-wrap:wrap}
.inline-note{font-size:.7rem;color:var(--muted);letter-spacing:.08em}
.mini-gears{display:inline-block;width:22px;height:22px;flex:none}
.mini-gears.sm{width:18px;height:18px}
.mini-gears svg{display:block;width:100%;height:100%}
.rows{list-style:none;margin:0;padding:0;border-top:1px solid var(--line)}
.rows li{display:flex;align-items:center;gap:.7rem;padding:.62rem .2rem;border-bottom:1px solid var(--line)}
.rows li:hover{background:var(--kraft-l)}
.rname{flex:1;font-size:.86rem;font-weight:500}
.st{font-size:.66rem;letter-spacing:.08em;padding:.2rem .5rem;border-radius:999px}
.st.run{background:rgba(255,107,53,.12);color:var(--orange-d)}
.st.ok{background:rgba(47,158,111,.14);color:var(--ok)}
.st.warn{background:rgba(217,138,43,.16);color:var(--warn)}
.dot{width:10px;height:10px;border-radius:50%;flex:none}
.dot.ok{background:var(--ok)} .dot.warn{background:var(--warn)}
/* ---------- progress ---------- */
.prog{display:flex;gap:1.3rem;align-items:center;margin-top:1rem;flex-wrap:wrap}
.prog-dial{position:relative;width:200px;flex:none}
.prog-dial svg{display:block;width:100%;height:auto}
.arc-track{fill:none;stroke:rgba(28,39,51,.1);stroke-width:10}
.arc-fill{fill:none;stroke:var(--orange);stroke-width:10;stroke-linecap:round;
transform:rotate(-90deg);transform-origin:100px 100px;
stroke-dasharray:527.79;stroke-dashoffset:527.79;transition:stroke-dashoffset .2s linear}
.prog-read{position:absolute;inset:0;display:grid;place-content:center;text-align:center;gap:.15rem}
.prog-read b{font-size:1.9rem;color:var(--blueprint)}
.prog-read small{font-size:.6rem;letter-spacing:.12em;color:var(--muted)}
.prog-side{flex:1;min-width:190px;display:grid;gap:.55rem}
.kv{display:flex;justify-content:space-between;gap:.6rem;font-size:.8rem;
padding-bottom:.4rem;border-bottom:1px dashed var(--line)}
.kv b{color:var(--blueprint)}
.foot-note{color:var(--muted);font-size:.78rem;border-left:3px solid var(--orange);
padding-left:.8rem;margin-bottom:2.5rem}
/* ---------- toast ---------- */
.toast-wrap{position:fixed;right:1rem;bottom:1rem;display:grid;gap:.5rem;z-index:60}
.toast{background:var(--blueprint-d);color:#e9f0f7;padding:.6rem .9rem;border-radius:var(--r-sm);
font-size:.8rem;box-shadow:var(--sh-2);border-left:3px solid var(--orange);
animation:tin .22s ease both}
@keyframes tin{from{opacity:0;transform:translateY(8px)}}
/* ---------- responsive ---------- */
@media (max-width:900px){
.hero{grid-template-columns:1fr;padding-top:2rem}
.grid-2{grid-template-columns:1fr}
}
@media (max-width:520px){
.wrap{width:min(1140px,100% - 1.2rem)}
.topnav{order:3;width:100%}
.hero-copy h1{font-size:2rem}
.hero-stats{gap:.9rem}
.panel,.card{padding:1rem;border-radius:var(--r-md)}
.prog{justify-content:center}
.prog-dial{width:170px}
.bench-foot{font-size:.62rem}
.toast-wrap{left:1rem;right:1rem}
}
@media (prefers-reduced-motion:reduce){
*{animation-duration:.001ms !important;transition-duration:.001ms !important}
.gear{transform:none}
.arc-fill{transition:none}
}/* DIY — Gear Assembly Loader
Procedural gear geometry + one rAF loop driving a single CSS custom property. */
(function () {
"use strict";
var reduced = window.matchMedia("(prefers-reduced-motion: reduce)");
/* ---------------- toast ---------------- */
var toastWrap = document.getElementById("toasts");
function toast(msg) {
if (!toastWrap) return;
var t = document.createElement("div");
t.className = "toast";
t.textContent = msg;
toastWrap.appendChild(t);
setTimeout(function () { t.remove(); }, 2600);
}
/* ---------------- geometry ----------------
Approximated involute flank: each tooth is built from points sampled
between the root circle and the addendum circle with a trapezoid profile
softened by a cosine ease, which reads as a real gear at UI sizes and
guarantees module-consistent meshing. */
function gearPath(teeth, module) {
var pitch = (teeth * module) / 2;
var add = pitch + module; // addendum (tip) radius
var ded = Math.max(pitch - 1.25 * module, module); // dedendum (root)
var step = (Math.PI * 2) / teeth;
var d = "";
// fraction of the angular pitch occupied by the tooth top
var topF = 0.36, riseF = 0.16;
for (var i = 0; i < teeth; i++) {
var a0 = i * step;
var pts = [
[a0, ded],
[a0 + step * riseF, add],
[a0 + step * (riseF + topF), add],
[a0 + step * (2 * riseF + topF), ded],
[a0 + step * 0.999, ded]
];
for (var p = 0; p < pts.length; p++) {
var x = Math.cos(pts[p][0]) * pts[p][1];
var y = Math.sin(pts[p][0]) * pts[p][1];
d += (d === "" ? "M" : "L") + x.toFixed(2) + " " + y.toFixed(2);
}
}
return d + "Z";
}
function pitchR(teeth, module) { return (teeth * module) / 2; }
/* ---------------- finishes ---------------- */
var FINISH = {
brass: { a: "#f6dfa0", b: "#c9a248", c: "#8a6a24", name: "BRASS C360" },
steel: { a: "#e6edf4", b: "#9fb0c1", c: "#5d6d7d", name: "STEEL 4140" },
copper: { a: "#f7c7a4", b: "#c9764a", c: "#8a4526", name: "COPPER C110" }
};
var finish = "brass";
function defsFor(id, f) {
return (
'<linearGradient id="' + id + '" x1="0" y1="0" x2="1" y2="1">' +
'<stop offset="0%" stop-color="' + f.a + '"/>' +
'<stop offset="45%" stop-color="' + f.b + '"/>' +
'<stop offset="100%" stop-color="' + f.c + '"/>' +
"</linearGradient>" +
'<radialGradient id="' + id + '-hub" cx="40%" cy="35%">' +
'<stop offset="0%" stop-color="' + f.a + '" stop-opacity=".85"/>' +
'<stop offset="100%" stop-color="' + f.c + '" stop-opacity=".95"/>' +
"</radialGradient>"
);
}
/* ---------------- state ---------------- */
var state = {
driverTeeth: 24,
module: 4,
speed: 1, // multiplier
dir: 1,
explode: 0,
angle: 0
};
var MODULE_MIN = 3.2;
var svg = document.getElementById("trainSvg");
var defs = document.getElementById("trainDefs");
var group = document.getElementById("trainGroup");
var stage = document.getElementById("stage");
var badge = document.getElementById("stateBadge");
var footMesh = document.getElementById("footMesh");
var TRAIN = [
{ ratio: 1.00, label: "G1 DRIVER" },
{ ratio: 0.62, label: "G2 IDLER" },
{ ratio: 1.30, label: "G3 REDUC" },
{ ratio: 0.75, label: "G4 IDLER" },
{ ratio: 1.05, label: "G5 OUTPUT" }
];
var nodes = []; // {g, cx, cy, k}
function buildTrain() {
var f = FINISH[finish];
defs.innerHTML = defsFor("gfill", f);
group.innerHTML = "";
nodes = [];
var mod = state.module;
var counts = TRAIN.map(function (t) {
return Math.max(8, Math.round(state.driverTeeth * t.ratio));
});
// lay out along a gentle arc, tangent circles + explode gap
var radii = counts.map(function (n) { return pitchR(n, mod); });
var xs = [];
var ys = [];
var x = radii[0];
for (var i = 0; i < counts.length; i++) {
if (i > 0) x += radii[i - 1] + radii[i] + state.explode;
xs.push(x);
ys.push(i % 2 === 0 ? 0 : -Math.min(18, mod * 4));
}
var total = xs[xs.length - 1] + radii[radii.length - 1];
var maxR = Math.max.apply(null, radii);
var pad = 26;
var w = total + pad * 2;
var h = maxR * 2 + pad * 2 + 40;
svg.setAttribute("viewBox", "0 0 " + w.toFixed(0) + " " + h.toFixed(0));
var midY = h / 2;
// shafts / centre axis line
var axis = document.createElementNS(svg.namespaceURI, "path");
axis.setAttribute("class", "axis");
var dAxis = "";
for (var a = 0; a < xs.length - 1; a++) {
dAxis += "M" + (xs[a] + pad) + " " + (midY + ys[a]) +
"L" + (xs[a + 1] + pad) + " " + (midY + ys[a + 1]);
}
axis.setAttribute("d", dAxis);
group.appendChild(axis);
var k = 1;
for (var j = 0; j < counts.length; j++) {
if (j > 0) k = -k * (counts[j - 1] / counts[j]);
var cx = xs[j] + pad, cy = midY + ys[j], r = radii[j], n = counts[j];
// Placement lives on its own wrapper: .gear-node carries a CSS transform
// for the seating animation, and a CSS transform would override a
// transform attribute set on the same element.
var pos = document.createElementNS(svg.namespaceURI, "g");
pos.setAttribute("class", "gear-pos");
pos.setAttribute("transform", "translate(" + cx.toFixed(1) + "," + cy.toFixed(1) + ")");
var node = document.createElementNS(svg.namespaceURI, "g");
node.setAttribute("class", "gear-node");
var halo = document.createElementNS(svg.namespaceURI, "circle");
halo.setAttribute("class", "pin-halo");
halo.setAttribute("r", (r + 6).toFixed(1));
node.appendChild(halo);
var spin = document.createElementNS(svg.namespaceURI, "g");
spin.setAttribute("class", "gear");
spin.style.setProperty("--k", k.toFixed(4));
var body = document.createElementNS(svg.namespaceURI, "path");
body.setAttribute("class", "gear-body");
body.setAttribute("fill", "url(#gfill)");
body.setAttribute("d", gearPath(n, mod));
spin.appendChild(body);
var rim = document.createElementNS(svg.namespaceURI, "circle");
rim.setAttribute("class", "gear-rim");
rim.setAttribute("r", Math.max(4, r - mod * 1.6).toFixed(1));
spin.appendChild(rim);
// spokes
var spokes = Math.min(6, Math.max(3, Math.round(r / 12)));
for (var s = 0; s < spokes; s++) {
var ang = (s / spokes) * Math.PI * 2;
var sp = document.createElementNS(svg.namespaceURI, "line");
sp.setAttribute("class", "gear-spoke");
sp.setAttribute("x1", (Math.cos(ang) * r * 0.24).toFixed(1));
sp.setAttribute("y1", (Math.sin(ang) * r * 0.24).toFixed(1));
sp.setAttribute("x2", (Math.cos(ang) * r * 0.72).toFixed(1));
sp.setAttribute("y2", (Math.sin(ang) * r * 0.72).toFixed(1));
spin.appendChild(sp);
}
var hub = document.createElementNS(svg.namespaceURI, "circle");
hub.setAttribute("class", "gear-hub");
hub.setAttribute("r", Math.max(4, r * 0.22).toFixed(1));
hub.setAttribute("fill", "url(#gfill-hub)");
spin.appendChild(hub);
node.appendChild(spin);
var pin = document.createElementNS(svg.namespaceURI, "circle");
pin.setAttribute("class", "pin");
pin.setAttribute("r", "3");
node.appendChild(pin);
var lbl = document.createElementNS(svg.namespaceURI, "text");
lbl.setAttribute("class", "glabel");
lbl.setAttribute("text-anchor", "middle");
lbl.setAttribute("y", (r + 20).toFixed(1));
lbl.textContent = TRAIN[j].label + " · z" + n;
node.appendChild(lbl);
pos.appendChild(node);
group.appendChild(pos);
nodes.push({ el: node, k: k, teeth: n });
}
var last = nodes[nodes.length - 1];
document.getElementById("statTeeth").textContent = String(counts[0]);
document.getElementById("statRatio").textContent =
"1 : " + Math.abs(last.k).toFixed(2);
footMesh.textContent = "MESH OK · " + FINISH[finish].name;
}
/* ---------------- assembly intro ---------------- */
var seated = false;
function playAssembly() {
if (!nodes.length) return;
seated = false;
if (badge) { badge.textContent = "SEATING…"; badge.removeAttribute("data-state"); }
stage.classList.remove("seating");
nodes.forEach(function (n, i) {
var dx = (i % 2 ? 1 : -1) * (140 + i * 30);
var dy = (i % 3 === 0 ? -1 : 1) * 120;
n.el.style.setProperty("--tx", dx + "px");
n.el.style.setProperty("--ty", dy + "px");
n.el.style.setProperty("--sc", "0.6");
n.el.style.setProperty("--op", "0");
});
if (reduced.matches) { settle(); return; }
// force reflow so the from-state is committed before transitioning
void stage.offsetWidth;
stage.classList.add("seating");
nodes.forEach(function (n, i) {
setTimeout(function () {
n.el.style.setProperty("--tx", "0px");
n.el.style.setProperty("--ty", "0px");
n.el.style.setProperty("--sc", "1");
n.el.style.setProperty("--op", "1");
}, 70 + i * 95);
});
setTimeout(settle, 70 + nodes.length * 95 + 700);
}
function settle() {
nodes.forEach(function (n) {
n.el.style.setProperty("--tx", "0px");
n.el.style.setProperty("--ty", "0px");
n.el.style.setProperty("--sc", "1");
n.el.style.setProperty("--op", "1");
});
seated = true;
if (badge) {
badge.textContent = reduced.matches ? "SEATED (STATIC)" : "RUNNING";
badge.setAttribute("data-state", "run");
}
}
/* ---------------- rAF loop ---------------- */
var last = 0;
function frame(ts) {
if (!last) last = ts;
var dt = Math.min(64, ts - last);
last = ts;
if (seated && !reduced.matches) {
// 42 rpm nominal at 1x → 252 deg/s
state.angle = (state.angle + (252 * state.speed * state.dir * dt) / 1000) % 360000;
document.documentElement.style.setProperty("--spin", state.angle.toFixed(2) + "deg");
tickMini(dt);
tickProgress(dt);
}
requestAnimationFrame(frame);
}
requestAnimationFrame(frame);
/* ---------------- controls ---------------- */
function fillRange(el) {
var min = +el.min, max = +el.max;
el.style.setProperty("--fill", (((+el.value - min) / (max - min)) * 100).toFixed(1) + "%");
}
var teeth = document.getElementById("teeth");
var speed = document.getElementById("speed");
var explode = document.getElementById("explode");
[teeth, speed, explode].forEach(fillRange);
teeth.addEventListener("input", function () {
fillRange(teeth);
state.driverTeeth = +teeth.value;
// keep the hero a constant physical size: shrink module as teeth grow
state.module = Math.max(MODULE_MIN, 96 / state.driverTeeth);
document.getElementById("teethOut").textContent = teeth.value;
buildTrain();
seated = true;
});
speed.addEventListener("input", function () {
fillRange(speed);
state.speed = +speed.value / 100;
document.getElementById("speedOut").textContent = state.speed.toFixed(2) + "×";
document.getElementById("statRpm").textContent =
Math.round(42 * state.speed) + " rpm";
});
explode.addEventListener("input", function () {
fillRange(explode);
state.explode = +explode.value;
document.getElementById("explodeOut").textContent = explode.value + " mm";
footMesh.textContent = state.explode > 2 ? "EXPLODED VIEW" : "MESH OK · " + FINISH[finish].name;
buildTrain();
seated = true;
});
document.querySelectorAll(".seg[data-finish]").forEach(function (b) {
b.addEventListener("click", function () {
document.querySelectorAll(".seg[data-finish]").forEach(function (o) {
o.classList.toggle("is-on", o === b);
o.setAttribute("aria-checked", o === b ? "true" : "false");
});
finish = b.dataset.finish;
buildTrain();
buildMinis();
buildProgGears();
seated = true;
toast("Finish set to " + FINISH[finish].name);
});
});
var revBtn = document.getElementById("reverseBtn");
revBtn.addEventListener("click", function () {
state.dir = -state.dir;
var on = state.dir < 0;
revBtn.setAttribute("aria-pressed", on ? "true" : "false");
toast(on ? "Drive reversed — counter-clockwise" : "Drive forward — clockwise");
});
document.getElementById("replayBtn").addEventListener("click", function () {
playAssembly();
toast("Replaying assembly sequence");
});
/* ---------------- mini inline loaders ---------------- */
function miniSvg() {
var f = FINISH[finish];
var id = "m" + Math.random().toString(36).slice(2, 8);
var mod = 1.9;
var a = gearPath(10, mod), b = gearPath(7, mod);
var ra = pitchR(10, mod), rb = pitchR(7, mod);
return (
'<svg viewBox="0 0 40 40" aria-hidden="true"><defs>' + defsFor(id, f) + "</defs>" +
'<g transform="translate(14,15)"><g class="gear" style="--k:1">' +
'<path d="' + a + '" fill="url(#' + id + ')"/><circle r="' + (ra * .22).toFixed(1) + '" fill="url(#' + id + '-hub)"/></g></g>' +
'<g transform="translate(' + (14 + ra + rb).toFixed(1) + ',15)"><g class="gear" style="--k:' + (-10 / 7).toFixed(3) + '">' +
'<path d="' + b + '" fill="url(#' + id + ')"/><circle r="' + (rb * .22).toFixed(1) + '" fill="url(#' + id + '-hub)"/></g></g>' +
'<g transform="translate(' + (14 + rb * .3).toFixed(1) + ',' + (15 + ra + rb) .toFixed(1) + ')"><g class="gear" style="--k:' + (-10 / 7).toFixed(3) + '">' +
'<path d="' + b + '" fill="url(#' + id + ')"/><circle r="' + (rb * .22).toFixed(1) + '" fill="url(#' + id + '-hub)"/></g></g>' +
"</svg>"
);
}
function buildMinis() {
document.querySelectorAll(".mini-gears").forEach(function (el) {
el.innerHTML = miniSvg();
});
}
function tickMini() { /* minis share --spin; nothing per-frame needed */ }
var saveBtn = document.getElementById("saveBtn");
var saveLabel = document.getElementById("saveLabel");
var saveNote = document.getElementById("saveNote");
var miniA = document.getElementById("miniA");
miniA.style.display = "none";
saveBtn.addEventListener("click", function () {
if (saveBtn.disabled) return;
saveBtn.disabled = true;
miniA.style.display = "inline-block";
saveLabel.textContent = "Saving…";
saveNote.textContent = "WRITING EEPROM";
setTimeout(function () {
miniA.style.display = "none";
saveLabel.textContent = "Save torque profile";
saveNote.textContent = "SAVED 14:07:22";
saveBtn.disabled = false;
toast("Torque profile TP-118 saved");
}, 2100);
});
/* ---------------- determinate variant ---------------- */
var arc = document.getElementById("arcFill");
var CIRC = 2 * Math.PI * 84;
var progGears = document.getElementById("progGears");
var progDefs = document.getElementById("progDefs");
var progVal = 0, progRun = false;
var pctEl = document.getElementById("progPct");
var a11y = document.getElementById("progA11y");
var progBtn = document.getElementById("progBtn");
function buildProgGears() {
var f = FINISH[finish];
progDefs.innerHTML = defsFor("pfill", f);
var mod = 3.4;
var big = gearPath(14, mod), small = gearPath(9, mod);
var rB = pitchR(14, mod), rS = pitchR(9, mod);
progGears.innerHTML =
'<g transform="translate(' + (100 - rS) + ',100)"><g class="gear" style="--k:1">' +
'<path d="' + big + '" fill="url(#pfill)"/><circle r="' + (rB * .24).toFixed(1) + '" fill="url(#pfill-hub)"/></g></g>' +
'<g transform="translate(' + (100 - rS + rB + rS) + ',100)"><g class="gear" style="--k:' + (-14 / 9).toFixed(3) + '">' +
'<path d="' + small + '" fill="url(#pfill)"/><circle r="' + (rS * .24).toFixed(1) + '" fill="url(#pfill-hub)"/></g></g>';
progGears.setAttribute("opacity", ".28");
}
function setProgress(v) {
progVal = Math.max(0, Math.min(100, v));
arc.style.strokeDashoffset = (CIRC * (1 - progVal / 100)).toFixed(2);
var pct = Math.round(progVal);
pctEl.textContent = pct + "%";
document.getElementById("kvBlocks").textContent = Math.round((pct / 100) * 512) + " / 512";
document.getElementById("kvTp").textContent =
(progRun ? (2.4 + Math.sin(pct / 7) * 0.6).toFixed(1) : "0.0") + " MB/s";
document.getElementById("kvEta").textContent =
progRun ? Math.max(1, Math.round((100 - pct) * 0.09)) + "s" : "—";
if (pct % 25 === 0) a11y.textContent = "Flashing " + pct + " percent complete";
}
arc.style.strokeDasharray = CIRC.toFixed(2);
setProgress(0);
function tickProgress(dt) {
if (!progRun) return;
setProgress(progVal + (dt / 1000) * 11 * state.speed);
if (progVal >= 100) {
progRun = false;
progBtn.textContent = "Flash again";
progBtn.disabled = false;
toast("Firmware KW-4.2.1 flashed · 512/512 blocks");
}
}
progBtn.addEventListener("click", function () {
if (progRun) return;
if (progVal >= 100) setProgress(0);
progRun = true;
progBtn.textContent = "Flashing…";
progBtn.disabled = true;
if (reduced.matches) {
// no rAF driving; step it on a timer instead
var iv = setInterval(function () {
setProgress(progVal + 8);
if (progVal >= 100) {
clearInterval(iv); progRun = false;
progBtn.textContent = "Flash again"; progBtn.disabled = false;
}
}, 260);
}
});
/* ---------------- reduced motion flag ---------------- */
function syncMotion() {
document.getElementById("motionFlag").textContent =
reduced.matches ? "MOTION: REDUCED" : "MOTION: ON";
}
if (reduced.addEventListener) reduced.addEventListener("change", function () {
syncMotion(); playAssembly();
});
/* ---------------- boot ---------------- */
state.module = Math.max(MODULE_MIN, 96 / state.driverTeeth);
buildTrain();
buildMinis();
buildProgGears();
syncMotion();
playAssembly();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DIY — Gear Assembly Loader</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>
<header class="topbar">
<div class="wrap topbar-in">
<div class="brand">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 40 40" width="34" height="34"><g fill="none" stroke="#ff6b35" stroke-width="2.4"><circle cx="20" cy="20" r="8"/><circle cx="20" cy="20" r="3"/><path d="M20 4v5M20 31v5M4 20h5M31 20h5M8.7 8.7l3.5 3.5M27.8 27.8l3.5 3.5M31.3 8.7l-3.5 3.5M12.2 27.8l-3.5 3.5"/></g></svg>
</span>
<span class="brand-txt">
<b>KRAFTWORKS</b>
<small>Bench Components · Rev. 4.2</small>
</span>
</div>
<nav class="topnav" aria-label="Sections">
<a href="#hero-demo">Hero train</a>
<a href="#inline-demo">Inline</a>
<a href="#progress-demo">Determinate</a>
</nav>
<span class="pill mono" id="motionFlag">MOTION: ON</span>
</div>
</header>
<main class="wrap">
<!-- ======================= HERO ======================= -->
<section class="hero" id="hero-demo">
<div class="hero-copy">
<span class="tag mono">PART No. GA-05 / GEAR TRAIN</span>
<h1>Gear Assembly Loader</h1>
<p>Five procedurally generated gears mesh on a blueprint bench. Tooth paths are computed from
module and tooth count so any pair meshes correctly, then a single animation loop drives every
rotation through one CSS custom property.</p>
<div class="hero-stats">
<div class="stat"><span class="mono" id="statTeeth">24</span><small>driver teeth</small></div>
<div class="stat"><span class="mono" id="statRatio">1 : 1.00</span><small>final ratio</small></div>
<div class="stat"><span class="mono" id="statRpm">42 rpm</span><small>driver speed</small></div>
</div>
</div>
<div class="bench" id="bench">
<div class="bench-head">
<span class="mono">ASSEMBLY VIEW · 1:1</span>
<span class="badge" id="stateBadge">SEATING…</span>
</div>
<div class="stage" id="stage">
<svg id="trainSvg" viewBox="0 0 640 340" role="img" aria-label="Animated train of five meshing gears">
<defs id="trainDefs"></defs>
<g id="trainGroup"></g>
</svg>
</div>
<div class="bench-foot mono">
<span>MODULE 4.0</span><span>PRESSURE ANGLE 20°</span><span id="footMesh">MESH OK</span>
</div>
</div>
</section>
<!-- ======================= CONTROLS ======================= -->
<section class="panel" aria-labelledby="ctrlH">
<h2 id="ctrlH">Bench controls</h2>
<div class="controls">
<div class="ctl">
<label for="teeth">Driver tooth count <b class="mono" id="teethOut">24</b></label>
<input id="teeth" type="range" min="10" max="40" step="1" value="24" />
<small>Regenerates the whole train live.</small>
</div>
<div class="ctl">
<label for="speed">Speed <b class="mono" id="speedOut">1.00×</b></label>
<input id="speed" type="range" min="0" max="250" step="1" value="100" />
<small>Angular velocity of the driver.</small>
</div>
<div class="ctl">
<label for="explode">Exploded spacing <b class="mono" id="explodeOut">0 mm</b></label>
<input id="explode" type="range" min="0" max="60" step="1" value="0" />
<small>Separates gears to expose the mesh.</small>
</div>
<div class="ctl">
<span class="lbl">Finish</span>
<div class="segs" role="radiogroup" aria-label="Metal finish">
<button class="seg is-on" role="radio" aria-checked="true" data-finish="brass">Brass</button>
<button class="seg" role="radio" aria-checked="false" data-finish="steel">Steel</button>
<button class="seg" role="radio" aria-checked="false" data-finish="copper">Copper</button>
</div>
</div>
<div class="ctl">
<span class="lbl">Drive</span>
<div class="btn-row">
<button class="btn" id="reverseBtn" aria-pressed="false">Reverse ⟲</button>
<button class="btn ghost" id="replayBtn">Replay assembly</button>
</div>
</div>
</div>
</section>
<!-- ======================= INLINE ======================= -->
<section class="grid-2" id="inline-demo">
<article class="card">
<header class="card-h"><h3>Compact inline loader</h3><span class="chip mono">3 GEARS · 28px</span></header>
<p class="muted">Three tiny gears sized for buttons and table rows. Drops into any inline flow
with no layout shift.</p>
<div class="inline-row">
<button class="btn primary" id="saveBtn">
<span class="mini-gears" id="miniA" aria-hidden="true"></span>
<span id="saveLabel">Save torque profile</span>
</button>
<span class="inline-note mono" id="saveNote">IDLE</span>
</div>
<ul class="rows">
<li><span class="mini-gears sm" data-mini aria-hidden="true"></span>
<span class="rname">Spindle calibration</span><span class="mono st run">RUNNING</span></li>
<li><span class="dot ok" aria-hidden="true"></span>
<span class="rname">Bearing seat check</span><span class="mono st ok">PASS</span></li>
<li><span class="mini-gears sm" data-mini aria-hidden="true"></span>
<span class="rname">Backlash sweep</span><span class="mono st run">RUNNING</span></li>
<li><span class="dot warn" aria-hidden="true"></span>
<span class="rname">Lubricant film</span><span class="mono st warn">RECHECK</span></li>
</ul>
</article>
<!-- ======================= PROGRESS ======================= -->
<article class="card" id="progress-demo">
<header class="card-h"><h3>Determinate variant</h3><span class="chip mono">ARC 0–100%</span></header>
<p class="muted">The gear train drives a stroke-dashoffset arc. Ratio between driver and the
arc pinion is fixed so the readout tracks real work, not a fake timer.</p>
<div class="prog">
<div class="prog-dial">
<svg viewBox="0 0 200 200" role="img" aria-label="Determinate gear progress dial">
<defs id="progDefs"></defs>
<circle class="arc-track" cx="100" cy="100" r="84" />
<circle class="arc-fill" id="arcFill" cx="100" cy="100" r="84" />
<g id="progGears"></g>
</svg>
<div class="prog-read">
<b class="mono" id="progPct" role="status" aria-live="polite">0%</b>
<small class="mono">FLASHING FIRMWARE</small>
</div>
</div>
<div class="prog-side">
<div class="kv"><span>Blocks written</span><b class="mono" id="kvBlocks">0 / 512</b></div>
<div class="kv"><span>Throughput</span><b class="mono" id="kvTp">0.0 MB/s</b></div>
<div class="kv"><span>Est. remaining</span><b class="mono" id="kvEta">—</b></div>
<button class="btn" id="progBtn">Start flash</button>
</div>
</div>
<div
class="sr-only" id="progA11y" aria-live="polite"></div>
</article>
</section>
<p class="foot-note">Illustrative UI only — always follow real safety guidance when working with
tools, electronics, or repairs.</p>
</main>
<div class="toast-wrap" id="toasts" aria-live="polite" aria-atomic="true"></div>
<script src="script.js"></script>
</body>
</html>Gear Assembly Loader
A workshop bench rendered on blueprint paper, with a five-gear train drawn entirely from generated
SVG. Nothing is hand-authored path data: a single gearPath(teeth, module) function samples a
softened involute-style tooth profile around the pitch circle, so a 34-tooth reducer and a 9-tooth
pinion built with the same module drop into mesh without any manual nudging. Gears are laid out
tangent to one another along a gentle zig-zag, each labelled with its part code and tooth count.
On load the gears fly in from off-stage, land on their orange pins with a small overshoot settle, and only then start turning. Rotation direction alternates down the train and speed scales by the inverse tooth ratio, so the output gear genuinely turns faster or slower than the driver depending on where the sliders sit. Bench controls change the driver tooth count (rebuilding the train live), the drive speed, the direction, an exploded-spacing slider that pulls the gears apart to expose the mesh, and a finish switcher for brass, steel and copper gradients.
Two smaller demos share the same machinery. A compact three-gear loader sized for buttons appears
inline while a save action runs and in table rows for in-flight jobs, and a determinate variant
drives a stroke-dashoffset progress arc from 0 to 100 percent with a monospace readout, block
counter, throughput and ETA.
How this look is built
Every gear on the page rotates from one CSS custom property: a single requestAnimationFrame loop
integrates elapsed time into --spin on :root, and each gear’s transform is
rotate(calc(var(--spin) * var(--k))) where --k is its signed ratio. That means one JS write per
frame instead of one per element, and the compositor handles the rest — far cheaper than the old
pattern of a keyframe animation per gear with hand-tuned durations. The assembly intro uses
transform and opacity transitions on per-gear --tx/--ty/--sc variables with a springy
cubic-bezier overshoot, committed after a forced reflow so the from-state is never skipped. The
progress arc is a plain circle with stroke-dasharray set to its circumference. Watch two things:
keep --spin unbounded-but-wrapped (it wraps at 360000deg) so float precision never degrades, and
under prefers-reduced-motion the loop stops writing --spin entirely, leaving a static seated
assembly while the determinate demo falls back to discrete timer steps.
Illustrative UI only — always follow real safety guidance when working with tools, electronics, or repairs.