Commit 7ad379ff by PLN (Algolia)

decide: comments & reactions SPA — 8 decision points, constraints fixed, ready for review

parent f42d96d0
<!DOCTYPE html>
<html lang="en" data-theme="auto">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Comments &amp; reactions — me.nech.pl</title>
<style>
:root {
--serif: "Iowan Old Style", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, "Times New Roman", serif;
--sans: system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
}
html[data-theme="auto"] {
--paper: #faf8f4; --surface: #ffffff; --ink: #23201b; --ink-2: #57524a;
--ink-3: #8a847a; --rule: #e2ddd3; --rule-2: #d3cdc1;
--accent: #2e5e4e; --accent-soft: #eaf1ee; --badge-ink: #1d4536;
--warn-soft: #f4ede1; --warn-ink: #6b5426; --code-bg: #f1eee7;
--shadow: 0 1px 2px rgba(40,35,25,.05), 0 4px 14px rgba(40,35,25,.05);
}
html[data-theme="light"] {
--paper: #faf8f4; --surface: #ffffff; --ink: #23201b; --ink-2: #57524a;
--ink-3: #8a847a; --rule: #e2ddd3; --rule-2: #d3cdc1;
--accent: #2e5e4e; --accent-soft: #eaf1ee; --badge-ink: #1d4536;
--warn-soft: #f4ede1; --warn-ink: #6b5426; --code-bg: #f1eee7;
--shadow: 0 1px 2px rgba(40,35,25,.05), 0 4px 14px rgba(40,35,25,.05);
}
html[data-theme="dark"] {
--paper: #17181a; --surface: #1f2124; --ink: #e8e5df; --ink-2: #b3aea6;
--ink-3: #7e7a73; --rule: #313439; --rule-2: #3d4046;
--accent: #7fb8a3; --accent-soft: #233029; --badge-ink: #a9d6c4;
--warn-soft: #2e2820; --warn-ink: #d3b878; --code-bg: #26282c;
--shadow: 0 1px 2px rgba(0,0,0,.3), 0 4px 14px rgba(0,0,0,.25);
}
* { box-sizing: border-box; }
html { color-scheme: light; }
html[data-theme="dark"] { color-scheme: dark; }
body {
margin: 0; background: var(--paper); color: var(--ink);
font-family: var(--serif); font-size: 17px; line-height: 1.6;
}
a { color: var(--accent); text-underline-offset: 2px; }
/* ---- top nav ---- */
.topnav {
position: sticky; top: 0; z-index: 20;
background: color-mix(in srgb, var(--paper) 88%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--rule);
}
.topnav-inner {
max-width: 56rem; margin: 0 auto; padding: .7rem 1.25rem;
display: flex; align-items: center; justify-content: space-between; gap: 1rem;
}
.site-title { font-size: .95rem; letter-spacing: .01em; color: var(--ink); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.site-title .tick { color: var(--ink-3); }
.theme-toggle { display: flex; border: 1px solid var(--rule-2); border-radius: 8px; overflow: hidden; background: var(--surface); }
.theme-toggle button {
appearance: none; border: 0; background: transparent; color: var(--ink-2);
font-family: var(--sans); font-size: .74rem; letter-spacing: .04em;
padding: .3rem .6rem; cursor: pointer; text-transform: uppercase;
}
.theme-toggle button + button { border-left: 1px solid var(--rule-2); }
.theme-toggle button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
/* ---- layout ---- */
main { max-width: 56rem; margin: 0 auto; padding: 0 1.25rem 5rem; }
section { margin-top: 3.2rem; }
.kicker {
font-family: var(--sans); font-size: .72rem; letter-spacing: .14em;
text-transform: uppercase; color: var(--ink-3); margin-bottom: .35rem;
}
h1 { font-size: 2rem; line-height: 1.2; margin: .2rem 0 .4rem; font-weight: 600; }
h2 { font-size: 1.35rem; margin: 0 0 1rem; font-weight: 600; }
.lede { color: var(--ink-2); font-size: 1.05rem; max-width: 44rem; }
/* ---- verdict banner ---- */
.verdict {
margin-top: 1.6rem; border: 1px solid var(--accent); border-left-width: 4px;
background: var(--accent-soft); border-radius: 10px; padding: 1rem 1.25rem;
}
.verdict .kicker { color: var(--accent); }
.verdict p { margin: 0; font-size: 1.08rem; }
/* ---- fixed panel ---- */
.fixed-panel {
margin-top: 1.4rem; background: var(--surface); border: 1px solid var(--rule);
border-radius: 10px; padding: 1.1rem 1.25rem; box-shadow: var(--shadow);
}
.fixed-panel ul { margin: .5rem 0 0; padding-left: 1.2rem; }
.fixed-panel li { margin: .25rem 0; }
.fixed-panel .tag {
display: inline-block; font-family: var(--sans); font-size: .68rem; letter-spacing: .08em;
text-transform: uppercase; color: var(--warn-ink); background: var(--warn-soft);
border-radius: 4px; padding: .1rem .45rem; margin-right: .5rem; vertical-align: 1px;
}
/* ---- evidence ---- */
.evidence-wrap { overflow-x: auto; }
table.evidence { border-collapse: collapse; width: 100%; font-family: var(--sans); font-size: .82rem; }
table.evidence th, table.evidence td { border: 1px solid var(--rule); padding: .45rem .6rem; text-align: left; vertical-align: top; }
table.evidence th { background: var(--code-bg); font-weight: 600; color: var(--ink-2); }
table.evidence td { color: var(--ink-2); }
table.evidence td:first-child { color: var(--ink); font-weight: 600; }
table.evidence tr.reco td { background: var(--accent-soft); }
.footnotes { font-family: var(--sans); font-size: .8rem; color: var(--ink-3); margin-top: .8rem; }
.footnotes li { margin: .2rem 0; }
.diagram { margin: 1.4rem 0 .2rem; }
.diagram svg { width: 100%; height: auto; display: block; }
/* ---- decision points ---- */
.dp, .option { scroll-margin-top: 4.5rem; }
.dp {
background: var(--surface); border: 1px solid var(--rule); border-radius: 12px;
padding: 1.4rem 1.5rem 1.5rem; margin-top: 1.6rem; box-shadow: var(--shadow);
}
.dp.done { border-color: var(--accent); }
.dp-head { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }
.dp-head h3 { margin: 0; font-size: 1.18rem; font-weight: 600; }
.dp-state { font-family: var(--sans); font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--ink-3); }
.dp-state.chosen { color: var(--accent); font-weight: 600; }
.framing { color: var(--ink-2); font-size: .95rem; margin: .55rem 0 1rem; font-style: italic; }
.option {
border: 1px solid var(--rule-2); border-radius: 10px; padding: .85rem 1rem;
margin-top: .7rem; cursor: pointer; transition: border-color .15s ease, background .15s ease;
}
.option:hover { border-color: var(--ink-3); }
.option.selected { border-color: var(--accent); background: var(--accent-soft); }
.option-head { display: flex; align-items: baseline; gap: .55rem; flex-wrap: wrap; }
.option-head .letter { font-family: var(--mono); font-weight: 700; color: var(--ink); }
.option-head .name { font-weight: 600; }
.badge {
font-family: var(--sans); font-size: .64rem; font-weight: 700; letter-spacing: .09em;
text-transform: uppercase; color: var(--badge-ink); background: color-mix(in srgb, var(--accent) 22%, transparent);
border: 1px solid var(--accent); border-radius: 999px; padding: .05rem .5rem;
}
.option p { margin: .4rem 0 0; font-size: .92rem; color: var(--ink-2); }
.option .costs { margin-top: .45rem; font-size: .88rem; color: var(--ink-2); }
.option .costs strong { color: var(--ink); font-family: var(--sans); font-size: .74rem; letter-spacing: .07em; text-transform: uppercase; margin-right: .35rem; }
.reco {
margin-top: .9rem; font-size: .9rem; color: var(--ink-2);
border-top: 1px dashed var(--rule-2); padding-top: .7rem;
}
.reco strong { font-family: var(--sans); font-size: .74rem; letter-spacing: .07em; text-transform: uppercase; color: var(--accent); margin-right: .4rem; }
.option input[type="radio"] { position: absolute; opacity: 0; pointer-events: none; }
.dp textarea, textarea.global-notes {
display: block; width: 100%; margin-top: .9rem; padding: .6rem .75rem;
background: var(--code-bg); color: var(--ink); border: 1px solid var(--rule-2);
border-radius: 8px; font-family: var(--sans); font-size: .88rem; line-height: 1.5; resize: vertical;
}
.dp textarea:focus, textarea.global-notes:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
textarea.global-notes { margin-top: 0; min-height: 6rem; background: var(--surface); }
/* ---- toolbar ---- */
.toolbar {
position: sticky; bottom: 0; z-index: 20; margin-top: 3rem;
background: color-mix(in srgb, var(--paper) 90%, transparent); backdrop-filter: blur(8px);
border-top: 1px solid var(--rule); padding: .7rem 0;
}
.toolbar-inner {
max-width: 56rem; margin: 0 auto; padding: 0 1.25rem;
display: flex; align-items: center; gap: .7rem; flex-wrap: wrap;
}
.progress { font-family: var(--sans); font-size: .82rem; color: var(--ink-2); }
.progress strong { color: var(--ink); font-size: .95rem; }
.progress .bar { display: inline-block; width: 5.5rem; height: 5px; background: var(--rule); border-radius: 999px; overflow: hidden; vertical-align: 2px; margin: 0 .5rem; }
.progress .bar i { display: block; height: 100%; background: var(--accent); width: 0%; transition: width .2s ease; }
.btn {
appearance: none; font-family: var(--sans); font-size: .82rem; cursor: pointer;
border: 1px solid var(--rule-2); background: var(--surface); color: var(--ink);
border-radius: 8px; padding: .42rem .85rem;
}
.btn:hover { border-color: var(--ink-3); }
.btn.primary { background: var(--accent); border-color: var(--accent); color: var(--paper); font-weight: 600; }
.btn.primary:hover { filter: brightness(1.06); }
.btn.danger { color: #b0483c; }
html[data-theme="dark"] .btn.danger { color: #e08b80; }
.toolbar .spacer { flex: 1; }
.export-hint { font-family: var(--sans); font-size: .75rem; color: var(--ink-3); width: 100%; margin-top: .1rem; }
@media (max-width: 640px) {
body { font-size: 16px; }
h1 { font-size: 1.6rem; }
.dp { padding: 1.1rem 1rem 1.2rem; }
}
@media print {
.topnav, .toolbar { display: none; }
body { background: #fff; }
}
</style>
</head>
<body>
<nav class="topnav" aria-label="Page header">
<div class="topnav-inner">
<div class="site-title"><span class="tick">decide ·</span> Comments &amp; reactions — me.nech.pl</div>
<div class="theme-toggle" role="group" aria-label="Color theme">
<button type="button" data-theme-choice="auto" aria-pressed="true">Auto</button>
<button type="button" data-theme-choice="light" aria-pressed="false">Light</button>
<button type="button" data-theme-choice="dark" aria-pressed="false">Dark</button>
</div>
</div>
</nav>
<main>
<header>
<div class="kicker">Decision review · 2026-11-01</div>
<h1>Comments &amp; reactions for a static-export blog</h1>
<p class="lede">How to add comments and reactions to me.nech.pl — a Next.js <strong>static export</strong> (no server runtime on the web host) served by nginx on the owner’s own server <em>erable</em> (Debian, Docker available), with an existing personal API gateway <em>nechapi</em> at api.nech.pl (SSOT <code style="font-family:var(--mono);font-size:.85em;">~/Work/Perso/nechapi</code>). Bilingual audience (FR/EN). This is a personal site, not a product.</p>
<div class="verdict">
<div class="kicker">Verdict</div>
<p>Self-hosted comments + reactions as a nechapi service on erable — SQLite, approval-queue antispam, no captcha — is the recommendation; the open questions are identity warmth and how much lands in v1.</p>
</div>
<div class="fixed-panel">
<div class="kicker">Fixed constraints</div>
<p style="margin:.3rem 0 0; font-size:.9rem; color:var(--ink-2);">From the owner, 2026-11-01. Not up for debate — every option below is judged against these.</p>
<ul>
<li><span class="tag">Fixed</span>1st-party / self-hosted ideally; data under owner control</li>
<li><span class="tag">Fixed</span>A database is acceptable (DB ok)</li>
<li><span class="tag">Required</span>Antispam — this is a public, unauthenticated posting surface</li>
<li><span class="tag">Banned</span>No CAPTCHA — explicitly unwanted (no Cloudflare Turnstile, no hCaptcha, no puzzles)</li>
<li><span class="tag">Fixed</span>Low maintenance — a personal site, not a product</li>
<li><span class="tag">Fixed</span>Bilingual audience (FR/EN)</li>
</ul>
</div>
</header>
<section id="evidence" aria-labelledby="evidence-h">
<div class="kicker">Evidence</div>
<h2 id="evidence-h">Constraint outcomes per architecture</h2>
<div class="evidence-wrap">
<table class="evidence">
<thead>
<tr>
<th scope="col">Architecture</th>
<th scope="col">1st-party</th>
<th scope="col">DB</th>
<th scope="col">Antispam layer</th>
<th scope="col">Maintenance</th>
<th scope="col">No-JS readers</th>
<th scope="col">v1 build cost</th>
</tr>
</thead>
<tbody>
<tr class="reco">
<td>nechapi service on erable <span class="badge">Recommended</span></td>
<td>Yes — own server, own gateway</td>
<td>SQLite file</td>
<td>Honeypot + rate limit + approval queue (owner-tuned)</td>
<td>Low-moderate: uptime, backups, filter tuning</td>
<td>Nothing (honest noscript note)</td>
<td>~1 weekend</td>
</tr>
<tr>
<td>Git-as-DB via Sveltia CMS / PR queue</td>
<td>Yes — content in own repo</td>
<td>Git (files under content/comments/)</td>
<td>Moderation = closing PRs / not merging</td>
<td>Low runtime, but per-comment deploy churn</td>
<td>Full (comments baked at build)</td>
<td>Days (config + light glue)</td>
</tr>
<tr>
<td>giscus (GitHub Discussions) <sup>2</sup></td>
<td>No — GitHub-hosted data &amp; branding</td>
<td>GitHub Discussions</td>
<td>GitHub accounts + repo moderation</td>
<td>Near zero</td>
<td>Nothing</td>
<td>Hours</td>
</tr>
<tr>
<td>Cactus Comments <sup>3</sup></td>
<td>Partial — self-hostable, Matrix backend</td>
<td>Matrix homeserver / PostgreSQL</td>
<td>Matrix identity + moderation in room</td>
<td>Moderate: run a homeserver or trust appservice</td>
<td>Nothing</td>
<td>Days (if homeserver exists) / weeks (if not)</td>
</tr>
<tr>
<td>Remark42 <sup>4</sup></td>
<td>Yes — self-hosted Go binary (on erable)</td>
<td>BoltDB / files</td>
<td>Built-in: OAuth-only posting, anti-spam image-less checks</td>
<td>Moderate: run + update a third-party binary</td>
<td>Nothing</td>
<td>~1 day (docker-compose)</td>
</tr>
<tr>
<td>Isso <sup>5</sup></td>
<td>Yes — self-hosted Python (on erable)</td>
<td>SQLite</td>
<td>Basic: comment moderation queue, no captcha by default</td>
<td>Moderate: aging project, owner must track security fixes</td>
<td>Nothing</td>
<td>~1 day</td>
</tr>
</tbody>
</table>
</div>
<figure class="diagram" aria-labelledby="diagram-cap">
<svg viewBox="0 0 880 150" role="img" aria-label="Architecture diagram: static blog fetches comments client-side from api.nech.pl, which writes to SQLite and exposes a moderation queue">
<defs>
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="var(--ink-3)"/>
</marker>
</defs>
<style>
.dg-box { fill: var(--surface); stroke: var(--rule-2); stroke-width: 1; rx: 8; }
.dg-box-hot { stroke: var(--accent); stroke-width: 1.5; }
.dg-lbl { font-family: system-ui, -apple-system, sans-serif; font-size: 13px; fill: var(--ink); }
.dg-sub { font-family: system-ui, -apple-system, sans-serif; font-size: 11px; fill: var(--ink-3); }
.dg-flow { stroke: var(--ink-3); stroke-width: 1.5; fill: none; marker-end: url(#arrow); }
.dg-dash { stroke-dasharray: 4 3; }
</style>
<rect class="dg-box" x="10" y="42" width="170" height="52"/>
<text class="dg-lbl" x="95" y="63" text-anchor="middle">Static blog (nginx)</text>
<text class="dg-sub" x="95" y="80" text-anchor="middle">out/ · no runtime</text>
<rect class="dg-box dg-box-hot" x="250" y="42" width="200" height="52"/>
<text class="dg-lbl" x="350" y="63" text-anchor="middle">api.nech.pl</text>
<text class="dg-sub" x="350" y="80" text-anchor="middle">comments svc · nechapi container tier</text>
<rect class="dg-box" x="520" y="42" width="130" height="52"/>
<text class="dg-lbl" x="585" y="63" text-anchor="middle">SQLite</text>
<text class="dg-sub" x="585" y="80" text-anchor="middle">one file · nightly dump</text>
<rect class="dg-box" x="700" y="42" width="170" height="52"/>
<text class="dg-lbl" x="785" y="63" text-anchor="middle">Moderation queue</text>
<text class="dg-sub" x="785" y="80" text-anchor="middle">approve · flush · reply</text>
<line class="dg-flow" x1="182" y1="68" x2="244" y2="68"/>
<line class="dg-flow" x1="452" y1="68" x2="514" y2="68"/>
<line class="dg-flow" x1="652" y1="68" x2="694" y2="68"/>
<line class="dg-flow dg-dash" x1="350" y1="96" x2="350" y2="126"/>
<line class="dg-flow dg-dash" x1="358" y1="126" x2="784" y2="126"/>
<line class="dg-flow dg-dash" x1="785" y1="126" x2="785" y2="100"/>
<text class="dg-sub" x="560" y="121" text-anchor="middle">held posts wait here — nothing publishes unreviewed</text>
<text class="dg-sub" x="95" y="26" text-anchor="middle">client-side fetch on load</text>
<line class="dg-flow" x1="95" y1="30" x2="95" y2="38"/>
</svg>
<figcaption id="diagram-cap" style="font-family:var(--sans); font-size:.78rem; color:var(--ink-3); margin-top:.4rem;">
v1 data flow: the static page fetches approved comments client-side from api.nech.pl; POSTs land in the hold queue, never straight on the page.
</figcaption>
</figure>
<ol class="footnotes">
<li>Constraints (1st-party, DB ok, antispam required, no captcha, low maintenance, FR/EN) stated by the owner, 2026-11-01.</li>
<li>giscus — comments backed by GitHub Discussions, embedded widget; prior art surveyed.</li>
<li>Cactus Comments — federated comments over Matrix; prior art surveyed.</li>
<li>Remark42 — self-hosted comment engine (Go); prior art surveyed.</li>
<li>Isso — self-hosted comments for static sites (Python/SQLite); prior art surveyed.</li>
</ol>
</section>
<section id="watchlist" aria-labelledby="watchlist-h">
<div class="kicker">Watchlist</div>
<h2 id="watchlist-h">Facts that could flip the recommendation</h2>
<ul style="margin:.2rem 0 0; padding-left:1.2rem; color:var(--ink-2);">
<li><strong style="color:var(--ink);">Erable reliability.</strong> If an always-on service on erable proves unreliable, DP1 flips to B (git-as-DB) — comments become build artifacts and the uptime question disappears.</li>
<li><strong style="color:var(--ink);">Threading wanted early.</strong> If the owner wants threaded replies in v1, DP8 reopens (option B) and the data model gains a parent_id + UI cost now.</li>
<li><strong style="color:var(--ink);">GitHub-first audience.</strong> If the audience is confirmed GitHub-centric, DP3 option B (OAuth-only) gains weight and auto-approval becomes safer.</li>
</ul>
</section>
<section id="dps" aria-labelledby="dps-h">
<div class="kicker">Decision points</div>
<h2 id="dps-h">Eight calls, one choice each</h2>
<p style="color:var(--ink-2); font-size:.95rem; margin-top:0;">Recommended options are listed first and badged. Every option carries its costs. Add a note where your reasoning departs from the reco rationale.</p>
<div id="dp-root"></div>
</section>
<section aria-labelledby="global-h">
<div class="kicker">Global</div>
<h2 id="global-h">Notes across the whole decision</h2>
<textarea class="global-notes" id="global-notes" placeholder="Anything the eight boxes didn’t capture — sequencing, open questions, who to tell…"></textarea>
</section>
<div class="toolbar">
<div class="toolbar-inner">
<div class="progress" id="progress" aria-live="polite"><strong>0/8</strong> decided<span class="bar"><i id="progress-fill"></i></span></div>
<div class="spacer"></div>
<button type="button" class="btn primary" id="btn-export">Export notes</button>
<button type="button" class="btn danger" id="btn-reset">Reset</button>
<div class="export-hint" id="export-hint" role="status"></div>
</div>
</div>
</main>
<script>
(function () {
"use strict";
var STORE_KEY = "decide-2026-11-01-comments-reactions";
var THEME_KEY = STORE_KEY + ".theme";
var DPS = [
{
id: "dp1",
title: "DP1 — Comments architecture",
framing: "A static export has no server-side render, so comments either ship as static content baked at build, load client-side from a service, or use the origin server (erable) as the API. The real tradeoff is where truth lives and who runs it.",
options: [
{ letter: "A", reco: true, name: "Self-hosted comments API on erable via nechapi",
body: "Small service (container tier on the nechapi golden base) + SQLite; blog fetches client-side from api.nech.pl; moderation queue in a tiny admin page or CLI.",
costs: "You own uptime, spam-filter code, and backups; ~1 weekend to build; comments need a client-side fetch (no-JS readers see nothing)." },
{ letter: "B", reco: false, name: "Git-as-DB: comments as files via pull request or CMS queue",
body: "Readers post through the existing Sveltia CMS (admin.github OAuth) into a content/comments/ folder; comments become build artifacts, zero new runtime.",
costs: "Slow feedback loop (commit+deploy per comment), GitHub account required for posters, moderation = git hygiene; spam manifests as PR spam you close." },
{ letter: "C", reco: false, name: "Third-party embed (giscus on GitHub Discussions)",
body: "Fastest, free, real accounts.",
costs: "Violates the 1st-party constraint, GitHub-centric audience, GitHub branding, data leaves your control. Listed for completeness; likely rejected." }
],
reco: "The owner already operates a personal API gateway precisely for this shape of thing; comments v1 is a small CRUD + queue, well inside nechapi’s remit."
},
{
id: "dp2",
title: "DP2 — Antispam without captcha",
framing: "An unauthenticated POST endpoint on the open internet gets automated abuse within days. Without captcha, the defense is layered friction + human review.",
options: [
{ letter: "A", reco: true, name: "Layered friction + moderation queue",
body: "Honeypot field + minimum-time-to-submit + per-IP rate limit (nginx or app-level) + manual approval before publish (default-on hold).",
costs: "Every real comment waits for owner approval (async, fits a personal blog); spam pile needs a one-click “flush” UX." },
{ letter: "B", reco: false, name: "Publish-then-flag (Akismet-style scoring + takedown)",
body: "Post instantly, score locally (keyword/URL heuristics, known-spam patterns), hide low-scores, owner sweeps.",
costs: "Risk of visible spam between sweeps; scoring rules need tuning; no 3rd-party Akismet unless accepted (API key, data leaves)." },
{ letter: "C", reco: false, name: "Proof-of-work / Anubis-style client challenge",
body: "Browser computes a small hash puzzle.",
costs: "Hostile to old devices, weird UX for a poetry blog’s audience, arms-race ownership." }
],
reco: "Approval-queue is the only option whose failure mode is “comment appears late” rather than “spam appears publicly”; honeypot + ratelimit kills 95% of robot traffic before it costs anything."
},
{
id: "dp3",
title: "DP3 — Identity & attribution",
framing: "Who is a commenter? This drives antispam strength and UX warmth.",
options: [
{ letter: "A", reco: true, name: "Name + optional email (never displayed beyond name), no accounts",
body: "Lowest friction, fits a personal blog; email enables owner reply and (later) notifications.",
costs: "Nothing verifies identity; impersonation possible (acceptable at this scale)." },
{ letter: "B", reco: false, name: "OAuth (GitHub) only",
body: "Real-ish identity, kills drive-by spam.",
costs: "Account wall contradicts the open-web feel; GitHub-only audience; more OAuth code to own." },
{ letter: "C", reco: false, name: "Both: anonymous with hold, OAuth posts auto-approved",
body: "Two trust tiers in one surface.",
costs: "Two code paths from day one — a v2 move." }
],
reco: "v1 should be one path; hold-for-approval makes weak identity safe."
},
{
id: "dp4",
title: "DP4 — Reactions",
framing: "The owner asked for reactions alongside comments. Reactions are anonymous, one-click, and don’t need moderation — a different trust model from comments.",
options: [
{ letter: "A", reco: true, name: "Same service, separate endpoint, no moderation",
body: "Emoji set (e.g. ♥ ⚡ ✨ 😂 😮), one count per post, 1 IP/post/day/emoji, no text → nothing to moderate.",
costs: "Counters are gameable per-IP (fine at this scale); needs the same API uptime." },
{ letter: "B", reco: false, name: "Defer reactions to v2",
body: "Ship comments first.",
costs: "None functionally; the owner asked for reactions, so this defers a want." },
{ letter: "C", reco: false, name: "Third-party reaction button",
body: "Off-the-shelf widget.",
costs: "Violates 1st-party for a trivial feature." }
],
reco: "Reactions are nearly free once the API exists; separate endpoint keeps the moderation-free trust model explicit."
},
{
id: "dp5",
title: "DP5 — Storage & durability",
framing: "“DB ok” per the owner. The question is which durability story the comments service gets.",
options: [
{ letter: "A", reco: true, name: "SQLite file on erable + nightly dump into the existing backup regime",
body: "One file, trivially backed up, zero daemon.",
costs: "Single-writer; fine at blog scale." },
{ letter: "B", reco: false, name: "Git-as-storage via the API writing commits",
body: "Audit trail for free.",
costs: "Write-per-comment = commits on the server, ugly at scale, couples uptime to git." },
{ letter: "C", reco: false, name: "Postgres container",
body: "Full relational server.",
costs: "Over-provisioned for one table." }
],
reco: "SQLite + the backups that already exist is boring on purpose; deletion beats addition."
},
{
id: "dp6",
title: "DP6 — Rendering & caching model",
framing: "The blog is static HTML; comments must either be baked at build (fresh only on deploy) or fetched client-side (always fresh, JS-required).",
options: [
{ letter: "A", reco: true, name: "Client-side fetch + noscript fallback note + cached JSON",
body: "The API sends long-lived Cache-Control, the page fetches on load, POST invalidates the per-post cache.",
costs: "No-JS readers don’t see comments (they see an honest note); one extra request." },
{ letter: "B", reco: false, name: "Bake at build + client refresh",
body: "Comments visible without JS when fresh.",
costs: "Two render paths, stale HTML until next deploy." },
{ letter: "C", reco: false, name: "Bake only",
body: "Simplest.",
costs: "Comments effectively real-time-never — the wrong model for a discussion surface." }
],
reco: "v1 honesty: fetch client-side, tell no-JS readers exactly what they’re missing."
},
{
id: "dp7",
title: "DP7 — Moderation UX for the owner",
framing: "The queue needs an owner surface. This is the daily-touch part.",
options: [
{ letter: "A", reco: true, name: "Approve/reply/flush via a tiny token-gated admin page on api.nech.pl",
body: "One screen: pending list, approve, delete, optional reply.",
costs: "Auth (a long token in a 1Password-protected URL is enough for v1)." },
{ letter: "B", reco: false, name: "CLI on erable",
body: "Zero UI to build, ssh in.",
costs: "Friction you’ll feel weekly." },
{ letter: "C", reco: false, name: "Sveltia CMS collection",
body: "Reuses the existing admin.",
costs: "CMS is built for content folders (git), not an API-backed queue." }
],
reco: "The approval queue is the product; a 100-line admin page beats weekly ssh."
},
{
id: "dp8",
title: "DP8 — Reply/notify (v1 scope)",
framing: "How much conversation machinery lands in v1.",
options: [
{ letter: "A", reco: true, name: "Flat comments, no threading, no notifications v1 — plus owner-email-on-hold as a checkbox item",
body: "Ship the smallest true thing; owner reads the queue anyway. Email-on-new-hold is ~10 lines given SMTP already configured on erable (MAIL-RECEIVE docs), so it folds into A.",
costs: "No conversations, no reply-by-email." },
{ letter: "B", reco: false, name: "Threading v1",
body: "Replies nested under comments from day one.",
costs: "Data model + UI cost now; personal-blog threads rarely go deep." },
{ letter: "C", reco: false, name: "Email notifications to owner on new hold (standalone option)",
body: "Small win on its own.",
costs: "Redundant if folded into A as recommended; standalone treatment splits the smallest-true-thing boundary for no extra gain." }
],
reco: "Flat + owner-email-on-hold is ~10 lines given existing mail; call that the boundary of v1."
}
];
// ---------- state ----------
var state = { choices: {}, notes: {}, global: "" };
function load() {
try {
var raw = localStorage.getItem(STORE_KEY);
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
state.choices = parsed.choices || {};
state.notes = parsed.notes || {};
state.global = typeof parsed.global === "string" ? parsed.global : "";
}
}
} catch (e) { /* fresh state on corrupt storage */ }
}
function save() {
try { localStorage.setItem(STORE_KEY, JSON.stringify(state)); } catch (e) { /* storage full/blocked */ }
}
load();
// ---------- theme ----------
var themeButtons = Array.prototype.slice.call(document.querySelectorAll(".theme-toggle button"));
function applyTheme(mode) {
document.documentElement.setAttribute("data-theme", mode);
themeButtons.forEach(function (b) {
b.setAttribute("aria-pressed", b.getAttribute("data-theme-choice") === mode ? "true" : "false");
});
try { localStorage.setItem(THEME_KEY, mode); } catch (e) { /* ignore */ }
}
themeButtons.forEach(function (b) {
b.addEventListener("click", function () { applyTheme(b.getAttribute("data-theme-choice")); });
});
var savedTheme = "auto";
try { savedTheme = localStorage.getItem(THEME_KEY) || "auto"; } catch (e) { /* default */ }
applyTheme(["auto", "light", "dark"].indexOf(savedTheme) >= 0 ? savedTheme : "auto");
// ---------- render decision points ----------
var root = document.getElementById("dp-root");
function esc(s) {
return String(s).replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
var html = "";
DPS.forEach(function (dp) {
html += '<article class="dp" id="' + dp.id + '" data-dp="' + dp.id + '">';
html += '<div class="dp-head"><h3>' + esc(dp.title) + '</h3><span class="dp-state" data-state-for="' + dp.id + '">undecided</span></div>';
html += '<p class="framing">' + esc(dp.framing) + '</p>';
dp.options.forEach(function (opt) {
html += '<label class="option" data-option="' + dp.id + ':' + opt.letter + '">';
html += '<input type="radio" name="' + dp.id + '" value="' + opt.letter + '">';
html += '<div class="option-head">';
html += '<span class="letter">' + opt.letter + '.</span>';
html += '<span class="name">' + esc(opt.name) + '</span>';
if (opt.reco) html += '<span class="badge">Recommended</span>';
html += '</div>';
html += '<p>' + esc(opt.body) + '</p>';
html += '<p class="costs"><strong>Costs</strong>' + esc(opt.costs) + '</p>';
html += '</label>';
});
html += '<div class="reco"><strong>Reco</strong>' + esc(dp.reco) + '</div>';
html += '<textarea data-note-for="' + dp.id + '" rows="3" placeholder="Notes on ' + esc(dp.title.split("—")[0].trim()) + ' — why, doubts, what would change your mind…"></textarea>';
html += '</article>';
});
root.innerHTML = html;
var dpCards = Array.prototype.slice.call(root.querySelectorAll(".dp"));
// ---------- interactions ----------
function findOption(dpId, letter) {
var dp = DPS.filter(function (d) { return d.id === dpId; })[0];
return dp ? dp.options.filter(function (o) { return o.letter === letter; })[0] : null;
}
function refreshProgress() {
var done = DPS.filter(function (dp) { return state.choices[dp.id]; }).length;
var strong = document.querySelector("#progress strong");
var fill = document.getElementById("progress-fill");
if (strong) strong.textContent = done + "/8";
if (fill) fill.style.width = (done / DPS.length * 100) + "%";
}
function refreshDp(dpId) {
var letter = state.choices[dpId];
var card = root.querySelector('[data-dp="' + dpId + '"]');
if (!card) return;
var stateEl = card.querySelector("[data-state-for]");
if (letter) {
var opt = findOption(dpId, letter);
card.classList.add("done");
if (stateEl) { stateEl.textContent = "chosen: " + letter + (opt ? " — " + opt.name : ""); stateEl.classList.add("chosen"); }
} else {
card.classList.remove("done");
if (stateEl) { stateEl.textContent = "undecided"; stateEl.classList.remove("chosen"); }
}
Array.prototype.forEach.call(card.querySelectorAll(".option"), function (label) {
var parts = label.getAttribute("data-option").split(":");
var selected = state.choices[dpId] === parts[1];
label.classList.toggle("selected", selected);
var input = label.querySelector("input[type=radio]");
if (input) input.checked = selected;
});
}
root.addEventListener("change", function (ev) {
var input = ev.target;
if (!input || input.type !== "radio") return;
var dpId = input.name;
state.choices[dpId] = input.value;
save();
refreshDp(dpId);
refreshProgress();
});
root.addEventListener("input", function (ev) {
var ta = ev.target;
if (!ta || !ta.hasAttribute || !ta.hasAttribute("data-note-for")) return;
state.notes[ta.getAttribute("data-note-for")] = ta.value;
save();
});
var globalNotes = document.getElementById("global-notes");
globalNotes.addEventListener("input", function () {
state.global = globalNotes.value;
save();
});
// hydrate
DPS.forEach(function (dp) {
refreshDp(dp.id);
var ta = root.querySelector('[data-note-for="' + dp.id + '"]');
if (ta && state.notes[dp.id]) ta.value = state.notes[dp.id];
});
globalNotes.value = state.global;
refreshProgress();
// ---------- export ----------
function buildMarkdown() {
var lines = [];
lines.push("# Decision notes — Comments & reactions for me.nech.pl");
lines.push("");
lines.push("Date: 2026-11-01 · Review: " + location.pathname.split("/").filter(Boolean).slice(-2).join("/") || "decisions/2026-11-01-comments-reactions");
lines.push("");
lines.push("> Verdict: Self-hosted comments + reactions as a nechapi service on erable — SQLite, approval-queue antispam, no captcha — is the recommendation; the open questions are identity warmth and how much lands in v1.");
lines.push("");
var decided = 0;
DPS.forEach(function (dp) {
var letter = state.choices[dp.id];
var note = (state.notes[dp.id] || "").trim();
lines.push("## " + dp.title);
if (letter) {
decided++;
var opt = findOption(dp.id, letter);
lines.push("");
lines.push("**Chosen: " + letter + " — " + (opt ? opt.name : "") + "**" + (opt && opt.reco ? " (recommended)" : "") + (opt && !opt.reco ? " (differs from recommendation)" : ""));
} else {
lines.push("");
lines.push("_Undecided._");
}
if (note) { lines.push(""); lines.push(note); }
lines.push("");
});
lines.push("---");
lines.push("");
lines.push("Progress: " + decided + "/8 decided.");
var g = state.global.trim();
if (g) { lines.push(""); lines.push("## Global notes"); lines.push(""); lines.push(g); lines.push(""); }
return lines.join("\n");
}
function showHint(msg) {
var hint = document.getElementById("export-hint");
hint.textContent = msg;
clearTimeout(showHint._t);
showHint._t = setTimeout(function () { hint.textContent = ""; }, 6000);
}
document.getElementById("btn-export").addEventListener("click", function () {
var md = buildMarkdown();
var slug = "comments-reactions-decision-notes.md";
var copied = false;
function download() {
try {
var blob = new Blob([md], { type: "text/markdown" });
var url = URL.createObjectURL(blob);
var a = document.createElement("a");
a.href = url;
a.download = slug;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(function () { URL.revokeObjectURL(url); }, 2000);
} catch (e) { /* download blocked; clipboard result already reported */ }
}
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(md).then(function () {
copied = true;
showHint("Markdown copied to clipboard and downloading as " + slug + ".");
download();
}, function () {
showHint("Clipboard unavailable — downloading as " + slug + ".");
download();
});
} else {
showHint("Downloading as " + slug + ".");
download();
}
});
// ---------- reset ----------
document.getElementById("btn-reset").addEventListener("click", function () {
if (!confirm("Reset this decision review? All choices and notes (including the global notes) will be cleared from local storage.")) return;
state = { choices: {}, notes: {}, global: "" };
save();
DPS.forEach(function (dp) { refreshDp(dp.id); });
Array.prototype.forEach.call(root.querySelectorAll("[data-note-for]"), function (ta) { ta.value = ""; });
globalNotes.value = "";
refreshProgress();
showHint("Reset complete.");
});
})();
</script>
</body>
</html>
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment