Compare commits
9 commits
26.20-alph
...
HEAD
| Author | SHA1 | Date | |
|---|---|---|---|
| 722dac4843 | |||
| c3c59b4bea | |||
| 58a99a0d9e | |||
| 22ce03d1f0 | |||
| 5d827e5eab | |||
| aaba14731c | |||
| 9fd9d1b7d1 | |||
| 2fbafd5c77 | |||
| 8b07b57242 |
|
|
@ -12,6 +12,7 @@ on:
|
||||||
- '**/*.md'
|
- '**/*.md'
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'website/**'
|
- 'website/**'
|
||||||
|
- 'ops/**'
|
||||||
- 'CHANGELOG.md'
|
- 'CHANGELOG.md'
|
||||||
- 'RELEASING.md'
|
- 'RELEASING.md'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
|
||||||
12
CHANGELOG.md
|
|
@ -7,6 +7,18 @@ This project uses calendar versioning: `YY.N-stage` (e.g. `26.0-alpha` = 2026, r
|
||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [26.20-alpha] - 2026-08-25
|
||||||
|
|
||||||
First release shaped by a real-hardware install (old laptop mainboard, BIOS boot,
|
First release shaped by a real-hardware install (old laptop mainboard, BIOS boot,
|
||||||
|
|
|
||||||
|
|
@ -102,6 +102,7 @@ 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] Installer webapp prototype — device reader + form → JSON (Robert)
|
||||||
- [x] Arch running on Proxmox, custom image builds in progress (Robert)
|
- [x] Arch running on Proxmox, custom image builds in progress (Robert)
|
||||||
- [x] Competitor analysis — see [docs/competitors.md](docs/competitors.md)
|
- [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] 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] 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/)
|
- [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/)
|
||||||
|
|
|
||||||
75
docs/hardware-test-2026-08.md
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
# 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 1–3
|
||||||
|
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).
|
||||||
|
|
@ -226,12 +226,17 @@ def _link_new_units(unit_dir: Path) -> list[str]:
|
||||||
"""`systemctl link` any unit file in unit_dir that isn't already symlinked
|
"""`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.
|
into /etc/systemd/system/. Returns the list of newly-linked unit names.
|
||||||
|
|
||||||
Newly-linked `.timer` units are additionally `systemctl enable`d so that
|
Newly-linked `.timer` units are additionally `systemctl enable`d *and
|
||||||
a self-update introducing a timer (e.g. 26.5 → 26.6 adding
|
started* so that a self-update introducing a timer (e.g. 26.5 → 26.6
|
||||||
furtka-catalog-sync.timer) activates it automatically — the installer's
|
adding furtka-catalog-sync.timer, 26.19 → 26.20 adding
|
||||||
enable list only applies to fresh installs. A linked-but-disabled timer
|
furtka-welcome.timer) activates it right away — the installer's enable
|
||||||
never fires on its own, so without this step catalog sync would never
|
list only applies to fresh installs, and `enable` alone only takes
|
||||||
happen on upgraded boxes.
|
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.
|
||||||
"""
|
"""
|
||||||
if not unit_dir.is_dir():
|
if not unit_dir.is_dir():
|
||||||
return []
|
return []
|
||||||
|
|
@ -242,9 +247,10 @@ def _link_new_units(unit_dir: Path) -> list[str]:
|
||||||
target = _SYSTEMD_DIR / unit_file.name
|
target = _SYSTEMD_DIR / unit_file.name
|
||||||
if target.exists() or target.is_symlink():
|
if target.exists() or target.is_symlink():
|
||||||
continue
|
continue
|
||||||
_run(["systemctl", "link", str(unit_file)])
|
_run(["systemctl", "link", str(current_symlink() / "assets" / "systemd" / unit_file.name)])
|
||||||
if unit_file.suffix == ".timer":
|
if unit_file.suffix == ".timer":
|
||||||
_run(["systemctl", "enable", unit_file.name])
|
_run(["systemctl", "enable", unit_file.name])
|
||||||
|
_run(["systemctl", "start", unit_file.name])
|
||||||
linked.append(unit_file.name)
|
linked.append(unit_file.name)
|
||||||
return linked
|
return linked
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,27 @@ 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)
|
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
|
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
|
## 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:
|
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:
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,22 @@ 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/ {
|
||||||
|
# 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 {
|
location = /favicon.svg {
|
||||||
access_log off;
|
access_log off;
|
||||||
log_not_found off;
|
log_not_found off;
|
||||||
|
|
|
||||||
2
ops/nginx/poll-ratelimit.conf
Normal 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;
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
17
ops/poll/furtka-poll.service
Normal 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
|
||||||
172
ops/poll/pollsvc.py
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
#!/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()
|
||||||
26
ops/poll/setup-poll.sh
Executable file
|
|
@ -0,0 +1,26 @@
|
||||||
|
#!/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"
|
||||||
|
|
@ -316,14 +316,17 @@ def test_link_new_units_only_links_missing(updater, tmp_path, monkeypatch):
|
||||||
|
|
||||||
linked = updater._link_new_units(unit_dir)
|
linked = updater._link_new_units(unit_dir)
|
||||||
assert linked == ["furtka-bar.timer"]
|
assert linked == ["furtka-bar.timer"]
|
||||||
# Two calls for the newly-linked timer: systemctl link + systemctl enable.
|
# Three calls for the newly-linked timer: link + enable + start. The
|
||||||
# The already-linked service is untouched. Timers need the follow-up
|
# already-linked service is untouched. `enable` alone only arms the
|
||||||
# `enable` so self-updates that introduce new timers don't leave them
|
# timer for the next boot; `start` makes it fire on the running box
|
||||||
# dormant — fresh installs get their enable via the webinstaller.
|
# (26.20 left furtka-welcome.timer dead until reboot without it).
|
||||||
assert len(seen) == 2
|
assert len(seen) == 3
|
||||||
assert seen[0][:2] == ["systemctl", "link"]
|
assert seen[0][:2] == ["systemctl", "link"]
|
||||||
assert seen[0][2].endswith("furtka-bar.timer")
|
# 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[1] == ["systemctl", "enable", "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):
|
def test_extract_tarball_uses_data_filter_when_available(tmp_path, updater, monkeypatch):
|
||||||
|
|
|
||||||
|
|
@ -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,18 @@ 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
|
||||||
|
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
|
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
|
||||||
|
|
|
||||||
|
|
@ -439,3 +439,132 @@ 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;
|
||||||
|
}
|
||||||
|
.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; } }
|
||||||
|
|
|
||||||
59
website/assets/js/poll.js
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
// 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();
|
||||||
|
})();
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
title: "Furtka"
|
title: "Furtka"
|
||||||
description: "Offenes Heimserver-Betriebssystem — einfach genug für alle."
|
description: "Offenes Heimserver-Betriebssystem — einfach genug für alle."
|
||||||
status: "<span class=\"mono\">26.20-alpha</span> — in Arbeit"
|
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.
|
# features_today / features_next müssen index-parallel zu content/_index.md bleiben.
|
||||||
intro: |
|
intro: |
|
||||||
**Furtka** ist ein offenes Heimserver-Betriebssystem.
|
**Furtka** ist ein offenes Heimserver-Betriebssystem.
|
||||||
|
|
@ -19,11 +21,15 @@ features_today:
|
||||||
- "Apps mit einem Klick installieren und entfernen"
|
- "Apps mit einem Klick installieren und entfernen"
|
||||||
- "Eine installierte App mit einem Klick aktualisieren (holt das neueste Container-Image)"
|
- "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"
|
- "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_label: "Was als Nächstes kommt"
|
||||||
features_next:
|
features_next:
|
||||||
- "Apps für Fotos, Dateien, Smarthome, Spiele-Streaming und Medien"
|
- "Apps für Fotos, Dateien, Smarthome, Spiele-Streaming und Medien"
|
||||||
- "Einfachere Sprache im Einrichtungs-Assistenten"
|
- "Einfachere Sprache im Einrichtungs-Assistenten"
|
||||||
- "Sichere Verbindung im Heimnetz (ohne Warnmeldung im Browser)"
|
- "Den Server auch von unterwegs erreichen"
|
||||||
- "Mehrere Server zusammenschalten"
|
- "Mehrere Server zusammenschalten"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
title: "Furtka"
|
title: "Furtka"
|
||||||
description: "Open-source home server OS — simple enough for everyone."
|
description: "Open-source home server OS — simple enough for everyone."
|
||||||
status: "<span class=\"mono\">26.20-alpha</span> — work in progress"
|
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.
|
# Keep features_today / features_next index-aligned with content/_index.de.md.
|
||||||
intro: |
|
intro: |
|
||||||
**Furtka** is an open-source home server OS.
|
**Furtka** is an open-source home server OS.
|
||||||
|
|
@ -19,11 +21,15 @@ features_today:
|
||||||
- "Install and remove apps with one click"
|
- "Install and remove apps with one click"
|
||||||
- "Update an installed app with one click (pulls the newest container image)"
|
- "Update an installed app with one click (pulls the newest container image)"
|
||||||
- "Update Furtka itself with one click — no reinstalling for new features"
|
- "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_label: "What's coming next"
|
||||||
features_next:
|
features_next:
|
||||||
- "Apps for photos, files, smart home, game streaming and media"
|
- "Apps for photos, files, smart home, game streaming and media"
|
||||||
- "Plainer language in the setup wizard"
|
- "Plainer language in the setup wizard"
|
||||||
- "Secure connection on your home network (no browser warning)"
|
- "Reaching your server from outside the house"
|
||||||
- "Linking several servers together"
|
- "Linking several servers together"
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
53
website/content/logo.de.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
53
website/content/logo.md
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
---
|
||||||
|
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.
|
||||||
12
website/data/logo/m.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
12
website/data/logo/r.json
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
|
@ -11,6 +11,13 @@ HERE="$(cd "$(dirname "$0")" && pwd)"
|
||||||
SRCROOT="${FURTKA_SRCROOT:-/srv/furtka-site}"
|
SRCROOT="${FURTKA_SRCROOT:-/srv/furtka-site}"
|
||||||
WEBROOT="${FURTKA_WEBROOT:-/var/www/furtka.org}"
|
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"
|
echo "==> rsync website/ → $SRCROOT"
|
||||||
rsync -az --delete \
|
rsync -az --delete \
|
||||||
--exclude='.hugo_build.lock' \
|
--exclude='.hugo_build.lock' \
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@
|
||||||
<span aria-hidden="true">→</span>
|
<span aria-hidden="true">→</span>
|
||||||
</a>
|
</a>
|
||||||
</p>
|
</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>
|
</header>
|
||||||
|
|
||||||
{{ with .Params.intro }}
|
{{ with .Params.intro }}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<script>document.documentElement.classList.replace('no-js','js');</script>
|
<script>document.documentElement.classList.replace('no-js','js');</script>
|
||||||
<title>{{ if .IsHome }}{{ site.Title }} — {{ site.Params.description }}{{ else }}{{ .Title }} · {{ site.Title }}{{ end }}</title>
|
{{ 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>
|
||||||
<meta name="description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
|
<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="#f7f6f3" media="(prefers-color-scheme: light)">
|
||||||
<meta name="theme-color" content="#0d0d0f" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#0d0d0f" media="(prefers-color-scheme: dark)">
|
||||||
|
|
@ -11,6 +15,10 @@
|
||||||
<meta property="og:description" content="{{ with .Params.description }}{{ . }}{{ else }}{{ site.Params.description }}{{ end }}">
|
<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:type" content="{{ if .IsPage }}article{{ else }}website{{ end }}">
|
||||||
<meta property="og:url" content="{{ .Permalink }}">
|
<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 }}">
|
{{ $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 }}
|
{{ range .AllTranslations }}
|
||||||
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}">
|
<link rel="alternate" hreflang="{{ .Lang }}" href="{{ .Permalink }}">
|
||||||
|
|
|
||||||
2
website/layouts/partials/mark-m.html
Normal 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>
|
||||||
2
website/layouts/partials/mark-r.html
Normal 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>
|
||||||
168
website/layouts/poll/single.html
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
{{ 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 }}
|
||||||
BIN
website/static/img/logo/m/social.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
website/static/img/logo/m/splash.png
Normal file
|
After Width: | Height: | Size: 13 KiB |
BIN
website/static/img/logo/m/tile-round.png
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
website/static/img/logo/m/tile.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
website/static/img/logo/r/social.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
website/static/img/logo/r/splash.png
Normal file
|
After Width: | Height: | Size: 18 KiB |
BIN
website/static/img/logo/r/tile-round.png
Normal file
|
After Width: | Height: | Size: 5.5 KiB |
BIN
website/static/img/logo/r/tile.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
website/static/img/logo/vote-card.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
9
website/tools/FONT-PROVENANCE.md
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
# 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.
|
||||||
BIN
website/tools/FamiljenGrotesk-SemiBold.ttf
Normal file
291
website/tools/logo-assets.py
Normal file
|
|
@ -0,0 +1,291 @@
|
||||||
|
#!/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())
|
||||||