feat(website): logo poll at /logo/ with cookie-free vote counter
Some checks failed
CI / test (push) Waiting to run
Build ISO / build-iso (push) Successful in 18m1s
CI / lint (push) Successful in 24s
CI / markdown-links (push) Successful in 18s
CI / validate-json (push) Successful in 21s
Deploy site / deploy (push) Has been cancelled

Two marks (M and R) side by side, EN + DE, with the site-header and real
16 px favicon context for each. Votes go to ops/poll/pollsvc.py, a stdlib
Python service behind nginx at /api/poll/ (SQLite, systemd DynamicUser,
rate-limited). Devices are counted once via a salted hash of IP + user
agent; the privacy pages document it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MScAinbyMdeNc7H2BZdnnG
This commit is contained in:
Daniel Maksymilian Syrnicki 2026-08-26 12:57:23 +02:00
parent 8b07b57242
commit 2fbafd5c77
16 changed files with 527 additions and 5 deletions

View file

@ -29,6 +29,16 @@ server {
try_files $uri $uri/ $uri.html =404; try_files $uri $uri/ $uri.html =404;
} }
# Poll counter (ops/poll/pollsvc.py) — cookie-free, rate-limited.
location /api/poll/ {
limit_req zone=poll burst=10 nodelay;
proxy_pass http://127.0.0.1:8090/;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header User-Agent $http_user_agent;
proxy_read_timeout 5s;
}
location = /favicon.svg { location = /favicon.svg {
access_log off; access_log off;
log_not_found off; log_not_found off;

View file

@ -0,0 +1,2 @@
# Rate limit for the poll API (http context; included from conf.d).
limit_req_zone $binary_remote_addr zone=poll:1m rate=30r/m;

View file

@ -16,6 +16,7 @@ install -d -o "$OWNER" -g "$OWNER" -m 0755 "$WEBROOT"
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT" install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF" cp "$(dirname "$0")/furtka.org.conf" "$SITE_CONF"
cp "$(dirname "$0")/poll-ratelimit.conf" /etc/nginx/conf.d/poll-ratelimit.conf
ln -sfn "$SITE_CONF" "$SITE_LINK" ln -sfn "$SITE_CONF" "$SITE_LINK"
# Drop the Ubuntu default site so it doesn't shadow us on :80. # Drop the Ubuntu default site so it doesn't shadow us on :80.

View file

@ -0,0 +1,17 @@
[Unit]
Description=furtka.org poll counter
After=network.target
[Service]
ExecStart=/usr/bin/python3 /opt/furtka-poll/pollsvc.py
DynamicUser=yes
StateDirectory=furtka-poll
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target

170
ops/poll/pollsvc.py Normal file
View file

@ -0,0 +1,170 @@
#!/usr/bin/env python3
"""Tiny cookie-free poll counter for furtka.org.
Runs behind nginx on 127.0.0.1:8090. One SQLite file, no dependencies
beyond the standard library.
GET /<poll> -> {"poll": "logo", "counts": {"m": 3, "r": 5}, "total": 8}
POST /<poll> -> body {"choice": "m"}; answers like GET plus "yours"
A device is identified by a salted SHA-256 of client IP + User-Agent, so
nobody needs a cookie and the raw address is never stored. Voting again
from the same device changes the vote instead of adding one.
"""
import hashlib
import json
import logging
import os
import sqlite3
import sys
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
POLLS = {
"logo": ("m", "r"),
}
STATE_DIR = os.environ.get("STATE_DIRECTORY", "/var/lib/furtka-poll")
DB_PATH = os.path.join(STATE_DIR, "votes.db")
SALT_PATH = os.path.join(STATE_DIR, "salt")
LISTEN = os.environ.get("POLL_LISTEN", "127.0.0.1")
PORT = int(os.environ.get("POLL_PORT", "8090"))
MAX_BODY = 512
log = logging.getLogger("pollsvc")
_lock = threading.Lock()
def _salt() -> bytes:
try:
with open(SALT_PATH, "rb") as f:
s = f.read()
if len(s) >= 16:
return s
except FileNotFoundError:
pass
s = os.urandom(32)
with open(SALT_PATH, "wb") as f:
f.write(s)
os.chmod(SALT_PATH, 0o600)
return s
def _db() -> sqlite3.Connection:
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
conn.execute(
"CREATE TABLE IF NOT EXISTS votes ("
" poll TEXT NOT NULL, device TEXT NOT NULL, choice TEXT NOT NULL,"
" ts INTEGER NOT NULL DEFAULT (unixepoch()),"
" PRIMARY KEY (poll, device))"
)
conn.commit()
return conn
SALT = None
CONN = None
def counts(poll: str) -> dict:
with _lock:
rows = CONN.execute(
"SELECT choice, COUNT(*) FROM votes WHERE poll=? GROUP BY choice", (poll,)
).fetchall()
c = {k: 0 for k in POLLS[poll]}
for choice, n in rows:
if choice in c:
c[choice] = n
return {"poll": poll, "counts": c, "total": sum(c.values())}
class Handler(BaseHTTPRequestHandler):
server_version = "furtka-poll/1"
def log_message(self, fmt, *args): # quiet; nginx has the access log
log.debug(fmt, *args)
def _client_ip(self) -> str:
xff = self.headers.get("X-Forwarded-For")
if xff:
return xff.split(",")[0].strip()
return self.headers.get("X-Real-IP") or self.client_address[0]
def _device(self) -> str:
ua = self.headers.get("User-Agent", "")
h = hashlib.sha256(SALT + self._client_ip().encode() + b"|" + ua.encode())
return h.hexdigest()[:32]
def _json(self, status: int, payload: dict):
body = json.dumps(payload).encode()
self.send_response(status)
self.send_header("Content-Type", "application/json; charset=utf-8")
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _poll(self):
name = self.path.strip("/").split("?")[0]
if name not in POLLS:
self._json(404, {"error": "no such poll"})
return None
return name
def do_GET(self):
poll = self._poll()
if not poll:
return
payload = counts(poll)
with _lock:
row = CONN.execute(
"SELECT choice FROM votes WHERE poll=? AND device=?", (poll, self._device())
).fetchone()
payload["yours"] = row[0] if row else None
self._json(200, payload)
def do_POST(self):
poll = self._poll()
if not poll:
return
try:
n = int(self.headers.get("Content-Length", "0"))
if n > MAX_BODY:
raise ValueError("body too large")
data = json.loads(self.rfile.read(n) or b"{}")
choice = str(data.get("choice", ""))
except (ValueError, json.JSONDecodeError):
self._json(400, {"error": "bad request"})
return
if choice not in POLLS[poll]:
self._json(400, {"error": "unknown choice"})
return
with _lock:
CONN.execute(
"INSERT INTO votes (poll, device, choice) VALUES (?,?,?)"
" ON CONFLICT(poll, device) DO UPDATE SET choice=excluded.choice, ts=unixepoch()",
(poll, self._device(), choice),
)
CONN.commit()
payload = counts(poll)
payload["yours"] = choice
self._json(200, payload)
def main():
global SALT, CONN
logging.basicConfig(
level=os.environ.get("POLL_LOGLEVEL", "INFO"),
stream=sys.stderr,
format="%(levelname)s %(message)s",
)
os.makedirs(STATE_DIR, exist_ok=True)
SALT = _salt()
CONN = _db()
srv = ThreadingHTTPServer((LISTEN, PORT), Handler)
log.info("listening on %s:%d, db %s", LISTEN, PORT, DB_PATH)
srv.serve_forever()
if __name__ == "__main__":
main()

19
ops/poll/setup-poll.sh Executable file
View file

@ -0,0 +1,19 @@
#!/usr/bin/env bash
# Install / update the poll counter on forge-runner-01. Idempotent.
#
# Usage (on the VM, with sudo):
# sudo ops/poll/setup-poll.sh
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
install -d -m 0755 /opt/furtka-poll
install -m 0644 "$HERE/pollsvc.py" /opt/furtka-poll/pollsvc.py
install -m 0644 "$HERE/furtka-poll.service" /etc/systemd/system/furtka-poll.service
systemctl daemon-reload
systemctl enable --now furtka-poll
systemctl restart furtka-poll
sleep 1
systemctl --no-pager --lines=3 status furtka-poll
curl -fsS http://127.0.0.1:8090/logo && echo
echo "OK: furtka-poll running on 127.0.0.1:8090"

View file

@ -42,6 +42,15 @@ This rsyncs `website/` to `/srv/furtka-site/` on the VM over SSH and runs
`hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path, `hugo --minify` into `/var/www/furtka.org`. Same end state as the CI path,
just with an SSH hop. just with an SSH hop.
### Poll counter
`/api/poll/` is a tiny stdlib Python service (`ops/poll/pollsvc.py`,
SQLite, systemd `DynamicUser`) behind nginx on `127.0.0.1:8090`. Install or
update it on the VM with `sudo ops/poll/setup-poll.sh`; the nginx location
and rate limit come from `ops/nginx/` via `setup-vm.sh`. Devices are told
apart by a salted hash of IP + user agent — no cookie — and a repeat vote
from the same device changes the choice instead of adding one.
### First-time VM setup ### First-time VM setup
Only needed once, when provisioning a fresh forge-runner VM: Only needed once, when provisioning a fresh forge-runner VM:
@ -58,11 +67,14 @@ hugo.toml Hugo config (multilingual: en default, de)
content/ Markdown pages content/ Markdown pages
_index.md Home (EN) _index.md Home (EN)
_index.de.md Home (DE) _index.de.md Home (DE)
logo.md / .de.md Logo poll (/logo/, /de/logo/) — layout `poll`, votes via /api/poll/
layouts/ Custom inline theme — no external theme or framework layouts/ Custom inline theme — no external theme or framework
_default/ baseof, single, list _default/ baseof, single, list
partials/ head, header, footer, gate SVG, lang switcher partials/ head, header, footer, gate SVG, lang switcher, mark-m / mark-r (poll)
poll/single.html Logo poll page; strings come from the content front matter
index.html Home-only layout with editorial hero index.html Home-only layout with editorial hero
assets/css/main.css Stylesheet (fingerprinted + minified on build) assets/css/main.css Stylesheet (fingerprinted + minified on build)
assets/js/poll.js Vote script for /logo/ — plain fetch, no cookies, no storage
static/favicon.svg Gate mark in crimson static/favicon.svg Gate mark in crimson
deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes) deploy.sh Manual rsync + remote Hugo build (over SSH, for off-CI pushes)
deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI deploy-ci.sh Local rsync + Hugo build — runs on forge-runner-01 from CI

View file

@ -439,3 +439,92 @@ main.container {
will-change: auto; will-change: auto;
} }
} }
/* ── Logo poll ───────────────────────────────────────────────── */
.poll-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr));
gap: 1.5rem;
margin-top: 2.5rem;
}
.poll-card {
display: flex;
flex-direction: column;
gap: 0.9rem;
padding: 1.4rem;
border: 1px solid var(--card-border);
border-radius: 1rem;
background: var(--card-bg);
transition: border-color 160ms, box-shadow 160ms;
}
.poll-card.is-yours {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-glow);
}
.poll-card h2 { margin: 0; font-size: 1.25rem; letter-spacing: -0.01em; }
.poll-desc { margin: 0; color: var(--fg-muted); font-size: 0.95rem; }
.poll-art {
display: grid;
place-items: center;
padding: 2.2rem 1rem;
border-radius: 0.75rem;
}
.poll-art--m { background: var(--bg-subtle); color: var(--accent); }
.poll-art--r { background: #151515; color: #af6428; }
.pm { display: block; }
.pm--big { height: 9rem; width: auto; }
.pm--head { height: 1.1em; width: auto; }
.poll-ctx {
display: flex;
flex-wrap: wrap;
gap: 0.6rem;
align-items: center;
}
.poll-ctx-head, .poll-ctx-tab {
display: inline-flex;
align-items: center;
gap: 0.45rem;
padding: 0.35rem 0.7rem;
border: 1px solid var(--border);
border-radius: 0.5rem;
background: var(--bg);
font-size: 0.82rem;
color: var(--fg-muted);
}
.poll-ctx-head { color: var(--accent); }
.poll-ctx-head--r { color: #af6428; }
.poll-ctx-name {
font-weight: 600;
letter-spacing: 0.12em;
text-transform: uppercase;
font-size: 0.7rem;
color: var(--fg);
}
.poll-ctx-tab img { image-rendering: pixelated; }
.poll-btn {
margin-top: auto;
padding: 0.85rem 1.2rem;
border: 1px solid var(--accent);
border-radius: 0.6rem;
background: var(--accent);
color: #fff;
font: inherit;
font-weight: 600;
cursor: pointer;
transition: background 140ms, transform 140ms;
}
.poll-btn:hover:not(:disabled) { background: var(--accent-hover); transform: translateY(-1px); }
.poll-btn:focus-visible { outline: 2px solid var(--fg); outline-offset: 2px; }
.poll-btn:disabled { cursor: default; opacity: 0.85; }
.is-yours .poll-btn { background: transparent; color: var(--accent); }
.poll-bar { height: 0.45rem; border-radius: 999px; background: var(--bg-subtle); overflow: hidden; display: none; }
.poll-bar i { display: block; height: 100%; width: 0; background: var(--accent); transition: width 500ms ease; }
.poll--voted .poll-bar { display: block; }
.poll-count { margin: 0; min-height: 1.4em; font-size: 0.85rem; color: var(--fg-muted); font-variant-numeric: tabular-nums; }
.poll-status { margin: 1.5rem 0 0; min-height: 1.5em; color: var(--fg-muted); }
.poll-note { margin-top: 1rem; max-width: var(--measure); font-size: 0.85rem; color: var(--fg-muted); }
.poll-note a { color: var(--accent); }
@media (prefers-reduced-motion: reduce) {
.poll-bar i, .poll-btn { transition: none; }
}

57
website/assets/js/poll.js Normal file
View file

@ -0,0 +1,57 @@
// furtka.org logo poll — no cookies, no storage. The server tells us
// what this device already chose (salted hash of IP + user agent).
(function () {
var root = document.querySelector('.poll');
if (!root || !window.fetch) return;
var api = root.dataset.api;
var status = root.querySelector('.poll-status');
var t = status.dataset;
var buttons = root.querySelectorAll('[data-vote]');
var busy = false;
function pct(n, total) { return total ? Math.round((n / total) * 100) : 0; }
function render(d) {
var total = d.total || 0;
root.classList.toggle('poll--voted', !!d.yours);
Object.keys(d.counts).forEach(function (k) {
var card = root.querySelector('[data-choice="' + k + '"]');
if (!card) return;
var n = d.counts[k], p = pct(n, total);
card.classList.toggle('is-yours', d.yours === k);
card.querySelector('.poll-bar i').style.width = p + '%';
card.querySelector('[data-count]').textContent = d.yours ? p + '% · ' + n : '';
var b = card.querySelector('[data-vote]');
b.disabled = d.yours === k;
b.textContent = d.yours === k ? t.tVoted : (d.yours ? t.tChange : b.dataset.label);
});
status.textContent = d.yours ? t.tTotal.replace('{n}', total) : '';
}
function load() {
fetch(api, { headers: { Accept: 'application/json' } })
.then(function (r) { if (!r.ok) throw r; return r.json(); })
.then(render)
.catch(function () { status.textContent = t.tError; });
}
buttons.forEach(function (b) {
b.dataset.label = b.textContent;
b.addEventListener('click', function () {
if (busy) return;
busy = true; b.disabled = true;
fetch(api, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ choice: b.dataset.vote })
})
.then(function (r) { if (!r.ok) throw r; return r.json(); })
.then(render)
.catch(function () { status.textContent = t.tError; b.disabled = false; })
.then(function () { busy = false; });
});
});
status.textContent = '';
load();
})();

View file

@ -11,7 +11,8 @@ Diese Website setzt **keine Cookies**, lädt **keine Schriften oder
Skripte von Drittanbietern**, bindet **keine Analyse- oder Skripte von Drittanbietern**, bindet **keine Analyse- oder
Tracking-Dienste** ein und enthält **keine externen Einbettungen** Tracking-Dienste** ein und enthält **keine externen Einbettungen**
(YouTube, Maps, Social-Media-Buttons, …). Technisch anfallend sind (YouTube, Maps, Social-Media-Buttons, …). Technisch anfallend sind
ausschließlich kurzfristige Server-Zugriffsprotokolle. ausschließlich kurzfristige Server-Zugriffsprotokolle — und, solange die
[Logo-Abstimmung](/de/logo/) läuft, ein gesalzener Hash pro Stimme (siehe unten).
### Verantwortlicher ### Verantwortlicher
@ -43,6 +44,22 @@ an Betrieb und Sicherheit).
Infrastruktur; es gibt keinen externen Auftragsverarbeiter. Infrastruktur; es gibt keinen externen Auftragsverarbeiter.
**Drittlandübermittlung:** keine. **Drittlandübermittlung:** keine.
### Logo-Abstimmung
Auf der Seite [/de/logo/](/de/logo/) können Sie zwischen zwei
Logo-Vorschlägen abstimmen. Damit jedes Gerät ohne Cookie einmal gezählt
wird, speichert der Abstimmungsserver einen **gesalzenen SHA-256-Hash aus
Ihrer IP-Adresse und Browser-Kennung** zusammen mit Ihrer Wahl und dem
Zeitpunkt der Stimmabgabe. Der Hash lässt sich nicht in die Adresse
zurückrechnen; das Salt verlässt den Server nicht.
**Zweck:** Zählung je Gerät einmal.
**Rechtsgrundlage:** Art. 6 Abs. 1 lit. f DSGVO — berechtigtes Interesse
an einer ehrlichen Auszählung; die Teilnahme ist freiwillig.
**Speicherdauer:** bis zum Ende der Abstimmung, danach wird die Datenbank
gelöscht.
**Empfänger:** keine.
### Cookies und Tracking ### Cookies und Tracking
Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder
@ -75,5 +92,5 @@ Website: <https://www.datenschutz.rlp.de>
### Stand ### Stand
Diese Erklärung ist aktuell gültig und wurde zuletzt am 18.04.2026 Diese Erklärung ist aktuell gültig und wurde zuletzt am 26.08.2026
aktualisiert. aktualisiert.

View file

@ -11,7 +11,8 @@ sitemap:
This website sets **no cookies**, loads **no third-party fonts or This website sets **no cookies**, loads **no third-party fonts or
scripts**, embeds **no analytics or tracking services**, and contains scripts**, embeds **no analytics or tracking services**, and contains
**no external embeds** (YouTube, Maps, social buttons, …). The only **no external embeds** (YouTube, Maps, social buttons, …). The only
technical data collected is short-lived server access logs. technical data collected is short-lived server access logs — plus, while
the [logo poll](/logo/) is running, one salted hash per vote (see below).
### Controller ### Controller
@ -43,6 +44,20 @@ and security.
external processor is involved. external processor is involved.
**Transfers outside the EU/EEA:** none. **Transfers outside the EU/EEA:** none.
### Logo poll
The page [/logo/](/logo/) lets you vote between two logo proposals. So
that each device is counted once without a cookie, the poll server stores
a **salted SHA-256 hash of your IP address and browser identification**
together with your choice and the time of the vote. The hash cannot be
turned back into the address; the salt never leaves the server.
**Purpose:** counting votes once per device.
**Legal basis:** Art. 6(1)(f) GDPR — legitimate interest in an honest
tally; voting is voluntary.
**Retention:** until the poll is closed, then the database is deleted.
**Recipients:** none.
### Cookies and tracking ### Cookies and tracking
None. No cookies are set, no localStorage or sessionStorage is used, and None. No cookies are set, no localStorage or sessionStorage is used, and
@ -75,6 +90,6 @@ Website: <https://www.datenschutz.rlp.de>
### Last updated ### Last updated
This statement was last updated on 2026-04-18. This statement was last updated on 2026-08-26.
The German version of this privacy statement is the legally binding one. The German version of this privacy statement is the legally binding one.

View file

@ -0,0 +1,30 @@
---
title: "Welches Logo?"
description: "Zwei Vorschläge für das Furtka-Zeichen. Wähl das, das dir besser gefällt — ein Klick genügt."
translationKey: "logo"
layout: single
type: poll
poll: logo
sitemap:
priority: 0.5
m:
title: "M — das f im Tor"
text: "Ein kleines f, aus einem Rundbogentor ausgestanzt. Der Stamm ist der Torpfosten, die zwei Balken sind die Querriegel."
r:
title: "R — Fenster, Rad und F"
text: "Ein Bogenfenster mit einem Speichenrad dahinter; in der oberen rechten Scheibe wird die Speiche zum F."
labels:
vote: "Dieses hier"
voted: "Deine Wahl"
change: "Zu diesem wechseln"
total: "Bisher {n} Stimmen. Danke!"
error: "Die Abstimmung ist gerade nicht erreichbar — bitte gleich noch einmal versuchen."
nojs: "Zum Abstimmen wird JavaScript benötigt."
note: "Keine Cookies, kein Konto. Damit jedes Gerät einmal zählt, speichert der Server einen gesalzenen Hash aus IP-Adresse und Browser — nicht die Adresse selbst. Details in der <a href=\"/de/datenschutz/\">Datenschutzerklärung</a>."
---
*Furtka* ist polnisch für die kleine Gartenpforte — die Tür im Zaun, durch
die man hineinkommt, ohne das große Tor aufzumachen. Beide Zeichen spielen
damit: das eine zeichnet den Buchstaben ins Tor, das andere schaut durchs
Tor auf ein Rad. Unter jedem Zeichen siehst du, wie es in der Kopfzeile
der Seite sitzt und als echtes 16-Pixel-Symbol im Browser-Tab.

31
website/content/logo.md Normal file
View file

@ -0,0 +1,31 @@
---
title: "Which logo?"
description: "Two proposals for the Furtka mark. Pick the one you like better — it takes one click."
translationKey: "logo"
url: /logo/
layout: single
type: poll
poll: logo
sitemap:
priority: 0.5
m:
title: "M — the f in the gate"
text: "A lowercase f cut out of an arched gate. The stem is the gate post, the two rails are the crossbars."
r:
title: "R — window, wheel and F"
text: "An arched window with a wheel of spokes behind it; in the top-right pane the spoke becomes an F."
labels:
vote: "This one"
voted: "Your pick"
change: "Switch to this one"
total: "{n} votes so far. Thank you!"
error: "Couldn't reach the poll right now — please try again in a moment."
nojs: "Voting needs JavaScript."
note: "No cookies, no account. To count each device once, the server keeps a salted hash of your IP address and browser — not the address itself. Details in the <a href=\"/privacy/\">privacy notice</a>."
---
*Furtka* is Polish for a small garden gate — the little door in the fence
that lets you in without opening the whole thing. Both marks play with that:
one draws the letter into the gate, the other looks through the gate at a
wheel. Below each mark you see how it sits in the site header and as a
real 16-pixel browser-tab icon.

View file

@ -0,0 +1,2 @@
{{- /* Mark M — lowercase f cut out of a gate. 64-unit grid, even-odd knockout. */ -}}
<svg class="pm {{ .class }}" viewBox="0 0 64 64" aria-hidden="true" focusable="false"><path fill="currentColor" fill-rule="evenodd" d="M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"/></svg>

View file

@ -0,0 +1,2 @@
{{- /* Mark R — window, wheel and F. 104x114 grid, stroke 8, hub knocked out. */ -}}
<svg class="pm {{ .class }}" viewBox="0 0 104 114" aria-hidden="true" focusable="false"><defs><clipPath id="pm-rc-{{ .id }}" clip-rule="evenodd"><path clip-rule="evenodd" d="M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z"/></clipPath></defs><g fill="none" stroke="currentColor" stroke-width="8" stroke-linejoin="round"><path d="M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z"/><path d="M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9" clip-path="url(#pm-rc-{{ .id }})"/><path d="M52 22H79M52 37H69" stroke-linecap="round"/><circle cx="52" cy="65" r="15"/></g></svg>

View file

@ -0,0 +1,48 @@
{{ define "main" }}
{{- $p := .Params -}}
<article class="poll" data-poll="{{ $p.poll }}" data-api="/api/poll/{{ $p.poll }}">
<header class="page-header">
<h1>{{ .Title }}</h1>
{{ with $p.description }}<p class="lede">{{ . }}</p>{{ end }}
</header>
<div class="prose">{{ .Content }}</div>
<div class="poll-grid">
<section class="poll-card" data-choice="m" aria-labelledby="poll-m-title">
<div class="poll-art poll-art--m">{{ partial "mark-m.html" (dict "class" "pm--big" "id" "m") }}</div>
<div class="poll-ctx">
<span class="poll-ctx-head">{{ partial "mark-m.html" (dict "class" "pm--head" "id" "mh") }}<span class="poll-ctx-name">Furtka</span></span>
<span class="poll-ctx-tab"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAABmJLR0QA/wD/AP+gvaeTAAABL0lEQVQ4jcWRP0tCYRTGf+d6+0dDbQ0RuAQaNYdXIi1ssCUQt4YkWvsK9Rn6DNHclEsoDt2oWVDEoCEaWmoKutk9Dd6rLzczwaHf9r7nPM953vMKEa7XVxfsWOcYZQ9hObhuKXI5iXeWdh9ezH4xDxUnuWvhn4PMR40DXkVkf/OmcfXDoJJa2bbELwMTv4hDPF/Z2bpt1noG1Ux8WrypFsjSH+KQx/e5TiJfbn/YAOLNFEENsdwrHGbdRt1UVZ3EqcAJEJ99swvAhdUt+Tmjr6Nfn4WoOIoKOQA7mLho1J6zd+0ngFoq4SCy0RdpGg0UQeKugWIZ/+EbU45AD/pjjQQqMSPBYDJuswSUBuyghzXMYBSGJqilk3mUtfBs7mAkA1UtAgN3EDL2E/7fYGy+AWGMWfwfY1MaAAAAAElFTkSuQmCC" width="16" height="16" alt="">Furtka</span>
</div>
<h2 id="poll-m-title">{{ $p.m.title }}</h2>
<p class="poll-desc">{{ $p.m.text }}</p>
<button type="button" class="poll-btn" data-vote="m">{{ $p.labels.vote }}</button>
<div class="poll-bar" aria-hidden="true"><i></i></div>
<p class="poll-count" data-count="m"></p>
</section>
<section class="poll-card" data-choice="r" aria-labelledby="poll-r-title">
<div class="poll-art poll-art--r">{{ partial "mark-r.html" (dict "class" "pm--big" "id" "r") }}</div>
<div class="poll-ctx">
<span class="poll-ctx-head poll-ctx-head--r">{{ partial "mark-r.html" (dict "class" "pm--head" "id" "rh") }}<span class="poll-ctx-name">Furtka</span></span>
<span class="poll-ctx-tab"><img src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAQCAYAAADJViUEAAAABmJLR0QA/wD/AP+gvaeTAAAB30lEQVQokY2TPUhVYRzGf8855/ox9LHZVkPkrZYsIegDnQPBey1zcKi8ObY0tjTk2hJEXK85FE3pzdUpiyiIIgRJxSiIQEgCl7p6Pedp8GhHuYHP+P8/v+d93hdekdFkKV8MxA2bDqA1s/oDfDAeK1YWpraGAhi/dqTlQNTyTFAQLBvPWLQGqDvBL+WgBu4C2gzPW1o3Bi89WFoLAA7mmsuCXuDu+mp4uFBZGMA8ttmPqdnJV9tPsKYEl9dquYcAejF84qyT5B3mfmFs/vZWpYmhY6VAwSg79Vbw3nAL6AwSJ1eBep1wJOsKk+A7gO0RScV6U7ivUJk/tx5v3AM2LAYC2e2gpf6xuV9ZOAl1FEDSHduTUT0eBugfX/oJfJHJB6Ac8noWNEgkbembPg1julfrtYfbBlEDmiMaqFpqLwuV0qjBOOT39cq3md0+VUv5acFJpDebfcklcq+yJjDWFAH1zTyfN8xFac0m7DObToXadYJBiA7sOB01AURp9I/C6Pyp7dpDx18jX8jAr4qVz13b+5v5TxiCRneOwrhgqKbkRC5I+hr5GsI95cUVJTxK4XJPeXFlz/BepWopPw2cBiayC8uH/n0MLe/i+oCPETALdAJXdqRaGGKhi/85ePYvrfW+Wa23Y78AAAAASUVORK5CYII=" width="15" height="16" alt="">Furtka</span>
</div>
<h2 id="poll-r-title">{{ $p.r.title }}</h2>
<p class="poll-desc">{{ $p.r.text }}</p>
<button type="button" class="poll-btn" data-vote="r">{{ $p.labels.vote }}</button>
<div class="poll-bar" aria-hidden="true"><i></i></div>
<p class="poll-count" data-count="r"></p>
</section>
</div>
<p class="poll-status" role="status" aria-live="polite"
data-t-voted="{{ $p.labels.voted }}"
data-t-total="{{ $p.labels.total }}"
data-t-error="{{ $p.labels.error }}"
data-t-nojs="{{ $p.labels.nojs }}"
data-t-change="{{ $p.labels.change }}">{{ $p.labels.nojs }}</p>
<p class="poll-note">{{ $p.labels.note | safeHTML }}</p>
</article>
{{ $js := resources.Get "js/poll.js" | minify | fingerprint }}
<script defer src="{{ $js.RelPermalink }}" integrity="{{ $js.Data.Integrity }}"></script>
{{ end }}