initial commit

This commit is contained in:
ryan
2026-08-15 11:15:31 +02:00
commit b05c7310a6
231 changed files with 5311 additions and 0 deletions
+2
View File
@@ -0,0 +1,2 @@
"""Sticknife registration app."""
+6
View File
@@ -0,0 +1,6 @@
from .server import main
if __name__ == "__main__":
main()
+37
View File
@@ -0,0 +1,37 @@
from __future__ import annotations
import subprocess
from dataclasses import dataclass
from .config import Config
@dataclass
class AuthentikSyncResult:
ok: bool
message: str
def trigger_ldap_sync(config: Config) -> AuthentikSyncResult:
if not config.authentik_sync_enabled:
return AuthentikSyncResult(True, "authentik sync disabled")
if not config.authentik_ldap_source_slugs:
return AuthentikSyncResult(False, "authentik sync enabled but AUTHENTIK_LDAP_SOURCE_SLUGS is empty")
cmd = [
"docker",
"exec",
"-d",
config.authentik_sync_container,
"ak",
"ldap_sync",
*config.authentik_ldap_source_slugs,
]
try:
subprocess.run(cmd, check=True, text=True, capture_output=True, timeout=10)
except subprocess.TimeoutExpired:
return AuthentikSyncResult(False, "authentik LDAP sync trigger timed out")
except subprocess.CalledProcessError as exc:
detail = exc.stderr.strip() or exc.stdout.strip() or str(exc)
return AuthentikSyncResult(False, f"authentik LDAP sync trigger failed: {detail}")
return AuthentikSyncResult(True, f"triggered authentik LDAP sync for {', '.join(config.authentik_ldap_source_slugs)}")
+120
View File
@@ -0,0 +1,120 @@
from __future__ import annotations
import os
import secrets
from dataclasses import dataclass
from pathlib import Path
def load_dotenv(path: str = ".env") -> None:
env_path = Path(path)
if not env_path.exists():
return
for raw_line in env_path.read_text().splitlines():
line = raw_line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, value = line.split("=", 1)
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def _bool(name: str, default: bool = False) -> bool:
value = os.environ.get(name)
if value is None:
return default
return value.lower() in {"1", "true", "yes", "on"}
def _csv(name: str) -> set[str]:
return {
item.strip().lower()
for item in os.environ.get(name, "").split(",")
if item.strip()
}
#: A "home" deployment serves the public landing page and nothing else: no database, no session,
#: no OIDC. Everything that needs an identity lives on the "full" deployment at auth_base_url.
ROLES = {"full", "home"}
@dataclass(frozen=True)
class Config:
role: str
auth_base_url: str
base_url: str
host: str
port: int
secret: str
cookie_secure: bool
database_url: str
trusted_domains: set[str]
default_ipa_group: str
app_admin_emails: set[str]
admin_groups: set[str]
oidc_issuer: str
oidc_client_id: str
oidc_client_secret: str
oidc_scopes: str
smtp_host: str
smtp_port: int
smtp_username: str
smtp_password: str
smtp_from: str
smtp_starttls: bool
smtp_ssl: bool
ipa_mode: str
ipa_principal: str
ipa_keytab: str
ipa_default_shell: str
ipa_home_root: str
authentik_sync_enabled: bool
authentik_sync_container: str
authentik_ldap_source_slugs: tuple[str, ...]
def get_config() -> Config:
load_dotenv()
role = os.environ.get("APP_ROLE", "full").strip().lower()
if role not in ROLES:
raise SystemExit(f"APP_ROLE must be one of {sorted(ROLES)}, got {role!r}")
base_url = os.environ.get("APP_BASE_URL", "http://127.0.0.1:8080").rstrip("/")
database_url = os.environ.get("DATABASE_URL", "")
if role != "home" and not database_url:
raise SystemExit("DATABASE_URL is required unless APP_ROLE=home")
return Config(
role=role,
# Where the pages this deployment does not serve actually live. A "full" deployment serves
# them itself, so its own base URL is the right answer.
auth_base_url=os.environ.get("AUTH_BASE_URL", base_url).rstrip("/"),
base_url=base_url,
host=os.environ.get("APP_HOST", "127.0.0.1"),
port=int(os.environ.get("APP_PORT", "8080")),
secret=os.environ.get("APP_SECRET", secrets.token_hex(32)),
cookie_secure=_bool("COOKIE_SECURE", False),
database_url=database_url,
trusted_domains=_csv("TRUSTED_EMAIL_DOMAINS"),
default_ipa_group=os.environ.get("DEFAULT_IPA_GROUP", "sticknife_users"),
app_admin_emails=_csv("APP_ADMIN_EMAILS"),
admin_groups=_csv("APP_ADMIN_GROUPS") or {"sticknife_admins"},
oidc_issuer=os.environ.get("OIDC_ISSUER", "https://auth.sticknife.com/application/o/charon/").rstrip("/"),
oidc_client_id=os.environ.get("OIDC_CLIENT_ID", ""),
oidc_client_secret=os.environ.get("OIDC_CLIENT_SECRET", ""),
oidc_scopes=os.environ.get("OIDC_SCOPES", "openid profile email groups"),
smtp_host=os.environ.get("SMTP_HOST", "mail.sticknife.com"),
smtp_port=int(os.environ.get("SMTP_PORT", "587")),
smtp_username=os.environ.get("SMTP_USERNAME", ""),
smtp_password=os.environ.get("SMTP_PASSWORD", ""),
smtp_from=os.environ.get("SMTP_FROM", "accounts@sticknife.com"),
smtp_starttls=_bool("SMTP_STARTTLS", True),
smtp_ssl=_bool("SMTP_SSL", False),
ipa_mode=os.environ.get("STICKNIFE_IPA_PROVISION_MODE", "dry-run"),
ipa_principal=os.environ.get("IPA_PRINCIPAL", ""),
ipa_keytab=os.environ.get("IPA_KEYTAB", ""),
ipa_default_shell=os.environ.get("IPA_DEFAULT_SHELL", "/bin/bash"),
ipa_home_root=os.environ.get("IPA_HOME_ROOT", "/home"),
authentik_sync_enabled=_bool("AUTHENTIK_SYNC_ENABLED", False),
authentik_sync_container=os.environ.get("AUTHENTIK_SYNC_CONTAINER", "authentik-worker-1"),
authentik_ldap_source_slugs=tuple(sorted(_csv("AUTHENTIK_LDAP_SOURCE_SLUGS"))),
)
+67
View File
@@ -0,0 +1,67 @@
from __future__ import annotations
import subprocess
from dataclasses import dataclass
def sql_literal(value: object) -> str:
if value is None:
return "NULL"
if isinstance(value, bool):
return "TRUE" if value else "FALSE"
text = str(value)
return "'" + text.replace("'", "''") + "'"
@dataclass
class Database:
url: str
def execute(self, sql: str) -> None:
subprocess.run(
["psql", self.url, "-X", "-v", "ON_ERROR_STOP=1", "-q"],
input=sql,
text=True,
check=True,
)
def rows(self, sql: str) -> list[dict[str, str | None]]:
result = subprocess.run(
[
"psql",
self.url,
"-X",
"-v",
"ON_ERROR_STOP=1",
"-q",
"-P",
"footer=off",
"-A",
"-F",
"\t",
"-c",
sql,
],
text=True,
check=True,
capture_output=True,
)
lines = [line for line in result.stdout.splitlines() if line.strip()]
if not lines:
return []
headers = lines[0].split("\t")
out: list[dict[str, str | None]] = []
for line in lines[1:]:
values = line.split("\t")
out.append(
{
key: None if idx >= len(values) or values[idx] == "" else values[idx]
for idx, key in enumerate(headers)
}
)
return out
def one(self, sql: str) -> dict[str, str | None] | None:
rows = self.rows(sql)
return rows[0] if rows else None
+181
View File
@@ -0,0 +1,181 @@
from __future__ import annotations
import os
import subprocess
import tempfile
from dataclasses import dataclass
from .config import Config
@dataclass
class IpaResult:
ok: bool
message: str
def kinit(config: Config) -> None:
if config.ipa_principal and config.ipa_keytab:
subprocess.run(
["kinit", "-kt", config.ipa_keytab, config.ipa_principal],
check=True,
text=True,
capture_output=True,
)
def create_user(config: Config, user: dict[str, str | None]) -> IpaResult:
username = user["username"] or ""
email = user["email"] or ""
full_name = user["full_name"] or username
parts = full_name.split(" ", 1)
first_name = parts[0]
last_name = parts[1] if len(parts) > 1 else username
if config.ipa_mode != "cli":
return IpaResult(True, f"dry-run: would create IPA user {username} <{email}>")
kinit(config)
add_cmd = [
"ipa",
"user-add",
username,
"--first",
first_name,
"--last",
last_name,
"--cn",
full_name,
"--email",
email,
"--shell",
config.ipa_default_shell,
"--homedir",
f"{config.ipa_home_root.rstrip('/')}/{username}",
"--random",
"--user-auth-type",
"password",
]
try:
subprocess.run(add_cmd, check=True, text=True, capture_output=True)
except subprocess.CalledProcessError as exc:
return IpaResult(False, exc.stderr.strip() or exc.stdout.strip() or str(exc))
return IpaResult(True, f"created IPA user {username}")
def add_default_group(config: Config, username: str) -> IpaResult:
if config.ipa_mode != "cli":
return IpaResult(True, f"dry-run: would add {username} to {config.default_ipa_group}")
try:
kinit(config)
subprocess.run(
["ipa", "group-add-member", config.default_ipa_group, "--users", username],
check=True,
text=True,
capture_output=True,
)
except subprocess.CalledProcessError as exc:
return IpaResult(False, exc.stderr.strip() or exc.stdout.strip() or str(exc))
return IpaResult(True, f"added {username} to {config.default_ipa_group}")
def set_user_enabled(config: Config, username: str, enabled: bool) -> IpaResult:
action = "enable" if enabled else "disable"
if config.ipa_mode != "cli":
return IpaResult(True, f"dry-run: would {action} IPA user {username}")
try:
kinit(config)
subprocess.run(
["ipa", f"user-{action}", username],
check=True,
text=True,
capture_output=True,
)
except subprocess.CalledProcessError as exc:
return IpaResult(False, exc.stderr.strip() or exc.stdout.strip() or str(exc))
return IpaResult(True, f"{action}d IPA user {username}")
def provision_user(config: Config, user: dict[str, str | None], *, active: bool = True) -> IpaResult:
username = user["username"] or ""
created = create_user(config, user)
if not created.ok:
return created
messages = [created.message]
if active:
grouped = add_default_group(config, username)
messages.append(grouped.message)
if not grouped.ok:
return IpaResult(False, "; ".join(messages))
else:
disabled = set_user_enabled(config, username, False)
messages.append(disabled.message)
if not disabled.ok:
return IpaResult(False, "; ".join(messages))
return IpaResult(True, "; ".join(messages))
def approve_user(config: Config, username: str) -> IpaResult:
enabled = set_user_enabled(config, username, True)
if not enabled.ok:
return enabled
grouped = add_default_group(config, username)
if not grouped.ok:
return IpaResult(False, f"{enabled.message}; {grouped.message}")
return IpaResult(True, f"{enabled.message}; {grouped.message}")
def set_password(config: Config, username: str, password: str) -> IpaResult:
if config.ipa_mode != "cli":
return IpaResult(True, f"dry-run: would set IPA password for {username}")
try:
kinit(config)
subprocess.run(
["ipa", "passwd", username],
input=f"{password}\n{password}\n",
check=True,
text=True,
capture_output=True,
)
except subprocess.CalledProcessError as exc:
return IpaResult(False, exc.stderr.strip() or exc.stdout.strip() or str(exc))
return IpaResult(True, f"set IPA password for {username}")
def email_matches(config: Config, username: str, email: str) -> bool:
try:
kinit(config)
result = subprocess.run(
["ipa", "user-show", username],
check=True,
text=True,
capture_output=True,
)
except subprocess.CalledProcessError:
return False
expected = f"email address: {email.lower()}"
return expected in result.stdout.lower()
def verify_password(config: Config, username: str, password: str) -> IpaResult:
realm = config.ipa_principal.rsplit("@", 1)[-1] if "@" in config.ipa_principal else "POTTERNET.LAN"
principal = f"{username}@{realm}"
with tempfile.NamedTemporaryFile(prefix="charon-krb5cc-") as cache:
env = os.environ.copy()
env["KRB5CCNAME"] = cache.name
try:
subprocess.run(
["kinit", principal],
input=f"{password}\n",
check=True,
text=True,
capture_output=True,
env=env,
)
except subprocess.CalledProcessError as exc:
return IpaResult(False, exc.stderr.strip() or exc.stdout.strip() or "old password did not authenticate")
finally:
subprocess.run(["kdestroy", "-c", cache.name], text=True, capture_output=True, env=env)
return IpaResult(True, f"verified password for {username}")
+58
View File
@@ -0,0 +1,58 @@
from __future__ import annotations
import smtplib
from email.message import EmailMessage
from .config import Config
def _send_message(config: Config, message: EmailMessage) -> None:
smtp_class = smtplib.SMTP_SSL if config.smtp_ssl else smtplib.SMTP
with smtp_class(config.smtp_host, config.smtp_port, timeout=20) as smtp:
if config.smtp_starttls and not config.smtp_ssl:
smtp.starttls()
if config.smtp_username:
smtp.login(config.smtp_username, config.smtp_password)
smtp.send_message(message)
def send_verification_email(config: Config, to_email: str, verify_url: str) -> None:
message = EmailMessage()
message["From"] = config.smtp_from
message["To"] = to_email
message["Subject"] = "Verify your Sticknife account"
message.set_content(
"\n".join(
[
"Welcome to Sticknife.",
"",
"Verify your email address to continue:",
verify_url,
"",
"If you did not request this, you can ignore this email.",
]
)
)
_send_message(config, message)
def send_password_reset_email(config: Config, to_email: str, reset_url: str) -> None:
message = EmailMessage()
message["From"] = config.smtp_from
message["To"] = to_email
message["Subject"] = "Set your Sticknife password"
message.set_content(
"\n".join(
[
"Your Sticknife account is ready.",
"",
"Set or reset your password here:",
reset_url,
"",
"This link expires soon. If you did not request this, you can ignore this email.",
]
)
)
_send_message(config, message)
+97
View File
@@ -0,0 +1,97 @@
from __future__ import annotations
import base64
import json
import urllib.error
import urllib.parse
import urllib.request
from dataclasses import dataclass
from typing import Any
from .config import Config
@dataclass(frozen=True)
class OidcProvider:
authorization_endpoint: str
token_endpoint: str
userinfo_endpoint: str
end_session_endpoint: str | None = None
def _json_get(url: str) -> dict[str, Any]:
with urllib.request.urlopen(url, timeout=20) as response:
return json.loads(response.read().decode())
def discover(config: Config) -> OidcProvider:
metadata = _json_get(f"{config.oidc_issuer}/.well-known/openid-configuration")
return OidcProvider(
authorization_endpoint=metadata["authorization_endpoint"],
token_endpoint=metadata["token_endpoint"],
userinfo_endpoint=metadata["userinfo_endpoint"],
end_session_endpoint=metadata.get("end_session_endpoint"),
)
def authorization_url(config: Config, state: str, next_path: str) -> str:
provider = discover(config)
query = urllib.parse.urlencode(
{
"client_id": config.oidc_client_id,
"redirect_uri": f"{config.base_url}/auth/callback",
"response_type": "code",
"scope": config.oidc_scopes,
"state": state,
}
)
return f"{provider.authorization_endpoint}?{query}"
def exchange_code(config: Config, code: str) -> dict[str, Any]:
provider = discover(config)
body = urllib.parse.urlencode(
{
"grant_type": "authorization_code",
"code": code,
"redirect_uri": f"{config.base_url}/auth/callback",
}
).encode()
request = urllib.request.Request(provider.token_endpoint, data=body, method="POST")
auth = base64.b64encode(f"{config.oidc_client_id}:{config.oidc_client_secret}".encode()).decode()
request.add_header("Authorization", f"Basic {auth}")
request.add_header("Content-Type", "application/x-www-form-urlencoded")
try:
with urllib.request.urlopen(request, timeout=20) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
raise RuntimeError(f"OIDC token exchange failed: HTTP {exc.code} {detail}") from exc
def userinfo(config: Config, access_token: str) -> dict[str, Any]:
provider = discover(config)
request = urllib.request.Request(provider.userinfo_endpoint)
request.add_header("Authorization", f"Bearer {access_token}")
try:
with urllib.request.urlopen(request, timeout=20) as response:
return json.loads(response.read().decode())
except urllib.error.HTTPError as exc:
detail = exc.read().decode(errors="replace")
raise RuntimeError(f"OIDC userinfo failed: HTTP {exc.code} {detail}") from exc
def groups_from_claims(claims: dict[str, Any]) -> set[str]:
raw = claims.get("groups") or claims.get("ak_groups") or []
if isinstance(raw, str):
return {raw}
if isinstance(raw, list):
return {str(group) for group in raw}
return set()
def logout_url(config: Config) -> str:
provider = discover(config)
endpoint = provider.end_session_endpoint or "https://auth.sticknife.com/if/session-end/"
query = urllib.parse.urlencode({"post_logout_redirect_uri": config.base_url})
return f"{endpoint}?{query}"
+400
View File
@@ -0,0 +1,400 @@
"""The Sticknife pantheon: who the gods are, what they run, and who they live with.
This module holds the *shape* of the pantheon. The words live one file per deity in
``templates/pantheon/`` — ``charon.toml``, ``hermes.toml``, and so on — so rewriting copy
never means editing layout code. To change what a god says, edit its file; the change shows
up on the next page load without a restart, the same as an HTML template.
It feeds three things:
* ``payload()`` — JSON embedded in the page for the force graph to lay out
* ``fallback()`` — server-rendered markup shown when JavaScript does not run
* ``tally()`` — a count line, if a page wants one
A member's seal is not declared anywhere; it is discovered. Drop a new master into
media/pantheon/, run ``scripts/build_pantheon_assets.py``, and the deity stops rendering as
an empty niche and starts wearing its seal — no code change.
Houses are functional groups. ``RING_ORDER`` sets the order they sit in, going clockwise, and
``RING_ANCHORS`` pins whichever of them have to land somewhere exact; the rest spread evenly
through the arc between two anchors. Reorder that list to reorder the pantheon — the angles
are derived, so nothing else needs touching and adding or dropping a house re-spaces the ring
on its own. The house marked ``center`` is the exception: bare metal sits at the middle and
does not orbit anything.
A house may name a ``hub``: the member that sits at the middle of the family, with the rest
hanging off it. Without one, a house with a ``role="host"`` member uses that instead, and a
house with neither links every member to every other, which leaves nobody in particular at
the middle. Tenants always hang off whatever they name in ``inside``, hub or not, so choosing
a hub rearranges a family without breaking who lives with whom.
Which mythology a name comes from is load-bearing in the layout, not trivia: the Greeks are
sprung toward the ring, while the Mesopotamian names are pushed gently outward off it. Those
four are exactly the tenants — Nabu on Hermes, Nanshe on Xenia, Uttu on Plutus, Gibil on
Astrape — so every borrowed god hangs outside the circle, tethered to the Greek whose metal
it lives on.
"""
from __future__ import annotations
import html
import json
import tomllib
from pathlib import Path
HERE = Path(__file__).resolve().parent
ART = HERE / "static" / "img" / "pantheon"
ART_URL = "/static/img/pantheon"
#: One file per deity, named for its key.
COPY = HERE / "templates" / "pantheon"
#: Fields a copy file may leave out, and what they mean when it does. A file with no ``house``
#: belongs to none and is not shown; no ``order`` puts it at the end of whatever house it names.
OPTIONAL = {"host": "", "runs": "", "role": "", "inside": "", "house": "", "order": 99}
#: Borrowed from Sumer and Babylon rather than Greece. The four on the ring are each a resident
#: on a Greek host's metal, which is why the layout pushes them outside it; the Abzu names never
#: touch the ring's physics at all, since their house lives in a drawer of its own.
MESOPOTAMIAN = {"nabu", "nanshe", "uttu", "gibil",
"marduk", "enki", "isimud", "kulla", "nisaba"}
#: The northern names. They do not orbit the pantheon at all — they live in a panel of their
#: own off the right-hand side, so the ring never has to make room for them.
NORSE = {"thor", "garmr", "ratatoskr", "kari", "mimir", "bifrost", "yggdrasil"}
def origin(key: str) -> str:
if key in MESOPOTAMIAN:
return "mesopotamian"
if key in NORSE:
return "norse"
return "greek"
#: Going round the ring, clockwise. This list is the only thing that decides the order — swap
#: two ids to swap two houses, insert one and the ring re-spaces itself. Any house left out of
#: it (the machines) does not orbit.
RING_ORDER = [
"media", # at the top
"message", # flanking one side
"count",
"access", # the gate, at the bottom
"power",
"make", # crafting, flanking the other side of media
]
#: Houses pinned to an exact angle, in degrees clockwise from twelve o'clock: -90 is the top,
#: 0 the right, 90 the bottom. Anything not pinned spreads evenly through the arc between its
#: neighbouring anchors, keeping RING_ORDER. Pin nothing and the whole ring spaces evenly from
#: the top. With six houses the anchors cost nothing — media sits three seats from access, so
#: holding both top and bottom still leaves the rest exactly 60 degrees apart. At counts where
#: 180 is not a multiple of 360/n the two anchors win and the arcs either side compress.
RING_ANCHORS = {
"media": -90, # media at twelve o'clock, flanked by make and message
"access": 90, # the gate at the bottom
}
#: The order the families stack in on a narrow screen, where the ring gives way to a column of
#: blocks. Deliberately separate from RING_ORDER: what reads well going clockwise is not what
#: reads well scrolling down, and the ring has two houses that are not on it at all. Anything
#: left out of this keeps its place from HOUSES, after everything that is listed. It is applied
#: only under the mobile breakpoint, so reordering here cannot disturb the ring.
STACK_ORDER = [
"media",
"message",
"make",
"count",
"access",
"power",
"control",
"abzu",
"norse",
]
#: The houses themselves. Who is *in* each one is not listed here — every copy file names the
#: house it belongs to and an ``order`` for where it sits along the ring, so adding a deity is
#: dropping in a file. ``bias`` nudges a whole family along the ring in seats, trimming the
#: lean a one-sided tenant puts on its kin.
HOUSES: list[dict] = [
{"id": "access", "name": "policy & access", "hub": "nomos"},
{"id": "make", "name": "creation"},
{"id": "message", "name": "communication"},
{"id": "count", "name": "finance"},
{"id": "power", "name": "systems"},
{
"id": "media",
"name": "media",
"hub": "dionysus",
# Nanshe sits out beyond Xenia with nothing on the far side to answer her, and the push
# of her leans the rest of the house anticlockwise; this puts Dionysus back on twelve.
"bias": 0.1,
},
{
"id": "control",
"name": "control",
"center": True, # the machines do not orbit; they are the middle
},
{
"id": "abzu",
"name": "abzu",
"side": True,
"hub": "marduk",
},
{
"id": "norse",
"name": "norse",
# Neither on the ring nor at the middle: this one lives in a drawer off the right edge
# and is drawn by its own little simulation, so it never competes for ring seats.
"side": True,
"hub": "thor", # sits above the rest of the cluster, and larger
},
]
# --------------------------------------------------------------------------------- the copy
#: key -> (mtime, parsed). Re-read only when a file actually changes, so editing copy shows up
#: on the next request without a restart but a page view is not nineteen parses.
_copy: dict[str, tuple[int, dict]] = {}
def paragraphs(text: str) -> list[str]:
"""Split prose on blank lines, one entry per paragraph.
A blank line starts a new paragraph; a single line break is just where the author happened
to wrap, and is undone. So copy can be hard-wrapped in the file to stay readable there
without the wrapping meaning anything on the page.
"""
out = []
for block in text.split("\n\n"):
joined = " ".join(block.split())
if joined:
out.append(joined)
return out
def member(key: str) -> dict:
"""One deity, read from its copy file.
A file being mid-edit should not take the page down: if it will not parse and we have read
it successfully before, the last good version is served and the bad one simply does not
take. With nothing cached to fall back on there is nothing to serve, and the error stands.
"""
path = COPY / f"{key}.toml"
cached = _copy.get(key)
try:
stamp = path.stat().st_mtime_ns
except OSError:
if cached:
return cached[1]
raise
if cached and cached[0] == stamp:
return cached[1]
try:
with path.open("rb") as handle:
loaded = tomllib.load(handle)
except (tomllib.TOMLDecodeError, OSError):
if cached:
return cached[1]
raise
entry = {**OPTIONAL, **loaded, "key": key}
entry["does"] = " ".join(entry.get("does", "").split())
entry["long"] = paragraphs(entry.get("long", ""))
_copy[key] = (stamp, entry)
return entry
def roster() -> dict[str, list[str]]:
"""House id to member keys, in the order they sit along the ring.
Membership is read from the copy files rather than declared here: each names its ``house``
and an ``order``, low to high. A file naming a house that does not exist is left out — the
houses are the fixed part, and a typo should cost one deity rather than the page.
"""
grouped: dict[str, list[tuple[int, str]]] = {house["id"]: [] for house in HOUSES}
for path in sorted(COPY.glob("*.toml")):
key = path.stem
who = member(key)
if who["house"] in grouped:
grouped[who["house"]].append((who["order"], key))
# ties break on key, so the order is stable whatever the filesystem hands back
return {hid: [key for _, key in sorted(rows)] for hid, rows in grouped.items()}
def _members() -> list[dict]:
seats = roster()
return [member(key) for house in HOUSES for key in seats[house["id"]]]
def has_seal(key: str) -> bool:
"""Whether the asset build has produced art for this deity yet."""
return (ART / f"{key}-node.webp").is_file()
def ring_angles() -> dict[str, float]:
"""Each ring house's angle in degrees clockwise from twelve o'clock.
Pinned houses sit exactly where ``RING_ANCHORS`` puts them; the rest are spread evenly
around the arc between whichever anchors they fall between, in ``RING_ORDER``. With no
anchors at all this is a plain even ring starting at the top.
"""
def tidy(deg: float) -> float:
return round(((deg + 180.0) % 360.0) - 180.0, 1)
count = len(RING_ORDER)
anchors = [(i, RING_ANCHORS[h]) for i, h in enumerate(RING_ORDER) if h in RING_ANCHORS]
if not anchors:
step = 360.0 / count
return {h: tidy(-90.0 + i * step) for i, h in enumerate(RING_ORDER)}
angles: dict[str, float] = {}
for n, (index, degrees) in enumerate(anchors):
angles[RING_ORDER[index]] = tidy(degrees)
next_index, next_degrees = anchors[(n + 1) % len(anchors)]
between = (next_index - index) % count or count # houses in this arc, plus one
span = (next_degrees - degrees) % 360 or 360.0 # clockwise sweep to the next
for step in range(1, between):
angles[RING_ORDER[(index + step) % count]] = tidy(degrees + span * step / between)
return angles
def data() -> list[dict]:
"""The houses, with copy read in and art URLs filled in for whichever seals exist."""
angles = ring_angles()
seats = roster()
out = []
for house in HOUSES:
members = []
for key in seats[house["id"]]:
art = has_seal(key)
entry = {
**member(key),
"origin": origin(key),
"node": f"{ART_URL}/{key}-node.webp" if art else "",
"icon": f"{ART_URL}/{key}-icon.webp" if art else "",
"seal": f"{ART_URL}/{key}-seal.webp" if art else "",
}
if house.get("hub") == key:
entry["hub"] = True
members.append(entry)
shape = {"id": house["id"], "name": house["name"], "members": members}
if house.get("center"):
shape["center"] = True
elif house.get("side"):
shape["side"] = True
else:
shape["angle"] = angles.get(house["id"])
if "bias" in house:
shape["bias"] = house["bias"]
out.append(shape)
return out
def payload() -> str:
"""JSON for the page, safe to drop inside a <script> element."""
raw = json.dumps(data(), separators=(",", ":"))
return raw.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
def tally() -> str:
members = _members()
struck = sum(1 for m in members if has_seal(m["key"]))
answering = sum(1 for m in members if m["state"] in ("live", "here", "locked"))
return f"{len(members)} named · {struck} seals struck · {answering} answering"
def stack_rank() -> dict[str, int]:
"""Each house's place in the stacked view, by id.
STACK_ORDER first, in the order given; anything it does not mention keeps its HOUSES order
behind them, so a house added to HOUSES still appears without having to be listed twice.
"""
rank = {hid: i for i, hid in enumerate(STACK_ORDER)}
tail = len(STACK_ORDER)
for house in HOUSES:
if house["id"] not in rank:
rank[house["id"]] = tail
tail += 1
return rank
def action(m: dict) -> str:
"""The one thing you can do with a deity, if anything."""
if m["state"] == "locked":
return '<a class="sn-btn" href="/auth/start?next=/profile">Request access</a>'
if m["state"] == "here":
return '<a class="sn-btn sn-btn-quiet" href="/profile">Your account</a>'
if m["state"] == "machine":
return ""
if not m["host"]:
# only claim a thing is unbuilt if it says so itself
return ('<span class="sn-btn sn-btn-quiet">Not built yet</span>'
if m["state"] == "planned" else "")
host = html.escape(m["host"], quote=True)
return f'<a class="sn-btn" href="https://{host}">Open {host}</a>'
def fallback() -> str:
"""The pantheon as stacked blocks: one section per house, each member a card that opens.
This is three things at once. It is what anyone without JavaScript gets; it is what the
page falls back to if the graph cannot start; and it is the whole layout on a narrow
screen, where a force-directed ring of thirty-two seals is unreadable and a list of
families is not. Being server-rendered, that last one costs a phone no physics at all —
the stylesheet simply shows this instead of the field.
Everything the detail panel shows is here too, folded into a <details> per member, so the
small screen never needs the panel to slide over it.
"""
def esc(value: object) -> str:
return html.escape(str(value), quote=True)
seats = roster()
names = {m["key"]: m["name"] for m in _members()}
rank = stack_rank()
# ships hidden: the stylesheet reveals it on a narrow screen, the noscript rule reveals it
# when scripts are off, and pantheon.js reveals it if the graph cannot start
parts = ['<div class="sn-fallback" id="sn-fallback" hidden>']
for house in HOUSES:
parts.append(f'<section class="sn-family" data-house="{esc(house["id"])}"'
f' style="--stack:{rank[house["id"]]}">'
f'<h2>{esc(house["name"])}</h2><ul>')
for key in seats[house["id"]]:
m = member(key)
art = has_seal(key)
face = (f'<img src="{ART_URL}/{key}-node.webp" alt="" width="240" height="240"'
f' loading="lazy" decoding="async">' if art
else f'<span class="sn-empty">{esc(m["name"][:1])}</span>')
kin = [names[k] for k in seats[house["id"]]
if k != key and member(k)["inside"] == m["inside"]]
below = [names[k] for k in seats[house["id"]] if member(k)["inside"] == key]
spec = [f"<dt>house</dt><dd>{esc(house['name'])}</dd>"]
if m["inside"]:
spec.append(f"<dt>lives with</dt><dd>{esc(names[m['inside']])}</dd>")
if kin:
spec.append(f"<dt>siblings</dt><dd>{esc(', '.join(kin))}</dd>")
if below:
spec.append(f"<dt>descendants</dt><dd>{esc(', '.join(below))}</dd>")
if m["host"]:
spec.append(f"<dt>host</dt><dd>{esc(m['host'])}</dd>")
spec.append(f"<dt>status</dt><dd>{esc(m['label'])}</dd>")
body = "".join(f"<p>{esc(para)}</p>" for para in m["long"])
parts.append(
f'<li><details class="sn-card" data-state="{esc(m["state"])}">'
f'<summary><span class="sn-card-face">{face}</span>'
f'<span class="sn-card-head"><b>{esc(m["name"])}</b>'
f'<span class="sn-card-domain">{esc(m["domain"])}</span>'
f'<span class="sn-card-does">{esc(m["does"])}</span></span></summary>'
f'<div class="sn-card-body">{body}'
f'<dl class="sn-spec">{"".join(spec)}</dl>'
f'<div class="sn-actions">{action(m)}</div></div>'
f'</details></li>')
parts.append("</ul></section>")
parts.append("</div>")
return "".join(parts)
+49
View File
@@ -0,0 +1,49 @@
CREATE TABLE IF NOT EXISTS snreg_users (
id BIGSERIAL PRIMARY KEY,
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL,
password_hash TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending_email',
email_verified_at TIMESTAMPTZ,
trusted_domain BOOLEAN NOT NULL DEFAULT FALSE,
is_admin BOOLEAN NOT NULL DEFAULT FALSE,
ipa_provisioned_at TIMESTAMPTZ,
ipa_message TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS snreg_email_tokens (
token TEXT PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES snreg_users(id) ON DELETE CASCADE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ
);
CREATE TABLE IF NOT EXISTS snreg_audit_log (
id BIGSERIAL PRIMARY KEY,
actor_user_id BIGINT REFERENCES snreg_users(id) ON DELETE SET NULL,
target_user_id BIGINT REFERENCES snreg_users(id) ON DELETE SET NULL,
action TEXT NOT NULL,
detail TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS snreg_users_status_idx ON snreg_users(status);
CREATE INDEX IF NOT EXISTS snreg_tokens_user_idx ON snreg_email_tokens(user_id);
ALTER TABLE snreg_users ADD COLUMN IF NOT EXISTS oidc_subject TEXT UNIQUE;
ALTER TABLE snreg_users ADD COLUMN IF NOT EXISTS auth_groups TEXT NOT NULL DEFAULT '';
CREATE INDEX IF NOT EXISTS snreg_users_oidc_subject_idx ON snreg_users(oidc_subject);
CREATE TABLE IF NOT EXISTS snreg_password_tokens (
token TEXT PRIMARY KEY,
username TEXT NOT NULL,
email TEXT NOT NULL,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS snreg_password_tokens_username_idx ON snreg_password_tokens(username);
+55
View File
@@ -0,0 +1,55 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import secrets
import time
def hash_password(password: str) -> str:
salt = secrets.token_bytes(16)
digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, 240_000)
return "pbkdf2_sha256$240000$%s$%s" % (
base64.b64encode(salt).decode(),
base64.b64encode(digest).decode(),
)
def verify_password(password: str, encoded: str) -> bool:
try:
algo, rounds, salt_b64, digest_b64 = encoded.split("$", 3)
if algo != "pbkdf2_sha256":
return False
salt = base64.b64decode(salt_b64)
expected = base64.b64decode(digest_b64)
actual = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, int(rounds))
return hmac.compare_digest(actual, expected)
except Exception:
return False
def token_urlsafe() -> str:
return secrets.token_urlsafe(32)
def sign(value: str, secret: str) -> str:
payload = f"{value}|{int(time.time())}"
mac = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
return base64.urlsafe_b64encode(f"{payload}|{mac}".encode()).decode()
def unsign(cookie: str, secret: str, max_age: int = 60 * 60 * 24 * 14) -> str | None:
try:
decoded = base64.urlsafe_b64decode(cookie.encode()).decode()
value, timestamp, mac = decoded.rsplit("|", 2)
payload = f"{value}|{timestamp}"
expected = hmac.new(secret.encode(), payload.encode(), hashlib.sha256).hexdigest()
if not hmac.compare_digest(mac, expected):
return None
if time.time() - int(timestamp) > max_age:
return None
return value
except Exception:
return None
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
from http.server import ThreadingHTTPServer, BaseHTTPRequestHandler
from .config import get_config
from .web import WebApp
class Handler(BaseHTTPRequestHandler):
app: WebApp
def do_GET(self) -> None:
self.app.dispatch(self)
def do_POST(self) -> None:
self.app.dispatch(self)
def log_message(self, fmt: str, *args) -> None:
print("%s - %s" % (self.address_string(), fmt % args))
def main() -> None:
config = get_config()
Handler.app = WebApp(config)
server = ThreadingHTTPServer((config.host, config.port), Handler)
print(f"Sticknife registration listening on http://{config.host}:{config.port}")
server.serve_forever()
+770
View File
@@ -0,0 +1,770 @@
/* The landing page: flat Sticknife chrome framing the pantheon.
Everything is scoped to body.landing so the rest of the app keeps styles.css as it was.
The identity is the logo's construction — content banded between hard rules, sitting on a
baseline — plus one discipline: gold belongs to the art and to status, never to the chrome.
That is what lets thirteen gilt seals be the only rich thing on the page. */
body.landing {
--ink: #0c0b0a;
--wall: #141210;
--rule: #2b2622;
--rule-2: #3a342d;
--cream: #f2ece1;
--muted: #8e8579;
--dim: #5f584f;
--gold: #b5976e;
--hot: #e1812b;
--chrome: "Oswald", "Archivo Narrow", "Barlow Condensed", "Roboto Condensed",
"Helvetica Neue Condensed", "Arial Narrow", system-ui, sans-serif;
--plaque: "Cinzel", "Trajan Pro", "Palatino Linotype", Palatino, "Book Antiqua",
Georgia, serif;
--data: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
--gutter: clamp(16px, 3vw, 28px);
--col: 1180px;
/* roughly the width of the Sign in button: how far right of the content column the fixed
nav sits, so the sign-up card passes underneath it rather than through it */
--nav-clear: 104px;
background: var(--ink);
color: var(--cream);
font-family: var(--chrome);
font-stretch: 85%;
overflow-x: hidden;
}
/* a faint warm cast from above, so the black isn't flat vinyl */
body.landing::before {
content: "";
position: fixed;
inset: 0;
z-index: 0;
pointer-events: none;
background: radial-gradient(120% 70% at 50% -10%, rgba(181, 151, 110, .07), transparent 60%);
}
body.landing .page { position: relative; z-index: 1; display: block; }
body.landing .landing-shell {
width: 100%;
margin: 0;
padding: 0;
border: 0;
background: none;
box-shadow: none;
}
.sn-page {
width: min(var(--col), 100%);
margin: 0 auto;
padding: 0 var(--gutter);
}
/* The auth nav lives in the shared topbar. It used to line up with the content column's right
edge, which is exactly where the sign-up card is, so scrolling dragged the card straight
under it. Sitting it a button's width further right puts it in the margin instead — and on
viewports too narrow to have a margin to spare, the floor keeps it on the page. */
body.landing .topbar {
top: 34px;
right: max(10px, calc(50vw - var(--col) / 2 + var(--gutter) - var(--nav-clear)));
gap: 8px;
}
body.landing .nav-button {
min-height: 36px;
border: 2px solid var(--cream);
border-radius: 0;
background: transparent;
color: var(--cream);
font-family: var(--chrome);
font-size: .78rem;
font-stretch: 85%;
font-weight: 600;
letter-spacing: .11em;
text-transform: uppercase;
transition: background .16s, color .16s;
}
body.landing .nav-button:hover { background: var(--cream); color: var(--ink); }
body.landing .navlinks form { margin: 0; }
/* ---------------------------------------------------------------- brand and structure */
.sn-head { padding: 30px 0 22px; }
.sn-brand { display: inline-block; }
.sn-brand img {
display: block;
width: clamp(150px, 17vw, 200px);
height: auto;
}
.sn-baseline { height: 3px; background: var(--cream); }
.sn-hair { height: 1px; background: var(--rule); }
/* ------------------------------------------------------------------------------- gate */
.sn-gate {
display: grid;
grid-template-columns: minmax(0, 1.3fr) minmax(320px, .7fr);
gap: clamp(28px, 5vw, 60px);
align-items: start;
padding: clamp(38px, 6vw, 62px) 0 clamp(34px, 5vw, 52px);
}
.sn-eyebrow {
margin: 0 0 18px;
color: var(--muted);
font-size: .72rem;
font-weight: 600;
letter-spacing: .3em;
text-transform: uppercase;
}
body.landing .sn-gate-copy h1 {
margin: 0 0 20px;
color: var(--cream);
font-family: var(--chrome);
font-size: clamp(2.3rem, 5.4vw, 4.2rem);
font-weight: 600;
line-height: .94;
letter-spacing: .012em;
text-transform: uppercase;
text-wrap: balance;
}
body.landing .sn-lede {
max-width: 46ch;
margin: 0;
color: var(--muted);
font-family: var(--plaque);
font-size: clamp(1rem, 1.5vw, 1.12rem);
line-height: 1.6;
}
.sn-lede em { color: var(--cream); font-style: normal; }
.sn-gate-card {
padding: clamp(20px, 2.4vw, 28px);
border: 1px solid var(--rule-2);
background: var(--wall);
}
body.landing .sn-gate-card h2 {
margin: 0 0 18px;
color: var(--muted);
font-family: var(--chrome);
font-size: .74rem;
font-weight: 600;
letter-spacing: .26em;
text-transform: uppercase;
}
body.landing .sn-gate-card label {
display: grid;
gap: 7px;
color: var(--muted);
font-family: var(--chrome);
font-size: .72rem;
font-weight: 600;
letter-spacing: .16em;
text-transform: uppercase;
}
body.landing .sn-gate-card .stack { display: grid; gap: 14px; }
body.landing .sn-gate-card input {
width: 100%;
height: 42px;
padding: 0 12px;
border: 1px solid var(--rule-2);
border-radius: 0;
background: #0a0908;
color: var(--cream);
font-family: var(--data);
font-size: .92rem;
letter-spacing: 0;
text-transform: none;
}
body.landing .sn-gate-card input:focus-visible { border-color: var(--gold); }
body.landing .sn-gate-card button {
width: 100%;
min-height: 42px;
margin-top: 4px;
border: 2px solid var(--cream);
border-radius: 0;
background: var(--cream);
color: var(--ink);
font-family: var(--chrome);
font-size: .8rem;
font-weight: 600;
font-stretch: 85%;
letter-spacing: .11em;
text-transform: uppercase;
cursor: pointer;
transition: background .16s;
}
body.landing .sn-gate-card button:hover { background: #fff; }
body.landing .sn-fineprint {
margin: 16px 0 0;
color: var(--dim);
font-family: var(--data);
font-size: .74rem;
line-height: 1.55;
}
.sn-fineprint b { color: var(--muted); font-weight: 400; }
/* the global link colour belongs to the other pages' palette; at this size the underline is
doing most of the work anyway */
body.landing .sn-fineprint a {
color: var(--muted);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-color: var(--rule-2);
}
body.landing .sn-fineprint a:hover { color: var(--gold); text-decoration-color: var(--gold); }
body.landing ul.sn-fineprint {
display: grid;
gap: 9px;
padding: 0;
list-style: none; /* separated by space alone — no marker, no indent */
}
body.landing .sn-gate-card .notice {
margin-bottom: 16px;
border-radius: 0;
font-family: var(--plaque);
font-size: .88rem;
}
/* ------------------------------------------------------------------------ section band */
.sn-band {
display: flex;
align-items: baseline;
justify-content: center;
gap: 20px;
padding: 28px 0 2px;
}
.sn-band-label {
color: var(--gold);
font-family: var(--plaque);
font-size: .96rem;
letter-spacing: .34em;
text-indent: .34em; /* offset the trailing letter-space so it centres optically */
text-transform: lowercase;
}
/* ----------------------------------------------------------------------------- the field */
.sn-field {
position: relative;
width: 100vw;
margin-left: calc(50% - 50vw);
/* Taller than the ring needs. geometry() spends the surplus on margin rather than on a
bigger ring, which is what keeps the tenants pushed outside the ring — Nanshe at the top
of it especially — from running off the top edge. */
height: clamp(900px, 105vh, 1140px);
overflow: hidden;
}
.sn-graph { position: absolute; inset: 0; width: 100%; height: 100%; }
/* houses are invisible until you go looking for one */
.sn-links g { opacity: 0; transition: opacity .3s ease; }
.sn-links g.on { opacity: 1; }
.sn-links line { stroke: var(--gold); stroke-width: 1; }
/* The cluster keeps its edges drawn rather than surfacing them on hover — it is small enough
to read at a glance, and the lines are what make it hang off the one at the top. Faint,
though: they are there to be inferred, not read. */
.sn-side-graph .sn-links line { stroke-opacity: .18; transition: stroke-opacity .3s ease; }
.sn-side-graph .sn-links.on line { stroke-opacity: .5; }
.sn-node { cursor: pointer; }
.sn-node image { transition: transform .3s ease; transform-box: fill-box; transform-origin: center; }
.sn-node .sn-halo { opacity: 0; transition: opacity .3s ease; }
.sn-node.lit .sn-halo, .sn-node.sel .sn-halo { opacity: 1; }
.sn-node.lit image, .sn-node.sel image { transform: scale(1.07); }
.sn-niche-ring { fill: #100e0c; stroke: var(--rule-2); stroke-width: 1; }
.sn-monogram {
fill: var(--rule-2);
font-family: var(--plaque);
font-size: 1.5rem;
letter-spacing: .08em;
text-anchor: middle;
}
.sn-label {
fill: var(--muted);
font-family: var(--plaque);
font-size: .68rem;
letter-spacing: .17em;
text-anchor: middle;
text-transform: uppercase;
opacity: .5;
transition: opacity .25s, fill .25s;
}
.sn-node.lit .sn-label, .sn-node.sel .sn-label { fill: var(--cream); opacity: 1; }
.sn-node[data-state="planned"] .sn-label, .sn-node[data-state="machine"] .sn-label { opacity: .34; }
.sn-graph.busy .sn-node:not(.lit):not(.sel) { opacity: .26; }
.sn-node { transition: opacity .3s ease; }
/* House names are authored lowercase — that is the control string. Every place one is shown
title-cases it in CSS, so the data stays plain and the page stays capitalised. */
.sn-house-tag {
fill: var(--gold);
font-family: var(--plaque);
font-size: .68rem;
letter-spacing: .22em;
text-anchor: middle;
text-transform: capitalize;
opacity: 0;
transition: opacity .3s ease;
}
.sn-house-tag.on { opacity: 1; }
.sn-node:focus { outline: none; }
.sn-node:focus-visible .sn-focus-ring { stroke: var(--hot); stroke-width: 2; }
.sn-focus-ring { fill: none; stroke: none; }
/* ------------------------------------------------------------------- the side drawers */
/* A rail of tabs pinned to the right edge, and one parked panel per tab behind it. The tabs
live on the rail rather than on the panels so that opening one never buries another's
handle underneath it. */
.sn-drawers {
position: absolute;
inset: 0;
z-index: 6;
pointer-events: none; /* the field underneath stays reachable */
--rail-w: 36px; /* keep in step with RAIL in pantheon.js */
}
.sn-drawers[hidden] { display: none; }
.sn-rail {
position: absolute;
z-index: 2;
top: 0;
bottom: 0;
right: 0;
width: var(--rail-w);
display: flex;
flex-direction: column;
gap: 10px;
padding-top: 72px;
border-left: 1px solid var(--rule-2);
background: rgba(11, 10, 9, .95);
pointer-events: auto;
}
.sn-side-tab {
flex: none;
padding: 20px 0;
border: 0;
background: transparent;
color: var(--muted);
cursor: pointer;
font-family: var(--plaque);
font-size: .7rem;
letter-spacing: .22em;
text-transform: capitalize;
transition: color .2s;
}
.sn-side-tab span {
display: block;
writing-mode: vertical-rl;
white-space: nowrap;
}
.sn-side-tab:hover { color: var(--cream); }
.sn-side-tab.on { color: var(--gold); }
.sn-side-tab:focus-visible { outline: 1px solid var(--hot); outline-offset: -3px; }
.sn-side-body {
position: absolute;
z-index: 1;
top: 0;
bottom: 0;
right: var(--rail-w);
width: clamp(260px, 24vw, 360px);
border-left: 1px solid var(--rule-2);
background: rgba(11, 10, 9, .97);
transform: translateX(calc(100% + var(--rail-w)));
pointer-events: none;
transition: transform .42s cubic-bezier(.22, .61, .36, 1);
}
.sn-side-body.open { transform: none; pointer-events: auto; }
.sn-side-graph { display: block; width: 100%; height: 100%; }
.sn-hint {
position: absolute;
z-index: 2;
bottom: 14px;
left: 50%;
margin: 0;
color: var(--dim);
font-family: var(--data);
font-size: .7rem;
transform: translateX(-50%);
transition: opacity .3s;
}
.sn-hint.away { opacity: 0; }
/* ------------------------------------------------------------- no-script / no-d3 listing */
/* The listing ships hidden and is only revealed when the graph cannot run — either by the
noscript rule in the page or by pantheon.js giving up. This [hidden] rule has to be here:
the display below would otherwise outrank the browser's own [hidden] { display: none }. */
.sn-fallback[hidden] { display: none; }
/* The stacked view: one block per family, each member a card that opens in place. It is the
no-JavaScript fallback and the whole layout on a narrow screen, where a ring of thirty-two
seals is unreadable and a list of families is not. */
.sn-fallback {
position: relative;
z-index: 1;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 30px 40px;
width: min(var(--col), 100%);
margin: 0 auto;
padding: 34px var(--gutter);
}
body.landing .sn-fallback h2 {
margin: 0 0 14px;
padding-bottom: 8px;
border-bottom: 1px solid var(--rule);
color: var(--gold);
font-family: var(--plaque);
font-size: .8rem;
letter-spacing: .2em;
text-transform: capitalize;
}
.sn-fallback ul { margin: 0; padding: 0; list-style: none; }
.sn-fallback li { margin: 0 0 10px; }
.sn-card { border: 1px solid var(--rule); background: rgba(20, 18, 16, .5); }
.sn-card[open] { border-color: var(--rule-2); background: var(--wall); }
.sn-card summary {
display: flex;
gap: 14px;
align-items: center;
padding: 12px;
cursor: pointer;
list-style: none;
}
.sn-card summary::-webkit-details-marker { display: none; }
.sn-card summary::marker { content: ""; }
.sn-card summary:focus-visible { outline: 1px solid var(--hot); outline-offset: -2px; }
.sn-card-face {
flex: none;
display: grid;
place-items: center;
width: 58px;
height: 58px;
}
.sn-card-face img { display: block; width: 100%; height: auto; }
.sn-card-face .sn-empty {
display: grid;
place-items: center;
width: 46px;
height: 46px;
border: 1px solid var(--rule-2);
border-radius: 50%;
color: var(--dim);
font-family: var(--plaque);
font-size: 1rem;
}
.sn-card-head { display: grid; gap: 2px; min-width: 0; }
.sn-fallback b {
color: var(--cream);
font-family: var(--plaque);
font-size: .84rem;
font-weight: 600;
letter-spacing: .13em;
text-transform: uppercase;
}
.sn-card-domain {
color: var(--gold);
font-family: var(--data);
font-size: .66rem;
letter-spacing: .12em;
text-transform: lowercase;
}
.sn-card-does {
color: var(--muted);
font-family: var(--plaque);
font-size: .82rem;
line-height: 1.45;
}
.sn-card-body { padding: 0 12px 14px; border-top: 1px solid var(--rule); }
.sn-card-body p {
margin: 12px 0 0;
color: var(--muted);
font-family: var(--plaque);
font-size: .88rem;
line-height: 1.6;
}
.sn-card-body .sn-spec { margin: 14px 0 16px; }
.sn-card[data-state="machine"] .sn-card-body .sn-actions { display: none; }
.sn-fallback a { color: var(--gold); font-family: var(--data); font-size: .78rem; }
.sn-fallback span { color: var(--dim); font-family: var(--data); font-size: .78rem; }
/* --------------------------------------------------------------------------- the drawer */
/* The detail panel rides in from the left edge of the field; the graph re-centres into the
space that is left, which the simulation animates on its own. */
.sn-drawer {
position: absolute;
z-index: 7;
top: 0;
bottom: 0;
left: 0;
width: clamp(320px, 33vw, 452px);
overflow-y: auto;
border-right: 1px solid var(--rule-2);
background: rgba(11, 10, 9, .97);
transform: translateX(-101%);
pointer-events: none;
transition: transform .42s cubic-bezier(.22, .61, .36, 1);
}
.sn-drawer.open {
transform: none;
pointer-events: auto;
}
.sn-drawer-in {
display: block;
padding: clamp(22px, 2.4vw, 34px);
}
.sn-drawer-art {
width: min(200px, 54%);
margin-bottom: clamp(18px, 2vw, 26px);
}
.sn-drawer-art img { display: block; width: 100%; height: auto; }
.sn-drawer-art .sn-empty {
display: grid;
place-items: center;
aspect-ratio: 1;
border: 1px solid var(--rule-2);
border-radius: 50%;
background: radial-gradient(circle at 50% 36%, #171512, #0e0c0b 72%);
color: var(--rule-2);
font-family: var(--plaque);
font-size: 3rem;
}
.sn-drawer-body { position: relative; }
body.landing .sn-drawer-body h3 {
margin: 0 44px 6px 0; /* clear of the close button */
color: var(--cream);
font-family: var(--plaque);
font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 600;
letter-spacing: .13em;
text-transform: uppercase;
}
.sn-drawer-domain {
margin: 0 0 20px;
color: var(--gold);
font-size: .78rem;
font-weight: 600;
letter-spacing: .22em;
text-transform: uppercase;
}
body.landing .sn-long {
max-width: 58ch;
margin: 0 0 22px;
color: var(--muted);
font-family: var(--plaque);
font-size: 1rem;
line-height: 1.65;
}
.sn-spec {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 9px 18px;
margin: 0 0 24px;
padding: 18px 0 0;
border-top: 1px solid var(--rule);
font-family: var(--data);
font-size: .78rem;
}
.sn-spec dt { color: var(--dim); }
.sn-spec dd { margin: 0; color: var(--cream); }
.sn-spec .sn-house-name { text-transform: capitalize; }
.sn-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.sn-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 38px;
padding: 0 16px;
border: 2px solid var(--cream);
background: transparent;
color: var(--cream);
font-family: var(--chrome);
font-size: .78rem;
font-weight: 600;
font-stretch: 85%;
letter-spacing: .11em;
text-transform: uppercase;
text-decoration: none;
cursor: pointer;
transition: background .16s, color .16s;
}
.sn-btn:hover { background: var(--cream); color: var(--ink); }
.sn-btn-quiet { border-color: var(--rule-2); color: var(--muted); }
.sn-btn-quiet:hover { border-color: var(--cream); background: transparent; color: var(--cream); }
.sn-close {
position: absolute;
top: 0;
right: 0;
z-index: 2;
display: grid;
place-items: center;
width: 34px;
height: 34px;
border: 1px solid var(--rule-2);
background: transparent;
color: var(--muted);
font-size: 1.1rem;
line-height: 1;
cursor: pointer;
}
.sn-close:hover { border-color: var(--cream); color: var(--cream); }
/* ------------------------------------------------------------------------------- footer */
.sn-maker {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 28px;
min-height: 128px;
}
.sn-maker img { display: block; width: 60px; height: auto; opacity: .16; }
body.landing .sn-thesis {
padding-bottom: 22px;
color: var(--muted);
font-family: var(--plaque);
font-size: 1rem;
letter-spacing: .04em;
}
.sn-colophon {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
gap: 14px;
padding: 14px 0 44px;
color: var(--dim);
font-family: var(--data);
font-size: .72rem;
}
/* -------------------------------------------------------------------------- responsive */
@media (max-width: 900px) {
.sn-gate { grid-template-columns: minmax(0, 1fr); }
}
/* no room to slide the graph aside: the panel simply covers it */
@media (max-width: 700px) {
.sn-drawer { width: 100%; border-right: 0; }
}
/* Below this the ring stops being readable: thirty-two seals want more width than a phone
has, and shrinking them to fit turns the whole thing into gravel. So the field gives way
to the stacked families, which need no script and no physics — the [hidden] attribute is
overridden here rather than removed, so this works with JavaScript switched off. */
@media (max-width: 820px) {
.sn-field {
height: auto;
overflow: visible;
}
.sn-graph,
.sn-hint,
.sn-drawers,
.sn-drawer { display: none; }
.sn-fallback[hidden] { display: grid; }
.sn-fallback { padding-top: 8px; }
/* Reading order down the column, set by STACK_ORDER in pantheon.py. Scoped to this query on
purpose: the custom property is inert everywhere else, so the desktop listing keeps the
order the markup is in and the ring is untouched. */
.sn-family { order: var(--stack, 0); }
}
@media (max-width: 560px) {
body.landing .topbar {
top: auto;
right: var(--gutter);
bottom: max(16px, env(safe-area-inset-bottom));
}
/* the field is already stacked by here — nothing left to size */
.sn-fallback { grid-template-columns: minmax(0, 1fr); gap: 26px; }
}
@media (prefers-reduced-motion: reduce) {
body.landing *, body.landing *::before, body.landing *::after {
animation-duration: .01ms !important;
transition-duration: .01ms !important;
}
}
+617
View File
@@ -0,0 +1,617 @@
:root {
color-scheme: dark;
--bg: #17140f;
--surface: rgba(31, 27, 21, .94);
--surface-2: #272118;
--line: #4a4033;
--text: #f4efe6;
--muted: #cfc3b3;
--accent: #d3a85b;
--accent-2: #7aa889;
--danger: #d97070;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: "GFS Neohellenic", Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
color: var(--text);
background:
radial-gradient(circle at 36% 22%, rgba(211, 168, 91, .12), transparent 34%),
linear-gradient(90deg, rgba(14, 13, 11, .54), rgba(14, 13, 11, .9)),
url("/static/img/styx.png") center / cover fixed,
var(--bg);
}
a { color: var(--accent); text-decoration: none; }
.page {
min-height: 100vh;
display: grid;
grid-template-rows: 1fr;
}
.topbar {
position: fixed;
z-index: 21;
top: 20px;
right: 28px;
display: flex;
justify-content: flex-end;
align-items: center;
min-height: 36px;
padding: 0;
}
.home-link { display: none; }
.navlinks {
display: flex;
align-items: center;
gap: 10px;
}
.navlinks form { margin: 0; }
.navlinks button {
background: transparent;
border: 0;
color: var(--accent);
cursor: pointer;
font: inherit;
}
.nav-button,
.register-flyout > summary {
display: flex;
align-items: center;
justify-content: center;
min-height: 36px;
border: 1px solid rgba(211, 168, 91, .46);
border-radius: 7px;
background: rgba(31, 27, 21, .9);
color: var(--accent);
padding: 0 13px;
font-family: "Cinzel", "GFS Didot", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
font-size: .9rem;
font-weight: 600;
letter-spacing: .03em;
cursor: pointer;
list-style: none;
}
.nav-button:hover,
.register-flyout > summary:hover {
border-color: rgba(211, 168, 91, .72);
background: rgba(45, 36, 24, .94);
color: #efbd73;
}
.shell {
align-self: center;
justify-self: end;
width: min(440px, calc(100vw - 32px));
margin: 0 9vw 8vh 0;
justify-self: end;
padding: 34px;
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 24px 80px rgba(0, 0, 0, .38);
}
.shell:has(.landing) {
justify-self: stretch;
width: auto;
margin: 0 auto 7vh;
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.shell-wide {
width: min(1080px, calc(100vw - 32px));
justify-self: center;
margin: 72px 0 8vh;
padding-top: 44px;
}
.landing {
display: grid;
grid-template-columns: minmax(0, 1fr);
justify-items: center;
align-items: center;
}
.myth-panel {
display: grid;
justify-items: center;
max-width: 980px;
padding: 16px 0 34px;
text-align: center;
text-shadow: 0 2px 24px rgba(0, 0, 0, .62);
transform: none;
}
.myth-panel img {
width: clamp(164px, 15vw, 236px);
height: clamp(164px, 15vw, 236px);
margin-bottom: 24px;
filter: drop-shadow(0 18px 38px rgba(0, 0, 0, .55));
}
.kicker {
margin: 0 0 18px;
color: var(--accent);
font-family: "Cinzel", "GFS Didot", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
font-size: clamp(1.05rem, 1.55vw, 1.45rem);
font-weight: 600;
letter-spacing: .06em;
}
.myth-panel h1 {
max-width: none;
margin: 0 0 28px;
color: var(--accent);
font-family: "Cinzel", "GFS Didot", Georgia, serif;
font-size: clamp(4.2rem, 8vw, 8.6rem);
font-weight: 600;
line-height: .95;
letter-spacing: .01em;
white-space: nowrap;
}
.myth-copy {
max-width: 860px;
margin-bottom: 42px;
color: #efe5d6;
font-size: clamp(1.25rem, 2.1vw, 2rem);
line-height: 1.35;
}
.crossing-grid {
display: grid;
grid-template-columns: repeat(2, minmax(220px, 1fr));
gap: clamp(26px, 5vw, 76px);
width: min(780px, 100%);
}
.crossing-item {
display: grid;
justify-items: center;
align-content: start;
min-height: 180px;
}
.crossing-item h2 {
margin: 0 0 10px;
color: var(--text);
font-family: "GFS Neohellenic", Inter, sans-serif;
font-size: clamp(1.2rem, 1.7vw, 1.55rem);
font-weight: 700;
}
.crossing-item p {
max-width: 28ch;
margin: 0 0 18px;
color: var(--muted);
font-size: clamp(1.02rem, 1.3vw, 1.25rem);
line-height: 1.35;
}
.choice-disclosure {
position: relative;
width: min(280px, 100%);
text-shadow: none;
}
.choice-disclosure summary {
display: flex;
justify-content: center;
align-items: center;
min-height: 42px;
border: 1px solid rgba(211, 168, 91, .46);
border-radius: 7px;
background: rgba(31, 27, 21, .9);
color: var(--accent);
padding: 0 14px;
font-family: "Cinzel", "GFS Didot", Georgia, serif;
font-weight: 600;
cursor: pointer;
list-style: none;
}
.choice-disclosure summary::-webkit-details-marker { display: none; }
.choice-disclosure summary::after {
content: "";
width: 0;
height: 0;
margin-left: 10px;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
}
.choice-disclosure[open] summary::after { transform: rotate(180deg); }
.dropdown-card {
position: absolute;
z-index: 5;
top: calc(100% + 10px);
left: 50%;
display: grid;
min-width: 320px;
padding: 10px;
border: 1px solid rgba(255, 255, 255, .12);
border-radius: 8px;
background: rgba(21, 18, 14, .98);
box-shadow: 0 18px 44px rgba(0, 0, 0, .46);
transform: translateX(-50%);
}
.dropdown-card a, .domain-chip {
display: flex;
justify-content: space-between;
gap: 18px;
align-items: center;
min-height: 40px;
border-radius: 6px;
padding: 0 10px;
color: var(--text);
font-size: 1rem;
}
.dropdown-card a:hover { background: rgba(211, 168, 91, .12); }
.dropdown-card span {
color: var(--muted);
font-size: .84rem;
}
.dropdown-card .request-domain {
justify-content: center;
margin-top: 8px;
background: var(--accent);
color: #1a1308;
font-weight: 800;
}
.dropdown-card .request-domain:hover { background: #e0bb76; }
.register-flyout {
position: fixed;
z-index: 20;
top: 20px;
right: 138px;
text-shadow: none;
}
.register-flyout > summary::-webkit-details-marker { display: none; }
.register-flyout > summary::after {
content: "";
width: 0;
height: 0;
margin-left: 9px;
border-left: 4px solid transparent;
border-right: 4px solid transparent;
border-top: 5px solid currentColor;
}
.register-flyout[open] > summary::after { transform: rotate(180deg); }
.register-flyout .register-card {
position: absolute;
top: calc(100% + 12px);
right: 0;
}
.register-card {
width: min(440px, calc(100vw - 32px));
padding: 34px;
border: 1px solid rgba(255, 255, 255, .1);
border-radius: 8px;
background: var(--surface);
box-shadow: 0 24px 80px rgba(0, 0, 0, .38);
}
.identity {
text-align: center;
margin-bottom: 24px;
}
.identity h1 {
color: var(--text);
font-family: "Cinzel", "GFS Didot", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
font-size: 1.55rem;
font-weight: 600;
letter-spacing: .03em;
}
.identity p, .register-card label, .register-card input, .register-card .fineprint {
font-family: "GFS Neohellenic", "GFS Didot", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
}
.identity img {
width: 84px;
height: 84px;
margin-bottom: 16px;
}
h1, h2, p { margin-top: 0; }
h1 {
margin-bottom: 8px;
font-size: 1.65rem;
}
h2 {
font-size: 1rem;
margin-bottom: 16px;
}
p, .fineprint {
color: var(--muted);
line-height: 1.5;
}
.stack, .grid-form {
display: grid;
gap: 15px;
}
.grid-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
label {
display: grid;
gap: 7px;
color: var(--muted);
font-size: .95rem;
}
input {
width: 100%;
height: 44px;
border-radius: 6px;
border: 1px solid var(--line);
background: #15120e;
color: var(--text);
padding: 0 12px;
font: inherit;
}
input:disabled { opacity: .64; }
button, .primary {
display: inline-flex;
justify-content: center;
align-items: center;
min-height: 44px;
border: 0;
border-radius: 6px;
background: var(--accent);
color: #1a1308;
padding: 0 16px;
font-family: "GFS Neohellenic", "GFS Didot", "Palatino Linotype", Palatino, "Book Antiqua", Georgia, serif;
font-size: 1.08rem;
font-weight: 700;
cursor: pointer;
}
.small {
min-height: 32px;
padding: 0 10px;
font-size: .86rem;
}
.danger { background: var(--danger); }
.notice {
padding: 12px 14px;
border: 1px solid rgba(122, 168, 137, .4);
border-radius: 6px;
color: var(--text);
background: rgba(122, 168, 137, .14);
margin-bottom: 18px;
}
.error {
border-color: rgba(217, 112, 112, .5);
background: rgba(217, 112, 112, .14);
}
.fineprint {
margin: 18px 0 0;
font-size: .9rem;
}
.pagehead {
display: flex;
justify-content: space-between;
gap: 24px;
align-items: center;
margin-bottom: 28px;
}
.avatar {
display: grid;
place-items: center;
width: 72px;
height: 72px;
overflow: hidden;
border-radius: 50%;
border: 1px solid var(--line);
color: var(--accent);
background: var(--surface-2);
font-size: .75rem;
}
.panel {
margin-top: 22px;
padding-top: 22px;
border-top: 1px solid var(--line);
}
table {
width: 100%;
border-collapse: collapse;
font-size: .92rem;
}
th, td {
padding: 12px 10px;
border-bottom: 1px solid rgba(255, 255, 255, .08);
text-align: left;
vertical-align: middle;
}
th {
color: var(--muted);
font-weight: 600;
}
.actions {
display: flex;
gap: 8px;
}
.actions form { margin: 0; }
.pill {
display: inline-flex;
align-items: center;
min-height: 24px;
border-radius: 999px;
background: rgba(211, 168, 91, .14);
color: var(--accent);
padding: 0 9px;
font-size: .78rem;
font-weight: 700;
}
@media (max-width: 1120px) {
.register-flyout {
right: 134px;
}
.register-flyout .register-card {
width: min(420px, calc(100vw - 32px));
}
}
@media (max-width: 760px) {
.topbar {
top: auto;
right: 16px;
bottom: max(16px, env(safe-area-inset-bottom));
min-height: 36px;
padding: 0;
}
.shell {
align-self: start;
justify-self: center;
margin: 18px 16px 96px;
padding: 24px;
}
.shell-wide {
margin-top: 78px;
padding-top: 28px;
}
.shell:has(.landing) { margin: 4px 16px 104px; }
.register-flyout {
position: fixed;
top: auto;
bottom: max(16px, env(safe-area-inset-bottom));
left: 16px;
right: auto;
margin-bottom: 0;
}
.register-flyout .register-card {
position: fixed;
z-index: 30;
top: auto;
bottom: calc(max(16px, env(safe-area-inset-bottom)) + 48px);
left: 16px;
right: auto;
width: min(420px, calc(100vw - 32px));
max-height: calc(100vh - 112px);
overflow: auto;
margin-top: 0;
transform-origin: bottom left;
}
.register-flyout > summary::after {
border-top: 0;
border-bottom: 5px solid currentColor;
}
.register-flyout[open] > summary::after { transform: rotate(180deg); }
.myth-panel { padding: 0; transform: none; }
.myth-panel img {
width: 132px;
height: 132px;
margin-bottom: 14px;
}
.kicker { margin-bottom: 14px; font-size: .98rem; }
.myth-panel h1 {
white-space: normal;
font-size: 2.65rem;
}
.myth-copy {
margin-bottom: 26px;
font-size: 1.25rem;
}
.crossing-grid {
grid-template-columns: 1fr;
gap: 24px;
}
.crossing-item { min-height: 0; }
.dropdown-card {
position: static;
min-width: 100%;
margin-top: 10px;
transform: none;
}
.register-card {
width: 100%;
padding: 24px;
}
.grid-form { grid-template-columns: 1fr; }
table {
display: block;
overflow-x: auto;
}
}
.admin-purge {
display: flex;
justify-content: flex-end;
margin-top: 16px;
}
button:disabled {
cursor: not-allowed;
opacity: .45;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 111 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 986 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 968 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 882 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 201 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 228 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 215 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 984 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 234 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 992 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 185 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1021 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 205 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 956 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 811 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Some files were not shown because too many files have changed in this diff Show More