DIY — Community Project Hub
A community hardware project gallery for the fictional CIRCUITO Project Hub, built as one static page in vanilla JS. A sticky sidebar filters live by board family, difficulty and a searchable component multi-select whose picks become removable chips, mirrored in an active-filter row with clear-all. Eighteen cards carry CSS-drawn circuit thumbnails, respect toggles, difficulty badges and mono part previews, alongside sorting, featured collections, an empty state and a staggered load-more.
MCP
Code
:root{
--teal:#00979d;
--teal-d:#017a80;
--teal-l:#4fbcc0;
--mist:#eef6f6;
--mist-2:#e0eded;
--ink:#1a2b34;
--ink-2:#3b5361;
--muted:#6f8592;
--amber:#f5a623;
--amber-soft:#fdf0d8;
--white:#ffffff;
--bg:#fbfdfd;
--line:rgba(26,43,52,.12);
--line-2:rgba(26,43,52,.22);
--ok:#2f9e6f;
--danger:#d4503e;
--r-sm:6px; --r-md:12px; --r-lg:18px;
--sh-1:0 1px 2px rgba(26,43,52,.06);
--sh-2:0 6px 18px -8px rgba(26,43,52,.22);
--sh-3:0 18px 42px -18px rgba(26,43,52,.32);
--mono:"JetBrains Mono",ui-monospace,SFMono-Regular,Menlo,monospace;
}
*,*::before,*::after{box-sizing:border-box}
[hidden]{display:none !important}
html{-webkit-text-size-adjust:100%}
body{
margin:0;
font-family:Inter,system-ui,-apple-system,"Segoe UI",sans-serif;
line-height:1.5;
color:var(--ink);
background:var(--bg);
-webkit-font-smoothing:antialiased;
text-rendering:optimizeLegibility;
}
h1,h2,h3{margin:0;line-height:1.15;letter-spacing:-.02em}
p{margin:0}
button,input,select{font:inherit;color:inherit}
a{color:inherit;text-decoration:none}
fieldset{margin:0;padding:0;border:0}
:focus-visible{outline:3px solid var(--amber);outline-offset:2px;border-radius:4px}
.wrap{width:min(1240px,100% - 40px);margin-inline:auto}
.mono{font-family:var(--mono)}
.muted{color:var(--muted)}
.skip{position:absolute;left:-9999px;top:0;background:var(--ink);color:#fff;padding:10px 16px;z-index:60;border-radius:0 0 var(--r-sm) 0}
.skip:focus{left:0}
/* ---------- buttons ---------- */
.btn{
display:inline-flex;align-items:center;justify-content:center;gap:8px;
padding:11px 20px;border-radius:999px;border:1px solid transparent;
font-weight:600;font-size:.94rem;cursor:pointer;background:none;
transition:background .18s ease,color .18s ease,border-color .18s ease,transform .12s ease,box-shadow .18s ease;
}
.btn:active{transform:translateY(1px)}
.btn.sm{padding:8px 15px;font-size:.86rem}
.btn-primary{background:var(--teal);color:#fff;box-shadow:var(--sh-2)}
.btn-primary:hover{background:var(--teal-d)}
.btn-outline{background:var(--white);border-color:var(--line-2);color:var(--ink)}
.btn-outline:hover{border-color:var(--teal);color:var(--teal-d);background:var(--mist)}
.btn-ghost{border-color:var(--line-2);color:var(--ink-2)}
.btn-ghost:hover{background:var(--mist);border-color:var(--teal-l);color:var(--teal-d)}
.linkbtn{background:none;border:0;padding:2px 4px;color:var(--teal-d);font-weight:600;font-size:.84rem;cursor:pointer;border-radius:var(--r-sm)}
.linkbtn:hover{text-decoration:underline}
/* ---------- topbar ---------- */
.topbar{position:sticky;top:0;z-index:40;background:rgba(255,255,255,.88);backdrop-filter:blur(10px);border-bottom:1px solid var(--line)}
.topbar-in{display:flex;align-items:center;gap:22px;height:66px}
.brand{display:flex;align-items:center;gap:10px}
.brand-mark{color:var(--teal);display:grid;place-items:center}
.brand-txt{display:flex;flex-direction:column;line-height:1.05}
.brand-txt b{font-size:1.02rem;font-weight:800;letter-spacing:.06em}
.brand-txt span{font-family:var(--mono);font-size:.66rem;color:var(--muted);letter-spacing:.16em;text-transform:uppercase}
.topnav{display:flex;gap:22px;margin-left:auto;font-size:.92rem;font-weight:500;color:var(--ink-2)}
.topnav a{padding:6px 2px;border-bottom:2px solid transparent}
.topnav a:hover{color:var(--teal-d);border-color:var(--teal-l)}
/* ---------- hero ---------- */
.hero{
background:
linear-gradient(180deg,var(--mist) 0%,rgba(238,246,246,.35) 100%),
repeating-linear-gradient(0deg,rgba(0,151,157,.07) 0 1px,transparent 1px 28px),
repeating-linear-gradient(90deg,rgba(0,151,157,.07) 0 1px,transparent 1px 28px);
border-bottom:1px solid var(--line);
}
.hero-in{display:grid;grid-template-columns:1.15fr .85fr;gap:40px;align-items:center;padding:58px 0 62px}
.eyebrow{font-family:var(--mono);font-size:.7rem;letter-spacing:.2em;text-transform:uppercase;color:var(--teal-d);background:var(--white);border:1px solid var(--line);padding:6px 12px;border-radius:999px;display:inline-block}
.hero h1{font-size:clamp(2rem,4.4vw,3.1rem);font-weight:800;margin:18px 0 14px;max-width:14ch}
.hero-copy>p{color:var(--ink-2);max-width:52ch;font-size:1.03rem}
.hero-actions{display:flex;gap:12px;flex-wrap:wrap;margin-top:24px}
.hero-stats{display:flex;gap:30px;list-style:none;margin:32px 0 0;padding:0}
.hero-stats li{display:flex;flex-direction:column}
.hero-stats b{font-family:var(--mono);font-size:1.35rem;color:var(--ink)}
.hero-stats span{font-size:.76rem;color:var(--muted);text-transform:uppercase;letter-spacing:.1em}
.hero-art{display:grid;place-items:center}
.board-art{
position:relative;width:min(340px,100%);aspect-ratio:4/3;border-radius:var(--r-lg);
background:linear-gradient(160deg,#0d7f85,#046065);
box-shadow:var(--sh-3);border:1px solid rgba(255,255,255,.25);overflow:hidden;
}
.board-art::after{content:"";position:absolute;inset:0;background:repeating-linear-gradient(90deg,rgba(255,255,255,.06) 0 1px,transparent 1px 14px)}
.chip-lg{position:absolute;left:50%;top:46%;transform:translate(-50%,-50%);background:#10222a;color:#cfe9ea;font-family:var(--mono);font-size:.68rem;letter-spacing:.14em;padding:20px 26px;border-radius:var(--r-sm);text-align:center;box-shadow:0 8px 20px rgba(0,0,0,.35);z-index:2}
.chip-lg i{display:block;font-style:normal;color:var(--amber);font-size:.6rem;margin-top:5px}
.pin-row{position:absolute;left:8%;right:8%;height:12px;background:repeating-linear-gradient(90deg,#0b1a20 0 6px,transparent 6px 13px);border-radius:2px;opacity:.85}
.pin-row.top{top:9%}
.pin-row.bottom{bottom:9%}
.led{position:absolute;width:9px;height:9px;border-radius:50%;box-shadow:0 0 12px currentColor}
.led-a{right:14%;top:26%;background:var(--amber);color:var(--amber);animation:blink 2.4s infinite}
.led-b{right:14%;top:36%;background:#7ee0b8;color:#7ee0b8;animation:blink 2.4s .8s infinite}
@keyframes blink{0%,60%,100%{opacity:1}70%,90%{opacity:.25}}
.trace{position:absolute;height:2px;background:rgba(255,255,255,.22);border-radius:2px}
.t1{left:10%;top:30%;width:26%}
.t2{left:10%;top:64%;width:34%}
.t3{right:10%;bottom:26%;width:22%}
/* ---------- layout ---------- */
.layout{display:grid;grid-template-columns:262px 1fr;gap:34px;padding:38px 0 56px;align-items:start}
/* ---------- filters ---------- */
.filters-in{position:sticky;top:86px;background:var(--white);border:1px solid var(--line);border-radius:var(--r-lg);padding:20px;box-shadow:var(--sh-1)}
.fhead{display:flex;align-items:center;justify-content:space-between;margin-bottom:6px}
.fhead h2{font-size:1rem}
.fgroup{padding:16px 0;border-top:1px solid var(--line)}
.fgroup:first-of-type{border-top:0;padding-top:10px}
legend{font-family:var(--mono);font-size:.68rem;letter-spacing:.16em;text-transform:uppercase;color:var(--muted);margin-bottom:10px;padding:0}
.check,.radio{display:flex;align-items:center;gap:10px;padding:7px 6px;border-radius:var(--r-sm);cursor:pointer;font-size:.9rem;transition:background .15s ease}
.check:hover,.radio:hover{background:var(--mist)}
.check input,.radio input{position:absolute;opacity:0;width:0;height:0}
.box{width:17px;height:17px;border:1.5px solid var(--line-2);border-radius:5px;flex:none;display:grid;place-items:center;transition:.15s ease;background:var(--white)}
.check input:checked + .box{background:var(--teal);border-color:var(--teal)}
.check input:checked + .box::after{content:"";width:9px;height:5px;border-left:2px solid #fff;border-bottom:2px solid #fff;transform:rotate(-45deg) translateY(-1px)}
.check input:focus-visible + .box{outline:3px solid var(--amber);outline-offset:2px}
.lbl{flex:1}
.lbl em{font-style:normal;font-family:var(--mono);font-size:.68rem;color:var(--muted);margin-left:5px}
.cnt{font-family:var(--mono);font-size:.72rem;color:var(--muted);background:var(--mist);padding:2px 7px;border-radius:999px}
.dot{width:16px;height:16px;border:1.5px solid var(--line-2);border-radius:50%;flex:none;display:grid;place-items:center;background:var(--white);transition:.15s}
.radio input:checked + .dot{border-color:var(--teal);border-width:5px}
.radio input:focus-visible + .dot{outline:3px solid var(--amber);outline-offset:2px}
.input,.select{
width:100%;padding:9px 12px;border:1px solid var(--line-2);border-radius:var(--r-sm);
background:var(--white);font-size:.88rem;transition:border-color .15s ease,box-shadow .15s ease;
}
.input:focus,.select:focus{outline:none;border-color:var(--teal);box-shadow:0 0 0 3px rgba(0,151,157,.16)}
.combo{position:relative}
.complist{max-height:172px;overflow-y:auto;margin-top:8px;border:1px solid var(--line);border-radius:var(--r-sm);background:var(--mist)}
.copt{display:flex;justify-content:space-between;align-items:center;gap:8px;width:100%;padding:8px 11px;background:none;border:0;text-align:left;cursor:pointer;font-size:.84rem;border-bottom:1px solid rgba(26,43,52,.06)}
.copt:last-child{border-bottom:0}
.copt:hover{background:var(--white);color:var(--teal-d)}
.copt[aria-selected="true"]{background:var(--white);color:var(--teal-d);font-weight:600}
.copt .n{font-family:var(--mono);font-size:.7rem;color:var(--muted)}
.copt-none{padding:12px;font-size:.83rem;color:var(--muted);text-align:center}
.chips{display:flex;flex-wrap:wrap;gap:6px;margin-top:10px}
.chip{display:inline-flex;align-items:center;gap:6px;background:var(--mist);border:1px solid var(--mist-2);color:var(--teal-d);font-size:.76rem;font-weight:600;padding:4px 6px 4px 10px;border-radius:999px;animation:pop .18s ease}
.chip button{background:none;border:0;cursor:pointer;width:17px;height:17px;border-radius:50%;display:grid;place-items:center;color:inherit;font-size:.85rem;line-height:1}
.chip button:hover{background:var(--teal);color:#fff}
@keyframes pop{from{opacity:0;transform:scale(.85)}to{opacity:1;transform:none}}
/* ---------- toolbar / active ---------- */
.toolbar{display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-bottom:14px}
.count{font-size:.9rem;color:var(--ink-2)}
.count b{font-family:var(--mono)}
.sortwrap{display:flex;align-items:center;gap:9px;font-size:.82rem;color:var(--muted);font-family:var(--mono);text-transform:uppercase;letter-spacing:.1em}
.sortwrap .select{width:auto;font-family:Inter,sans-serif;text-transform:none;letter-spacing:0;color:var(--ink)}
.activebar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;background:var(--white);border:1px solid var(--line);border-radius:var(--r-md);padding:10px 14px;margin-bottom:16px}
.activelbl{font-family:var(--mono);font-size:.68rem;letter-spacing:.16em;text-transform:uppercase;color:var(--muted)}
.activechips{display:flex;flex-wrap:wrap;gap:6px;flex:1}
/* ---------- grid / cards ---------- */
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(258px,1fr));gap:20px}
.card{
position:relative;background:var(--white);border:1px solid var(--line);border-radius:var(--r-lg);
overflow:hidden;display:flex;flex-direction:column;
transition:transform .2s ease,box-shadow .2s ease,border-color .2s ease;
}
.card:hover{transform:translateY(-4px);box-shadow:var(--sh-3);border-color:var(--teal-l)}
.card.in{animation:rise .38s cubic-bezier(.2,.7,.3,1) backwards}
@keyframes rise{from{opacity:0;transform:translateY(14px)}to{opacity:1;transform:none}}
.thumb{position:relative;aspect-ratio:16/10;background:var(--mist);overflow:hidden}
.thumb::before{content:"";position:absolute;inset:0;background:repeating-linear-gradient(45deg,rgba(255,255,255,.5) 0 2px,transparent 2px 9px);opacity:.5}
.thumb svg{position:absolute;inset:0;width:100%;height:100%}
.tag{
position:absolute;top:0;right:0;font-family:var(--mono);font-size:.62rem;letter-spacing:.12em;
text-transform:uppercase;color:#fff;padding:6px 12px 6px 16px;border-radius:0 0 0 14px;z-index:2;font-weight:700;
}
.tag-nova{background:var(--teal)}
.tag-pulse{background:#2f6ea8}
.tag-mote{background:#7b5cc4}
.tag-tiny{background:#cf7a1b}
.cbody{padding:15px 16px 16px;display:flex;flex-direction:column;gap:10px;flex:1}
.ctitle{font-size:1rem;font-weight:700;line-height:1.28}
.author{display:flex;align-items:center;gap:7px;font-size:.8rem;color:var(--muted)}
.avatar{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;font-size:.66rem;font-weight:700;color:#fff;flex:none;font-family:var(--mono)}
.meta{display:flex;flex-wrap:wrap;gap:6px}
.badge{font-size:.7rem;font-weight:600;padding:3px 9px;border-radius:999px;border:1px solid transparent;font-family:var(--mono);letter-spacing:.04em}
.b-Beginner{background:#e6f6ef;color:#20724f;border-color:#c8ebdc}
.b-Intermediate{background:var(--amber-soft);color:#9a640a;border-color:#f6dfb2}
.b-Advanced{background:#fbe6e3;color:#a8382a;border-color:#f3cdc7}
.time{font-size:.7rem;font-family:var(--mono);color:var(--muted);background:var(--mist);padding:3px 9px;border-radius:999px}
.parts{list-style:none;margin:0;padding:0;font-family:var(--mono);font-size:.72rem;color:var(--ink-2);display:flex;flex-direction:column;gap:3px;border-top:1px dashed var(--line);padding-top:10px}
.parts li::before{content:"›";color:var(--teal);margin-right:6px}
.parts .more{color:var(--muted)}
.parts .more::before{content:"+";color:var(--muted)}
.cfoot{display:flex;align-items:center;justify-content:space-between;margin-top:auto;padding-top:12px}
.respect{display:inline-flex;align-items:center;gap:7px;background:var(--white);border:1px solid var(--line-2);border-radius:999px;padding:5px 12px 5px 9px;cursor:pointer;font-family:var(--mono);font-size:.78rem;font-weight:600;color:var(--ink-2);transition:.16s ease}
.respect:hover{border-color:var(--danger);color:var(--danger)}
.respect svg{width:14px;height:14px;transition:transform .2s ease}
.respect[aria-pressed="true"]{background:#fdeeeb;border-color:#f3c8c1;color:var(--danger)}
.respect[aria-pressed="true"] svg{fill:currentColor;transform:scale(1.12)}
.stamp{font-family:var(--mono);font-size:.68rem;color:var(--muted)}
/* ---------- empty / more ---------- */
.empty{text-align:center;padding:64px 20px;border:1px dashed var(--line-2);border-radius:var(--r-lg);background:var(--white)}
.empty-art{color:var(--teal-l);margin-bottom:14px}
.empty h3{font-size:1.15rem;margin-bottom:8px}
.empty p{color:var(--muted);margin-bottom:20px}
.moreWrap{display:flex;justify-content:center;margin-top:28px}
/* ---------- collections ---------- */
.collections{background:var(--mist);border-top:1px solid var(--line);border-bottom:1px solid var(--line);padding:54px 0 60px}
.sechead{margin-bottom:24px}
.sechead h2{font-size:1.6rem}
.sechead p{color:var(--muted);margin-top:6px}
.colgrid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:18px}
.colcard{position:relative;background:var(--white);border:1px solid var(--line);border-radius:var(--r-lg);padding:24px;overflow:hidden;transition:transform .2s ease,box-shadow .2s ease}
.colcard:hover,.colcard:focus-visible{transform:translateY(-3px);box-shadow:var(--sh-3)}
.colcard::after{content:"";position:absolute;right:-40px;top:-40px;width:150px;height:150px;border-radius:50%;opacity:.14;background:var(--teal)}
.c-wear::after{background:#7b5cc4}
.c-robo::after{background:var(--amber)}
.colicon{display:grid;place-items:center;width:42px;height:42px;border-radius:var(--r-md);background:var(--mist);color:var(--teal-d);margin-bottom:14px}
.c-wear .colicon{color:#6544b8}
.c-robo .colicon{color:#b3760f}
.colcard h3{font-size:1.12rem;margin-bottom:7px}
.colcard p{color:var(--ink-2);font-size:.9rem;margin-bottom:14px;max-width:34ch}
.colmeta{font-family:var(--mono);font-size:.74rem;color:var(--muted)}
.colmeta b{color:var(--ink)}
/* ---------- footer / toast ---------- */
.foot{padding:26px 0 34px}
.foot-in{display:flex;justify-content:space-between;gap:12px;flex-wrap:wrap;font-size:.76rem;color:var(--ink-2)}
.toast{
position:fixed;left:50%;bottom:26px;transform:translate(-50%,20px);
background:var(--ink);color:#fff;padding:11px 20px;border-radius:999px;font-size:.86rem;
box-shadow:var(--sh-3);opacity:0;pointer-events:none;transition:opacity .22s ease,transform .22s ease;z-index:70;max-width:90vw;text-align:center;
}
.toast.show{opacity:1;transform:translate(-50%,0)}
/* ---------- responsive ---------- */
@media (max-width:980px){
.hero-in{grid-template-columns:1fr;padding:44px 0 48px}
.hero-art{order:-1}
.layout{grid-template-columns:1fr}
.filters-in{position:static}
}
@media (max-width:520px){
.wrap{width:min(100% - 24px,100%)}
.topnav{display:none}
.topbar-in{height:60px;gap:12px}
.hero h1{font-size:1.9rem;max-width:none}
.hero-stats{gap:18px}
.hero-stats b{font-size:1.1rem}
.hero-actions .btn{flex:1 1 100%}
.grid{grid-template-columns:1fr}
.toolbar{align-items:flex-start;flex-direction:column;gap:8px}
.sortwrap{width:100%;justify-content:space-between}
.collections{padding:38px 0 42px}
.sechead h2{font-size:1.32rem}
.foot-in{flex-direction:column;gap:4px}
}
@media (prefers-reduced-motion:reduce){
*,*::before,*::after{animation-duration:.001ms !important;transition-duration:.001ms !important}
}/* CIRCUITO Project Hub — community gallery interactions (vanilla JS) */
(function () {
"use strict";
/* ------------------------------------------------------------------ data */
var BOARDS = {
nova: { label: "Nova", hue: "#00979d" },
pulse: { label: "Pulse", hue: "#2f6ea8" },
mote: { label: "Mote", hue: "#7b5cc4" },
tiny: { label: "Tiny", hue: "#cf7a1b" }
};
var PROJECTS = [
{ id:1, title:"Greenhouse humidity logger with e-paper readout", author:"Mira Solvang", board:"nova", diff:"Beginner", time:"3 h", respect:412, days:4, trend:96, parts:["DHT22 sensor","2.9\" e-paper","SD card module","RTC clock","Li-ion shield"] },
{ id:2, title:"Six-axis desktop arm driven by closed-loop steppers", author:"Tobias Renk", board:"pulse", diff:"Advanced", time:"22 h", respect:1284, days:11, trend:88, parts:["NEMA17 stepper","A4988 driver","Rotary encoder","24V PSU","Limit switch"] },
{ id:3, title:"Conductive-thread jacket with breathing LED spine", author:"Alia Norrbom", board:"tiny", diff:"Intermediate", time:"7 h", respect:733, days:2, trend:99, parts:["WS2812 strip","Conductive thread","LiPo 400mAh","Tilt switch"] },
{ id:4, title:"Mesh soil network for a community allotment", author:"Devan Roque", board:"mote", diff:"Intermediate", time:"9 h", respect:508, days:18, trend:71, parts:["LoRa radio","Capacitive soil probe","Solar panel 5V","TP4056 charger","Antenna 868"] },
{ id:5, title:"Retro seven-segment word clock in a walnut frame", author:"Hana Peltier", board:"nova", diff:"Beginner", time:"5 h", respect:964, days:30, trend:64, parts:["TM1637 display","DS3231 RTC","Rotary encoder","Walnut panel"] },
{ id:6, title:"Line-following robot tuned with a PID web console", author:"Iker Vasquez", board:"pulse", diff:"Intermediate", time:"12 h", respect:851, days:7, trend:85, parts:["IR array x5","TB6612 driver","Wi-Fi module","N20 motors","Li-ion 18650"] },
{ id:7, title:"Pocket theremin with capacitive antenna and speaker", author:"Rune Ashby", board:"nova", diff:"Intermediate", time:"6 h", respect:376, days:14, trend:58, parts:["Copper antenna","PAM8403 amp","8Ω speaker","Potentiometer x2"] },
{ id:8, title:"Battery-free NFC door tag reader for a maker space", author:"Sofia Lindqvist", board:"mote", diff:"Advanced", time:"15 h", respect:642, days:25, trend:49, parts:["PN532 NFC","Relay module","OLED 0.96\"","Buzzer","EEPROM"] },
{ id:9, title:"Sourdough proofing box with PID heat and fan curve", author:"Nils Baumann", board:"nova", diff:"Intermediate", time:"8 h", respect:1109, days:5, trend:93, parts:["Thermistor NTC","SSR 40A","Blower fan","OLED 0.96\"","Heating pad"] },
{ id:10, title:"Wearable posture buzzer stitched into a collar", author:"Yara Mbeki", board:"tiny", diff:"Beginner", time:"2 h", respect:288, days:1, trend:97, parts:["MPU6050 IMU","Vibration motor","LiPo 150mAh","Snap connectors"] },
{ id:11, title:"Ambient air-quality lamp that shifts colour by PM2.5", author:"Elias Fontaine", board:"mote", diff:"Beginner", time:"4 h", respect:721, days:9, trend:80, parts:["PMS5003 sensor","WS2812 ring","Wi-Fi module","Diffuser dome"] },
{ id:12, title:"CNC pen plotter built from two salvaged scanners", author:"Petra Nowak", board:"pulse", diff:"Advanced", time:"26 h", respect:1573, days:21, trend:76, parts:["NEMA17 stepper","GT2 belt","A4988 driver","Servo SG90","Limit switch"] },
{ id:13, title:"Split ortholinear keyboard with rotary volume knob", author:"Callum Ferris", board:"pulse", diff:"Advanced", time:"18 h", respect:1342, days:16, trend:74, parts:["Hot-swap sockets","Rotary encoder","OLED 0.96\"","TRRS jack","Diode 1N4148"] },
{ id:14, title:"Tide clock that pulls harmonics from a coastal feed", author:"Marit Haugen", board:"mote", diff:"Intermediate", time:"10 h", respect:497, days:3, trend:90, parts:["Wi-Fi module","Stepper 28BYJ-48","DS3231 RTC","Acrylic dial"] },
{ id:15, title:"Sewable turn-signal backpack for night commuting", author:"Owen Delacroix", board:"tiny", diff:"Beginner", time:"3 h", respect:655, days:12, trend:69, parts:["WS2812 strip","Conductive thread","Tilt switch","LiPo 400mAh","Snap connectors"] },
{ id:16, title:"Reflow hotplate with thermocouple safety cutoff", author:"Ines Bardales", board:"nova", diff:"Advanced", time:"14 h", respect:1021, days:27, trend:55, parts:["MAX6675 thermocouple","SSR 40A","Heating pad","Buzzer","Rotary encoder"] },
{ id:17, title:"Rain-gauge weather node with a tipping bucket", author:"Jonas Kittel", board:"mote", diff:"Intermediate", time:"11 h", respect:389, days:33, trend:41, parts:["Reed switch","LoRa radio","Solar panel 5V","BME280 sensor","Antenna 868"] },
{ id:18, title:"Interactive LED chess board with reed-switch sensing", author:"Lucia Ferrand", board:"pulse", diff:"Advanced", time:"30 h", respect:1866, days:6, trend:92, parts:["Reed switch","WS2812 strip","Multiplexer 74HC","Wi-Fi module","Acrylic dial"] }
];
var PAGE = 12;
/* --------------------------------------------------------------- helpers */
var $ = function (s, r) { return (r || document).querySelector(s); };
var $$ = function (s, r) { return Array.prototype.slice.call((r || document).querySelectorAll(s)); };
var toastEl = $("#toast");
var toastT;
function toast(msg) {
toastEl.textContent = msg;
toastEl.classList.add("show");
clearTimeout(toastT);
toastT = setTimeout(function () { toastEl.classList.remove("show"); }, 2400);
}
$$("[data-toast]").forEach(function (b) {
b.addEventListener("click", function () { toast(b.getAttribute("data-toast")); });
});
function initials(name) {
return name.split(" ").map(function (p) { return p[0]; }).join("").slice(0, 2).toUpperCase();
}
function avatarColor(name) {
var h = 0;
for (var i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) % 360;
return "hsl(" + h + " 42% 42%)";
}
function ago(d) { return d === 1 ? "1 day ago" : d + " days ago"; }
function esc(s) { return String(s).replace(/[&<>"]/g, function (c) { return ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]; }); }
/* -------------------------------------------------------- thumbnail art */
function thumb(p) {
var hue = BOARDS[p.board].hue;
var seed = p.id * 7;
var bars = "";
for (var i = 0; i < 7; i++) {
var x = 12 + i * 26 + (seed % 5);
var h = 18 + ((seed * (i + 3)) % 46);
bars += '<rect x="' + x + '" y="' + (108 - h) + '" width="12" height="' + h + '" rx="3" fill="' + hue + '" opacity="' + (0.16 + (i % 4) * 0.14).toFixed(2) + '"/>';
}
return '<svg viewBox="0 0 200 125" role="img" aria-label="Abstract circuit illustration">' +
'<defs><linearGradient id="g' + p.id + '" x1="0" y1="0" x2="1" y2="1">' +
'<stop offset="0" stop-color="' + hue + '" stop-opacity=".16"/>' +
'<stop offset="1" stop-color="' + hue + '" stop-opacity=".02"/></linearGradient></defs>' +
'<rect width="200" height="125" fill="url(#g' + p.id + ')"/>' +
bars +
'<path d="M8 ' + (34 + (seed % 20)) + ' h44 l14 -14 h52 l12 12 h62" fill="none" stroke="' + hue + '" stroke-width="1.6" opacity=".55"/>' +
'<circle cx="' + (56 + (seed % 60)) + '" cy="' + (34 + (seed % 20)) + '" r="4" fill="' + hue + '"/>' +
'<rect x="72" y="44" width="56" height="34" rx="5" fill="#1a2b34" opacity=".9"/>' +
'<text x="100" y="65" text-anchor="middle" font-family="JetBrains Mono, monospace" font-size="10" fill="#cfe9ea">' + BOARDS[p.board].label.toUpperCase() + '</text>' +
'</svg>';
}
/* --------------------------------------------------------------- filters */
var state = { boards: [], diff: "all", comps: [], sort: "trending", shown: PAGE };
var ALL_COMPS = (function () {
var m = {};
PROJECTS.forEach(function (p) { p.parts.forEach(function (c) { m[c] = (m[c] || 0) + 1; }); });
return Object.keys(m).sort().map(function (c) { return { name: c, n: m[c] }; });
})();
function matches(p) {
if (state.boards.length && state.boards.indexOf(p.board) === -1) return false;
if (state.diff !== "all" && p.diff !== state.diff) return false;
for (var i = 0; i < state.comps.length; i++) {
if (p.parts.indexOf(state.comps[i]) === -1) return false;
}
return true;
}
function sorted(list) {
var l = list.slice();
if (state.sort === "newest") l.sort(function (a, b) { return a.days - b.days; });
else if (state.sort === "respect") l.sort(function (a, b) { return b.respect - a.respect; });
else l.sort(function (a, b) { return b.trend - a.trend; });
return l;
}
/* ----------------------------------------------------------- card render */
var respected = {};
function cardHTML(p, idx) {
var on = !!respected[p.id];
var shownParts = p.parts.slice(0, 3).map(function (c) { return "<li>" + esc(c) + "</li>"; }).join("");
var extra = p.parts.length - 3;
if (extra > 0) shownParts += '<li class="more">' + extra + " more component" + (extra > 1 ? "s" : "") + "</li>";
return '<article class="card in" style="animation-delay:' + Math.min(idx, 11) * 35 + 'ms">' +
'<div class="thumb"><span class="tag tag-' + p.board + '">' + BOARDS[p.board].label + '</span>' + thumb(p) + '</div>' +
'<div class="cbody">' +
'<h3 class="ctitle">' + esc(p.title) + "</h3>" +
'<span class="author"><span class="avatar" style="background:' + avatarColor(p.author) + '">' + initials(p.author) + "</span>" + esc(p.author) + "</span>" +
'<div class="meta"><span class="badge b-' + p.diff + '">' + p.diff + '</span><span class="time">⏱ ' + p.time + "</span></div>" +
'<ul class="parts">' + shownParts + "</ul>" +
'<div class="cfoot">' +
'<button class="respect" type="button" data-id="' + p.id + '" aria-pressed="' + on + '" aria-label="Respect this project">' +
'<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true"><path d="M12 20s-7-4.5-7-9.2A4.1 4.1 0 0 1 12 8a4.1 4.1 0 0 1 7 2.8C19 15.5 12 20 12 20z" stroke-linejoin="round"/></svg>' +
"<span>" + (p.respect + (on ? 1 : 0)) + "</span>" +
"</button>" +
'<span class="stamp">' + ago(p.days) + "</span>" +
"</div>" +
"</div></article>";
}
var grid = $("#grid"), emptyEl = $("#empty"), countLine = $("#countLine"), moreBtn = $("#loadMore");
function render(keepShown) {
var list = sorted(PROJECTS.filter(matches));
if (!keepShown) state.shown = PAGE;
var visible = list.slice(0, state.shown);
grid.innerHTML = visible.map(cardHTML).join("");
emptyEl.hidden = list.length !== 0;
grid.hidden = list.length === 0;
moreBtn.parentElement.hidden = list.length <= state.shown;
countLine.innerHTML = list.length
? "Showing <b>" + visible.length + "</b> of <b>" + list.length + "</b> projects"
: "No projects match your filters";
renderActive();
renderBoardCounts();
}
function renderBoardCounts() {
Object.keys(BOARDS).forEach(function (b) {
var el = $('[data-count-board="' + b + '"]');
if (!el) return;
var save = state.boards;
state.boards = [];
var n = PROJECTS.filter(function (p) { return p.board === b && matches(p); }).length;
state.boards = save;
el.textContent = n;
});
}
/* ------------------------------------------------------- active chip row */
var activeBar = $("#activeBar"), activeChips = $("#activeChips");
function chip(label, kind, value) {
return '<span class="chip">' + esc(label) +
'<button type="button" data-kind="' + kind + '" data-value="' + esc(value) + '" aria-label="Remove filter ' + esc(label) + '">×</button></span>';
}
function renderActive() {
var html = "";
state.boards.forEach(function (b) { html += chip(BOARDS[b].label + " board", "board", b); });
if (state.diff !== "all") html += chip(state.diff, "diff", state.diff);
state.comps.forEach(function (c) { html += chip(c, "comp", c); });
activeChips.innerHTML = html;
activeBar.hidden = html === "";
}
activeChips.addEventListener("click", function (e) {
var b = e.target.closest("button[data-kind]");
if (!b) return;
removeFilter(b.dataset.kind, b.dataset.value);
});
function removeFilter(kind, value) {
if (kind === "board") {
state.boards = state.boards.filter(function (x) { return x !== value; });
var cb = $('[data-board="' + value + '"]');
if (cb) cb.checked = false;
} else if (kind === "diff") {
state.diff = "all";
$('input[name="diff"][value="all"]').checked = true;
} else {
state.comps = state.comps.filter(function (x) { return x !== value; });
renderChips();
renderComps();
}
state.shown = PAGE;
render();
}
/* -------------------------------------------------------- component list */
var compSearch = $("#compSearch"), compList = $("#compList"), compChips = $("#compChips");
function renderComps() {
var q = compSearch.value.trim().toLowerCase();
var list = ALL_COMPS.filter(function (c) { return c.name.toLowerCase().indexOf(q) !== -1; });
if (!list.length) {
compList.innerHTML = '<p class="copt-none">No part named “' + esc(compSearch.value) + '”</p>';
return;
}
compList.innerHTML = list.map(function (c) {
var sel = state.comps.indexOf(c.name) !== -1;
return '<button class="copt" type="button" role="option" aria-selected="' + sel + '" data-comp="' + esc(c.name) + '">' +
"<span>" + (sel ? "✓ " : "") + esc(c.name) + '</span><span class="n">' + c.n + "</span></button>";
}).join("");
}
function renderChips() {
compChips.innerHTML = state.comps.map(function (c) {
return '<span class="chip">' + esc(c) + '<button type="button" data-comp-rm="' + esc(c) + '" aria-label="Remove ' + esc(c) + '">×</button></span>';
}).join("");
}
compList.addEventListener("click", function (e) {
var b = e.target.closest("[data-comp]");
if (!b) return;
var name = b.dataset.comp;
var i = state.comps.indexOf(name);
if (i === -1) state.comps.push(name); else state.comps.splice(i, 1);
state.shown = PAGE;
renderChips(); renderComps(); render();
});
compChips.addEventListener("click", function (e) {
var b = e.target.closest("[data-comp-rm]");
if (!b) return;
removeFilter("comp", b.dataset.compRm);
});
compSearch.addEventListener("input", renderComps);
/* ----------------------------------------------------------- board/diff */
$$("[data-board]").forEach(function (cb) {
cb.addEventListener("change", function () {
var b = cb.dataset.board;
if (cb.checked) { if (state.boards.indexOf(b) === -1) state.boards.push(b); }
else state.boards = state.boards.filter(function (x) { return x !== b; });
state.shown = PAGE;
render();
});
});
$$('input[name="diff"]').forEach(function (r) {
r.addEventListener("change", function () {
state.diff = r.value;
state.shown = PAGE;
render();
});
});
$("#sort").addEventListener("change", function (e) {
state.sort = e.target.value;
render(true);
toast("Sorted by " + e.target.selectedOptions[0].textContent.toLowerCase());
});
/* --------------------------------------------------------------- respect */
grid.addEventListener("click", function (e) {
var b = e.target.closest(".respect");
if (!b) return;
var id = Number(b.dataset.id);
var on = b.getAttribute("aria-pressed") === "true";
respected[id] = !on;
b.setAttribute("aria-pressed", String(!on));
var base = PROJECTS.filter(function (p) { return p.id === id; })[0].respect;
b.querySelector("span").textContent = base + (!on ? 1 : 0);
toast(!on ? "Respect sent to the maker." : "Respect removed.");
});
/* ------------------------------------------------------------- load more */
moreBtn.addEventListener("click", function () {
var before = state.shown;
state.shown += PAGE;
render(true);
var total = PROJECTS.filter(matches).length;
toast("Loaded " + Math.min(PAGE, total - before) + " more projects.");
});
/* ----------------------------------------------------------- clear/reset */
function clearAll() {
state.boards = []; state.diff = "all"; state.comps = []; state.shown = PAGE;
$$("[data-board]").forEach(function (cb) { cb.checked = false; });
$('input[name="diff"][value="all"]').checked = true;
compSearch.value = "";
renderChips(); renderComps(); render();
toast("All filters cleared.");
}
$("#clearAll").addEventListener("click", clearAll);
$("#clearAll2").addEventListener("click", clearAll);
$("#resetBtn").addEventListener("click", clearAll);
$$(".colcard").forEach(function (c) {
var open = function () { toast(c.querySelector("h3").textContent + " collection — demo only."); };
c.addEventListener("click", open);
c.addEventListener("keydown", function (e) { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); open(); } });
});
/* ------------------------------------------------------------------ boot */
renderComps();
renderChips();
render();
})();<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>CIRCUITO Project Hub — Community Builds</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="#results">Skip to projects</a>
<header class="topbar">
<div class="wrap topbar-in">
<a class="brand" href="#" aria-label="CIRCUITO Project Hub home">
<span class="brand-mark" aria-hidden="true">
<svg viewBox="0 0 32 32" width="28" height="28" role="presentation">
<circle cx="16" cy="16" r="14" fill="none" stroke="currentColor" stroke-width="2.4"/>
<path d="M6 16h5l2.5-6 4.5 12 2.5-6h5" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="brand-txt"><b>CIRCUITO</b><span>Project Hub</span></span>
</a>
<nav class="topnav" aria-label="Main">
<a href="#results">Projects</a>
<a href="#collections">Collections</a>
<a href="#">Boards</a>
<a href="#">Docs</a>
</nav>
<button class="btn btn-ghost sm" type="button" data-toast="Sign-in is disabled in this demo.">Sign in</button>
</div>
</header>
<section class="hero">
<div class="wrap hero-in">
<div class="hero-copy">
<span class="eyebrow">Open hardware · community made</span>
<h1>Every build, wired out loud.</h1>
<p>Browse 4,180 open projects from makers using CIRCUITO boards — schematics, code and bill of materials included. Filter by board family, difficulty and the components sitting in your parts drawer.</p>
<div class="hero-actions">
<button class="btn btn-primary" type="button" data-toast="Project submission opens in the full app.">Share your project</button>
<a class="btn btn-ghost" href="#collections">Browse collections</a>
</div>
<ul class="hero-stats">
<li><b>4,180</b><span>projects</span></li>
<li><b>962</b><span>makers</span></li>
<li><b>27</b><span>board families</span></li>
</ul>
</div>
<div class="hero-art" aria-hidden="true">
<div class="board-art">
<span class="chip-lg">CIRCUITO<i>NOVA R3</i></span>
<span class="pin-row top"></span>
<span class="pin-row bottom"></span>
<span class="led led-a"></span>
<span class="led led-b"></span>
<span class="trace t1"></span><span class="trace t2"></span><span class="trace t3"></span>
</div>
</div>
</div>
</section>
<main class="wrap layout">
<!-- ================= FILTERS ================= -->
<aside class="filters" aria-label="Filter projects">
<div class="filters-in">
<div class="fhead">
<h2>Filters</h2>
<button class="linkbtn" type="button" id="clearAll">Clear all</button>
</div>
<fieldset class="fgroup">
<legend>Board family</legend>
<label class="check"><input type="checkbox" data-board="nova" /><span class="box"></span><span class="lbl">Nova <em>uno-class</em></span><span class="cnt" data-count-board="nova">0</span></label>
<label class="check"><input type="checkbox" data-board="pulse" /><span class="box"></span><span class="lbl">Pulse <em>32-bit</em></span><span class="cnt" data-count-board="pulse">0</span></label>
<label class="check"><input type="checkbox" data-board="mote" /><span class="box"></span><span class="lbl">Mote <em>wireless</em></span><span class="cnt" data-count-board="mote">0</span></label>
<label class="check"><input type="checkbox" data-board="tiny" /><span class="box"></span><span class="lbl">Tiny <em>wearable</em></span><span class="cnt" data-count-board="tiny">0</span></label>
</fieldset>
<fieldset class="fgroup">
<legend>Difficulty</legend>
<div class="radios" role="radiogroup" aria-label="Difficulty">
<label class="radio"><input type="radio" name="diff" value="all" checked /><span class="dot"></span>Any level</label>
<label class="radio"><input type="radio" name="diff" value="Beginner" /><span class="dot"></span>Beginner</label>
<label class="radio"><input type="radio" name="diff" value="Intermediate" /><span class="dot"></span>Intermediate</label>
<label class="radio"><input type="radio" name="diff" value="Advanced" /><span class="dot"></span>Advanced</label>
</div>
</fieldset>
<fieldset class="fgroup">
<legend>Components</legend>
<div class="combo">
<input type="search" id="compSearch" class="input" placeholder="Search parts…" autocomplete="off" aria-label="Search components" aria-controls="compList" />
<div class="complist" id="compList" role="listbox" aria-label="Component options"></div>
</div>
<div class="chips" id="compChips" aria-live="polite"></div>
</fieldset>
</div>
</aside>
<!-- ================= RESULTS ================= -->
<section class="results" id="results">
<div class="toolbar">
<p class="count" id="countLine" aria-live="polite">Showing 12 of 12 projects</p>
<label class="sortwrap">Sort
<select id="sort" class="select" aria-label="Sort projects">
<option value="trending">Trending</option>
<option value="newest">Newest</option>
<option value="respect">Most respected</option>
</select>
</label>
</div>
<div class="activebar" id="activeBar" hidden>
<span class="activelbl">Active:</span>
<div class="activechips" id="activeChips"></div>
<button class="linkbtn" type="button" id="clearAll2">Clear all</button>
</div>
<div class="grid" id="grid"></div>
<div class="empty" id="empty" hidden>
<div class="empty-art" aria-hidden="true">
<svg viewBox="0 0 64 64" width="64" height="64"><rect x="8" y="16" width="48" height="32" rx="6" fill="none" stroke="currentColor" stroke-width="3"/><path d="M18 34h8l4-8 6 16 4-8h6" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/></svg>
</div>
<h3>No project matches that combination</h3>
<p>Try loosening the difficulty or removing a component from the list.</p>
<button class="btn btn-primary" type="button" id="resetBtn">Reset filters</button>
</div>
<div class="moreWrap">
<button class="btn btn-outline" type="button" id="loadMore">Load more projects</button>
</div>
</section>
</main>
<!-- ================= COLLECTIONS ================= -->
<section class="collections" id="collections">
<div class="wrap">
<div class="sechead">
<h2>Featured collections</h2>
<p>Curated shelves maintained by the community moderators.</p>
</div>
<div class="colgrid">
<article class="colcard c-home" tabindex="0">
<span class="colicon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22"><path d="M3 11l9-7 9 7v9a1 1 0 0 1-1 1h-5v-6H9v6H4a1 1 0 0 1-1-1z" fill="none" stroke="currentColor" stroke-width="2" stroke-linejoin="round"/></svg>
</span>
<h3>Home automation</h3>
<p>Relays, sensors and dashboards for rooms that answer back.</p>
<span class="colmeta"><b>412</b> projects · updated weekly</span>
</article>
<article class="colcard c-wear" tabindex="0">
<span class="colicon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22"><rect x="7" y="6" width="10" height="12" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><path d="M9 6V3h6v3M9 18v3h6v-3" fill="none" stroke="currentColor" stroke-width="2"/></svg>
</span>
<h3>Wearables</h3>
<p>Sewable boards, conductive thread and very small batteries.</p>
<span class="colmeta"><b>187</b> projects · 24 new</span>
</article>
<article class="colcard c-robo" tabindex="0">
<span class="colicon" aria-hidden="true">
<svg viewBox="0 0 24 24" width="22" height="22"><rect x="4" y="8" width="16" height="11" rx="3" fill="none" stroke="currentColor" stroke-width="2"/><path d="M12 8V4M8 13h.01M16 13h.01" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</span>
<h3>Robotics starter</h3>
<p>Motor drivers, line followers and the first arm you will overbuild.</p>
<span class="colmeta"><b>263</b> projects · beginner friendly</span>
</article>
</div>
</div>
</section>
<footer class="foot">
<div class="wrap foot-in">
<span class="mono">CIRCUITO Project Hub · fictional demo platform</span>
<span class="mono muted">Schematics CC-BY-SA · code MIT</span>
</div>
</footer>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Community Project Hub
CIRCUITO Project Hub is a fictional open-hardware community gallery. The page opens with a header band — headline, blurb, three counters and a “Share your project” call to action — next to a CSS-drawn development board with blinking status LEDs and pin headers, all vector and gradient work, no images.
The body is a two-column layout. A sticky filter panel holds board-family checkboxes with live per-family counts, a difficulty radio group and a component multi-select with its own search field: every part you pick becomes a removable chip, and the grid narrows to projects that contain all selected parts. A second chip row above the results mirrors the whole filter state — boards, difficulty and components together — so any single constraint can be dropped with one click, and two clear-all buttons reset everything.
Cards show a board-coloured corner tag, a procedurally generated circuit illustration, an author avatar built from initials and a hashed hue, a difficulty badge, a time estimate, a monospace list of the first three components with a “+N more” tail, and a respect counter that toggles as a pressed button. Sorting switches between trending, newest and most respected; a load-more button appends the next batch with a per-card stagger; and when a filter combination returns nothing, a friendly empty state offers a reset. Three featured collection cards close the page.
How this look is built
The information architecture nods to Arduino Project Hub as a stylistic reference only — everything here is invented. The heavy lifting is modern CSS: grid-template-columns: repeat(auto-fill, minmax(258px,1fr)) gives a card grid that reflows without breakpoints, position: sticky keeps the filter panel pinned without a scroll listener, layered repeating-linear-gradients draw the airy blueprint grid and the hatched card thumbs, and aspect-ratio locks illustration proportions so nothing jumps while fonts load. Filter chips animate in with a keyframe plus a computed animation-delay per card instead of JS timers, and :focus-visible with aria-pressed on the respect toggles keeps keyboard and screen-reader state honest. That beats the older float/absolute-positioned approach because there is no fixed column math to maintain, no JS measuring heights, and one @media (max-width:520px) block is enough to collapse the whole thing to a single column.
Illustrative UI only — always follow real safety guidance when working with tools, electronics, or repairs.