diff --git a/.gitignore b/.gitignore
index 0d04fa1..ae46525 100644
--- a/.gitignore
+++ b/.gitignore
@@ -16,6 +16,8 @@ services/logic/.ephemeris/
# Dane wejściowe (duże pliki Excela trzymane poza repo)
services/data/data_files/*.xlsx
+# Rejestr plików (DAN-27) powstaje przy uruchomieniu — to stan, nie kod.
+services/data/data_files/.files-state.json
!services/data/data_files/.gitkeep
# Narzędzia
diff --git a/docker-compose-stack.yml b/docker-compose-stack.yml
index 57bf27a..da76fa3 100644
--- a/docker-compose-stack.yml
+++ b/docker-compose-stack.yml
@@ -11,8 +11,10 @@ services:
volumes:
- ./services/data/data_files:/app/data_files
- data_cache:/app/.cache
- ports:
- - "8002:8002"
+ # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
+ # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
+ # w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
+ # `docker compose run --publish`.
logic:
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
@@ -25,8 +27,10 @@ services:
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on:
- data
- ports:
- - "8001:8001"
+ # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
+ # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
+ # w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
+ # `docker compose run --publish`.
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up
@@ -36,8 +40,10 @@ services:
container_name: astrololo-engine-swisseph
restart: unless-stopped
profiles: ["comparison"]
- ports:
- - "8003:8003"
+ # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
+ # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
+ # w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
+ # `docker compose run --publish`.
presentation:
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
diff --git a/docker-compose.yml b/docker-compose.yml
index 569fb05..ef7f8ce 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -9,8 +9,10 @@ services:
volumes:
- ./services/data/data_files:/app/data_files
- data_cache:/app/.cache
- ports:
- - "8002:8002"
+ # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
+ # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
+ # w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
+ # `docker compose run --publish`.
logic:
build: ./services/logic
@@ -21,8 +23,10 @@ services:
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
depends_on:
- data
- ports:
- - "8001:8001"
+ # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
+ # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
+ # w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
+ # `docker compose run --publish`.
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
# docker compose --profile comparison up
@@ -30,8 +34,10 @@ services:
engine-swisseph:
build: ./services/engine-swisseph
profiles: ["comparison"]
- ports:
- - "8003:8003"
+ # Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
+ # compose, a opublikowana na hoście była osiągalna z pominięciem logowania
+ # w prezentacji. Do diagnostyki: `docker compose exec` albo tymczasowe
+ # `docker compose run --publish`.
presentation:
build: ./services/presentation
diff --git a/docs/konta-i-uprawnienia.md b/docs/konta-i-uprawnienia.md
index 9291b1d..4079eee 100644
--- a/docs/konta-i-uprawnienia.md
+++ b/docs/konta-i-uprawnienia.md
@@ -93,6 +93,63 @@ sprawdzić, że w źródle strony nie ma żadnego z tych śladów, i mieć kontr
pozytywną, że przy uprawnieniu wszystkie są. Inaczej test przechodzi także wtedy,
gdy generowanie jest zepsute dla wszystkich.
+## Pełna paranoja: ukrywanie jest nadrzędne
+
+Właściciel produktu postawił to wyżej niż wygodę i wyżej niż czytelność
+komunikatów: *„nie chcę, żeby osoba wrzucająca bazy wiedziała, po co to robi
+i jak będzie w przyszłości działał program, bo to rozgada"*. Persona nazywa się
+**wgrywacz** — konto z uprawnieniami `files` + `files_input` i niczym więcej.
+
+Nie ma dowiedzieć się: jakie inne funkcje istnieją, że teksty pisze model
+językowy i u jakiego dostawcy, do czego służą wgrywane pliki, co jest planowane,
+że istnieje walidacja plików, ani że istnieje konto, które widzi więcej.
+
+### Wyciek prawie nigdy nie siedzi tam, gdzie się go szuka
+
+Audyt sześciu kanałów potwierdził 26 wycieków. Ani jeden nie był przyciskiem.
+
+| Kanał | Co wyciekało |
+|---|---|
+| `/static/**` poza bramką | komplet skryptów i arkuszy dla **niezalogowanego** |
+| komentarze w CSS/JS | pełne zdania po polsku o funkcjach, o kwarantannie i o tym, że administrator widzi więcej |
+| `styles.css` jako jeden plik | nazwy selektorów = spis funkcji programu |
+| `base.html` | skrypty kosmogramu na **każdej** stronie, łącznie z „przyszłą zakładką" |
+| komunikaty błędu | nazwa trasy, nazwa podsystemu, nazwa gałęzi rozwojowej, wewnętrzny `host:port` |
+| komunikat po wgraniu | słowo „administrator" — i **dwie różne treści**, czyli wyrocznia do odgadywania reguł walidacji |
+| `/openapi.json` warstw wewnętrznych | katalog wszystkich funkcji, bez logowania |
+
+### Trzy zasady, które z tego wynikają
+
+**Zasób jest częścią funkcji.** Skrypt i arkusz przechodzą przez tę samą bramkę
+co ekran (`features.STATIC`). Nazwa pliku jest zgadywalna, więc plik publiczny
+opowiada o funkcji równie dokładnie jak przycisk. Publiczny został jeden
+`base.css` — bo potrzebuje go ekran logowania — i dlatego nie wolno w nim
+umieścić niczego, co nazywa funkcję.
+
+**Komentarz nie jedzie na drut.** `_asset_body()` usuwa komentarze przy
+serwowaniu. Zostają w repozytorium, gdzie są potrzebne. Ta jedna zmiana zamyka
+cztery z sześciu kanałów naraz.
+
+**Różnica jest informacją.** Dwa różne komunikaty po wgraniu pliku były
+wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać odpowiedź. Teraz
+komunikat jest jeden, niezależnie od wyniku. Z tego samego powodu odmowa to
+404 identyczne z „nie ma takiej trasy", a akcja bez uprawnienia cofa się do
+domyślnej zamiast tłumaczyć, czego brakuje.
+
+### Zapora słownikowa
+
+Łatanie punkt po punkcie przegrywa z następną zmianą. Dlatego
+`test_slownik_zakazany.py` nie sprawdza miejsc, tylko przechodzi **wszystko**,
+co dane konto może pobrać, i szuka słów, które nie mają prawa paść.
+
+Dwie z trzech list biorą się wprost z katalogu funkcji — nazwa funkcji, adres
+jej ekranu i nazwy jej zasobów — więc dopisanie funkcji automatycznie dopisuje
+je do tego, czego konto bez niej nie może zobaczyć. Trzecia lista, słownictwo
+dziedziny i mechanizmów, jest pisana ręcznie, bo katalog jej nie zna.
+
+Test ma kontrolę pozytywną: dla administratora te same słowa **muszą** się
+pojawiać. Bez niej przechodziłby także wtedy, gdyby program był pusty.
+
## Gdzie leżą konta
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
diff --git a/services/data/app/main.py b/services/data/app/main.py
index 3d3c799..0dfdbf7 100644
--- a/services/data/app/main.py
+++ b/services/data/app/main.py
@@ -28,7 +28,12 @@ async def lifespan(app: FastAPI):
yield
-app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan)
+# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
+# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
+# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
+# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
+app = FastAPI(title="astrololo · warstwa bazodanowa", lifespan=lifespan,
+ docs_url=None, redoc_url=None, openapi_url=None)
security.install(app, "danych") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
diff --git a/services/engine-swisseph/app/main.py b/services/engine-swisseph/app/main.py
index 4cfabd5..06c30e6 100644
--- a/services/engine-swisseph/app/main.py
+++ b/services/engine-swisseph/app/main.py
@@ -20,7 +20,12 @@ from pydantic import BaseModel
import swisseph as swe
-app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)")
+# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
+# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
+# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
+# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
+app = FastAPI(title="astrololo · engine-swisseph (AGPL, izolowany)",
+ docs_url=None, redoc_url=None, openapi_url=None)
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
diff --git a/services/logic/app/main.py b/services/logic/app/main.py
index 3f9d61d..dc4e738 100644
--- a/services/logic/app/main.py
+++ b/services/logic/app/main.py
@@ -18,7 +18,12 @@ from app.clients.data_client import DataClient
from app.models import QueryRequest, QueryResponse
from app.service import QueryService
-app = FastAPI(title="astrololo · warstwa logiczna")
+# Bez /docs, /redoc i /openapi.json: te trasy oddają KOMPLETNY katalog funkcji —
+# nazwy tras, kształty żądań, listę dostawców modeli — i to każdemu, kto potrafi
+# je otworzyć, bez żadnego logowania. Ochrona zakładek w prezentacji nic nie
+# daje, gdy obok stoi usługa, która sama się spisuje (PRE-27).
+app = FastAPI(title="astrololo · warstwa logiczna",
+ docs_url=None, redoc_url=None, openapi_url=None)
service = QueryService()
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
diff --git a/services/presentation/app/features.py b/services/presentation/app/features.py
index 192097e..1d6f61b 100644
--- a/services/presentation/app/features.py
+++ b/services/presentation/app/features.py
@@ -118,17 +118,77 @@ ROUTES: dict[tuple[str, str], str | None] = {
}
-def required(method: str, path: str) -> str | None:
+# ── zasoby statyczne ─────────────────────────────────────────────────────
+# Skrypt i arkusz stylów są CZĘŚCIĄ funkcji, nie jej dekoracją. Nazwa pliku jest
+# zgadywalna, a treść opowiada o funkcji dokładniej niż przycisk: `models.js`
+# wymienia dostawców modeli, `compile.js` — składanie raportu. Dlatego każdy
+# zasób przechodzi przez tę samą bramkę co ekrany.
+#
+# Wartość to ZBIÓR uprawnień, z których wystarczy JEDNO: wyszukiwarka lokalizacji
+# siedzi na trzech ekranach i konto z którymkolwiek z nich musi ją dostać.
+# `None` = każdy zalogowany. Zasób spoza mapy wymaga admina — jak trasa.
+_FORMULARZE = frozenset({"chart", "interpret", "timeline", "compile"})
+_LOKALIZACJA = frozenset({"chart", "interpret", "timeline"})
+_KOLO = frozenset({"chart", "compile"})
+
+STATIC: dict[str, frozenset[str] | str | None] = {
+ # arkusze ekranów — jeden ekran, jedno uprawnienie
+ "s-chart.css": "chart",
+ "s-interpret.css": "interpret",
+ "s-timeline.css": "timeline",
+ "s-synastry.css": "synastry",
+ "s-significators.css": "significators",
+ "s-compile.css": "compile",
+ "s-files.css": "files",
+ "s-settings.css": "settings",
+ "s-accounts.css": ADMIN,
+ "x-ai.css": "ai",
+ # skrypty
+ "formsync.js": _FORMULARZE,
+ "now.js": _LOKALIZACJA,
+ "geo.js": _LOKALIZACJA,
+ "vendor/leaflet/leaflet.js": _LOKALIZACJA,
+ "vendor/leaflet/leaflet.css": _LOKALIZACJA,
+ "wheelzoom.js": _KOLO,
+ "wheeltip.js": _KOLO,
+ "copy.js": frozenset({"interpret", "timeline"}),
+ "compile.js": "compile",
+ "models.js": "ai",
+ "progress.js": "ai",
+ "natal.js": "ai",
+ "predictions.js": "ai",
+ # base.css celowo NIE MA tu wpisu: potrzebuje go ekran logowania, więc jest
+ # publiczny (PUBLIC_PATHS w security.py). Dlatego nie wolno w nim trzymać
+ # niczego, co nazywa jakąkolwiek funkcję.
+}
+
+
+def static_required(name: str) -> frozenset[str] | str | None:
+ """Uprawnienie dla zasobu. Nieznany zasób → admin, jak nieznana trasa."""
+ return STATIC.get(name, ADMIN)
+
+
+def required(method: str, path: str) -> frozenset[str] | str | None:
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
+ if path.startswith("/static/"):
+ return static_required(path[len("/static/"):])
return ROUTES.get((method.upper(), path), ADMIN)
-def can(perms: frozenset[str], feature: str | None) -> bool:
- return feature is None or feature in perms
+def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
+ """Czy te uprawnienia otwierają tę funkcję.
+
+ Zbiór znaczy „wystarczy którekolwiek": jeden zasób bywa wspólny dla kilku
+ ekranów i nie ma powodu wymagać ich wszystkich naraz."""
+ if feature is None:
+ return True
+ if isinstance(feature, str):
+ return feature in perms
+ return bool(perms & feature)
def screens_for(perms: frozenset[str]) -> list[Feature]:
diff --git a/services/presentation/app/main.py b/services/presentation/app/main.py
index 3a2731c..5306692 100644
--- a/services/presentation/app/main.py
+++ b/services/presentation/app/main.py
@@ -12,14 +12,15 @@ from __future__ import annotations
import hashlib
import json
+import logging
+import re
from datetime import datetime, timedelta, timezone
from functools import lru_cache
from pathlib import Path
import httpx
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
-from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
-from fastapi.staticfiles import StaticFiles
+from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
from fastapi.templating import Jinja2Templates
import base64
@@ -41,8 +42,9 @@ from app.config import DEFAULT_LOCATION_LABEL, default_form
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
app = FastAPI(title="astrololo · warstwa prezentacji",
docs_url=None, redoc_url=None, openapi_url=None)
-app.mount("/static", StaticFiles(directory="app/static"), name="static")
templates = Jinja2Templates(directory="app/templates")
+# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
+log = logging.getLogger("astrololo.prezentacja")
logic = LogicClient()
security.install(app) # logowanie + limit żądań (LOG-32)
@@ -55,18 +57,86 @@ security.install(app) # logowanie + limit żądań (LOG-32)
_STATIC_DIR = Path("app/static")
+_TYPY_ZASOBOW = {
+ ".css": "text/css; charset=utf-8",
+ ".js": "application/javascript; charset=utf-8",
+ ".svg": "image/svg+xml",
+ ".png": "image/png",
+ ".woff2": "font/woff2",
+}
+
+
+def _bez_komentarzy(tresc: str, sufiks: str) -> str:
+ """Usuwa komentarze z arkusza albo skryptu przed wysłaniem.
+
+ Komentarz w pliku statycznym jedzie do przeglądarki bajt w bajt, a te
+ komentarze opisują funkcje pełnym zdaniem po polsku — łącznie z tym, czego
+ konto ograniczone NIE widzi i kto to widzi. Zostają w repozytorium, bo tam
+ są potrzebne; do odpowiedzi nie mają po co trafiać.
+
+ Kasujemy tylko komentarze blokowe i CAŁE wiersze `//`. Komentarz na końcu
+ wiersza zostaje CELOWO: żeby go usunąć, trzeba rozpoznać, czy `//` nie jest
+ w napisie albo w wyrażeniu regularnym — a pomyłka tutaj psuje skrypt po
+ cichu. Test pilnuje słownika zakazanych słów, więc reszta by i tak wyszła.
+ """
+ tresc = re.sub(r"/\*.*?\*/", "", tresc, flags=re.S)
+ if sufiks == ".js":
+ tresc = re.sub(r"(?m)^[ \t]*//.*$\n?", "", tresc)
+ return re.sub(r"\n{3,}", "\n\n", tresc).strip() + "\n"
+
+
+@lru_cache(maxsize=None)
+def _asset_body(name: str) -> bytes | None:
+ """Treść zasobu tak, jak pójdzie na drut — albo None, gdy go nie ma."""
+ sciezka = (_STATIC_DIR / name).resolve()
+ korzen = _STATIC_DIR.resolve()
+ # Wyjście poza katalog zasobów: `..` w ścieżce albo dowiązanie na zewnątrz.
+ if korzen not in sciezka.parents or not sciezka.is_file():
+ return None
+ surowe = sciezka.read_bytes()
+ sufiks = sciezka.suffix.lower()
+ # Kod obcy zostaje nietknięty: minifikowany plik potrafi mieć `//` w napisie
+ # albo w wyrażeniu regularnym, a on i tak nie mówi nic o naszym programie.
+ if sufiks in (".css", ".js") and not name.startswith("vendor/"):
+ try:
+ return _bez_komentarzy(surowe.decode("utf-8"), sufiks).encode("utf-8")
+ except UnicodeDecodeError:
+ return surowe
+ return surowe
+
+
@lru_cache(maxsize=None)
def _asset_version(name: str) -> str:
- try:
- return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8]
- except OSError:
- return "0" # brak pliku nie może wywrócić strony
+ tresc = _asset_body(name)
+ # Hash liczony z treści WYSYŁANEJ, nie z pliku na dysku: inaczej zmiana
+ # samego komentarza podbijałaby adres, choć odpowiedź jest ta sama.
+ return hashlib.md5(tresc).hexdigest()[:8] if tresc is not None else "0"
def static_url(name: str) -> str:
return f"/static/{name}?v={_asset_version(name)}"
+@app.get("/static/{nazwa:path}")
+def static_file(nazwa: str):
+ """Zasoby idą przez bramkę uprawnień jak każda inna trasa.
+
+ Dopóki `/static/` było w PUBLIC_PREFIXES, katalog omijał całe sprawdzanie:
+ ukrycie przycisku w szablonie nic nie dawało, bo obok leżał skrypt tej samej
+ funkcji, pod nazwą, którą wystarczyło zgadnąć. Uprawnienie zasobu sprawdza
+ middleware (features.static_required); tutaj zostaje samo wydanie pliku.
+ """
+ tresc = _asset_body(nazwa)
+ if tresc is None:
+ raise HTTPException(status_code=404)
+ return Response(
+ tresc,
+ media_type=_TYPY_ZASOBOW.get(Path(nazwa).suffix.lower(), "application/octet-stream"),
+ # Adres niesie hash treści, więc wersja pod danym adresem jest niezmienna.
+ headers={"Cache-Control": "public, max-age=31536000, immutable"},
+ )
+
+
templates.env.globals["static"] = static_url
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
@@ -179,13 +249,24 @@ def _report_records(report: dict | None) -> int:
return total
+# Jedno zdanie dla wszystkich awarii — i CELOWO żadnego szczegółu.
+# Poprzednia treść wypisywała na ekran nazwę endpointu (/chart/positions), nazwę
+# podsystemu („silnik efemeryd"), nazwę gałęzi rozwojowej i — przez str(wyjątku
+# httpx) — wewnętrzny adres z portem sąsiedniej usługi. Dla konta, które ma
+# widzieć wyłącznie Pliki, to był komplet: co program liczy, że stoi za nim
+# łańcuch usług, i gdzie zapukać.
+_AWARIA = "Nie udało się teraz wykonać tej operacji. Spróbuj ponownie za chwilę."
+
+
def _logic_error(e: Exception) -> str:
- if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
- return (
- "Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
- "Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
- )
- return f"Warstwa logiczna niedostępna: {e}"
+ """Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran.
+
+ Odsiew siedzi TUTAJ, a nie w siedemnastu miejscach wywołania: gdyby zależał
+ od tego, czy ktoś pamiętał przekazać uprawnienia, prędzej czy później jedno
+ wywołanie zostałoby pominięte — i nie byłoby tego jak zauważyć.
+ """
+ log.warning("warstwa logiczna: %s: %s", type(e).__name__, e)
+ return _AWARIA
# ---------------- Horoskop: pozycje (strona główna) ----------------
@@ -198,9 +279,11 @@ def chart_form(request: Request):
target = _landing(request)
if target:
return RedirectResponse(target, status_code=303)
- return HTMLResponse("
Brak przypisanych funkcji
"
- "
Skontaktuj się z osobą, która zakładała konto.
",
- status_code=200)
+ # Ta sama odpowiedź co bramka dla trasy bez uprawnienia. Poprzednia treść
+ # mówiła o „przypisanych funkcjach" i o „osobie, która zakładała konto",
+ # czyli wprost: funkcje są komuś przydzielane, jest ktoś, kto to robi,
+ # a twoje konto zostało okrojone.
+ return HTMLResponse("
404 — nie znaleziono
", status_code=404)
return templates.TemplateResponse(
request, "chart.html",
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
@@ -767,7 +850,7 @@ def files_use(request: Request, path: str = Form(...), use: str = Form("")):
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
return _files_redirect(done="Zapisano." if want == files_state.READY
- else "Baza włączona do użytku.")
+ else "Plik włączony do użytku.")
@app.post("/files/upload")
@@ -782,12 +865,13 @@ async def files_upload(request: Request, upload: UploadFile = File(...)):
base64.b64encode(raw).decode("ascii"), by=_who(request))
except httpx.HTTPError as e:
return _files_redirect(error=_logic_error(e))
- if out.get("accepted"):
- return _files_redirect(done=f"Wgrano „{out.get('name')}”. Można ją włączyć do użytku.")
- # Bez powodu i bez słowa „walidacja" — poza administratorem nikt nie ma
- # skąd wiedzieć, że taki mechanizm istnieje.
- return _files_redirect(done=f"Wgrano „{out.get('name')}”. "
- f"Zanim trafi do użytku, musi ją zatwierdzić administrator.")
+ # DOKŁADNIE ten sam komunikat niezależnie od wyniku sprawdzenia. Dwa różne
+ # komunikaty były wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać
+ # odpowiedź, żeby odgadnąć reguły, które ma znać wyłącznie administrator.
+ # Poprzednia treść mówiła przy tym wprost, że istnieje „administrator", który
+ # musi plik „zatwierdzić" — czyli że konto wgrywającego jest ograniczone
+ # i że plik przechodzi przez jakąś bramkę.
+ return _files_redirect(done=f"Wgrano „{out.get('name')}”.")
@app.post("/files/archive")
@@ -995,4 +1079,6 @@ def wyloguj(request: Request):
@app.get("/health")
def health() -> dict:
- return {"status": "ok", "layer": "presentation"}
+ # Sama „ok" — nazwa warstwy mówiła, że warstw jest więcej i jak się nazywa.
+ # Kubernetes i compose czytają wyłącznie kod odpowiedzi.
+ return {"status": "ok"}
diff --git a/services/presentation/app/security.py b/services/presentation/app/security.py
index a56b3a8..1aeca5e 100644
--- a/services/presentation/app/security.py
+++ b/services/presentation/app/security.py
@@ -133,8 +133,16 @@ def trust_proxy() -> bool:
# żądaniu, więc handler i tak widziałby zalogowaną osobę.
# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być
# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła.
-PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj"})
-PUBLIC_PREFIXES = ("/static/",)
+# Ekran logowania musi się wyświetlić PRZED zalogowaniem, więc jego arkusz jest
+# jedynym publicznym zasobem — i dlatego nie wolno w nim trzymać niczego, co
+# nazywa funkcję (patrz features.STATIC).
+PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj", "/static/base.css"})
+# Pusto CELOWO. Dopóki był tu "/static/", cały katalog omijał bramkę: każdy plik
+# JS i arkusz stylów pobierał ktokolwiek, także niezalogowany, pod zgadywalnym
+# adresem — a ich treść wymienia ekrany, dostawców modeli i przeznaczenie baz.
+# Ruch do zasobów nie trafiał przy tym ani do dziennika, ani pod limit żądań,
+# więc wyciek był dla właściciela niewidoczny.
+PUBLIC_PREFIXES: tuple[str, ...] = ()
_hits: dict[str, deque[float]] = {}
diff --git a/services/presentation/app/static/base.css b/services/presentation/app/static/base.css
new file mode 100644
index 0000000..358000e
--- /dev/null
+++ b/services/presentation/app/static/base.css
@@ -0,0 +1,87 @@
+/* Powłoka strony i ekran logowania — JEDYNY arkusz publiczny.
+ Nie wolno tu wstawić niczego, co nazywa funkcję: ten plik pobiera
+ każdy, także niezalogowany, bo potrzebuje go ekran logowania. */
+
+:root {
+ --bg: #0f1020;
+ --panel: #1a1c33;
+ --ink: #e8e8f0;
+ --muted: #9aa0c0;
+ --accent: #8b7bf0;
+ --line: #2a2d4a;
+}
+* { box-sizing: border-box; }
+body {
+ margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
+ font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
+ display: flex; justify-content: center; padding: 3rem 1rem;
+}
+main { width: 100%; max-width: 880px; }
+h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
+.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
+.topbar nav { display: flex; gap: .25rem; }
+.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
+.topbar nav a:hover { color: var(--ink); }
+.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
+.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
+.actions { display: flex; gap: .5rem; margin-top: 1rem; }
+form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
+input, select, button {
+ font: inherit; padding: .6rem .75rem; border-radius: 8px;
+ border: 1px solid var(--line); background: #12132a; color: var(--ink);
+}
+button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
+button:hover { filter: brightness(1.1); }
+.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
+.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
+/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
+ kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
+ podział domów pochodzi z innego systemu, niż zamówiono. */
+.notice-warn {
+ margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
+ border: 1px solid var(--warn-border, #b8860b);
+ border-left-width: 4px;
+ border-radius: 4px;
+ background: var(--warn-bg, rgba(184, 134, 11, .10));
+ color: var(--warn-fg, inherit);
+}
+table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
+th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
+th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
+tr:last-child td { border-bottom: none; }
+.muted { color: var(--muted); }
+.small { font-size: .85rem; }
+.nowrap { white-space: nowrap; }
+/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
+.card {
+ margin-top: 1rem; padding: 1rem 1.25rem;
+ background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
+}
+.card .actions { margin-top: .9rem; }
+/* Potwierdzenie operacji. Odróżnia się od .notice-warn, bo tamto ostrzega,
+ a to tylko potwierdza — pomylenie ich uczy ignorować oba. */
+.done-note {
+ margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem;
+ border: 1px solid var(--line); border-left: 4px solid var(--accent);
+ border-radius: 4px; background: rgba(255, 255, 255, .03);
+}
+/* ── wyjście z aplikacji (LOG-32) ───────────────────────────────────── */
+/* Odsunięte od zakładek i wyglądające inaczej niż one: to czynność, nie miejsce,
+ a pomylenie go z zakładką kosztuje przypadkowe wylogowania. Odstęp robimy
+ marginesem, nie rozpychaczem — `nav` ma szerokość treści, więc nie ma czego
+ rozpychać. */
+.whoami { color: var(--muted); font-size: .85rem; align-self: center;
+ margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); }
+/* Wylogowanie idzie POST-em, więc jest przyciskiem — ale ma wyglądać jak
+ reszta paska, nie jak przycisk akcji w formularzu. */
+.wyloguj-form { display: inline; margin: 0; padding: 0; background: none; border: none; }
+button.wyloguj {
+ background: none; border: 1px solid transparent; color: var(--muted);
+ font: inherit; padding: .4rem .8rem; border-radius: 8px; cursor: pointer;
+}
+button.wyloguj:hover { color: var(--ink); }
+/* Ekran logowania — wąski, bez nawigacji: nie ma dokąd iść przed zalogowaniem. */
+.login-main { max-width: 26rem; }
+.login-card label { display: block; margin-top: .75rem; }
+.login-card input { width: 100%; }
+.login-note { margin-top: 1.25rem; }
diff --git a/services/presentation/app/static/s-accounts.css b/services/presentation/app/static/s-accounts.css
new file mode 100644
index 0000000..112807e
--- /dev/null
+++ b/services/presentation/app/static/s-accounts.css
@@ -0,0 +1,24 @@
+/* Arkusz ekranu „admin” — dokładany tylko dla konta z tym uprawnieniem. */
+
+.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
+.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
+.grid input { width: 100%; }
+.loc { margin-top: .8rem; }
+.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
+button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
+.row { display: flex; gap: .5rem; }
+.row input[type=text] { flex: 1; }
+/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
+ poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
+ nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
+ kolumn jest po prostu mniej. */
+.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
+ gap: .35rem 1rem; align-items: start; }
+.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
+.opts.systems label { display: flex; align-items: center; gap: .35rem; }
+.opts .hint { font-style: normal; opacity: .75; }
+.opts input[type=number] { width: 5rem; }
+.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
+.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
+.account-note input { width: 100%; }
+button.danger { background: #8b2f2f; }
diff --git a/services/presentation/app/static/s-chart.css b/services/presentation/app/static/s-chart.css
new file mode 100644
index 0000000..9c40d66
--- /dev/null
+++ b/services/presentation/app/static/s-chart.css
@@ -0,0 +1,119 @@
+/* Arkusz ekranu „chart” — dokładany tylko dla konta z tym uprawnieniem. */
+
+.sub { color: var(--muted); margin: .25rem 0 2rem; }
+.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
+.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
+.grid input { width: 100%; }
+.loc { margin-top: .8rem; }
+.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
+button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
+.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
+td.retro { color: #ff9b6a; font-weight: 600; }
+/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
+ (DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
+ 1) selektor wariantu U+FE0E w samych danych (glyphs.py),
+ 2) font-variant-emoji:text — nowsza własność CSS,
+ 3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
+ fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
+ znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
+ „Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
+.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
+ font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
+ "DejaVu Sans", sans-serif; }
+td.glyph { color: var(--accent); white-space: nowrap; }
+/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
+.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
+.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
+.wheel-fig figcaption { margin-top: .4rem; }
+/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
+svg.wheel text.glyph { font-size: inherit; }
+/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
+.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
+.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
+.aspectarian-fig figcaption { margin-top: .4rem; }
+svg.aspectarian text.glyph { font-size: inherit; }
+/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
+.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
+.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
+.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
+.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
+svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
+/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
+ ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
+ ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
+ zostać na wierzchu. */
+.wheel-fig svg.wheel { cursor: zoom-in; }
+.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
+.wheel-fig.zoomed {
+ position: fixed; inset: 0; z-index: 1100; margin: 0;
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ gap: .5rem; padding: 1rem; background: var(--bg);
+}
+/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
+ bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
+.wheel-fig.zoomed svg.wheel {
+ max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
+}
+body.wheel-zoom-open { overflow: hidden; }
+/* nie przewijaj treści pod spodem */
+/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
+ koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
+ (1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
+ kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
+.wheel-tip {
+ position: fixed; z-index: 1150; pointer-events: none;
+ max-width: 22rem; padding: .4rem .6rem;
+ background: var(--panel); color: var(--fg, inherit);
+ border: 1px solid var(--line); border-radius: 8px;
+ font-size: .85rem; line-height: 1.35;
+ box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
+}
+.wheel-tip[hidden] { display: none; }
+/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
+ poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
+ nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
+ kolumn jest po prostu mniej. */
+.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
+ gap: .35rem 1rem; align-items: start; }
+.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
+.opts.systems label { display: flex; align-items: center; gap: .35rem; }
+.opts .hint { font-style: normal; opacity: .75; }
+.opts input[type=number] { width: 5rem; }
+.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
+/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
+ przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
+ 1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
+ się sama tabela, nigdy strona. */
+.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
+.table-scroll > table { margin-top: 0; border-radius: 0; }
+.table-scroll th, .table-scroll td { white-space: nowrap; }
+/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
+ nie wiadomo, który to wiersz. */
+.table-scroll th:first-child, .table-scroll td:first-child {
+ position: sticky; left: 0; background: var(--panel); z-index: 1;
+ box-shadow: 1px 0 0 var(--line);
+}
+.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
+ background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
+/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
+.geo { margin-top: .9rem; }
+.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
+.geo-search input[type=search] { flex: 1; min-width: 12rem; }
+.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
+ border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
+.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
+.geo-results li:last-child { border-bottom: none; }
+.geo-results li:hover { background: var(--panel); color: #fff; }
+.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
+.geo-map[hidden] { display: none; }
+.geo-attrib { margin: .4rem 0 0; }
+.geo-attrib a { color: var(--accent); }
+.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
+/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
+.leaflet-control-attribution { font-size: .68rem; }
+button.toggle {
+ background: none; border: 1px solid var(--line); border-radius: 50%;
+ width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
+}
+button.toggle.on { color: var(--accent); border-color: var(--accent); }
+button.toggle.off { color: var(--muted); }
diff --git a/services/presentation/app/static/s-compile.css b/services/presentation/app/static/s-compile.css
new file mode 100644
index 0000000..d0937b6
--- /dev/null
+++ b/services/presentation/app/static/s-compile.css
@@ -0,0 +1,114 @@
+/* Arkusz ekranu „compile” — dokładany tylko dla konta z tym uprawnieniem. */
+
+.sub { color: var(--muted); margin: .25rem 0 2rem; }
+.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
+.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
+.grid input { width: 100%; }
+.loc { margin-top: .8rem; }
+.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
+button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
+.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
+td.retro { color: #ff9b6a; font-weight: 600; }
+/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
+ (DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
+ 1) selektor wariantu U+FE0E w samych danych (glyphs.py),
+ 2) font-variant-emoji:text — nowsza własność CSS,
+ 3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
+ fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
+ znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
+ „Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
+.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
+ font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
+ "DejaVu Sans", sans-serif; }
+td.glyph { color: var(--accent); white-space: nowrap; }
+/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
+.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
+.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
+.wheel-fig figcaption { margin-top: .4rem; }
+/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
+svg.wheel text.glyph { font-size: inherit; }
+/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
+.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
+.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
+.aspectarian-fig figcaption { margin-top: .4rem; }
+svg.aspectarian text.glyph { font-size: inherit; }
+/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
+.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
+.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
+.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
+.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
+svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
+/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
+ ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
+ ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
+ zostać na wierzchu. */
+.wheel-fig svg.wheel { cursor: zoom-in; }
+.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
+.wheel-fig.zoomed {
+ position: fixed; inset: 0; z-index: 1100; margin: 0;
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
+ gap: .5rem; padding: 1rem; background: var(--bg);
+}
+/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
+ bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
+.wheel-fig.zoomed svg.wheel {
+ max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
+}
+body.wheel-zoom-open { overflow: hidden; }
+/* nie przewijaj treści pod spodem */
+/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
+ koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
+ (1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
+ kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
+.wheel-tip {
+ position: fixed; z-index: 1150; pointer-events: none;
+ max-width: 22rem; padding: .4rem .6rem;
+ background: var(--panel); color: var(--fg, inherit);
+ border: 1px solid var(--line); border-radius: 8px;
+ font-size: .85rem; line-height: 1.35;
+ box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
+}
+.wheel-tip[hidden] { display: none; }
+/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
+ poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
+ nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
+ kolumn jest po prostu mniej. */
+.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
+ gap: .35rem 1rem; align-items: start; }
+.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
+.opts.systems label { display: flex; align-items: center; gap: .35rem; }
+.opts .hint { font-style: normal; opacity: .75; }
+.opts input[type=number] { width: 5rem; }
+.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
+/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
+ przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
+ 1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
+ się sama tabela, nigdy strona. */
+.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
+.table-scroll > table { margin-top: 0; border-radius: 0; }
+.table-scroll th, .table-scroll td { white-space: nowrap; }
+/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
+ nie wiadomo, który to wiersz. */
+.table-scroll th:first-child, .table-scroll td:first-child {
+ position: sticky; left: 0; background: var(--panel); z-index: 1;
+ box-shadow: 1px 0 0 var(--line);
+}
+.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
+ background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
+/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
+.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
+.readiness li { padding: .2rem 0; }
+.readiness .rdy-ok { color: #7fd18b; }
+.readiness .rdy-missing { color: #e0c060; }
+.report-head { margin: 1.5rem 0 .5rem; }
+.report-head h2 { margin: 0; font-size: 1.4rem; }
+/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
+ bez przepisywania ich na HTML. */
+.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
+ border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
+button.toggle {
+ background: none; border: 1px solid var(--line); border-radius: 50%;
+ width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
+}
+button.toggle.on { color: var(--accent); border-color: var(--accent); }
+button.toggle.off { color: var(--muted); }
diff --git a/services/presentation/app/static/s-files.css b/services/presentation/app/static/s-files.css
new file mode 100644
index 0000000..e45a83f
--- /dev/null
+++ b/services/presentation/app/static/s-files.css
@@ -0,0 +1,36 @@
+/* Arkusz ekranu „files” — dokładany tylko dla konta z tym uprawnieniem. */
+
+.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
+.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
+.grid input { width: 100%; }
+button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
+.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
+.opts input[type=number] { width: 5rem; }
+.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
+/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
+ przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
+ 1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
+ się sama tabela, nigdy strona. */
+.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
+.table-scroll > table { margin-top: 0; border-radius: 0; }
+.table-scroll th, .table-scroll td { white-space: nowrap; }
+/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
+ nie wiadomo, który to wiersz. */
+.table-scroll th:first-child, .table-scroll td:first-child {
+ position: sticky; left: 0; background: var(--panel); z-index: 1;
+ box-shadow: 1px 0 0 var(--line);
+}
+button.danger { background: #8b2f2f; }
+/* ── ekran plików (DAN-27) ──────────────────────────────────────────── */
+form.inline { display: inline; }
+td.ops form.inline + form.inline { margin-left: .4rem; }
+button.toggle {
+ background: none; border: 1px solid var(--line); border-radius: 50%;
+ width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
+}
+button.toggle.on { color: var(--accent); border-color: var(--accent); }
+button.toggle.off { color: var(--muted); }
+tr.row-archived td { opacity: .55; }
+/* Wstrzymane widzi tylko administrator — dla reszty tych wierszy nie ma
+ w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
+tr.row-held td { background: rgba(184, 134, 11, .07); }
diff --git a/services/presentation/app/static/s-interpret.css b/services/presentation/app/static/s-interpret.css
new file mode 100644
index 0000000..b326ef9
--- /dev/null
+++ b/services/presentation/app/static/s-interpret.css
@@ -0,0 +1,42 @@
+/* Arkusz ekranu „interpret” — dokładany tylko dla konta z tym uprawnieniem. */
+
+.sub { color: var(--muted); margin: .25rem 0 2rem; }
+.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
+.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
+.grid input { width: 100%; }
+button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
+td.retro { color: #ff9b6a; font-weight: 600; }
+.opts input[type=number] { width: 5rem; }
+.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
+.sig-item { margin-top: 1.25rem; }
+.sig-head { padding: .5rem 0; }
+.samples { margin-top: .3rem; }
+.samples td { font-size: .92rem; vertical-align: top; }
+.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
+.facet { margin: .4rem 0 .6rem 1rem; }
+.facet-head { color: var(--ink); font-size: .9rem; }
+.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
+ background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
+.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
+/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
+.geo { margin-top: .9rem; }
+.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
+.geo-search input[type=search] { flex: 1; min-width: 12rem; }
+.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
+ border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
+.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
+.geo-results li:last-child { border-bottom: none; }
+.geo-results li:hover { background: var(--panel); color: #fff; }
+.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
+.geo-map[hidden] { display: none; }
+.geo-attrib { margin: .4rem 0 0; }
+.geo-attrib a { color: var(--accent); }
+.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
+/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
+.leaflet-control-attribution { font-size: .68rem; }
+/* Generator promptu do LLM (LOG-29/30) */
+textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
+ background: #12132a; color: var(--ink); border: 1px solid var(--line);
+ border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
+textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
diff --git a/services/presentation/app/static/s-settings.css b/services/presentation/app/static/s-settings.css
new file mode 100644
index 0000000..7f1d8f3
--- /dev/null
+++ b/services/presentation/app/static/s-settings.css
@@ -0,0 +1,7 @@
+/* Arkusz ekranu „settings” — dokładany tylko dla konta z tym uprawnieniem. */
+
+.sub { color: var(--muted); margin: .25rem 0 2rem; }
+.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
+.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
+.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
+ background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
diff --git a/services/presentation/app/static/s-significators.css b/services/presentation/app/static/s-significators.css
new file mode 100644
index 0000000..f417ea9
--- /dev/null
+++ b/services/presentation/app/static/s-significators.css
@@ -0,0 +1,8 @@
+/* Arkusz ekranu „significators” — dokładany tylko dla konta z tym uprawnieniem. */
+
+.sub { color: var(--muted); margin: .25rem 0 2rem; }
+.row { display: flex; gap: .5rem; }
+.row input[type=text] { flex: 1; }
+.opts input[type=number] { width: 5rem; }
+.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
+.empty { color: var(--muted); }
diff --git a/services/presentation/app/static/s-synastry.css b/services/presentation/app/static/s-synastry.css
new file mode 100644
index 0000000..d1587b3
--- /dev/null
+++ b/services/presentation/app/static/s-synastry.css
@@ -0,0 +1,23 @@
+/* Arkusz ekranu „synastry” — dokładany tylko dla konta z tym uprawnieniem. */
+
+.sub { color: var(--muted); margin: .25rem 0 2rem; }
+.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
+.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
+.grid input { width: 100%; }
+.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
+td.retro { color: #ff9b6a; font-weight: 600; }
+/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
+ (DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
+ 1) selektor wariantu U+FE0E w samych danych (glyphs.py),
+ 2) font-variant-emoji:text — nowsza własność CSS,
+ 3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
+ fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
+ znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
+ „Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
+.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
+ font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
+ "DejaVu Sans", sans-serif; }
+td.glyph { color: var(--accent); white-space: nowrap; }
+svg.aspectarian text.glyph { font-size: inherit; }
+.opts input[type=number] { width: 5rem; }
+.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
diff --git a/services/presentation/app/static/s-timeline.css b/services/presentation/app/static/s-timeline.css
new file mode 100644
index 0000000..b4434d4
--- /dev/null
+++ b/services/presentation/app/static/s-timeline.css
@@ -0,0 +1,39 @@
+/* Arkusz ekranu „timeline” — dokładany tylko dla konta z tym uprawnieniem. */
+
+.sub { color: var(--muted); margin: .25rem 0 2rem; }
+.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
+.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
+.grid input { width: 100%; }
+button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
+td.retro { color: #ff9b6a; font-weight: 600; }
+.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
+.sig-item { margin-top: 1.25rem; }
+.sig-head { padding: .5rem 0; }
+.samples { margin-top: .3rem; }
+.samples td { font-size: .92rem; vertical-align: top; }
+.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
+.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
+ background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
+.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
+/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
+.geo { margin-top: .9rem; }
+.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
+.geo-search input[type=search] { flex: 1; min-width: 12rem; }
+.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
+ border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
+.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
+.geo-results li:last-child { border-bottom: none; }
+.geo-results li:hover { background: var(--panel); color: #fff; }
+.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
+.geo-map[hidden] { display: none; }
+.geo-attrib { margin: .4rem 0 0; }
+.geo-attrib a { color: var(--accent); }
+.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
+/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
+.leaflet-control-attribution { font-size: .68rem; }
+/* Generator promptu do LLM (LOG-29/30) */
+textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
+ background: #12132a; color: var(--ink); border: 1px solid var(--line);
+ border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
+textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css
deleted file mode 100644
index e42efc9..0000000
--- a/services/presentation/app/static/styles.css
+++ /dev/null
@@ -1,287 +0,0 @@
-:root {
- --bg: #0f1020;
- --panel: #1a1c33;
- --ink: #e8e8f0;
- --muted: #9aa0c0;
- --accent: #8b7bf0;
- --line: #2a2d4a;
-}
-* { box-sizing: border-box; }
-body {
- margin: 0; min-height: 100vh; background: var(--bg); color: var(--ink);
- font: 15px/1.5 system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
- display: flex; justify-content: center; padding: 3rem 1rem;
-}
-main { width: 100%; max-width: 880px; }
-h1 { margin: 0; font-size: 2rem; letter-spacing: .5px; }
-.sub { color: var(--muted); margin: .25rem 0 2rem; }
-
-.topbar { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; flex-wrap: wrap; margin-bottom: .25rem; }
-.topbar nav { display: flex; gap: .25rem; }
-.topbar nav a { color: var(--muted); text-decoration: none; padding: .4rem .8rem; border-radius: 8px; border: 1px solid transparent; }
-.topbar nav a:hover { color: var(--ink); }
-.topbar nav a.active { color: #fff; background: var(--panel); border-color: var(--line); }
-.foot { color: var(--muted); font-size: .8rem; margin-top: 2.5rem; opacity: .7; }
-
-.grid { display: flex; gap: .6rem; flex-wrap: wrap; }
-.grid label { display: flex; flex-direction: column; gap: .25rem; color: var(--muted); font-size: .85rem; flex: 1; min-width: 9rem; }
-.grid input { width: 100%; }
-.loc { margin-top: .8rem; }
-.loc summary { color: var(--muted); cursor: pointer; font-size: .85rem; margin-bottom: .6rem; }
-.actions { display: flex; gap: .5rem; margin-top: 1rem; }
-button.ghost { background: transparent; color: var(--accent); border: 1px solid var(--line); }
-.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .92em; }
-td.retro { color: #ff9b6a; font-weight: 600; }
-/* Glify astrologiczne (LOG-22). Trzy warstwy obrony przed prezentacją emoji
- (DAN-18: kolorowe kwadraty są nieczytelne i niesterowalne kolorem):
- 1) selektor wariantu U+FE0E w samych danych (glyphs.py),
- 2) font-variant-emoji:text — nowsza własność CSS,
- 3) font-family celujący w MONOCHROMATYCZNE fonty symboli PRZED jakimkolwiek
- fontem emoji. Bez tego macOS/Chromium i tak sięga po Apple Color Emoji dla
- znaków zodiaku (♈–♓), mimo VS15 — potwierdzone wizualnie. „Apple Symbols",
- „Segoe UI Symbol", „Noto Sans Symbols2" mają te glify w wersji tekstowej. */
-.glyph { font-size: 1.15em; line-height: 1; font-variant-emoji: text;
- font-family: "Apple Symbols", "Segoe UI Symbol", "Noto Sans Symbols2",
- "DejaVu Sans", sans-serif; }
-td.glyph { color: var(--accent); white-space: nowrap; }
-/* Kosmogram (PRE-12) — SVG skaluje się do szerokości, ale nie rośnie w nieskończoność. */
-.wheel-fig { margin: 1.5rem 0 .5rem; text-align: center; }
-.wheel-fig svg.wheel { width: 100%; max-width: 460px; height: auto; }
-.wheel-fig figcaption { margin-top: .4rem; }
-/* glify wewnątrz SVG dziedziczą monochromatyczny stack fontów (DAN-18) */
-svg.wheel text.glyph { font-size: inherit; }
-
-/* Aspektarian (PRE-18) — trójkątna siatka aspektów, węższa od koła. */
-.aspectarian-fig { margin: .25rem 0 1.25rem; text-align: center; }
-.aspectarian-fig svg.aspectarian { width: 100%; max-width: 380px; height: auto; }
-.aspectarian-fig figcaption { margin-top: .4rem; }
-svg.aspectarian text.glyph { font-size: inherit; }
-
-/* Wykres deklinacji i oś antyscji (LOG-07, etap 6) — szersze, pełna szerokość. */
-.declination-fig, .antiscia-fig { margin: 1.25rem 0 .75rem; text-align: center; }
-.declination-fig svg.declination { width: 100%; max-width: 560px; height: auto; }
-.antiscia-fig svg.antiscia { width: 100%; max-width: 560px; height: auto; }
-.declination-fig figcaption, .antiscia-fig figcaption { margin-top: .4rem; }
-svg.declination text.glyph, svg.antiscia text.glyph { font-size: inherit; }
-
-/* Powiększenie na pełne okno (PRE-25). Kliknięcie rozciąga wykres na cały ekran,
- ponowne wraca. z-index 1100 CELOWO pomiędzy: ponad kontrolki Leafleta (1000),
- ale PONIŻEJ okna postępu (1200) — gdy trwa pisanie horoskopu, log operacji ma
- zostać na wierzchu. */
-.wheel-fig svg.wheel { cursor: zoom-in; }
-.wheel-fig svg.wheel:focus-visible { outline: 2px solid var(--accent); outline-offset: 4px; }
-.wheel-fig.zoomed {
- position: fixed; inset: 0; z-index: 1100; margin: 0;
- display: flex; flex-direction: column; align-items: center; justify-content: center;
- gap: .5rem; padding: 1rem; background: var(--bg);
-}
-/* min() po obu wymiarach: koło jest kwadratowe, więc ograniczamy je krótszym
- bokiem okna — inaczej na szerokim ekranie wystawałoby w pionie. */
-.wheel-fig.zoomed svg.wheel {
- max-width: min(96vw, 92vh); width: min(96vw, 92vh); height: auto; cursor: zoom-out;
-}
-body.wheel-zoom-open { overflow: hidden; } /* nie przewijaj treści pod spodem */
-
-/* Dymek kosmogramu. z-index 1150 CELOWO pomiędzy: ponad nakładką powiększonego
- koła (1100), żeby działał także po powiększeniu, ale PONIŻEJ okna postępu
- (1200), które ma zostać na wierzchu. pointer-events: none — dymek chodzi za
- kursorem i nie może sam siebie zasłonić ani przechwycić najechania. */
-.wheel-tip {
- position: fixed; z-index: 1150; pointer-events: none;
- max-width: 22rem; padding: .4rem .6rem;
- background: var(--panel); color: var(--fg, inherit);
- border: 1px solid var(--line); border-radius: 8px;
- font-size: .85rem; line-height: 1.35;
- box-shadow: 0 4px 14px rgba(0, 0, 0, .45);
-}
-.wheel-tip[hidden] { display: none; }
-form { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 1.25rem; }
-.row { display: flex; gap: .5rem; }
-.row input[type=text] { flex: 1; }
-input, select, button {
- font: inherit; padding: .6rem .75rem; border-radius: 8px;
- border: 1px solid var(--line); background: #12132a; color: var(--ink);
-}
-button { background: var(--accent); color: #fff; border: none; cursor: pointer; padding-inline: 1.25rem; }
-button:hover { filter: brightness(1.1); }
-.opts { display: flex; gap: 1.5rem; margin-top: .75rem; color: var(--muted); align-items: center; }
-
-/* Lista systemów domów urosła z 3 do 13 pozycji. Na flexie zawijały się w cztery
- poszarpane wiersze o różnej długości; siatka o stałej szerokości kolumny ustawia
- nazwy w pionie i daje się przeczytać wzrokiem. auto-fill, więc na wąskim ekranie
- kolumn jest po prostu mniej. */
-.opts.systems { display: grid; grid-template-columns: repeat(auto-fill, minmax(11.5rem, 1fr));
- gap: .35rem 1rem; align-items: start; }
-.opts.systems > .opts-label { grid-column: 1 / -1; margin-bottom: .1rem; }
-.opts.systems label { display: flex; align-items: center; gap: .35rem; }
-.opts .hint { font-style: normal; opacity: .75; }
-.opts input[type=number] { width: 5rem; }
-.meta { color: var(--muted); margin: 1.5rem 0 .5rem; font-size: .9rem; }
-
-/* Fallback systemu domów. Celowo NIE jest to dyskretny „muted" — po fallbacku
- kosmogram wygląda bezbłędnie, więc ten akapit jest jedynym sygnałem, że
- podział domów pochodzi z innego systemu, niż zamówiono. */
-.house-warning {
- margin: 1rem 0; padding: .75rem 1rem; font-size: .92rem; line-height: 1.45;
- border: 1px solid var(--warn-border, #b8860b);
- border-left-width: 4px;
- border-radius: 4px;
- background: var(--warn-bg, rgba(184, 134, 11, .10));
- color: var(--warn-fg, inherit);
-}
-.error { background: #3a1320; border: 1px solid #6a2233; color: #ffb3c0; padding: .75rem 1rem; border-radius: 8px; margin-top: 1.5rem; }
-.empty { color: var(--muted); }
-table { width: 100%; border-collapse: collapse; margin-top: .5rem; background: var(--panel); border-radius: 12px; overflow: hidden; }
-
-/* Tabela porównania systemów domów ma tyle kolumn, ile zaznaczono systemów —
- przy komplecie trzynastu robi się 1520 px i ROZPYCHAŁA CAŁĄ STRONĘ (dokument
- 1713 px przy oknie 1280 px, poziomy pasek przewijania na body). Przewijać ma
- się sama tabela, nigdy strona. */
-.table-scroll { overflow-x: auto; margin-top: .5rem; border-radius: 12px; }
-.table-scroll > table { margin-top: 0; border-radius: 0; }
-.table-scroll th, .table-scroll td { white-space: nowrap; }
-
-/* Numer domu przyklejony do lewej krawędzi: bez tego po przewinięciu w prawo
- nie wiadomo, który to wiersz. */
-.table-scroll th:first-child, .table-scroll td:first-child {
- position: sticky; left: 0; background: var(--panel); z-index: 1;
- box-shadow: 1px 0 0 var(--line);
-}
-th, td { text-align: left; padding: .6rem .8rem; border-bottom: 1px solid var(--line); }
-th { color: var(--accent); font-size: .8rem; text-transform: uppercase; letter-spacing: .5px; }
-tr:last-child td { border-bottom: none; }
-
-.muted { color: var(--muted); }
-.small { font-size: .85rem; }
-.nowrap { white-space: nowrap; }
-.sig-item { margin-top: 1.25rem; }
-.sig-head { padding: .5rem 0; }
-.samples { margin-top: .3rem; }
-.samples td { font-size: .92rem; vertical-align: top; }
-.samples td.nowrap { color: var(--accent); padding-right: 1rem; }
-.facet { margin: .4rem 0 .6rem 1rem; }
-.facet-head { color: var(--ink); font-size: .9rem; }
-.badge { display: inline-block; font-size: .72rem; padding: .05rem .4rem; border-radius: 6px;
- background: #2a2d4a; color: var(--accent); margin-left: .3rem; }
-.samples td.sig { color: var(--accent); padding-right: 1rem; cursor: help; }
-
-/* Wyszukiwarka lokalizacji + mapa (OSM/Leaflet) */
-.geo { margin-top: .9rem; }
-.geo-search { display: flex; gap: .5rem; flex-wrap: wrap; }
-.geo-search input[type=search] { flex: 1; min-width: 12rem; }
-.geo-results { list-style: none; margin: .4rem 0 0; padding: 0; border: 1px solid var(--line);
- border-radius: 8px; overflow: hidden; background: #12132a; max-height: 14rem; overflow-y: auto; }
-.geo-results li { padding: .5rem .75rem; cursor: pointer; border-bottom: 1px solid var(--line); font-size: .9rem; }
-.geo-results li:last-child { border-bottom: none; }
-.geo-results li:hover { background: var(--panel); color: #fff; }
-.geo-map { height: 320px; margin-top: .6rem; border: 1px solid var(--line); border-radius: 10px; }
-.geo-map[hidden] { display: none; }
-.geo-attrib { margin: .4rem 0 0; }
-.geo-attrib a { color: var(--accent); }
-.geo-pin { font-size: 20px; line-height: 24px; text-align: center; }
-/* kafelki OSM są jasne — trzymamy kontrolki czytelne na ciemnym tle */
-.leaflet-control-attribution { font-size: .68rem; }
-
-/* Generator promptu do LLM (LOG-29/30) */
-textarea.prompt { width: 100%; margin-top: .5rem; padding: .7rem .8rem; box-sizing: border-box;
- background: #12132a; color: var(--ink); border: 1px solid var(--line);
- border-radius: 10px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
- font-size: .82rem; line-height: 1.45; resize: vertical; white-space: pre; }
-textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
-
-/* Okno postępu przy generowaniu horoskopu */
-/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
- atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
- kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
- wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
-.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
- display: flex; align-items: center; justify-content: center;
- z-index: 1200; padding: 1rem; }
-.progress-overlay[hidden] { display: none; }
-.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
- padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
-.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
-.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
- color: var(--muted); font-size: .9rem; }
-.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
- border: 2px solid var(--line); border-top-color: var(--accent);
- animation: spin .8s linear infinite; }
-@keyframes spin { to { transform: rotate(360deg); } }
-@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
-.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
- background: #12132a; border: 1px solid var(--line); border-radius: 10px;
- max-height: 15rem; overflow-y: auto;
- font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
-.progress-log li { color: var(--ink); white-space: pre-wrap; }
-.progress-log li.log-ok { color: #7fd18b; }
-.progress-log li.log-warn { color: #e0c060; }
-.progress-log li.log-err { color: #ef6b6b; }
-
-/* Zakładka „Skompiluj" (PRE-23) — składanie raportu z trzech źródeł. */
-.readiness { list-style: none; margin: .3rem 0 1rem; padding: 0; }
-.readiness li { padding: .2rem 0; }
-.readiness .rdy-ok { color: #7fd18b; }
-.readiness .rdy-missing { color: #e0c060; }
-.report-head { margin: 1.5rem 0 .5rem; }
-.report-head h2 { margin: 0; font-size: 1.4rem; }
-/* Treść od AI bywa długa — white-space:pre-wrap zachowuje akapity modelu,
- bez przepisywania ich na HTML. */
-.report-text { white-space: pre-wrap; background: var(--panel); border: 1px solid var(--line);
- border-radius: 10px; padding: .8rem 1rem; margin-top: .4rem; line-height: 1.6; }
-
-
-/* ── ekran kont (PRE-26) ────────────────────────────────────────────── */
-.account-card {
- margin-top: 1rem; padding: 1rem 1.25rem;
- background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
-}
-.account-head { display: flex; align-items: baseline; gap: .75rem; margin-bottom: .5rem; }
-.account-note input { width: 100%; }
-.account-card .actions { margin-top: .9rem; }
-button.danger { background: #8b2f2f; }
-
-/* Potwierdzenie operacji. Odróżnia się od .house-warning, bo tamto ostrzega,
- a to tylko potwierdza — pomylenie ich uczy ignorować oba. */
-.done-note {
- margin: 1rem 0; padding: .6rem .9rem; font-size: .9rem;
- border: 1px solid var(--line); border-left: 4px solid var(--accent);
- border-radius: 4px; background: rgba(255, 255, 255, .03);
-}
-
-
-/* ── ekran plików (DAN-27) ──────────────────────────────────────────── */
-form.inline { display: inline; }
-td.ops form.inline + form.inline { margin-left: .4rem; }
-button.toggle {
- background: none; border: 1px solid var(--line); border-radius: 50%;
- width: 1.9rem; height: 1.9rem; padding: 0; font-size: 1rem; line-height: 1;
-}
-button.toggle.on { color: var(--accent); border-color: var(--accent); }
-button.toggle.off { color: var(--muted); }
-tr.row-archived td { opacity: .55; }
-/* Wstrzymane widzi tylko administrator — dla reszty tych wierszy nie ma
- w ogóle w odpowiedzi, więc ten styl nigdy nie dotyczy ich strony. */
-tr.row-held td { background: rgba(184, 134, 11, .07); }
-
-
-/* ── wyjście z aplikacji (LOG-32) ───────────────────────────────────── */
-/* Odsunięte od zakładek i wyglądające inaczej niż one: to czynność, nie miejsce,
- a pomylenie go z zakładką kosztuje przypadkowe wylogowania. Odstęp robimy
- marginesem, nie rozpychaczem — `nav` ma szerokość treści, więc nie ma czego
- rozpychać. */
-.whoami { color: var(--muted); font-size: .85rem; align-self: center;
- margin-left: 1rem; padding-left: 1rem; border-left: 1px solid var(--line); }
-/* Wylogowanie idzie POST-em, więc jest przyciskiem — ale ma wyglądać jak
- reszta paska, nie jak przycisk akcji w formularzu. */
-.wyloguj-form { display: inline; margin: 0; padding: 0; background: none; border: none; }
-button.wyloguj {
- background: none; border: 1px solid transparent; color: var(--muted);
- font: inherit; padding: .4rem .8rem; border-radius: 8px; cursor: pointer;
-}
-button.wyloguj:hover { color: var(--ink); }
-
-/* Ekran logowania — wąski, bez nawigacji: nie ma dokąd iść przed zalogowaniem. */
-.login-main { max-width: 26rem; }
-.login-card label { display: block; margin-top: .75rem; }
-.login-card input { width: 100%; }
-.login-note { margin-top: 1.25rem; }
diff --git a/services/presentation/app/static/x-ai.css b/services/presentation/app/static/x-ai.css
new file mode 100644
index 0000000..0cc2d13
--- /dev/null
+++ b/services/presentation/app/static/x-ai.css
@@ -0,0 +1,31 @@
+/* Generowanie tekstu przez model — dokładany wyłącznie, gdy konto ma
+ uprawnienie „ai”. Selektory nazywają funkcję, więc arkusz nie może
+ wchodzić do arkuszy ekranów, na których ta funkcja bywa wyłączona. */
+
+/* Okno postępu przy generowaniu horoskopu */
+/* z-index ponad Leaflet: jego kontrolki (.leaflet-top/.leaflet-bottom — zoom,
+ atrybucja) siedzą na z-index:1000 i — bo .leaflet-container nie tworzy własnego
+ kontekstu stackowania — trafiają wprost do korzenia. Przy z-index:50 mapa
+ wychodziła NA WIERZCH modala. 1200 daje zapas nad 1000. */
+.progress-overlay { position: fixed; inset: 0; background: rgba(8,9,20,.72);
+ display: flex; align-items: center; justify-content: center;
+ z-index: 1200; padding: 1rem; }
+.progress-overlay[hidden] { display: none; }
+.progress-box { background: var(--panel); border: 1px solid var(--line); border-radius: 14px;
+ padding: 1rem 1.1rem; width: min(38rem, 100%); box-shadow: 0 18px 48px rgba(0,0,0,.45); }
+.progress-head { display: flex; align-items: center; gap: .6rem; margin-bottom: .6rem; }
+.progress-clock { margin-left: auto; font-family: ui-monospace, Menlo, monospace;
+ color: var(--muted); font-size: .9rem; }
+.progress-spinner { width: 14px; height: 14px; border-radius: 50%; flex: none;
+ border: 2px solid var(--line); border-top-color: var(--accent);
+ animation: spin .8s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+@media (prefers-reduced-motion: reduce) { .progress-spinner { animation: none; } }
+.progress-log { list-style: none; margin: 0 0 .6rem; padding: .6rem .7rem;
+ background: #12132a; border: 1px solid var(--line); border-radius: 10px;
+ max-height: 15rem; overflow-y: auto;
+ font-family: ui-monospace, Menlo, monospace; font-size: .78rem; line-height: 1.6; }
+.progress-log li { color: var(--ink); white-space: pre-wrap; }
+.progress-log li.log-ok { color: #7fd18b; }
+.progress-log li.log-warn { color: #e0c060; }
+.progress-log li.log-err { color: #ef6b6b; }
diff --git a/services/presentation/app/templates/_result_tables.html b/services/presentation/app/templates/_result_tables.html
index 10f156e..302182d 100644
--- a/services/presentation/app/templates/_result_tables.html
+++ b/services/presentation/app/templates/_result_tables.html
@@ -10,7 +10,7 @@
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
{% if result.house_warnings %}
{% for w in result.house_warnings %}
-