diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b3bd18..ecdb389 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,16 @@ Versioning: [Semantic Versioning](https://semver.org/). ## [Unreleased] +### German localization (widget) +- **`data-lang="de"`** — full German dictionary for comments, reactions and + polls; `auto` now also detects German from the browser language + (primary subtag, so `de-DE`, `de-AT`, `de-CH` all match). English stays the + fallback, and `data-text` overrides still win. +- **Admin embed builder** — "Deutsch" added to the language selector. +- **Tests** — new `widget-i18n.test.ts`: language selection, auto-detection, + override precedence, and a key-parity check so every locale defines exactly + the English keys. + ### Telegram alerts v2 (runtime) - **Per-event alert selection** — new `telegram_events` setting (comment,poll,reaction — default `comment`) with checkboxes in the admin diff --git a/integrations/README.md b/integrations/README.md index 1f7c48d..0a6b89b 100644 --- a/integrations/README.md +++ b/integrations/README.md @@ -31,7 +31,7 @@ All templates accept the same options (see the main widget docs in - `pollStyle` / `pollResults` — poll display options - `sort` — `newest | oldest | best` (default `newest`) - `reactionsPosition` — `top | bottom` (default `bottom`) -- `lang` — `en | it | auto` (default `auto`) +- `lang` — `en | it | de | auto` (default `auto`) - `theme` — `auto | light | dark` - `accent` / `accent2` — brand colors - `radius` — corner radius in px (default 14) @@ -102,7 +102,7 @@ type CommentProps = { pollResults?: 'after' | 'always'; sort?: 'newest' | 'oldest' | 'best'; reactionsPosition?: 'top' | 'bottom'; - lang?: 'en' | 'it' | 'auto'; + lang?: 'en' | 'it' | 'de' | 'auto'; theme?: 'auto' | 'light' | 'dark'; accent?: string; radius?: string; diff --git a/integrations/react/Comment.tsx b/integrations/react/Comment.tsx index 305d77d..3f874a7 100644 --- a/integrations/react/Comment.tsx +++ b/integrations/react/Comment.tsx @@ -11,7 +11,7 @@ type CommentsProps = { pollResults?: 'after' | 'always'; sort?: 'newest' | 'oldest' | 'best'; reactionsPosition?: 'top' | 'bottom'; - lang?: 'en' | 'it' | 'auto'; + lang?: 'en' | 'it' | 'de' | 'auto'; theme?: 'auto' | 'light' | 'dark'; accent?: string; accent2?: string; diff --git a/packages/runtime/src/static-content.ts b/packages/runtime/src/static-content.ts index e35a649..3b8d2cd 100644 --- a/packages/runtime/src/static-content.ts +++ b/packages/runtime/src/static-content.ts @@ -1,10 +1,10 @@ // AUTO-GENERATED by scripts/sync-static.mjs — DO NOT EDIT BY HAND. // Regenerate with: npm run build -export const WIDGET_JS: string = "(() => {\n // src/widget.js\n (function() {\n \"use strict\";\n var WORKER_FILE = \"pow-worker.js\";\n var workerUrl = WORKER_FILE;\n var scriptSrc = typeof document !== \"undefined\" && document.currentScript && document.currentScript.src;\n if (scriptSrc) {\n try {\n workerUrl = new URL(WORKER_FILE, scriptSrc).href;\n } catch (e) {\n workerUrl = WORKER_FILE;\n }\n }\n var AVATAR_GRADIENTS = [\n \"linear-gradient(135deg,#ff9a5a,#f05a1c)\",\n \"linear-gradient(135deg,#a78bfa,#7c3aed)\",\n \"linear-gradient(135deg,#5eead4,#0d9488)\",\n \"linear-gradient(135deg,#7dd3fc,#0284c7)\",\n \"linear-gradient(135deg,#f9a8d4,#db2777)\",\n \"linear-gradient(135deg,#bef264,#65a30d)\",\n \"linear-gradient(135deg,#fda4af,#e11d48)\",\n \"linear-gradient(135deg,#fcd34d,#d97706)\"\n ];\n function hashString(str) {\n var h = 0;\n for (var i = 0; i < str.length; i += 1) {\n h = h * 31 + str.charCodeAt(i) | 0;\n }\n return Math.abs(h);\n }\n function el(tag, className, text) {\n var node = document.createElement(tag);\n if (className) node.className = className;\n if (text !== void 0) node.textContent = text;\n return node;\n }\n var I18N = {\n en: {\n title: \"Comments\",\n nickPlaceholder: \"Name (optional)\",\n bodyPlaceholder: \"Write a comment\\u2026\",\n post: \"Post comment\",\n hint: \"Plain text \\xB7 emoji welcome \\xB7 anti-spam proof-of-work\",\n empty: \"No comments yet \\u2014 be the first. \\u{1F4AC}\",\n anonymous: \"Anonymous\",\n emptyComment: \"Comment cannot be empty.\",\n solving: \"Solving proof-of-work\\u2026\",\n pending: \"Comment submitted \\u2014 awaiting moderation.\",\n posted: \"Comment posted.\",\n reactAria: \"React with \",\n reactionRecorded: \"\\u2713 Reaction recorded\",\n slowDown: \"Slow down \\u2014 too many reactions.\",\n challengeUsed: \"Challenge already used \\u2014 try again.\",\n countOne: \"1 comment\",\n countMany: \"{n} comments\",\n voted: \"\\u2713 You voted\",\n closed: \"This poll is closed\",\n pollMissing: \"Poll not found\",\n alreadyVoted: \"You already voted in this poll\",\n pollLoadError: \"Failed to load the poll\",\n pollVoteCountOne: \"1 vote\",\n pollVoteCountMany: \"{n} votes\",\n pollLeader: \"Leads by {n} votes\",\n pollVoteBtn: \"Vote ({n})\",\n pollMultiHint: \"Select one or more options\",\n pollChangeVotes: \"Change your votes\",\n pollVotesChanged: \"Votes updated \\u2014 you can vote again\",\n viewResults: \"View results\",\n backToVote: \"Vote\",\n resultsPreview: \"Live results\",\n startTitle: \"Start the conversation\",\n pinned: \"Pinned\",\n flag: \"Report\",\n flagBusy: \"Sending report\\u2026\",\n flagDone: \"Thanks \\u2014 report sent\",\n flagErr: \"Report failed\",\n sortNewest: \"Newest\",\n sortOldest: \"Oldest\",\n sortBest: \"Best\",\n timeNow: \"now\",\n timeMinAgo: \"{n} min ago\",\n timeHourAgo: \"{n} h ago\",\n timeDayAgo: \"{n} d ago\",\n timeWeekAgo: \"{n} w ago\",\n timeMonthAgo: \"{n} mo ago\",\n timeYearAgo: \"{n} y ago\",\n readMore: \"Read more\",\n showLess: \"Show less\",\n like: \"Like\",\n liked: \"Liked\",\n reply: \"Reply\",\n cancelReply: \"Cancel\",\n replyPlaceholder: \"Write a reply\\u2026\",\n showMoreReplies: \"Show more replies ({n})\",\n parentRemoved: \"Parent comment removed\",\n author: \"Author\"\n },\n it: {\n title: \"Commenti\",\n nickPlaceholder: \"Nome (facoltativo)\",\n bodyPlaceholder: \"Scrivi un commento\\u2026\",\n post: \"Pubblica commento\",\n hint: \"Testo semplice \\xB7 emoji benvenute \\xB7 anti-spam proof-of-work\",\n empty: \"Nessun commento ancora \\u2014 sii il primo. \\u{1F4AC}\",\n anonymous: \"Anonimo\",\n emptyComment: \"Il commento non pu\\xF2 essere vuoto.\",\n solving: \"Risoluzione proof-of-work\\u2026\",\n pending: \"Commento inviato \\u2014 in attesa di moderazione.\",\n posted: \"Commento pubblicato.\",\n reactAria: \"Reagisci con \",\n reactionRecorded: \"\\u2713 Reazione registrata\",\n slowDown: \"Rallenta \\u2014 troppe reazioni.\",\n challengeUsed: \"Challenge gi\\xE0 usata \\u2014 riprova.\",\n countOne: \"1 commento\",\n countMany: \"{n} commenti\",\n voted: \"\\u2713 Hai votato\",\n closed: \"Sondaggio chiuso\",\n pollMissing: \"Sondaggio non trovato\",\n alreadyVoted: \"Hai gi\\xE0 votato in questo sondaggio\",\n pollLoadError: \"Impossibile caricare il sondaggio\",\n pollVoteCountOne: \"1 voto\",\n pollVoteCountMany: \"{n} voti\",\n pollLeader: \"Conduce con {n} voti\",\n pollVoteBtn: \"Vota ({n})\",\n pollMultiHint: \"Seleziona una o pi\\xF9 opzioni\",\n pollChangeVotes: \"Modifica i tuoi voti\",\n pollVotesChanged: \"Voti aggiornati \\u2014 puoi votare di nuovo\",\n viewResults: \"Vedi risultati\",\n backToVote: \"Vota\",\n resultsPreview: \"Risultati in tempo reale\",\n startTitle: \"Inizia la conversazione\",\n pinned: \"In evidenza\",\n flag: \"Segnala\",\n flagBusy: \"Invio segnalazione\\u2026\",\n flagDone: \"Grazie \\u2014 segnalazione inviata\",\n flagErr: \"Segnalazione non riuscita\",\n sortNewest: \"Pi\\xF9 recenti\",\n sortOldest: \"Pi\\xF9 vecchi\",\n sortBest: \"Migliori\",\n timeNow: \"ora\",\n timeMinAgo: \"{n} min fa\",\n timeHourAgo: \"{n} h fa\",\n timeDayAgo: \"{n} g fa\",\n timeWeekAgo: \"{n} sett fa\",\n timeMonthAgo: \"{n} mesi fa\",\n timeYearAgo: \"{n} anni fa\",\n readMore: \"Leggi tutto\",\n showLess: \"Mostra meno\",\n like: \"Mi piace\",\n liked: \"Ti piace\",\n reply: \"Rispondi\",\n cancelReply: \"Annulla\",\n replyPlaceholder: \"Scrivi una risposta\\u2026\",\n showMoreReplies: \"Mostra altre risposte ({n})\",\n parentRemoved: \"Commento padre rimosso\",\n author: \"Autore\"\n }\n };\n var STYLES = '.sl-root{--accent:#f57d1f;--accent-2:#e85d0a;--bg:#ffffff;--card:#ffffff;--border:#e8e8e6;--text:#1a1a1a;--muted:#6f6f6f;--radius:14px;--shadow:0 1px 2px rgba(16,24,40,.04),0 6px 20px -6px rgba(16,24,40,.08);font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Inter,Roboto,\"Helvetica Neue\",Arial,sans-serif;color:var(--text);max-width:640px;margin:0 auto;line-height:1.55;-webkit-font-smoothing:antialiased}.sl-root.sl-theme-dark{--bg:#161616;--card:#1f1f1f;--border:#2b2b2b;--text:#f4f4f4;--muted:#9d9d9d;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px -12px rgba(0,0,0,.5)}@media(prefers-color-scheme:dark){.sl-root:not(.sl-theme-light){--bg:#161616;--card:#1f1f1f;--border:#2b2b2b;--text:#f4f4f4;--muted:#9d9d9d;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px -12px rgba(0,0,0,.5)}}.sl-root *{box-sizing:border-box}.sl-heading{display:flex;align-items:center;justify-content:space-between;margin:0 0 18px}.sl-heading h3{margin:0;font-size:15px;font-weight:650;letter-spacing:-.1px}.sl-count{font-size:11px;font-weight:600;color:var(--muted);background:color-mix(in srgb,var(--border) 60%,transparent);padding:3px 10px;border-radius:999px}.sl-list{list-style:none;margin:0 0 20px;padding:0;display:flex;flex-direction:column;gap:14px}.sl-comment{display:flex;flex-direction:column;gap:14px;padding:16px 18px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);transition:box-shadow .18s ease,transform .18s ease,border-color .18s ease}.sl-comment .sl-top{display:flex;gap:12px;min-width:0}.sl-comment:hover{box-shadow:0 2px 4px rgba(16,24,40,.05),0 12px 32px -10px rgba(16,24,40,.12);transform:translateY(-1px);border-color:color-mix(in srgb,var(--accent) 25%,var(--border))}.sl-avatar{flex:none;width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:650;font-size:14px;user-select:none;box-shadow:inset 0 -2px 6px rgba(0,0,0,.14)}.sl-main{min-width:0;flex:1}.sl-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:3px}.sl-nick{font-weight:600;font-size:13.5px;letter-spacing:-.1px}.sl-time{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}.sl-body{margin:0;font-size:14.5px;white-space:pre-wrap;overflow-wrap:break-word;word-break:break-word}.sl-empty{padding:28px 18px;text-align:center;color:var(--muted);font-size:14px;background:var(--card);border:1px dashed var(--border);border-radius:var(--radius)}.sl-hidden{display:none!important}.sl-start{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px 16px 16px;margin-bottom:20px;text-align:center}.sl-start .sl-form{margin:0;box-shadow:none;text-align:left}.sl-start-msg{margin:0 0 14px;font-size:15px;font-weight:650;letter-spacing:-.2px}.sl-start-sub{display:block;margin-top:3px;font-weight:400;font-size:13px;color:var(--muted)}.sl-start:not(.sl-start-empty) .sl-start-msg{display:none}.sl-form{display:flex;flex-direction:column;gap:10px;padding:16px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}.sl-nick-input,.sl-body-input{width:100%;padding:11px 14px;border:1px solid var(--border);border-radius:10px;background:var(--bg);color:var(--text);font:inherit;font-size:14px;outline:none;transition:border-color .15s ease,box-shadow .15s ease,background .15s ease}.sl-nick-input:focus,.sl-body-input:focus{border-color:var(--accent);background:var(--bg);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.sl-nick-input::placeholder,.sl-body-input::placeholder{color:var(--muted)}.sl-body-input{resize:vertical;min-height:80px;line-height:1.5}.sl-form-row{display:flex;align-items:center;gap:12px;justify-content:space-between;margin-top:2px}.sl-hint{font-size:11.5px;color:var(--muted)}.sl-submit{appearance:none;border:0;cursor:pointer;padding:10px 18px;border-radius:999px;font:inherit;font-weight:600;font-size:13.5px;color:#fff;background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 6px 16px -6px color-mix(in srgb,var(--accent) 60%,transparent);transition:transform .12s ease,filter .12s ease,opacity .12s ease}.sl-submit:hover{transform:translateY(-1px);filter:brightness(1.05)}.sl-submit:disabled{opacity:.55;cursor:progress;transform:none;filter:none}.sl-status{display:flex;align-items:center;gap:8px;min-height:20px;margin:12px 2px 0;font-size:13px;color:var(--muted)}.sl-status[data-kind=\"ok\"]{color:#16a34a}.sl-status[data-kind=\"err\"]{color:#dc2626}.sl-status .sl-spinner{width:14px;height:14px;border:2px solid color-mix(in srgb,var(--muted) 30%,transparent);border-top-color:var(--accent);border-radius:50%;animation:sl-spin .7s linear infinite}.sl-reactions{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px}.sl-reaction{appearance:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;font:inherit;font-size:14px;padding:7px 13px;border-radius:999px;border:1px solid var(--border);background:var(--card);color:var(--text);transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}.sl-reaction:hover{transform:translateY(-1px);border-color:var(--accent);box-shadow:0 4px 14px -6px color-mix(in srgb,var(--accent) 45%,transparent)}.sl-reaction:disabled{opacity:.6;cursor:progress;transform:none}.sl-reaction .sl-reaction-count{font-size:11.5px;font-weight:700;color:var(--muted);font-variant-numeric:tabular-nums}.sl-reaction-status{min-height:16px;margin:0 2px 12px;font-size:12px;color:var(--muted)}.sl-reaction-status[data-kind=\"ok\"]{color:#16a34a}.sl-reaction-status[data-kind=\"err\"]{color:#dc2626}.sl-poll-heading{display:flex;align-items:center;justify-content:space-between;margin:0 0 14px}.sl-poll-heading h3{margin:0;font-size:15px;font-weight:650;letter-spacing:-.1px;line-height:1.4}.sl-poll-options{list-style:none;margin:0 0 12px;padding:0;display:flex;flex-direction:column;gap:10px}.sl-poll-btn{appearance:none;width:100%;text-align:left;cursor:pointer;font:inherit;font-size:14px;padding:12px 16px;border-radius:12px;border:1px solid var(--border);background:var(--card);color:var(--text);transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}.sl-poll-btn:hover{transform:translateY(-1px);border-color:var(--accent);box-shadow:0 4px 14px -6px color-mix(in srgb,var(--accent) 45%,transparent)}.sl-poll-btn:disabled{opacity:.55;cursor:progress;transform:none}.sl-poll-opt-label{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:6px}.sl-poll-opt-name{font-size:14px;font-weight:600}.sl-poll-opt-meta{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}.sl-poll-bar{height:8px;border-radius:999px;background:color-mix(in srgb,var(--border) 60%,transparent);overflow:hidden}.sl-poll-fill{height:100%;border-radius:999px;background:linear-gradient(135deg,var(--accent),var(--accent-2));transition:width .5s ease}.sl-poll-status{display:flex;align-items:center;gap:8px;min-height:20px;margin:10px 2px 0;font-size:13px;color:var(--muted)}.sl-poll-status[data-kind=\"ok\"]{color:#16a34a}.sl-poll-status[data-kind=\"err\"]{color:#dc2626}.sl-poll-count{font-size:11px;font-weight:600;color:var(--muted);background:color-mix(in srgb,var(--border) 60%,transparent);padding:3px 10px;border-radius:999px;white-space:nowrap;font-variant-numeric:tabular-nums}.sl-poll-rank{flex:none;font-size:10.5px;font-weight:700;color:var(--muted);background:color-mix(in srgb,var(--border) 55%,transparent);border-radius:999px;padding:1px 7px;letter-spacing:.2px;font-variant-numeric:tabular-nums}.sl-poll-opt-label{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}.sl-poll-opt-name{font-size:14px;font-weight:600;flex:1;min-width:0}.sl-poll-opt-meta{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums;flex:none}.sl-poll-bar{height:8px;border-radius:999px;background:color-mix(in srgb,var(--border) 60%,transparent);overflow:hidden}.sl-poll-fill{height:100%;border-radius:999px;background:linear-gradient(135deg,var(--accent),var(--accent-2));transition:width .6s cubic-bezier(.22,1,.36,1)}.sl-poll-option.sl-poll-leader .sl-poll-fill{background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 0 14px -2px color-mix(in srgb,var(--accent) 65%,transparent)}.sl-poll-option.sl-poll-leader .sl-poll-opt-name{color:var(--accent)}.sl-poll-lead-info{margin:2px 0 0;font-size:12px;font-weight:600;color:var(--accent);animation:sl-pop .3s ease}.sl-poll-check{display:flex;align-items:center;gap:10px}.sl-poll-check::before{content:\"\";flex:none;width:16px;height:16px;border-radius:5px;border:1.5px solid var(--border);background:var(--bg);transition:background .12s ease,border-color .12s ease}.sl-poll-check.selected{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,var(--card))}.sl-poll-check.selected::before{background:var(--accent);border-color:var(--accent);box-shadow:inset 0 0 0 3px var(--card)}.sl-poll-vote-btn{appearance:none;border:0;cursor:pointer;padding:9px 18px;border-radius:999px;font:inherit;font-weight:600;font-size:13px;color:#fff;background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 6px 16px -6px color-mix(in srgb,var(--accent) 60%,transparent);transition:transform .12s ease,filter .12s ease,opacity .12s ease;margin-top:2px}.sl-poll-vote-btn:hover{transform:translateY(-1px);filter:brightness(1.05)}.sl-poll-vote-btn:disabled{opacity:.55;cursor:not-allowed;transform:none;filter:none}.sl-poll-multi-hint{margin:10px 2px 0;font-size:11.5px;color:var(--muted)}.sl-poll-results-link{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:999px;padding:5px 14px;margin-top:12px;transition:color .12s ease,border-color .12s ease}.sl-poll-results-link:hover{color:var(--accent);border-color:var(--accent)}.sl-poll-actions{display:flex;flex-direction:column;align-items:flex-start;gap:2px}.sl-pop{animation:sl-pop .35s cubic-bezier(.22,1,.36,1)}@keyframes sl-pop{0%{transform:scale(.94)}60%{transform:scale(1.03)}100%{transform:scale(1)}}.sl-replies{list-style:none;margin:0;padding:4px 0 0 14px;display:flex;flex-direction:column;gap:10px;border-left:2px solid color-mix(in srgb,var(--border) 65%,transparent)}.sl-reply{box-shadow:none;padding:12px 14px;background:color-mix(in srgb,var(--card) 55%,transparent)}.sl-reply:hover{box-shadow:none;transform:none}.sl-reply .sl-avatar{width:28px;height:28px;font-size:12px;border-radius:8px}.sl-reply .sl-nick{font-size:12.5px}.sl-owner-badge{display:inline-block;margin-left:7px;font-size:10px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:1px 7px;border-radius:999px;vertical-align:1px}.sl-reply-btn{appearance:none;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;font-weight:600;cursor:pointer;padding:0;margin-top:6px;transition:color .15s}.sl-reply-btn:hover{color:var(--accent)}.sl-reply-form{margin-top:12px}.sl-parent-removed{color:var(--muted);font-style:italic}.sl-show-more{list-style:none}.sl-sort{appearance:none;font:inherit;font-size:11.5px;color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:3px 9px;cursor:pointer;margin-left:8px;transition:border-color .15s}.sl-sort:hover,.sl-sort:focus-visible{border-color:var(--accent);outline:none}.sl-pin-badge{display:inline-block;margin-left:7px;font-size:10px;font-weight:700;letter-spacing:.3px;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:1px 7px;border-radius:999px;vertical-align:1px}.sl-pinned{border-color:color-mix(in srgb,var(--accent) 35%,var(--border))}.sl-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}.sl-vote-btn{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:999px;padding:3px 10px;transition:color .12s ease,border-color .12s ease,transform .12s ease}.sl-vote-btn:hover{color:var(--accent);border-color:var(--accent);transform:translateY(-1px)}.sl-vote-btn.liked{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent)}.sl-vote-btn:disabled{opacity:.6;cursor:progress;transform:none}.sl-flag-btn{appearance:none;cursor:pointer;font:inherit;font-size:11.5px;color:var(--muted);background:transparent;border:0;padding:3px 6px;border-radius:8px}.sl-flag-btn:hover{color:#dc2626;background:color-mix(in srgb,#dc2626 8%,transparent)}.sl-flag-btn:disabled{opacity:.6;cursor:progress}.sl-flag-btn.done{color:#16a34a}.sl-more-btn{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--accent);background:none;border:0;padding:0 0 0 4px}.sl-skeleton-card{pointer-events:none}.sl-sk{height:12px;border-radius:6px;background:linear-gradient(90deg,color-mix(in srgb,var(--border) 55%,transparent),color-mix(in srgb,var(--border) 22%,transparent),color-mix(in srgb,var(--border) 55%,transparent));background-size:200% 100%;animation:sl-shimmer 1.3s infinite linear}.sl-sk-nick{width:30%;height:10px;margin-bottom:10px}.sl-sk-line{width:100%;margin-bottom:8px}.sl-sk-line.short{width:70%}.sl-sk-bar{height:8px;border-radius:999px}@keyframes sl-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}@keyframes sl-spin{to{transform:rotate(360deg)}}.sl-root :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}@media (prefers-reduced-motion: reduce){.sl-root *{transition:none!important;animation:none!important}.sl-poll-fill{transition:none!important}}';\n function injectStyles() {\n if (document.getElementById(\"sl-styles\")) return;\n var style = document.createElement(\"style\");\n style.id = \"sl-styles\";\n style.textContent = STYLES;\n (document.head || document.documentElement).appendChild(style);\n }\n function mount(root, opts) {\n if (!root || root.__slMounted) return;\n var endpoint = (opts && opts.endpoint || root.getAttribute(\"data-endpoint\") || root.getAttribute(\"data-api\") || \"\").replace(/\\/+$/, \"\");\n var articlePath = opts && opts.articlePath || root.getAttribute(\"data-article-path\") || root.getAttribute(\"data-article-id\") || (typeof location !== \"undefined\" ? location.pathname : \"\");\n var hostContext = opts && opts.hostContext || root.getAttribute(\"data-host-context\") || (typeof location !== \"undefined\" ? location.hostname : \"\");\n if (!endpoint) return;\n root.__slMounted = true;\n root.classList.add(\"sl-root\");\n var lang = opts && opts.lang || root.getAttribute(\"data-lang\") || \"auto\";\n if (lang !== \"en\" && lang !== \"it\") {\n lang = typeof navigator !== \"undefined\" && navigator.language && navigator.language.toLowerCase().indexOf(\"it\") === 0 ? \"it\" : \"en\";\n }\n var dict = I18N[lang] || I18N.en;\n var customTexts = null;\n if (opts && opts.texts) {\n customTexts = opts.texts;\n } else {\n var rawTexts = root.getAttribute(\"data-text\");\n if (rawTexts) {\n try {\n customTexts = JSON.parse(rawTexts);\n } catch (e) {\n customTexts = null;\n }\n }\n }\n function t(key) {\n if (customTexts && Object.prototype.hasOwnProperty.call(customTexts, key) && typeof customTexts[key] === \"string\" && customTexts[key].length) return customTexts[key];\n if (dict && Object.prototype.hasOwnProperty.call(dict, key)) return dict[key];\n return key;\n }\n function countLabel(n) {\n return String(t(n === 1 ? \"countOne\" : \"countMany\")).replace(\"{n}\", String(n));\n }\n function pollCountLabel(n) {\n return String(t(n === 1 ? \"pollVoteCountOne\" : \"pollVoteCountMany\")).replace(\"{n}\", String(n));\n }\n function relativeTime(ts) {\n var diff = Math.floor(Date.now() / 1e3) - ts;\n if (diff < 60) return t(\"timeNow\");\n if (diff < 3600) return t(\"timeMinAgo\").replace(\"{n}\", String(Math.floor(diff / 60)));\n if (diff < 86400) return t(\"timeHourAgo\").replace(\"{n}\", String(Math.floor(diff / 3600)));\n if (diff < 604800) return t(\"timeDayAgo\").replace(\"{n}\", String(Math.floor(diff / 86400)));\n if (diff < 2592e3) return t(\"timeWeekAgo\").replace(\"{n}\", String(Math.floor(diff / 604800)));\n if (diff < 31536e3) return t(\"timeMonthAgo\").replace(\"{n}\", String(Math.floor(diff / 2592e3)));\n if (diff < 31536e4) return t(\"timeYearAgo\").replace(\"{n}\", String(Math.floor(diff / 31536e3)));\n return new Date(ts * 1e3).toLocaleDateString();\n }\n var theme = String(opts && opts.theme || root.getAttribute(\"data-theme\") || \"\").trim();\n if (theme === \"dark\") root.classList.add(\"sl-theme-dark\");\n else if (theme === \"light\") root.classList.add(\"sl-theme-light\");\n var accent = String(opts && opts.accent || root.getAttribute(\"data-accent\") || \"\").trim();\n if (accent) root.style.setProperty(\"--accent\", accent);\n var accent2 = String(opts && opts.accent2 || root.getAttribute(\"data-accent-2\") || \"\").trim();\n if (accent2) root.style.setProperty(\"--accent-2\", accent2);\n var radius = String(opts && opts.radius || root.getAttribute(\"data-radius\") || \"\").trim();\n if (radius) root.style.setProperty(\"--radius\", radius.replace(/[^0-9.]/g, \"\") + \"px\");\n var maxWidth = String(opts && opts.maxWidth || root.getAttribute(\"data-max-width\") || \"\").trim();\n if (maxWidth) root.style.setProperty(\"max-width\", maxWidth.replace(/[^0-9.]/g, \"\") + \"px\");\n var pollId = String(opts && opts.pollId || root.getAttribute(\"data-poll-id\") || \"\").trim();\n var pollStyle = String(opts && opts.pollStyle || root.getAttribute(\"data-poll-style\") || \"bars\").trim();\n var pollResults = String(opts && opts.pollResults || root.getAttribute(\"data-poll-results\") || \"after\").trim();\n var timeGateMs = 3e3;\n var rawGate = opts && opts.timeGateMs != null ? String(opts.timeGateMs) : root.getAttribute(\"data-time-gate-ms\");\n if (rawGate != null && /^\\d+$/.test(rawGate)) timeGateMs = parseInt(rawGate, 10);\n var reactionsPosition = String(opts && opts.reactionsPosition || root.getAttribute(\"data-reactions-position\") || \"bottom\").trim();\n if (reactionsPosition !== \"top\" && reactionsPosition !== \"bottom\") reactionsPosition = \"bottom\";\n var commentsSort = String(opts && opts.commentsSort || root.getAttribute(\"data-comments-sort\") || \"newest\").trim();\n if (commentsSort !== \"newest\" && commentsSort !== \"oldest\" && commentsSort !== \"best\") commentsSort = \"newest\";\n if (pollId) {\n initPoll();\n return;\n }\n var reactionsOnly = !!(opts && opts.reactionsOnly) || root.getAttribute(\"data-reactions-only\") !== null;\n var reactions = null;\n if (opts && Array.isArray(opts.reactions)) {\n reactions = opts.reactions.filter(function(s) {\n return typeof s === \"string\" && s.length;\n });\n } else {\n var rawAttr = root.getAttribute(\"data-reactions\");\n if (rawAttr) {\n reactions = rawAttr.split(\",\").map(function(s) {\n return s.trim();\n }).filter(Boolean);\n }\n }\n var heading = null, count = null, list = null, nickInput = null, bodyInput = null;\n var submitBtn = null, status = null, form = null;\n var reactBar = null, reactStatus = null;\n var reactBtns = {}, reactBusy = false;\n var sortSelect = null;\n var loadedOnce = false;\n var commentVoterKey = \"sl-comment-voter-token\";\n var commentVoterToken = null;\n try {\n commentVoterToken = localStorage.getItem(commentVoterKey);\n } catch (e) {\n commentVoterToken = null;\n }\n function buildReactions() {\n if (!reactBar || !reactions) return;\n reactions.forEach(function(r) {\n var btn = el(\"button\", \"sl-reaction\");\n btn.type = \"button\";\n btn.setAttribute(\"aria-label\", t(\"reactAria\") + r);\n var c = el(\"span\", \"sl-reaction-count\", \"0\");\n btn.append(el(\"span\", \"sl-reaction-emoji\", r), c);\n reactBtns[r] = { btn, count: c };\n btn.addEventListener(\"click\", function() {\n submitReaction(r);\n });\n reactBar.appendChild(btn);\n });\n }\n if (reactionsOnly) {\n reactStatus = el(\"p\", \"sl-reaction-status\");\n reactBar = el(\"div\", \"sl-reactions\");\n buildReactions();\n root.append(reactBar, reactStatus);\n } else {\n heading = el(\"div\", \"sl-heading\");\n heading.appendChild(el(\"h3\", null, t(\"title\")));\n count = el(\"span\", \"sl-count\", \"\\u2026\");\n heading.appendChild(count);\n sortSelect = el(\"select\", \"sl-sort\");\n sortSelect.setAttribute(\"aria-label\", t(\"title\") + \" \\u2014 sort\");\n [[\"newest\", t(\"sortNewest\")], [\"oldest\", t(\"sortOldest\")], [\"best\", t(\"sortBest\")]].forEach(function(opt) {\n var o = document.createElement(\"option\");\n o.value = opt[0];\n o.textContent = opt[1];\n sortSelect.appendChild(o);\n });\n sortSelect.value = commentsSort;\n sortSelect.style.display = \"none\";\n sortSelect.addEventListener(\"change\", function() {\n commentsSort = sortSelect.value;\n loadComments();\n });\n heading.appendChild(sortSelect);\n list = el(\"ul\", \"sl-list\");\n nickInput = el(\"input\");\n nickInput.type = \"text\";\n nickInput.maxLength = 50;\n nickInput.placeholder = t(\"nickPlaceholder\");\n nickInput.className = \"sl-nick-input\";\n bodyInput = el(\"textarea\");\n bodyInput.maxLength = 3e3;\n bodyInput.rows = 3;\n bodyInput.placeholder = t(\"bodyPlaceholder\");\n bodyInput.className = \"sl-body-input\";\n submitBtn = el(\"button\", \"sl-submit\", t(\"post\"));\n submitBtn.type = \"submit\";\n var hint = el(\"span\", \"sl-hint\", t(\"hint\"));\n status = el(\"p\", \"sl-status\");\n form = el(\"form\", \"sl-form\");\n var row = el(\"div\", \"sl-form-row\");\n row.append(hint, submitBtn);\n var hpInput = makeHoneypot(\"website\");\n form.append(nickInput, bodyInput, hpInput, row);\n var startCard = el(\"div\", \"sl-start sl-start-empty\");\n var startMsg = el(\"p\", \"sl-start-msg\", t(\"startTitle\"));\n startMsg.appendChild(el(\"span\", \"sl-start-sub\", t(\"empty\")));\n startCard.append(startMsg, form, status);\n root.append(heading);\n if (reactions && reactions.length) {\n reactStatus = el(\"p\", \"sl-reaction-status\");\n reactBar = el(\"div\", \"sl-reactions\");\n buildReactions();\n if (reactionsPosition === \"top\") root.append(reactBar, reactStatus);\n }\n root.append(list, startCard);\n if (reactions && reactions.length && reactionsPosition !== \"top\") {\n root.append(reactBar, reactStatus);\n }\n }\n function setStatus(message, kind) {\n status.replaceChildren();\n status.removeAttribute(\"data-kind\");\n if (kind === \"ok\") {\n status.setAttribute(\"data-kind\", \"ok\");\n status.appendChild(el(\"span\", null, \"\\u2713\"));\n }\n if (kind === \"err\") {\n status.setAttribute(\"data-kind\", \"err\");\n status.appendChild(el(\"span\", null, \"\\u2715\"));\n }\n if (kind === \"busy\") {\n status.appendChild(el(\"span\", \"sl-spinner\"));\n }\n status.appendChild(document.createTextNode(message));\n }\n var MAX_DEPTH = 3;\n var REPLIES_PER_PAGE = 5;\n function buildTree(comments) {\n var byId = {};\n var roots = [];\n comments.forEach(function(c) {\n c.children = [];\n c.parentMissing = false;\n byId[c.id] = c;\n });\n comments.forEach(function(c) {\n if (c.parent_id && byId[c.parent_id]) {\n byId[c.parent_id].children.push(c);\n } else {\n if (c.parent_id) c.parentMissing = true;\n roots.push(c);\n }\n });\n return roots;\n }\n var READ_MORE_CHARS = 400;\n function skeletonItem() {\n var li = el(\"li\", \"sl-comment sl-skeleton-card\");\n var nick = el(\"div\", \"sl-sk sl-sk-nick\");\n var line1 = el(\"div\", \"sl-sk sl-sk-line\");\n var line2 = el(\"div\", \"sl-sk sl-sk-line short\");\n li.append(nick, line1, line2);\n return li;\n }\n function makeBody(text) {\n var p = el(\"p\", \"sl-body\");\n if (text.length <= READ_MORE_CHARS) {\n p.textContent = text;\n return p;\n }\n var short = text.slice(0, READ_MORE_CHARS).replace(/\\s+\\S*$/, \"\") + \"\\u2026\";\n var shown = el(\"span\", null, short);\n var full = el(\"span\", null, text);\n full.style.display = \"none\";\n var btn = el(\"button\", \"sl-more-btn\", t(\"readMore\"));\n btn.type = \"button\";\n btn.addEventListener(\"click\", function() {\n var isCollapsed = full.style.display === \"none\";\n shown.style.display = isCollapsed ? \"none\" : \"inline\";\n full.style.display = isCollapsed ? \"inline\" : \"none\";\n btn.textContent = isCollapsed ? t(\"showLess\") : t(\"readMore\");\n });\n p.append(shown, full, btn);\n return p;\n }\n function renderComment(c, depth) {\n var li = el(\"li\", \"sl-comment\" + (depth > 0 ? \" sl-reply\" : \"\") + (c.pinned && depth === 0 ? \" sl-pinned\" : \"\"));\n var nick = c.nickname && c.nickname.trim() || t(\"anonymous\");\n var avatar = el(\"div\", \"sl-avatar\", nick.charAt(0).toUpperCase());\n avatar.style.background = AVATAR_GRADIENTS[hashString(nick) % AVATAR_GRADIENTS.length];\n var main = el(\"div\", \"sl-main\");\n var head = el(\"div\", \"sl-head\");\n var nickWrap = el(\"span\", \"sl-nick\", nick);\n if (c.is_owner) nickWrap.appendChild(el(\"span\", \"sl-owner-badge\", t(\"author\")));\n if (c.pinned && depth === 0) nickWrap.appendChild(el(\"span\", \"sl-pin-badge\", \"\\u{1F4CC} \" + t(\"pinned\")));\n head.append(nickWrap, el(\"span\", \"sl-time\", relativeTime(c.created_at)));\n var bodyEl = c.parentMissing ? el(\"p\", \"sl-body sl-parent-removed\", t(\"parentRemoved\")) : makeBody(c.body);\n main.append(head, bodyEl);\n var actions = el(\"div\", \"sl-actions\");\n var votes = Number(c.votes) || 0;\n var voteBtn = el(\"button\", \"sl-vote-btn\" + (c.voted ? \" liked\" : \"\"), (c.voted ? \"\\u2713 \" : \"\\u25B2 \") + votes);\n voteBtn.type = \"button\";\n voteBtn.setAttribute(\"aria-pressed\", c.voted ? \"true\" : \"false\");\n voteBtn.title = c.voted ? t(\"liked\") : t(\"like\");\n voteBtn.addEventListener(\"click\", function() {\n voteComment(c, voteBtn);\n });\n actions.appendChild(voteBtn);\n if (depth < MAX_DEPTH) {\n var replyBtn = el(\"button\", \"sl-reply-btn\", t(\"reply\"));\n replyBtn.type = \"button\";\n replyBtn.addEventListener(\"click\", function() {\n toggleReplyForm(c, main, replyBtn);\n });\n actions.appendChild(replyBtn);\n }\n var flagBtn = el(\"button\", \"sl-flag-btn\", t(\"flag\"));\n flagBtn.type = \"button\";\n flagBtn.addEventListener(\"click\", function() {\n flagComment(c, flagBtn);\n });\n actions.appendChild(flagBtn);\n main.appendChild(actions);\n var top = el(\"div\", \"sl-top\");\n top.append(avatar, main);\n li.append(top);\n if (c.children && c.children.length) {\n var ul = el(\"ul\", \"sl-replies\");\n var visible = c.children.slice(0, REPLIES_PER_PAGE);\n visible.forEach(function(ch) {\n ul.appendChild(renderComment(ch, depth + 1));\n });\n var hidden = c.children.slice(REPLIES_PER_PAGE);\n if (hidden.length) {\n var more = el(\"li\", \"sl-show-more\");\n var moreBtn = el(\"button\", \"sl-reply-btn\", t(\"showMoreReplies\").replace(\"{n}\", String(hidden.length)));\n moreBtn.type = \"button\";\n moreBtn.addEventListener(\"click\", function() {\n hidden.forEach(function(ch) {\n ul.insertBefore(renderComment(ch, depth + 1), more);\n });\n more.remove();\n });\n more.appendChild(moreBtn);\n ul.appendChild(more);\n }\n li.appendChild(ul);\n }\n return li;\n }\n function commentActionChallenge() {\n return fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n });\n }\n function voteComment(c, btn) {\n if (btn.disabled || c.voted) return;\n btn.disabled = true;\n commentActionChallenge().then(function(challenge) {\n return solveWithWorker(challenge, \"comment-action\", \"vote\", c.id).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n var payload = {\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n commentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n };\n if (commentVoterToken) payload.voterToken = commentVoterToken;\n return fetch(endpoint + \"/api/comments/vote\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(payload)\n });\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, status: res.status, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n if (outcome.data.voterToken) {\n commentVoterToken = outcome.data.voterToken;\n try {\n localStorage.setItem(commentVoterKey, commentVoterToken);\n } catch (e) {\n }\n }\n c.voted = true;\n c.votes = Number(outcome.data.votes) || Number(c.votes) + 1;\n btn.classList.add(\"liked\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n btn.textContent = \"\\u2713 \" + c.votes;\n btn.title = t(\"liked\");\n } else if (outcome.status === 409) {\n c.voted = true;\n btn.classList.add(\"liked\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n btn.textContent = \"\\u2713 \" + (Number(c.votes) || 0);\n }\n }).catch(function() {\n }).finally(function() {\n btn.disabled = false;\n });\n }\n function flagComment(c, btn) {\n if (btn.disabled) return;\n btn.disabled = true;\n btn.textContent = t(\"flagBusy\");\n commentActionChallenge().then(function(challenge) {\n return solveWithWorker(challenge, \"comment-action\", \"flag\", c.id).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return fetch(endpoint + \"/api/comments/flag\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n commentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n btn.textContent = t(\"flagDone\");\n btn.classList.add(\"done\");\n } else {\n btn.textContent = t(\"flag\");\n btn.disabled = false;\n }\n }).catch(function() {\n btn.textContent = t(\"flag\");\n btn.disabled = false;\n });\n }\n function toggleReplyForm(c, main, btn) {\n var existing = main.querySelector(\".sl-reply-form\");\n if (existing) {\n existing.remove();\n btn.textContent = t(\"reply\");\n return;\n }\n var form2 = el(\"form\", \"sl-form sl-reply-form\");\n var nickInput2 = el(\"input\");\n nickInput2.type = \"text\";\n nickInput2.maxLength = 50;\n nickInput2.placeholder = t(\"nickPlaceholder\");\n nickInput2.className = \"sl-nick-input\";\n var bodyInput2 = el(\"textarea\");\n bodyInput2.maxLength = 3e3;\n bodyInput2.rows = 2;\n bodyInput2.placeholder = t(\"replyPlaceholder\");\n bodyInput2.className = \"sl-body-input\";\n var row2 = el(\"div\", \"sl-form-row\");\n var submit = el(\"button\", \"sl-submit\", t(\"post\"));\n submit.type = \"submit\";\n var cancel = el(\"button\", \"sl-reply-btn\", t(\"cancelReply\"));\n cancel.type = \"button\";\n cancel.addEventListener(\"click\", function() {\n form2.remove();\n btn.textContent = t(\"reply\");\n });\n row2.append(cancel, submit);\n var hpInput2 = makeHoneypot(\"website\");\n form2.append(nickInput2, bodyInput2, hpInput2, row2);\n var statusEl = el(\"p\", \"sl-status\");\n form2.appendChild(statusEl);\n var busy = false;\n form2.addEventListener(\"submit\", function(event) {\n event.preventDefault();\n if (busy) return;\n var nickname = nickInput2.value.trim();\n var body = bodyInput2.value.trim();\n if (!body) {\n statusEl.textContent = t(\"emptyComment\");\n return;\n }\n busy = true;\n submit.disabled = true;\n statusEl.textContent = t(\"solving\");\n fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", nickname, body).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return fetch(endpoint + \"/api/comments\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n nickname,\n body,\n parentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n statusEl.textContent = outcome.data.comment && outcome.data.comment.status === \"pending\" ? t(\"pending\") : t(\"posted\");\n bodyInput2.value = \"\";\n return loadComments();\n }\n statusEl.textContent = \"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\");\n }).catch(function(err) {\n statusEl.textContent = \"Error: \" + err.message;\n }).finally(function() {\n busy = false;\n submit.disabled = false;\n });\n });\n main.appendChild(form2);\n btn.textContent = t(\"cancelReply\");\n bodyInput2.focus();\n }\n function loadComments() {\n if (!loadedOnce) {\n loadedOnce = true;\n list.classList.remove(\"sl-hidden\");\n startCard.classList.add(\"sl-hidden\");\n var frag = document.createDocumentFragment();\n for (var i = 0; i < 3; i += 1) frag.appendChild(skeletonItem());\n list.appendChild(frag);\n }\n var suffix = commentVoterToken ? \"&voterToken=\" + encodeURIComponent(commentVoterToken) : \"\";\n return fetch(\n endpoint + \"/api/comments?article_path=\" + encodeURIComponent(articlePath) + \"&host_context=\" + encodeURIComponent(hostContext) + suffix\n ).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load comments\");\n return res.json();\n }).then(function(data) {\n var comments = Array.isArray(data.comments) ? data.comments : [];\n count.textContent = countLabel(comments.length);\n list.replaceChildren();\n if (comments.length === 0) {\n list.classList.add(\"sl-hidden\");\n startCard.classList.remove(\"sl-hidden\");\n startCard.classList.add(\"sl-start-empty\");\n if (sortSelect) sortSelect.style.display = \"none\";\n return;\n }\n list.classList.remove(\"sl-hidden\");\n startCard.classList.remove(\"sl-hidden\");\n startCard.classList.remove(\"sl-start-empty\");\n if (sortSelect) sortSelect.style.display = comments.length > 1 ? \"\" : \"none\";\n var roots = buildTree(comments);\n var pinnedRoots = roots.filter(function(r) {\n return r.pinned;\n });\n var restRoots = roots.filter(function(r) {\n return !r.pinned;\n });\n var dir = commentsSort === \"oldest\" ? 1 : -1;\n restRoots.sort(function(a, b) {\n if (commentsSort === \"best\") {\n return (Number(b.votes) || 0) - (Number(a.votes) || 0) || a.created_at - b.created_at;\n }\n return (a.created_at - b.created_at) * dir;\n });\n pinnedRoots.concat(restRoots).forEach(function(r) {\n list.appendChild(renderComment(r, 0));\n });\n });\n }\n function createPowWorkerUrl() {\n return fetch(workerUrl, { mode: \"cors\", credentials: \"omit\" }).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load pow worker\");\n return res.blob();\n }).then(function(blob) {\n return URL.createObjectURL(blob);\n });\n }\n function makeHoneypot(name) {\n var hp = el(\"input\");\n hp.type = \"text\";\n hp.name = name || \"website\";\n hp.tabIndex = -1;\n hp.autocomplete = \"off\";\n hp.setAttribute(\"aria-hidden\", \"true\");\n hp.className = \"sl-hp\";\n hp.style.cssText = \"position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important;overflow:hidden!important;\";\n return hp;\n }\n function waitForGate(startMs) {\n if (timeGateMs <= 0) return Promise.resolve();\n var wait = Math.max(0, timeGateMs + 500 - (Date.now() - startMs));\n if (wait <= 0) return Promise.resolve();\n return new Promise(function(resolveGate) {\n setTimeout(resolveGate, wait);\n });\n }\n function solveWithWorker(challenge, kind, a, b) {\n return new Promise(function(resolve, reject) {\n createPowWorkerUrl().then(function(url) {\n var worker;\n try {\n worker = new Worker(url);\n } catch (err) {\n reject(err);\n return null;\n }\n worker.onmessage = function(e) {\n URL.revokeObjectURL(url);\n worker.terminate();\n if (e.data && e.data.type === \"nonce\") resolve(e.data.nonce);\n else reject(new Error(e.data && e.data.message || \"pow worker failed\"));\n };\n worker.onerror = function() {\n URL.revokeObjectURL(url);\n worker.terminate();\n reject(new Error(\"pow worker error\"));\n };\n var msg = { challenge };\n if (kind === \"poll\") {\n msg.pollId = a;\n if (Array.isArray(b)) msg.options = b;\n else msg.option = b;\n } else if (kind === \"comment-action\") {\n msg.action = a;\n msg.commentId = b;\n } else {\n msg.nickname = a;\n msg.body = b;\n }\n worker.postMessage(msg);\n return null;\n }).catch(reject);\n });\n }\n function submitComment(event) {\n event.preventDefault();\n var nickname = nickInput.value.trim();\n var body = bodyInput.value.trim();\n if (!body) {\n setStatus(t(\"emptyComment\"), \"err\");\n return;\n }\n submitBtn.disabled = true;\n setStatus(t(\"solving\"), \"busy\");\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", nickname, body).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n return fetch(endpoint + \"/api/comments\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n nickname,\n body,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce,\n honeypot: hpInput.value\n })\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n bodyInput.value = \"\";\n setStatus(\n outcome.data.comment && outcome.data.comment.status === \"pending\" ? t(\"pending\") : t(\"posted\"),\n \"ok\"\n );\n return loadComments();\n }\n setStatus(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }).catch(function(err) {\n setStatus(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n submitBtn.disabled = false;\n });\n }\n if (form) form.addEventListener(\"submit\", submitComment);\n if (!reactionsOnly) loadComments();\n loadReactions();\n function setReactStatus(message, kind) {\n if (!reactStatus) return;\n reactStatus.textContent = message;\n if (kind) reactStatus.setAttribute(\"data-kind\", kind);\n else reactStatus.removeAttribute(\"data-kind\");\n }\n function setReactBusy(busy) {\n reactBusy = busy;\n reactions.forEach(function(x) {\n if (reactBtns[x]) reactBtns[x].btn.disabled = busy;\n });\n }\n function renderReactionCounts(counts) {\n var map = {};\n counts.forEach(function(r) {\n map[r.reaction] = Number(r.count) || 0;\n });\n reactions.forEach(function(x) {\n if (reactBtns[x]) reactBtns[x].count.textContent = String(map[x] || 0);\n });\n }\n function loadReactions() {\n if (!reactBar) return;\n fetch(endpoint + \"/api/reactions?article_path=\" + encodeURIComponent(articlePath)).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load reactions\");\n return res.json();\n }).then(function(data) {\n if (Array.isArray(data.reactions)) renderReactionCounts(data.reactions);\n }).catch(function() {\n });\n }\n function submitReaction(r) {\n if (reactBusy || !reactBtns[r]) return;\n setReactBusy(true);\n setReactStatus(t(\"solving\"));\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/reactions/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", \"\", \"\").then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n return fetch(endpoint + \"/api/reactions\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n reaction: r,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, status: res.status, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n setReactStatus(t(\"reactionRecorded\"), \"ok\");\n if (Array.isArray(outcome.data.reactions)) renderReactionCounts(outcome.data.reactions);\n } else if (outcome.status === 429) {\n setReactStatus(t(\"slowDown\"), \"err\");\n } else if (outcome.status === 409) {\n setReactStatus(t(\"challengeUsed\"), \"err\");\n } else {\n setReactStatus(\n \"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"),\n \"err\"\n );\n }\n }).catch(function(err) {\n setReactStatus(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n setReactBusy(false);\n });\n }\n function initPoll() {\n var voterKey = \"sl-voter-token\";\n var voterToken = null;\n try {\n voterToken = localStorage.getItem(voterKey);\n } catch (e) {\n voterToken = null;\n }\n var heading2 = el(\"div\", \"sl-poll-heading\");\n var qEl = el(\"h3\", null, \"\\u2026\");\n var totalChip = el(\"span\", \"sl-poll-count\", \"\");\n heading2.append(qEl, totalChip);\n var statusEl = el(\"p\", \"sl-poll-status\");\n var listEl = el(\"ul\", \"sl-poll-options\");\n var actionsEl = el(\"div\", \"sl-poll-actions\");\n root.append(heading2, listEl, actionsEl, statusEl);\n renderPollSkeleton();\n var selected = {};\n var multiVoteBtn = null;\n var revealed = false;\n var currentPoll = null;\n function renderPollSkeleton() {\n listEl.replaceChildren();\n for (var i = 0; i < 3; i += 1) {\n var li = el(\"li\", \"sl-poll-option\");\n li.appendChild(el(\"div\", \"sl-sk sl-sk-bar\"));\n listEl.appendChild(li);\n }\n }\n function setStatus2(message, kind) {\n statusEl.replaceChildren();\n statusEl.removeAttribute(\"data-kind\");\n if (kind === \"ok\") {\n statusEl.setAttribute(\"data-kind\", \"ok\");\n statusEl.appendChild(el(\"span\", null, \"\\u2713\"));\n } else if (kind === \"err\") {\n statusEl.setAttribute(\"data-kind\", \"err\");\n statusEl.appendChild(el(\"span\", null, \"\\u2715\"));\n } else if (kind === \"busy\") {\n statusEl.appendChild(el(\"span\", \"sl-spinner\"));\n }\n statusEl.appendChild(document.createTextNode(message));\n }\n function animateValue(node, to, suffix) {\n var dur = 520, start = null;\n function step(ts) {\n if (!start) start = ts;\n var p = Math.min(1, (ts - start) / dur);\n var eased = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;\n node.textContent = Math.round(to * eased) + (suffix || \"\");\n if (p < 1) requestAnimationFrame(step);\n }\n requestAnimationFrame(step);\n }\n function renderPoll(poll) {\n currentPoll = poll;\n qEl.textContent = poll.question || \"\";\n totalChip.textContent = pollCountLabel(Number(poll.total) || 0);\n var opts2 = Array.isArray(poll.options) ? poll.options : [];\n var total = Number(poll.total) || 0;\n var showResults = poll.status !== \"open\" || !!poll.voted || pollResults === \"always\" || revealed;\n listEl.replaceChildren();\n actionsEl.replaceChildren();\n multiVoteBtn = null;\n var rows = opts2.map(function(o) {\n return { option: o, count: poll.counts && poll.counts[o] ? Number(poll.counts[o]) : 0 };\n });\n if (showResults) {\n rows = rows.slice().sort(function(a, b) {\n return b.count - a.count || opts2.indexOf(a.option) - opts2.indexOf(b.option);\n });\n }\n var leader = showResults && rows.length > 0 && rows[0].count > (rows[1] ? rows[1].count : 0);\n rows.forEach(function(r, i) {\n var o = r.option, c = r.count;\n var pct = total > 0 ? Math.round(c / total * 100) : 0;\n var li = el(\"li\", \"sl-poll-option\" + (showResults && leader && i === 0 ? \" sl-poll-leader\" : \"\"));\n if (showResults) {\n if (pollStyle !== \"counts\") {\n var label = el(\"div\", \"sl-poll-opt-label\");\n var meta = el(\"span\", \"sl-poll-opt-meta\", \"\");\n if (pollStyle === \"percent\") animateValue(meta, pct, \"%\");\n else if (pollStyle === \"minimal\") meta.textContent = \"\";\n else animateValue(meta, c, \" \\xB7 \" + pct + \"%\");\n label.append(el(\"span\", \"sl-poll-rank\", \"#\" + (i + 1)), el(\"span\", \"sl-poll-opt-name\", o), meta);\n var bar = el(\"div\", \"sl-poll-bar\");\n var fill = el(\"div\", \"sl-poll-fill\");\n fill.style.width = \"0%\";\n fill.style.transitionDelay = i * 90 + \"ms\";\n bar.appendChild(fill);\n li.append(label, bar);\n requestAnimationFrame(function() {\n requestAnimationFrame(function() {\n fill.style.width = pct + \"%\";\n });\n });\n } else {\n var cLabel = el(\"div\", \"sl-poll-opt-label\");\n var cMeta = el(\"span\", \"sl-poll-opt-meta\", \"\");\n animateValue(cMeta, c, \"\");\n cLabel.append(el(\"span\", \"sl-poll-rank\", \"#\" + (i + 1)), el(\"span\", \"sl-poll-opt-name\", o), cMeta);\n li.appendChild(cLabel);\n }\n } else {\n var btn = el(\"button\", \"sl-poll-btn\" + (poll.multi ? \" sl-poll-check\" : \"\"), o);\n btn.type = \"button\";\n if (poll.multi) {\n btn.setAttribute(\"aria-pressed\", \"false\");\n btn.addEventListener(\"click\", function() {\n toggleSelect(o, btn);\n });\n } else {\n btn.addEventListener(\"click\", function() {\n vote([o], btn, false);\n });\n }\n li.appendChild(btn);\n }\n listEl.appendChild(li);\n });\n if (showResults) {\n if (leader) {\n var gap = rows[0].count - (rows[1] ? rows[1].count : 0);\n actionsEl.appendChild(el(\"p\", \"sl-poll-lead-info\", t(\"pollLeader\").replace(\"{n}\", String(gap))));\n }\n if (poll.multi && poll.singleVote && poll.status === \"open\" && poll.voted) {\n var changeBtn = el(\"button\", \"sl-poll-vote-btn\", t(\"pollChangeVotes\"));\n changeBtn.type = \"button\";\n changeBtn.addEventListener(\"click\", changeVotes);\n actionsEl.appendChild(changeBtn);\n }\n if (revealed && !poll.voted && poll.status === \"open\") {\n setStatus2(t(\"resultsPreview\"), void 0);\n var backBtn = el(\"button\", \"sl-poll-results-link\", t(\"backToVote\"));\n backBtn.type = \"button\";\n backBtn.addEventListener(\"click\", function() {\n revealed = false;\n renderPoll(currentPoll);\n });\n actionsEl.appendChild(backBtn);\n } else {\n setStatus2(poll.status !== \"open\" ? t(\"closed\") : t(\"voted\"), poll.status !== \"open\" ? void 0 : \"ok\");\n }\n } else {\n if (poll.multi) {\n multiVoteBtn = el(\"button\", \"sl-poll-vote-btn\", t(\"pollVoteBtn\").replace(\"{n}\", \"0\"));\n multiVoteBtn.type = \"button\";\n multiVoteBtn.disabled = true;\n multiVoteBtn.addEventListener(\"click\", function() {\n submitMulti();\n });\n actionsEl.appendChild(multiVoteBtn);\n actionsEl.appendChild(el(\"p\", \"sl-poll-multi-hint\", t(\"pollMultiHint\")));\n }\n var resultsLink = el(\"button\", \"sl-poll-results-link\", t(\"viewResults\"));\n resultsLink.type = \"button\";\n resultsLink.addEventListener(\"click\", function() {\n revealed = true;\n renderPoll(currentPoll);\n });\n actionsEl.appendChild(resultsLink);\n setStatus2(\"\");\n }\n }\n function toggleSelect(o, btn) {\n if (selected[o]) {\n delete selected[o];\n btn.classList.remove(\"selected\");\n btn.setAttribute(\"aria-pressed\", \"false\");\n } else {\n selected[o] = true;\n btn.classList.add(\"selected\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n }\n var n = Object.keys(selected).length;\n if (multiVoteBtn) {\n multiVoteBtn.disabled = n === 0;\n multiVoteBtn.textContent = t(\"pollVoteBtn\").replace(\"{n}\", String(n));\n }\n }\n function submitMulti() {\n var opts2 = Object.keys(selected);\n if (!opts2.length) return;\n vote(opts2, multiVoteBtn, true);\n }\n function changeVotes() {\n setStatus2(t(\"solving\"), \"busy\");\n fetch(endpoint + \"/api/polls/revoke\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ pollId, voterToken })\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n selected = {};\n setStatus2(t(\"pollVotesChanged\"), \"ok\");\n renderPoll(outcome.data.poll);\n } else {\n setStatus2(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }\n }).catch(function(err) {\n setStatus2(\"Error: \" + err.message, \"err\");\n });\n }\n function findIn(data) {\n var found = null;\n (Array.isArray(data.polls) ? data.polls : []).forEach(function(p) {\n if (p.id === pollId) found = p;\n });\n return found;\n }\n function loadPoll() {\n var suffix = voterToken ? \"&voterToken=\" + encodeURIComponent(voterToken) : \"\";\n return fetch(endpoint + \"/api/polls?article_path=\" + encodeURIComponent(articlePath) + suffix).then(function(res) {\n if (!res.ok) throw new Error(t(\"pollLoadError\"));\n return res.json();\n }).then(function(data) {\n var found = findIn(data);\n if (found) {\n renderPoll(found);\n return;\n }\n return fetch(endpoint + \"/api/polls?id=\" + encodeURIComponent(pollId) + suffix).then(function(res2) {\n if (!res2.ok) throw new Error(t(\"pollLoadError\"));\n return res2.json();\n }).then(function(data2) {\n var g = findIn(data2);\n if (g) {\n renderPoll(g);\n return;\n }\n qEl.textContent = t(\"pollMissing\");\n });\n }).catch(function() {\n setStatus2(t(\"pollLoadError\"), \"err\");\n });\n }\n function vote(options, btn, multi) {\n if (btn) {\n btn.disabled = true;\n btn.classList.add(\"sl-pop\");\n }\n setStatus2(t(\"solving\"), \"busy\");\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/polls/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n var solveArg = multi ? options : options[0];\n return solveWithWorker(challenge, \"poll\", pollId, solveArg).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n var payload = {\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n pollId,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n };\n if (multi) payload.options = options;\n else payload.option = options[0];\n if (voterToken) payload.voterToken = voterToken;\n return fetch(endpoint + \"/api/polls/vote\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(payload)\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, status: res.status, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n if (outcome.data.voterToken) {\n voterToken = outcome.data.voterToken;\n try {\n localStorage.setItem(voterKey, voterToken);\n } catch (e) {\n }\n }\n selected = {};\n var updatedPoll = outcome.data.poll;\n if (updatedPoll) updatedPoll.voted = !!outcome.data.voted;\n renderPoll(updatedPoll);\n } else if (outcome.status === 409) {\n setStatus2(t(\"alreadyVoted\"), \"err\");\n loadPoll();\n } else {\n setStatus2(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }\n }).catch(function(err) {\n setStatus2(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n if (btn) btn.disabled = false;\n });\n }\n loadPoll();\n }\n }\n function unmount(root) {\n if (!root) return;\n root.__slMounted = false;\n root.classList.remove(\"sl-root\");\n root.replaceChildren();\n }\n function initAll() {\n injectStyles();\n var roots = document.querySelectorAll(\"[data-staticlayer]\");\n for (var i = 0; i < roots.length; i += 1) mount(roots[i]);\n if (typeof window !== \"undefined\") {\n window.StaticLayer = { mount, unmount };\n }\n }\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", initAll);\n } else {\n initAll();\n }\n })();\n})();\n"; +export const WIDGET_JS: string = "(() => {\n // src/widget.js\n (function() {\n \"use strict\";\n var WORKER_FILE = \"pow-worker.js\";\n var workerUrl = WORKER_FILE;\n var scriptSrc = typeof document !== \"undefined\" && document.currentScript && document.currentScript.src;\n if (scriptSrc) {\n try {\n workerUrl = new URL(WORKER_FILE, scriptSrc).href;\n } catch (e) {\n workerUrl = WORKER_FILE;\n }\n }\n var AVATAR_GRADIENTS = [\n \"linear-gradient(135deg,#ff9a5a,#f05a1c)\",\n \"linear-gradient(135deg,#a78bfa,#7c3aed)\",\n \"linear-gradient(135deg,#5eead4,#0d9488)\",\n \"linear-gradient(135deg,#7dd3fc,#0284c7)\",\n \"linear-gradient(135deg,#f9a8d4,#db2777)\",\n \"linear-gradient(135deg,#bef264,#65a30d)\",\n \"linear-gradient(135deg,#fda4af,#e11d48)\",\n \"linear-gradient(135deg,#fcd34d,#d97706)\"\n ];\n function hashString(str) {\n var h = 0;\n for (var i = 0; i < str.length; i += 1) {\n h = h * 31 + str.charCodeAt(i) | 0;\n }\n return Math.abs(h);\n }\n function el(tag, className, text) {\n var node = document.createElement(tag);\n if (className) node.className = className;\n if (text !== void 0) node.textContent = text;\n return node;\n }\n var I18N = {\n en: {\n title: \"Comments\",\n nickPlaceholder: \"Name (optional)\",\n bodyPlaceholder: \"Write a comment\\u2026\",\n post: \"Post comment\",\n hint: \"Plain text \\xB7 emoji welcome \\xB7 anti-spam proof-of-work\",\n empty: \"No comments yet \\u2014 be the first. \\u{1F4AC}\",\n anonymous: \"Anonymous\",\n emptyComment: \"Comment cannot be empty.\",\n solving: \"Solving proof-of-work\\u2026\",\n pending: \"Comment submitted \\u2014 awaiting moderation.\",\n posted: \"Comment posted.\",\n reactAria: \"React with \",\n reactionRecorded: \"\\u2713 Reaction recorded\",\n slowDown: \"Slow down \\u2014 too many reactions.\",\n challengeUsed: \"Challenge already used \\u2014 try again.\",\n countOne: \"1 comment\",\n countMany: \"{n} comments\",\n voted: \"\\u2713 You voted\",\n closed: \"This poll is closed\",\n pollMissing: \"Poll not found\",\n alreadyVoted: \"You already voted in this poll\",\n pollLoadError: \"Failed to load the poll\",\n pollVoteCountOne: \"1 vote\",\n pollVoteCountMany: \"{n} votes\",\n pollLeader: \"Leads by {n} votes\",\n pollVoteBtn: \"Vote ({n})\",\n pollMultiHint: \"Select one or more options\",\n pollChangeVotes: \"Change your votes\",\n pollVotesChanged: \"Votes updated \\u2014 you can vote again\",\n viewResults: \"View results\",\n backToVote: \"Vote\",\n resultsPreview: \"Live results\",\n startTitle: \"Start the conversation\",\n pinned: \"Pinned\",\n flag: \"Report\",\n flagBusy: \"Sending report\\u2026\",\n flagDone: \"Thanks \\u2014 report sent\",\n flagErr: \"Report failed\",\n sortNewest: \"Newest\",\n sortOldest: \"Oldest\",\n sortBest: \"Best\",\n timeNow: \"now\",\n timeMinAgo: \"{n} min ago\",\n timeHourAgo: \"{n} h ago\",\n timeDayAgo: \"{n} d ago\",\n timeWeekAgo: \"{n} w ago\",\n timeMonthAgo: \"{n} mo ago\",\n timeYearAgo: \"{n} y ago\",\n readMore: \"Read more\",\n showLess: \"Show less\",\n like: \"Like\",\n liked: \"Liked\",\n reply: \"Reply\",\n cancelReply: \"Cancel\",\n replyPlaceholder: \"Write a reply\\u2026\",\n showMoreReplies: \"Show more replies ({n})\",\n parentRemoved: \"Parent comment removed\",\n author: \"Author\"\n },\n it: {\n title: \"Commenti\",\n nickPlaceholder: \"Nome (facoltativo)\",\n bodyPlaceholder: \"Scrivi un commento\\u2026\",\n post: \"Pubblica commento\",\n hint: \"Testo semplice \\xB7 emoji benvenute \\xB7 anti-spam proof-of-work\",\n empty: \"Nessun commento ancora \\u2014 sii il primo. \\u{1F4AC}\",\n anonymous: \"Anonimo\",\n emptyComment: \"Il commento non pu\\xF2 essere vuoto.\",\n solving: \"Risoluzione proof-of-work\\u2026\",\n pending: \"Commento inviato \\u2014 in attesa di moderazione.\",\n posted: \"Commento pubblicato.\",\n reactAria: \"Reagisci con \",\n reactionRecorded: \"\\u2713 Reazione registrata\",\n slowDown: \"Rallenta \\u2014 troppe reazioni.\",\n challengeUsed: \"Challenge gi\\xE0 usata \\u2014 riprova.\",\n countOne: \"1 commento\",\n countMany: \"{n} commenti\",\n voted: \"\\u2713 Hai votato\",\n closed: \"Sondaggio chiuso\",\n pollMissing: \"Sondaggio non trovato\",\n alreadyVoted: \"Hai gi\\xE0 votato in questo sondaggio\",\n pollLoadError: \"Impossibile caricare il sondaggio\",\n pollVoteCountOne: \"1 voto\",\n pollVoteCountMany: \"{n} voti\",\n pollLeader: \"Conduce con {n} voti\",\n pollVoteBtn: \"Vota ({n})\",\n pollMultiHint: \"Seleziona una o pi\\xF9 opzioni\",\n pollChangeVotes: \"Modifica i tuoi voti\",\n pollVotesChanged: \"Voti aggiornati \\u2014 puoi votare di nuovo\",\n viewResults: \"Vedi risultati\",\n backToVote: \"Vota\",\n resultsPreview: \"Risultati in tempo reale\",\n startTitle: \"Inizia la conversazione\",\n pinned: \"In evidenza\",\n flag: \"Segnala\",\n flagBusy: \"Invio segnalazione\\u2026\",\n flagDone: \"Grazie \\u2014 segnalazione inviata\",\n flagErr: \"Segnalazione non riuscita\",\n sortNewest: \"Pi\\xF9 recenti\",\n sortOldest: \"Pi\\xF9 vecchi\",\n sortBest: \"Migliori\",\n timeNow: \"ora\",\n timeMinAgo: \"{n} min fa\",\n timeHourAgo: \"{n} h fa\",\n timeDayAgo: \"{n} g fa\",\n timeWeekAgo: \"{n} sett fa\",\n timeMonthAgo: \"{n} mesi fa\",\n timeYearAgo: \"{n} anni fa\",\n readMore: \"Leggi tutto\",\n showLess: \"Mostra meno\",\n like: \"Mi piace\",\n liked: \"Ti piace\",\n reply: \"Rispondi\",\n cancelReply: \"Annulla\",\n replyPlaceholder: \"Scrivi una risposta\\u2026\",\n showMoreReplies: \"Mostra altre risposte ({n})\",\n parentRemoved: \"Commento padre rimosso\",\n author: \"Autore\"\n },\n de: {\n title: \"Kommentare\",\n nickPlaceholder: \"Name (optional)\",\n bodyPlaceholder: \"Kommentar schreiben \\u2026\",\n post: \"Kommentar senden\",\n hint: \"Nur Text \\xB7 Emojis willkommen \\xB7 Spamschutz per Proof-of-Work\",\n empty: \"Noch keine Kommentare \\u2013 schreiben Sie den ersten. \\u{1F4AC}\",\n anonymous: \"Anonym\",\n emptyComment: \"Der Kommentar darf nicht leer sein.\",\n solving: \"Proof-of-Work wird berechnet \\u2026\",\n pending: \"Kommentar gesendet \\u2013 wartet auf Freigabe.\",\n posted: \"Kommentar ver\\xF6ffentlicht.\",\n reactAria: \"Reagieren mit \",\n reactionRecorded: \"\\u2713 Reaktion gespeichert\",\n slowDown: \"Bitte etwas langsamer \\u2013 zu viele Reaktionen.\",\n challengeUsed: \"Challenge bereits verwendet \\u2013 bitte erneut versuchen.\",\n countOne: \"1 Kommentar\",\n countMany: \"{n} Kommentare\",\n voted: \"\\u2713 Abgestimmt\",\n closed: \"Diese Umfrage ist beendet\",\n pollMissing: \"Umfrage nicht gefunden\",\n alreadyVoted: \"Sie haben bei dieser Umfrage bereits abgestimmt\",\n pollLoadError: \"Umfrage konnte nicht geladen werden\",\n pollVoteCountOne: \"1 Stimme\",\n pollVoteCountMany: \"{n} Stimmen\",\n pollLeader: \"F\\xFChrt mit {n} Stimmen\",\n pollVoteBtn: \"Abstimmen ({n})\",\n pollMultiHint: \"Eine oder mehrere Optionen ausw\\xE4hlen\",\n pollChangeVotes: \"Stimmen \\xE4ndern\",\n pollVotesChanged: \"Stimmen zur\\xFCckgesetzt \\u2013 Sie k\\xF6nnen erneut abstimmen\",\n viewResults: \"Ergebnisse ansehen\",\n backToVote: \"Abstimmen\",\n resultsPreview: \"Aktuelle Ergebnisse\",\n startTitle: \"Starten Sie die Diskussion\",\n pinned: \"Angeheftet\",\n flag: \"Melden\",\n flagBusy: \"Meldung wird gesendet \\u2026\",\n flagDone: \"Danke \\u2013 Meldung gesendet\",\n flagErr: \"Meldung fehlgeschlagen\",\n sortNewest: \"Neueste\",\n sortOldest: \"\\xC4lteste\",\n sortBest: \"Beste\",\n timeNow: \"gerade eben\",\n timeMinAgo: \"vor {n} Min.\",\n timeHourAgo: \"vor {n} Std.\",\n timeDayAgo: \"vor {n} T.\",\n timeWeekAgo: \"vor {n} Wo.\",\n timeMonthAgo: \"vor {n} Mon.\",\n timeYearAgo: \"vor {n} J.\",\n readMore: \"Weiterlesen\",\n showLess: \"Weniger anzeigen\",\n like: \"Gef\\xE4llt mir\",\n liked: \"Gef\\xE4llt mir\",\n reply: \"Antworten\",\n cancelReply: \"Abbrechen\",\n replyPlaceholder: \"Antwort schreiben \\u2026\",\n showMoreReplies: \"Weitere Antworten anzeigen ({n})\",\n parentRemoved: \"Urspr\\xFCnglicher Kommentar entfernt\",\n author: \"Autor\"\n }\n };\n var SUPPORTED_LANGS = [\"en\", \"it\", \"de\"];\n var STYLES = '.sl-root{--accent:#f57d1f;--accent-2:#e85d0a;--bg:#ffffff;--card:#ffffff;--border:#e8e8e6;--text:#1a1a1a;--muted:#6f6f6f;--radius:14px;--shadow:0 1px 2px rgba(16,24,40,.04),0 6px 20px -6px rgba(16,24,40,.08);font-family:-apple-system,BlinkMacSystemFont,\"Segoe UI\",Inter,Roboto,\"Helvetica Neue\",Arial,sans-serif;color:var(--text);max-width:640px;margin:0 auto;line-height:1.55;-webkit-font-smoothing:antialiased}.sl-root.sl-theme-dark{--bg:#161616;--card:#1f1f1f;--border:#2b2b2b;--text:#f4f4f4;--muted:#9d9d9d;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px -12px rgba(0,0,0,.5)}@media(prefers-color-scheme:dark){.sl-root:not(.sl-theme-light){--bg:#161616;--card:#1f1f1f;--border:#2b2b2b;--text:#f4f4f4;--muted:#9d9d9d;--shadow:0 1px 2px rgba(0,0,0,.3),0 10px 30px -12px rgba(0,0,0,.5)}}.sl-root *{box-sizing:border-box}.sl-heading{display:flex;align-items:center;justify-content:space-between;margin:0 0 18px}.sl-heading h3{margin:0;font-size:15px;font-weight:650;letter-spacing:-.1px}.sl-count{font-size:11px;font-weight:600;color:var(--muted);background:color-mix(in srgb,var(--border) 60%,transparent);padding:3px 10px;border-radius:999px}.sl-list{list-style:none;margin:0 0 20px;padding:0;display:flex;flex-direction:column;gap:14px}.sl-comment{display:flex;flex-direction:column;gap:14px;padding:16px 18px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);transition:box-shadow .18s ease,transform .18s ease,border-color .18s ease}.sl-comment .sl-top{display:flex;gap:12px;min-width:0}.sl-comment:hover{box-shadow:0 2px 4px rgba(16,24,40,.05),0 12px 32px -10px rgba(16,24,40,.12);transform:translateY(-1px);border-color:color-mix(in srgb,var(--accent) 25%,var(--border))}.sl-avatar{flex:none;width:36px;height:36px;border-radius:10px;display:flex;align-items:center;justify-content:center;color:#fff;font-weight:650;font-size:14px;user-select:none;box-shadow:inset 0 -2px 6px rgba(0,0,0,.14)}.sl-main{min-width:0;flex:1}.sl-head{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;margin-bottom:3px}.sl-nick{font-weight:600;font-size:13.5px;letter-spacing:-.1px}.sl-time{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}.sl-body{margin:0;font-size:14.5px;white-space:pre-wrap;overflow-wrap:break-word;word-break:break-word}.sl-empty{padding:28px 18px;text-align:center;color:var(--muted);font-size:14px;background:var(--card);border:1px dashed var(--border);border-radius:var(--radius)}.sl-hidden{display:none!important}.sl-start{background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow);padding:20px 16px 16px;margin-bottom:20px;text-align:center}.sl-start .sl-form{margin:0;box-shadow:none;text-align:left}.sl-start-msg{margin:0 0 14px;font-size:15px;font-weight:650;letter-spacing:-.2px}.sl-start-sub{display:block;margin-top:3px;font-weight:400;font-size:13px;color:var(--muted)}.sl-start:not(.sl-start-empty) .sl-start-msg{display:none}.sl-form{display:flex;flex-direction:column;gap:10px;padding:16px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius);box-shadow:var(--shadow)}.sl-nick-input,.sl-body-input{width:100%;padding:11px 14px;border:1px solid var(--border);border-radius:10px;background:var(--bg);color:var(--text);font:inherit;font-size:14px;outline:none;transition:border-color .15s ease,box-shadow .15s ease,background .15s ease}.sl-nick-input:focus,.sl-body-input:focus{border-color:var(--accent);background:var(--bg);box-shadow:0 0 0 3px color-mix(in srgb,var(--accent) 14%,transparent)}.sl-nick-input::placeholder,.sl-body-input::placeholder{color:var(--muted)}.sl-body-input{resize:vertical;min-height:80px;line-height:1.5}.sl-form-row{display:flex;align-items:center;gap:12px;justify-content:space-between;margin-top:2px}.sl-hint{font-size:11.5px;color:var(--muted)}.sl-submit{appearance:none;border:0;cursor:pointer;padding:10px 18px;border-radius:999px;font:inherit;font-weight:600;font-size:13.5px;color:#fff;background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 6px 16px -6px color-mix(in srgb,var(--accent) 60%,transparent);transition:transform .12s ease,filter .12s ease,opacity .12s ease}.sl-submit:hover{transform:translateY(-1px);filter:brightness(1.05)}.sl-submit:disabled{opacity:.55;cursor:progress;transform:none;filter:none}.sl-status{display:flex;align-items:center;gap:8px;min-height:20px;margin:12px 2px 0;font-size:13px;color:var(--muted)}.sl-status[data-kind=\"ok\"]{color:#16a34a}.sl-status[data-kind=\"err\"]{color:#dc2626}.sl-status .sl-spinner{width:14px;height:14px;border:2px solid color-mix(in srgb,var(--muted) 30%,transparent);border-top-color:var(--accent);border-radius:50%;animation:sl-spin .7s linear infinite}.sl-reactions{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px}.sl-reaction{appearance:none;display:inline-flex;align-items:center;gap:7px;cursor:pointer;font:inherit;font-size:14px;padding:7px 13px;border-radius:999px;border:1px solid var(--border);background:var(--card);color:var(--text);transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}.sl-reaction:hover{transform:translateY(-1px);border-color:var(--accent);box-shadow:0 4px 14px -6px color-mix(in srgb,var(--accent) 45%,transparent)}.sl-reaction:disabled{opacity:.6;cursor:progress;transform:none}.sl-reaction .sl-reaction-count{font-size:11.5px;font-weight:700;color:var(--muted);font-variant-numeric:tabular-nums}.sl-reaction-status{min-height:16px;margin:0 2px 12px;font-size:12px;color:var(--muted)}.sl-reaction-status[data-kind=\"ok\"]{color:#16a34a}.sl-reaction-status[data-kind=\"err\"]{color:#dc2626}.sl-poll-heading{display:flex;align-items:center;justify-content:space-between;margin:0 0 14px}.sl-poll-heading h3{margin:0;font-size:15px;font-weight:650;letter-spacing:-.1px;line-height:1.4}.sl-poll-options{list-style:none;margin:0 0 12px;padding:0;display:flex;flex-direction:column;gap:10px}.sl-poll-btn{appearance:none;width:100%;text-align:left;cursor:pointer;font:inherit;font-size:14px;padding:12px 16px;border-radius:12px;border:1px solid var(--border);background:var(--card);color:var(--text);transition:transform .12s ease,border-color .12s ease,box-shadow .12s ease}.sl-poll-btn:hover{transform:translateY(-1px);border-color:var(--accent);box-shadow:0 4px 14px -6px color-mix(in srgb,var(--accent) 45%,transparent)}.sl-poll-btn:disabled{opacity:.55;cursor:progress;transform:none}.sl-poll-opt-label{display:flex;align-items:baseline;justify-content:space-between;gap:10px;margin-bottom:6px}.sl-poll-opt-name{font-size:14px;font-weight:600}.sl-poll-opt-meta{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums}.sl-poll-bar{height:8px;border-radius:999px;background:color-mix(in srgb,var(--border) 60%,transparent);overflow:hidden}.sl-poll-fill{height:100%;border-radius:999px;background:linear-gradient(135deg,var(--accent),var(--accent-2));transition:width .5s ease}.sl-poll-status{display:flex;align-items:center;gap:8px;min-height:20px;margin:10px 2px 0;font-size:13px;color:var(--muted)}.sl-poll-status[data-kind=\"ok\"]{color:#16a34a}.sl-poll-status[data-kind=\"err\"]{color:#dc2626}.sl-poll-count{font-size:11px;font-weight:600;color:var(--muted);background:color-mix(in srgb,var(--border) 60%,transparent);padding:3px 10px;border-radius:999px;white-space:nowrap;font-variant-numeric:tabular-nums}.sl-poll-rank{flex:none;font-size:10.5px;font-weight:700;color:var(--muted);background:color-mix(in srgb,var(--border) 55%,transparent);border-radius:999px;padding:1px 7px;letter-spacing:.2px;font-variant-numeric:tabular-nums}.sl-poll-opt-label{display:flex;align-items:baseline;gap:8px;margin-bottom:6px}.sl-poll-opt-name{font-size:14px;font-weight:600;flex:1;min-width:0}.sl-poll-opt-meta{font-size:11.5px;color:var(--muted);font-variant-numeric:tabular-nums;flex:none}.sl-poll-bar{height:8px;border-radius:999px;background:color-mix(in srgb,var(--border) 60%,transparent);overflow:hidden}.sl-poll-fill{height:100%;border-radius:999px;background:linear-gradient(135deg,var(--accent),var(--accent-2));transition:width .6s cubic-bezier(.22,1,.36,1)}.sl-poll-option.sl-poll-leader .sl-poll-fill{background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 0 14px -2px color-mix(in srgb,var(--accent) 65%,transparent)}.sl-poll-option.sl-poll-leader .sl-poll-opt-name{color:var(--accent)}.sl-poll-lead-info{margin:2px 0 0;font-size:12px;font-weight:600;color:var(--accent);animation:sl-pop .3s ease}.sl-poll-check{display:flex;align-items:center;gap:10px}.sl-poll-check::before{content:\"\";flex:none;width:16px;height:16px;border-radius:5px;border:1.5px solid var(--border);background:var(--bg);transition:background .12s ease,border-color .12s ease}.sl-poll-check.selected{border-color:var(--accent);background:color-mix(in srgb,var(--accent) 8%,var(--card))}.sl-poll-check.selected::before{background:var(--accent);border-color:var(--accent);box-shadow:inset 0 0 0 3px var(--card)}.sl-poll-vote-btn{appearance:none;border:0;cursor:pointer;padding:9px 18px;border-radius:999px;font:inherit;font-weight:600;font-size:13px;color:#fff;background:linear-gradient(135deg,var(--accent),var(--accent-2));box-shadow:0 6px 16px -6px color-mix(in srgb,var(--accent) 60%,transparent);transition:transform .12s ease,filter .12s ease,opacity .12s ease;margin-top:2px}.sl-poll-vote-btn:hover{transform:translateY(-1px);filter:brightness(1.05)}.sl-poll-vote-btn:disabled{opacity:.55;cursor:not-allowed;transform:none;filter:none}.sl-poll-multi-hint{margin:10px 2px 0;font-size:11.5px;color:var(--muted)}.sl-poll-results-link{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:999px;padding:5px 14px;margin-top:12px;transition:color .12s ease,border-color .12s ease}.sl-poll-results-link:hover{color:var(--accent);border-color:var(--accent)}.sl-poll-actions{display:flex;flex-direction:column;align-items:flex-start;gap:2px}.sl-pop{animation:sl-pop .35s cubic-bezier(.22,1,.36,1)}@keyframes sl-pop{0%{transform:scale(.94)}60%{transform:scale(1.03)}100%{transform:scale(1)}}.sl-replies{list-style:none;margin:0;padding:4px 0 0 14px;display:flex;flex-direction:column;gap:10px;border-left:2px solid color-mix(in srgb,var(--border) 65%,transparent)}.sl-reply{box-shadow:none;padding:12px 14px;background:color-mix(in srgb,var(--card) 55%,transparent)}.sl-reply:hover{box-shadow:none;transform:none}.sl-reply .sl-avatar{width:28px;height:28px;font-size:12px;border-radius:8px}.sl-reply .sl-nick{font-size:12.5px}.sl-owner-badge{display:inline-block;margin-left:7px;font-size:10px;font-weight:700;letter-spacing:.3px;text-transform:uppercase;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:1px 7px;border-radius:999px;vertical-align:1px}.sl-reply-btn{appearance:none;border:0;background:transparent;color:var(--muted);font:inherit;font-size:12px;font-weight:600;cursor:pointer;padding:0;margin-top:6px;transition:color .15s}.sl-reply-btn:hover{color:var(--accent)}.sl-reply-form{margin-top:12px}.sl-parent-removed{color:var(--muted);font-style:italic}.sl-show-more{list-style:none}.sl-sort{appearance:none;font:inherit;font-size:11.5px;color:var(--muted);background:var(--card);border:1px solid var(--border);border-radius:999px;padding:3px 9px;cursor:pointer;margin-left:8px;transition:border-color .15s}.sl-sort:hover,.sl-sort:focus-visible{border-color:var(--accent);outline:none}.sl-pin-badge{display:inline-block;margin-left:7px;font-size:10px;font-weight:700;letter-spacing:.3px;color:var(--accent);background:color-mix(in srgb,var(--accent) 12%,transparent);border:1px solid color-mix(in srgb,var(--accent) 30%,transparent);padding:1px 7px;border-radius:999px;vertical-align:1px}.sl-pinned{border-color:color-mix(in srgb,var(--accent) 35%,var(--border))}.sl-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap;margin-top:10px}.sl-vote-btn{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--muted);background:transparent;border:1px solid var(--border);border-radius:999px;padding:3px 10px;transition:color .12s ease,border-color .12s ease,transform .12s ease}.sl-vote-btn:hover{color:var(--accent);border-color:var(--accent);transform:translateY(-1px)}.sl-vote-btn.liked{color:var(--accent);border-color:var(--accent);background:color-mix(in srgb,var(--accent) 10%,transparent)}.sl-vote-btn:disabled{opacity:.6;cursor:progress;transform:none}.sl-flag-btn{appearance:none;cursor:pointer;font:inherit;font-size:11.5px;color:var(--muted);background:transparent;border:0;padding:3px 6px;border-radius:8px}.sl-flag-btn:hover{color:#dc2626;background:color-mix(in srgb,#dc2626 8%,transparent)}.sl-flag-btn:disabled{opacity:.6;cursor:progress}.sl-flag-btn.done{color:#16a34a}.sl-more-btn{appearance:none;cursor:pointer;font:inherit;font-size:12px;font-weight:600;color:var(--accent);background:none;border:0;padding:0 0 0 4px}.sl-skeleton-card{pointer-events:none}.sl-sk{height:12px;border-radius:6px;background:linear-gradient(90deg,color-mix(in srgb,var(--border) 55%,transparent),color-mix(in srgb,var(--border) 22%,transparent),color-mix(in srgb,var(--border) 55%,transparent));background-size:200% 100%;animation:sl-shimmer 1.3s infinite linear}.sl-sk-nick{width:30%;height:10px;margin-bottom:10px}.sl-sk-line{width:100%;margin-bottom:8px}.sl-sk-line.short{width:70%}.sl-sk-bar{height:8px;border-radius:999px}@keyframes sl-shimmer{from{background-position:200% 0}to{background-position:-200% 0}}@keyframes sl-spin{to{transform:rotate(360deg)}}.sl-root :focus-visible{outline:2px solid var(--accent);outline-offset:2px;border-radius:4px}@media (prefers-reduced-motion: reduce){.sl-root *{transition:none!important;animation:none!important}.sl-poll-fill{transition:none!important}}';\n function injectStyles() {\n if (document.getElementById(\"sl-styles\")) return;\n var style = document.createElement(\"style\");\n style.id = \"sl-styles\";\n style.textContent = STYLES;\n (document.head || document.documentElement).appendChild(style);\n }\n function mount(root, opts) {\n if (!root || root.__slMounted) return;\n var endpoint = (opts && opts.endpoint || root.getAttribute(\"data-endpoint\") || root.getAttribute(\"data-api\") || \"\").replace(/\\/+$/, \"\");\n var articlePath = opts && opts.articlePath || root.getAttribute(\"data-article-path\") || root.getAttribute(\"data-article-id\") || (typeof location !== \"undefined\" ? location.pathname : \"\");\n var hostContext = opts && opts.hostContext || root.getAttribute(\"data-host-context\") || (typeof location !== \"undefined\" ? location.hostname : \"\");\n if (!endpoint) return;\n root.__slMounted = true;\n root.classList.add(\"sl-root\");\n var lang = opts && opts.lang || root.getAttribute(\"data-lang\") || \"auto\";\n if (SUPPORTED_LANGS.indexOf(lang) === -1) {\n var navLang = typeof navigator !== \"undefined\" && navigator.language ? navigator.language.toLowerCase().split(\"-\")[0] : \"\";\n lang = SUPPORTED_LANGS.indexOf(navLang) !== -1 ? navLang : \"en\";\n }\n var dict = I18N[lang] || I18N.en;\n var customTexts = null;\n if (opts && opts.texts) {\n customTexts = opts.texts;\n } else {\n var rawTexts = root.getAttribute(\"data-text\");\n if (rawTexts) {\n try {\n customTexts = JSON.parse(rawTexts);\n } catch (e) {\n customTexts = null;\n }\n }\n }\n function t(key) {\n if (customTexts && Object.prototype.hasOwnProperty.call(customTexts, key) && typeof customTexts[key] === \"string\" && customTexts[key].length) return customTexts[key];\n if (dict && Object.prototype.hasOwnProperty.call(dict, key)) return dict[key];\n return key;\n }\n function countLabel(n) {\n return String(t(n === 1 ? \"countOne\" : \"countMany\")).replace(\"{n}\", String(n));\n }\n function pollCountLabel(n) {\n return String(t(n === 1 ? \"pollVoteCountOne\" : \"pollVoteCountMany\")).replace(\"{n}\", String(n));\n }\n function relativeTime(ts) {\n var diff = Math.floor(Date.now() / 1e3) - ts;\n if (diff < 60) return t(\"timeNow\");\n if (diff < 3600) return t(\"timeMinAgo\").replace(\"{n}\", String(Math.floor(diff / 60)));\n if (diff < 86400) return t(\"timeHourAgo\").replace(\"{n}\", String(Math.floor(diff / 3600)));\n if (diff < 604800) return t(\"timeDayAgo\").replace(\"{n}\", String(Math.floor(diff / 86400)));\n if (diff < 2592e3) return t(\"timeWeekAgo\").replace(\"{n}\", String(Math.floor(diff / 604800)));\n if (diff < 31536e3) return t(\"timeMonthAgo\").replace(\"{n}\", String(Math.floor(diff / 2592e3)));\n if (diff < 31536e4) return t(\"timeYearAgo\").replace(\"{n}\", String(Math.floor(diff / 31536e3)));\n return new Date(ts * 1e3).toLocaleDateString();\n }\n var theme = String(opts && opts.theme || root.getAttribute(\"data-theme\") || \"\").trim();\n if (theme === \"dark\") root.classList.add(\"sl-theme-dark\");\n else if (theme === \"light\") root.classList.add(\"sl-theme-light\");\n var accent = String(opts && opts.accent || root.getAttribute(\"data-accent\") || \"\").trim();\n if (accent) root.style.setProperty(\"--accent\", accent);\n var accent2 = String(opts && opts.accent2 || root.getAttribute(\"data-accent-2\") || \"\").trim();\n if (accent2) root.style.setProperty(\"--accent-2\", accent2);\n var radius = String(opts && opts.radius || root.getAttribute(\"data-radius\") || \"\").trim();\n if (radius) root.style.setProperty(\"--radius\", radius.replace(/[^0-9.]/g, \"\") + \"px\");\n var maxWidth = String(opts && opts.maxWidth || root.getAttribute(\"data-max-width\") || \"\").trim();\n if (maxWidth) root.style.setProperty(\"max-width\", maxWidth.replace(/[^0-9.]/g, \"\") + \"px\");\n var pollId = String(opts && opts.pollId || root.getAttribute(\"data-poll-id\") || \"\").trim();\n var pollStyle = String(opts && opts.pollStyle || root.getAttribute(\"data-poll-style\") || \"bars\").trim();\n var pollResults = String(opts && opts.pollResults || root.getAttribute(\"data-poll-results\") || \"after\").trim();\n var timeGateMs = 3e3;\n var rawGate = opts && opts.timeGateMs != null ? String(opts.timeGateMs) : root.getAttribute(\"data-time-gate-ms\");\n if (rawGate != null && /^\\d+$/.test(rawGate)) timeGateMs = parseInt(rawGate, 10);\n var reactionsPosition = String(opts && opts.reactionsPosition || root.getAttribute(\"data-reactions-position\") || \"bottom\").trim();\n if (reactionsPosition !== \"top\" && reactionsPosition !== \"bottom\") reactionsPosition = \"bottom\";\n var commentsSort = String(opts && opts.commentsSort || root.getAttribute(\"data-comments-sort\") || \"newest\").trim();\n if (commentsSort !== \"newest\" && commentsSort !== \"oldest\" && commentsSort !== \"best\") commentsSort = \"newest\";\n if (pollId) {\n initPoll();\n return;\n }\n var reactionsOnly = !!(opts && opts.reactionsOnly) || root.getAttribute(\"data-reactions-only\") !== null;\n var reactions = null;\n if (opts && Array.isArray(opts.reactions)) {\n reactions = opts.reactions.filter(function(s) {\n return typeof s === \"string\" && s.length;\n });\n } else {\n var rawAttr = root.getAttribute(\"data-reactions\");\n if (rawAttr) {\n reactions = rawAttr.split(\",\").map(function(s) {\n return s.trim();\n }).filter(Boolean);\n }\n }\n var heading = null, count = null, list = null, nickInput = null, bodyInput = null;\n var submitBtn = null, status = null, form = null;\n var reactBar = null, reactStatus = null;\n var reactBtns = {}, reactBusy = false;\n var sortSelect = null;\n var loadedOnce = false;\n var commentVoterKey = \"sl-comment-voter-token\";\n var commentVoterToken = null;\n try {\n commentVoterToken = localStorage.getItem(commentVoterKey);\n } catch (e) {\n commentVoterToken = null;\n }\n function buildReactions() {\n if (!reactBar || !reactions) return;\n reactions.forEach(function(r) {\n var btn = el(\"button\", \"sl-reaction\");\n btn.type = \"button\";\n btn.setAttribute(\"aria-label\", t(\"reactAria\") + r);\n var c = el(\"span\", \"sl-reaction-count\", \"0\");\n btn.append(el(\"span\", \"sl-reaction-emoji\", r), c);\n reactBtns[r] = { btn, count: c };\n btn.addEventListener(\"click\", function() {\n submitReaction(r);\n });\n reactBar.appendChild(btn);\n });\n }\n if (reactionsOnly) {\n reactStatus = el(\"p\", \"sl-reaction-status\");\n reactBar = el(\"div\", \"sl-reactions\");\n buildReactions();\n root.append(reactBar, reactStatus);\n } else {\n heading = el(\"div\", \"sl-heading\");\n heading.appendChild(el(\"h3\", null, t(\"title\")));\n count = el(\"span\", \"sl-count\", \"\\u2026\");\n heading.appendChild(count);\n sortSelect = el(\"select\", \"sl-sort\");\n sortSelect.setAttribute(\"aria-label\", t(\"title\") + \" \\u2014 sort\");\n [[\"newest\", t(\"sortNewest\")], [\"oldest\", t(\"sortOldest\")], [\"best\", t(\"sortBest\")]].forEach(function(opt) {\n var o = document.createElement(\"option\");\n o.value = opt[0];\n o.textContent = opt[1];\n sortSelect.appendChild(o);\n });\n sortSelect.value = commentsSort;\n sortSelect.style.display = \"none\";\n sortSelect.addEventListener(\"change\", function() {\n commentsSort = sortSelect.value;\n loadComments();\n });\n heading.appendChild(sortSelect);\n list = el(\"ul\", \"sl-list\");\n nickInput = el(\"input\");\n nickInput.type = \"text\";\n nickInput.maxLength = 50;\n nickInput.placeholder = t(\"nickPlaceholder\");\n nickInput.className = \"sl-nick-input\";\n bodyInput = el(\"textarea\");\n bodyInput.maxLength = 3e3;\n bodyInput.rows = 3;\n bodyInput.placeholder = t(\"bodyPlaceholder\");\n bodyInput.className = \"sl-body-input\";\n submitBtn = el(\"button\", \"sl-submit\", t(\"post\"));\n submitBtn.type = \"submit\";\n var hint = el(\"span\", \"sl-hint\", t(\"hint\"));\n status = el(\"p\", \"sl-status\");\n form = el(\"form\", \"sl-form\");\n var row = el(\"div\", \"sl-form-row\");\n row.append(hint, submitBtn);\n var hpInput = makeHoneypot(\"website\");\n form.append(nickInput, bodyInput, hpInput, row);\n var startCard = el(\"div\", \"sl-start sl-start-empty\");\n var startMsg = el(\"p\", \"sl-start-msg\", t(\"startTitle\"));\n startMsg.appendChild(el(\"span\", \"sl-start-sub\", t(\"empty\")));\n startCard.append(startMsg, form, status);\n root.append(heading);\n if (reactions && reactions.length) {\n reactStatus = el(\"p\", \"sl-reaction-status\");\n reactBar = el(\"div\", \"sl-reactions\");\n buildReactions();\n if (reactionsPosition === \"top\") root.append(reactBar, reactStatus);\n }\n root.append(list, startCard);\n if (reactions && reactions.length && reactionsPosition !== \"top\") {\n root.append(reactBar, reactStatus);\n }\n }\n function setStatus(message, kind) {\n status.replaceChildren();\n status.removeAttribute(\"data-kind\");\n if (kind === \"ok\") {\n status.setAttribute(\"data-kind\", \"ok\");\n status.appendChild(el(\"span\", null, \"\\u2713\"));\n }\n if (kind === \"err\") {\n status.setAttribute(\"data-kind\", \"err\");\n status.appendChild(el(\"span\", null, \"\\u2715\"));\n }\n if (kind === \"busy\") {\n status.appendChild(el(\"span\", \"sl-spinner\"));\n }\n status.appendChild(document.createTextNode(message));\n }\n var MAX_DEPTH = 3;\n var REPLIES_PER_PAGE = 5;\n function buildTree(comments) {\n var byId = {};\n var roots = [];\n comments.forEach(function(c) {\n c.children = [];\n c.parentMissing = false;\n byId[c.id] = c;\n });\n comments.forEach(function(c) {\n if (c.parent_id && byId[c.parent_id]) {\n byId[c.parent_id].children.push(c);\n } else {\n if (c.parent_id) c.parentMissing = true;\n roots.push(c);\n }\n });\n return roots;\n }\n var READ_MORE_CHARS = 400;\n function skeletonItem() {\n var li = el(\"li\", \"sl-comment sl-skeleton-card\");\n var nick = el(\"div\", \"sl-sk sl-sk-nick\");\n var line1 = el(\"div\", \"sl-sk sl-sk-line\");\n var line2 = el(\"div\", \"sl-sk sl-sk-line short\");\n li.append(nick, line1, line2);\n return li;\n }\n function makeBody(text) {\n var p = el(\"p\", \"sl-body\");\n if (text.length <= READ_MORE_CHARS) {\n p.textContent = text;\n return p;\n }\n var short = text.slice(0, READ_MORE_CHARS).replace(/\\s+\\S*$/, \"\") + \"\\u2026\";\n var shown = el(\"span\", null, short);\n var full = el(\"span\", null, text);\n full.style.display = \"none\";\n var btn = el(\"button\", \"sl-more-btn\", t(\"readMore\"));\n btn.type = \"button\";\n btn.addEventListener(\"click\", function() {\n var isCollapsed = full.style.display === \"none\";\n shown.style.display = isCollapsed ? \"none\" : \"inline\";\n full.style.display = isCollapsed ? \"inline\" : \"none\";\n btn.textContent = isCollapsed ? t(\"showLess\") : t(\"readMore\");\n });\n p.append(shown, full, btn);\n return p;\n }\n function renderComment(c, depth) {\n var li = el(\"li\", \"sl-comment\" + (depth > 0 ? \" sl-reply\" : \"\") + (c.pinned && depth === 0 ? \" sl-pinned\" : \"\"));\n var nick = c.nickname && c.nickname.trim() || t(\"anonymous\");\n var avatar = el(\"div\", \"sl-avatar\", nick.charAt(0).toUpperCase());\n avatar.style.background = AVATAR_GRADIENTS[hashString(nick) % AVATAR_GRADIENTS.length];\n var main = el(\"div\", \"sl-main\");\n var head = el(\"div\", \"sl-head\");\n var nickWrap = el(\"span\", \"sl-nick\", nick);\n if (c.is_owner) nickWrap.appendChild(el(\"span\", \"sl-owner-badge\", t(\"author\")));\n if (c.pinned && depth === 0) nickWrap.appendChild(el(\"span\", \"sl-pin-badge\", \"\\u{1F4CC} \" + t(\"pinned\")));\n head.append(nickWrap, el(\"span\", \"sl-time\", relativeTime(c.created_at)));\n var bodyEl = c.parentMissing ? el(\"p\", \"sl-body sl-parent-removed\", t(\"parentRemoved\")) : makeBody(c.body);\n main.append(head, bodyEl);\n var actions = el(\"div\", \"sl-actions\");\n var votes = Number(c.votes) || 0;\n var voteBtn = el(\"button\", \"sl-vote-btn\" + (c.voted ? \" liked\" : \"\"), (c.voted ? \"\\u2713 \" : \"\\u25B2 \") + votes);\n voteBtn.type = \"button\";\n voteBtn.setAttribute(\"aria-pressed\", c.voted ? \"true\" : \"false\");\n voteBtn.title = c.voted ? t(\"liked\") : t(\"like\");\n voteBtn.addEventListener(\"click\", function() {\n voteComment(c, voteBtn);\n });\n actions.appendChild(voteBtn);\n if (depth < MAX_DEPTH) {\n var replyBtn = el(\"button\", \"sl-reply-btn\", t(\"reply\"));\n replyBtn.type = \"button\";\n replyBtn.addEventListener(\"click\", function() {\n toggleReplyForm(c, main, replyBtn);\n });\n actions.appendChild(replyBtn);\n }\n var flagBtn = el(\"button\", \"sl-flag-btn\", t(\"flag\"));\n flagBtn.type = \"button\";\n flagBtn.addEventListener(\"click\", function() {\n flagComment(c, flagBtn);\n });\n actions.appendChild(flagBtn);\n main.appendChild(actions);\n var top = el(\"div\", \"sl-top\");\n top.append(avatar, main);\n li.append(top);\n if (c.children && c.children.length) {\n var ul = el(\"ul\", \"sl-replies\");\n var visible = c.children.slice(0, REPLIES_PER_PAGE);\n visible.forEach(function(ch) {\n ul.appendChild(renderComment(ch, depth + 1));\n });\n var hidden = c.children.slice(REPLIES_PER_PAGE);\n if (hidden.length) {\n var more = el(\"li\", \"sl-show-more\");\n var moreBtn = el(\"button\", \"sl-reply-btn\", t(\"showMoreReplies\").replace(\"{n}\", String(hidden.length)));\n moreBtn.type = \"button\";\n moreBtn.addEventListener(\"click\", function() {\n hidden.forEach(function(ch) {\n ul.insertBefore(renderComment(ch, depth + 1), more);\n });\n more.remove();\n });\n more.appendChild(moreBtn);\n ul.appendChild(more);\n }\n li.appendChild(ul);\n }\n return li;\n }\n function commentActionChallenge() {\n return fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n });\n }\n function voteComment(c, btn) {\n if (btn.disabled || c.voted) return;\n btn.disabled = true;\n commentActionChallenge().then(function(challenge) {\n return solveWithWorker(challenge, \"comment-action\", \"vote\", c.id).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n var payload = {\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n commentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n };\n if (commentVoterToken) payload.voterToken = commentVoterToken;\n return fetch(endpoint + \"/api/comments/vote\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(payload)\n });\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, status: res.status, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n if (outcome.data.voterToken) {\n commentVoterToken = outcome.data.voterToken;\n try {\n localStorage.setItem(commentVoterKey, commentVoterToken);\n } catch (e) {\n }\n }\n c.voted = true;\n c.votes = Number(outcome.data.votes) || Number(c.votes) + 1;\n btn.classList.add(\"liked\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n btn.textContent = \"\\u2713 \" + c.votes;\n btn.title = t(\"liked\");\n } else if (outcome.status === 409) {\n c.voted = true;\n btn.classList.add(\"liked\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n btn.textContent = \"\\u2713 \" + (Number(c.votes) || 0);\n }\n }).catch(function() {\n }).finally(function() {\n btn.disabled = false;\n });\n }\n function flagComment(c, btn) {\n if (btn.disabled) return;\n btn.disabled = true;\n btn.textContent = t(\"flagBusy\");\n commentActionChallenge().then(function(challenge) {\n return solveWithWorker(challenge, \"comment-action\", \"flag\", c.id).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return fetch(endpoint + \"/api/comments/flag\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n commentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n btn.textContent = t(\"flagDone\");\n btn.classList.add(\"done\");\n } else {\n btn.textContent = t(\"flag\");\n btn.disabled = false;\n }\n }).catch(function() {\n btn.textContent = t(\"flag\");\n btn.disabled = false;\n });\n }\n function toggleReplyForm(c, main, btn) {\n var existing = main.querySelector(\".sl-reply-form\");\n if (existing) {\n existing.remove();\n btn.textContent = t(\"reply\");\n return;\n }\n var form2 = el(\"form\", \"sl-form sl-reply-form\");\n var nickInput2 = el(\"input\");\n nickInput2.type = \"text\";\n nickInput2.maxLength = 50;\n nickInput2.placeholder = t(\"nickPlaceholder\");\n nickInput2.className = \"sl-nick-input\";\n var bodyInput2 = el(\"textarea\");\n bodyInput2.maxLength = 3e3;\n bodyInput2.rows = 2;\n bodyInput2.placeholder = t(\"replyPlaceholder\");\n bodyInput2.className = \"sl-body-input\";\n var row2 = el(\"div\", \"sl-form-row\");\n var submit = el(\"button\", \"sl-submit\", t(\"post\"));\n submit.type = \"submit\";\n var cancel = el(\"button\", \"sl-reply-btn\", t(\"cancelReply\"));\n cancel.type = \"button\";\n cancel.addEventListener(\"click\", function() {\n form2.remove();\n btn.textContent = t(\"reply\");\n });\n row2.append(cancel, submit);\n var hpInput2 = makeHoneypot(\"website\");\n form2.append(nickInput2, bodyInput2, hpInput2, row2);\n var statusEl = el(\"p\", \"sl-status\");\n form2.appendChild(statusEl);\n var busy = false;\n form2.addEventListener(\"submit\", function(event) {\n event.preventDefault();\n if (busy) return;\n var nickname = nickInput2.value.trim();\n var body = bodyInput2.value.trim();\n if (!body) {\n statusEl.textContent = t(\"emptyComment\");\n return;\n }\n busy = true;\n submit.disabled = true;\n statusEl.textContent = t(\"solving\");\n fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", nickname, body).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return fetch(endpoint + \"/api/comments\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n nickname,\n body,\n parentId: c.id,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n statusEl.textContent = outcome.data.comment && outcome.data.comment.status === \"pending\" ? t(\"pending\") : t(\"posted\");\n bodyInput2.value = \"\";\n return loadComments();\n }\n statusEl.textContent = \"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\");\n }).catch(function(err) {\n statusEl.textContent = \"Error: \" + err.message;\n }).finally(function() {\n busy = false;\n submit.disabled = false;\n });\n });\n main.appendChild(form2);\n btn.textContent = t(\"cancelReply\");\n bodyInput2.focus();\n }\n function loadComments() {\n if (!loadedOnce) {\n loadedOnce = true;\n list.classList.remove(\"sl-hidden\");\n startCard.classList.add(\"sl-hidden\");\n var frag = document.createDocumentFragment();\n for (var i = 0; i < 3; i += 1) frag.appendChild(skeletonItem());\n list.appendChild(frag);\n }\n var suffix = commentVoterToken ? \"&voterToken=\" + encodeURIComponent(commentVoterToken) : \"\";\n return fetch(\n endpoint + \"/api/comments?article_path=\" + encodeURIComponent(articlePath) + \"&host_context=\" + encodeURIComponent(hostContext) + suffix\n ).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load comments\");\n return res.json();\n }).then(function(data) {\n var comments = Array.isArray(data.comments) ? data.comments : [];\n count.textContent = countLabel(comments.length);\n list.replaceChildren();\n if (comments.length === 0) {\n list.classList.add(\"sl-hidden\");\n startCard.classList.remove(\"sl-hidden\");\n startCard.classList.add(\"sl-start-empty\");\n if (sortSelect) sortSelect.style.display = \"none\";\n return;\n }\n list.classList.remove(\"sl-hidden\");\n startCard.classList.remove(\"sl-hidden\");\n startCard.classList.remove(\"sl-start-empty\");\n if (sortSelect) sortSelect.style.display = comments.length > 1 ? \"\" : \"none\";\n var roots = buildTree(comments);\n var pinnedRoots = roots.filter(function(r) {\n return r.pinned;\n });\n var restRoots = roots.filter(function(r) {\n return !r.pinned;\n });\n var dir = commentsSort === \"oldest\" ? 1 : -1;\n restRoots.sort(function(a, b) {\n if (commentsSort === \"best\") {\n return (Number(b.votes) || 0) - (Number(a.votes) || 0) || a.created_at - b.created_at;\n }\n return (a.created_at - b.created_at) * dir;\n });\n pinnedRoots.concat(restRoots).forEach(function(r) {\n list.appendChild(renderComment(r, 0));\n });\n });\n }\n function createPowWorkerUrl() {\n return fetch(workerUrl, { mode: \"cors\", credentials: \"omit\" }).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load pow worker\");\n return res.blob();\n }).then(function(blob) {\n return URL.createObjectURL(blob);\n });\n }\n function makeHoneypot(name) {\n var hp = el(\"input\");\n hp.type = \"text\";\n hp.name = name || \"website\";\n hp.tabIndex = -1;\n hp.autocomplete = \"off\";\n hp.setAttribute(\"aria-hidden\", \"true\");\n hp.className = \"sl-hp\";\n hp.style.cssText = \"position:absolute!important;left:-9999px!important;width:1px!important;height:1px!important;opacity:0!important;overflow:hidden!important;\";\n return hp;\n }\n function waitForGate(startMs) {\n if (timeGateMs <= 0) return Promise.resolve();\n var wait = Math.max(0, timeGateMs + 500 - (Date.now() - startMs));\n if (wait <= 0) return Promise.resolve();\n return new Promise(function(resolveGate) {\n setTimeout(resolveGate, wait);\n });\n }\n function solveWithWorker(challenge, kind, a, b) {\n return new Promise(function(resolve, reject) {\n createPowWorkerUrl().then(function(url) {\n var worker;\n try {\n worker = new Worker(url);\n } catch (err) {\n reject(err);\n return null;\n }\n worker.onmessage = function(e) {\n URL.revokeObjectURL(url);\n worker.terminate();\n if (e.data && e.data.type === \"nonce\") resolve(e.data.nonce);\n else reject(new Error(e.data && e.data.message || \"pow worker failed\"));\n };\n worker.onerror = function() {\n URL.revokeObjectURL(url);\n worker.terminate();\n reject(new Error(\"pow worker error\"));\n };\n var msg = { challenge };\n if (kind === \"poll\") {\n msg.pollId = a;\n if (Array.isArray(b)) msg.options = b;\n else msg.option = b;\n } else if (kind === \"comment-action\") {\n msg.action = a;\n msg.commentId = b;\n } else {\n msg.nickname = a;\n msg.body = b;\n }\n worker.postMessage(msg);\n return null;\n }).catch(reject);\n });\n }\n function submitComment(event) {\n event.preventDefault();\n var nickname = nickInput.value.trim();\n var body = bodyInput.value.trim();\n if (!body) {\n setStatus(t(\"emptyComment\"), \"err\");\n return;\n }\n submitBtn.disabled = true;\n setStatus(t(\"solving\"), \"busy\");\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/comments/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", nickname, body).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n return fetch(endpoint + \"/api/comments\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n nickname,\n body,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce,\n honeypot: hpInput.value\n })\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n bodyInput.value = \"\";\n setStatus(\n outcome.data.comment && outcome.data.comment.status === \"pending\" ? t(\"pending\") : t(\"posted\"),\n \"ok\"\n );\n return loadComments();\n }\n setStatus(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }).catch(function(err) {\n setStatus(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n submitBtn.disabled = false;\n });\n }\n if (form) form.addEventListener(\"submit\", submitComment);\n if (!reactionsOnly) loadComments();\n loadReactions();\n function setReactStatus(message, kind) {\n if (!reactStatus) return;\n reactStatus.textContent = message;\n if (kind) reactStatus.setAttribute(\"data-kind\", kind);\n else reactStatus.removeAttribute(\"data-kind\");\n }\n function setReactBusy(busy) {\n reactBusy = busy;\n reactions.forEach(function(x) {\n if (reactBtns[x]) reactBtns[x].btn.disabled = busy;\n });\n }\n function renderReactionCounts(counts) {\n var map = {};\n counts.forEach(function(r) {\n map[r.reaction] = Number(r.count) || 0;\n });\n reactions.forEach(function(x) {\n if (reactBtns[x]) reactBtns[x].count.textContent = String(map[x] || 0);\n });\n }\n function loadReactions() {\n if (!reactBar) return;\n fetch(endpoint + \"/api/reactions?article_path=\" + encodeURIComponent(articlePath)).then(function(res) {\n if (!res.ok) throw new Error(\"failed to load reactions\");\n return res.json();\n }).then(function(data) {\n if (Array.isArray(data.reactions)) renderReactionCounts(data.reactions);\n }).catch(function() {\n });\n }\n function submitReaction(r) {\n if (reactBusy || !reactBtns[r]) return;\n setReactBusy(true);\n setReactStatus(t(\"solving\"));\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/reactions/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n return solveWithWorker(challenge, \"comment\", \"\", \"\").then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n return fetch(endpoint + \"/api/reactions\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n reaction: r,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n })\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, status: res.status, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n setReactStatus(t(\"reactionRecorded\"), \"ok\");\n if (Array.isArray(outcome.data.reactions)) renderReactionCounts(outcome.data.reactions);\n } else if (outcome.status === 429) {\n setReactStatus(t(\"slowDown\"), \"err\");\n } else if (outcome.status === 409) {\n setReactStatus(t(\"challengeUsed\"), \"err\");\n } else {\n setReactStatus(\n \"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"),\n \"err\"\n );\n }\n }).catch(function(err) {\n setReactStatus(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n setReactBusy(false);\n });\n }\n function initPoll() {\n var voterKey = \"sl-voter-token\";\n var voterToken = null;\n try {\n voterToken = localStorage.getItem(voterKey);\n } catch (e) {\n voterToken = null;\n }\n var heading2 = el(\"div\", \"sl-poll-heading\");\n var qEl = el(\"h3\", null, \"\\u2026\");\n var totalChip = el(\"span\", \"sl-poll-count\", \"\");\n heading2.append(qEl, totalChip);\n var statusEl = el(\"p\", \"sl-poll-status\");\n var listEl = el(\"ul\", \"sl-poll-options\");\n var actionsEl = el(\"div\", \"sl-poll-actions\");\n root.append(heading2, listEl, actionsEl, statusEl);\n renderPollSkeleton();\n var selected = {};\n var multiVoteBtn = null;\n var revealed = false;\n var currentPoll = null;\n function renderPollSkeleton() {\n listEl.replaceChildren();\n for (var i = 0; i < 3; i += 1) {\n var li = el(\"li\", \"sl-poll-option\");\n li.appendChild(el(\"div\", \"sl-sk sl-sk-bar\"));\n listEl.appendChild(li);\n }\n }\n function setStatus2(message, kind) {\n statusEl.replaceChildren();\n statusEl.removeAttribute(\"data-kind\");\n if (kind === \"ok\") {\n statusEl.setAttribute(\"data-kind\", \"ok\");\n statusEl.appendChild(el(\"span\", null, \"\\u2713\"));\n } else if (kind === \"err\") {\n statusEl.setAttribute(\"data-kind\", \"err\");\n statusEl.appendChild(el(\"span\", null, \"\\u2715\"));\n } else if (kind === \"busy\") {\n statusEl.appendChild(el(\"span\", \"sl-spinner\"));\n }\n statusEl.appendChild(document.createTextNode(message));\n }\n function animateValue(node, to, suffix) {\n var dur = 520, start = null;\n function step(ts) {\n if (!start) start = ts;\n var p = Math.min(1, (ts - start) / dur);\n var eased = p < 0.5 ? 2 * p * p : 1 - Math.pow(-2 * p + 2, 2) / 2;\n node.textContent = Math.round(to * eased) + (suffix || \"\");\n if (p < 1) requestAnimationFrame(step);\n }\n requestAnimationFrame(step);\n }\n function renderPoll(poll) {\n currentPoll = poll;\n qEl.textContent = poll.question || \"\";\n totalChip.textContent = pollCountLabel(Number(poll.total) || 0);\n var opts2 = Array.isArray(poll.options) ? poll.options : [];\n var total = Number(poll.total) || 0;\n var showResults = poll.status !== \"open\" || !!poll.voted || pollResults === \"always\" || revealed;\n listEl.replaceChildren();\n actionsEl.replaceChildren();\n multiVoteBtn = null;\n var rows = opts2.map(function(o) {\n return { option: o, count: poll.counts && poll.counts[o] ? Number(poll.counts[o]) : 0 };\n });\n if (showResults) {\n rows = rows.slice().sort(function(a, b) {\n return b.count - a.count || opts2.indexOf(a.option) - opts2.indexOf(b.option);\n });\n }\n var leader = showResults && rows.length > 0 && rows[0].count > (rows[1] ? rows[1].count : 0);\n rows.forEach(function(r, i) {\n var o = r.option, c = r.count;\n var pct = total > 0 ? Math.round(c / total * 100) : 0;\n var li = el(\"li\", \"sl-poll-option\" + (showResults && leader && i === 0 ? \" sl-poll-leader\" : \"\"));\n if (showResults) {\n if (pollStyle !== \"counts\") {\n var label = el(\"div\", \"sl-poll-opt-label\");\n var meta = el(\"span\", \"sl-poll-opt-meta\", \"\");\n if (pollStyle === \"percent\") animateValue(meta, pct, \"%\");\n else if (pollStyle === \"minimal\") meta.textContent = \"\";\n else animateValue(meta, c, \" \\xB7 \" + pct + \"%\");\n label.append(el(\"span\", \"sl-poll-rank\", \"#\" + (i + 1)), el(\"span\", \"sl-poll-opt-name\", o), meta);\n var bar = el(\"div\", \"sl-poll-bar\");\n var fill = el(\"div\", \"sl-poll-fill\");\n fill.style.width = \"0%\";\n fill.style.transitionDelay = i * 90 + \"ms\";\n bar.appendChild(fill);\n li.append(label, bar);\n requestAnimationFrame(function() {\n requestAnimationFrame(function() {\n fill.style.width = pct + \"%\";\n });\n });\n } else {\n var cLabel = el(\"div\", \"sl-poll-opt-label\");\n var cMeta = el(\"span\", \"sl-poll-opt-meta\", \"\");\n animateValue(cMeta, c, \"\");\n cLabel.append(el(\"span\", \"sl-poll-rank\", \"#\" + (i + 1)), el(\"span\", \"sl-poll-opt-name\", o), cMeta);\n li.appendChild(cLabel);\n }\n } else {\n var btn = el(\"button\", \"sl-poll-btn\" + (poll.multi ? \" sl-poll-check\" : \"\"), o);\n btn.type = \"button\";\n if (poll.multi) {\n btn.setAttribute(\"aria-pressed\", \"false\");\n btn.addEventListener(\"click\", function() {\n toggleSelect(o, btn);\n });\n } else {\n btn.addEventListener(\"click\", function() {\n vote([o], btn, false);\n });\n }\n li.appendChild(btn);\n }\n listEl.appendChild(li);\n });\n if (showResults) {\n if (leader) {\n var gap = rows[0].count - (rows[1] ? rows[1].count : 0);\n actionsEl.appendChild(el(\"p\", \"sl-poll-lead-info\", t(\"pollLeader\").replace(\"{n}\", String(gap))));\n }\n if (poll.multi && poll.singleVote && poll.status === \"open\" && poll.voted) {\n var changeBtn = el(\"button\", \"sl-poll-vote-btn\", t(\"pollChangeVotes\"));\n changeBtn.type = \"button\";\n changeBtn.addEventListener(\"click\", changeVotes);\n actionsEl.appendChild(changeBtn);\n }\n if (revealed && !poll.voted && poll.status === \"open\") {\n setStatus2(t(\"resultsPreview\"), void 0);\n var backBtn = el(\"button\", \"sl-poll-results-link\", t(\"backToVote\"));\n backBtn.type = \"button\";\n backBtn.addEventListener(\"click\", function() {\n revealed = false;\n renderPoll(currentPoll);\n });\n actionsEl.appendChild(backBtn);\n } else {\n setStatus2(poll.status !== \"open\" ? t(\"closed\") : t(\"voted\"), poll.status !== \"open\" ? void 0 : \"ok\");\n }\n } else {\n if (poll.multi) {\n multiVoteBtn = el(\"button\", \"sl-poll-vote-btn\", t(\"pollVoteBtn\").replace(\"{n}\", \"0\"));\n multiVoteBtn.type = \"button\";\n multiVoteBtn.disabled = true;\n multiVoteBtn.addEventListener(\"click\", function() {\n submitMulti();\n });\n actionsEl.appendChild(multiVoteBtn);\n actionsEl.appendChild(el(\"p\", \"sl-poll-multi-hint\", t(\"pollMultiHint\")));\n }\n var resultsLink = el(\"button\", \"sl-poll-results-link\", t(\"viewResults\"));\n resultsLink.type = \"button\";\n resultsLink.addEventListener(\"click\", function() {\n revealed = true;\n renderPoll(currentPoll);\n });\n actionsEl.appendChild(resultsLink);\n setStatus2(\"\");\n }\n }\n function toggleSelect(o, btn) {\n if (selected[o]) {\n delete selected[o];\n btn.classList.remove(\"selected\");\n btn.setAttribute(\"aria-pressed\", \"false\");\n } else {\n selected[o] = true;\n btn.classList.add(\"selected\");\n btn.setAttribute(\"aria-pressed\", \"true\");\n }\n var n = Object.keys(selected).length;\n if (multiVoteBtn) {\n multiVoteBtn.disabled = n === 0;\n multiVoteBtn.textContent = t(\"pollVoteBtn\").replace(\"{n}\", String(n));\n }\n }\n function submitMulti() {\n var opts2 = Object.keys(selected);\n if (!opts2.length) return;\n vote(opts2, multiVoteBtn, true);\n }\n function changeVotes() {\n setStatus2(t(\"solving\"), \"busy\");\n fetch(endpoint + \"/api/polls/revoke\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ pollId, voterToken })\n }).then(function(res) {\n return res.json().then(function(d) {\n return { ok: res.ok, data: d };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n selected = {};\n setStatus2(t(\"pollVotesChanged\"), \"ok\");\n renderPoll(outcome.data.poll);\n } else {\n setStatus2(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }\n }).catch(function(err) {\n setStatus2(\"Error: \" + err.message, \"err\");\n });\n }\n function findIn(data) {\n var found = null;\n (Array.isArray(data.polls) ? data.polls : []).forEach(function(p) {\n if (p.id === pollId) found = p;\n });\n return found;\n }\n function loadPoll() {\n var suffix = voterToken ? \"&voterToken=\" + encodeURIComponent(voterToken) : \"\";\n return fetch(endpoint + \"/api/polls?article_path=\" + encodeURIComponent(articlePath) + suffix).then(function(res) {\n if (!res.ok) throw new Error(t(\"pollLoadError\"));\n return res.json();\n }).then(function(data) {\n var found = findIn(data);\n if (found) {\n renderPoll(found);\n return;\n }\n return fetch(endpoint + \"/api/polls?id=\" + encodeURIComponent(pollId) + suffix).then(function(res2) {\n if (!res2.ok) throw new Error(t(\"pollLoadError\"));\n return res2.json();\n }).then(function(data2) {\n var g = findIn(data2);\n if (g) {\n renderPoll(g);\n return;\n }\n qEl.textContent = t(\"pollMissing\");\n });\n }).catch(function() {\n setStatus2(t(\"pollLoadError\"), \"err\");\n });\n }\n function vote(options, btn, multi) {\n if (btn) {\n btn.disabled = true;\n btn.classList.add(\"sl-pop\");\n }\n setStatus2(t(\"solving\"), \"busy\");\n var t0 = Date.now();\n fetch(\n endpoint + \"/api/polls/challenge?hostContext=\" + encodeURIComponent(hostContext) + \"&articlePath=\" + encodeURIComponent(articlePath)\n ).then(function(res) {\n if (!res.ok) throw new Error(\"challenge request failed\");\n return res.json();\n }).then(function(challenge) {\n var solveArg = multi ? options : options[0];\n return solveWithWorker(challenge, \"poll\", pollId, solveArg).then(function(nonce) {\n return { challenge, nonce };\n });\n }).then(function(solved) {\n return waitForGate(t0).then(function() {\n var payload = {\n challengeId: solved.challenge.challengeId,\n hostContext: solved.challenge.hostContext,\n articlePath: solved.challenge.articlePath,\n pollId,\n difficulty: solved.challenge.difficulty,\n expiresAt: solved.challenge.expiresAt,\n signature: solved.challenge.signature,\n nonce: solved.nonce\n };\n if (multi) payload.options = options;\n else payload.option = options[0];\n if (voterToken) payload.voterToken = voterToken;\n return fetch(endpoint + \"/api/polls/vote\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify(payload)\n });\n });\n }).then(function(res) {\n return res.json().then(function(data) {\n return { ok: res.ok, status: res.status, data };\n });\n }).then(function(outcome) {\n if (outcome.ok) {\n if (outcome.data.voterToken) {\n voterToken = outcome.data.voterToken;\n try {\n localStorage.setItem(voterKey, voterToken);\n } catch (e) {\n }\n }\n selected = {};\n var updatedPoll = outcome.data.poll;\n if (updatedPoll) updatedPoll.voted = !!outcome.data.voted;\n renderPoll(updatedPoll);\n } else if (outcome.status === 409) {\n setStatus2(t(\"alreadyVoted\"), \"err\");\n loadPoll();\n } else {\n setStatus2(\"Error: \" + (outcome.data && outcome.data.error ? outcome.data.error : \"unknown\"), \"err\");\n }\n }).catch(function(err) {\n setStatus2(\"Error: \" + err.message, \"err\");\n }).finally(function() {\n if (btn) btn.disabled = false;\n });\n }\n loadPoll();\n }\n }\n function unmount(root) {\n if (!root) return;\n root.__slMounted = false;\n root.classList.remove(\"sl-root\");\n root.replaceChildren();\n }\n function initAll() {\n injectStyles();\n var roots = document.querySelectorAll(\"[data-staticlayer]\");\n for (var i = 0; i < roots.length; i += 1) mount(roots[i]);\n if (typeof window !== \"undefined\") {\n window.StaticLayer = { mount, unmount };\n }\n }\n if (document.readyState === \"loading\") {\n document.addEventListener(\"DOMContentLoaded\", initAll);\n } else {\n initAll();\n }\n })();\n})();\n"; export const POW_WORKER_JS: string = "(() => {\n // ../protocol/dist/constants.js\n var PROTOCOL_VERSION = 1;\n var CHALLENGE_ID_BYTES = 32;\n var NONCE_BYTES = 8;\n var UINT64_MAX = (1n << 64n) - 1n;\n var MAX_HOST_CONTEXT_BYTES = 255;\n var MAX_ARTICLE_PATH_BYTES = 255;\n var MAX_NICKNAME_BYTES = 50;\n var MAX_BODY_BYTES = 3e3;\n var MAX_POLL_ID_BYTES = 64;\n var MAX_OPTION_BYTES = 100;\n var MAX_POLL_OPTIONS = 10;\n var MAX_COMMENT_ID_BYTES = 64;\n var MIN_DIFFICULTY = 0;\n var MAX_DIFFICULTY = 256;\n\n // ../protocol/dist/errors.js\n var ProtocolError = class extends Error {\n constructor(message) {\n super(message);\n this.name = \"ProtocolError\";\n }\n };\n\n // ../protocol/dist/utf8.js\n var encoder = new TextEncoder();\n var decoder = new TextDecoder(\"utf-8\", { fatal: true });\n function hasUnpairedSurrogate(s) {\n for (let i = 0; i < s.length; i++) {\n const code = s.charCodeAt(i);\n if (code >= 55296 && code <= 56319) {\n const next = s.charCodeAt(i + 1);\n if (!(next >= 56320 && next <= 57343))\n return true;\n i++;\n } else if (code >= 56320 && code <= 57343) {\n return true;\n }\n }\n return false;\n }\n function utf8EncodeStrict(s) {\n if (hasUnpairedSurrogate(s)) {\n throw new ProtocolError(\"Invalid UTF-8: input contains an unpaired surrogate\");\n }\n return encoder.encode(s);\n }\n\n // ../protocol/dist/encoding.js\n function encodeCanonicalPayload(p) {\n if (p.version !== PROTOCOL_VERSION) {\n throw new ProtocolError(`Unsupported protocol version: ${p.version}`);\n }\n const host = utf8EncodeStrict(p.hostContext);\n const path = utf8EncodeStrict(p.articlePath);\n const nick = utf8EncodeStrict(p.nickname);\n const body = utf8EncodeStrict(p.body);\n assertByteLen(\"hostContext\", host.length, MAX_HOST_CONTEXT_BYTES);\n assertByteLen(\"articlePath\", path.length, MAX_ARTICLE_PATH_BYTES);\n assertByteLen(\"nickname\", nick.length, MAX_NICKNAME_BYTES);\n assertByteLen(\"body\", body.length, MAX_BODY_BYTES);\n if (p.challengeId.length !== CHALLENGE_ID_BYTES) {\n throw new ProtocolError(`challengeId must be exactly ${CHALLENGE_ID_BYTES} bytes, got ${p.challengeId.length}`);\n }\n if (p.nonce < 0n || p.nonce > UINT64_MAX) {\n throw new ProtocolError(\"nonce out of uint64 range\");\n }\n const total = 1 + 2 + host.length + 2 + path.length + 2 + nick.length + 4 + body.length + CHALLENGE_ID_BYTES + NONCE_BYTES;\n const out = new Uint8Array(total);\n const view = new DataView(out.buffer);\n let o = 0;\n view.setUint8(o, p.version);\n o += 1;\n view.setUint16(o, host.length, false);\n o += 2;\n out.set(host, o);\n o += host.length;\n view.setUint16(o, path.length, false);\n o += 2;\n out.set(path, o);\n o += path.length;\n view.setUint16(o, nick.length, false);\n o += 2;\n out.set(nick, o);\n o += nick.length;\n view.setUint32(o, body.length, false);\n o += 4;\n out.set(body, o);\n o += body.length;\n out.set(p.challengeId, o);\n o += CHALLENGE_ID_BYTES;\n view.setBigUint64(o, p.nonce, false);\n o += NONCE_BYTES;\n if (o !== total) {\n throw new ProtocolError(`internal encoding error: wrote ${o} of ${total} bytes`);\n }\n return out;\n }\n function encodeCanonicalPollPayload(p) {\n if (p.version !== PROTOCOL_VERSION) {\n throw new ProtocolError(`Unsupported protocol version: ${p.version}`);\n }\n const host = utf8EncodeStrict(p.hostContext);\n const path = utf8EncodeStrict(p.articlePath);\n const pollId = utf8EncodeStrict(p.pollId);\n const option = utf8EncodeStrict(p.option);\n assertByteLen(\"hostContext\", host.length, MAX_HOST_CONTEXT_BYTES);\n assertByteLen(\"articlePath\", path.length, MAX_ARTICLE_PATH_BYTES);\n assertByteLen(\"pollId\", pollId.length, MAX_POLL_ID_BYTES);\n assertByteLen(\"option\", option.length, MAX_OPTION_BYTES);\n if (p.challengeId.length !== CHALLENGE_ID_BYTES) {\n throw new ProtocolError(`challengeId must be exactly ${CHALLENGE_ID_BYTES} bytes, got ${p.challengeId.length}`);\n }\n if (p.nonce < 0n || p.nonce > UINT64_MAX) {\n throw new ProtocolError(\"nonce out of uint64 range\");\n }\n const total = 1 + 2 + host.length + 2 + path.length + 2 + pollId.length + 2 + option.length + CHALLENGE_ID_BYTES + NONCE_BYTES;\n const out = new Uint8Array(total);\n const view = new DataView(out.buffer);\n let o = 0;\n view.setUint8(o, p.version);\n o += 1;\n view.setUint16(o, host.length, false);\n o += 2;\n out.set(host, o);\n o += host.length;\n view.setUint16(o, path.length, false);\n o += 2;\n out.set(path, o);\n o += path.length;\n view.setUint16(o, pollId.length, false);\n o += 2;\n out.set(pollId, o);\n o += pollId.length;\n view.setUint16(o, option.length, false);\n o += 2;\n out.set(option, o);\n o += option.length;\n out.set(p.challengeId, o);\n o += CHALLENGE_ID_BYTES;\n view.setBigUint64(o, p.nonce, false);\n o += NONCE_BYTES;\n if (o !== total) {\n throw new ProtocolError(`internal poll encoding error: wrote ${o} of ${total} bytes`);\n }\n return out;\n }\n function sortPollOptions(options) {\n return [...options].sort((a, b) => a < b ? -1 : a > b ? 1 : 0);\n }\n var ACTION_BYTE = { flag: 1, vote: 2 };\n function encodeCanonicalCommentActionPayload(p) {\n if (p.version !== PROTOCOL_VERSION) {\n throw new ProtocolError(`Unsupported protocol version: ${p.version}`);\n }\n const host = utf8EncodeStrict(p.hostContext);\n const path = utf8EncodeStrict(p.articlePath);\n const commentId = utf8EncodeStrict(p.commentId);\n const actionByte = ACTION_BYTE[p.action];\n assertByteLen(\"hostContext\", host.length, MAX_HOST_CONTEXT_BYTES);\n assertByteLen(\"articlePath\", path.length, MAX_ARTICLE_PATH_BYTES);\n assertByteLen(\"commentId\", commentId.length, MAX_COMMENT_ID_BYTES);\n if (p.challengeId.length !== CHALLENGE_ID_BYTES) {\n throw new ProtocolError(`challengeId must be exactly ${CHALLENGE_ID_BYTES} bytes, got ${p.challengeId.length}`);\n }\n if (p.nonce < 0n || p.nonce > UINT64_MAX) {\n throw new ProtocolError(\"nonce out of uint64 range\");\n }\n const total = 1 + 1 + 2 + host.length + 2 + path.length + 2 + commentId.length + CHALLENGE_ID_BYTES + NONCE_BYTES;\n const out = new Uint8Array(total);\n const view = new DataView(out.buffer);\n let o = 0;\n view.setUint8(o, p.version);\n o += 1;\n view.setUint8(o, actionByte);\n o += 1;\n view.setUint16(o, host.length, false);\n o += 2;\n out.set(host, o);\n o += host.length;\n view.setUint16(o, path.length, false);\n o += 2;\n out.set(path, o);\n o += path.length;\n view.setUint16(o, commentId.length, false);\n o += 2;\n out.set(commentId, o);\n o += commentId.length;\n out.set(p.challengeId, o);\n o += CHALLENGE_ID_BYTES;\n view.setBigUint64(o, p.nonce, false);\n o += NONCE_BYTES;\n if (o !== total) {\n throw new ProtocolError(`internal comment-action encoding error: wrote ${o} of ${total} bytes`);\n }\n return out;\n }\n function encodeCanonicalPollPayloadMulti(p) {\n if (p.version !== PROTOCOL_VERSION) {\n throw new ProtocolError(`Unsupported protocol version: ${p.version}`);\n }\n const host = utf8EncodeStrict(p.hostContext);\n const path = utf8EncodeStrict(p.articlePath);\n const pollId = utf8EncodeStrict(p.pollId);\n if (!Array.isArray(p.options) || p.options.length < 1 || p.options.length > MAX_POLL_OPTIONS) {\n throw new ProtocolError(`options must be 1..${MAX_POLL_OPTIONS} strings`);\n }\n const sorted = sortPollOptions(p.options);\n const opts = sorted.map((o2) => utf8EncodeStrict(o2));\n assertByteLen(\"hostContext\", host.length, MAX_HOST_CONTEXT_BYTES);\n assertByteLen(\"articlePath\", path.length, MAX_ARTICLE_PATH_BYTES);\n assertByteLen(\"pollId\", pollId.length, MAX_POLL_ID_BYTES);\n for (const o2 of opts)\n assertByteLen(\"option\", o2.length, MAX_OPTION_BYTES);\n if (p.challengeId.length !== CHALLENGE_ID_BYTES) {\n throw new ProtocolError(`challengeId must be exactly ${CHALLENGE_ID_BYTES} bytes, got ${p.challengeId.length}`);\n }\n if (p.nonce < 0n || p.nonce > UINT64_MAX) {\n throw new ProtocolError(\"nonce out of uint64 range\");\n }\n const total = 1 + 2 + host.length + 2 + path.length + 2 + pollId.length + 1 + opts.reduce((n, o2) => n + 2 + o2.length, 0) + CHALLENGE_ID_BYTES + NONCE_BYTES;\n const out = new Uint8Array(total);\n const view = new DataView(out.buffer);\n let o = 0;\n view.setUint8(o, p.version);\n o += 1;\n view.setUint16(o, host.length, false);\n o += 2;\n out.set(host, o);\n o += host.length;\n view.setUint16(o, path.length, false);\n o += 2;\n out.set(path, o);\n o += path.length;\n view.setUint16(o, pollId.length, false);\n o += 2;\n out.set(pollId, o);\n o += pollId.length;\n view.setUint8(o, opts.length);\n o += 1;\n for (const opt of opts) {\n view.setUint16(o, opt.length, false);\n o += 2;\n out.set(opt, o);\n o += opt.length;\n }\n out.set(p.challengeId, o);\n o += CHALLENGE_ID_BYTES;\n view.setBigUint64(o, p.nonce, false);\n o += NONCE_BYTES;\n if (o !== total) {\n throw new ProtocolError(`internal poll-multi encoding error: wrote ${o} of ${total} bytes`);\n }\n return out;\n }\n function serializeNonce(nonce) {\n if (nonce < 0n || nonce > UINT64_MAX) {\n throw new ProtocolError(\"nonce out of uint64 range\");\n }\n if (nonce <= BigInt(Number.MAX_SAFE_INTEGER)) {\n return Number(nonce);\n }\n return nonce.toString(10);\n }\n function assertByteLen(field, actual, max) {\n if (actual > max) {\n throw new ProtocolError(`${field} exceeds ${max} UTF-8 bytes (got ${actual})`);\n }\n }\n\n // ../protocol/dist/crypto.js\n var subtle = globalThis.crypto.subtle;\n async function sha256(data) {\n const digest = await subtle.digest(\"SHA-256\", data);\n return new Uint8Array(digest);\n }\n\n // ../protocol/dist/base64url.js\n var ALPHABET = \"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_\";\n var LOOKUP = buildLookup();\n function buildLookup() {\n const table = new Int16Array(128).fill(-1);\n for (let i = 0; i < ALPHABET.length; i++) {\n table[ALPHABET.charCodeAt(i)] = i;\n }\n return table;\n }\n function base64UrlToBytes(input) {\n if (input.length === 0)\n return new Uint8Array(0);\n if (input.includes(\"=\")) {\n throw new ProtocolError(\"base64url input must not contain padding\");\n }\n if (input.length % 4 === 1) {\n throw new ProtocolError(\"base64url input has invalid length\");\n }\n const out = [];\n let buffer = 0;\n let bits = 0;\n for (let i = 0; i < input.length; i++) {\n const code = input.charCodeAt(i);\n const value = code < 128 ? LOOKUP[code] : -1;\n if (value < 0) {\n throw new ProtocolError(`invalid base64url character at index ${i}`);\n }\n buffer = buffer << 6 | value;\n bits += 6;\n if (bits >= 8) {\n bits -= 8;\n out.push(buffer >> bits & 255);\n }\n }\n return new Uint8Array(out);\n }\n\n // ../protocol/dist/pow.js\n function leadingZeroBits(hash) {\n let bits = 0;\n for (const byte of hash) {\n if (byte === 0) {\n bits += 8;\n continue;\n }\n let b = byte;\n while ((b & 128) === 0) {\n bits += 1;\n b = b << 1 & 255;\n }\n break;\n }\n return bits;\n }\n function assertDifficulty(difficulty) {\n if (!Number.isInteger(difficulty) || difficulty < MIN_DIFFICULTY || difficulty > MAX_DIFFICULTY) {\n throw new ProtocolError(`difficulty must be an integer in [${MIN_DIFFICULTY}, ${MAX_DIFFICULTY}]`);\n }\n }\n async function verifyPow(canonicalPayload, difficulty) {\n assertDifficulty(difficulty);\n const digest = await sha256(canonicalPayload);\n return leadingZeroBits(digest) >= difficulty;\n }\n async function verifyPowFields(payload, difficulty) {\n return verifyPow(encodeCanonicalPayload(payload), difficulty);\n }\n async function mineNonce(base, difficulty, options = {}) {\n assertDifficulty(difficulty);\n const start = options.startNonce ?? 0n;\n const maxAttempts = options.maxAttempts ?? 1n << 32n;\n if (start < 0n || start > UINT64_MAX) {\n throw new ProtocolError(\"startNonce out of uint64 range\");\n }\n let nonce = start;\n for (let i = 0n; i < maxAttempts; i++) {\n if (nonce > UINT64_MAX)\n break;\n const payload = encodeCanonicalPayload({ ...base, nonce });\n const digest = await sha256(payload);\n if (leadingZeroBits(digest) >= difficulty) {\n return nonce;\n }\n nonce += 1n;\n }\n throw new ProtocolError(`mineNonce: no nonce satisfying difficulty ${difficulty} within ${maxAttempts} attempts`);\n }\n async function minePollNonce(base, difficulty, options = {}) {\n assertDifficulty(difficulty);\n const start = options.startNonce ?? 0n;\n const maxAttempts = options.maxAttempts ?? 1n << 32n;\n if (start < 0n || start > UINT64_MAX) {\n throw new ProtocolError(\"startNonce out of uint64 range\");\n }\n let nonce = start;\n for (let i = 0n; i < maxAttempts; i++) {\n if (nonce > UINT64_MAX)\n break;\n const payload = encodeCanonicalPollPayload({ ...base, nonce });\n const digest = await sha256(payload);\n if (leadingZeroBits(digest) >= difficulty) {\n return nonce;\n }\n nonce += 1n;\n }\n throw new ProtocolError(`minePollNonce: no nonce satisfying difficulty ${difficulty} within ${maxAttempts} attempts`);\n }\n async function minePollNonceMulti(base, difficulty, options = {}) {\n assertDifficulty(difficulty);\n const start = options.startNonce ?? 0n;\n const maxAttempts = options.maxAttempts ?? 1n << 32n;\n if (start < 0n || start > UINT64_MAX) {\n throw new ProtocolError(\"startNonce out of uint64 range\");\n }\n let nonce = start;\n for (let i = 0n; i < maxAttempts; i++) {\n if (nonce > UINT64_MAX)\n break;\n const payload = encodeCanonicalPollPayloadMulti({ ...base, nonce });\n const digest = await sha256(payload);\n if (leadingZeroBits(digest) >= difficulty) {\n return nonce;\n }\n nonce += 1n;\n }\n throw new ProtocolError(`minePollNonceMulti: no nonce satisfying difficulty ${difficulty} within ${maxAttempts} attempts`);\n }\n async function mineCommentActionNonce(base, difficulty, options = {}) {\n assertDifficulty(difficulty);\n const start = options.startNonce ?? 0n;\n const maxAttempts = options.maxAttempts ?? 1n << 32n;\n if (start < 0n || start > UINT64_MAX) {\n throw new ProtocolError(\"startNonce out of uint64 range\");\n }\n let nonce = start;\n for (let i = 0n; i < maxAttempts; i++) {\n if (nonce > UINT64_MAX)\n break;\n const payload = encodeCanonicalCommentActionPayload({ ...base, nonce });\n const digest = await sha256(payload);\n if (leadingZeroBits(digest) >= difficulty) {\n return nonce;\n }\n nonce += 1n;\n }\n throw new ProtocolError(`mineCommentActionNonce: no nonce satisfying difficulty ${difficulty} within ${maxAttempts} attempts`);\n }\n\n // src/pow-worker.js\n self.onmessage = async function(event) {\n var msg = event.data || {};\n var challenge = msg.challenge;\n try {\n if (!challenge || typeof challenge.challengeId !== \"string\") {\n throw new Error(\"malformed solve request\");\n }\n var nonce;\n if (typeof msg.pollId === \"string\" && Array.isArray(msg.options)) {\n var mbase = {\n version: PROTOCOL_VERSION,\n hostContext: challenge.hostContext,\n articlePath: challenge.articlePath,\n pollId: msg.pollId,\n options: sortPollOptions(msg.options),\n challengeId: base64UrlToBytes(challenge.challengeId)\n };\n nonce = await minePollNonceMulti(mbase, challenge.difficulty);\n if (!await verifyPow(encodeCanonicalPollPayloadMulti({ ...mbase, nonce }), challenge.difficulty)) {\n throw new Error(\"internal: mined nonce failed verification\");\n }\n } else if (typeof msg.pollId === \"string\" && typeof msg.option === \"string\") {\n var pbase = {\n version: PROTOCOL_VERSION,\n hostContext: challenge.hostContext,\n articlePath: challenge.articlePath,\n pollId: msg.pollId,\n option: msg.option,\n challengeId: base64UrlToBytes(challenge.challengeId)\n };\n nonce = await minePollNonce(pbase, challenge.difficulty);\n if (!await verifyPow(encodeCanonicalPollPayload({ ...pbase, nonce }), challenge.difficulty)) {\n throw new Error(\"internal: mined nonce failed verification\");\n }\n } else if (typeof msg.action === \"string\" && typeof msg.commentId === \"string\") {\n var cbase = {\n version: PROTOCOL_VERSION,\n hostContext: challenge.hostContext,\n articlePath: challenge.articlePath,\n action: msg.action === \"vote\" ? \"vote\" : \"flag\",\n commentId: msg.commentId,\n challengeId: base64UrlToBytes(challenge.challengeId)\n };\n nonce = await mineCommentActionNonce(cbase, challenge.difficulty);\n if (!await verifyPow(encodeCanonicalCommentActionPayload({ ...cbase, nonce }), challenge.difficulty)) {\n throw new Error(\"internal: mined nonce failed verification\");\n }\n } else {\n var nickname = msg.nickname;\n var body = msg.body;\n if (typeof nickname !== \"string\" || typeof body !== \"string\") {\n throw new Error(\"malformed solve request\");\n }\n var base = {\n version: PROTOCOL_VERSION,\n hostContext: challenge.hostContext,\n articlePath: challenge.articlePath,\n nickname,\n body,\n challengeId: base64UrlToBytes(challenge.challengeId)\n };\n nonce = await mineNonce(base, challenge.difficulty);\n if (!await verifyPowFields({ ...base, nonce }, challenge.difficulty)) {\n throw new Error(\"internal: mined nonce failed verification\");\n }\n }\n self.postMessage({ type: \"nonce\", nonce: serializeNonce(nonce) });\n } catch (err) {\n self.postMessage({ type: \"error\", message: err instanceof Error ? err.message : String(err) });\n }\n };\n})();\n"; -export const ADMIN_HTML: string = "\n\n
\n\n\nModeration console
Free, no credit card, no Zero Trust plan. Create a GitHub OAuth App, set 4 Worker vars once, and from then on this button signs you in without a password.
\n……GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ADMIN_IDS (your GitHub numeric id) on the Worker and re-deploy.Full step-by-step guide: sign in with your password once → Settings → Admin access & sign-in.
\nComments awaiting approval — search, filter and moderate.
\n \n \nAlready public — unapprove or delete.
\n \n \nEvery page that received activity on this Worker — click to filter the queue.
\nBlocked nicknames cannot comment. Allowlisted nicknames skip the queue (and in allowlist mode only they can comment). Matched case-insensitively.
\nAuto-approved — skip moderation
\nBanned — cannot comment (ban a nickname)
\nComments containing any of these words are auto-rejected and never stored
\nLive settings — applied immediately, no redeploy needed. Env-var defaults are used until you override them here.
\nHigher = harder for bots (and slightly slower for visitors). Range 0–256 · default 16.
\nComma-separated. Empty disables reactions.
\nPrivate/community sites can lock comments to approved members.
\nShown as the author of your admin replies, with an \"Author\" badge on the widget.
\nPassword-less login with GitHub OAuth is built in and free — no Cloudflare Zero Trust plan, no credit card, and no password to remember after the first setup. Create a GitHub OAuth App once, set 4 Worker vars, and the login screen shows \"Sign in with GitHub\". Your admin password stays as a fallback and is never sent to GitHub.
\nLost the admin password? Once GitHub sign-in is configured, just click \"Sign in with GitHub\". Otherwise rotate it: npx wrangler secret put ADMIN_SECRET (you choose the new value) — or reinstall fresh. Re-running the installer preserves your existing secrets (including the current password) by design.
github.com → your avatar → Settings → Developer settings (bottom of the sidebar) → OAuth Apps → New OAuth App.…… — this exact URL.https://api.github.com/users/your-login — copy the \"id\" number (e.g. 1234567).GITHUB_CLIENT_ID = the Client ID, GITHUB_ADMIN_IDS = your numeric id (or GITHUB_ADMIN_LOGINS = your login). Set GITHUB_CLIENT_SECRET as a secret: npx wrangler secret put GITHUB_CLIENT_SECRET (or in the dashboard: your Worker → Settings → Variables → Edit → add as a secret).npx wrangler deploy), then refresh this page: the login shows \"Sign in with GitHub\". Only accounts in your allowlist can open the console.Privacy: GitHub sees only your login — the identity provider you chose. The token is used once to read your id and is then discarded, never stored. No visitor data ever leaves your Worker.
\nGet a private notification on Telegram when there is new activity on your site. Privacy-first: the message only says which page the activity happened on — comment text, nicknames and poll options are never sent to Telegram. Your bot token and chat id are stored only in your own private D1 database.
\nChoose which activity triggers an alert. The message includes the page link — never the content itself.
\nCreate your bot: open Telegram → search @BotFather → /newbot → choose a name and a username → copy the token it sends you.
\nFind your chat id: message your bot once (e.g. \"hi\"), then open https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates in a browser and copy the number in \"chat\":{\"id\":123456789,…} (channels/groups have a leading minus).
Tip: press Save settings first, then Send test message to verify instantly. If it fails, Telegram shows the exact reason below.
\nThen press Save settings and trigger one of the enabled activities on your site (a comment, poll vote or reaction): you'll get the notification.
\nDownload everything your visitors wrote — you are the data controller and can move your data anytime. Exports never include IPs, cookies or tracking data.
\nDesign your comment / reaction widget and copy the embed snippet into any page of your site.
\n\nOne snippet, any page. Paste it wherever you want the widget to appear (end of the article, before the footer…).
\ndata-article-path to that page's path (e.g. /blog/hello-world) — or leave it empty and the widget uses the page's own URL automatically.💡 The widget talks to …. If your site is on a different domain, that domain must be in the Worker's allowed origins (set during installation) or the browser will block the requests.
Updates as you change the options above — this is exactly what your visitors will see (themes and colors apply live).
\n \n \nCreate StrawPoll-style polls for any page. Votes are anonymous (no IP, no cookies) and protected by proof-of-work; one vote per browser uses an anonymous in-browser token when enabled.
\nLeave empty for a global poll — shown on any page where you embed it. Set it to show the poll only on that article's page.
\nlocalStorage). It is not a cookie and contains no personal data; only a hash of it is kept in your own D1 — never sent to us.Updates as you type — this is what visitors see before voting.
\n \nCheck whether a newer StaticLayer release is available for this Worker.
\nChecking…
\nModeration console
Free, no credit card, no Zero Trust plan. Create a GitHub OAuth App, set 4 Worker vars once, and from then on this button signs you in without a password.
\n……GITHUB_CLIENT_ID, GITHUB_CLIENT_SECRET, GITHUB_ADMIN_IDS (your GitHub numeric id) on the Worker and re-deploy.Full step-by-step guide: sign in with your password once → Settings → Admin access & sign-in.
\nComments awaiting approval — search, filter and moderate.
\n \n \nAlready public — unapprove or delete.
\n \n \nEvery page that received activity on this Worker — click to filter the queue.
\nBlocked nicknames cannot comment. Allowlisted nicknames skip the queue (and in allowlist mode only they can comment). Matched case-insensitively.
\nAuto-approved — skip moderation
\nBanned — cannot comment (ban a nickname)
\nComments containing any of these words are auto-rejected and never stored
\nLive settings — applied immediately, no redeploy needed. Env-var defaults are used until you override them here.
\nHigher = harder for bots (and slightly slower for visitors). Range 0–256 · default 16.
\nComma-separated. Empty disables reactions.
\nPrivate/community sites can lock comments to approved members.
\nShown as the author of your admin replies, with an \"Author\" badge on the widget.
\nPassword-less login with GitHub OAuth is built in and free — no Cloudflare Zero Trust plan, no credit card, and no password to remember after the first setup. Create a GitHub OAuth App once, set 4 Worker vars, and the login screen shows \"Sign in with GitHub\". Your admin password stays as a fallback and is never sent to GitHub.
\nLost the admin password? Once GitHub sign-in is configured, just click \"Sign in with GitHub\". Otherwise rotate it: npx wrangler secret put ADMIN_SECRET (you choose the new value) — or reinstall fresh. Re-running the installer preserves your existing secrets (including the current password) by design.
github.com → your avatar → Settings → Developer settings (bottom of the sidebar) → OAuth Apps → New OAuth App.…… — this exact URL.https://api.github.com/users/your-login — copy the \"id\" number (e.g. 1234567).GITHUB_CLIENT_ID = the Client ID, GITHUB_ADMIN_IDS = your numeric id (or GITHUB_ADMIN_LOGINS = your login). Set GITHUB_CLIENT_SECRET as a secret: npx wrangler secret put GITHUB_CLIENT_SECRET (or in the dashboard: your Worker → Settings → Variables → Edit → add as a secret).npx wrangler deploy), then refresh this page: the login shows \"Sign in with GitHub\". Only accounts in your allowlist can open the console.Privacy: GitHub sees only your login — the identity provider you chose. The token is used once to read your id and is then discarded, never stored. No visitor data ever leaves your Worker.
\nGet a private notification on Telegram when there is new activity on your site. Privacy-first: the message only says which page the activity happened on — comment text, nicknames and poll options are never sent to Telegram. Your bot token and chat id are stored only in your own private D1 database.
\nChoose which activity triggers an alert. The message includes the page link — never the content itself.
\nCreate your bot: open Telegram → search @BotFather → /newbot → choose a name and a username → copy the token it sends you.
\nFind your chat id: message your bot once (e.g. \"hi\"), then open https://api.telegram.org/bot<YOUR_TOKEN>/getUpdates in a browser and copy the number in \"chat\":{\"id\":123456789,…} (channels/groups have a leading minus).
Tip: press Save settings first, then Send test message to verify instantly. If it fails, Telegram shows the exact reason below.
\nThen press Save settings and trigger one of the enabled activities on your site (a comment, poll vote or reaction): you'll get the notification.
\nDownload everything your visitors wrote — you are the data controller and can move your data anytime. Exports never include IPs, cookies or tracking data.
\nDesign your comment / reaction widget and copy the embed snippet into any page of your site.
\n\nOne snippet, any page. Paste it wherever you want the widget to appear (end of the article, before the footer…).
\ndata-article-path to that page's path (e.g. /blog/hello-world) — or leave it empty and the widget uses the page's own URL automatically.💡 The widget talks to …. If your site is on a different domain, that domain must be in the Worker's allowed origins (set during installation) or the browser will block the requests.
Updates as you change the options above — this is exactly what your visitors will see (themes and colors apply live).
\n \n \nCreate StrawPoll-style polls for any page. Votes are anonymous (no IP, no cookies) and protected by proof-of-work; one vote per browser uses an anonymous in-browser token when enabled.
\nLeave empty for a global poll — shown on any page where you embed it. Set it to show the poll only on that article's page.
\nlocalStorage). It is not a cookie and contains no personal data; only a hash of it is kept in your own D1 — never sent to us.Updates as you type — this is what visitors see before voting.
\n \nCheck whether a newer StaticLayer release is available for this Worker.
\nChecking…
\n