PRE-27: pełne ukrycie niedostępnych funkcji (paranoja)
build-render / build (push) Failing after 8s
build-swisseph / build (push) Successful in 9s
build / build (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 5s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 4s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 4s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Failing after 4s
build-render / build (push) Failing after 8s
build-swisseph / build (push) Successful in 9s
build / build (push) Successful in 8s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 5s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 4s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 4s
Testy / Build obrazu silnika B (swisseph) (push) Successful in 6s
Testy / Kontrola składni wszystkich warstw (push) Failing after 4s
Ukrywanie jest teraz nadrzędne wobec wygody i czytelności komunikatów. Persona: konto z uprawnieniami files + files_input, zatrudnione wyłącznie do wgrywania plików. Nie ma się dowiedzieć, po co je wgrywa ani co program będzie robił — bo to rozgada. Audyt sześciu kanałów wycieku (statyki, HTML, sondowanie HTTP, ekran plików, odpowiedzi JSON i błędy, pozostałe warstwy) potwierdził 26 wycieków, każdy odtworzony uruchomionym kodem i zweryfikowany adwersarialnie. Ani jeden nie był przyciskiem. ZASÓB JEST CZĘŚCIĄ FUNKCJI /static/ omijało CAŁĄ bramkę (PUBLIC_PREFIXES), więc każdy skrypt i arkusz pobierał ktokolwiek, także niezalogowany, pod zgadywalnym adresem — a ich treść wymienia ekrany, dostawców modeli i przeznaczenie plików. Ruch ten nie trafiał przy tym ani do dziennika, ani pod limit żądań, więc wyciek był niewidoczny. Zasoby idą teraz trasą z bramką; każdy ma w features.STATIC uprawnienie swojego ekranu. Publiczny został jeden base.css, bo potrzebuje go ekran logowania. KOMENTARZ NIE JEDZIE NA DRUT Komentarze w CSS/JS opisywały funkcje pełnymi zdaniami po polsku — łącznie z „Wstrzymane widzi tylko administrator", czyli i mechanizmem kwarantanny, i istnieniem konta o wyższych uprawnieniach. _asset_body() usuwa je przy serwowaniu; w repozytorium zostają. styles.css rozbity na base.css + arkusz na ekran + x-ai.css. Jeden plik z wszystkimi selektorami był spisem treści programu. Podział zrobiony mechanicznie, z osobnym sprawdzeniem, że żaden ekran nie stracił reguły. base.html ładował skrypty kosmogramu na KAŻDEJ stronie — konto mające wyłącznie Pliki pobierało je przy wejściu na swój jedyny ekran, razem ze wzmianką o „przyszłej zakładce". Teraz dokłada je ekran, który ich używa. RÓŻNICA JEST INFORMACJĄ Komunikat po wgraniu pliku różnił się zależnie od wyniku walidacji — czyli był wyrocznią do odgadywania reguł, które ma znać tylko administrator — i mówił wprost, że plik „musi zatwierdzić administrator". Teraz jest jeden, ten sam. _logic_error wypisywał na ekran nazwę trasy, nazwę podsystemu, nazwę gałęzi rozwojowej i wewnętrzny host:port. Jedno zdanie dla wszystkich awarii, szczegóły do dziennika. Odsiew w jednym punkcie, nie w siedemnastu wywołaniach. Ponadto: stopka nie ogłasza architektury, /health nie nazywa warstwy, konto bez ekranów dostaje 404 zamiast tłumaczenia, ekran plików mówi o plikach zamiast o „bazach interpretacyjnych", klasy .house-warning i .account-card przemianowane na neutralne, a logic/data/render/engine-swisseph nie wystawiają już /docs ani /openapi.json i nie publikują portów na hoście. ZAPORA SŁOWNIKOWA 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ść (87 pozycji dla tej persony). Nazwy funkcji, adresy ekranów i nazwy zasobów biorą się wprost z katalogu, więc nowa funkcja obejmuje się sama. Kontrola pozytywna pilnuje, żeby test nie przechodził dlatego, że program jest pusty. Sprawdzone: zapora puszczona na treść sprzed poprawek daje 16 trafień na samym styles.css i łapie każdy ze zneutralizowanych komunikatów. 358 testów zielonych, ekrany obejrzane w przeglądarce. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #78.
This commit is contained in:
@@ -16,6 +16,8 @@ services/logic/.ephemeris/
|
|||||||
|
|
||||||
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
# Dane wejściowe (duże pliki Excela trzymane poza repo)
|
||||||
services/data/data_files/*.xlsx
|
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
|
!services/data/data_files/.gitkeep
|
||||||
|
|
||||||
# Narzędzia
|
# Narzędzia
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./services/data/data_files:/app/data_files
|
- ./services/data/data_files:/app/data_files
|
||||||
- data_cache:/app/.cache
|
- data_cache:/app/.cache
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8002:8002"
|
# 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:
|
logic:
|
||||||
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
|
image: gitea.czernobog.pl/gitea/astrololo-logic:latest
|
||||||
@@ -25,8 +27,10 @@ services:
|
|||||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||||
depends_on:
|
depends_on:
|
||||||
- data
|
- data
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8001:8001"
|
# 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":
|
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||||
# docker compose --profile comparison up
|
# docker compose --profile comparison up
|
||||||
@@ -36,8 +40,10 @@ services:
|
|||||||
container_name: astrololo-engine-swisseph
|
container_name: astrololo-engine-swisseph
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
profiles: ["comparison"]
|
profiles: ["comparison"]
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8003:8003"
|
# 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:
|
presentation:
|
||||||
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
|
image: gitea.czernobog.pl/gitea/astrololo-presentation:latest
|
||||||
|
|||||||
+12
-6
@@ -9,8 +9,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- ./services/data/data_files:/app/data_files
|
- ./services/data/data_files:/app/data_files
|
||||||
- data_cache:/app/.cache
|
- data_cache:/app/.cache
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8002:8002"
|
# 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:
|
logic:
|
||||||
build: ./services/logic
|
build: ./services/logic
|
||||||
@@ -21,8 +23,10 @@ services:
|
|||||||
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
ENGINE_SWISSEPH_URL: http://engine-swisseph:8003
|
||||||
depends_on:
|
depends_on:
|
||||||
- data
|
- data
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8001:8001"
|
# 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":
|
# Silnik B (AGPL) — OPCJONALNY, izolowany. Startuje tylko z profilem "comparison":
|
||||||
# docker compose --profile comparison up
|
# docker compose --profile comparison up
|
||||||
@@ -30,8 +34,10 @@ services:
|
|||||||
engine-swisseph:
|
engine-swisseph:
|
||||||
build: ./services/engine-swisseph
|
build: ./services/engine-swisseph
|
||||||
profiles: ["comparison"]
|
profiles: ["comparison"]
|
||||||
ports:
|
# Bez `ports:` CELOWO. Ta usługa rozmawia wyłącznie po sieci wewnętrznej
|
||||||
- "8003:8003"
|
# 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:
|
presentation:
|
||||||
build: ./services/presentation
|
build: ./services/presentation
|
||||||
|
|||||||
@@ -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,
|
pozytywną, że przy uprawnieniu wszystkie są. Inaczej test przechodzi także wtedy,
|
||||||
gdy generowanie jest zepsute dla wszystkich.
|
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
|
## Gdzie leżą konta
|
||||||
|
|
||||||
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
|
Plik JSON wskazany przez `ACCOUNTS_FILE` (domyślnie `/app/state/accounts.json`),
|
||||||
|
|||||||
@@ -28,7 +28,12 @@ async def lifespan(app: FastAPI):
|
|||||||
yield
|
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)
|
security.install(app, "danych") # token międzywarstwowy (LOG-32)
|
||||||
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
# Szyfrowanie łącza od logiki. PO `security.install`, żeby także odmowa
|
||||||
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
|
|||||||
@@ -20,7 +20,12 @@ from pydantic import BaseModel
|
|||||||
|
|
||||||
import swisseph as swe
|
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).
|
# Tryb Moshiera: bez plików efemeryd, w pełni samowystarczalny (~0,1\" dokładności).
|
||||||
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
|
_FLAGS = swe.FLG_MOSEPH | swe.FLG_SPEED
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ from app.clients.data_client import DataClient
|
|||||||
from app.models import QueryRequest, QueryResponse
|
from app.models import QueryRequest, QueryResponse
|
||||||
from app.service import QueryService
|
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()
|
service = QueryService()
|
||||||
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
security.install(app, "logiczna") # token międzywarstwowy (LOG-32)
|
||||||
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
# Szyfrowanie łącza od prezentacji. PO `security.install`, żeby także odmowa
|
||||||
|
|||||||
@@ -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.
|
"""Uprawnienie wymagane przez trasę. Nieznana trasa → wymaga admina.
|
||||||
|
|
||||||
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
|
Domyślną odpowiedzią jest NAJWĘŻSZE uprawnienie, nie najszersze: dopisanie
|
||||||
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
|
trasy bez wpisu w ROUTES ma ją zamknąć, a nie otworzyć. Odwrotny domyślny
|
||||||
wybór oznaczałby, że każde przeoczenie tworzy dziurę."""
|
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)
|
return ROUTES.get((method.upper(), path), ADMIN)
|
||||||
|
|
||||||
|
|
||||||
def can(perms: frozenset[str], feature: str | None) -> bool:
|
def can(perms: frozenset[str], feature: frozenset[str] | str | None) -> bool:
|
||||||
return feature is None or feature in perms
|
"""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]:
|
def screens_for(perms: frozenset[str]) -> list[Feature]:
|
||||||
|
|||||||
@@ -12,14 +12,15 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timedelta, timezone
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
|
||||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
|
||||||
from fastapi.staticfiles import StaticFiles
|
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
|
|
||||||
import base64
|
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.
|
# gdyby obok leżał ich katalog. Znalezione testem przechodzącym po trasach aplikacji.
|
||||||
app = FastAPI(title="astrololo · warstwa prezentacji",
|
app = FastAPI(title="astrololo · warstwa prezentacji",
|
||||||
docs_url=None, redoc_url=None, openapi_url=None)
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
app.mount("/static", StaticFiles(directory="app/static"), name="static")
|
|
||||||
templates = Jinja2Templates(directory="app/templates")
|
templates = Jinja2Templates(directory="app/templates")
|
||||||
|
# Szczegóły awarii idą TUTAJ, nie na ekran (patrz _logic_error).
|
||||||
|
log = logging.getLogger("astrololo.prezentacja")
|
||||||
logic = LogicClient()
|
logic = LogicClient()
|
||||||
security.install(app) # logowanie + limit żądań (LOG-32)
|
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")
|
_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)
|
@lru_cache(maxsize=None)
|
||||||
def _asset_version(name: str) -> str:
|
def _asset_version(name: str) -> str:
|
||||||
try:
|
tresc = _asset_body(name)
|
||||||
return hashlib.md5((_STATIC_DIR / name).read_bytes()).hexdigest()[:8]
|
# Hash liczony z treści WYSYŁANEJ, nie z pliku na dysku: inaczej zmiana
|
||||||
except OSError:
|
# samego komentarza podbijałaby adres, choć odpowiedź jest ta sama.
|
||||||
return "0" # brak pliku nie może wywrócić strony
|
return hashlib.md5(tresc).hexdigest()[:8] if tresc is not None else "0"
|
||||||
|
|
||||||
|
|
||||||
def static_url(name: str) -> str:
|
def static_url(name: str) -> str:
|
||||||
return f"/static/{name}?v={_asset_version(name)}"
|
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
|
templates.env.globals["static"] = static_url
|
||||||
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
|
# Katalog systemów domów — jedno źródło nazw dla wszystkich szablonów.
|
||||||
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
templates.env.globals["HOUSE_SYSTEMS"] = HOUSE_SYSTEMS
|
||||||
@@ -179,13 +249,24 @@ def _report_records(report: dict | None) -> int:
|
|||||||
return total
|
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:
|
def _logic_error(e: Exception) -> str:
|
||||||
if isinstance(e, httpx.HTTPStatusError) and e.response.status_code == 404:
|
"""Komunikat dla użytkownika. Diagnostyka idzie do dziennika, nie na ekran.
|
||||||
return (
|
|
||||||
"Warstwa logiczna działa, ale nie ma endpointu /chart/positions. "
|
Odsiew siedzi TUTAJ, a nie w siedemnastu miejscach wywołania: gdyby zależał
|
||||||
"Uruchom warstwę logiczną z silnikiem efemeryd (gałąź feat/logic-engine)."
|
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ć.
|
||||||
return f"Warstwa logiczna niedostępna: {e}"
|
"""
|
||||||
|
log.warning("warstwa logiczna: %s: %s", type(e).__name__, e)
|
||||||
|
return _AWARIA
|
||||||
|
|
||||||
|
|
||||||
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
# ---------------- Horoskop: pozycje (strona główna) ----------------
|
||||||
@@ -198,9 +279,11 @@ def chart_form(request: Request):
|
|||||||
target = _landing(request)
|
target = _landing(request)
|
||||||
if target:
|
if target:
|
||||||
return RedirectResponse(target, status_code=303)
|
return RedirectResponse(target, status_code=303)
|
||||||
return HTMLResponse("<h1>Brak przypisanych funkcji</h1>"
|
# Ta sama odpowiedź co bramka dla trasy bez uprawnienia. Poprzednia treść
|
||||||
"<p>Skontaktuj się z osobą, która zakładała konto.</p>",
|
# mówiła o „przypisanych funkcjach" i o „osobie, która zakładała konto",
|
||||||
status_code=200)
|
# czyli wprost: funkcje są komuś przydzielane, jest ktoś, kto to robi,
|
||||||
|
# a twoje konto zostało okrojone.
|
||||||
|
return HTMLResponse("<h1>404 — nie znaleziono</h1>", status_code=404)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
request, "chart.html",
|
request, "chart.html",
|
||||||
{"result": None, "form": default_form(), "location_label": DEFAULT_LOCATION_LABEL,
|
{"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:
|
except httpx.HTTPError as e:
|
||||||
return _files_redirect(error=_logic_error(e))
|
return _files_redirect(error=_logic_error(e))
|
||||||
return _files_redirect(done="Zapisano." if want == files_state.READY
|
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")
|
@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))
|
base64.b64encode(raw).decode("ascii"), by=_who(request))
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
return _files_redirect(error=_logic_error(e))
|
return _files_redirect(error=_logic_error(e))
|
||||||
if out.get("accepted"):
|
# DOKŁADNIE ten sam komunikat niezależnie od wyniku sprawdzenia. Dwa różne
|
||||||
return _files_redirect(done=f"Wgrano „{out.get('name')}”. Można ją włączyć do użytku.")
|
# komunikaty były wyrocznią: wystarczyło wgrywać spreparowane pliki i czytać
|
||||||
# Bez powodu i bez słowa „walidacja" — poza administratorem nikt nie ma
|
# odpowiedź, żeby odgadnąć reguły, które ma znać wyłącznie administrator.
|
||||||
# skąd wiedzieć, że taki mechanizm istnieje.
|
# Poprzednia treść mówiła przy tym wprost, że istnieje „administrator", który
|
||||||
return _files_redirect(done=f"Wgrano „{out.get('name')}”. "
|
# musi plik „zatwierdzić" — czyli że konto wgrywającego jest ograniczone
|
||||||
f"Zanim trafi do użytku, musi ją zatwierdzić administrator.")
|
# i że plik przechodzi przez jakąś bramkę.
|
||||||
|
return _files_redirect(done=f"Wgrano „{out.get('name')}”.")
|
||||||
|
|
||||||
|
|
||||||
@app.post("/files/archive")
|
@app.post("/files/archive")
|
||||||
@@ -995,4 +1079,6 @@ def wyloguj(request: Request):
|
|||||||
|
|
||||||
@app.get("/health")
|
@app.get("/health")
|
||||||
def health() -> dict:
|
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"}
|
||||||
|
|||||||
@@ -133,8 +133,16 @@ def trust_proxy() -> bool:
|
|||||||
# żądaniu, więc handler i tak widziałby zalogowaną osobę.
|
# żądaniu, więc handler i tak widziałby zalogowaną osobę.
|
||||||
# /logowanie MUSI stać poza bramką — inaczej żeby się zalogować, trzeba by być
|
# /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.
|
# zalogowanym. /wyloguj też: ma działać także wtedy, gdy sesja już wygasła.
|
||||||
PUBLIC_PATHS = frozenset({"/health", "/logowanie", "/wyloguj"})
|
# Ekran logowania musi się wyświetlić PRZED zalogowaniem, więc jego arkusz jest
|
||||||
PUBLIC_PREFIXES = ("/static/",)
|
# 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]] = {}
|
_hits: dict[str, deque[float]] = {}
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
@@ -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; }
|
||||||
@@ -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); }
|
||||||
@@ -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); }
|
||||||
@@ -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); }
|
||||||
@@ -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; }
|
||||||
@@ -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; }
|
||||||
@@ -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); }
|
||||||
@@ -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; }
|
||||||
@@ -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; }
|
||||||
@@ -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; }
|
|
||||||
@@ -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; }
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
|
inny. Musi być WIDOCZNE, bo z samego wykresu tego nie da się poznać. #}
|
||||||
{% if result.house_warnings %}
|
{% if result.house_warnings %}
|
||||||
{% for w in result.house_warnings %}
|
{% for w in result.house_warnings %}
|
||||||
<p class="house-warning">{{ w }}</p>
|
<p class="notice-warn">{{ w }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
{% set nav_active = "accounts" %}
|
{% set nav_active = "accounts" %}
|
||||||
{% block title %}Konta{% endblock %}
|
{% block title %}Konta{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-accounts.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
|
Konta i ich dostęp do funkcji programu. Konto <strong>{{ admin_login }}</strong>
|
||||||
@@ -15,7 +18,7 @@
|
|||||||
odpowiadają tak samo jak nieistniejące.
|
odpowiadają tak samo jak nieistniejące.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %}
|
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
|
||||||
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
||||||
|
|
||||||
<div class="meta">Konta ({{ users | length }})</div>
|
<div class="meta">Konta ({{ users | length }})</div>
|
||||||
@@ -24,7 +27,7 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% for login, row in users | dictsort %}
|
{% for login, row in users | dictsort %}
|
||||||
<form method="post" action="/accounts/update" class="account-card">
|
<form method="post" action="/accounts/update" class="card">
|
||||||
<input type="hidden" name="login" value="{{ login }}">
|
<input type="hidden" name="login" value="{{ login }}">
|
||||||
<div class="account-head">
|
<div class="account-head">
|
||||||
<strong>{{ login }}</strong>
|
<strong>{{ login }}</strong>
|
||||||
@@ -66,7 +69,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
<div class="meta">Nowe konto</div>
|
<div class="meta">Nowe konto</div>
|
||||||
<form method="post" action="/accounts/create" class="account-card">
|
<form method="post" action="/accounts/create" class="card">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label>Login <input type="text" name="login" required autocomplete="off"></label>
|
<label>Login <input type="text" name="login" required autocomplete="off"></label>
|
||||||
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
|
<label>Hasło <input type="password" name="password" required autocomplete="new-password"></label>
|
||||||
|
|||||||
@@ -4,16 +4,18 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>astrololo · {% block title %}{% endblock %}</title>
|
<title>astrololo · {% block title %}{% endblock %}</title>
|
||||||
<link rel="stylesheet" href="{{ static('styles.css') }}">
|
{# Arkusz wspólny niesie WYŁĄCZNIE powłokę i logowanie — wszystko, co nazywa
|
||||||
{# Wspólne dane formularza między zakładkami (PRE-21). W <head> z `defer`
|
jakąkolwiek funkcję, mieszka w arkuszu tej funkcji i dokłada je `styles`.
|
||||||
CELOWO: skrypty defer wykonują się w kolejności dokumentu, więc ten zdąży
|
Inaczej jeden plik, pobierany też przed zalogowaniem, byłby spisem treści
|
||||||
odtworzyć współrzędne, ZANIM geo.js zbuduje mapę — mapa startuje od razu
|
programu: nazwa selektora opowiada o funkcji tak samo dobrze jak przycisk. #}
|
||||||
we właściwym miejscu, bez przestawiania. #}
|
<link rel="stylesheet" href="{{ static('base.css') }}">
|
||||||
<script defer src="{{ static('formsync.js') }}"></script>
|
{% block styles %}{% endblock %}
|
||||||
{# Powiększanie kosmogramu (PRE-25) — globalnie, bo koło pojawi się też na
|
{# Skrypty dokłada ekran, który ich potrzebuje. Ładowane stąd docierały na
|
||||||
zakładce „Skompiluj"; skrypt sam sprawdza, czy jest co powiększać. #}
|
KAŻDĄ stronę — także do konta, które ma wyłącznie Pliki — a ich treść
|
||||||
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
wymienia z nazwy ekrany, których takie konto nie ma prawa znać.
|
||||||
<script defer src="{{ static('wheeltip.js') }}"></script>
|
Blok jest w <head>, bo `defer` wykonuje skrypty w kolejności dokumentu:
|
||||||
|
formsync musi odtworzyć współrzędne, ZANIM geo.js zbuduje mapę. #}
|
||||||
|
{% block head_scripts %}{% endblock %}
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main>
|
<main>
|
||||||
@@ -45,9 +47,15 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
{% block content %}{% endblock %}
|
{% 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') %}
|
||||||
<footer class="foot">
|
<footer class="foot">
|
||||||
prezentacja → logika → dane · widok testowy
|
prezentacja → logika → dane · widok testowy
|
||||||
</footer>
|
</footer>
|
||||||
|
{% endif %}
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
{% block title %}Horoskop{% endblock %}
|
{% block title %}Horoskop{% endblock %}
|
||||||
{% set nav_active = "chart" %}
|
{% set nav_active = "chart" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-chart.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
{% block head_scripts %}
|
||||||
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
|
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
||||||
|
<script defer src="{{ static('wheeltip.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
<p class="sub">Wpisz dane momentu i miejsca — program policzy pozycje obiektów, osie i domy (silnik efemeryd warstwy logicznej).</p>
|
||||||
|
|
||||||
@@ -71,7 +79,7 @@
|
|||||||
{% if result %}
|
{% if result %}
|
||||||
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
|
{# Fallback systemu domów — z kosmogramu nie da się go poznać, więc mówimy wprost #}
|
||||||
{% for w in result.house_warnings or [] %}
|
{% for w in result.house_warnings or [] %}
|
||||||
<p class="house-warning">{{ w }}</p>
|
<p class="notice-warn">{{ w }}</p>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
<div class="meta">
|
<div class="meta">
|
||||||
Silnik: <strong>{{ result.engine }}</strong> ·
|
Silnik: <strong>{{ result.engine }}</strong> ·
|
||||||
|
|||||||
@@ -2,6 +2,15 @@
|
|||||||
{% block title %}Skompiluj{% endblock %}
|
{% block title %}Skompiluj{% endblock %}
|
||||||
{% set nav_active = "compile" %}
|
{% set nav_active = "compile" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-compile.css') }}">
|
||||||
|
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block head_scripts %}
|
||||||
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
|
<script defer src="{{ static('wheelzoom.js') }}"></script>
|
||||||
|
<script defer src="{{ static('wheeltip.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
{# Opis raportu zależy od tego, z czego konto może go złożyć. Wersja z AI
|
{# 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
|
wymieniałaby funkcje, których część kont nie ma — a wymienienie ich w zdaniu
|
||||||
|
|||||||
@@ -2,18 +2,25 @@
|
|||||||
{% set nav_active = "files" %}
|
{% set nav_active = "files" %}
|
||||||
{% block title %}Pliki{% endblock %}
|
{% block title %}Pliki{% endblock %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-files.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% 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. #}
|
||||||
<p class="muted">
|
<p class="muted">
|
||||||
Bazy interpretacyjne, z których korzysta program. Zaznaczona baza bierze udział
|
Zaznaczony plik jest w użyciu. Odznaczony zostaje na dysku, ale nie jest używany.
|
||||||
w wyszukiwaniu; odznaczona zostaje na dysku, ale program jej nie używa.
|
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{% if error %}<p class="house-warning">{{ error }}</p>{% endif %}
|
{% if error %}<p class="notice-warn">{{ error }}</p>{% endif %}
|
||||||
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
{% if done %}<p class="done-note">{{ done }}</p>{% endif %}
|
||||||
|
|
||||||
<div class="meta">Bazy ({{ files | length }})</div>
|
<div class="meta">Pliki ({{ files | length }})</div>
|
||||||
{% if not files %}
|
{% if not files %}
|
||||||
<p class="muted">Nie ma jeszcze żadnego pliku bazy.</p>
|
<p class="muted">Nie ma jeszcze żadnego pliku.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="table-scroll">
|
<div class="table-scroll">
|
||||||
@@ -35,7 +42,7 @@
|
|||||||
<input type="hidden" name="path" value="{{ f.path }}">
|
<input type="hidden" name="path" value="{{ f.path }}">
|
||||||
<input type="hidden" name="use" value="{{ '0' if f.in_use else '1' }}">
|
<input type="hidden" name="use" value="{{ '0' if f.in_use else '1' }}">
|
||||||
<button type="submit" class="toggle {{ 'on' if f.in_use else 'off' }}"
|
<button type="submit" class="toggle {{ 'on' if f.in_use else 'off' }}"
|
||||||
title="{{ 'Odstaw tę bazę' if f.in_use else 'Włącz tę bazę do użytku' }}">
|
title="{{ 'Odstaw ten plik' if f.in_use else 'Włącz ten plik do użytku' }}">
|
||||||
{{ '●' if f.in_use else '○' }}
|
{{ '●' if f.in_use else '○' }}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -84,8 +91,8 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if can(request, 'files_input') %}
|
{% if can(request, 'files_input') %}
|
||||||
<div class="meta">Wgraj nową bazę</div>
|
<div class="meta">Wgraj plik</div>
|
||||||
<form method="post" action="/files/upload" enctype="multipart/form-data" class="account-card">
|
<form method="post" action="/files/upload" enctype="multipart/form-data" class="card">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label>Plik <input type="file" name="upload" required></label>
|
<label>Plik <input type="file" name="upload" required></label>
|
||||||
</div>
|
</div>
|
||||||
@@ -101,7 +108,7 @@
|
|||||||
Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie
|
Plik musi je spełnić, żeby dało się go włączyć do użytku. Plik, który ich nie
|
||||||
spełnia, <strong>nie jest kasowany</strong> — czeka na Twoją decyzję.
|
spełnia, <strong>nie jest kasowany</strong> — czeka na Twoją decyzję.
|
||||||
</p>
|
</p>
|
||||||
<form method="post" action="/files/rules" class="account-card">
|
<form method="post" action="/files/rules" class="card">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
<label>Dozwolone rozszerzenia (po przecinku)
|
<label>Dozwolone rozszerzenia (po przecinku)
|
||||||
<input type="text" name="extensions" value="{{ (rules.extensions or []) | join(', ') }}"></label>
|
<input type="text" name="extensions" value="{{ (rules.extensions or []) | join(', ') }}"></label>
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
{% block title %}Interpretacje{% endblock %}
|
{% block title %}Interpretacje{% endblock %}
|
||||||
{% set nav_active = "interpret" %}
|
{% set nav_active = "interpret" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-interpret.css') }}">
|
||||||
|
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block head_scripts %}
|
||||||
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
<p class="sub">Program policzy horoskop i wyszuka w bazie interpretacje pasujące do obliczeń (pierwsza wersja: planeta w swoim znaku).</p>
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,9 @@
|
|||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>astrololo · logowanie</title>
|
<title>astrololo · logowanie</title>
|
||||||
<link rel="stylesheet" href="{{ static('styles.css') }}">
|
{# Ekran logowania nie dziedziczy z base.html (nie ma paska zakładek), więc
|
||||||
|
linkuje arkusz sam. To JEDYNY arkusz publiczny — patrz features.STATIC. #}
|
||||||
|
<link rel="stylesheet" href="{{ static('base.css') }}">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<main class="login-main">
|
<main class="login-main">
|
||||||
@@ -13,9 +15,9 @@
|
|||||||
{% if request.query_params.get('wylogowano') %}
|
{% if request.query_params.get('wylogowano') %}
|
||||||
<p class="done-note">Wylogowano.</p>
|
<p class="done-note">Wylogowano.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% if blad %}<p class="house-warning">{{ blad }}</p>{% endif %}
|
{% if blad %}<p class="notice-warn">{{ blad }}</p>{% endif %}
|
||||||
|
|
||||||
<form method="post" action="/logowanie" class="account-card login-card">
|
<form method="post" action="/logowanie" class="card login-card">
|
||||||
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
|
{# Adres powrotu jedzie w ukrytym polu, ale i tak przechodzi przez sito
|
||||||
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
|
po stronie serwera — pole formularza jest wejściem od użytkownika. #}
|
||||||
<input type="hidden" name="dokad" value="{{ dokad }}">
|
<input type="hidden" name="dokad" value="{{ dokad }}">
|
||||||
@@ -28,8 +30,9 @@
|
|||||||
</form>
|
</form>
|
||||||
|
|
||||||
<p class="muted small login-note">
|
<p class="muted small login-note">
|
||||||
Nie masz konta albo nie pamiętasz hasła? Zgłoś się do osoby, która zakładała
|
{# Bez wskazywania, że dostęp ktoś „zakłada": to mówi wprost, że istnieje
|
||||||
Ci dostęp — kont nie da się odzyskać samodzielnie.
|
konto o wyższych uprawnieniach i że własne jest okrojone. #}
|
||||||
|
Nie pamiętasz hasła? Skontaktuj się z osobą, od której masz dane logowania.
|
||||||
</p>
|
</p>
|
||||||
</main>
|
</main>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
{% block title %}Ustawienia{% endblock %}
|
{% block title %}Ustawienia{% endblock %}
|
||||||
{% set nav_active = "settings" %}
|
{% set nav_active = "settings" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-settings.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
<p class="sub">Bazy interpretacyjne dostępne na udziale sieciowym — i to, które biorą udział w interpretacji.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
{% block title %}Sygnifikatory{% endblock %}
|
{% block title %}Sygnifikatory{% endblock %}
|
||||||
{% set nav_active = "significators" %}
|
{% set nav_active = "significators" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-significators.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
<p class="sub">Wyszukiwanie w bazach interpretacji (warstwa danych). Wymaga wgranych baz.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
{% block title %}Synastria{% endblock %}
|
{% block title %}Synastria{% endblock %}
|
||||||
{% set nav_active = "synastry" %}
|
{% set nav_active = "synastry" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-synastry.css') }}">
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
|
<p class="sub">Dwie osoby → aspekty <strong>między</strong> ich horoskopami (planeta jednej osoby do planety drugiej). Bez interpretacji z bazy — sama siatka aspektów.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,13 @@
|
|||||||
{% block title %}Kalendarz{% endblock %}
|
{% block title %}Kalendarz{% endblock %}
|
||||||
{% set nav_active = "timeline" %}
|
{% set nav_active = "timeline" %}
|
||||||
|
|
||||||
|
{% block styles %}<link rel="stylesheet" href="{{ static('s-timeline.css') }}">
|
||||||
|
{% if can(request, 'ai') %}<link rel="stylesheet" href="{{ static('x-ai.css') }}">{% endif %}
|
||||||
|
{% endblock %}
|
||||||
|
{% block head_scripts %}
|
||||||
|
<script defer src="{{ static('formsync.js') }}"></script>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
<p class="sub">Zbiorcza oś czasu technik predykcyjnych (profekcje, solariusze, dyrekcje solar-arc) z interpretacjami z bazy dla dat.</p>
|
||||||
|
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ TEMPLATES = pathlib.Path(__file__).resolve().parents[1] / "app" / "templates"
|
|||||||
|
|
||||||
|
|
||||||
def test_static_url_carries_content_hash():
|
def test_static_url_carries_content_hash():
|
||||||
assert re.fullmatch(r"/static/styles\.css\?v=[0-9a-f]{8}", static_url("styles.css"))
|
assert re.fullmatch(r"/static/base\.css\?v=[0-9a-f]{8}", static_url("base.css"))
|
||||||
|
|
||||||
|
|
||||||
def test_version_is_content_based_so_differs_between_files():
|
def test_version_is_content_based_so_differs_between_files():
|
||||||
assert _asset_version("styles.css") != _asset_version("formsync.js")
|
assert _asset_version("base.css") != _asset_version("formsync.js")
|
||||||
|
|
||||||
|
|
||||||
def test_missing_file_does_not_crash():
|
def test_missing_file_does_not_crash():
|
||||||
@@ -35,7 +35,17 @@ def test_no_template_serves_raw_static_paths():
|
|||||||
assert 'href="/static/' not in txt, f"{f.name}: surowy href bez cache-bustingu"
|
assert 'href="/static/' not in txt, f"{f.name}: surowy href bez cache-bustingu"
|
||||||
|
|
||||||
|
|
||||||
def test_base_uses_helper_for_stylesheet_and_scripts():
|
def test_base_uses_helper_for_the_shared_stylesheet():
|
||||||
base = (TEMPLATES / "base.html").read_text(encoding="utf-8")
|
base = (TEMPLATES / "base.html").read_text(encoding="utf-8")
|
||||||
assert "static('styles.css')" in base
|
assert "static('base.css')" in base
|
||||||
assert "static('formsync.js')" in base
|
|
||||||
|
|
||||||
|
def test_version_follows_what_is_sent_not_what_is_on_disk():
|
||||||
|
"""Adres niesie hash treści WYSYŁANEJ, bo komentarze są przy serwowaniu
|
||||||
|
usuwane. Gdyby hash szedł z pliku na dysku, poprawka w samym komentarzu
|
||||||
|
podbijałaby adres i kasowała cache przeglądarki bez żadnej zmiany dla niej."""
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
from app.main import _asset_body
|
||||||
|
|
||||||
|
assert _asset_version("base.css") == hashlib.md5(_asset_body("base.css")).hexdigest()[:8]
|
||||||
|
|||||||
@@ -65,12 +65,19 @@ def test_every_shared_field_is_synced(key):
|
|||||||
assert f"'{key}'" in SYNC_JS, f"pole {key} nie jest synchronizowane"
|
assert f"'{key}'" in SYNC_JS, f"pole {key} nie jest synchronizowane"
|
||||||
|
|
||||||
|
|
||||||
def test_sync_script_loaded_before_map_script():
|
@pytest.mark.parametrize("name", WITH_FORM)
|
||||||
"""KOLEJNOŚĆ MA ZNACZENIE: formsync w <head> z `defer` wykonuje się przed
|
def test_sync_script_loaded_before_map_script(name):
|
||||||
geo.js (skrypty defer idą w kolejności dokumentu), więc mapa startuje już
|
"""KOLEJNOŚĆ MA ZNACZENIE: formsync z `defer` wykonuje się przed geo.js
|
||||||
z odtworzonymi współrzędnymi zamiast skakać po chwili."""
|
(skrypty defer idą w kolejności dokumentu), więc mapa startuje już
|
||||||
assert "formsync.js" in BASE_HTML, "formsync.js nie jest ładowany globalnie"
|
z odtworzonymi współrzędnymi zamiast skakać po chwili.
|
||||||
assert "defer" in BASE_HTML.split("formsync.js")[0].rsplit("<script", 1)[-1] + "defer"
|
|
||||||
|
Skrypt siedzi teraz w bloku `head_scripts` EKRANU, nie w base.html. Ładowany
|
||||||
|
globalnie docierał także do konta, które ma wyłącznie Pliki — a jego treść
|
||||||
|
wymienia z nazwy zakładki, których takie konto nie ma."""
|
||||||
|
html = (TEMPLATES / name).read_text(encoding="utf-8")
|
||||||
|
assert "head_scripts" in html and "formsync.js" in html
|
||||||
|
przed_geo = html.split("formsync.js")[0]
|
||||||
|
assert "defer" in przed_geo.rsplit("<script", 1)[-1] + "defer"
|
||||||
|
|
||||||
|
|
||||||
def test_sync_reacts_to_other_browser_tab():
|
def test_sync_reacts_to_other_browser_tab():
|
||||||
|
|||||||
@@ -113,7 +113,10 @@ def test_fallback_notice_is_shown_prominently():
|
|||||||
sygnałem, że podział domów jest z innego systemu."""
|
sygnałem, że podział domów jest z innego systemu."""
|
||||||
html = _render("_result_tables.html",
|
html = _render("_result_tables.html",
|
||||||
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
|
result={"house_warnings": ["UWAGA: system domów nie ma definicji"]})
|
||||||
assert "house-warning" in html
|
# Klasa nazywa się neutralnie: `house-warning` siedziała też na ekranie
|
||||||
|
# logowania i na ekranie plików, czyli nazwa z astrologii lądowała tam,
|
||||||
|
# gdzie astrologii ma nie być widać.
|
||||||
|
assert "notice-warn" in html
|
||||||
assert "UWAGA: system domów nie ma definicji" in html
|
assert "UWAGA: system domów nie ma definicji" in html
|
||||||
|
|
||||||
|
|
||||||
@@ -184,7 +187,12 @@ def test_wheel_orientation_reaches_the_renderer(monkeypatch, orientation):
|
|||||||
# overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli
|
# overflow-x, ROZPYCHAŁA CAŁY DOKUMENT — 1713 px przy oknie 1280 px, czyli
|
||||||
# poziomy pasek przewijania na body i rozjechany układ.
|
# poziomy pasek przewijania na body i rozjechany układ.
|
||||||
|
|
||||||
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
|
# Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
|
||||||
|
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
|
||||||
|
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||||
|
# pilnuje osobny test pokrycia.
|
||||||
|
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||||
|
for f in sorted((APP / "static").glob("*.css")))
|
||||||
|
|
||||||
|
|
||||||
def test_comparison_table_scrolls_instead_of_widening_the_page():
|
def test_comparison_table_scrolls_instead_of_widening_the_page():
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
"""Zapora słownikowa: czego konto NIE MOŻE zobaczyć w żadnej swojej odpowiedzi.
|
||||||
|
|
||||||
|
Poprzednie testy pilnowały pojedynczych miejsc — tego przycisku, tamtego bloku.
|
||||||
|
Wyciek prawie nigdy nie siedzi w miejscu, o którym się pamięta: katalog modeli
|
||||||
|
jechał blokiem JSON, nazwy ekranów komentarzem w arkuszu stylów, mechanizm
|
||||||
|
kwarantanny zdaniem po polsku w komentarzu CSS, a nazwa gałęzi rozwojowej
|
||||||
|
w komunikacie o błędzie. Wspólne mają jedno — pewne SŁOWA nie mają prawa paść.
|
||||||
|
|
||||||
|
Więc test nie sprawdza miejsc, tylko przechodzi WSZYSTKO, co dane konto może
|
||||||
|
pobrać (strony i zasoby), i szuka słów zakazanych. Nowy ekran, nowy skrypt,
|
||||||
|
nowy komunikat wchodzą pod tę samą zaporę bez dopisywania czegokolwiek.
|
||||||
|
|
||||||
|
Dwie z trzech list biorą się wprost z katalogu funkcji, więc nie rozjadą się
|
||||||
|
z programem: dopisanie funkcji automatycznie dopisuje jej nazwę i jej adres do
|
||||||
|
tego, czego konto bez niej nie ma prawa zobaczyć.
|
||||||
|
"""
|
||||||
|
import re
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app import accounts as store
|
||||||
|
from app import features
|
||||||
|
|
||||||
|
# ── persona ──────────────────────────────────────────────────────────────
|
||||||
|
# Osoba zatrudniona wyłącznie do wgrywania plików. Ma widzieć swój ekran i nic
|
||||||
|
# poza nim — ani co program robi, ani co będzie robił, ani że ktoś widzi więcej.
|
||||||
|
WGRYWACZ = ["files", "files_input"]
|
||||||
|
|
||||||
|
# ── słowa zakazane, których NIE DA SIĘ wyprowadzić z katalogu ────────────
|
||||||
|
# Katalog zna nazwy funkcji, ale nie zna dziedziny programu ani słownictwa jego
|
||||||
|
# mechanizmów. Te trzeba wypisać, i każde ma tu swój powód.
|
||||||
|
DZIEDZINA = [
|
||||||
|
# (c) po co są wgrywane pliki
|
||||||
|
"astrolog", "horoskop", "kosmogram", "zodiak", "efemeryd", "sygnifikator",
|
||||||
|
"interpretacyjn", "aspektarian", "antyscj", "deklinacj",
|
||||||
|
# „baza" mówi, że to nie są zwykłe pliki, tylko zbiory czegoś. Na ekranie
|
||||||
|
# wgrywającego wszystko nazywa się plikiem; w częściach administracyjnych
|
||||||
|
# słowo zostaje, bo tam jest właściwe (i tam ten test nie zagląda).
|
||||||
|
"baz",
|
||||||
|
# (b) że tekst pisze model językowy i u kogo
|
||||||
|
"LLM", "anthropic", "openai", "gpt", "claude", "prompt", "token",
|
||||||
|
# (e)/(f) mechanizmy i to, że istnieje ktoś, kto widzi więcej
|
||||||
|
"administrator", "uprawnieni", "walidacj", "kwarantann", "wstrzyman",
|
||||||
|
"zatwierdzi", "przypisan", "zakłada", "zakładał",
|
||||||
|
# „konto" w obu przypadkach: pierwsza wersja listy miała samo „konto",
|
||||||
|
# więc przepuszczała zdanie „Nie masz konta" — literówka, która kosztowałaby
|
||||||
|
# cały sens testu, gdyby nie wyszła przy oglądaniu strony.
|
||||||
|
"konto", "konta", "kontem",
|
||||||
|
# (d) że produkt jest w budowie i ma ponumerowany backlog
|
||||||
|
"roadmap", "przyszł", "planowan", "w budowie", "widok testowy",
|
||||||
|
# topologia: gdzie pukać, żeby ominąć logowanie
|
||||||
|
"warstwa logiczna", "warstwie logicznej", "openapi", "swagger",
|
||||||
|
# architektura: „to jedna z warstw" mówi, że warstw jest więcej
|
||||||
|
"layer", "presentation", "prezentacj",
|
||||||
|
"localhost", "http://logic", "8001", "8002", "8003",
|
||||||
|
]
|
||||||
|
WZORCE = [
|
||||||
|
(r"(?i)\b(PRE|LOG|DAN)-\d+\b", "identyfikator wymagania (ujawnia backlog)"),
|
||||||
|
(r"(?i)\bfeat/[\w-]+", "nazwa gałęzi rozwojowej"),
|
||||||
|
(r"(?i)\bmodel(u|em|e|i|ach|owi)?\b", "generowanie tekstu przez model"),
|
||||||
|
(r"(?i)\bAI\b", "generowanie tekstu przez model"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _zakazane_dla(perms: set[str]) -> list[tuple[str, str]]:
|
||||||
|
"""(igła, powód) — wyprowadzone z katalogu plus lista dziedzinowa."""
|
||||||
|
out = [(s, "słownik dziedziny/mechanizmu") for s in DZIEDZINA]
|
||||||
|
for f in features.ALL:
|
||||||
|
if f.key in perms:
|
||||||
|
continue
|
||||||
|
out.append((f.label, f"nazwa funkcji, której to konto nie ma ({f.key})"))
|
||||||
|
if f.href and f.href != "/":
|
||||||
|
out.append((f.href, f"adres ekranu, którego to konto nie ma ({f.key})"))
|
||||||
|
# Nazwa zasobu też jest wyciekiem: `<script src=".../wheelzoom.js">` w źródle
|
||||||
|
# mówi, że istnieje coś do powiększania, nawet jeśli sam plik zwróci 404.
|
||||||
|
# Wykaz bierze się z mapy zasobów, więc nowy skrypt obejmuje sam z siebie.
|
||||||
|
for nazwa, need in features.STATIC.items():
|
||||||
|
if not features.can(frozenset(perms), need):
|
||||||
|
out.append((nazwa, f"nazwa zasobu, którego to konto nie może pobrać"))
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# Ekran logowania MUSI mówić o koncie — inaczej nie da się go opisać. To jedyne
|
||||||
|
# odstępstwo i jest wąskie: dotyczy tej jednej strony i tych dwóch słów.
|
||||||
|
WYJATKI = {"/logowanie": {"konto", "konta", "kontem"}}
|
||||||
|
|
||||||
|
|
||||||
|
def _przeskanuj(tresc: str, perms: set[str], gdzie: str) -> list[str]:
|
||||||
|
wolno = WYJATKI.get(gdzie, set())
|
||||||
|
grzechy = []
|
||||||
|
for igla, powod in _zakazane_dla(perms):
|
||||||
|
if igla in wolno:
|
||||||
|
continue
|
||||||
|
if re.search(rf"(?i)(?<![\w-]){re.escape(igla)}", tresc):
|
||||||
|
grzechy.append(f"{gdzie}: „{igla}” — {powod}")
|
||||||
|
for wzor, powod in WZORCE:
|
||||||
|
m = re.search(wzor, tresc)
|
||||||
|
if m:
|
||||||
|
grzechy.append(f"{gdzie}: „{m.group(0)}” — {powod}")
|
||||||
|
return grzechy
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture()
|
||||||
|
def env(tmp_path, monkeypatch):
|
||||||
|
monkeypatch.setenv("ACCOUNTS_FILE", str(tmp_path / "accounts.json"))
|
||||||
|
monkeypatch.setenv("APP_USER", "szef")
|
||||||
|
monkeypatch.setenv("APP_PASSWORD", "tajne-szefa")
|
||||||
|
monkeypatch.delenv("APP_USERS", raising=False)
|
||||||
|
monkeypatch.setenv("RATE_LIMIT_PER_MIN", "0")
|
||||||
|
monkeypatch.setenv("SESSION_SECRET", "t" * 64)
|
||||||
|
monkeypatch.setenv("COOKIES_INSECURE", "1")
|
||||||
|
|
||||||
|
|
||||||
|
def _klient(monkeypatch):
|
||||||
|
from starlette.testclient import TestClient
|
||||||
|
|
||||||
|
from app.main import app, logic
|
||||||
|
|
||||||
|
# Warstwa logiczna niedostępna CELOWO: ekran plików idzie wtedy ścieżką
|
||||||
|
# błędu, a to właśnie komunikaty o błędach wypisywały nazwy tras, nazwę
|
||||||
|
# gałęzi i wewnętrzny adres z portem.
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
def pada(*a, **k):
|
||||||
|
raise httpx.ConnectError("[Errno 111] connect to http://logic:8001/files failed")
|
||||||
|
|
||||||
|
for nazwa in ("files_list", "report", "timeline", "prompt", "horoscope", "positions"):
|
||||||
|
if hasattr(logic, nazwa):
|
||||||
|
monkeypatch.setattr(logic, nazwa, pada, raising=False)
|
||||||
|
return TestClient(app, follow_redirects=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _co_moze_pobrac(perms: set[str]) -> list[str]:
|
||||||
|
"""Wszystko, co to konto ma prawo dostać: strony i zasoby."""
|
||||||
|
strony = [p for (m, p), need in features.ROUTES.items()
|
||||||
|
if m == "GET" and features.can(frozenset(perms), need)]
|
||||||
|
zasoby = ["/static/base.css"] + [
|
||||||
|
f"/static/{n}" for n, need in features.STATIC.items()
|
||||||
|
if features.can(frozenset(perms), need)]
|
||||||
|
return sorted(set(strony + zasoby))
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_uploader_never_reads_a_forbidden_word(env, monkeypatch):
|
||||||
|
store.create("wgrywacz", "x", WGRYWACZ)
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
c = _klient(monkeypatch)
|
||||||
|
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('wgrywacz')}"}
|
||||||
|
perms = set(WGRYWACZ)
|
||||||
|
|
||||||
|
grzechy, obejrzane = [], 0
|
||||||
|
for adres in _co_moze_pobrac(perms):
|
||||||
|
r = c.get(adres, headers=naglowki)
|
||||||
|
if r.status_code != 200:
|
||||||
|
continue
|
||||||
|
obejrzane += 1
|
||||||
|
grzechy += _przeskanuj(r.text, perms, adres)
|
||||||
|
assert obejrzane >= 3, f"test niczego nie obejrzał ({obejrzane}) — zapora byłaby pozorna"
|
||||||
|
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_anonymous_visitor_never_reads_a_forbidden_word(env, monkeypatch):
|
||||||
|
"""Przed zalogowaniem widać ekran logowania i jeden arkusz. Nic więcej,
|
||||||
|
i nic w nich nie może nazywać funkcji."""
|
||||||
|
store.create("wgrywacz", "x", WGRYWACZ)
|
||||||
|
c = _klient(monkeypatch)
|
||||||
|
grzechy, obejrzane = [], 0
|
||||||
|
for adres in ("/logowanie", "/health", "/static/base.css"):
|
||||||
|
r = c.get(adres)
|
||||||
|
assert r.status_code == 200, f"{adres}: {r.status_code}"
|
||||||
|
obejrzane += 1
|
||||||
|
grzechy += _przeskanuj(r.text, set(), adres)
|
||||||
|
assert obejrzane == 3
|
||||||
|
assert not grzechy, "\n" + "\n".join(grzechy)
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_other_asset_is_denied_to_the_uploader(env, monkeypatch):
|
||||||
|
"""Zasób spoza jego zestawu ma być NIE DO ODRÓŻNIENIA od nieistniejącego."""
|
||||||
|
store.create("wgrywacz", "x", WGRYWACZ)
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
c = _klient(monkeypatch)
|
||||||
|
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('wgrywacz')}"}
|
||||||
|
wolno = set(_co_moze_pobrac(set(WGRYWACZ)))
|
||||||
|
nieistniejacy = c.get("/static/nie-ma-takiego-pliku.js", headers=naglowki)
|
||||||
|
for nazwa in features.STATIC:
|
||||||
|
adres = f"/static/{nazwa}"
|
||||||
|
if adres in wolno:
|
||||||
|
continue
|
||||||
|
r = c.get(adres, headers=naglowki)
|
||||||
|
assert r.status_code == nieistniejacy.status_code == 404, f"{adres}: {r.status_code}"
|
||||||
|
assert r.text == nieistniejacy.text, f"{adres}: odpowiedź różni się od „nie ma takiego pliku”"
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_catalogue_drives_the_list_so_it_cannot_go_stale(env):
|
||||||
|
"""Zapora ma się uczyć z katalogu, nie z pamięci tego, kto ją pisał."""
|
||||||
|
zakazane = {i for i, _ in _zakazane_dla(set(WGRYWACZ))}
|
||||||
|
assert "Sygnifikatory" in zakazane and "/interpret" in zakazane
|
||||||
|
# a to, co konto MA, nie może trafić na listę zakazanych
|
||||||
|
assert "Pliki" not in zakazane
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_owner_sees_everything(env, monkeypatch):
|
||||||
|
"""Kontrola pozytywna: dla administratora te słowa MAJĄ się pojawiać.
|
||||||
|
Bez niej zapora przechodziłaby także wtedy, gdyby program był pusty."""
|
||||||
|
from app import security, session
|
||||||
|
|
||||||
|
c = _klient(monkeypatch)
|
||||||
|
naglowki = {"Cookie": f"{session.COOKIE}={security.issue_session('szef')}"}
|
||||||
|
html = c.get("/accounts", headers=naglowki).text
|
||||||
|
assert "Sygnifikatory" in html and "Generowanie tekstu przez model" in html
|
||||||
@@ -15,13 +15,31 @@ import re
|
|||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8")
|
JS = (APP / "static" / "wheeltip.js").read_text(encoding="utf-8")
|
||||||
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
|
# Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
|
||||||
|
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
|
||||||
|
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||||
|
# pilnuje osobny test pokrycia.
|
||||||
|
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||||
|
for f in sorted((APP / "static").glob("*.css")))
|
||||||
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_script_is_loaded_globally():
|
def test_script_is_loaded_only_where_there_is_a_wheel():
|
||||||
"""W base.html, bo koło jest też na zakładce „Skompiluj"."""
|
"""Koło jest na Horoskopie i na Skompiluj — i tylko tam ten skrypt ma prawo
|
||||||
assert "wheeltip.js" in BASE
|
się pojawić.
|
||||||
|
|
||||||
|
Wcześniej ładowany był z base.html, czyli na KAŻDEJ stronie. Konto, które ma
|
||||||
|
wyłącznie Pliki, pobierało go przy zwykłym wejściu na swój jedyny ekran,
|
||||||
|
a jego treść wymienia z nazwy zakładki, o których takie konto nie ma prawa
|
||||||
|
wiedzieć — łącznie z tą, której jeszcze nie ma."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
szablony = Path(__file__).resolve().parents[1] / "app" / "templates"
|
||||||
|
z_kolem, bez_kola = set(), set()
|
||||||
|
for f in szablony.glob("*.html"):
|
||||||
|
(z_kolem if "wheeltip.js" in f.read_text(encoding="utf-8") else bez_kola).add(f.name)
|
||||||
|
assert z_kolem == {"chart.html", "compile.html"}, f"ładowany też przez: {z_kolem}"
|
||||||
|
assert "files.html" in bez_kola and "logowanie.html" in bez_kola
|
||||||
|
|
||||||
|
|
||||||
def test_tooltip_sits_above_the_zoom_overlay_but_below_the_progress_window():
|
def test_tooltip_sits_above_the_zoom_overlay_but_below_the_progress_window():
|
||||||
|
|||||||
@@ -9,13 +9,31 @@ import re
|
|||||||
|
|
||||||
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
APP = pathlib.Path(__file__).resolve().parents[1] / "app"
|
||||||
JS = (APP / "static" / "wheelzoom.js").read_text(encoding="utf-8")
|
JS = (APP / "static" / "wheelzoom.js").read_text(encoding="utf-8")
|
||||||
CSS = (APP / "static" / "styles.css").read_text(encoding="utf-8")
|
# Arkusz jest teraz rozbity per uprawnienie (arkusz wspólny + arkusze ekranów),
|
||||||
|
# bo jeden plik ze wszystkimi selektorami był spisem funkcji programu dla
|
||||||
|
# każdego, kto go pobrał. Reguły szukamy w całości — który arkusz ją niesie,
|
||||||
|
# pilnuje osobny test pokrycia.
|
||||||
|
CSS = "\n".join(f.read_text(encoding="utf-8")
|
||||||
|
for f in sorted((APP / "static").glob("*.css")))
|
||||||
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
def test_script_is_loaded_globally():
|
def test_script_is_loaded_only_where_there_is_a_wheel():
|
||||||
"""Ładowane w base.html, bo koło pojawi się też na zakładce Skompiluj."""
|
"""Koło jest na Horoskopie i na Skompiluj — i tylko tam ten skrypt ma prawo
|
||||||
assert "wheelzoom.js" in BASE
|
się pojawić.
|
||||||
|
|
||||||
|
Wcześniej ładowany był z base.html, czyli na KAŻDEJ stronie. Konto, które ma
|
||||||
|
wyłącznie Pliki, pobierało go przy zwykłym wejściu na swój jedyny ekran,
|
||||||
|
a jego treść wymienia z nazwy zakładki, o których takie konto nie ma prawa
|
||||||
|
wiedzieć — łącznie z tą, której jeszcze nie ma."""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
szablony = Path(__file__).resolve().parents[1] / "app" / "templates"
|
||||||
|
z_kolem, bez_kola = set(), set()
|
||||||
|
for f in szablony.glob("*.html"):
|
||||||
|
(z_kolem if "wheelzoom.js" in f.read_text(encoding="utf-8") else bez_kola).add(f.name)
|
||||||
|
assert z_kolem == {"chart.html", "compile.html"}, f"ładowany też przez: {z_kolem}"
|
||||||
|
assert "files.html" in bez_kola and "logowanie.html" in bez_kola
|
||||||
|
|
||||||
|
|
||||||
def test_toggles_on_click():
|
def test_toggles_on_click():
|
||||||
|
|||||||
@@ -24,7 +24,12 @@ from app.compile import RenderError, to_pdf, toolchain_ready
|
|||||||
|
|
||||||
log = logging.getLogger("astrololo.render")
|
log = logging.getLogger("astrololo.render")
|
||||||
|
|
||||||
app = FastAPI(title="astrololo · usługa render (PDF)")
|
# 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 · usługa render (PDF)",
|
||||||
|
docs_url=None, redoc_url=None, openapi_url=None)
|
||||||
security.install(app, "render") # token międzywarstwowy (LOG-32)
|
security.install(app, "render") # token międzywarstwowy (LOG-32)
|
||||||
# Szyfrowanie łącza od prezentacji. PO security.install, żeby także odmowa
|
# Szyfrowanie łącza od prezentacji. PO security.install, żeby także odmowa
|
||||||
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
# tokenowa wracała zaszyfrowana — inaczej klient nie umiałby jej odczytać.
|
||||||
|
|||||||
Reference in New Issue
Block a user