Compare commits

..

No commits in common. "HEAD" and "26.20-alpha" have entirely different histories.

42 changed files with 21 additions and 1242 deletions

View file

@ -12,7 +12,6 @@ on:
- '**/*.md'
- 'docs/**'
- 'website/**'
- 'ops/**'
- 'CHANGELOG.md'
- 'RELEASING.md'
workflow_dispatch:

View file

@ -7,18 +7,6 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
## [Unreleased]
### Fixed
- **Self-update starts newly added timers immediately and links units
through `current`.** `_link_new_units()` enabled a timer a release
introduced but never started it, so after the 26.19 → 26.20 update the
bench sat with `furtka-welcome.timer` `inactive (dead)` — and the console
banner without its IP line — until the next reboot. It also linked the
unit from `/opt/furtka/versions/<ver>/…` instead of `/opt/furtka/current/…`,
so the link would have stopped following later updates and rollbacks
(installer-created links use `current`). Now: link via `current`, enable,
start.
## [26.20-alpha] - 2026-08-25
First release shaped by a real-hardware install (old laptop mainboard, BIOS boot,

View file

@ -102,7 +102,6 @@ None of these nail the "your dad can set this up" experience. The installer wiza
- [x] Installer webapp prototype — device reader + form → JSON (Robert)
- [x] Arch running on Proxmox, custom image builds in progress (Robert)
- [x] Competitor analysis — see [docs/competitors.md](docs/competitors.md)
- [x] First bare-metal install (old laptop board, BIOS, SSD with a previous Proxmox VE) — 4 bugs found and shipped in 26.20-alpha, report in [docs/hardware-test-2026-08.md](docs/hardware-test-2026-08.md)
- [x] Wizard flow spec — see [docs/wizard-flow.md](docs/wizard-flow.md)
- [x] Release process + CI — CalVer tags, conventional commits, Forgejo Actions (ruff, pytest, JSON, link checks), `26.0-alpha` tagged
- [x] Forgejo runner live on Proxmox VM (`forge-runner-01`, Ubuntu 24.04) — docker-outside-of-docker with host-mode jobs for ISO builds, setup captured in [docs/runner-setup.md](docs/runner-setup.md) + [ops/forgejo-runner/](ops/forgejo-runner/)

View file

@ -1,75 +0,0 @@
# Hardware test report — "junk bench", 2026-08-24/26
First end-to-end run of Furtka on real hardware that was not a Proxmox VM
and not the Medion box: a bare old-laptop mainboard lying on the desk, a
1 TB Crucial BX SATA SSD that had previously lived in a Proxmox VE host, a
USB hub for keyboard + boot stick, LAN cable, HDMI monitor. Tester: Daniel.
Release under test: **26.19-alpha**, then self-updated to **26.20-alpha**.
Why it mattered: every bug below is invisible on the smoke VM. A VM has a
fresh empty disk, a fast virtual NIC, UEFI firmware and no USB stick to pull.
## Timeline
| When | What happened | Outcome |
|---|---|---|
| 08-24 | ISO flashed with Fedora Media Writer, bench booted, browser opened `proski.local:5000` | "Server Not Found" — **typo** (`proski` vs `proksi`). Bench powered off. |
| 08-25 | `proksi.local:5000` — wizard came up (BIOS/syslinux boot path, stock Arch splash) | OK |
| 08-25 13:07 | Install run 1 | **Failed at 12 %**: `Partition(s) 2, 3 on /dev/sda have been written, but we have been unable to inform the kernel of the change … in use` |
| 08-25 | Root shell over SSH (`passwd` + `ssh-copy-id` on the live ISO): SSD still carried a Proxmox VE `pve` volume group (swap, root, thin pool); udev had activated it, six device-mapper nodes sat on `sda3` | Cleared by hand: `dmsetup remove` ×6, `wipefs -a /dev/sda`, `blockdev --rereadpt` |
| 08-25 13:16 | Install run 2 | `Installation completed without any errors`; BIOS detected → `Grub`, MBR table, `sda1` 1 G vfat `/boot`, `sda2` ext4 `/` |
| 08-25 | "Reboot now" clicked | Box **booted the USB stick again** (BIOS order). Stick pulled, rebooted → `furtka.local` = 192.168.178.142, Caddy answering |
| 08-25 16:22 | First boot of the installed system | `furtka-catalog-sync.service` failed: `Network is unreachable`. `network-online.target` had fired on the IPv6 SLAAC address ~1 s before DHCPv4 landed. State file stuck on `checking`, apps page empty; next timer run would have been 18:26 |
| 08-25 ~17:00 | Catalog synced via the "Sync apps catalog" button | Catalog 26.13-alpha, 7 apps listed |
| 08-25 17:47 | 26.20-alpha released with the fixes below | Box reports `Update available: 26.19-alpha → 26.20-alpha` |
| 08-26 09:34 | Self-update to 26.20-alpha from `/settings` | `current → 26.20-alpha`, no failed units. **New finding:** `furtka-welcome.timer` linked + enabled by the updater but `inactive (dead)``enable` alone only arms it for the next boot |
## Findings and fixes
| # | Finding | Root cause | Fix | Shipped in | Validated on hardware |
|---|---|---|---|---|---|
| 1 | Console banner showed only `proksi.local` / `furtka.local`, never the IP fallback | Banner script was a oneshot after `network-online.target` and dropped the IP line when no address existed yet. On the live ISO releng's root autologin on tty1 also made `agetty --reload` a no-op | `furtka-issue.timer` (ISO) / `furtka-welcome.timer` (installed) re-run the script every 5 s; fallback line is always present ("no IP address yet — check the cable / DHCP" until one arrives); ISO no longer auto-logs root in on tty1 | 26.20-alpha (`0e05bef`) | Installed side: pending reboot or next update. ISO side: pending reinstall from the 26.20 ISO |
| 2 | Install failed at partitioning on a disk with a previous OS | Live ISO ships lvm2/mdadm/dmraid/cryptsetup; udev auto-activates whatever the disk carries; parted cannot re-read the partition table while dm nodes hold the partitions | `webinstaller/diskprep.py`: `release_disk()` runs before archinstall — swapoff/umount leaves-first, `dmsetup remove` / `mdadm --stop`, `wipefs -a`, `blockdev --rereadpt`; steps logged at the top of the install log; progress page names this failure explicitly | 26.20-alpha (`5ddda53`) | Pending reinstall from the 26.20 ISO (the disk now carries Furtka itself, which exercises the same path minus LVM) |
| 3 | "Remove the USB stick" was easy to miss — the box booted the installer again | Hint lived on the *rebooting* page and in a `confirm()` popup, i.e. after the click, with the machine restarting 3 s later | Done state shows a two-step list (pull the stick → restart) above the button; button reads "USB stick is out — restart now" | 26.20-alpha (`60404ae`) | Pending reinstall |
| 4 | No apps after first boot | Boot-time catalog sync raced DHCPv4 (IPv6 made `network-online` fire early), no retry, timer jitter pushed the next attempt 2 h out, state file stuck on `checking` | `furtka-catalog-sync.service` retries on failure (20 s, up to 8× in 15 min); failures recorded as stage `error`; apps page shows "last sync failed: …" | 26.20-alpha (`32ba522`) | Retry config confirmed live on the box after the update; a cold boot with the new unit is still pending |
| 5 | New timer dead after self-update | `_link_new_units()` linked + enabled but never started the timer; also linked from the versioned dir instead of `current` | Link via `current`, enable, start | Unreleased (this branch) | — |
Not a bug, but worth knowing: on this board the BIOS boots the USB stick
before the SSD, so every reinstall needs the stick pulled at the reboot
prompt. The stock Arch splash on the BIOS/syslinux boot path is unbranded
(only menu labels are) — cosmetic.
## What worked first time
- Fedora Media Writer → hybrid ISO boots on BIOS firmware.
- mDNS: `proksi.local` (live ISO) and `furtka.local` (installed) resolved
from a Fedora client once spelled correctly.
- BIOS detection → `Grub` + MBR layout; the installed system booted from
the SSD without intervention.
- Login, `/settings`, catalog sync via the UI button, `furtka update --check`,
self-update 26.19 → 26.20 with symlink flip and clean unit state.
- Time sync, Docker 29.7.2, Caddy on :80, sshd with key auth for the wizard
user (`wheel` + `docker` groups as configured).
## Still open from this run
- Reinstall from the 26.20-alpha ISO on the bench to validate findings 13
on the ISO side (the installed side of #1 validates on the next reboot).
- Cold boot with the retrying catalog-sync unit (#4) on a dual-stack LAN.
- Rebrand the syslinux boot splash.
- `smoke-latest.yml` is `workflow_dispatch` only and has not run since
April; the smoke VM would not have caught any of the above anyway, but it
is currently not running at all.
## Reproducing the bench conditions in a VM
Most of this can be provoked on Proxmox if you want a regression check
without the hardware:
- Finding 2: attach a disk that already carries an LVM install (e.g. a
spare Proxmox VE or Ubuntu LVM disk image) before booting the ISO.
- Finding 3: leave the ISO attached as CD-ROM with boot order CD-first.
- Finding 4: give the VM's bridge IPv6 RA before DHCPv4 — harder; the
retry logic is unit-tested instead.
- Finding 1: use SeaBIOS instead of OVMF (the README notes SeaBIOS fails on
`ldlinux.c32` from this ISO, so this one really needs hardware).

View file

@ -226,17 +226,12 @@ def _link_new_units(unit_dir: Path) -> list[str]:
"""`systemctl link` any unit file in unit_dir that isn't already symlinked
into /etc/systemd/system/. Returns the list of newly-linked unit names.
Newly-linked `.timer` units are additionally `systemctl enable`d *and
started* so that a self-update introducing a timer (e.g. 26.5 26.6
adding furtka-catalog-sync.timer, 26.19 26.20 adding
furtka-welcome.timer) activates it right away the installer's enable
list only applies to fresh installs, and `enable` alone only takes
effect at the next boot (the bench sat on a dead welcome timer after the
26.20 update).
Units are linked through the `/opt/furtka/current` symlink, not the
versioned directory that `unit_dir` scans, so the link keeps following
later updates and rollbacks the same way the installer-created links do.
Newly-linked `.timer` units are additionally `systemctl enable`d so that
a self-update introducing a timer (e.g. 26.5 26.6 adding
furtka-catalog-sync.timer) activates it automatically the installer's
enable list only applies to fresh installs. A linked-but-disabled timer
never fires on its own, so without this step catalog sync would never
happen on upgraded boxes.
"""
if not unit_dir.is_dir():
return []
@ -247,10 +242,9 @@ def _link_new_units(unit_dir: Path) -> list[str]:
target = _SYSTEMD_DIR / unit_file.name
if target.exists() or target.is_symlink():
continue
_run(["systemctl", "link", str(current_symlink() / "assets" / "systemd" / unit_file.name)])
_run(["systemctl", "link", str(unit_file)])
if unit_file.suffix == ".timer":
_run(["systemctl", "enable", unit_file.name])
_run(["systemctl", "start", unit_file.name])
linked.append(unit_file.name)
return linked

View file

@ -51,27 +51,6 @@ mDNS is wired: `avahi-daemon` + `nss-mdns` come from `packages.extra`, the live
5. Find its IP in Proxmox's VM summary (or your router's DHCP table)
6. Open `http://<vm-ip>:5000` — the existing 3-screen wizard should be there
## Real hardware (what differs from the VM flow)
Learned on the first bare-metal run, see [docs/hardware-test-2026-08.md](../docs/hardware-test-2026-08.md):
- **BIOS boxes work.** The hybrid ISO boots via syslinux on legacy firmware
(you'll see the stock Arch splash — only the menu labels are branded), and
the installer detects the missing `/sys/firmware/efi` and picks GRUB + MBR.
- **Pull the USB stick before "restart now".** Most BIOSes boot USB before
the disk; the done page says so above the button since 26.20-alpha.
- **Disks with a previous OS are fine since 26.20-alpha.** The live ISO
auto-activates old LVM/RAID/swap; `webinstaller/diskprep.py` releases the
disk before archinstall partitions it. On 26.19 and earlier you'd hit
`unable to inform the kernel of the change … in use` at 12 % — clear it
from the tty1 root shell with `dmsetup remove <node>` for every dm node
shown by `lsblk`, then `wipefs -a /dev/sdX` and retry.
- **Slow NICs.** The console banner's IP fallback line is timer-driven
since 26.20-alpha; on 26.19 it only appeared if DHCP beat getty.
- **Shell on the live ISO**: since 26.20-alpha tty1 stays at the login
prompt (banner refreshes live); log in as `root`, no password. sshd runs;
set a root password with `passwd` to reach it over the network.
## What you see after install + reboot
Once `archinstall` finishes and you click **Reboot now**, the VM comes up into the installed system. No more port `:5000` — the wizard ISO is gone. Instead:

View file

@ -29,22 +29,6 @@ server {
try_files $uri $uri/ $uri.html =404;
}
# Poll counter (ops/poll/pollsvc.py) — cookie-free, rate-limited.
location /api/poll/ {
# Public traffic arrives from the edge proxy via 192.168.178.164.
# Only that hop is trusted when unwinding X-Forwarded-For, so a
# client-supplied X-Forwarded-For cannot forge a fresh "device".
set_real_ip_from 192.168.178.164;
real_ip_header X-Forwarded-For;
real_ip_recursive on;
limit_req zone=poll burst=10 nodelay;
proxy_pass http://127.0.0.1:8090/;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header User-Agent $http_user_agent;
proxy_read_timeout 5s;
}
location = /favicon.svg {
access_log off;
log_not_found off;

View file

@ -1,2 +0,0 @@
# 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,7 +16,6 @@ install -d -o "$OWNER" -g "$OWNER" -m 0755 "$WEBROOT"
install -d -o "$OWNER" -g "$OWNER" -m 0755 "$SRCROOT"
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"
# Drop the Ubuntu default site so it doesn't shadow us on :80.

View file

@ -1,17 +0,0 @@
[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

View file

@ -1,172 +0,0 @@
#!/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 (as resolved by
nginx from the trusted edge proxy) + 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:
# nginx resolves the trusted proxy chain (real_ip module, see
# ops/nginx/furtka.org.conf) and hands us the result as X-Real-IP.
# We never parse X-Forwarded-For here: its leading entries are
# whatever the client chose to send.
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()

View file

@ -1,26 +0,0 @@
#!/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 # install / update
# sudo ops/poll/setup-poll.sh --reset # ... and wipe all votes
set -euo pipefail
HERE="$(cd "$(dirname "$0")" && pwd)"
if [ "${1:-}" = "--reset" ]; then
systemctl stop furtka-poll 2>/dev/null || true
rm -f /var/lib/furtka-poll/votes.db
echo "votes wiped"
fi
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

@ -316,17 +316,14 @@ def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
linked = updater._link_new_units(unit_dir)
assert linked == ["furtka-bar.timer"]
# Three calls for the newly-linked timer: link + enable + start. The
# already-linked service is untouched. `enable` alone only arms the
# timer for the next boot; `start` makes it fire on the running box
# (26.20 left furtka-welcome.timer dead until reboot without it).
assert len(seen) == 3
# Two calls for the newly-linked timer: systemctl link + systemctl enable.
# The already-linked service is untouched. Timers need the follow-up
# `enable` so self-updates that introduce new timers don't leave them
# dormant — fresh installs get their enable via the webinstaller.
assert len(seen) == 2
assert seen[0][:2] == ["systemctl", "link"]
# Linked via the `current` symlink, not the versioned dir being scanned,
# so the link survives the next symlink flip / rollback.
assert seen[0][2] == str(updater.current_symlink() / "assets" / "systemd" / "furtka-bar.timer")
assert seen[0][2].endswith("furtka-bar.timer")
assert seen[1] == ["systemctl", "enable", "furtka-bar.timer"]
assert seen[2] == ["systemctl", "start", "furtka-bar.timer"]
def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch):

View file

@ -42,15 +42,6 @@ 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,
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
Only needed once, when provisioning a fresh forge-runner VM:
@ -67,18 +58,11 @@ hugo.toml Hugo config (multilingual: en default, de)
content/ Markdown pages
_index.md Home (EN)
_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
_default/ baseof, single, list
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
partials/ head, header, footer, gate SVG, lang switcher
index.html Home-only layout with editorial hero
assets/css/main.css Stylesheet (fingerprinted + minified on build)
assets/js/poll.js Vote script for /logo/ — plain fetch, no cookies, no storage
data/logo/{m,r}.json Generated: mark SVGs, lockups, 16 px icons, console banner per proposal
static/img/logo/ Generated: tiles, splash and social card per proposal
tools/logo-assets.py Generator for the two lines above (cairosvg + Pillow + fontTools);
run on the dev box, commit the output. Font: tools/FONT-PROVENANCE.md
static/favicon.svg Gate mark in crimson
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

View file

@ -439,132 +439,3 @@ main.container {
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;
}
.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); }
.poll-art--r { background: #151515; }
.poll-art .pm { height: 9rem; width: auto; }
.pm { display: block; }
.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.25rem 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); }
/* showcase */
.show { display: flex; flex-direction: column; gap: 3rem; margin-top: 4rem; }
.show-intro { margin: 0; }
.show-row { display: flex; flex-direction: column; gap: 0.6rem; }
.show-h { margin: 0; font-size: 1.35rem; letter-spacing: -0.01em; }
.show-p { margin: 0 0 0.6rem; max-width: var(--measure); color: var(--fg-muted); font-size: 0.95rem; }
.show-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(17rem, 1fr)); gap: 1.25rem; }
.show-cell {
display: flex; flex-direction: column; gap: 0.9rem;
padding: 1.1rem; border: 1px solid var(--card-border); border-radius: 0.9rem; background: var(--card-bg);
}
.show-cell--m { color: var(--accent); }
.show-cell--r { color: #af6428; }
.show-tag { font-size: 0.7rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--fg-muted); }
.sizes { display: flex; align-items: flex-end; gap: 1.1rem; flex-wrap: wrap; }
.sizes figure { margin: 0; display: flex; flex-direction: column; align-items: center; gap: 0.35rem; }
.sizes figcaption { font-family: var(--font-mono); font-size: 0.65rem; color: var(--fg-muted); }
.sz { display: block; }
.sz .pm { width: auto; }
.sz-96 .pm { height: 96px; } .sz-48 .pm { height: 48px; } .sz-32 .pm { height: 32px; } .sz-24 .pm { height: 24px; }
.px { image-rendering: pixelated; }
.lock { display: grid; place-items: center; padding: 1rem; border-radius: 0.6rem; background: var(--bg-subtle); }
.lock-h .pm { height: 3rem; width: auto; max-width: 100%; }
.lock-s .pm { height: 7rem; width: auto; max-width: 100%; }
.mock { border: 1px solid var(--border); border-radius: 0.7rem; overflow: hidden; background: var(--bg); color: var(--fg); font-size: 0.8rem; }
.tabbar { background: var(--bg-subtle); padding: 0.55rem 0.65rem; }
.tab { display: inline-flex; align-items: center; gap: 0.45rem; padding: 0.3rem 0.65rem; border: 1px solid var(--border); border-radius: 0.4rem; background: var(--bg); color: var(--fg-muted); }
.urlbar { padding: 0.45rem 0.75rem; border-top: 1px solid var(--border); font-family: var(--font-mono); font-size: 0.72rem; color: var(--fg-muted); }
.uihead { display: flex; align-items: center; gap: 0.5rem; padding: 0.8rem 0.9rem; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
.uihead-mark { display: inline-flex; color: inherit; }
.show-cell--m .uihead-mark { color: var(--accent); }
.show-cell--r .uihead-mark { color: #af6428; }
.uihead-mark .pm { height: 1.25rem; width: auto; }
.uihead-name { font-weight: 600; letter-spacing: 0.13em; text-transform: uppercase; font-size: 0.72rem; color: var(--fg); }
.uihead-nav { margin-left: auto; display: flex; gap: 0.7rem; color: var(--fg-muted); }
.uibody { padding: 0.9rem; display: flex; flex-direction: column; gap: 0.45rem; }
.uibody i { display: block; height: 0.45rem; border-radius: 3px; background: var(--bg-subtle); }
.uibody i.s { width: 45%; }
.tiles { display: flex; gap: 1rem; justify-content: center; padding: 1.2rem; border-radius: 0.6rem; background: var(--bg-subtle); }
.tiles img { width: 6rem; height: 6rem; }
.term {
margin: 0; padding: 0.9rem 1rem; border-radius: 0.6rem; overflow-x: auto;
background: #100f0c; color: #d8d2c2; font-family: var(--font-mono); font-size: 0.55rem; line-height: 1;
-webkit-text-size-adjust: none; text-size-adjust: none;
}
.term i, .term b { font-style: normal; font-weight: 400; color: inherit; }
.show-cell--m .term i { color: #ff6b56; } .show-cell--m .term b { color: #ff6b56; }
.show-cell--r .term i { color: #c9803f; } .show-cell--r .term b { color: #c9803f; }
.shot { display: block; width: 100%; height: auto; border-radius: 0.6rem; }
.grounds { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.6rem; }
.ground { display: grid; place-items: center; padding: 1.2rem; border-radius: 0.6rem; border: 1px solid var(--border); }
.ground--fg { background: var(--bg-subtle); color: var(--fg); }
.ground .pm { height: 3.2rem; width: auto; }
.poll-final { margin-top: 3.5rem; display: flex; flex-direction: column; gap: 1rem; }
.poll-final-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); gap: 1.25rem; }
.poll-final-card {
display: flex; flex-direction: column; gap: 0.8rem; align-items: stretch;
padding: 1.1rem; border: 1px solid var(--card-border); border-radius: 0.9rem; background: var(--card-bg);
}
.poll-final-mark { display: grid; place-items: center; padding: 0.8rem; }
.poll-final-mark .pm { height: 3.5rem; width: auto; }
.home-poll {
display: inline-flex; align-items: center; gap: 0.6rem; margin-top: 1.4rem;
padding: 0.55rem 0.95rem; border: 1px solid var(--border); border-radius: 999px;
background: var(--card-bg); color: var(--fg); text-decoration: none; font-size: 0.92rem;
}
.home-poll:hover { border-color: var(--accent); }
.home-poll .pm { height: 1.1rem; width: auto; color: var(--accent); }
.home-poll .pm + .pm { color: #af6428; }
/* Random left/right per visit data-logo-order is set in head.html before first paint.
Every pair container is a grid/flex, so `order` swaps the columns without touching the DOM. */
[data-logo-order="rm"] .poll-card[data-choice="m"],
[data-logo-order="rm"] .show-cell.show-cell--m,
[data-logo-order="rm"] .poll-final-card[data-choice="m"],
[data-logo-order="mr"] .poll-card[data-choice="r"],
[data-logo-order="mr"] .show-cell.show-cell--r,
[data-logo-order="mr"] .poll-final-card[data-choice="r"] { order: 1; }
@media (prefers-reduced-motion: reduce) { .poll-bar i, .poll-btn { transition: none; } }

View file

@ -1,59 +0,0 @@
// 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 each(sel, fn) { Array.prototype.forEach.call(root.querySelectorAll(sel), fn); }
function render(d) {
var total = d.total || 0;
root.classList.toggle('poll--voted', !!d.yours);
Object.keys(d.counts).forEach(function (k) {
var n = d.counts[k], p = pct(n, total), mine = d.yours === k;
each('[data-choice="' + k + '"]', function (card) {
card.classList.toggle('is-yours', mine);
var bar = card.querySelector('.poll-bar i'); if (bar) bar.style.width = p + '%';
var c = card.querySelector('[data-count]'); if (c) c.textContent = d.yours ? p + ' % · ' + n : '';
});
each('[data-vote="' + k + '"]', function (b) {
b.disabled = mine;
b.textContent = mine ? 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; });
}
Array.prototype.forEach.call(buttons, 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(function (d) { render(d); status.scrollIntoView({ block: 'nearest' }); })
.catch(function () { status.textContent = t.tError; b.disabled = false; })
.then(function () { busy = false; });
});
});
status.textContent = '';
load();
})();

View file

@ -2,8 +2,6 @@
title: "Furtka"
description: "Offenes Heimserver-Betriebssystem — einfach genug für alle."
status: "<span class=\"mono\">26.20-alpha</span> — in Arbeit"
poll_link: "/de/logo/"
poll_text: "Wir suchen ein Logo — hilf uns beim Aussuchen"
# features_today / features_next müssen index-parallel zu content/_index.md bleiben.
intro: |
**Furtka** ist ein offenes Heimserver-Betriebssystem.
@ -21,15 +19,11 @@ features_today:
- "Apps mit einem Klick installieren und entfernen"
- "Eine installierte App mit einem Klick aktualisieren (holt das neueste Container-Image)"
- "Furtka selbst mit einem Klick aktualisieren — keine Neuinstallation mehr für neue Features"
- "Die Bedienseite fragt nach einem Passwort — niemand im WLAN kommt einfach so rein"
- "Neue Apps kommen aus einem Online-Katalog, ohne auf ein Furtka-Update zu warten"
- "Apps können auf anderen Apps aufbauen — was eine braucht, wird mit installiert"
- "Verschlüsselte Verbindung im Heimnetz, in den Einstellungen einschaltbar"
features_next_label: "Was als Nächstes kommt"
features_next:
- "Apps für Fotos, Dateien, Smarthome, Spiele-Streaming und Medien"
- "Einfachere Sprache im Einrichtungs-Assistenten"
- "Den Server auch von unterwegs erreichen"
- "Sichere Verbindung im Heimnetz (ohne Warnmeldung im Browser)"
- "Mehrere Server zusammenschalten"
---

View file

@ -2,8 +2,6 @@
title: "Furtka"
description: "Open-source home server OS — simple enough for everyone."
status: "<span class=\"mono\">26.20-alpha</span> — work in progress"
poll_link: "/logo/"
poll_text: "We're picking a logo — help us choose"
# Keep features_today / features_next index-aligned with content/_index.de.md.
intro: |
**Furtka** is an open-source home server OS.
@ -21,15 +19,11 @@ features_today:
- "Install and remove apps with one click"
- "Update an installed app with one click (pulls the newest container image)"
- "Update Furtka itself with one click — no reinstalling for new features"
- "The control page asks for a password — nobody on the Wi-Fi can just walk in"
- "New apps arrive from an online catalog, without waiting for a Furtka update"
- "Apps can build on other apps — what one needs gets installed alongside it"
- "Encrypted connection on your home network, switched on in settings"
features_next_label: "What's coming next"
features_next:
- "Apps for photos, files, smart home, game streaming and media"
- "Plainer language in the setup wizard"
- "Reaching your server from outside the house"
- "Secure connection on your home network (no browser warning)"
- "Linking several servers together"
---

View file

@ -11,8 +11,7 @@ Diese Website setzt **keine Cookies**, lädt **keine Schriften oder
Skripte von Drittanbietern**, bindet **keine Analyse- oder
Tracking-Dienste** ein und enthält **keine externen Einbettungen**
(YouTube, Maps, Social-Media-Buttons, …). Technisch anfallend sind
ausschließlich kurzfristige Server-Zugriffsprotokolle — und, solange die
[Logo-Abstimmung](/de/logo/) läuft, ein gesalzener Hash pro Stimme (siehe unten).
ausschließlich kurzfristige Server-Zugriffsprotokolle.
### Verantwortlicher
@ -44,22 +43,6 @@ an Betrieb und Sicherheit).
Infrastruktur; es gibt keinen externen Auftragsverarbeiter.
**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
Keine. Es werden keine Cookies gesetzt, kein LocalStorage oder
@ -92,5 +75,5 @@ Website: <https://www.datenschutz.rlp.de>
### Stand
Diese Erklärung ist aktuell gültig und wurde zuletzt am 26.08.2026
Diese Erklärung ist aktuell gültig und wurde zuletzt am 18.04.2026
aktualisiert.

View file

@ -11,8 +11,7 @@ sitemap:
This website sets **no cookies**, loads **no third-party fonts or
scripts**, embeds **no analytics or tracking services**, and contains
**no external embeds** (YouTube, Maps, social buttons, …). The only
technical data collected is short-lived server access logs — plus, while
the [logo poll](/logo/) is running, one salted hash per vote (see below).
technical data collected is short-lived server access logs.
### Controller
@ -44,20 +43,6 @@ and security.
external processor is involved.
**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
None. No cookies are set, no localStorage or sessionStorage is used, and
@ -90,6 +75,6 @@ Website: <https://www.datenschutz.rlp.de>
### Last updated
This statement was last updated on 2026-08-26.
This statement was last updated on 2026-04-18.
The German version of this privacy statement is the legally binding one.

View file

@ -1,53 +0,0 @@
---
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
image: /img/logo/vote-card.png
sitemap:
priority: 0.5
m:
short: "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:
short: "R"
title: "R — Fenster, Rad und F"
text: "Ein Bogenfenster mit einem Speichenrad dahinter; in der oberen rechten Scheibe wird die Speiche zum F."
show:
intro: "Noch unentschieden? Hier ist jedes Zeichen überall dort, wo es funktionieren muss."
sizes: "Groß und klein"
sizes_text: "Vom Poster bis zum 16-Pixel-Symbol im Browser-Tab. Unter 32 Pixeln wechseln beide auf eine vereinfachte Zeichnung — M verliert nichts, R behält Fenster, Kreuz und Nabe."
name: "Mit dem Namen"
name_text: "Nebeneinander und gestapelt. Die Wortmarke ist bei beiden dieselbe, also entscheidet nur das Zeichen."
browser: "Im Browser"
browser_text: "Das Tab-Symbol in echter Größe und die Kopfzeile von furtka.org."
nav1: "Doku"
nav2: "Blog"
phone: "Auf dem Handy"
phone_text: "Startbildschirm-Symbol — eckig wie auf Android, rund wie manche Launcher es beschneiden."
box: "Auf dem Server"
box_text: "Der Text-Willkommensbildschirm, den der Server am Monitor zeigt, und der Startbildschirm beim Booten."
shared: "Geteilt"
shared_text: "Die Vorschaukarte, die erscheint, wenn jemand einen Link zu furtka.org postet."
colour: "In Farbe"
colour_text: "Auf hell, auf dunkel, auf der eigenen Farbe und in normaler Textfarbe — jedes Zeichen ist eine Form und nimmt die Farbe an, die es umgibt."
final: "Genug gesehen?"
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. Stimm direkt hier ab — oder scroll erst weiter und sieh
beide Zeichen überall, wo sie funktionieren müssen, vom Poster bis zum
winzigen Symbol im Browser-Tab.

View file

@ -1,53 +0,0 @@
---
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
image: /img/logo/vote-card.png
sitemap:
priority: 0.5
m:
short: "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:
short: "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."
show:
intro: "Not sure yet? Here is each mark everywhere it would have to work."
sizes: "Big and small"
sizes_text: "From a poster to the 16-pixel icon in a browser tab. Below 32 pixels both marks switch to a simplified drawing — M drops nothing, R keeps the window, the cross and the hub."
name: "With the name"
name_text: "Side by side and stacked. The wordmark is the same for both, so only the mark decides."
browser: "In the browser"
browser_text: "The tab icon at its real size, and the site header of furtka.org."
nav1: "Docs"
nav2: "Blog"
phone: "On the phone"
phone_text: "Home-screen icon — square as on Android, round as some launchers cut it."
box: "On the box"
box_text: "The text-mode welcome screen your server shows on a monitor, and the boot splash."
shared: "Shared"
shared_text: "The preview card that appears when someone posts a link to furtka.org."
colour: "In colour"
colour_text: "On light, on dark, on its own colour, and in plain text colour — each mark is one shape and takes whatever colour is around it."
final: "Seen enough?"
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. Vote right here — or scroll on first and see both marks everywhere
they would have to work, from a poster to the tiny icon in a browser tab.

View file

@ -1,12 +0,0 @@
{
"display": "<svg class=\"pm\" viewBox=\"0 0 64 64\" aria-hidden=\"true\" focusable=\"false\"><defs><linearGradient id=\"gmd\" x1=\".15\" y1=\"0\" x2=\".7\" y2=\"1\"><stop offset=\"0\" stop-color=\"#d95a45\"/><stop offset=\".55\" stop-color=\"#c03a28\"/><stop offset=\"1\" stop-color=\"#8a2618\"/></linearGradient><clipPath id=\"cmd\" clip-rule=\"evenodd\"><path 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\"/></clipPath></defs><path fill=\"url(#gmd)\" 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\"/><g clip-path=\"url(#cmd)\"><path fill=\"#fff\" opacity=\".14\" d=\"M6 28a26 26 0 0 1 52 0v4.5a26 26 0 0 0-52 0Z\"/></g></svg>",
"flat": "<svg class=\"pm\" 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>",
"min": "<svg class=\"pm\" viewBox=\"0 0 64 64\" aria-hidden=\"true\" focusable=\"false\"><path fill=\"currentColor\" fill-rule=\"evenodd\" d=\"M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z\"/></svg>",
"lockup_h": "<svg class=\"pm\" viewBox=\"0 -4 232.44 64.00\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"scale(1.0000) translate(-6 -2)\"><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\"/></g><g fill=\"currentColor\"><path transform=\"translate(68.24 58.00) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(87.21 58.00) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(121.45 58.00) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(144.17 58.00) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(163.14 58.00) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(195.88 58.00) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"lockup_s": "<svg class=\"pm\" viewBox=\"0 0 164.20 125.89\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"translate(56.10 0) scale(1.0000) translate(-6 -2)\"><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\"/></g><g fill=\"currentColor\"><path transform=\"translate(0.00 111.76) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(18.97 111.76) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(53.21 111.76) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(75.93 111.76) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(94.90 111.76) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(127.64 111.76) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"tab16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABJklEQVR42sWRvUoDURCFv9msf1hoZyFCGmEjWks2iIkSi9gIIZ2FQWx9BX0Gn0GsrUwjCSmMaB1ICBEsxMJGK8F13bHYmFzWJQQi+HX3zsyZc88VIlyvry7YCf8YZQ9huXfdUeRyEu8s03h4MfvFPFTd1K5FcA4yTzyvIrK/edO6+iVQTa9sWxJUgAmG4wXKztZtu94XqGWT0+JNdUCWGI3H9znfKVS6HzaAeDMlUGNY7hUOc41W05yquc6pwAmQnH2zi8CFFZaCvNHn69dnMTocRYU8gN3buGjUnnN33SeAetpxEdkYDGkG/QkvdBwKKJbxH4Gx5Qj0YLDWcKCSMBzEk220y0A5JoM+FmMy1EE9kyqgrMVlMJKAqpaA2Az+7An/LzA232GMWfyHzvuOAAAAAElFTkSuQmCC",
"full16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABJUlEQVR42sWRz0oCYRTFf3dmkDbSbqCwTQQzPUGkgRriE6TQrnqGwKB1u54jwjbtIxzB0hdIC7fppjb92SQ1t0WMfZlOgUFnd7977uGc8wlj0Mh4yYFK0UaWABDt2ujZ2sXN0yhXzKFawnb7fgVlD0iOcB9BDrKXnUOB8JtAtYTt9vxjYIM4qFSzzc5mJGJF727fr/x4/BGnXE8v735x0Mh4yVeVnmH7NlRr6z7VDsonvEXkYNWvIeSAB03YqXxw9ewADFSKlpFZQ9leb7XPY3zM8hIWgFMHwBI89HN7t9Cp0YJ62ttXZMc4nDfK8AEcAFUS5ndEtlWlgLA4vgtmhgKTkGte583Z6GAIiykR6yBI+0fAyvgOfiGAMjexg7+K8P8CU+Md2cpUHzmzh4cAAAAASUVORK5CYII=",
"banner": " ▄▄▄▄▄▄▄▄\n ▄████████████▄\n ▄██████████████████\n ▄█████████▀▀▀▀▀▀▀████▄\n ▄██████▀ █████▄\n ██████▀ ██████\n ██████ █████████████\n ██████ ▀▀▀▀▀▀▀██████\n ██████ ██████\n ██████ ▄▄▄▄▄▄▄██████\n ██████ █████████████\n ██████ █████████████\n ██████ █████████████\n ██████ █████████████\n",
"color": "#c03a28",
"ink": "#0d0d0f"
}

View file

@ -1,12 +0,0 @@
{
"display": "<svg class=\"pm\" viewBox=\"0 0 104 114\" aria-hidden=\"true\" focusable=\"false\"><defs><clipPath id=\"crd\" 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><linearGradient id=\"grd\" gradientUnits=\"userSpaceOnUse\" x1=\"0\" y1=\"2\" x2=\"0\" y2=\"112\"><stop offset=\"0\" stop-color=\"#c9803f\"/><stop offset=\".6\" stop-color=\"#af6428\"/><stop offset=\"1\" stop-color=\"#9a561f\"/></linearGradient></defs><g fill=\"none\" stroke=\"url(#grd)\" 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(#crd)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></svg>",
"flat": "<svg class=\"pm\" viewBox=\"0 0 104 114\" aria-hidden=\"true\" focusable=\"false\"><defs><clipPath id=\"c\" 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(#c)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></svg>",
"min": "<svg class=\"pm\" viewBox=\"0 0 104 114\" aria-hidden=\"true\" focusable=\"false\"><g fill=\"none\" stroke=\"currentColor\" stroke-width=\"11\" stroke-linejoin=\"round\"><path d=\"M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z\"/><path d=\"M52 8V106M8 65H96\"/><path d=\"M52 23H78M52 39H68\" stroke-linecap=\"round\"/></g><circle cx=\"52\" cy=\"65\" r=\"19\" fill=\"currentColor\"/></svg>",
"lockup_h": "<svg class=\"pm\" viewBox=\"0 -4 233.17 64.00\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"scale(0.5273) translate(-2 -2)\"><defs><clipPath id=\"c\" 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(#c)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></g><g fill=\"currentColor\"><path transform=\"translate(68.97 58.00) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(87.94 58.00) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(122.17 58.00) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(144.90 58.00) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(163.87 58.00) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(196.60 58.00) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"lockup_s": "<svg class=\"pm\" viewBox=\"0 0 164.20 125.89\" aria-hidden=\"true\" focusable=\"false\"><g transform=\"translate(55.74 0) scale(0.5273) translate(-2 -2)\"><defs><clipPath id=\"c\" 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(#c)\"/><path d=\"M52 22H79M52 37H69\" stroke-linecap=\"round\"/><circle cx=\"52\" cy=\"65\" r=\"15\"/></g></g><g fill=\"currentColor\"><path transform=\"translate(0.00 111.76) scale(0.05354 -0.05354)\" d=\"M115 0V494H30V600H115V637Q115 687 132.5 724.0Q150 761 192.0 781.5Q234 802 305 802Q333 802 358 799V695Q344 698 324 698Q287 698 273.5 682.0Q260 666 260 632V600H358V494H260V0Z\"/><path transform=\"translate(18.97 111.76) scale(0.05354 -0.05354)\" d=\"M271 -15Q169 -15 119.0 48.5Q69 112 69 218V600H218V244Q218 179 241.5 143.0Q265 107 316 107Q379 107 413.0 156.0Q447 205 447 276V600H596V0H462V90H438Q423 44 382.5 14.5Q342 -15 271 -15Z\"/><path transform=\"translate(53.21 111.76) scale(0.05354 -0.05354)\" d=\"M76 0V600H211V492H235Q241 521 257.0 547.0Q273 573 307.0 589.5Q341 606 399 606H428V471H387Q301 471 263.0 427.0Q225 383 225 294V0Z\"/><path transform=\"translate(75.93 111.76) scale(0.05354 -0.05354)\" d=\"M162 0Q139 27 125.5 65.0Q112 103 112 179V494H30V600H114V750H259V600H358V494H260V196Q260 116 278.0 74.5Q296 33 321 6V0Z\"/><path transform=\"translate(94.90 111.76) scale(0.05354 -0.05354)\" d=\"M78 0V800H226V366H274L449 600H608V594L397 320L632 6V0H457L274 254H226V0Z\"/><path transform=\"translate(127.64 111.76) scale(0.05354 -0.05354)\" d=\"M280 -13Q198 -13 144.0 27.5Q90 68 63.5 138.5Q37 209 37 301Q37 395 64.0 465.0Q91 535 145.0 574.0Q199 613 280 613Q344 613 384.5 587.5Q425 562 445 522H469V600H606V0H469V78H445Q425 41 385.0 14.0Q345 -13 280 -13ZM317 95Q381 95 419.5 151.0Q458 207 458 300Q458 394 419.5 449.5Q381 505 317 505Q257 505 223.5 453.0Q190 401 190 300Q190 199 223.5 147.0Q257 95 317 95Z\"/></g></svg>",
"tab16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAQCAYAAADJViUEAAAB4klEQVR42o2TPWhTYRSGn/Pdm7Qd/NnqpoPYqIvVguAP7SwUmlRrhw5qrx1dHF0c7OoiFElTOyhOpjFrJ6uIgihSKKYloiBCwSJ00bTJva9DbtubEKHveM77nPN+Bz4joYUgk3PGLYl+oCfR+gt8FJrLFVbLO0UDmL9xrPuQ3/3cIGuwLrQko8dhQxF6ZXI10CDQK3jR3dOYuPKouuUADqe68gYjwP3tTe9otrA6jngicRBRk6Jvkp4iKxtc3aqlZgDs5dSp84qi94iH2bnK3Z1IxckTgTM3S6veGXwQ3AEGXKToOlCv400nXV7kfgBImjazXD3tHcgWKhe2w8YDoCFj3JnUB1Ydm1v5nYQjz44DmNk9SQt+PZwCGJuv/gK+msg4sBSm7SQoMCPqjW/6zAsZ2qzXZtg7cw3o8umgUtCXNyyIR02EHn9uFr4vtfusFGQWDU5j9raZl1RkGrGkCYSsjKPenKeLghU/jplGOtd0mmdtGwSG0Y8UxqU0gB+P/pmdrZzZjT158g2mSwn4da7wZXC3fzvzGYHr9GbfC7OCUkwWUy4a7eTrCA/n1zYs4nEM54fzaxv7hvcrKwWZReAsUGw5kunI3sew9TZuFPjkA8vAAHCtZaoMQWjY5f8sXv4HrfW+WZX2x04AAAAASUVORK5CYII=",
"full16": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA8AAAAQCAYAAADJViUEAAAChUlEQVR42nWTT4jUZRjHP993fr/ZSSSKoFu4UDC/TQNLErrUVQndSaigw2o0oyRG7KkgFAKxS9TFIpp1W4I6eHCmdutSHcwgoaK10FndFkwIRNSD5u7On9/77TA7y4T1PT087/u8z/f7fd5HDKH58paHnHT3AtuwNg7yhtvgX9LEM7s+uvTXIB8GQaNaftWF3gLWdqMzwrPAVuFZxA9BeqrXC5ca1Wz/oEYAjVq2D/OeCOOVqQtnAOZeeuz+7obuTQhvS14BIHrE8qTFoT31i5/p5MHNG9NOfkVmb+XEwuzg1VMT2QMqcl1SE+j2+XseqWW7vqFd3JQUO/HNiEuBpDWsX6GUwyo2p2X+7uRh7oWZ81cBmtXs2HKpsyNER7CWovL5Rq38+qA4KRaSNbvGLb84Enpj6wbKP2E9mgQFsH9Emoxm5lQ1e6aU9w6s5Ms7giDEMLV7+sLnAq+zilpFjKy7Xam3vk1D3CpZ7UJ6OYT4LvBHVP5Oszr2Af+BZG2Oo83a2Bu5QdG/Wq6EGHaOn2h9//VrWx7uLPcWm7XsKqgN4OhRwbU1XdwLbOvrCSWItNPiEkAnp2Mh9c/bQ/f7nQW/Veqt/QY1atmk7GfTbvurL2rln2Ontx3II5x+rt56X+DmK9nHAGpWx45iPyglhyPdadDmQs6hPPFOrIOI47a+FP4Q/Gck31dwcgTpWuhTZTTSOy+FO2maPr77k4U5uve8BUC3dHjPVOubEP0EaClQ+B14BCCJuCx4GnmiUl84yf9gfPribeBAo1b+zvCp8Y2koLAYo88B9/3706+UjK4rWZloVLNVhlYM61whaDGB/CzSk8Dzw53c35l5o1130ZBvQTz7D8VtHXvXpMGjAAAAAElFTkSuQmCC",
"banner": " ▄▄▄▄▄▄▄▄▄▄▄▄\n ▄▄████▀▀████▀▀████▄▄\n ▄██▀▀ ██ ▀▀██▄\n ██▀ ██████████ ▀██\n ███ ██ ▀██\n ████▄ ███████ ██\n ██▀▀██▄▄ ██ ██\n ██ ▀▀██▄▄██████▄ ██\n ██ ▀██▀▀ ▀▀██ ██\n ███████████ ███████████\n ██ ▀██▄ ▄██▀ ██\n ██ ▄██████████████▄ ██\n ██ ▄▄██▀▀ ▀██▀ ▀▀██▄▄ ██\n ████▀▀ ██ ▀▀████\n ██▀ ██ ▀██\n ██▄▄▄▄▄▄▄▄▄▄████▄▄▄▄▄▄▄▄▄▄██\n ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀",
"color": "#af6428",
"ink": "#151515"
}

View file

@ -11,13 +11,6 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
SRCROOT="${FURTKA_SRCROOT:-/srv/furtka-site}"
WEBROOT="${FURTKA_WEBROOT:-/var/www/furtka.org}"
# The runner job runs as root inside the container; hand the trees back
# to the VM user no matter how this script ends, so the manual deploy.sh
# path keeps working even after a failed build.
if [ "$(id -u)" = 0 ]; then
trap 'chown -R "${FURTKA_OWNER:-1000:1000}" "$SRCROOT" "$WEBROOT"' EXIT
fi
echo "==> rsync website/ → $SRCROOT"
rsync -az --delete \
--exclude='.hugo_build.lock' \

View file

@ -12,9 +12,6 @@
<span aria-hidden="true"></span>
</a>
</p>
{{ with .Params.poll_link }}
<p class="reveal"><a class="home-poll" href="{{ . }}">{{ partial "mark-m.html" (dict "class" "" "id" "hm") }}{{ partial "mark-r.html" (dict "class" "" "id" "hr") }}<span>{{ $.Params.poll_text }} →</span></a></p>
{{ end }}
</header>
{{ with .Params.intro }}

View file

@ -1,11 +1,7 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<script>document.documentElement.classList.replace('no-js','js');</script>
{{ if eq .Type "poll" }}{{/* Coin-flip which mark sits left/first, so neither side collects the
"first thing on the page" votes. Decided before first paint (no reflow), applied by CSS `order`,
and kept for the tab session so a reload or a vote doesn't shuffle the page under the visitor. */}}
<script>(function(){var k='furtka-logo-order',o=null;try{o=sessionStorage.getItem(k)}catch(e){}if(o!=='mr'&&o!=='rm'){o=Math.random()<0.5?'mr':'rm';try{sessionStorage.setItem(k,o)}catch(e){}}document.documentElement.setAttribute('data-logo-order',o)})();</script>
{{ end }}<title>{{ if .IsHome }}{{ site.Title }} — {{ site.Params.description }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}</title>
<title>{{ if .IsHome }}{{ site.Title }} — {{ site.Params.description }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}</title>
<meta name="description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
<meta name="theme-color" content="#f7f6f3" media="(prefers-color-scheme: light)">
<meta name="theme-color" content="#0d0d0f" media="(prefers-color-scheme: dark)">
@ -15,10 +11,6 @@
<meta property="og:description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
<meta property="og:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
<meta property="og:url" content="{{ .Permalink }}">
{{ with .Params.image }}<meta property="og:image" content="{{ . | absURL }}">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta name="twitter:card" content="summary_large_image">{{ end }}
{{ $parts := split .Site.Language.LanguageCode "-" }}<meta property="og:locale" content="{{ index $parts 0 }}{{ if gt (len $parts) 1 }}_{{ upper (index $parts 1) }}{{ end }}">
{{ range .AllTranslations }}
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}">

View file

@ -1,2 +0,0 @@
{{- /* 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

@ -1,2 +0,0 @@
{{- /* 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

@ -1,168 +0,0 @@
{{ define "main" }}
{{- $p := .Params -}}
{{/* Display order of the two marks; flip via `order: [r, m]` in front matter so neither
side gets the "first thing on the page" advantage for the whole poll. */}}
{{- $order := $p.order | default (slice "m" "r") -}}
{{- $s := $p.show -}}
<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>
{{/* ── Quick vote ─────────────────────────────────────────── */}}
<div class="poll-grid" id="vote">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
{{ $c := index $p $k }}
<section class="poll-card" data-choice="{{ $k }}" aria-labelledby="poll-{{ $k }}-title">
<div class="poll-art poll-art--{{ $k }}">{{ $d.display | safeHTML }}</div>
<h2 id="poll-{{ $k }}-title">{{ $c.title }}</h2>
<p class="poll-desc">{{ $c.text }}</p>
<button type="button" class="poll-btn" data-vote="{{ $k }}">{{ $p.labels.vote }}</button>
<div class="poll-bar" aria-hidden="true"><i></i></div>
<p class="poll-count" data-count="{{ $k }}"></p>
</section>
{{ end }}
</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-change="{{ $p.labels.change }}">{{ $p.labels.nojs }}</p>
{{/* ── Showcase ───────────────────────────────────────────── */}}
<div class="show">
<p class="section-eyebrow show-intro">{{ $s.intro }}</p>
<section class="show-row">
<h2 class="show-h">{{ $s.sizes }}</h2>
<p class="show-p">{{ $s.sizes_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="sizes">
<figure><span class="sz sz-96">{{ $d.flat | safeHTML }}</span><figcaption>96</figcaption></figure>
<figure><span class="sz sz-48">{{ $d.flat | safeHTML }}</span><figcaption>48</figcaption></figure>
<figure><span class="sz sz-32">{{ $d.min | safeHTML }}</span><figcaption>32</figcaption></figure>
<figure><span class="sz sz-24">{{ $d.min | safeHTML }}</span><figcaption>24</figcaption></figure>
<figure><img class="px" src="{{ $d.tab16 | safeURL }}" width="16" height="16" alt=""><figcaption>16</figcaption></figure>
</div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.name }}</h2>
<p class="show-p">{{ $s.name_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="lock lock-h">{{ $d.lockup_h | safeHTML }}</div>
<div class="lock lock-s">{{ $d.lockup_s | safeHTML }}</div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.browser }}</h2>
<p class="show-p">{{ $s.browser_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="mock">
<div class="tabbar"><span class="tab"><img class="px" src="{{ $d.tab16 | safeURL }}" width="16" height="16" alt="">Furtka</span></div>
<div class="urlbar">https://furtka.org</div>
<div class="uihead"><span class="uihead-mark">{{ $d.flat | safeHTML }}</span><span class="uihead-name">Furtka</span><span class="uihead-nav"><span>{{ $s.nav1 }}</span><span>{{ $s.nav2 }}</span></span></div>
<div class="uibody"><i></i><i class="s"></i></div>
</div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.phone }}</h2>
<p class="show-p">{{ $s.phone_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="tiles"><img src="/img/logo/{{ $k }}/tile.png" width="96" height="96" alt=""><img src="/img/logo/{{ $k }}/tile-round.png" width="96" height="96" alt=""></div>
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.box }}</h2>
<p class="show-p">{{ $s.box_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<pre class="term"><i>{{ $d.banner }}</i>
<b>Furtka is ready.</b> Open http://furtka.local</pre>
<img class="shot" src="/img/logo/{{ $k }}/splash.png" width="960" height="540" alt="">
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.shared }}</h2>
<p class="show-p">{{ $s.shared_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<img class="shot" src="/img/logo/{{ $k }}/social.png" width="600" height="315" alt="">
</div>
{{ end }}
</div>
</section>
<section class="show-row">
<h2 class="show-h">{{ $s.colour }}</h2>
<p class="show-p">{{ $s.colour_text }}</p>
<div class="show-pair">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="show-cell show-cell--{{ $k }}">
<span class="show-tag">{{ index $p $k "short" }}</span>
<div class="grounds">
<span class="ground" style="background:#f7f6f3;color:{{ $d.color }}">{{ $d.flat | safeHTML }}</span>
<span class="ground" style="background:{{ $d.ink }};color:{{ $d.color }}">{{ $d.flat | safeHTML }}</span>
<span class="ground" style="background:{{ $d.color }};color:#f7f6f3">{{ $d.flat | safeHTML }}</span>
<span class="ground ground--fg">{{ $d.flat | safeHTML }}</span>
</div>
</div>
{{ end }}
</div>
</section>
</div>
{{/* ── Vote again at the end ─────────────────────────────── */}}
<section class="poll-final">
<h2 class="show-h">{{ $s.final }}</h2>
<div class="poll-final-row">
{{ range $k := $order }}{{ $d := index site.Data.logo $k }}
<div class="poll-final-card" data-choice="{{ $k }}">
<span class="poll-final-mark show-cell--{{ $k }}">{{ $d.flat | safeHTML }}</span>
<button type="button" class="poll-btn" data-vote="{{ $k }}">{{ $p.labels.vote }} — {{ index $p $k "short" }}</button>
<div class="poll-bar" aria-hidden="true"><i></i></div>
<p class="poll-count" data-count="{{ $k }}"></p>
</div>
{{ end }}
</div>
</section>
<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 }}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

View file

@ -1,9 +0,0 @@
# Build-time font
`FamiljenGrotesk-SemiBold.ttf` — Familjen Grotesk, weight 600, fetched from
Google Fonts (fonts.gstatic.com, family `Familjen+Grotesk:wght@600`) on
2026-08-26. Licence: SIL Open Font License 1.1.
It is used **only** by `tools/logo-assets.py` to outline the wordmark into
SVG paths and to render the splash / social-card PNGs. The site itself ships
no webfonts.

View file

@ -1,291 +0,0 @@
#!/usr/bin/env python3
"""Generate the logo-poll showcase assets for /logo/ from one geometry source.
python3 website/tools/logo-assets.py
Writes website/static/img/logo/{m,r}/*.png and website/data/logo/{m,r}.json.
Needs: cairosvg, Pillow, fontTools (pip). Run it on the dev box and commit
the output the site build itself needs nothing beyond Hugo.
Mark M: lowercase f knocked out of a gate, 64-unit grid (even-odd fill).
Mark R: window, wheel and F, 104x114 grid, stroke 8 (from Robert's sketch).
"""
import base64
import io
import json
import os
import sys
import cairosvg
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.ttLib import TTFont
from PIL import Image, ImageDraw
HERE = os.path.dirname(os.path.abspath(__file__))
SITE = os.path.dirname(HERE)
FONT = os.path.join(HERE, "FamiljenGrotesk-SemiBold.ttf")
XMLNS = 'xmlns="http://www.w3.org/2000/svg"'
# ---------------------------------------------------------------- geometry
M_FLAT = (
"M6 60V28a26 26 0 0 1 52 0v32Z M20.0 60V26.5A10.5 10.5 0 0 1 30.5 16H45.0v8.6"
"H32.5a3.5 3.5 0 0 0-3.5 3.5V32h16v8.6H29.0V60Z"
)
M_MIN = (
"M5 60V27a27 27 0 0 1 54 0v33Z M19.5 60V26A10 10 0 0 1 29.5 16H46v10H32"
"a2.5 2.5 0 0 0-2.5 2.5V32H46v10H29.5v18Z"
)
R_FRAME = "M6 108V42A36 36 0 0 1 42 6H62A36 36 0 0 1 98 42V108Z"
R_OUTER = "M2 112V42A40 40 0 0 1 42 2H62A40 40 0 0 1 102 42V112Z"
R_HOLE = "M63 65a11 11 0 1 0-22 0a11 11 0 1 0 22 0Z"
R_SPOKES = "M52 8V106 M8 65H96 M52 65L4 31.1 M52 65L4 98.9 M52 65L100 98.9"
R_ARMS = "M52 22H79M52 37H69"
MARKS = {
"m": {
"vb": (0, 0, 64, 64),
"box": (6, 2, 52, 58), # x, y, w, h of the drawn mark inside the viewBox
"color": "#c03a28",
"ink": "#0d0d0f",
"grad": ("#d95a45", "#c03a28", "#8a2618"),
},
"r": {
"vb": (0, 0, 104, 114),
"box": (2, 2, 100, 110),
"color": "#af6428",
"ink": "#151515",
"grad": ("#c9803f", "#af6428", "#9a561f"),
},
}
def body(k, tier, color="currentColor", uid=""):
"""SVG inner markup for mark k at tier display | flat | min."""
if k == "m":
if tier == "display":
g = MARKS["m"]["grad"]
return (
f'<defs><linearGradient id="g{uid}" x1=".15" y1="0" x2=".7" y2="1">'
f'<stop offset="0" stop-color="{g[0]}"/><stop offset=".55" stop-color="{g[1]}"/>'
f'<stop offset="1" stop-color="{g[2]}"/></linearGradient>'
f'<clipPath id="c{uid}" clip-rule="evenodd"><path d="{M_FLAT}"/></clipPath></defs>'
f'<path fill="url(#g{uid})" fill-rule="evenodd" d="{M_FLAT}"/>'
f'<g clip-path="url(#c{uid})"><path fill="#fff" opacity=".14" '
f'd="M6 28a26 26 0 0 1 52 0v4.5a26 26 0 0 0-52 0Z"/></g>'
)
path = M_MIN if tier == "min" else M_FLAT
return f'<path fill="{color}" fill-rule="evenodd" d="{path}"/>'
if tier == "min":
return (
f'<g fill="none" stroke="{color}" stroke-width="11" stroke-linejoin="round">'
f'<path d="{R_FRAME}"/><path d="M52 8V106M8 65H96"/>'
f'<path d="M52 23H78M52 39H68" stroke-linecap="round"/></g>'
f'<circle cx="52" cy="65" r="19" fill="{color}"/>'
)
defs = (
f'<clipPath id="c{uid}" clip-rule="evenodd"><path clip-rule="evenodd" '
f'd="{R_OUTER} {R_HOLE}"/></clipPath>'
)
if tier == "display":
g = MARKS["r"]["grad"]
defs += (
f'<linearGradient id="g{uid}" gradientUnits="userSpaceOnUse" x1="0" y1="2" x2="0" y2="112">'
f'<stop offset="0" stop-color="{g[0]}"/><stop offset=".6" stop-color="{g[1]}"/>'
f'<stop offset="1" stop-color="{g[2]}"/></linearGradient>'
)
color = f"url(#g{uid})"
return (
f"<defs>{defs}</defs>"
f'<g fill="none" stroke="{color}" stroke-width="8" stroke-linejoin="round">'
f'<path d="{R_FRAME}"/><path d="{R_SPOKES}" clip-path="url(#c{uid})"/>'
f'<path d="{R_ARMS}" stroke-linecap="round"/><circle cx="52" cy="65" r="15"/></g>'
)
def svg(k, tier, color="currentColor", uid="", cls="pm", standalone=False):
x, y, w, h = MARKS[k]["vb"]
ns = f" {XMLNS}" if standalone else ""
return (
f'<svg class="{cls}"{ns} viewBox="{x} {y} {w} {h}" aria-hidden="true" focusable="false">'
f"{body(k, tier, color, uid)}</svg>"
)
# ---------------------------------------------------------------- raster
def raster(svgtxt, w, h):
png = cairosvg.svg2png(bytestring=svgtxt.encode(), output_width=w, output_height=h)
return Image.open(io.BytesIO(png)).convert("RGBA")
def mark_png(k, tier, height, color=None):
_, _, w, h = MARKS[k]["vb"]
return raster(svg(k, tier, color or MARKS[k]["color"], standalone=True), round(height * w / h), height)
# ---------------------------------------------------------------- wordmark
_font = TTFont(FONT)
_upm = _font["head"].unitsPerEm
_cap = _font["OS/2"].sCapHeight or int(_upm * 0.7)
_glyphs = _font.getGlyphSet()
_cmap = _font.getBestCmap()
def wordmark(text, size, x0=0.0, baseline=0.0, tracking=-0.028):
"""Outline text as one SVG path; returns (path_d, advance_width)."""
s = size / _upm
parts, x = [], x0
for ch in text:
name = _cmap[ord(ch)]
pen = SVGPathPen(_glyphs)
g = _glyphs[name]
g.draw(pen)
d = pen.getCommands()
if d:
parts.append(f'<path transform="translate({x:.2f} {baseline:.2f}) scale({s:.5f} {-s:.5f})" d="{d}"/>')
x += g.width * s + tracking * size
return "".join(parts), x - x0 - tracking * size
def lockup(k, stacked=False):
"""Mark + 'furtka', outlined, currentColor. Returns an inline SVG string."""
bx, by, bw, bh = MARKS[k]["box"]
H = 58.0 # mark height in lockup units
sc = H / bh
mw = bw * sc
size = H * 0.72 * _upm / _cap # cap height = 72 % of the mark height
if stacked:
paths, tw = wordmark("furtka", size)
gap = 12
total_w = max(mw, tw)
total_h = H + gap + size * _cap / _upm + size * 0.22
mx = (total_w - mw) / 2
tx = (total_w - tw) / 2
base = H + gap + size * _cap / _upm
paths, _ = wordmark("furtka", size, x0=tx, baseline=base)
inner = (
f'<g transform="translate({mx:.2f} 0) scale({sc:.4f}) translate({-bx} {-by})">{body(k, "flat")}</g>'
f'<g fill="currentColor">{paths}</g>'
)
return f'<svg class="pm" viewBox="0 0 {total_w:.2f} {total_h:.2f}" aria-hidden="true" focusable="false">{inner}</svg>'
gap = H * 0.28
paths, tw = wordmark("furtka", size, x0=mw + gap, baseline=H)
total_w = mw + gap + tw
inner = (
f'<g transform="scale({sc:.4f}) translate({-bx} {-by})">{body(k, "flat")}</g>'
f'<g fill="currentColor">{paths}</g>'
)
return f'<svg class="pm" viewBox="0 -4 {total_w:.2f} {H + 6:.2f}" aria-hidden="true" focusable="false">{inner}</svg>'
def lockup_png(k, W, Hpx, stacked, mark_h):
"""Rasterise the lockup on the mark's ink ground (splash / social card)."""
ink = MARKS[k]["ink"]
col = MARKS[k]["color"]
inner = lockup(k, stacked).replace("currentColor", col)
inner = inner.replace('<svg class="pm"', f"<svg {XMLNS}")
vb = inner.split('viewBox="')[1].split('"')[0].split()
vw, vh = float(vb[2]), float(vb[3])
scale = mark_h / 58.0
w, h = round(vw * scale), round(vh * scale)
im = Image.new("RGBA", (W, Hpx), ink)
im.alpha_composite(raster(inner, w, h), ((W - w) // 2, (Hpx - h) // 2))
return im
# ---------------------------------------------------------------- tiles etc.
def tile(k, size, pad=0.18, radius=None):
im = Image.new("RGBA", (size, size), MARKS[k]["ink"])
m = mark_png(k, "flat", int(size * (1 - 2 * pad)))
im.alpha_composite(m, ((size - m.width) // 2, (size - m.height) // 2))
if radius:
mask = Image.new("L", (size, size), 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, size - 1, size - 1], radius, fill=255)
im.putalpha(mask)
return im
def banner(k, cols=30):
"""Half-block console art (~30 columns) from the interface tier.
Sampled by coverage: the mark is rendered 4x and box-averaged down to
one sample per half-character, then thresholded at 50 %.
"""
_, _, w, h = MARKS[k]["vb"]
rows = round(cols * h / w)
rows += rows % 2
a = raster(svg(k, "flat", "#000", standalone=True), cols * 4, rows * 4).split()[3]
a = a.resize((cols, rows), Image.BOX)
lines = []
for y in range(0, rows, 2):
s = ""
for x in range(cols):
t = a.getpixel((x, y)) > 128
b = a.getpixel((x, y + 1)) > 128
s += "" if t and b else "" if t else "" if b else " "
lines.append(s.rstrip())
return "\n".join(lines)
def b64png(im):
buf = io.BytesIO()
im.save(buf, "PNG", optimize=True)
return "data:image/png;base64," + base64.b64encode(buf.getvalue()).decode()
def vote_card(path):
"""1200x630 share card for /logo/: M on cream, R on ink, side by side."""
W, H = 1200, 630
im = Image.new("RGBA", (W, H), "#f7f6f3")
ImageDraw.Draw(im).rectangle([W // 2, 0, W, H], fill=MARKS["r"]["ink"])
for k, cx in (("m", W // 4), ("r", 3 * W // 4)):
m = mark_png(k, "flat", 330)
im.alpha_composite(m, (cx - m.width // 2, 110 - (m.height - 330) // 2))
size = 46
d = ImageDraw.Draw(im)
for text, cx, col in (("M", W // 4, "#6b6b6f"), ("R", 3 * W // 4, "#8a8a90")):
paths, tw = wordmark(text, size)
# letters via the same outlines: rasterise a tiny SVG for crisp text
svgtxt = (f'<svg {XMLNS} viewBox="0 -{size} {tw:.0f} {size * 1.3:.0f}">'
f'<g fill="{col}">{paths}</g></svg>')
t = raster(svgtxt, round(tw), round(size * 1.3))
im.alpha_composite(t, (cx - t.width // 2, 500))
# url line, centred on the seam
paths, tw = wordmark("furtka.org/logo", 40)
svgtxt = (f'<svg {XMLNS} viewBox="0 -40 {tw:.0f} 52"><g fill="#c03a28">{paths}</g></svg>')
t = raster(svgtxt, round(tw), 52)
pad = 18
box = Image.new("RGBA", (t.width + 2 * pad, t.height + 2 * pad), "#f7f6f3")
ImageDraw.Draw(box).rounded_rectangle([0, 0, box.width - 1, box.height - 1], 14, outline="#e4e3dc")
box.alpha_composite(t, (pad, pad))
im.alpha_composite(box, (W // 2 - box.width // 2, 40))
im.convert("RGB").save(path, optimize=True)
def main():
vote_card(os.path.join(SITE, "static", "img", "logo", "vote-card.png"))
for k in MARKS:
out = os.path.join(SITE, "static", "img", "logo", k)
os.makedirs(out, exist_ok=True)
tile(k, 192, radius=42).save(os.path.join(out, "tile.png"), optimize=True)
tile(k, 192, radius=96).save(os.path.join(out, "tile-round.png"), optimize=True)
lockup_png(k, 960, 540, True, 190).save(os.path.join(out, "splash.png"), optimize=True)
lockup_png(k, 600, 315, False, 130).save(os.path.join(out, "social.png"), optimize=True)
data = {
"display": svg(k, "display", uid=f"{k}d"),
"flat": svg(k, "flat"),
"min": svg(k, "min"),
"lockup_h": lockup(k),
"lockup_s": lockup(k, stacked=True),
"tab16": b64png(mark_png(k, "min", 16)),
"full16": b64png(mark_png(k, "flat", 16)),
"banner": banner(k),
"color": MARKS[k]["color"],
"ink": MARKS[k]["ink"],
}
with open(os.path.join(SITE, "data", "logo", f"{k}.json"), "w") as f:
json.dump(data, f, indent=1, ensure_ascii=False)
print(k, "ok")
if __name__ == "__main__":
sys.exit(main())