initial commit
@@ -0,0 +1,81 @@
|
||||
# ---------------------------------------------------------------------------
|
||||
# Environment & secrets
|
||||
# ---------------------------------------------------------------------------
|
||||
# config.py loads .env via load_dotenv(). Under APP_ROLE=full this carries
|
||||
# APP_SECRET, DATABASE_URL, OIDC_CLIENT_SECRET and SMTP credentials.
|
||||
.env
|
||||
.env.*
|
||||
*.env
|
||||
!.env.example
|
||||
|
||||
# Kerberos / FreeIPA -- ipa.py kinit's with a keytab
|
||||
*.keytab
|
||||
krb5cc_*
|
||||
*.ccache
|
||||
|
||||
# TLS material
|
||||
*.pem
|
||||
*.key
|
||||
*.crt
|
||||
*.p12
|
||||
*.pfx
|
||||
|
||||
# The live unit carries host-specific paths and the local account name.
|
||||
# Ship the template instead; see sticknife-home.service.example.
|
||||
*.service
|
||||
!*.service.example
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Databases
|
||||
# ---------------------------------------------------------------------------
|
||||
# app/schema.sql is the tracked schema; the populated database is not.
|
||||
*.db
|
||||
*.db-journal
|
||||
*.db-wal
|
||||
*.db-shm
|
||||
*.sqlite
|
||||
*.sqlite3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Python
|
||||
# ---------------------------------------------------------------------------
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.egg-info/
|
||||
*.egg
|
||||
dist/
|
||||
build/
|
||||
.mypy_cache/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Virtual environments
|
||||
# ---------------------------------------------------------------------------
|
||||
venv/
|
||||
venv*/
|
||||
.venv/
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Logs
|
||||
# ---------------------------------------------------------------------------
|
||||
*.log
|
||||
logs/
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IDE & editor
|
||||
# ---------------------------------------------------------------------------
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Backup & temp files
|
||||
# ---------------------------------------------------------------------------
|
||||
*.bak
|
||||
*.tmp
|
||||
*.temp
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Sticknife registration app."""
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
from .server import main
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -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)}")
|
||||
@@ -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"))),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
@@ -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)
|
||||
@@ -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}"
|
||||
@@ -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)
|
||||
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 255 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 111 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 986 KiB |
|
After Width: | Height: | Size: 125 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 6.4 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 968 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 225 KiB |
|
After Width: | Height: | Size: 7.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 882 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 6.6 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 134 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 171 KiB |
|
After Width: | Height: | Size: 228 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 1.0 MiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 215 KiB |
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 984 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 234 KiB |
|
After Width: | Height: | Size: 5.1 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 992 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 185 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 1021 KiB |
|
After Width: | Height: | Size: 99 KiB |
|
After Width: | Height: | Size: 205 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 956 KiB |
|
After Width: | Height: | Size: 94 KiB |
|
After Width: | Height: | Size: 204 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 811 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 176 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 8.5 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
|
After Width: | Height: | Size: 198 KiB |
|
After Width: | Height: | Size: 254 KiB |
|
After Width: | Height: | Size: 6.5 KiB |
|
After Width: | Height: | Size: 18 KiB |