DIY — Parts & Tools Checklist
A workshop-styled parts and tools checklist for a Bluetooth speaker build, with custom orange checkboxes, mono part references, quantity steppers, prices, and where-to-buy actions. Checked rows strike through and feed a sticky summary bar plus a hero progress ring that recompute the remaining cost and readiness percentage live. Includes a check-all-owned-tools quick action, print/export stub, and localStorage persistence — all in vanilla HTML, CSS, and JS.
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;
--shadow: 0 2px 6px rgba(28, 39, 51, 0.06), 0 12px 32px rgba(28, 39, 51, 0.08);
}
* , *::before, *::after { box-sizing: border-box; }
body {
margin: 0;
font-family: "Inter", system-ui, -apple-system, sans-serif;
background: var(--bg);
color: var(--ink);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
padding-bottom: 96px; /* room for sticky summary */
}
.mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
}
.sr-only {
position: absolute; width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden;
clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
.wrap {
max-width: 1060px;
margin: 0 auto;
padding: 28px 20px 40px;
}
/* ============ HERO ============ */
.hero {
position: relative;
background: 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);
background-size: 8px 8px, 8px 8px, 80px 80px, 80px 80px;
border-radius: var(--r-lg);
padding: 30px 32px 26px;
color: var(--kraft-l);
box-shadow: var(--shadow);
overflow: hidden;
}
.hero-grid-tag {
position: absolute;
top: 16px;
right: 18px;
display: flex;
gap: 8px;
}
.hero-grid-tag .mono {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.08em;
color: rgba(217, 199, 167, 0.75);
border: 1px solid rgba(217, 199, 167, 0.35);
border-radius: var(--r-sm);
padding: 3px 8px;
}
.hero-main {
display: flex;
align-items: center;
gap: 28px;
justify-content: space-between;
}
.hero-kicker {
margin: 0 0 6px;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--orange);
}
.hero-title {
margin: 0 0 10px;
font-size: clamp(26px, 4.5vw, 40px);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.04em;
line-height: 1.1;
color: var(--white);
}
.hero-sub {
margin: 0 0 14px;
max-width: 52ch;
font-size: 14.5px;
color: rgba(239, 230, 212, 0.85);
}
.hero-meta { display: flex; flex-wrap: wrap; gap: 8px; }
.meta-chip {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--kraft);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(217, 199, 167, 0.28);
border-radius: 999px;
padding: 4px 10px;
}
/* progress ring */
.hero-ring {
position: relative;
flex: 0 0 auto;
width: 120px;
height: 120px;
display: grid;
place-items: center;
}
.ring { transform: rotate(-90deg); }
.ring-track {
fill: none;
stroke: rgba(255, 255, 255, 0.12);
stroke-width: 10;
}
.ring-fill {
fill: none;
stroke: var(--orange);
stroke-width: 10;
stroke-linecap: round;
stroke-dasharray: 326.7; /* 2πr, r=52 */
stroke-dashoffset: 326.7;
transition: stroke-dashoffset 0.6s cubic-bezier(0.22, 1, 0.36, 1), stroke 0.3s;
}
.ring-fill.is-complete { stroke: var(--ok); }
.ring-label {
position: absolute;
inset: 0;
display: grid;
place-content: center;
text-align: center;
gap: 0;
}
.ring-label strong {
font-size: 22px;
font-weight: 700;
color: var(--white);
}
.ring-label span {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--kraft);
}
/* hero actions */
.hero-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 22px;
padding-top: 18px;
border-top: 1px dashed rgba(217, 199, 167, 0.35);
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
font: inherit;
font-size: 13.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.05em;
border-radius: var(--r-sm);
padding: 10px 16px;
border: 1px solid transparent;
cursor: pointer;
transition: transform 0.12s, background 0.15s, box-shadow 0.15s, color 0.15s, border-color 0.15s;
}
.btn:active { transform: translateY(1px) scale(0.99); }
.btn:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
.btn-orange {
background: var(--orange);
color: #fff;
box-shadow: 0 4px 14px rgba(255, 107, 53, 0.35);
}
.btn-orange:hover { background: var(--orange-d); }
.btn-ghost {
background: transparent;
color: var(--kraft-l);
border-color: rgba(239, 230, 212, 0.4);
}
.btn-ghost:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(239, 230, 212, 0.7);
}
.btn-danger-hover:hover {
color: #ffb4a8;
border-color: rgba(212, 80, 62, 0.7);
}
/* ============ PANELS ============ */
.columns {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
margin-top: 24px;
}
.panel {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
box-shadow: var(--shadow);
display: flex;
flex-direction: column;
overflow: hidden;
}
.panel-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 18px;
background:
linear-gradient(rgba(28, 39, 51, 0.045) 1px, transparent 1px),
linear-gradient(90deg, rgba(28, 39, 51, 0.045) 1px, transparent 1px),
var(--kraft-l);
background-size: 10px 10px, 10px 10px, auto;
border-bottom: 1px solid var(--line);
}
.panel-title {
margin: 0;
display: flex;
align-items: center;
gap: 10px;
font-size: 15px;
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.panel-num {
font-size: 11px;
font-weight: 700;
color: var(--orange-d);
border: 1.5px solid var(--orange);
border-radius: var(--r-sm);
padding: 2px 6px;
background: var(--orange-soft);
}
.panel-count {
font-size: 12px;
font-weight: 600;
color: var(--muted);
background: rgba(28, 39, 51, 0.05);
border-radius: 999px;
padding: 3px 9px;
}
.rows {
list-style: none;
margin: 0;
padding: 6px 0;
flex: 1;
}
/* ---- row ---- */
.row {
display: grid;
grid-template-columns: auto 1fr auto;
align-items: center;
gap: 12px;
padding: 11px 18px;
border-bottom: 1px dashed var(--line);
transition: background 0.15s;
}
.row:last-child { border-bottom: none; }
.row:hover { background: rgba(217, 199, 167, 0.14); }
.row.is-checked { background: rgba(47, 158, 111, 0.06); }
.row.is-checked:hover { background: rgba(47, 158, 111, 0.1); }
/* custom checkbox */
.chk {
position: relative;
display: inline-flex;
flex: 0 0 auto;
cursor: pointer;
}
.chk input {
position: absolute;
opacity: 0;
width: 100%;
height: 100%;
margin: 0;
cursor: pointer;
}
.chk-box {
width: 22px;
height: 22px;
border: 2px solid var(--line-2);
border-radius: var(--r-sm);
background: var(--white);
display: grid;
place-items: center;
transition: background 0.15s, border-color 0.15s, transform 0.12s;
}
.chk-box svg {
width: 13px;
height: 13px;
color: #fff;
opacity: 0;
transform: scale(0.4);
transition: opacity 0.15s, transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.chk:hover .chk-box { border-color: var(--orange); }
.chk input:focus-visible + .chk-box {
outline: 2px solid var(--orange);
outline-offset: 2px;
}
.chk input:checked + .chk-box {
background: var(--orange);
border-color: var(--orange-d);
}
.chk input:checked + .chk-box svg {
opacity: 1;
transform: scale(1);
}
/* row body */
.row-body { min-width: 0; }
.row-name {
display: block;
font-size: 14.5px;
font-weight: 600;
color: var(--ink);
transition: color 0.15s;
}
.row.is-checked .row-name {
text-decoration: line-through;
text-decoration-color: var(--orange);
text-decoration-thickness: 2px;
color: var(--muted);
}
.row-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 3px;
}
.badge {
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.04em;
border-radius: 4px;
padding: 1.5px 6px;
}
.badge-ref {
color: var(--blueprint);
background: rgba(15, 42, 74, 0.08);
border: 1px solid rgba(15, 42, 74, 0.16);
}
.badge-qty {
color: var(--orange-d);
background: var(--orange-soft);
border: 1px solid rgba(224, 82, 31, 0.25);
}
.badge-owned {
color: var(--ok);
background: rgba(47, 158, 111, 0.1);
border: 1px solid rgba(47, 158, 111, 0.3);
}
/* right side: price + actions */
.row-side {
display: flex;
align-items: center;
gap: 10px;
}
.row-price {
font-size: 13.5px;
font-weight: 700;
color: var(--ink-2);
min-width: 62px;
text-align: right;
transition: color 0.15s;
}
.row.is-checked .row-price { color: var(--muted); text-decoration: line-through; }
/* quantity stepper */
.stepper {
display: inline-flex;
align-items: center;
border: 1px solid var(--line-2);
border-radius: var(--r-sm);
overflow: hidden;
background: var(--white);
}
.step-btn {
font: inherit;
width: 24px;
height: 26px;
display: grid;
place-items: center;
border: none;
background: transparent;
color: var(--ink-2);
font-weight: 700;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.step-btn:hover { background: var(--orange-soft); color: var(--orange-d); }
.step-btn:focus-visible { outline: 2px solid var(--orange); outline-offset: -2px; }
.step-btn:disabled { opacity: 0.35; cursor: default; }
.step-btn:disabled:hover { background: transparent; color: var(--ink-2); }
.step-val {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 12px;
font-weight: 600;
min-width: 24px;
text-align: center;
border-left: 1px solid var(--line);
border-right: 1px solid var(--line);
align-self: stretch;
display: grid;
place-items: center;
}
/* buy link */
.buy-link {
display: grid;
place-items: center;
width: 30px;
height: 30px;
border: 1px solid var(--line);
border-radius: var(--r-sm);
background: transparent;
color: var(--muted);
cursor: pointer;
transition: color 0.15s, border-color 0.15s, background 0.15s;
}
.buy-link:hover {
color: var(--orange-d);
border-color: var(--orange);
background: var(--orange-soft);
}
.buy-link:focus-visible { outline: 2px solid var(--orange); outline-offset: 2px; }
.panel-foot {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 18px;
border-top: 2px dashed var(--line-2);
background: var(--kraft-l);
}
.foot-label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--ink-2);
}
.foot-total { font-size: 16px; font-weight: 700; color: var(--blueprint); }
.cut-note {
margin: 26px 0 0;
text-align: center;
font-size: 12px;
color: var(--muted);
letter-spacing: 0.06em;
user-select: none;
}
/* ============ STICKY SUMMARY ============ */
.summary {
position: fixed;
left: 0;
right: 0;
bottom: 0;
z-index: 40;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
background: var(--blueprint-d);
background-image:
linear-gradient(var(--grid) 1px, transparent 1px),
linear-gradient(90deg, var(--grid) 1px, transparent 1px);
background-size: 8px 8px, 8px 8px;
border-top: 3px solid var(--orange);
box-shadow: 0 -8px 24px rgba(10, 30, 54, 0.3);
}
.summary-inner {
max-width: 1060px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 18px;
}
.summary-stat {
display: flex;
flex-direction: column;
gap: 1px;
min-width: 90px;
}
.summary-label {
font-size: 10.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.12em;
color: var(--kraft);
}
.summary-stat strong {
font-size: 16px;
font-weight: 700;
color: var(--white);
}
.summary-price { color: var(--orange) !important; }
.summary-cost { text-align: right; align-items: flex-end; }
.summary-bar {
flex: 1;
height: 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
.summary-bar-fill {
height: 100%;
width: 0%;
border-radius: 999px;
background: linear-gradient(90deg, var(--orange-d), var(--orange));
transition: width 0.5s cubic-bezier(0.22, 1, 0.36, 1), background 0.3s;
}
.summary-bar-fill.is-complete {
background: linear-gradient(90deg, #237a55, var(--ok));
}
/* ============ TOAST ============ */
.toast {
position: fixed;
left: 50%;
bottom: 92px;
transform: translateX(-50%) translateY(16px);
z-index: 60;
background: var(--ink);
color: var(--kraft-l);
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.03em;
padding: 10px 18px;
border-radius: 999px;
border: 1px solid rgba(255, 107, 53, 0.5);
box-shadow: 0 10px 30px rgba(28, 39, 51, 0.35);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s, transform 0.25s;
max-width: min(90vw, 420px);
text-align: center;
}
.toast.show {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ============ RESPONSIVE ============ */
@media (max-width: 860px) {
.columns { grid-template-columns: 1fr; }
}
@media (max-width: 520px) {
.wrap { padding: 16px 12px 32px; }
.hero { padding: 22px 18px 20px; }
.hero-main { flex-direction: column-reverse; align-items: flex-start; gap: 16px; }
.hero-grid-tag { position: static; margin-bottom: 12px; }
.hero-ring { align-self: center; }
.hero-actions .btn { flex: 1 1 auto; justify-content: center; padding: 10px 10px; font-size: 12px; }
.row {
grid-template-columns: auto 1fr;
row-gap: 8px;
}
.row-side {
grid-column: 2;
justify-content: flex-end;
}
.row-price { min-width: 0; }
.summary-inner { gap: 12px; }
.summary-stat { min-width: 0; }
.summary-stat strong { font-size: 14px; }
.toast { bottom: 84px; }
}/* ============================================================
DIY — Parts & Tools Checklist
Bluetooth speaker build sheet. Vanilla JS, localStorage state.
============================================================ */
(() => {
"use strict";
const STORAGE_KEY = "diy-parts-list:bld-2041";
/* ---------- data ---------- */
const PARTS = [
{ id: "p-driver", name: 'Fenwick Audio 3" full-range driver (pair)', ref: "FA-3FR-8", price: 24.9, qty: 2, min: 2, max: 4, vendor: "Fenwick Audio" },
{ id: "p-amp", name: "HexTone 2×20W Class-D amp board", ref: "HT-AMP-2020", price: 18.5, qty: 1, min: 1, max: 2, vendor: "HexTone Labs" },
{ id: "p-bt", name: "BlueCore 5.3 receiver module", ref: "BC-53-RX", price: 11.2, qty: 1, min: 1, max: 2, vendor: "BlueCore" },
{ id: "p-batt", name: "VoltCrate 18650 cells (2600 mAh)", ref: "VC-18650-26", price: 6.4, qty: 4, min: 2, max: 8, vendor: "VoltCrate" },
{ id: "p-bms", name: "VoltCrate 3S BMS protection board", ref: "VC-BMS-3S", price: 7.8, qty: 1, min: 1, max: 2, vendor: "VoltCrate" },
{ id: "p-ply", name: 'Birch plywood sheet 12 mm (600×400)', ref: "PLY-B12-64", price: 14.0, qty: 1, min: 1, max: 3, vendor: "Timberline Supply" },
{ id: "p-grille", name: "Steel speaker grille mesh (black)", ref: "GRL-STL-BK", price: 5.6, qty: 2, min: 2, max: 4, vendor: "Timberline Supply" },
{ id: "p-knob", name: "Machined aluminum volume knob", ref: "KNB-AL-28", price: 4.2, qty: 1, min: 1, max: 2, vendor: "HexTone Labs" },
{ id: "p-wire", name: "Silicone hookup wire kit, 18 AWG", ref: "WIR-18-KIT", price: 8.9, qty: 1, min: 1, max: 3, vendor: "VoltCrate" },
{ id: "p-hw", name: "M3 hardware pack (screws + inserts)", ref: "HW-M3-MIX", price: 3.5, qty: 1, min: 1, max: 3, vendor: "Timberline Supply" },
];
const TOOLS = [
{ id: "t-iron", name: "Soldering iron (temp-controlled)", ref: "TL-SLDR-60", price: 32.0, vendor: "BenchMate" },
{ id: "t-jig", name: "Jigsaw with fine wood blade", ref: "TL-JIG-01", price: 45.0, vendor: "Timberline Supply" },
{ id: "t-drill", name: "Cordless drill + brad point bits", ref: "TL-DRL-12V", price: 58.0, vendor: "BenchMate" },
{ id: "t-clamp", name: "Bar clamps, 300 mm (set of 4)", ref: "TL-CLMP-4", price: 21.0, vendor: "Timberline Supply" },
{ id: "t-multi", name: "Digital multimeter", ref: "TL-DMM-830", price: 16.5, vendor: "VoltCrate" },
{ id: "t-strip", name: "Wire stripper / crimper", ref: "TL-STRP-01", price: 9.8, vendor: "BenchMate" },
{ id: "t-sand", name: "Sanding block + 120/240 grit", ref: "TL-SND-KIT", price: 6.2, vendor: "Timberline Supply" },
{ id: "t-glue", name: "Wood glue + flush trim saw", ref: "TL-GLU-SAW", price: 11.4, vendor: "Timberline Supply" },
];
/* ---------- state ---------- */
// state = { checked: {id:true}, qty: {id:number} }
let state = { checked: {}, qty: {} };
function loadState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return;
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
state.checked = parsed.checked && typeof parsed.checked === "object" ? parsed.checked : {};
state.qty = parsed.qty && typeof parsed.qty === "object" ? parsed.qty : {};
}
} catch (_) { /* corrupted state — start fresh */ }
}
function saveState() {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch (_) { /* storage unavailable (private mode) */ }
}
const qtyOf = (part) => {
const q = state.qty[part.id];
return Number.isInteger(q) ? Math.min(part.max, Math.max(part.min, q)) : part.qty;
};
const money = (n) => "$" + n.toFixed(2);
/* ---------- toast ---------- */
const toastEl = document.getElementById("toast");
let toastTimer = null;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toastEl.classList.remove("show"), 2400);
}
/* ---------- render ---------- */
const partsList = document.getElementById("partsList");
const toolsList = document.getElementById("toolsList");
const CHECK_SVG =
'<svg viewBox="0 0 24 24" aria-hidden="true"><path fill="currentColor" d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>';
const LINK_SVG =
'<svg viewBox="0 0 24 24" width="15" height="15" aria-hidden="true"><path fill="currentColor" d="M7 7h5v2H9v6h6v-3h2v5H7zm7-2h5v5h-2V8.41l-5.3 5.3-1.41-1.42L15.59 7H14z"/></svg>';
function buildRow(item, isPart) {
const li = document.createElement("li");
li.className = "row";
li.dataset.id = item.id;
const checked = !!state.checked[item.id];
if (checked) li.classList.add("is-checked");
const qty = isPart ? qtyOf(item) : 1;
li.innerHTML = `
<label class="chk">
<input type="checkbox" ${checked ? "checked" : ""}
aria-label="${isPart ? "Acquired" : "Owned"}: ${item.name}" data-action="toggle" />
<span class="chk-box">${CHECK_SVG}</span>
</label>
<div class="row-body">
<span class="row-name">${item.name}</span>
<span class="row-meta">
<span class="badge badge-ref mono">${item.ref}</span>
${isPart
? `<span class="badge badge-qty mono" data-role="qty-badge">×${qty}</span>`
: `<span class="badge badge-owned mono" data-role="owned-badge" ${checked ? "" : "hidden"}>OWNED</span>`}
</span>
</div>
<div class="row-side">
${isPart ? `
<span class="stepper" role="group" aria-label="Quantity for ${item.name}">
<button type="button" class="step-btn" data-action="dec" aria-label="Decrease quantity">−</button>
<span class="step-val" data-role="qty-val" aria-live="polite">${qty}</span>
<button type="button" class="step-btn" data-action="inc" aria-label="Increase quantity">+</button>
</span>` : ""}
<span class="row-price mono" data-role="price">${money(item.price * qty)}</span>
<button type="button" class="buy-link" data-action="buy"
aria-label="Where to buy ${item.name}" title="Where to buy">${LINK_SVG}</button>
</div>`;
return li;
}
function renderLists() {
partsList.replaceChildren(...PARTS.map((p) => buildRow(p, true)));
toolsList.replaceChildren(...TOOLS.map((t) => buildRow(t, false)));
}
/* ---------- totals / summary ---------- */
const el = (id) => document.getElementById(id);
const RING_CIRC = 2 * Math.PI * 52;
function updateTotals() {
let partsChecked = 0, toolsChecked = 0;
let partsRemaining = 0, toolsRemaining = 0;
for (const p of PARTS) {
const cost = p.price * qtyOf(p);
if (state.checked[p.id]) partsChecked++;
else partsRemaining += cost;
}
for (const t of TOOLS) {
if (state.checked[t.id]) toolsChecked++;
else toolsRemaining += t.price;
}
const totalItems = PARTS.length + TOOLS.length;
const totalChecked = partsChecked + toolsChecked;
const remaining = partsRemaining + toolsRemaining;
const pct = totalItems ? Math.round((totalChecked / totalItems) * 100) : 0;
el("partsCount").textContent = `${partsChecked}/${PARTS.length}`;
el("toolsCount").textContent = `${toolsChecked}/${TOOLS.length}`;
el("partsTotal").textContent = money(partsRemaining);
el("toolsTotal").textContent = money(toolsRemaining);
el("sumChecked").textContent = `${totalChecked} / ${totalItems}`;
el("sumRemaining").textContent = money(remaining);
const bar = el("sumBar");
bar.style.width = pct + "%";
bar.classList.toggle("is-complete", pct === 100);
const ring = el("ringFill");
ring.style.strokeDashoffset = String(RING_CIRC * (1 - pct / 100));
ring.classList.toggle("is-complete", pct === 100);
el("ringPct").textContent = pct + "%";
}
/* ---------- row interactions (event delegation) ---------- */
function findItem(id) {
return PARTS.find((p) => p.id === id) || TOOLS.find((t) => t.id === id) || null;
}
function setChecked(row, item, on) {
state.checked[item.id] = on;
if (!on) delete state.checked[item.id];
row.classList.toggle("is-checked", on);
const ownedBadge = row.querySelector('[data-role="owned-badge"]');
if (ownedBadge) ownedBadge.hidden = !on;
saveState();
updateTotals();
}
function setQty(row, part, next) {
const q = Math.min(part.max, Math.max(part.min, next));
state.qty[part.id] = q;
row.querySelector('[data-role="qty-val"]').textContent = String(q);
row.querySelector('[data-role="qty-badge"]').textContent = "×" + q;
row.querySelector('[data-role="price"]').textContent = money(part.price * q);
const [dec, inc] = row.querySelectorAll(".step-btn");
dec.disabled = q <= part.min;
inc.disabled = q >= part.max;
saveState();
updateTotals();
}
function onListEvent(e) {
const actionEl = e.target.closest("[data-action]");
if (!actionEl) return;
const row = actionEl.closest(".row");
const item = findItem(row.dataset.id);
if (!item) return;
const action = actionEl.dataset.action;
if (action === "toggle" && e.type === "change") {
setChecked(row, item, actionEl.checked);
} else if (e.type === "click") {
if (action === "inc") setQty(row, item, qtyOf(item) + 1);
else if (action === "dec") setQty(row, item, qtyOf(item) - 1);
else if (action === "buy") toast(`${item.ref} — stocked by ${item.vendor} (demo link)`);
}
}
for (const list of [partsList, toolsList]) {
list.addEventListener("click", onListEvent);
list.addEventListener("change", onListEvent);
}
/* ---------- header actions ---------- */
el("btnOwnTools").addEventListener("click", () => {
let flipped = 0;
for (const t of TOOLS) {
if (!state.checked[t.id]) { state.checked[t.id] = true; flipped++; }
}
if (!flipped) { toast("All tools already marked as owned"); return; }
// sync DOM
for (const row of toolsList.querySelectorAll(".row")) {
row.classList.add("is-checked");
row.querySelector('input[type="checkbox"]').checked = true;
const badge = row.querySelector('[data-role="owned-badge"]');
if (badge) badge.hidden = false;
}
saveState();
updateTotals();
toast(`Marked ${flipped} tool${flipped > 1 ? "s" : ""} as owned`);
});
el("btnPrint").addEventListener("click", () => {
toast("Build sheet BLD-2041 queued for print / PDF export (demo)");
});
el("btnReset").addEventListener("click", () => {
state = { checked: {}, qty: {} };
try { localStorage.removeItem(STORAGE_KEY); } catch (_) {}
renderLists();
syncStepperDisabled();
updateTotals();
toast("Checklist reset — fresh build sheet");
});
/* ---------- init ---------- */
function syncStepperDisabled() {
for (const row of partsList.querySelectorAll(".row")) {
const part = PARTS.find((p) => p.id === row.dataset.id);
if (!part) continue;
const q = qtyOf(part);
const [dec, inc] = row.querySelectorAll(".step-btn");
dec.disabled = q <= part.min;
inc.disabled = q >= part.max;
}
}
loadState();
renderLists();
syncStepperDisabled();
updateTotals();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>DIY — Parts & Tools Checklist</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>
<main class="wrap">
<!-- ============ HERO / PROJECT HEADER ============ -->
<header class="hero" aria-labelledby="project-title">
<div class="hero-grid-tag" aria-hidden="true">
<span class="mono">BLD-2041</span>
<span class="mono">REV C</span>
</div>
<div class="hero-main">
<div class="hero-copy">
<p class="hero-kicker mono">Project build sheet // Workshop 7</p>
<h1 id="project-title" class="hero-title">Bluetooth Speaker Build</h1>
<p class="hero-sub">
Portable 20W stereo speaker on a birch-ply enclosure. Check off parts as they arrive
and mark the tools you already own — the summary bar tracks what's left to buy.
</p>
<div class="hero-meta">
<span class="meta-chip mono">EST. TIME · 2 weekends</span>
<span class="meta-chip mono">SKILL · Intermediate</span>
<span class="meta-chip mono">v1.3</span>
</div>
</div>
<div class="hero-ring" role="img" aria-label="Build readiness progress ring" aria-describedby="ring-caption">
<svg class="ring" viewBox="0 0 120 120" width="120" height="120" aria-hidden="true">
<circle class="ring-track" cx="60" cy="60" r="52" />
<circle class="ring-fill" id="ringFill" cx="60" cy="60" r="52" />
</svg>
<div class="ring-label">
<strong id="ringPct" class="mono">0%</strong>
<span>ready</span>
</div>
<p id="ring-caption" class="sr-only">Percentage of checklist items acquired</p>
</div>
</div>
<div class="hero-actions">
<button type="button" class="btn btn-orange" id="btnOwnTools">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M9 16.17 4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
Check all owned tools
</button>
<button type="button" class="btn btn-ghost" id="btnPrint">
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M19 8H5c-1.66 0-3 1.34-3 3v6h4v4h12v-4h4v-6c0-1.66-1.34-3-3-3M8 5h8v3H8zm8 14H8v-5h8zm3-7c-.55 0-1-.45-1-1s.45-1 1-1 1 .45 1 1-.45 1-1 1"/></svg>
Print / export
</button>
<button type="button" class="btn btn-ghost btn-danger-hover" id="btnReset">Reset list</button>
</div>
</header>
<!-- ============ CHECKLIST ============ -->
<div class="columns">
<!-- PARTS -->
<section class="panel" aria-labelledby="parts-heading">
<header class="panel-head">
<h2 id="parts-heading" class="panel-title">
<span class="panel-num mono" aria-hidden="true">01</span> Parts
</h2>
<span class="panel-count mono" id="partsCount">0/0</span>
</header>
<ul class="rows" id="partsList" aria-label="Parts to purchase"></ul>
<footer class="panel-foot">
<span class="foot-label">Parts subtotal (remaining)</span>
<strong class="mono foot-total" id="partsTotal">$0.00</strong>
</footer>
</section>
<!-- TOOLS -->
<section class="panel" aria-labelledby="tools-heading">
<header class="panel-head">
<h2 id="tools-heading" class="panel-title">
<span class="panel-num mono" aria-hidden="true">02</span> Tools
</h2>
<span class="panel-count mono" id="toolsCount">0/0</span>
</header>
<ul class="rows" id="toolsList" aria-label="Tools required"></ul>
<footer class="panel-foot">
<span class="foot-label">Tools subtotal (remaining)</span>
<strong class="mono foot-total" id="toolsTotal">$0.00</strong>
</footer>
</section>
</div>
<p class="cut-note" aria-hidden="true">✂ — — — — — — — — cut here & tape to the workbench — — — — — — — —</p>
</main>
<!-- ============ STICKY SUMMARY BAR ============ -->
<div class="summary" role="status" aria-live="polite">
<div class="summary-inner">
<div class="summary-stat">
<span class="summary-label">Checked</span>
<strong class="mono" id="sumChecked">0 / 0</strong>
</div>
<div class="summary-bar" aria-hidden="true">
<div class="summary-bar-fill" id="sumBar"></div>
</div>
<div class="summary-stat summary-cost">
<span class="summary-label">Left to buy</span>
<strong class="mono summary-price" id="sumRemaining">$0.00</strong>
</div>
</div>
</div>
<div class="toast mono" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Parts & Tools Checklist
A build-sheet style checklist for a fictional Bluetooth speaker project, split into two panels: Parts and Tools. Every row combines a custom safety-orange checkbox, the item name, a mono part reference badge, a quantity badge, a live price, and a “where to buy” link that fires a toast. Parts rows also carry a +/- quantity stepper that adjusts the line price and every dependent total in real time.
Checking a row strikes it through and instantly recomputes the panel subtotals, the sticky summary bar (items checked, remaining cost, progress bar), and the blueprint-hero progress ring showing overall readiness. A Check all owned tools quick action flips the whole tools panel at once, and Reset returns the sheet to factory state.
All state — checked items and chosen quantities — persists to localStorage, so a refresh picks up exactly where the build left off. The whole component is vanilla HTML/CSS/JS on a navy blueprint-grid aesthetic with kraft-paper panels and dashed “cut here” details.
Illustrative UI only — always follow real safety guidance when working with tools, electronics, or repairs.