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 %} -

{{ w }}

+

{{ w }}

{% endfor %} {% endif %} diff --git a/services/presentation/app/templates/accounts.html b/services/presentation/app/templates/accounts.html index a8e1b39..976af11 100644 --- a/services/presentation/app/templates/accounts.html +++ b/services/presentation/app/templates/accounts.html @@ -2,6 +2,9 @@ {% set nav_active = "accounts" %} {% block title %}Konta{% endblock %} +{% block styles %} +{% endblock %} + {% block content %}

Konta i ich dostęp do funkcji programu. Konto {{ admin_login }} @@ -15,7 +18,7 @@ odpowiadają tak samo jak nieistniejące.

-{% if error %}

{{ error }}

{% endif %} +{% if error %}

{{ error }}

{% endif %} {% if done %}

{{ done }}

{% endif %}
Konta ({{ users | length }})
@@ -24,7 +27,7 @@ {% endif %} {% for login, row in users | dictsort %} -
+
{{ login }} @@ -66,7 +69,7 @@ {% endfor %}
Nowe konto
- +
diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html index 93e0710..4eb5bcf 100644 --- a/services/presentation/app/templates/base.html +++ b/services/presentation/app/templates/base.html @@ -4,16 +4,18 @@ astrololo · {% block title %}{% endblock %} - - {# Wspólne dane formularza między zakładkami (PRE-21). W z `defer` - CELOWO: skrypty defer wykonują się w kolejności dokumentu, więc ten zdąży - odtworzyć współrzędne, ZANIM geo.js zbuduje mapę — mapa startuje od razu - we właściwym miejscu, bez przestawiania. #} - - {# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na - zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #} - - + {# Arkusz wspólny niesie WYŁĄCZNIE powłokę i logowanie — wszystko, co nazywa + jakąkolwiek funkcję, mieszka w arkuszu tej funkcji i dokłada je `styles`. + Inaczej jeden plik, pobierany też przed zalogowaniem, byłby spisem treści + programu: nazwa selektora opowiada o funkcji tak samo dobrze jak przycisk. #} + + {% block styles %}{% endblock %} + {# Skrypty dokłada ekran, który ich potrzebuje. Ładowane stąd docierały na + KAŻDĄ stronę — także do konta, które ma wyłącznie Pliki — a ich treść + wymienia z nazwy ekrany, których takie konto nie ma prawa znać. + Blok jest w , bo `defer` wykonuje skrypty w kolejności dokumentu: + formsync musi odtworzyć współrzędne, ZANIM geo.js zbuduje mapę. #} + {% block head_scripts %}{% endblock %}
@@ -45,9 +47,15 @@ {% block content %}{% endblock %} + {# Stopka wymieniała warstwy architektury i nazywała całość „widokiem + testowym" — czyli mówiła, że to nie jest gotowy produkt i że za nim stoi + łańcuch usług. Dla konta ograniczonego program ma wyglądać na kompletny, + więc ten opis widzi wyłącznie administrator. #} + {% if can(request, 'admin') %}
prezentacja → logika → dane · widok testowy
+ {% endif %}
diff --git a/services/presentation/app/templates/chart.html b/services/presentation/app/templates/chart.html index 0c87e80..4ec941b 100644 --- a/services/presentation/app/templates/chart.html +++ b/services/presentation/app/templates/chart.html @@ -2,6 +2,14 @@ {% block title %}Horoskop{% endblock %} {% set nav_active = "chart" %} +{% block styles %} +{% endblock %} +{% block head_scripts %} + + + +{% endblock %} + {% block content %}

Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).

@@ -71,7 +79,7 @@ {% if result %} {# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #} {% for w in result.house_warnings or [] %} -

{{ w }}

+

{{ w }}

{% endfor %}
Silnik: {{ result.engine }} · diff --git a/services/presentation/app/templates/compile.html b/services/presentation/app/templates/compile.html index d3ebcce..71cb211 100644 --- a/services/presentation/app/templates/compile.html +++ b/services/presentation/app/templates/compile.html @@ -2,6 +2,15 @@ {% block title %}Skompiluj{% endblock %} {% set nav_active = "compile" %} +{% block styles %} +{% if can(request, 'ai') %}{% endif %} +{% endblock %} +{% block head_scripts %} + + + +{% endblock %} + {% block content %} {# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja z AI wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu diff --git a/services/presentation/app/templates/files.html b/services/presentation/app/templates/files.html index e155f51..e8bf9f6 100644 --- a/services/presentation/app/templates/files.html +++ b/services/presentation/app/templates/files.html @@ -2,18 +2,25 @@ {% set nav_active = "files" %} {% block title %}Pliki{% endblock %} +{% block styles %} +{% endblock %} + {% block content %} +{# Opis czysto operacyjny: co robi zaznaczenie, i nic o tym, do CZEGO te dane + służą. Poprzednia treść nazywała je „bazami interpretacyjnymi" i mówiła + o „wyszukiwaniu" — czyli zdradzała i dziedzinę programu, i istnienie ekranu + wyszukiwarki. To jest jedyny ekran, który ogląda konto wgrywające dane, więc + każde zdanie na nim waży najwięcej. #}

- Bazy interpretacyjne, z których korzysta program. Zaznaczona baza bierze udział - w wyszukiwaniu; odznaczona zostaje na dysku, ale program jej nie używa. + Zaznaczony plik jest w użyciu. Odznaczony zostaje na dysku, ale nie jest używany.

-{% if error %}

{{ error }}

{% endif %} +{% if error %}

{{ error }}

{% endif %} {% if done %}

{{ done }}

{% endif %} -
Bazy ({{ files | length }})
+
Pliki ({{ files | length }})
{% if not files %} -

Nie ma jeszcze żadnego pliku bazy.

+

Nie ma jeszcze żadnego pliku.

{% endif %}
@@ -35,7 +42,7 @@ @@ -84,8 +91,8 @@
{% if can(request, 'files_input') %} -
Wgraj nową bazę
-