DIY — Build Log
Hackaday-style chronological build log for a fictional self-balancing robot project, set on a blueprint-grid hero with a maker-workshop palette. A dashed vertical timeline holds eight dated entry cards with prose, media placeholders, and mono tags; fail entries get danger badges plus lesson-learned callouts while milestones glow green. Includes filter chips, a follow button with live count, a mono status card for hours and cost, and a sticky IntersectionObserver mini-map.
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-sm: 0 1px 3px rgba(28, 39, 51, 0.08), 0 1px 2px rgba(28, 39, 51, 0.05);
--shadow-md: 0 6px 20px rgba(28, 39, 51, 0.1), 0 2px 6px rgba(28, 39, 51, 0.06);
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
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;
-moz-osx-font-smoothing: grayscale;
}
.mono {
font-family: "JetBrains Mono", ui-monospace, monospace;
}
/* ===== Hero ===== */
.hero {
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);
background-size: 8px 8px, 8px 8px, 80px 80px, 80px 80px;
color: var(--white);
padding: 56px 24px 72px;
border-bottom: 4px solid var(--orange);
}
.hero-inner {
max-width: 1080px;
margin: 0 auto;
}
.hero-top {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 22px;
}
.hero-badge {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.12em;
padding: 6px 12px;
border: 1px solid rgba(140, 180, 220, 0.4);
border-radius: var(--r-sm);
color: var(--kraft);
background: rgba(10, 30, 54, 0.6);
}
.hero-badge--live {
color: #7fe0b4;
border-color: rgba(47, 158, 111, 0.55);
display: inline-flex;
align-items: center;
gap: 8px;
}
.pulse-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--ok);
animation: pulse 1.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(47, 158, 111, 0.55); }
50% { box-shadow: 0 0 0 6px rgba(47, 158, 111, 0); }
}
.hero-title {
margin: 0 0 12px;
font-size: clamp(2rem, 5.5vw, 3.4rem);
font-weight: 800;
text-transform: uppercase;
letter-spacing: 0.06em;
line-height: 1.1;
}
.hero-sub {
margin: 0 0 28px;
max-width: 620px;
color: var(--kraft-l);
font-size: 1.02rem;
}
.hero-sub strong { color: var(--white); }
.hero-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 16px;
}
.btn {
font: inherit;
cursor: pointer;
border: none;
border-radius: var(--r-sm);
font-weight: 700;
}
.btn-follow {
display: inline-flex;
align-items: center;
gap: 9px;
padding: 12px 22px;
background: var(--orange);
color: var(--blueprint-d);
text-transform: uppercase;
letter-spacing: 0.08em;
font-size: 0.85rem;
box-shadow: 0 4px 0 var(--orange-d);
transition: transform 0.12s ease, box-shadow 0.12s ease, background 0.15s ease;
}
.btn-follow:hover { background: #ff7d4d; }
.btn-follow:active {
transform: translateY(3px);
box-shadow: 0 1px 0 var(--orange-d);
}
.btn-follow:focus-visible {
outline: 3px solid var(--kraft);
outline-offset: 2px;
}
.btn-follow[aria-pressed="true"] {
background: var(--kraft);
box-shadow: 0 4px 0 #b8a67f;
}
.btn-follow-icon { flex: none; }
.follower-count {
color: var(--kraft-l);
font-size: 0.92rem;
}
.follower-count .mono {
color: var(--white);
font-weight: 700;
}
/* ===== Status card ===== */
.status-card {
max-width: 1080px;
margin: -36px auto 0;
padding: 0 24px;
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 1px;
position: relative;
z-index: 2;
}
.status-cell {
background: var(--white);
border: 1px solid var(--line);
padding: 18px 20px;
display: flex;
flex-direction: column;
gap: 4px;
box-shadow: var(--shadow-sm);
}
.status-cell:first-child { border-radius: var(--r-md) 0 0 var(--r-md); }
.status-cell:last-child { border-radius: 0 var(--r-md) var(--r-md) 0; }
.status-label {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.1em;
color: var(--muted);
}
.status-value {
font-size: 1.15rem;
font-weight: 700;
color: var(--ink);
}
.status-value.mono { font-size: 1.08rem; }
.status-value--state {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.98rem;
}
.ok-dot {
flex: none;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--ok);
}
/* ===== Filters ===== */
.filters {
max-width: 1080px;
margin: 28px auto 8px;
padding: 0 24px;
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.chip {
font: inherit;
font-size: 0.82rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
padding: 8px 16px;
border-radius: 999px;
border: 1.5px solid var(--line-2);
background: var(--white);
color: var(--ink-2);
cursor: pointer;
transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
}
.chip:hover { border-color: var(--orange); color: var(--orange-d); }
.chip:active { transform: scale(0.96); }
.chip:focus-visible {
outline: 3px solid var(--orange);
outline-offset: 2px;
}
.chip.is-active {
background: var(--blueprint);
border-color: var(--blueprint);
color: var(--white);
}
/* ===== Layout ===== */
.log-layout {
max-width: 1080px;
margin: 0 auto;
padding: 16px 24px 64px;
display: grid;
grid-template-columns: minmax(0, 1fr) 148px;
gap: 32px;
align-items: start;
}
/* ===== Timeline ===== */
.timeline {
position: relative;
display: flex;
flex-direction: column;
gap: 28px;
}
.entry {
display: grid;
grid-template-columns: 34px minmax(0, 1fr);
gap: 0;
}
.entry-rail {
position: relative;
}
.entry-rail::before {
content: "";
position: absolute;
left: 10px;
top: 0;
bottom: -28px;
width: 2px;
background: repeating-linear-gradient(
to bottom,
var(--line-2) 0 6px,
transparent 6px 12px
);
}
.entry:last-of-type .entry-rail::before { bottom: 0; }
.entry-node {
position: absolute;
left: 3px;
top: 26px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--bg);
border: 3px solid var(--muted);
transition: border-color 0.2s ease, transform 0.2s ease;
}
.entry--milestone .entry-node { border-color: var(--ok); }
.entry--fail .entry-node { border-color: var(--danger); }
.entry.is-in-view .entry-node {
border-color: var(--orange);
transform: scale(1.25);
}
.entry-card {
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 22px 24px;
box-shadow: var(--shadow-sm);
transition: box-shadow 0.2s ease, transform 0.2s ease, border-color 0.2s ease;
}
.entry-card:hover {
box-shadow: var(--shadow-md);
transform: translateY(-2px);
}
.entry--fail .entry-card { border-left: 4px solid var(--danger); }
.entry--milestone .entry-card { border-left: 4px solid var(--ok); }
.entry-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
margin-bottom: 10px;
}
.entry-date {
font-size: 0.78rem;
font-weight: 600;
letter-spacing: 0.1em;
color: var(--muted);
}
.badge {
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.12em;
padding: 4px 10px;
border-radius: var(--r-sm);
}
.badge-update {
background: var(--kraft-l);
color: var(--ink-2);
border: 1px solid var(--kraft);
}
.badge-fail {
background: rgba(212, 80, 62, 0.12);
color: #a83527;
border: 1px solid rgba(212, 80, 62, 0.4);
}
.badge-milestone {
background: rgba(47, 158, 111, 0.12);
color: #1e7350;
border: 1px solid rgba(47, 158, 111, 0.4);
}
.entry-title {
margin: 0 0 10px;
font-size: 1.28rem;
font-weight: 800;
letter-spacing: 0.01em;
line-height: 1.25;
}
.entry-prose {
margin: 0 0 16px;
color: var(--ink-2);
font-size: 0.95rem;
}
/* Media placeholder */
.entry-media {
margin: 0 0 16px;
}
.media-ph {
position: relative;
height: 180px;
border-radius: var(--r-sm);
border: 2px dashed var(--line-2);
background-color: var(--kraft-l);
background-image:
linear-gradient(rgba(28, 39, 51, 0.06) 1px, transparent 1px),
linear-gradient(90deg, rgba(28, 39, 51, 0.06) 1px, transparent 1px);
background-size: 20px 20px;
display: flex;
align-items: flex-end;
overflow: hidden;
}
.media-ph::before {
content: "";
position: absolute;
inset: 0;
background: radial-gradient(circle at 30% 35%, rgba(255, 107, 53, 0.14), transparent 55%);
}
.media-ph--b, .media-ph--d, .media-ph--f {
background-color: #f3ded9;
}
.media-ph--e, .media-ph--h {
background-color: #ddeee5;
}
.media-tag {
position: relative;
margin: 10px;
font-size: 0.7rem;
font-weight: 600;
padding: 4px 8px;
background: var(--blueprint);
color: var(--kraft-l);
border-radius: var(--r-sm);
}
.entry-media figcaption {
margin-top: 8px;
font-size: 0.8rem;
color: var(--muted);
font-style: italic;
}
/* Lesson callout */
.lesson {
margin: 0 0 16px;
padding: 14px 16px;
background: var(--orange-soft);
border-left: 4px solid var(--orange);
border-radius: 0 var(--r-sm) var(--r-sm) 0;
}
.lesson-label {
display: block;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.14em;
color: var(--orange-d);
margin-bottom: 4px;
}
.lesson p {
margin: 0;
font-size: 0.9rem;
color: var(--ink);
font-weight: 500;
}
/* Tags */
.entry-tags {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.tag {
font-size: 0.72rem;
font-weight: 600;
padding: 4px 10px;
border-radius: var(--r-sm);
background: var(--bg);
border: 1px solid var(--line);
color: var(--ink-2);
transition: background 0.15s ease, border-color 0.15s ease;
}
.tag:hover { border-color: var(--orange); background: var(--orange-soft); }
/* Hidden by filter */
.entry.is-filtered {
display: none;
}
.timeline-empty {
text-align: center;
padding: 48px 24px;
border: 2px dashed var(--line-2);
border-radius: var(--r-md);
color: var(--muted);
font-weight: 500;
}
/* ===== Mini-map ===== */
.minimap {
position: sticky;
top: 24px;
background: var(--white);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 14px;
box-shadow: var(--shadow-sm);
}
.minimap-title {
display: block;
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.16em;
color: var(--muted);
margin-bottom: 10px;
padding-left: 6px;
}
.minimap-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.minimap-item {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
font: inherit;
border: none;
background: transparent;
padding: 7px 6px;
border-radius: var(--r-sm);
cursor: pointer;
transition: background 0.15s ease;
}
.minimap-item:hover { background: var(--kraft-l); }
.minimap-item:focus-visible {
outline: 2px solid var(--orange);
outline-offset: 1px;
}
.minimap-item.is-active {
background: var(--blueprint);
}
.minimap-item.is-active .mm-date { color: var(--white); }
.minimap-item.is-hidden { display: none; }
.mm-dot {
flex: none;
width: 9px;
height: 9px;
border-radius: 50%;
}
.mm-dot--update { background: var(--muted); }
.mm-dot--fail { background: var(--danger); }
.mm-dot--milestone { background: var(--ok); }
.mm-date {
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.06em;
color: var(--ink-2);
}
/* ===== Footer ===== */
.page-footer {
max-width: 1080px;
margin: 0 auto;
padding: 24px;
border-top: 1px solid var(--line);
display: flex;
justify-content: space-between;
flex-wrap: wrap;
gap: 8px;
color: var(--muted);
font-size: 0.8rem;
}
.page-footer .mono { letter-spacing: 0.1em; }
/* ===== Toast ===== */
.toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: var(--blueprint-d);
color: var(--kraft-l);
font-family: "JetBrains Mono", ui-monospace, monospace;
font-size: 0.8rem;
font-weight: 600;
padding: 12px 20px;
border-radius: var(--r-sm);
border-left: 4px solid var(--orange);
box-shadow: var(--shadow-md);
opacity: 0;
pointer-events: none;
transition: opacity 0.25s ease, transform 0.25s ease;
z-index: 50;
max-width: calc(100vw - 48px);
}
.toast.is-visible {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
/* ===== Reduced motion ===== */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.pulse-dot { animation: none; }
* { transition-duration: 0.01ms !important; }
}
/* ===== Responsive ===== */
@media (max-width: 860px) {
.log-layout {
grid-template-columns: minmax(0, 1fr);
}
.minimap {
position: sticky;
top: 0;
order: -1;
z-index: 10;
display: flex;
align-items: center;
gap: 10px;
padding: 10px 14px;
border-radius: 0 0 var(--r-md) var(--r-md);
overflow-x: auto;
}
.minimap-title { margin: 0; white-space: nowrap; }
.minimap-list {
flex-direction: row;
gap: 4px;
}
.minimap-item { padding: 6px 8px; white-space: nowrap; }
.status-card {
grid-template-columns: repeat(2, 1fr);
gap: 10px;
}
.status-cell,
.status-cell:first-child,
.status-cell:last-child {
border-radius: var(--r-md);
}
}
@media (max-width: 520px) {
.hero { padding: 40px 18px 60px; }
.hero-title { letter-spacing: 0.03em; }
.status-card { padding: 0 18px; }
.filters,
.log-layout,
.page-footer { padding-left: 18px; padding-right: 18px; }
.entry {
grid-template-columns: 24px minmax(0, 1fr);
}
.entry-rail::before { left: 7px; }
.entry-node { left: 0; width: 14px; height: 14px; }
.entry-card { padding: 16px 16px; }
.entry-title { font-size: 1.1rem; }
.media-ph { height: 140px; }
.mm-date { display: none; }
.minimap-item { padding: 8px; }
.mm-dot { width: 11px; height: 11px; }
}/* DIY — Build Log interactions
- Follow button with live follower count
- Filter chips (All / Milestones / Fails / Updates)
- Sticky mini-map synced via IntersectionObserver + click-to-scroll
- toast() helper for feedback
*/
(function () {
"use strict";
/* ---------- toast helper ---------- */
var toastEl = document.getElementById("toast");
var toastTimer = null;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("is-visible");
}, 2400);
}
/* ---------- follow button ---------- */
var followBtn = document.getElementById("followBtn");
var followLabel = document.getElementById("followLabel");
var followerCountEl = document.getElementById("followerCount");
var followers = 1284;
var following = false;
function renderFollowers() {
followerCountEl.textContent = followers.toLocaleString("en-US");
}
followBtn.addEventListener("click", function () {
following = !following;
followers += following ? 1 : -1;
followBtn.setAttribute("aria-pressed", String(following));
followLabel.textContent = following ? "Following" : "Follow build";
renderFollowers();
toast(
following
? "SUBSCRIBED // you will hear about every faceplant"
: "UNFOLLOWED // the robot is quietly disappointed"
);
});
/* ---------- filter chips ---------- */
var chips = Array.prototype.slice.call(document.querySelectorAll(".chip"));
var entries = Array.prototype.slice.call(document.querySelectorAll(".entry"));
var minimapItems = Array.prototype.slice.call(
document.querySelectorAll(".minimap-item")
);
var emptyState = document.getElementById("emptyState");
var entryCountEl = document.getElementById("entryCount");
var FILTER_LABELS = {
all: "ALL ENTRIES",
milestone: "MILESTONES ONLY",
fail: "FAILS ONLY // learn from my pain",
update: "UPDATES ONLY"
};
function applyFilter(kind) {
var visible = 0;
entries.forEach(function (entry) {
var match = kind === "all" || entry.dataset.kind === kind;
entry.classList.toggle("is-filtered", !match);
if (match) visible++;
});
minimapItems.forEach(function (item) {
var target = document.getElementById(item.dataset.target);
var hidden = !target || target.classList.contains("is-filtered");
item.classList.toggle("is-hidden", hidden);
});
emptyState.hidden = visible !== 0;
entryCountEl.textContent = String(visible);
chips.forEach(function (chip) {
var active = chip.dataset.filter === kind;
chip.classList.toggle("is-active", active);
chip.setAttribute("aria-pressed", String(active));
});
toast("FILTER // " + (FILTER_LABELS[kind] || kind.toUpperCase()));
}
chips.forEach(function (chip) {
chip.addEventListener("click", function () {
applyFilter(chip.dataset.filter);
});
});
/* ---------- mini-map: click to scroll ---------- */
minimapItems.forEach(function (item) {
item.addEventListener("click", function () {
var target = document.getElementById(item.dataset.target);
if (!target) return;
target.scrollIntoView({ behavior: "smooth", block: "center" });
var title = target.querySelector(".entry-title");
if (title) {
toast("JUMP // " + title.textContent.trim());
}
});
});
/* ---------- mini-map: highlight entry in view ---------- */
var activeId = null;
function setActive(id) {
if (id === activeId) return;
activeId = id;
minimapItems.forEach(function (item) {
item.classList.toggle("is-active", item.dataset.target === id);
});
entries.forEach(function (entry) {
entry.classList.toggle("is-in-view", entry.id === id);
});
}
if ("IntersectionObserver" in window) {
var ratios = {};
var observer = new IntersectionObserver(
function (observed) {
observed.forEach(function (obs) {
ratios[obs.target.id] = obs.isIntersecting ? obs.intersectionRatio : 0;
});
var bestId = null;
var bestRatio = 0;
entries.forEach(function (entry) {
if (entry.classList.contains("is-filtered")) return;
var r = ratios[entry.id] || 0;
if (r > bestRatio) {
bestRatio = r;
bestId = entry.id;
}
});
if (bestId) setActive(bestId);
},
{
rootMargin: "-15% 0px -35% 0px",
threshold: [0, 0.25, 0.5, 0.75, 1]
}
);
entries.forEach(function (entry) {
observer.observe(entry);
});
} else if (entries.length) {
setActive(entries[0].id);
}
/* ---------- init ---------- */
renderFollowers();
entryCountEl.textContent = String(entries.length);
})();<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>DIY — Build Log</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>
<!-- ===== Blueprint hero / project header ===== -->
<header class="hero">
<div class="hero-inner">
<div class="hero-top">
<span class="hero-badge">BUILD LOG // PRJ-0047</span>
<span class="hero-badge hero-badge--live"><span class="pulse-dot" aria-hidden="true"></span> ACTIVE PROJECT</span>
</div>
<h1 class="hero-title">Self-Balancing Robot</h1>
<p class="hero-sub">Six months of fails, firmware tantrums & one glorious upright robot. Logged by <strong>Marta “ServoQueen” Ibáñez</strong> of the Tinker Barn workshop.</p>
<div class="hero-actions">
<button class="btn btn-follow" id="followBtn" aria-pressed="false">
<svg class="btn-follow-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path fill="currentColor" d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/></svg>
<span id="followLabel">Follow build</span>
</button>
<span class="follower-count" aria-live="polite"><span class="mono" id="followerCount">1,284</span> followers</span>
</div>
</div>
</header>
<!-- ===== Status card ===== -->
<section class="status-card" aria-label="Project status">
<div class="status-cell">
<span class="status-label">Current state</span>
<span class="status-value status-value--state"><span class="ok-dot" aria-hidden="true"></span> Balancing 40+ min</span>
</div>
<div class="status-cell">
<span class="status-label">Hours logged</span>
<span class="status-value mono">217.5 h</span>
</div>
<div class="status-cell">
<span class="status-label">Cost so far</span>
<span class="status-value mono">$438.12</span>
</div>
<div class="status-cell">
<span class="status-label">Entries</span>
<span class="status-value mono" id="entryCount">8</span>
</div>
</section>
<!-- ===== Filters ===== -->
<nav class="filters" aria-label="Filter log entries">
<button class="chip is-active" data-filter="all" aria-pressed="true">All</button>
<button class="chip" data-filter="milestone" aria-pressed="false">Milestones</button>
<button class="chip" data-filter="fail" aria-pressed="false">Fails</button>
<button class="chip" data-filter="update" aria-pressed="false">Updates</button>
</nav>
<!-- ===== Layout: timeline + minimap ===== -->
<div class="log-layout">
<main class="timeline" id="timeline">
<!-- Entry 1 -->
<article class="entry" data-kind="update" id="entry-1">
<div class="entry-rail"><span class="entry-node" aria-hidden="true"></span></div>
<div class="entry-card">
<header class="entry-head">
<time class="entry-date mono" datetime="2026-01-14">JAN 14, 2026</time>
<span class="badge badge-update">UPDATE</span>
</header>
<h2 class="entry-title">Kickoff: parts pile & wild optimism</h2>
<p class="entry-prose">Ordered the core stack: two NEMA-17 steppers, a pair of 90 mm rubber wheels, an IMU breakout (BNO-085), and a fistful of 18650 cells from the salvage bin. Sketched the chassis in cardboard first — three decks, sensors on top, batteries on the bottom for a low center of gravity. Estimated timeline: "maybe three weekends." Narrator: it was not three weekends.</p>
<figure class="entry-media" aria-label="Photo: parts laid out on workbench">
<div class="media-ph media-ph--a"><span class="media-tag mono">IMG_0047_parts.jpg</span></div>
<figcaption>The full parts pile on the bench. Cardboard chassis v0 in the corner.</figcaption>
</figure>
<ul class="entry-tags">
<li class="tag mono">mechanical</li>
<li class="tag mono">planning</li>
<li class="tag mono">BOM-v1</li>
</ul>
</div>
</article>
<!-- Entry 2 -->
<article class="entry entry--fail" data-kind="fail" id="entry-2">
<div class="entry-rail"><span class="entry-node" aria-hidden="true"></span></div>
<div class="entry-card">
<header class="entry-head">
<time class="entry-date mono" datetime="2026-02-02">FEB 02, 2026</time>
<span class="badge badge-fail">FAIL</span>
</header>
<h2 class="entry-title">The Great Faceplant, episode one</h2>
<p class="entry-prose">First powered test. The PID loop ran at 50 Hz because I copied a config from a forum post without reading it. The robot oscillated twice, then launched itself off the bench like it had somewhere better to be. Snapped a wheel hub and cracked the top deck. Ten seconds of video, six weeks of pretending it didn't happen.</p>
<figure class="entry-media" aria-label="Photo: robot lying on the floor">
<div class="media-ph media-ph--b"><span class="media-tag mono">VID_faceplant_01.mp4</span></div>
<figcaption>Frame from the crash video. Note the wheel hub in three pieces.</figcaption>
</figure>
<aside class="lesson">
<span class="lesson-label mono">LESSON LEARNED</span>
<p>Control loop rate matters more than gains. Anything under ~200 Hz on this wheelbase is a catapult, not a controller.</p>
</aside>
<ul class="entry-tags">
<li class="tag mono">firmware</li>
<li class="tag mono">PID</li>
<li class="tag mono">oops</li>
</ul>
</div>
</article>
<!-- Entry 3 -->
<article class="entry" data-kind="update" id="entry-3">
<div class="entry-rail"><span class="entry-node" aria-hidden="true"></span></div>
<div class="entry-card">
<header class="entry-head">
<time class="entry-date mono" datetime="2026-02-21">FEB 21, 2026</time>
<span class="badge badge-update">UPDATE</span>
</header>
<h2 class="entry-title">Chassis v2: plywood out, printed brackets in</h2>
<p class="entry-prose">Redesigned the frame around 2020 aluminum extrusion with printed PETG corner brackets. Motor mounts are now slotted so I can tune wheel spacing without reprinting. Total weight dropped from 1.9 kg to 1.4 kg, and the whole thing stopped flexing like a diving board. Also added a proper kill switch after episode one.</p>
<figure class="entry-media" aria-label="Photo: new aluminum chassis">
<div class="media-ph media-ph--c"><span class="media-tag mono">IMG_0112_chassis_v2.jpg</span></div>
<figcaption>Chassis v2 with slotted motor mounts and the very red kill switch.</figcaption>
</figure>
<ul class="entry-tags">
<li class="tag mono">mechanical</li>
<li class="tag mono">3d-print</li>
<li class="tag mono">chassis-v2</li>
</ul>
</div>
</article>
<!-- Entry 4 -->
<article class="entry entry--fail" data-kind="fail" id="entry-4">
<div class="entry-rail"><span class="entry-node" aria-hidden="true"></span></div>
<div class="entry-card">
<header class="entry-head">
<time class="entry-date mono" datetime="2026-03-15">MAR 15, 2026</time>
<span class="badge badge-fail">FAIL</span>
</header>
<h2 class="entry-title">IMU mounted on the vibration highway</h2>
<p class="entry-prose">Spent two weekends chasing a phantom drift in the pitch estimate. Rewrote the complementary filter three times. Turns out the IMU was bolted directly above a stepper — every microstep rang through the deck and straight into the accelerometer. Moved it to the top deck on a foam pad and the "firmware bug" evaporated instantly.</p>
<figure class="entry-media" aria-label="Plot: noisy accelerometer trace">
<div class="media-ph media-ph--d"><span class="media-tag mono">plot_accel_noise.png</span></div>
<figcaption>Before/after accelerometer trace. Left: chaos. Right: foam pad.</figcaption>
</figure>
<aside class="lesson">
<span class="lesson-label mono">LESSON LEARNED</span>
<p>Before blaming software, check the mechanical path. Sensor placement is a filter you don't have to write.</p>
</aside>
<ul class="entry-tags">
<li class="tag mono">firmware</li>
<li class="tag mono">sensors</li>
<li class="tag mono">oops</li>
</ul>
</div>
</article>
<!-- Entry 5 -->
<article class="entry entry--milestone" data-kind="milestone" id="entry-5">
<div class="entry-rail"><span class="entry-node" aria-hidden="true"></span></div>
<div class="entry-card">
<header class="entry-head">
<time class="entry-date mono" datetime="2026-04-09">APR 09, 2026</time>
<span class="badge badge-milestone">MILESTONE</span>
</header>
<h2 class="entry-title">First stand: 11 seconds of glory</h2>
<p class="entry-prose">Cascaded PID (angle inner loop at 400 Hz, velocity outer loop at 100 Hz), fresh IMU placement, and slightly deflated wheels for grip. Set it down, let go, and it just… stood there. Eleven seconds before a floorboard seam took it out. I yelled loud enough that the neighbor's dog filed a complaint. Everything after this point is refinement.</p>
<figure class="entry-media" aria-label="Video still: robot standing upright">
<div class="media-ph media-ph--e"><span class="media-tag mono">VID_first_stand.mp4</span></div>
<figcaption>It stands. On its own. Unassisted. I have witnesses.</figcaption>
</figure>
<ul class="entry-tags">
<li class="tag mono">firmware</li>
<li class="tag mono">PID</li>
<li class="tag mono">milestone</li>
</ul>
</div>
</article>
<!-- Entry 6 -->
<article class="entry entry--fail" data-kind="fail" id="entry-6">
<div class="entry-rail"><span class="entry-node" aria-hidden="true"></span></div>
<div class="entry-card">
<header class="entry-head">
<time class="entry-date mono" datetime="2026-05-03">MAY 03, 2026</time>
<span class="badge badge-fail">FAIL</span>
</header>
<h2 class="entry-title">Battery sag, the silent saboteur</h2>
<p class="entry-prose">Balancing was rock solid for twenty minutes, then progressively drunker until it fell over. Logged bus voltage and found the salvage 18650s sagging from 12.1 V to 9.4 V under load — motor torque dropped with it and my gains stopped being right. Swapped in a proper 3S pack with voltage compensation in the motor driver. Also melted one XT60 connector learning to solder them properly.</p>
<figure class="entry-media" aria-label="Photo: melted connector next to new battery pack">
<div class="media-ph media-ph--f"><span class="media-tag mono">IMG_0203_xt60_rip.jpg</span></div>
<figcaption>RIP connector no. 1. The new 3S pack behind it, unbothered.</figcaption>
</figure>
<aside class="lesson">
<span class="lesson-label mono">LESSON LEARNED</span>
<p>Salvage cells are free for a reason. Feed control loops a compensated voltage or your tuning only works at full charge.</p>
</aside>
<ul class="entry-tags">
<li class="tag mono">electrical</li>
<li class="tag mono">power</li>
<li class="tag mono">oops</li>
</ul>
</div>
</article>
<!-- Entry 7 -->
<article class="entry" data-kind="update" id="entry-7">
<div class="entry-rail"><span class="entry-node" aria-hidden="true"></span></div>
<div class="entry-card">
<header class="entry-head">
<time class="entry-date mono" datetime="2026-06-11">JUN 11, 2026</time>
<span class="badge badge-update">UPDATE</span>
</header>
<h2 class="entry-title">Remote control + telemetry dashboard</h2>
<p class="entry-prose">Added an ESP-NOW link to a hand controller built from a thumbstick and a leftover dev board. The robot now streams pitch, wheel velocity, and bus voltage at 20 Hz to a little web dashboard, which makes tuning feel like science instead of vibes. Driving it around the workshop feels ridiculous in the best way.</p>
<figure class="entry-media" aria-label="Screenshot: telemetry dashboard">
<div class="media-ph media-ph--g"><span class="media-tag mono">scrn_telemetry_v1.png</span></div>
<figcaption>Live telemetry: pitch holding at ±0.4° while driving.</figcaption>
</figure>
<ul class="entry-tags">
<li class="tag mono">firmware</li>
<li class="tag mono">telemetry</li>
<li class="tag mono">esp-now</li>
</ul>
</div>
</article>
<!-- Entry 8 -->
<article class="entry entry--milestone" data-kind="milestone" id="entry-8">
<div class="entry-rail"><span class="entry-node" aria-hidden="true"></span></div>
<div class="entry-card">
<header class="entry-head">
<time class="entry-date mono" datetime="2026-07-18">JUL 18, 2026</time>
<span class="badge badge-milestone">MILESTONE</span>
</header>
<h2 class="entry-title">40-minute endurance run — it just works now</h2>
<p class="entry-prose">Full battery, rough workshop floor, doorway thresholds, one deliberate shove (sorry, robot). Forty minutes of continuous balancing with pitch never exceeding ±2.1°. Six months, three chassis, one melted connector, and a robot that now politely refuses to fall over. Next up: obstacle avoidance, and maybe teaching it to fetch the multimeter.</p>
<figure class="entry-media" aria-label="Photo: robot balancing next to a stopwatch">
<div class="media-ph media-ph--h"><span class="media-tag mono">VID_endurance_40m.mp4</span></div>
<figcaption>Minute 40. Still upright. Still smug about it.</figcaption>
</figure>
<ul class="entry-tags">
<li class="tag mono">milestone</li>
<li class="tag mono">endurance</li>
<li class="tag mono">v1.0</li>
</ul>
</div>
</article>
<p class="timeline-empty" id="emptyState" hidden>No entries match this filter. The robot demands more content.</p>
</main>
<!-- ===== Sticky mini-map ===== -->
<aside class="minimap" aria-label="Entry navigation">
<span class="minimap-title mono">LOG MAP</span>
<ol class="minimap-list" id="minimapList">
<li><button class="minimap-item" data-target="entry-1"><span class="mm-dot mm-dot--update" aria-hidden="true"></span><span class="mm-date mono">JAN 14</span></button></li>
<li><button class="minimap-item" data-target="entry-2"><span class="mm-dot mm-dot--fail" aria-hidden="true"></span><span class="mm-date mono">FEB 02</span></button></li>
<li><button class="minimap-item" data-target="entry-3"><span class="mm-dot mm-dot--update" aria-hidden="true"></span><span class="mm-date mono">FEB 21</span></button></li>
<li><button class="minimap-item" data-target="entry-4"><span class="mm-dot mm-dot--fail" aria-hidden="true"></span><span class="mm-date mono">MAR 15</span></button></li>
<li><button class="minimap-item" data-target="entry-5"><span class="mm-dot mm-dot--milestone" aria-hidden="true"></span><span class="mm-date mono">APR 09</span></button></li>
<li><button class="minimap-item" data-target="entry-6"><span class="mm-dot mm-dot--fail" aria-hidden="true"></span><span class="mm-date mono">MAY 03</span></button></li>
<li><button class="minimap-item" data-target="entry-7"><span class="mm-dot mm-dot--update" aria-hidden="true"></span><span class="mm-date mono">JUN 11</span></button></li>
<li><button class="minimap-item" data-target="entry-8"><span class="mm-dot mm-dot--milestone" aria-hidden="true"></span><span class="mm-date mono">JUL 18</span></button></li>
</ol>
</aside>
</div>
<footer class="page-footer">
<span class="mono">PRJ-0047 // TINKER BARN WORKSHOP</span>
<span>Logged with duct tape & determination.</span>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Build Log
A Hackaday-flavored project log page telling the story of a fictional self-balancing robot across six months of entries. The hero sits on the signature navy blueprint grid with stencil-style headings, a mono project ID badge, and a safety-orange follow button whose follower count updates live. Directly below, a floating status card reports the current state, hours logged, cost so far, and entry count in JetBrains Mono.
The timeline itself is a dashed vertical rail with eight dated entry cards. Each card carries a title, real-feeling prose, a graph-paper media placeholder with a mono filename tag, and lowercase mono tags like firmware and oops. Fail entries are flagged with a danger-tinted badge, a red card accent, and an orange lesson-learned callout; milestone entries get an ok-green badge and accent instead.
Filter chips (All / Milestones / Fails / Updates) show and hide entries and keep the entry count honest, while a sticky mini-map on the right lists every entry as a colored dot plus date. An IntersectionObserver highlights whichever entry is currently in view and enlarges its timeline node; clicking a mini-map row smooth-scrolls to that entry. Every action confirms itself through a small blueprint-styled toast. On narrow screens the mini-map collapses into a sticky horizontal dot strip.
Illustrative UI only — always follow real safety guidance when working with tools, electronics, or repairs.