astrololo: generowanie tekstu jako moduł odłączalny (2/5)
build / build (push) Failing after 2s
Testy / Testy warstwy logicznej (silnik) (push) Failing after 4s
Testy / Testy warstwy prezentacji (dostęp do baz) (push) Failing after 3s
Testy / Testy warstwy bazodanowej (ochrona baz) (push) Failing after 3s
Testy / Testy astrodemo (push) Failing after 3s
Testy / Build obrazu silnika B (swisseph) (push) Failing after 2s
Testy / Kontrola składni wszystkich warstw (push) Failing after 3s

Drugi z pięciu kroków budowy trzech produktów. Czysty refaktor — zachowanie
aplikacji się nie zmienia, liczba testów rośnie tylko o nowe.

DLACZEGO WARUNEK W SZABLONIE NIE WYSTARCZA. Dotąd generowanie było chowane przez
`{% if can(request, 'ai') %}`. To sprawia, że funkcji nie WIDAĆ, ale nie że jej
NIE MA: plik szablonu dalej leży w obrazie i dalej zawiera jej nazwy, więc `grep`
po kontenerze pokazuje wszystko, czego warunek nie pokazał na ekranie. Astroklient
ma nie mieć śladu, nie mieć wyłączoną funkcję — więc granica musi być KATALOGIEM.

CO SIĘ PRZENIOSŁO do app/dodatki/: dwa szablony, pięć plików statycznych, trasa
strumienia, cztery metody klienta warstwy logicznej, wpis w katalogu funkcji,
wpisy tras i zasobów, akcje formularza i reguły CSS nazywające funkcję
(`textarea.prompt`).

JAK APLIKACJA O TO PYTA. Neutralny most `app/rozszerzenia.py` odpowiada wyłącznie
na pytanie „czy coś jest podpięte i co wnosi". Sam nie wymienia ani jednej nazwy —
pilnuje tego osobny test, bo most jest w KAŻDYM obrazie. Dlatego też katalog
nazywa się `dodatki`, a nie `ai`: nazwa w instrukcji importu byłaby dokładnie tym
śladem, którego wydzielanie ma się pozbyć.

TRZY MIEJSCA, KTÓRE OKAZAŁY SIĘ TRUDNE:

1. Pola formularza. FastAPI czyta je z SYGNATURY, a wspólny handler nie może
   wymieniać `prompt_budget` ani `llm_provider`. Rozwiązane zależnością: moduł
   deklaruje własne pola u siebie, handler wie tylko, że dostaje słownik.
2. Klient warstwy logicznej jest wspólny, więc metody `prompt`/`horoscope`/
   `llm_models` musiały z niego wyjść. Wspólny klient daje teraz samą drogę
   w dół (`wywolaj`, `pobierz`, `strumien`) — z szyfrowaniem łącza i tokenem
   międzywarstwowym; co nią pojedzie, jest sprawą modułu.
3. Podtytuł ekranu Skompiluj miał wariant „z AI" i wariant bez. Zamiast warunku
   jedno zdanie prawdziwe niezależnie od tego, jakie moduły są w obrazie.

DOCKERFILE. `COPY . .` wnosiło do obrazu także testy — a plik testowy nazywa
funkcje wprost. Teraz wchodzi wyłącznie `app/`.

TEST GRANICY. `test_ai_tylko_w_module.py` przechodzi po wszystkim poza modułem
i szuka siedemnastu słów. Znalazł dwanaście resztek, których nie widziałem:
komentarze w main.py i security.py, przykład `models.js` w komentarzu features.py,
komentarze w compile.js i w trzech arkuszach, oraz regułę `textarea.prompt`,
która przy rozbijaniu CSS trafiła do arkuszy ekranów zamiast do modułu.

Ma też kontrolę pozytywną (te słowa MAJĄ padać w module — inaczej test
przechodziłby, gdyby funkcję wydrążono) i test odłączalności mostu.

SPRAWDZONE NA KOPII BEZ MODUŁU: aplikacja startuje, wszystkie ekrany oddają 200,
katalog ma 13 funkcji zamiast 14, trasy strumienia nie ma, spreparowane
`action=prompt` wraca do akcji domyślnej bez śladu, a `grep` po drzewie nie
znajduje ani jednego ze słów.

Testy: presentation 362, logic 342, data 37, render 41.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit was merged in pull request #80.
This commit is contained in:
2026-08-26 12:23:08 +02:00
committed by gitea
parent 10ade55525
commit 62d6f9d4d5
43 changed files with 722 additions and 277 deletions
+5 -3
View File
@@ -2,8 +2,10 @@
//
// Raport ma trzy kawałki liczone w różnych miejscach:
// 1. horoskop — liczy serwer przy wysłaniu tego formularza,
// 2. interpretacja natalna — z zakładki Interpretacje (natal.js),
// 3. predykcje okresowe — z zakładki Kalendarz (predictions.js).
// 2. interpretacja natalna — z zakładki Interpretacje,
// 3. predykcje okresowe — z zakładki Kalendarz.
// Kawałki 2 i 3 wnosi moduł opcjonalny. Gdy go nie ma, magazynów nie ma również
// i czytanie ich zwraca pusto — raport składa się wtedy z samego horoskopu.
//
// Punkty 2 i 3 mieszkają w magazynie przeglądarki, więc to tutaj je dokładamy.
// Odczytujemy je przez API tamtych modułów, a nie sięgając wprost do
@@ -86,7 +88,7 @@
}
// ── PDF (PRE-24) ────────────────────────────────────────────────────
// Części od AI mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
// Te kawałki mieszkają w przeglądarce, więc raport składamy TU i wysyłamy
// do serwera, który dokłada kosmogram i woła usługę render.
function formData() {
function v(name) {
@@ -1,58 +0,0 @@
// Podpowiedzi modeli zależne od wybranego dostawcy.
//
// Pole modelu jest CELOWO tekstowe (input + datalist), a nie zamkniętym <select>:
// katalog to tylko wygoda, a konto może mieć dostęp do modeli, o których kod nie
// wie. Puste pole = model domyślny dostawcy.
document.addEventListener('DOMContentLoaded', function () {
const providerEl = document.getElementById('llmProvider');
const modelEl = document.getElementById('llmModel');
const listEl = document.getElementById('llmModelList');
const hintEl = document.getElementById('llmModelHint');
const dataEl = document.getElementById('llmCatalog');
if (!providerEl || !modelEl || !listEl || !dataEl) return;
let catalog = {};
let defaults = {};
try {
const parsed = JSON.parse(dataEl.textContent || '{}');
catalog = parsed.providers || {};
defaults = parsed.defaults || {};
} catch (e) {
return; // brak katalogu — pole nadal działa jako wolny tekst
}
const fmt = n =>
n >= 1000000 ? (n / 1000000) + 'M' : (n >= 1000 ? Math.round(n / 1000) + 'k' : String(n));
function refresh(resetValue) {
const provider = providerEl.value;
const models = catalog[provider] || [];
listEl.innerHTML = '';
models.forEach(m => {
const opt = document.createElement('option');
opt.value = m.id;
opt.label = m.label + ' · kontekst ' + fmt(m.context_window);
listEl.appendChild(opt);
});
const fallback = defaults[provider] || '';
modelEl.placeholder = fallback ? 'domyślny: ' + fallback : 'domyślny dostawcy';
// po zmianie dostawcy stary model nie ma sensu (np. gpt-4o u Anthropica)
if (resetValue) modelEl.value = '';
if (hintEl) {
const chosen = models.find(m => m.id === modelEl.value.trim());
hintEl.textContent = chosen
? chosen.label + ' — okno kontekstu ' + fmt(chosen.context_window) +
' tokenów, maksymalna odpowiedź ' + fmt(chosen.max_output) + '.'
: 'Zostaw puste, by użyć modelu domyślnego. Możesz też wpisać dowolny ' +
'identyfikator modelu, do którego Twoje konto ma dostęp.';
}
}
providerEl.addEventListener('change', () => refresh(true));
modelEl.addEventListener('input', () => refresh(false));
refresh(false);
});
-97
View File
@@ -1,97 +0,0 @@
// 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 };
})();
@@ -1,150 +0,0 @@
// Zapamiętane predykcje okresowe (PRE-22).
//
// Kalendarz pozwala policzyć horoskop na wybrany okres. Partnerzy chcą kilku
// okresów naraz — i żeby wszystkie trafiły potem do raportu (zakładka
// „Skompiluj", PRE-23/24). Tu jest magazyn i lista.
//
// Trzymamy w localStorage, tak jak wspólne dane formularza (PRE-21): serwer
// zostaje bezstanowy, żadne dane urodzeniowe ani treści z baz nie lądują po
// stronie usługi. Zakładka „Skompiluj" odczyta to samo miejsce.
(function () {
'use strict';
var KEY = 'astrololo.predictions.v1';
function read() {
try {
var v = JSON.parse(localStorage.getItem(KEY) || '[]');
return Array.isArray(v) ? v : [];
} catch (e) {
return []; // uszkodzony wpis nie może zablokować strony
}
}
function write(list) {
try {
localStorage.setItem(KEY, JSON.stringify(list));
return true;
} catch (e) {
return false; // najczęściej przepełniony magazyn
}
}
function byPeriod(a, b) {
return String(a.from_date).localeCompare(String(b.from_date));
}
// Klucz tożsamości to OKRES. Ponowne policzenie tego samego zakresu ma
// podmienić wpis, a nie dokładać duplikat — inaczej lista puchłaby przy
// każdej próbie z innym modelem.
function upsert(entry) {
var list = read().filter(function (p) {
return !(p.from_date === entry.from_date && p.to_date === entry.to_date);
});
list.push(entry);
list.sort(byPeriod);
return write(list) ? list : null;
}
function remove(id) {
var list = read().filter(function (p) { return p.id !== id; });
write(list);
return list;
}
function val(sel) {
var el = document.querySelector(sel);
return el ? el.value.trim() : '';
}
/** Wyciąga świeżo policzony horoskop okresowy ze strony. */
function capture() {
var text = val('#horoscopeText');
var from = val('input[name=from_date]');
var to = val('input[name=to_date]');
if (!text || !from || !to) return null;
return {
id: from + '..' + to,
from_date: from,
to_date: to,
person: val('input[name=person]'),
text: text,
saved_at: new Date().toISOString()
};
}
function fmtDate(iso) {
return String(iso || '').slice(0, 10);
}
function render() {
var host = document.getElementById('savedPredictions');
if (!host) return;
var list = read();
if (!list.length) {
host.innerHTML = '<p class="muted small">Brak zapamiętanych predykcji. ' +
'Policz horoskop na wybrany okres — zapamięta się sam i trafi do raportu.</p>';
return;
}
var rows = list.map(function (p) {
var chars = (p.text || '').length;
return '<tr>' +
'<td class="nowrap">' + fmtDate(p.from_date) + ' ' + fmtDate(p.to_date) + '</td>' +
'<td class="muted small">' + fmtDate(p.saved_at) + '</td>' +
'<td class="muted small">' + chars.toLocaleString('pl-PL') + ' znaków</td>' +
'<td><button type="button" class="ghost" data-drop="' +
String(p.id).replace(/"/g, '&quot;') + '">Usuń</button></td>' +
'</tr>';
}).join('');
host.innerHTML =
'<div class="meta">Zapamiętane predykcje (' + list.length + ') — wejdą do raportu</div>' +
'<table class="angles"><thead><tr><th>Okres</th><th>Zapisano</th>' +
'<th>Objętość</th><th></th></tr></thead><tbody>' + rows + '</tbody></table>';
Array.prototype.forEach.call(host.querySelectorAll('[data-drop]'), function (btn) {
btn.addEventListener('click', function () {
remove(btn.getAttribute('data-drop'));
render();
});
});
}
function saveCurrent() {
var entry = capture();
if (!entry) return;
var note = document.getElementById('predictionsNote');
if (upsert(entry)) {
if (note) note.textContent = 'Zapamiętano predykcję ' +
entry.from_date + ' ' + entry.to_date + '.';
} else if (note) {
// Horoskopy bywają długie; gdy magazyn się przepełni, trzeba powiedzieć
// wprost, zamiast po cichu zgubić wynik.
note.textContent = 'Nie udało się zapamiętać — magazyn przeglądarki pełny. ' +
'Usuń starsze predykcje i spróbuj ponownie.';
}
render();
}
function ready(fn) {
if (document.readyState !== 'loading') fn();
else document.addEventListener('DOMContentLoaded', fn);
}
ready(function () {
if (!document.getElementById('savedPredictions')) return; // nie ta zakładka
render();
// 1) horoskop policzony przez okno postępu (strumień)
document.addEventListener('astrololo:horoscope', function (e) {
if (!e.detail || e.detail.profile === 'period') saveCurrent();
});
// 2) wariant bez strumienia: strona wróciła z gotowym wynikiem po zwykłym
// POST. Zapis jest idempotentny (klucz = okres), więc odświeżenie strony
// niczego nie duplikuje.
if (document.querySelector('#horoscopeText')) saveCurrent();
});
// Udostępniamy magazyn zakładce „Skompiluj" (PRE-23) — jedno źródło prawdy.
window.astrololoPredictions = { read: read, remove: remove, key: KEY };
})();
@@ -1,138 +0,0 @@
// Okno postępu przy pisaniu horoskopu.
//
// Problem: generowanie trwa minutami, a zwykły POST formularza nie daje żadnego
// sygnału — aplikacja wygląda na zawieszoną. Zamiast udawanego paska postępu
// czytamy strumień RZECZYWISTYCH zdarzeń z serwera (NDJSON) i wypisujemy je
// jako log: budowa promptu, limity modelu, każda tura generowania.
//
// Degradacja: jeśli przeglądarka nie umie strumieniować `fetch`, nie przechwytujemy
// wysyłki — formularz idzie klasycznie i wszystko działa jak wcześniej, tylko bez okna.
document.addEventListener('DOMContentLoaded', function () {
const canStream = typeof fetch === 'function' && typeof ReadableStream === 'function' &&
typeof TextDecoder === 'function';
const form = document.querySelector('form[action="/interpret"], form[action="/timeline"]');
if (!canStream || !form) return;
const profile = form.getAttribute('action') === '/timeline' ? 'period' : 'natal';
const btn = form.querySelector('button[value="horoscope"]');
if (!btn) return;
// --- okno ---------------------------------------------------------------
const overlay = document.createElement('div');
overlay.className = 'progress-overlay';
overlay.hidden = true;
overlay.innerHTML =
'<div class="progress-box" role="dialog" aria-modal="true" aria-label="Postęp generowania">' +
'<div class="progress-head">' +
'<span class="progress-spinner" aria-hidden="true"></span>' +
'<strong id="progressTitle">Piszę horoskop…</strong>' +
'<span class="progress-clock" id="progressClock">0:00</span>' +
'</div>' +
'<ol class="progress-log" id="progressLog"></ol>' +
'<p class="muted small">Nie zamykaj tej karty — generowanie trwa na serwerze.</p>' +
'<div class="actions"><button type="button" class="ghost" id="progressClose" hidden>Zamknij</button></div>' +
'</div>';
document.body.appendChild(overlay);
const logEl = overlay.querySelector('#progressLog');
const clockEl = overlay.querySelector('#progressClock');
const titleEl = overlay.querySelector('#progressTitle');
const closeEl = overlay.querySelector('#progressClose');
let timer = null;
function addLine(text, kind) {
const li = document.createElement('li');
if (kind) li.className = 'log-' + kind;
const now = new Date();
li.textContent = String(now.getHours()).padStart(2, '0') + ':' +
String(now.getMinutes()).padStart(2, '0') + ':' +
String(now.getSeconds()).padStart(2, '0') + ' ' + text;
logEl.appendChild(li);
logEl.scrollTop = logEl.scrollHeight;
}
function startClock() {
const t0 = Date.now();
timer = setInterval(function () {
const s = Math.floor((Date.now() - t0) / 1000);
clockEl.textContent = Math.floor(s / 60) + ':' + String(s % 60).padStart(2, '0');
}, 1000);
}
function finish(title, allowClose) {
if (timer) { clearInterval(timer); timer = null; }
titleEl.textContent = title;
overlay.querySelector('.progress-spinner').style.visibility = 'hidden';
if (allowClose) closeEl.hidden = false;
}
closeEl.addEventListener('click', function () { overlay.hidden = true; });
// --- przechwycenie wysyłki ----------------------------------------------
btn.addEventListener('click', function (event) {
event.preventDefault();
if (!form.reportValidity()) return; // te same reguły co przy zwykłej wysyłce
const data = new FormData(form);
data.set('profile', profile);
logEl.innerHTML = '';
closeEl.hidden = true;
overlay.querySelector('.progress-spinner').style.visibility = '';
titleEl.textContent = 'Piszę horoskop…';
overlay.hidden = false;
startClock();
addLine('Wysyłam żądanie…');
fetch('/horoscope/stream', { method: 'POST', body: data })
.then(function (response) {
if (!response.ok || !response.body) throw new Error('HTTP ' + response.status);
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
function pump() {
return reader.read().then(function (chunk) {
if (chunk.done) return;
buffer += decoder.decode(chunk.value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // ostatni może być niepełny
lines.forEach(function (raw) {
if (!raw.trim()) return;
let ev;
try { ev = JSON.parse(raw); } catch (e) { return; }
if (ev.type === 'ping') return; // sam heartbeat, nie logujemy
if (ev.type === 'result') {
addLine(ev.message || 'Gotowe.', 'ok');
if (ev.html) {
const host = document.getElementById('promptResult');
if (host) host.innerHTML = ev.html;
}
finish('Gotowe', true);
overlay.hidden = true;
const host = document.getElementById('promptResult');
if (host) host.scrollIntoView({ behavior: 'smooth', block: 'start' });
// Gotowy horoskop ogłaszamy zdarzeniem, zamiast zapisywać go tutaj:
// to okno postępu, a nie magazyn. Nasłuchuje predictions.js (PRE-22).
document.dispatchEvent(new CustomEvent('astrololo:horoscope', {
detail: { profile: profile }
}));
return;
}
addLine(ev.message || ev.type, ev.type === 'error' ? 'err'
: ev.type === 'warn' ? 'warn' : null);
if (ev.type === 'error') finish('Nie udało się', true);
});
return pump();
});
}
return pump();
})
.catch(function (e) {
addLine('Połączenie przerwane: ' + e.message, 'err');
addLine('Możesz spróbować ponownie — nic nie zostało utracone.');
finish('Nie udało się', true);
});
});
});
@@ -102,7 +102,7 @@ body.wheel-zoom-open { overflow: hidden; }
.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,
/* Bloki tekstu bywa długie — white-space:pre-wrap zachowuje akapity
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; }
@@ -34,9 +34,3 @@ td.retro { color: #ff9b6a; font-weight: 600; }
.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; }
@@ -31,9 +31,3 @@ td.retro { color: #ff9b6a; font-weight: 600; }
.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; }
-31
View File
@@ -1,31 +0,0 @@
/* 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; }