DIY — Hardware Writeup Article
A brutalist hardware-blog article page for a fictional hacking zine called Solder and Smoke. Sticky reading-progress bar, slab uppercase headline, byline with share and save controls, duotone CSS figures, acid-green pull quotes, two syntax-coloured code blocks with line numbers and copy buttons, a mono bill-of-materials table, related-post cards, a tag row, a six-comment thread with nesting, votes and collapse controls, a character-counting composer, and a table of contents that tracks scroll position.
MCP
Code
:root{
--black:#0b0b0b;
--black-2:#131313;
--black-3:#1b1b1b;
--bone:#f5f2eb;
--bone-d:#cfcabf;
--muted:#8d887d;
--acid:#9dff3d;
--acid-d:#7ad020;
--amber:#ffb300;
--danger:#ff4d3d;
--line:#2a2a2a;
--line-2:#3d3d3d;
--bd:2px solid var(--bone);
--bd-dim:2px solid var(--line-2);
--wrap:1180px;
--ui:"Inter",system-ui,-apple-system,"Segoe UI",sans-serif;
--mono:"JetBrains Mono",ui-monospace,"SFMono-Regular",Consolas,monospace;
}
*,*::before,*::after{box-sizing:border-box}
html{scroll-behavior:smooth;scroll-padding-top:104px}
body{
margin:0;
background:var(--black);
color:var(--bone);
font-family:var(--ui);
font-size:17px;
line-height:1.5;
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
background-image:
repeating-linear-gradient(0deg,rgba(255,255,255,.022) 0 1px,transparent 1px 4px);
}
.mono{font-family:var(--mono)}
.wrap{width:100%;max-width:var(--wrap);margin:0 auto;padding:0 20px}
a{color:inherit}
:focus-visible{outline:3px solid var(--acid);outline-offset:2px}
.skip{position:absolute;left:-9999px;top:0;background:var(--acid);color:#0b0b0b;padding:10px 16px;font-weight:800;z-index:60}
.skip:focus{left:8px;top:8px}
/* ---------- masthead ---------- */
.masthead{position:sticky;top:0;z-index:40;background:var(--black);border-bottom:var(--bd)}
.mast-inner{display:flex;align-items:center;gap:20px;height:66px}
.brand{display:flex;align-items:center;gap:10px;text-decoration:none}
.brand-mark{color:var(--acid);display:flex}
.brand-text{font-weight:800;letter-spacing:.06em;font-size:19px;text-transform:uppercase}
.brand-text .amp{color:var(--acid);margin:0 2px}
.mast-nav{margin-left:auto;display:flex;align-items:center;gap:22px}
.mast-nav a{
text-decoration:none;text-transform:uppercase;font-size:12px;font-weight:700;
letter-spacing:.12em;color:var(--bone-d);padding:6px 0;border-bottom:2px solid transparent;
transition:color .14s linear,border-color .14s linear;
}
.mast-nav a:hover{color:var(--acid);border-bottom-color:var(--acid)}
.mast-nav .nav-cta{border:var(--bd);padding:7px 12px;color:var(--bone)}
.mast-nav .nav-cta:hover{background:var(--acid);border-color:var(--acid);color:#0b0b0b}
.menu-btn{
display:none;margin-left:auto;background:transparent;color:var(--bone);border:var(--bd);
font-family:var(--mono);font-size:12px;font-weight:700;letter-spacing:.12em;padding:8px 12px;cursor:pointer;
}
.menu-btn:hover{background:var(--acid);color:#0b0b0b;border-color:var(--acid)}
.mobile-nav{display:none;flex-direction:column;border-top:var(--bd-dim);background:var(--black-2)}
.mobile-nav a{padding:14px 20px;text-decoration:none;text-transform:uppercase;font-size:13px;font-weight:700;letter-spacing:.1em;border-bottom:1px solid var(--line)}
.mobile-nav a:hover{color:var(--acid)}
.progress{height:5px;background:var(--black-3);border-top:1px solid var(--line)}
.progress-fill{display:block;height:100%;width:0;background:var(--acid);transition:width .08s linear}
/* ---------- layout ---------- */
.layout{display:grid;grid-template-columns:minmax(0,1fr);gap:44px;padding-top:40px;padding-bottom:64px;align-items:start}
@media(min-width:1040px){.layout{grid-template-columns:minmax(0,1fr) 268px}}
/* ---------- article header ---------- */
.kicker{display:flex;align-items:center;gap:14px;flex-wrap:wrap;margin-bottom:18px}
.tag-cat{
background:var(--acid);color:#0b0b0b;font-family:var(--mono);font-weight:700;font-size:12px;
letter-spacing:.16em;padding:5px 10px;
}
.kicker-meta{font-size:12px;color:var(--muted);letter-spacing:.1em}
.headline{
font-size:clamp(34px,6vw,60px);line-height:1.02;margin:0 0 18px;font-weight:800;
text-transform:uppercase;letter-spacing:-.015em;
}
.dek{font-size:clamp(17px,2.1vw,21px);color:var(--bone-d);max-width:66ch;margin:0 0 26px;line-height:1.45}
.byline{display:flex;align-items:center;gap:14px;flex-wrap:wrap;border-top:var(--bd);border-bottom:var(--bd);padding:14px 0;margin-bottom:34px}
.avatar{
width:44px;height:44px;display:grid;place-items:center;background:var(--acid);color:#0b0b0b;
font-family:var(--mono);font-weight:700;font-size:14px;flex:none;
}
.byline-txt{min-width:0}
.byline-name{margin:0;font-weight:700;font-size:15px}
.byline-meta{margin:2px 0 0;font-size:11px;color:var(--muted);letter-spacing:.1em}
.share{margin-left:auto;display:flex;gap:8px;flex-wrap:wrap}
.btn-share{
background:transparent;border:var(--bd-dim);color:var(--bone-d);font-family:var(--mono);
font-size:11px;font-weight:700;letter-spacing:.12em;padding:8px 12px;cursor:pointer;
transition:background .12s linear,color .12s linear,border-color .12s linear,transform .08s ease;
}
.btn-share:hover{border-color:var(--acid);color:var(--acid)}
.btn-share:active{transform:translateY(1px)}
.btn-share[aria-pressed="true"]{background:var(--amber);border-color:var(--amber);color:#0b0b0b}
/* ---------- prose ---------- */
.article p{max-width:68ch}
.article > p{margin:0 0 22px;color:var(--bone)}
.h2{
font-size:clamp(24px,3.4vw,34px);text-transform:uppercase;font-weight:800;letter-spacing:-.01em;
margin:52px 0 18px;padding-bottom:10px;border-bottom:var(--bd);
}
.h3{font-size:19px;text-transform:uppercase;letter-spacing:.06em;font-weight:700;margin:34px 0 14px;color:var(--acid)}
.ic{font-family:var(--mono);font-size:.88em;background:var(--black-3);border:1px solid var(--line-2);padding:1px 6px;color:var(--amber)}
.pull{
margin:34px 0;padding:6px 0 6px 22px;border-left:6px solid var(--acid);
}
.pull p{margin:0;font-size:clamp(19px,2.6vw,25px);line-height:1.3;font-weight:700;text-transform:none;color:var(--bone)}
.findings{list-style:none;margin:0 0 26px;padding:0;max-width:68ch}
.findings li{position:relative;padding:12px 0 12px 30px;border-bottom:1px solid var(--line)}
.findings li::before{content:"";position:absolute;left:2px;top:19px;width:12px;height:12px;background:var(--acid)}
.findings b{color:var(--acid);font-weight:700}
/* ---------- figures (CSS duotone illustrations) ---------- */
.fig{margin:32px 0;padding:0}
.duo{
position:relative;display:block;width:100%;height:250px;border:var(--bd);overflow:hidden;
background:
repeating-linear-gradient(0deg,rgba(157,255,61,.07) 0 1px,transparent 1px 5px),
linear-gradient(135deg,#101010,#1c1c1c);
}
.fig-hero .duo{height:300px}
.fig figcaption{font-size:11.5px;color:var(--muted);letter-spacing:.08em;margin-top:10px;text-transform:uppercase}
.duo-printer .d-body{position:absolute;left:50%;top:26%;transform:translateX(-50%);width:52%;height:48%;background:var(--bone);border:2px solid #0b0b0b}
.duo-printer .d-slot{position:absolute;left:50%;top:30%;transform:translateX(-50%);width:40%;height:8px;background:#0b0b0b}
.duo-printer .d-paper{position:absolute;left:50%;top:12%;transform:translateX(-50%);width:34%;height:18%;background:linear-gradient(180deg,#fff,#d8d5cd);border:2px solid #0b0b0b;border-bottom:none}
.duo-printer .d-led{position:absolute;left:50%;top:64%;margin-left:16%;width:12px;height:12px;background:var(--acid);box-shadow:0 0 0 3px rgba(157,255,61,.22);animation:blink 2.2s steps(1,end) infinite}
.duo-printer .d-scan{position:absolute;inset:0;background:linear-gradient(90deg,transparent,rgba(157,255,61,.16),transparent);width:34%;animation:scan 5.5s linear infinite}
@keyframes blink{0%,60%{opacity:1}61%,100%{opacity:.25}}
@keyframes scan{0%{transform:translateX(-40%)}100%{transform:translateX(320%)}}
.duo-board .b-pcb{position:absolute;left:9%;top:16%;width:82%;height:68%;background:#16281a;border:2px solid var(--acid-d)}
.duo-board .b-mcu{position:absolute;left:30%;top:36%;width:20%;height:28%;background:#0b0b0b;border:2px solid var(--bone-d);box-shadow:0 0 0 6px rgba(245,242,235,.06)}
.duo-board .b-cap{position:absolute;width:9%;height:14%;background:var(--amber);border:2px solid #0b0b0b}
.duo-board .b-cap1{left:16%;top:28%}
.duo-board .b-cap2{left:16%;top:56%}
.duo-board .b-zif{position:absolute;right:13%;top:30%;width:7%;height:40%;background:var(--bone);border:2px solid #0b0b0b}
.duo-board .b-trace{
position:absolute;left:9%;top:16%;width:82%;height:68%;
background:
repeating-linear-gradient(90deg,transparent 0 26px,rgba(157,255,61,.22) 26px 28px),
repeating-linear-gradient(0deg,transparent 0 22px,rgba(157,255,61,.14) 22px 23px);
mix-blend-mode:screen;pointer-events:none;
}
.duo-receipt .r-paper{position:absolute;left:50%;top:8%;transform:translateX(-50%);width:44%;height:84%;background:var(--bone);border:2px solid #0b0b0b}
.duo-receipt .r-l{position:absolute;left:50%;transform:translateX(-50%);height:7px;background:#0b0b0b}
.duo-receipt .r-l1{top:20%;width:32%}
.duo-receipt .r-l2{top:29%;width:24%}
.duo-receipt .r-l3{top:38%;width:30%}
.duo-receipt .r-blk{
position:absolute;left:50%;top:50%;transform:translateX(-50%);width:30%;height:24%;
background:repeating-conic-gradient(#0b0b0b 0 25%,var(--bone) 0 50%) 0 0/12px 12px;
}
.duo-receipt .r-zig{
position:absolute;left:50%;bottom:6%;transform:translateX(-50%);width:44%;height:12px;
background:repeating-linear-gradient(135deg,var(--bone) 0 8px,transparent 8px 16px);
}
/* ---------- code blocks ---------- */
.code{border:var(--bd);margin:30px 0;background:var(--black-2)}
.code-head{display:flex;align-items:center;gap:12px;padding:9px 12px;border-bottom:var(--bd);background:var(--black-3)}
.code-file{font-size:12px;letter-spacing:.08em;color:var(--acid)}
.btn-copy{
margin-left:auto;background:transparent;border:2px solid var(--line-2);color:var(--bone-d);
font-family:var(--mono);font-size:11px;font-weight:700;letter-spacing:.12em;padding:5px 10px;cursor:pointer;
transition:background .12s linear,color .12s linear,border-color .12s linear;
}
.btn-copy:hover{background:var(--acid);border-color:var(--acid);color:#0b0b0b}
.btn-copy.done{background:var(--amber);border-color:var(--amber);color:#0b0b0b}
.code pre{margin:0;overflow-x:auto;padding:14px 0;counter-reset:line}
.code code{font-family:var(--mono);font-size:13.5px;line-height:1.7;display:block;min-width:max-content;color:var(--bone)}
.ln{display:inline-block;width:44px;padding-right:14px;text-align:right;color:#5a5a5a;user-select:none}
.ln::before{counter-increment:line;content:counter(line)}
.code .k{color:var(--acid);font-weight:600}
.code .f{color:var(--amber)}
.code .fn{color:#7fd3ff}
.code .s{color:#ffd98a}
.code .n{color:#ff9de0}
.code .c{color:#6f6f6f;font-style:italic}
/* ---------- BOM ---------- */
.bom{border:var(--bd);background:var(--black-2);margin:40px 0;padding:0}
.bom-title{margin:0;padding:12px 16px;font-size:12.5px;letter-spacing:.16em;background:var(--bone);color:#0b0b0b;font-weight:700}
.bom-scroll{overflow-x:auto}
.bom-table{width:100%;border-collapse:collapse;min-width:520px;font-family:var(--mono);font-size:13px}
.bom-table th,.bom-table td{text-align:left;padding:10px 16px;border-bottom:1px solid var(--line)}
.bom-table th{font-size:11px;letter-spacing:.12em;color:var(--acid);border-bottom:var(--bd-dim)}
.bom-table tbody tr:hover{background:rgba(157,255,61,.06)}
.bom-table td:first-child{color:var(--amber)}
.bom-table td:last-child,.bom-table th:last-child{text-align:right}
.bom-total{margin:0;padding:11px 16px;font-size:12px;letter-spacing:.12em;color:var(--bone);border-top:var(--bd-dim)}
/* ---------- related ---------- */
.related{margin:48px 0 30px}
.rel-title{font-size:12px;letter-spacing:.16em;color:var(--muted);margin:0 0 14px}
.rel-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(210px,1fr))}
.rel-card{
display:flex;flex-direction:column;gap:8px;border:var(--bd-dim);padding:14px;text-decoration:none;
background:var(--black-2);transition:border-color .14s linear,transform .12s ease,background .14s linear;
}
.rel-card:hover{border-color:var(--acid);transform:translate(-2px,-2px);box-shadow:4px 4px 0 var(--acid)}
.rel-tag{font-size:10.5px;letter-spacing:.16em;color:var(--acid)}
.rel-h{font-weight:700;font-size:15.5px;line-height:1.25}
.rel-m{font-size:10.5px;letter-spacing:.1em;color:var(--muted)}
/* ---------- tags ---------- */
.tags{display:flex;flex-wrap:wrap;gap:8px;padding:22px 0;border-top:var(--bd);border-bottom:var(--bd);margin-bottom:44px}
.tag{
font-family:var(--mono);font-size:12px;letter-spacing:.06em;text-decoration:none;
border:2px solid var(--line-2);padding:6px 10px;color:var(--bone-d);
transition:background .12s linear,color .12s linear,border-color .12s linear;
}
.tag:hover{background:var(--acid);border-color:var(--acid);color:#0b0b0b}
/* ---------- comments ---------- */
.comments-head{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap}
.comments-head .h2{margin:0 0 0;flex:1 1 auto}
.count{font-size:14px;color:var(--acid);vertical-align:middle}
.btn-ghost{
background:transparent;border:var(--bd-dim);color:var(--bone-d);font-family:var(--mono);font-size:11px;
font-weight:700;letter-spacing:.12em;padding:8px 12px;cursor:pointer;margin-bottom:12px;
}
.btn-ghost:hover{border-color:var(--acid);color:var(--acid)}
.c-list{list-style:none;margin:26px 0 0;padding:0}
.c-children{list-style:none;margin:0;padding:0 0 0 26px;border-left:2px solid var(--line-2)}
.c-item{margin:0 0 18px}
.c-body{border:var(--bd-dim);background:var(--black-2);padding:14px 16px}
.c-item.is-new .c-body{border-color:var(--acid);animation:pop .32s ease}
@keyframes pop{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:none}}
.c-meta{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}
.c-av{width:28px;height:28px;display:grid;place-items:center;background:var(--bone);color:#0b0b0b;font-family:var(--mono);font-size:11px;font-weight:700;flex:none}
.c-name{font-size:14px;font-weight:700}
.badge{font-size:10px;letter-spacing:.14em;padding:2px 6px;font-weight:700}
.badge-op{background:var(--acid);color:#0b0b0b}
.badge-staff{background:var(--amber);color:#0b0b0b}
.c-time{font-size:11px;color:var(--muted);letter-spacing:.08em;margin-left:auto}
.c-text{margin:0;font-size:15px;color:var(--bone-d);max-width:70ch}
.c-actions{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-top:12px}
.vote{display:flex;align-items:center;border:2px solid var(--line-2)}
.v-btn{background:transparent;border:none;color:var(--muted);cursor:pointer;font-size:12px;padding:5px 9px;line-height:1}
.v-btn:hover{color:var(--acid)}
.v-btn.on[data-vote="up"]{color:#0b0b0b;background:var(--acid)}
.v-btn.on[data-vote="down"]{color:#0b0b0b;background:var(--danger)}
.v-count{font-size:12px;padding:0 8px;min-width:34px;text-align:center;color:var(--bone)}
.c-reply,.c-toggle{
background:transparent;border:none;color:var(--muted);font-family:var(--mono);font-size:11px;
letter-spacing:.12em;font-weight:700;cursor:pointer;padding:5px 2px;border-bottom:2px solid transparent;
}
.c-reply:hover,.c-toggle:hover{color:var(--acid);border-bottom-color:var(--acid)}
.c-item.collapsed .c-children{display:none}
/* composer */
.composer{margin-top:30px;border:var(--bd);background:var(--black-2);padding:16px}
.comp-label{display:block;font-size:11.5px;letter-spacing:.16em;color:var(--acid);margin-bottom:10px}
.comp-input{
width:100%;background:var(--black);border:2px solid var(--line-2);color:var(--bone);
font-family:var(--ui);font-size:15px;padding:12px;resize:vertical;
}
.comp-input::placeholder{color:#6b675e}
.comp-input:focus{border-color:var(--acid);outline:none}
.comp-foot{display:flex;align-items:center;gap:14px;margin-top:12px;flex-wrap:wrap}
.counter{font-size:11.5px;color:var(--muted);letter-spacing:.1em}
.counter.warn{color:var(--amber)}
.counter.max{color:var(--danger)}
.btn-post{
margin-left:auto;background:var(--acid);border:2px solid var(--acid);color:#0b0b0b;
font-family:var(--mono);font-weight:700;font-size:12px;letter-spacing:.14em;padding:11px 18px;cursor:pointer;
transition:transform .08s ease,box-shadow .12s ease;
}
.btn-post:hover{box-shadow:4px 4px 0 var(--bone)}
.btn-post:active{transform:translate(2px,2px);box-shadow:none}
/* ---------- rail ---------- */
.rail{display:none}
@media(min-width:1040px){.rail{display:block}}
.rail-inner{position:sticky;top:104px}
.rail-title{font-size:11px;letter-spacing:.18em;color:var(--muted);margin:0 0 12px}
.toc{display:flex;flex-direction:column;border-left:2px solid var(--line-2)}
.toc a{
text-decoration:none;font-size:13.5px;color:var(--bone-d);padding:9px 14px;
border-left:3px solid transparent;margin-left:-2px;transition:color .14s linear,border-color .14s linear;
}
.toc a:hover{color:var(--bone)}
.toc a.active{color:var(--acid);border-left-color:var(--acid);font-weight:700}
.rail-box{margin-top:26px;border:var(--bd);padding:14px}
.rail-box-t{font-size:11px;letter-spacing:.16em;color:var(--acid);margin:0 0 8px}
.rail-box-p{margin:0 0 12px;font-size:13.5px;color:var(--bone-d)}
.rail-link{font-family:var(--mono);font-size:11.5px;letter-spacing:.12em;font-weight:700;text-decoration:none;color:var(--bone)}
.rail-link:hover{color:var(--acid)}
/* ---------- footer / toast ---------- */
.foot{border-top:var(--bd);padding:26px 0;background:var(--black-2)}
.foot-inner p{margin:0 0 6px;font-size:11px;letter-spacing:.1em;color:var(--muted)}
.toast{
position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);opacity:0;pointer-events:none;
background:var(--acid);color:#0b0b0b;font-family:var(--mono);font-size:12px;font-weight:700;
letter-spacing:.1em;padding:12px 18px;border:2px solid #0b0b0b;box-shadow:5px 5px 0 rgba(245,242,235,.9);
transition:opacity .18s ease,transform .18s ease;z-index:70;max-width:calc(100vw - 32px);text-align:center;
}
.toast.show{opacity:1;transform:translate(-50%,0)}
/* ---------- responsive ---------- */
@media(max-width:900px){
.mast-nav{display:none}
.menu-btn{display:block}
.mobile-nav[data-open="true"]{display:flex}
}
@media(max-width:520px){
body{font-size:16px}
.wrap{padding:0 14px}
.mast-inner{height:58px;gap:10px}
.brand-text{font-size:16px;letter-spacing:.04em}
html{scroll-padding-top:80px}
.layout{padding-top:26px;gap:30px}
.headline{font-size:clamp(28px,9vw,36px)}
.byline{align-items:flex-start}
.share{margin-left:0;width:100%}
.btn-share{flex:1 1 auto;text-align:center}
.duo{height:190px}
.fig-hero .duo{height:210px}
.h2{margin:38px 0 14px}
.pull{padding-left:14px;border-left-width:5px}
.code code{font-size:12px}
.ln{width:32px;padding-right:9px}
.c-children{padding-left:12px}
.c-time{margin-left:0;width:100%}
.btn-post{width:100%;margin-left:0}
.rail-inner{position:static}
.toast{left:14px;right:14px;transform:translateY(20px);max-width:none}
.toast.show{transform:translateY(0)}
}
@media(prefers-reduced-motion:reduce){
*{animation:none!important;transition:none!important}
html{scroll-behavior:auto}
}(function () {
"use strict";
/* ---------------- toast ---------------- */
var toastEl = document.getElementById("toast");
var toastTimer = null;
function toast(msg) {
if (!toastEl) return;
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastTimer);
toastTimer = setTimeout(function () {
toastEl.classList.remove("show");
}, 2200);
}
/* ---------------- clipboard helper ---------------- */
function copyText(text) {
if (navigator.clipboard && navigator.clipboard.writeText) {
return navigator.clipboard.writeText(text);
}
return new Promise(function (resolve, reject) {
try {
var ta = document.createElement("textarea");
ta.value = text;
ta.setAttribute("readonly", "");
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
resolve();
} catch (e) {
reject(e);
}
});
}
/* ---------------- mobile nav ---------------- */
var menuBtn = document.getElementById("menuBtn");
var mobileNav = document.getElementById("mobileNav");
if (menuBtn && mobileNav) {
menuBtn.addEventListener("click", function () {
var open = menuBtn.getAttribute("aria-expanded") === "true";
menuBtn.setAttribute("aria-expanded", String(!open));
mobileNav.hidden = open;
mobileNav.dataset.open = String(!open);
menuBtn.textContent = open ? "MENU" : "CLOSE";
});
mobileNav.addEventListener("click", function (e) {
if (e.target.tagName !== "A") return;
menuBtn.setAttribute("aria-expanded", "false");
mobileNav.hidden = true;
mobileNav.dataset.open = "false";
menuBtn.textContent = "MENU";
});
}
/* ---------------- reading progress ---------------- */
var fill = document.getElementById("progressFill");
var bar = document.getElementById("progressBar");
var article = document.getElementById("article");
var ticking = false;
function updateProgress() {
ticking = false;
if (!fill || !article) return;
var rect = article.getBoundingClientRect();
var start = rect.top + window.scrollY;
var total = Math.max(1, article.offsetHeight - window.innerHeight);
var pct = ((window.scrollY - start) / total) * 100;
pct = Math.min(100, Math.max(0, pct));
fill.style.width = pct.toFixed(2) + "%";
if (bar) bar.setAttribute("aria-valuenow", String(Math.round(pct)));
}
function onScroll() {
if (ticking) return;
ticking = true;
requestAnimationFrame(updateProgress);
}
window.addEventListener("scroll", onScroll, { passive: true });
window.addEventListener("resize", onScroll);
updateProgress();
/* ---------------- share row ---------------- */
var copyLink = document.getElementById("copyLink");
if (copyLink) {
copyLink.addEventListener("click", function () {
copyText(window.location.href || "https://solder-and-smoke.example/log/0417").then(
function () {
toast("LINK COPIED TO CLIPBOARD");
},
function () {
toast("COPY BLOCKED BY BROWSER");
}
);
});
}
var citeBtn = document.querySelector('[data-share="cite"]');
if (citeBtn) {
citeBtn.addEventListener("click", function () {
var cite =
'Kovacs, M. "Reverse engineering a $6 thermal printer." Solder & Smoke, Log #0417, 30 Jul 2026.';
copyText(cite).then(
function () {
toast("CITATION COPIED");
},
function () {
toast("COPY BLOCKED BY BROWSER");
}
);
});
}
var saveBtn = document.getElementById("saveBtn");
if (saveBtn) {
saveBtn.addEventListener("click", function () {
var on = saveBtn.getAttribute("aria-pressed") === "true";
saveBtn.setAttribute("aria-pressed", String(!on));
saveBtn.textContent = on ? "SAVE" : "SAVED";
toast(on ? "REMOVED FROM READING LIST" : "SAVED TO READING LIST");
});
}
/* ---------------- code copy buttons ---------------- */
document.querySelectorAll(".btn-copy").forEach(function (btn) {
btn.addEventListener("click", function () {
var target = document.querySelector(btn.getAttribute("data-copy"));
if (!target) return;
// strip the line-number gutters before copying
var clone = target.cloneNode(true);
clone.querySelectorAll(".ln").forEach(function (n) {
n.remove();
});
copyText(clone.textContent.replace(/^\n/, "")).then(
function () {
btn.textContent = "COPIED";
btn.classList.add("done");
setTimeout(function () {
btn.textContent = "COPY";
btn.classList.remove("done");
}, 1600);
toast("SNIPPET COPIED");
},
function () {
toast("COPY BLOCKED BY BROWSER");
}
);
});
});
/* ---------------- comment votes ---------------- */
document.addEventListener("click", function (e) {
var btn = e.target.closest ? e.target.closest(".v-btn") : null;
if (!btn) return;
var group = btn.closest(".vote");
var countEl = group.querySelector(".v-count");
var base = parseInt(group.dataset.base || countEl.textContent, 10) || 0;
group.dataset.base = String(base);
var dir = btn.getAttribute("data-vote");
var current = group.dataset.state || "none";
var next = current === dir ? "none" : dir;
group.dataset.state = next;
group.querySelectorAll(".v-btn").forEach(function (b) {
b.classList.toggle("on", b.getAttribute("data-vote") === next);
});
var delta = next === "up" ? 1 : next === "down" ? -1 : 0;
countEl.textContent = String(base + delta);
});
/* ---------------- reply buttons ---------------- */
var textarea = document.getElementById("cText");
document.addEventListener("click", function (e) {
var btn = e.target.closest ? e.target.closest(".c-reply") : null;
if (!btn || !textarea) return;
var item = btn.closest(".c-item");
var name = item.querySelector(".c-name");
var handle = name ? "@" + name.textContent.trim() + " " : "";
if (textarea.value.indexOf(handle) !== 0) {
textarea.value = handle + textarea.value;
}
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
updateCounter();
toast("REPLYING TO " + (name ? name.textContent.trim().toUpperCase() : "THREAD"));
});
/* ---------------- thread collapse ---------------- */
function setThread(item, collapsed) {
var toggle = item.querySelector(":scope > .c-body .c-toggle");
var kids = item.querySelectorAll(":scope > .c-children > .c-item").length;
item.classList.toggle("collapsed", collapsed);
if (toggle) {
toggle.setAttribute("aria-expanded", String(!collapsed));
toggle.textContent =
(collapsed ? "SHOW " : "HIDE ") + kids + (kids === 1 ? " REPLY" : " REPLIES");
}
}
document.addEventListener("click", function (e) {
var btn = e.target.closest ? e.target.closest(".c-toggle") : null;
if (!btn) return;
var item = btn.closest(".c-item");
setThread(item, !item.classList.contains("collapsed"));
});
var collapseAll = document.getElementById("collapseAll");
if (collapseAll) {
collapseAll.addEventListener("click", function () {
var expanded = collapseAll.getAttribute("aria-expanded") === "true";
document.querySelectorAll(".c-list > .c-item").forEach(function (item) {
if (item.querySelector(":scope > .c-children")) setThread(item, expanded);
});
collapseAll.setAttribute("aria-expanded", String(!expanded));
collapseAll.textContent = expanded ? "EXPAND REPLIES" : "COLLAPSE REPLIES";
toast(expanded ? "ALL THREADS COLLAPSED" : "ALL THREADS EXPANDED");
});
}
/* ---------------- composer ---------------- */
var counter = document.getElementById("counter");
var form = document.getElementById("composer");
var list = document.getElementById("cList");
var countBadge = document.getElementById("cCount");
var MAX = 480;
var posted = 0;
function updateCounter() {
if (!textarea || !counter) return;
var n = textarea.value.length;
counter.textContent = n + " / " + MAX;
counter.classList.toggle("warn", n > MAX * 0.8 && n < MAX);
counter.classList.toggle("max", n >= MAX);
}
if (textarea) {
textarea.addEventListener("input", updateCounter);
updateCounter();
}
function esc(s) {
return s.replace(/[&<>"']/g, function (c) {
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c];
});
}
if (form) {
form.addEventListener("submit", function (e) {
e.preventDefault();
var text = (textarea.value || "").trim();
if (text.length < 3) {
toast("WRITE SOMETHING FIRST");
textarea.focus();
return;
}
posted += 1;
var li = document.createElement("li");
li.className = "c-item is-new";
li.innerHTML =
'<div class="c-body">' +
'<div class="c-meta"><span class="c-av" aria-hidden="true">YO</span>' +
'<b class="c-name">you</b><span class="badge badge-staff mono">NEW</span>' +
'<span class="c-time mono">just now</span></div>' +
'<p class="c-text">' +
esc(text) +
"</p>" +
'<div class="c-actions">' +
'<div class="vote" data-vote-group><button class="v-btn" type="button" data-vote="up" aria-label="Upvote">▲</button>' +
'<span class="v-count mono">1</span>' +
'<button class="v-btn" type="button" data-vote="down" aria-label="Downvote">▼</button></div>' +
'<button class="c-reply" type="button">REPLY</button>' +
"</div></div>";
list.insertBefore(li, list.firstElementChild);
textarea.value = "";
updateCounter();
if (countBadge) countBadge.textContent = String(6 + posted);
toast("COMMENT POSTED");
setTimeout(function () {
li.classList.remove("is-new");
}, 2400);
});
}
/* ---------------- TOC via IntersectionObserver ---------------- */
var links = Array.prototype.slice.call(document.querySelectorAll(".toc a"));
var targets = links
.map(function (a) {
return document.getElementById(a.dataset.toc);
})
.filter(Boolean);
function activate(id) {
links.forEach(function (a) {
a.classList.toggle("active", a.dataset.toc === id);
});
}
if (targets.length && "IntersectionObserver" in window) {
var visible = new Map();
var io = new IntersectionObserver(
function (entries) {
entries.forEach(function (en) {
if (en.isIntersecting) visible.set(en.target.id, en.boundingClientRect.top);
else visible.delete(en.target.id);
});
if (visible.size) {
var best = null;
visible.forEach(function (top, id) {
if (best === null || top < best.top) best = { id: id, top: top };
});
activate(best.id);
}
},
{ rootMargin: "-100px 0px -60% 0px", threshold: 0 }
);
targets.forEach(function (t) {
io.observe(t);
});
activate(targets[0].id);
}
links.forEach(function (a) {
a.addEventListener("click", function () {
activate(a.dataset.toc);
});
});
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Reverse engineering a $6 thermal printer — SOLDER & SMOKE</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>
<a class="skip" href="#article">Skip to article</a>
<header class="masthead">
<div class="wrap mast-inner">
<a class="brand" href="#" aria-label="SOLDER and SMOKE home">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="30" height="30" role="presentation">
<rect x="1" y="1" width="30" height="30" fill="none" stroke="currentColor" stroke-width="2"/>
<path d="M7 22 L13 10 L17 18 L21 13 L25 22" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linejoin="miter"/>
<circle cx="13" cy="10" r="2.4" fill="currentColor"/>
</svg>
</span>
<span class="brand-text">SOLDER<span class="amp">&</span>SMOKE</span>
</a>
<nav class="mast-nav" aria-label="Sections">
<a href="#article">Teardowns</a>
<a href="#bom">Parts</a>
<a href="#comments">Comments</a>
<a class="nav-cta" href="#composer">Submit a tip</a>
</nav>
<button class="menu-btn" type="button" id="menuBtn" aria-expanded="false" aria-controls="mobileNav">MENU</button>
</div>
<nav class="mobile-nav" id="mobileNav" hidden aria-label="Sections (mobile)">
<a href="#article">Teardowns</a>
<a href="#bom">Parts</a>
<a href="#comments">Comments</a>
<a href="#composer">Submit a tip</a>
</nav>
<div class="progress" role="progressbar" aria-label="Reading progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0" id="progressBar">
<span class="progress-fill" id="progressFill"></span>
</div>
</header>
<main class="wrap layout">
<article class="article" id="article">
<div class="kicker">
<span class="tag-cat">TEARDOWN</span>
<span class="kicker-meta mono">LOG #0417 · SERIAL BUS</span>
</div>
<h1 class="headline">Reverse engineering a $6 thermal printer</h1>
<p class="dek">It arrived in a padded envelope with no datasheet, no driver, and a USB-C port that
only charges. Two evenings, one logic analyzer and a lot of receipt paper later, it prints
arbitrary bitmaps over Bluetooth — and the protocol turned out to be almost polite.</p>
<div class="byline">
<span class="avatar" aria-hidden="true">MK</span>
<div class="byline-txt">
<p class="byline-name">Mara Kovacs</p>
<p class="byline-meta mono">30 JUL 2026 · 14 MIN READ · 6 COMMENTS</p>
</div>
<div class="share" role="group" aria-label="Share this article">
<button class="btn-share" type="button" data-share="link" id="copyLink">COPY LINK</button>
<button class="btn-share" type="button" data-share="cite">CITE</button>
<button class="btn-share" type="button" data-share="save" id="saveBtn" aria-pressed="false">SAVE</button>
</div>
</div>
<figure class="fig fig-hero">
<div class="duo duo-printer" aria-hidden="true">
<span class="d-body"></span>
<span class="d-slot"></span>
<span class="d-paper"></span>
<span class="d-led"></span>
<span class="d-scan"></span>
</div>
<figcaption class="mono">FIG. 1 — The unit as received. Single ABS shell, four hidden clips, zero screws.</figcaption>
</figure>
<h2 id="s-teardown" class="h2">The teardown</h2>
<p>The shell has no fasteners at all. Four internal clips run along the seam, two per long side,
and they release in order from the paper slot backwards if you slide a plastic spudger in at a
shallow angle. Do the front pair first and the rear pair pop with almost no force. Do it in the
other order and you will snap the left rear clip, which I know because I snapped the left rear clip.</p>
<p>Inside: one four-layer board about the size of a credit card, a 1200 mAh pouch cell held down
with foam tape, a stepper-free platen roller driven by a small brushed motor, and the thermal
head itself on a flex ribbon with a 24-pin ZIF connector. The main IC has its markings lasered
off, but the pinout, the 8 MHz crystal and the two-wire debug pads made the family obvious
within a few minutes of probing.</p>
<blockquote class="pull">
<p>Nobody obfuscates a protocol they expect you to look at. This one was never obfuscated —
it was simply never documented.</p>
</blockquote>
<p>The debug pads are unpopulated but on a 1.27 mm pitch, so a set of pogo pins and a steady hand
is enough. I did not need them in the end: the interesting traffic was all on the air, and the
vendor app was happy to talk while a sniffer sat between it and the printer.</p>
<figure class="fig">
<div class="duo duo-board" aria-hidden="true">
<span class="b-pcb"></span>
<span class="b-mcu"></span>
<span class="b-cap b-cap1"></span>
<span class="b-cap b-cap2"></span>
<span class="b-zif"></span>
<span class="b-trace"></span>
</div>
<figcaption class="mono">FIG. 2 — Main board, top side. MCU markings removed; ZIF at right feeds the thermal head.</figcaption>
</figure>
<h2 id="s-protocol" class="h2">Sniffing the protocol</h2>
<p>The printer advertises a single GATT service with one write-without-response characteristic and
one notify characteristic. Everything — status, feed, density, image data — moves through those
two handles as framed packets. Each frame starts with a two-byte magic, carries a command byte,
a length, a payload and a CRC-8 with polynomial 0x07, then a fixed terminator.</p>
<div class="code" data-lang="python">
<div class="code-head">
<span class="mono code-file">sniff_frames.py</span>
<button class="btn-copy" type="button" data-copy="#code1">COPY</button>
</div>
<pre id="code1"><code><span class="ln"></span><span class="c"># frame = 51 78 CMD 00 LEN 00 PAYLOAD CRC FF</span>
<span class="ln"></span><span class="k">def</span> <span class="f">frame</span>(cmd, payload=<span class="s">b""</span>):
<span class="ln"></span> body = <span class="fn">bytes</span>([<span class="n">0x51</span>, <span class="n">0x78</span>, cmd, <span class="n">0x00</span>, <span class="fn">len</span>(payload), <span class="n">0x00</span>])
<span class="ln"></span> <span class="k">return</span> body + payload + <span class="fn">bytes</span>([crc8(payload), <span class="n">0xFF</span>])
<span class="ln"></span>
<span class="ln"></span><span class="k">def</span> <span class="f">crc8</span>(data, poly=<span class="n">0x07</span>):
<span class="ln"></span> crc = <span class="n">0</span>
<span class="ln"></span> <span class="k">for</span> b <span class="k">in</span> data:
<span class="ln"></span> crc ^= b
<span class="ln"></span> <span class="k">for</span> _ <span class="k">in</span> <span class="fn">range</span>(<span class="n">8</span>):
<span class="ln"></span> crc = ((crc << <span class="n">1</span>) ^ poly) & <span class="n">0xFF</span> <span class="k">if</span> crc & <span class="n">0x80</span> <span class="k">else</span> crc << <span class="n">1</span>
<span class="ln"></span> <span class="k">return</span> crc</code></pre>
</div>
<p>Command <code class="ic">0xA9</code> is a keepalive the app sends every two seconds. Ignore it and the
printer sleeps mid-job, which produced my first three half-printed test pages. Command
<code class="ic">0xA2</code> sets thermal density from 1 to 5; anything above 3 on cheap paper bleeds badly.
Command <code class="ic">0xA6</code> feeds a line without heating, and <code class="ic">0xA1</code> takes 48 bytes of packed
1-bit pixels — one full 384-dot row.</p>
<h3 class="h3">What the sniff turned up</h3>
<ul class="findings">
<li><b>384 dots per line</b>, 8 dots/mm, so the printable width is exactly 48 mm on 58 mm paper.</li>
<li><b>No pairing secret.</b> Any BLE central that knows the service UUID can print. There is no auth at all.</li>
<li><b>The CRC is checked.</b> Bad frames are dropped silently — no NAK, no notify, nothing.</li>
<li><b>Row order is MSB-first</b>, left pixel in bit 7, which is the opposite of what I guessed first.</li>
<li><b>Battery status</b> arrives unsolicited on the notify handle roughly every 10 seconds as a single percentage byte.</li>
<li><b>Overheat cutout</b> at a reported 58 °C halts the job and only resumes after a fresh keepalive.</li>
</ul>
<aside class="bom" id="bom" aria-labelledby="bom-title">
<h3 class="bom-title mono" id="bom-title">BILL OF MATERIALS — BENCH RIG</h3>
<div class="bom-scroll">
<table class="bom-table">
<thead>
<tr><th scope="col">REF</th><th scope="col">ITEM</th><th scope="col">SPEC</th><th scope="col">QTY</th><th scope="col">EST.</th></tr>
</thead>
<tbody>
<tr><td>U1</td><td>Thermal printer</td><td>58 mm BLE, unbranded</td><td>1</td><td>$6.10</td></tr>
<tr><td>U2</td><td>Logic analyzer</td><td>8-ch, 24 MS/s</td><td>1</td><td>$11.00</td></tr>
<tr><td>U3</td><td>BLE sniffer dongle</td><td>2.4 GHz, follow-connection</td><td>1</td><td>$18.50</td></tr>
<tr><td>J1</td><td>Pogo pin jig</td><td>1.27 mm pitch, 4-pos</td><td>1</td><td>$4.20</td></tr>
<tr><td>P1</td><td>Receipt paper</td><td>58 mm × 12 m, BPA-free</td><td>6</td><td>$5.40</td></tr>
<tr><td>T1</td><td>Plastic spudger set</td><td>nylon, 3-piece</td><td>1</td><td>$2.80</td></tr>
</tbody>
</table>
</div>
<p class="bom-total mono">TOTAL — $48.00 · ALL PRICES INDICATIVE, JUL 2026</p>
</aside>
<h2 id="s-driver" class="h2">Writing a driver</h2>
<p>With the frame format nailed down the driver is short. Dither the source image to 1-bit with
Floyd–Steinberg, pack each row into 48 bytes, and stream the rows with a small delay so the head
driver keeps up. Sending faster than about 12 ms per row makes the print smear vertically —
the roller is the bottleneck, not the radio.</p>
<div class="code" data-lang="javascript">
<div class="code-head">
<span class="mono code-file">print.js</span>
<button class="btn-copy" type="button" data-copy="#code2">COPY</button>
</div>
<pre id="code2"><code><span class="ln"></span><span class="k">const</span> WIDTH = <span class="n">384</span>, BYTES = WIDTH / <span class="n">8</span>;
<span class="ln"></span>
<span class="ln"></span><span class="k">async function</span> <span class="f">printRows</span>(chr, rows) {
<span class="ln"></span> <span class="k">await</span> <span class="f">send</span>(chr, <span class="f">frame</span>(<span class="n">0xA2</span>, <span class="k">new</span> <span class="fn">Uint8Array</span>([<span class="n">3</span>]))); <span class="c">// density</span>
<span class="ln"></span> <span class="k">for</span> (<span class="k">const</span> row <span class="k">of</span> rows) {
<span class="ln"></span> <span class="k">await</span> <span class="f">send</span>(chr, <span class="f">frame</span>(<span class="n">0xA1</span>, row.<span class="f">slice</span>(<span class="n">0</span>, BYTES)));
<span class="ln"></span> <span class="k">await</span> <span class="f">sleep</span>(<span class="n">12</span>); <span class="c">// roller limit</span>
<span class="ln"></span> }
<span class="ln"></span> <span class="k">await</span> <span class="f">send</span>(chr, <span class="f">frame</span>(<span class="n">0xA6</span>, <span class="k">new</span> <span class="fn">Uint8Array</span>([<span class="n">40</span>]))); <span class="c">// tear-off feed</span>
<span class="ln"></span>}</code></pre>
</div>
<p>The full source, the captured frame dumps and a small web front end that prints straight from a
browser over Web Bluetooth are all in the project log, along with the three failed attempts that
taught me about the keepalive.</p>
<figure class="fig">
<div class="duo duo-receipt" aria-hidden="true">
<span class="r-paper"></span>
<span class="r-l r-l1"></span><span class="r-l r-l2"></span><span class="r-l r-l3"></span>
<span class="r-blk"></span>
<span class="r-zig"></span>
</div>
<figcaption class="mono">FIG. 3 — First clean bitmap after fixing bit order and honouring the keepalive.</figcaption>
</figure>
<h2 id="s-next" class="h2">Where this goes next</h2>
<p>Because there is no authentication, a printer left powered on is a printer anyone in radio range
can drive. That is a fun party trick and a genuinely bad property for a device people put on a
shop counter. The firmware appears to be updatable over the same characteristic with command
<code class="ic">0xB0</code>, which is the next thing I want to characterise — carefully, on a second unit,
with a full flash dump taken first.</p>
<p>If you have one of these with a slightly different shell, the protocol is very likely identical.
Check whether your unit answers <code class="ic">0xA9</code> and let me know in the comments; I am
collecting a compatibility list.</p>
<div class="related" aria-label="Related posts">
<h3 class="rel-title mono">RELATED FROM THE LOG</h3>
<div class="rel-grid">
<a class="rel-card" href="#article">
<span class="rel-tag mono">TEARDOWN</span>
<span class="rel-h">The $3 e-ink tag that speaks a real protocol</span>
<span class="rel-m mono">9 MIN · 41 COMMENTS</span>
</a>
<a class="rel-card" href="#article">
<span class="rel-tag mono">TOOLS</span>
<span class="rel-h">Building a pogo jig from a broken keyboard</span>
<span class="rel-m mono">6 MIN · 18 COMMENTS</span>
</a>
<a class="rel-card" href="#article">
<span class="rel-tag mono">RADIO</span>
<span class="rel-h">Following BLE connections on a $18 dongle</span>
<span class="rel-m mono">12 MIN · 77 COMMENTS</span>
</a>
</div>
</div>
<div class="tags" aria-label="Tags">
<a class="tag" href="#article">#thermal-printer</a>
<a class="tag" href="#article">#ble</a>
<a class="tag" href="#article">#reverse-engineering</a>
<a class="tag" href="#article">#protocol</a>
<a class="tag" href="#article">#teardown</a>
<a class="tag" href="#article">#web-bluetooth</a>
</div>
<section class="comments" id="comments" aria-labelledby="comments-title">
<div class="comments-head">
<h2 class="h2" id="comments-title">Comments <span class="count mono" id="cCount">6</span></h2>
<button class="btn-ghost" type="button" id="collapseAll" aria-expanded="true">COLLAPSE REPLIES</button>
</div>
<ul class="c-list" id="cList">
<li class="c-item" data-votes="34">
<div class="c-body">
<div class="c-meta"><span class="c-av" aria-hidden="true">DV</span><b class="c-name">devnull_vic</b><span class="badge badge-op mono">OP</span><span class="c-time mono">2h ago</span></div>
<p class="c-text">Author here — the compatibility list is live in the log. So far six shell variants, all identical on the wire. The one exception is a unit with a white paper door that uses density 1–7 instead of 1–5.</p>
<div class="c-actions">
<div class="vote" data-vote-group><button class="v-btn" type="button" data-vote="up" aria-label="Upvote">▲</button><span class="v-count mono">34</span><button class="v-btn" type="button" data-vote="down" aria-label="Downvote">▼</button></div>
<button class="c-reply" type="button">REPLY</button>
<button class="c-toggle" type="button" aria-expanded="true">HIDE 2 REPLIES</button>
</div>
</div>
<ul class="c-children">
<li class="c-item">
<div class="c-body">
<div class="c-meta"><span class="c-av" aria-hidden="true">RS</span><b class="c-name">rustysolder</b><span class="c-time mono">96m ago</span></div>
<p class="c-text">Mine is the white-door variant and density 6 melts the paper into a grey smear instantly. 4 is the practical ceiling. Good excuse to buy nicer rolls.</p>
<div class="c-actions">
<div class="vote" data-vote-group><button class="v-btn" type="button" data-vote="up" aria-label="Upvote">▲</button><span class="v-count mono">12</span><button class="v-btn" type="button" data-vote="down" aria-label="Downvote">▼</button></div>
<button class="c-reply" type="button">REPLY</button>
</div>
</div>
</li>
<li class="c-item">
<div class="c-body">
<div class="c-meta"><span class="c-av" aria-hidden="true">KP</span><b class="c-name">k_prasad</b><span class="badge badge-staff mono">STAFF</span><span class="c-time mono">71m ago</span></div>
<p class="c-text">Adding the variant table to the post as an addendum. If anyone has one that rejects 0xA1 outright, please dump the notify handle before power cycling — that is the interesting failure mode.</p>
<div class="c-actions">
<div class="vote" data-vote-group><button class="v-btn" type="button" data-vote="up" aria-label="Upvote">▲</button><span class="v-count mono">9</span><button class="v-btn" type="button" data-vote="down" aria-label="Downvote">▼</button></div>
<button class="c-reply" type="button">REPLY</button>
</div>
</div>
</li>
</ul>
</li>
<li class="c-item" data-votes="21">
<div class="c-body">
<div class="c-meta"><span class="c-av" aria-hidden="true">MB</span><b class="c-name">mel_bitrot</b><span class="c-time mono">3h ago</span></div>
<p class="c-text">The 12 ms figure matches what I measured on a similar head. If you preheat with a couple of blank rows at density 2 first, you can drop to 9 ms for the rest of the page without smearing.</p>
<div class="c-actions">
<div class="vote" data-vote-group><button class="v-btn" type="button" data-vote="up" aria-label="Upvote">▲</button><span class="v-count mono">21</span><button class="v-btn" type="button" data-vote="down" aria-label="Downvote">▼</button></div>
<button class="c-reply" type="button">REPLY</button>
<button class="c-toggle" type="button" aria-expanded="true">HIDE 1 REPLY</button>
</div>
</div>
<ul class="c-children">
<li class="c-item">
<div class="c-body">
<div class="c-meta"><span class="c-av" aria-hidden="true">TQ</span><b class="c-name">tinq</b><span class="c-time mono">2h ago</span></div>
<p class="c-text">Confirmed on two units. Preheat rows also stop the first printed line from coming out faint, which had been bugging me for months.</p>
<div class="c-actions">
<div class="vote" data-vote-group><button class="v-btn" type="button" data-vote="up" aria-label="Upvote">▲</button><span class="v-count mono">15</span><button class="v-btn" type="button" data-vote="down" aria-label="Downvote">▼</button></div>
<button class="c-reply" type="button">REPLY</button>
</div>
</div>
</li>
</ul>
</li>
<li class="c-item" data-votes="7">
<div class="c-body">
<div class="c-meta"><span class="c-av" aria-hidden="true">GH</span><b class="c-name">ghost_of_pcb</b><span class="c-time mono">5h ago</span></div>
<p class="c-text">Lasered-off markings almost always mean a repackaged part rather than a custom one. Try dumping the ROM through the two-wire pads and grepping for a vendor string — half the time it is right there in the bootloader banner.</p>
<div class="c-actions">
<div class="vote" data-vote-group><button class="v-btn" type="button" data-vote="up" aria-label="Upvote">▲</button><span class="v-count mono">7</span><button class="v-btn" type="button" data-vote="down" aria-label="Downvote">▼</button></div>
<button class="c-reply" type="button">REPLY</button>
</div>
</div>
</li>
</ul>
<form class="composer" id="composer" novalidate>
<label class="comp-label mono" for="cText">JOIN THE THREAD</label>
<textarea id="cText" class="comp-input" rows="4" maxlength="480" placeholder="Measured something different? Post your numbers."></textarea>
<div class="comp-foot">
<span class="counter mono" id="counter" aria-live="polite">0 / 480</span>
<button class="btn-post" type="submit">POST COMMENT</button>
</div>
</form>
</section>
</article>
<aside class="rail" aria-label="Article contents">
<div class="rail-inner">
<p class="rail-title mono">ON THIS PAGE</p>
<nav class="toc" id="toc">
<a href="#s-teardown" data-toc="s-teardown">The teardown</a>
<a href="#s-protocol" data-toc="s-protocol">Sniffing the protocol</a>
<a href="#bom" data-toc="bom">Bill of materials</a>
<a href="#s-driver" data-toc="s-driver">Writing a driver</a>
<a href="#s-next" data-toc="s-next">Where this goes next</a>
<a href="#comments" data-toc="comments">Comments</a>
</nav>
<div class="rail-box">
<p class="rail-box-t mono">PROJECT LOG</p>
<p class="rail-box-p">Frame dumps, dither code and the Web Bluetooth front end.</p>
<a class="rail-link" href="#article">OPEN LOG →</a>
</div>
</div>
</aside>
</main>
<footer class="foot">
<div class="wrap foot-inner">
<p class="mono">SOLDER & SMOKE — A FICTIONAL HARDWARE-HACKING BLOG. DEMO CONTENT ONLY.</p>
<p class="mono">NO REAL PRODUCTS, PRICES OR PEOPLE ARE DEPICTED.</p>
</div>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Hardware Writeup Article
A full-length technical article page for a fictional hardware-hacking blog, built on a pure-black canvas with bone-white text, an acid-green accent and hard 2px borders — no rounded corners anywhere. The piece is a teardown writeup (“Reverse engineering a $6 thermal printer”) laid out as category tag, slab uppercase headline, dek, byline row and share controls, then long prose broken up by pull quotes, CSS-drawn duotone figures with mono captions, a findings list and a boxed bill of materials.
Two code blocks carry hand-rolled syntax colouring, CSS-counter line numbers and copy buttons that
strip the gutter before writing to the clipboard. The share row copies the page link or a formatted
citation, and the save button is a real toggle with aria-pressed. A sticky progress bar under the
masthead reports scroll depth through the article and exposes it as a progressbar with a live
aria-valuenow.
The comment section holds six fictional comments nested one level deep, with vote controls that track up/down/none state, an OP badge and a staff badge, per-thread collapse buttons that keep their label in sync with the reply count, and a global collapse control. The composer counts characters toward a 480 limit, warns near the cap, escapes input, and prepends new comments to the top of the list with a short entrance animation. On wide screens a sticky right rail shows a table of contents that highlights the current section.
How this look is built
The information architecture follows the spirit of long-form hardware-blog writeups like Hackaday
article pages, but every name, byline and part is invented. The layout is a single CSS grid that
gains its right rail only above 1040px, so the rail needs no JS and no duplicate markup;
position: sticky handles both the masthead and the rail, replacing the old scroll-listener
“stick the header” hack. Scroll spying uses an IntersectionObserver with a -100px 0px -60%
root margin instead of measuring offsetTop on every scroll event — it fires only on crossings, so
it stays smooth without throttling, and only the progress bar needs a passive scroll listener
(rAF-batched). Line numbers come from a CSS counter on .ln rather than a second column of markup,
the figures are pure gradient/repeating-conic-gradient compositions so there are zero image
requests, and scroll-padding-top keeps anchor jumps clear of the sticky masthead.
Illustrative UI only — always follow real safety guidance when working with tools, electronics, or repairs.