' + blocks;
+ }
+
+ function ready(fn) {
+ if (document.readyState !== 'loading') fn();
+ else document.addEventListener('DOMContentLoaded', fn);
+ }
+
+ ready(function () {
+ if (!document.getElementById('readiness')) return; // nie ta zakładka
+ // Horoskop jest w dokumencie tylko po wysłaniu formularza.
+ renderReadiness(!!document.getElementById('reportPerson'));
+ renderNatal();
+ renderPredictions();
+ });
+})();
diff --git a/services/presentation/app/static/natal.js b/services/presentation/app/static/natal.js
new file mode 100644
index 0000000..10abacb
--- /dev/null
+++ b/services/presentation/app/static/natal.js
@@ -0,0 +1,97 @@
+// Zapamiętana interpretacja natalna (PRE-23).
+//
+// Odpowiednik predictions.js, tylko że natalna jest JEDNA — dotyczy momentu
+// urodzenia, nie wybranego okresu. Ponowne wygenerowanie podmienia poprzednią.
+//
+// Po co magazyn: zakładka „Skompiluj" składa raport z trzech kawałków liczonych
+// w różnych miejscach (horoskop, interpretacja natalna, predykcje okresowe).
+// Bez zapamiętania trzeba by je generować od nowa na jednej stronie.
+//
+// localStorage, tak jak reszta stanu (PRE-21/22) — serwer zostaje bezstanowy.
+(function () {
+ 'use strict';
+
+ var KEY = 'astrololo.natal.v1';
+
+ function read() {
+ try {
+ var v = JSON.parse(localStorage.getItem(KEY) || 'null');
+ return (v && typeof v === 'object' && v.text) ? v : null;
+ } catch (e) {
+ return null; // uszkodzony wpis nie może zablokować strony
+ }
+ }
+
+ function clear() {
+ try { localStorage.removeItem(KEY); } catch (e) { /* trudno */ }
+ }
+
+ function val(sel) {
+ var el = document.querySelector(sel);
+ return el ? el.value.trim() : '';
+ }
+
+ function save() {
+ var text = val('#horoscopeText');
+ if (!text) return false; // nie ma czego zapamiętać
+ var entry = {
+ text: text,
+ person: val('input[name=person]'),
+ date: val('input[name=date]'),
+ time: val('input[name=time]'),
+ tz_offset: val('input[name=tz_offset]'),
+ lat: val('input[name=lat]'),
+ lon: val('input[name=lon]'),
+ saved_at: new Date().toISOString()
+ };
+ try {
+ localStorage.setItem(KEY, JSON.stringify(entry));
+ return true;
+ } catch (e) {
+ return false; // przepełniony magazyn — zgłaszamy wyżej
+ }
+ }
+
+ function note(msg) {
+ var el = document.getElementById('natalNote');
+ if (el) el.textContent = msg;
+ }
+
+ function saveCurrent() {
+ var text = val('#horoscopeText');
+ if (!text) return;
+ if (save()) {
+ note('Interpretacja natalna zapamiętana — trafi do raportu (zakładka Skompiluj).');
+ } else {
+ // Interpretacje bywają długie; cicha strata byłaby najgorsza.
+ note('Nie udało się zapamiętać — magazyn przeglądarki pełny. '
+ + 'Usuń starsze predykcje i spróbuj ponownie.');
+ }
+ }
+
+ function ready(fn) {
+ if (document.readyState !== 'loading') fn();
+ else document.addEventListener('DOMContentLoaded', fn);
+ }
+
+ ready(function () {
+ if (!document.getElementById('natalNote')) return; // nie ta zakładka
+
+ // 1) interpretacja policzona przez okno postępu (strumień)
+ document.addEventListener('astrololo:horoscope', function (e) {
+ if (!e.detail || e.detail.profile === 'natal') saveCurrent();
+ });
+
+ // 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
+ // POST. Zapis nadpisuje jeden slot, więc odświeżenie niczego nie mnoży.
+ if (document.querySelector('#horoscopeText')) saveCurrent();
+
+ var status = read();
+ if (status) {
+ note('W raporcie jest zapamiętana interpretacja natalna z '
+ + String(status.saved_at).slice(0, 10) + '.');
+ }
+ });
+
+ window.astrololoNatal = { read: read, clear: clear, key: KEY };
+})();
diff --git a/services/presentation/app/static/styles.css b/services/presentation/app/static/styles.css
index 85ad0f0..467aac5 100644
--- a/services/presentation/app/static/styles.css
+++ b/services/presentation/app/static/styles.css
@@ -152,3 +152,15 @@ textarea.prompt:focus { outline: 2px solid var(--accent); outline-offset: 1px; }
.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; }
diff --git a/services/presentation/app/templates/base.html b/services/presentation/app/templates/base.html
index 3bfb405..71ec1e2 100644
--- a/services/presentation/app/templates/base.html
+++ b/services/presentation/app/templates/base.html
@@ -23,6 +23,7 @@
InterpretacjeKalendarzSygnifikatory
+ Skompiluj
{% block content %}{% endblock %}
diff --git a/services/presentation/app/templates/compile.html b/services/presentation/app/templates/compile.html
new file mode 100644
index 0000000..45a18f3
--- /dev/null
+++ b/services/presentation/app/templates/compile.html
@@ -0,0 +1,124 @@
+{% extends "base.html" %}
+{% block title %}Skompiluj{% endblock %}
+{% block nav_compile %}active{% endblock %}
+
+{% block content %}
+
Składa w jedno: policzony horoskop, interpretację natalną z AI oraz wszystkie
+zapamiętane predykcje okresowe. Dane pobiera z pozostałych zakładek — nie trzeba nic przepisywać.
+
+{# Gotowość materiału — wypełniana po stronie przeglądarki (compile.js), bo część
+ kawałków (AI) mieszka w magazynie lokalnym, nie na serwerze. #}
+
+
+
+
+{% if error %}
{{ error }}
{% endif %}
+
+{% if result %}
+ {# ── 1. Nagłówek: kto ──────────────────────────────────────────────── #}
+
+
{{ form.person or 'Bez podanego imienia' }}
+
+ {{ form.date }} {{ form.time }} · offset {{ form.tz_offset }} h względem GMT ·
+ {{ '%.4f'|format(form.lat|float) }}, {{ '%.4f'|format(form.lon|float) }}
+ {% if moment %}· {{ moment }}{% endif %}
+
Horoskop · silnik {{ result.engine }}
+ {% if result.house_system %}· domy {{ result.house_system }}{% endif %}
+ {% if result.zodiac %}· zodiak {{ result.zodiac }}{% endif %}
+
+
Sym.
Obiekt
Znak
W znaku
Dom
Kier.
+
+ {% for p in result.positions %}
+
+
{{ p.glyph or '' }}{% if p.sign_glyph %} {{ p.sign_glyph }}{% endif %}
+
{{ p.name }}
{{ p.sign }}
{{ p.in_sign }}
+
{{ p.house if p.house is defined else '—' }}
+
{{ p.direction }}
+
+ {% endfor %}
+
+
+
+ {% if result.angles %}
+
+
Oś
Znak
W znaku
+
+ {% for key in ["Asc", "MC", "Dsc", "IC"] %}
+ {% set a = result.angles[key] %}
+
{{ a.name }}
{{ a.sign }}
{{ a.in_sign }}
+ {% endfor %}
+
+
+ {% endif %}
+
+ {# ── 4. i 5. Części od AI — wstawia przeglądarka z magazynu ───────── #}
+
+
+{% endif %}
+
+{# Magazyny wczytujemy dla ich API odczytu. Ich własne UI samo się wyłącza —
+ każdy sprawdza, czy jego kontener jest na stronie (tu go nie ma). #}
+
+
+
+{% endblock %}
diff --git a/services/presentation/app/templates/interpret.html b/services/presentation/app/templates/interpret.html
index 620b8ed..07506b6 100644
--- a/services/presentation/app/templates/interpret.html
+++ b/services/presentation/app/templates/interpret.html
@@ -36,6 +36,7 @@
{% if location_label %}
Wstępnie wpisano lokalizację: {{ location_label }} ({{ form.lat }}, {{ form.lon }}). Zmień pola lub kliknij „Tu i teraz".
{% endif %}
{% include "_prompt_block.html" %}
+
@@ -99,4 +100,6 @@
+{# po progress.js — nasłuchuje zdarzenia o gotowej interpretacji (PRE-23) #}
+
{% endblock %}
diff --git a/services/presentation/tests/test_compile.py b/services/presentation/tests/test_compile.py
new file mode 100644
index 0000000..1992c44
--- /dev/null
+++ b/services/presentation/tests/test_compile.py
@@ -0,0 +1,134 @@
+"""Zakładka „Skompiluj" — zbiorczy raport (PRE-23).
+
+Raport składa się z trzech kawałków liczonych w różnych miejscach: horoskopu
+(serwer), interpretacji natalnej i predykcji okresowych (magazyn przeglądarki).
+Pilnujemy, żeby zakładka umiała je zebrać, a przede wszystkim — żeby POWIEDZIAŁA,
+czego brakuje. Niekompletny raport, o którym dowiadujesz się dopiero z PDF-a,
+byłby najgorszym wynikiem.
+"""
+import pathlib
+
+from starlette.testclient import TestClient
+
+APP = pathlib.Path(__file__).resolve().parents[1] / "app"
+TPL = (APP / "templates" / "compile.html").read_text(encoding="utf-8")
+BASE = (APP / "templates" / "base.html").read_text(encoding="utf-8")
+JS = (APP / "static" / "compile.js").read_text(encoding="utf-8")
+NATAL_JS = (APP / "static" / "natal.js").read_text(encoding="utf-8")
+INTERPRET = (APP / "templates" / "interpret.html").read_text(encoding="utf-8")
+MAIN = (APP / "main.py").read_text(encoding="utf-8")
+
+
+def _client():
+ import os
+
+ os.environ.pop("APP_PASSWORD", None) # bez bramki logowania w teście
+ from app.main import app
+
+ return TestClient(app)
+
+
+# ───────────────────────────────── nawigacja i trasa ─────────────────────
+
+def test_tab_is_in_the_menu():
+ assert 'href="/compile"' in BASE and "Skompiluj" in BASE
+
+
+def test_get_renders_empty_form():
+ r = _client().get("/compile")
+ assert r.status_code == 200
+ assert "Złóż podsumowanie" in r.text
+
+
+def test_routes_exist():
+ assert '@app.get("/compile"' in MAIN and '@app.post("/compile"' in MAIN
+
+
+# ─────────────────────────── kolejność w raporcie ────────────────────────
+
+def test_report_order_matches_the_brief():
+ """Prośba partnerów: najpierw imię i nazwisko, potem wprowadzone dane, potem
+ RYSUNEK kosmogramu, a dopiero po nim interpretacja natalna i predykcje."""
+ person = TPL.index('id="reportPerson"')
+ wheel = TPL.index("wheel_svg")
+ natal = TPL.index('id="reportNatal"')
+ preds = TPL.index('id="reportPredictions"')
+ assert person < wheel < natal < preds
+
+
+def test_person_name_heads_the_report():
+ assert 'id="reportPerson"' in TPL
+ assert "form.person" in TPL
+
+
+# ──────────────────────── zbieranie materiału z magazynów ────────────────
+
+def test_reads_stores_through_their_api_not_raw_storage():
+ """Format danych ma jednego właściciela — moduł, który je zapisuje.
+ Sięganie tu wprost do localStorage rozjechałoby się przy pierwszej zmianie."""
+ assert "window.astrololoNatal" in JS
+ assert "window.astrololoPredictions" in JS
+ # chodzi o brak BEZPOŚREDNIEGO dostępu, nie o samo słowo (pada w komentarzu)
+ assert "localStorage.getItem" not in JS
+ assert "localStorage.setItem" not in JS
+
+
+def test_stores_are_loaded_on_the_tab():
+ for src in ("natal.js", "predictions.js", "compile.js"):
+ assert src in TPL, f"brak {src} na zakładce"
+
+
+def test_stores_load_before_the_assembler():
+ """Szukamy TAGÓW skryptów, nie samych nazw — te padają też w komentarzach."""
+ def tag(name):
+ return TPL.index('src="/static/' + name + '"')
+
+ assert tag("natal.js") < tag("compile.js")
+ assert tag("predictions.js") < tag("compile.js")
+
+
+# ─────────────────────────── mówi, czego brakuje ─────────────────────────
+
+def test_shows_what_is_missing():
+ assert 'id="readiness"' in TPL
+ assert "rdy-missing" in JS
+ for hint in ("Interpretacje", "Kalendarz"):
+ assert hint in JS, f"brak podpowiedzi, gdzie uzupełnić: {hint}"
+
+
+def test_all_three_parts_are_checked():
+ for label in ("Policzony horoskop", "Interpretacja natalna", "Predykcje okresowe"):
+ assert label in JS
+
+
+# ───────────────────────────── natalna zapamiętywana ─────────────────────
+
+def test_natal_is_captured_on_interpret_tab():
+ assert "natal.js" in INTERPRET
+ assert 'id="natalNote"' in INTERPRET
+
+
+def test_natal_store_keeps_a_single_entry():
+ """Natalna jest JEDNA — dotyczy momentu urodzenia, nie okresu. Ponowne
+ wygenerowanie ma podmienić, nie dokładać."""
+ assert "setItem" in NATAL_JS and "push" not in NATAL_JS
+
+
+def test_natal_only_stores_natal_profile():
+ assert "'natal'" in NATAL_JS
+
+
+def test_full_storage_is_reported():
+ assert "pełny" in NATAL_JS
+
+
+# ─────────────────────────────── bezpieczeństwo ──────────────────────────
+
+def test_ai_text_is_escaped_before_injection():
+ """Treść od modelu wstawiamy do DOM — bez ucieczki byłby to wektor wstrzyknięcia."""
+ assert "function esc" in JS
+ assert "replace(/