Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions docs/superpowers/specs/2026-07-25-guide-popup-support-design.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
# Obsługa popupów w `guidebot guide` (PDF)

Data: 2026-07-25

## Problem

`guide` odrzuca każdy scenariusz, w którym jakiś krok otwiera nowe okno:

```
BŁĄD: scenariusze z popupem nie są obsługiwane w `guide` v1 (krok otwiera nowe okno)
```

Blokada siedzi w `guide/prolog.py` (`scan_for_blockers`) i wyzwala ją zamrożona
flaga `opens_popup: true`. Dotyczy to całej klasy scenariuszy, których sednem
jest logowanie — a więc dokładnie tych, dla których przewodnik krok-po-kroku ma
największy sens.

Blokada nie jest kaprysem: `guide` nie ma **żadnego** cyklu życia okien.
`_Capture.page` jest ustawiane raz z okna głównego i każde z pięciu wywołań
`_screenshot(cap.page, …)` (`replay.py:216, 268, 328, 396, 403`) fotografuje
właśnie je. Krok działający na popupie szukałby swojego celu w oknie głównym
i padał z niezgodnością tożsamości — kilka kroków za późno, w niezrozumiałym
miejscu. Odrzucenie z góry było uczciwsze niż taka awaria.

## Rozwiązanie

Zamiast dokładać wykrywanie okien, zmieniamy jedno pojęcie: `_Capture.page`
przestaje znaczyć „okno główne", a zaczyna znaczyć **okno aktywne**. Pętla
przechwytywania nie dowiaduje się o tym niczego — te same pięć wywołań
`_screenshot(cap.page, …)` fotografuje inną wartość.

### Cykl życia sterowany sidecarem

Popupu nie wykrywamy heurystycznie. `compile` już zamraża `opens_popup: true`
(`recorder/compile/run.py:365`), więc:

* krok `click` z tą flagą owija kliknięcie w `context.expect_page()`
i po powrocie przełącza aktywną parę `(page, recorder)` na nowe okno;
* krok `closeWindow` zamyka popup i przywraca parę okna głównego.

Świadomie **nie** sięgamy po `render/popup_detect.py` ani `render/popup_crop.py`
(789 linii). To maszyneria wideo: wykrywanie momentu z kwantem ciszy, przejścia
float/slide, kadrowanie klatek. PDF nie ma osi czasu, więc nie ma czego kadrować.

Popup dostaje własny `Recorder` — bez `frame=`, bo nie żyje w iframie powłoki
chrome. Pasek chrome na popupie zostaje sterowany istniejącym `cfg.popup.is_bare`,
które `run_guide` już przekazuje do `Chrome(...)`.

`closeWindow` przestaje być komendą wyłącznie narracyjną. Dziś siedzi
w `NARRATION_ONLY_KINDS` z komentarzem „popup bookkeeping the guide already
rejects" — dostaje własny rodzaj strony i własną fazę, bo od teraz **wykonuje**
pracę w przeglądarce.

### Płótno — w CSS, nie w obrazie

Zrzut popupu zostaje w naturalnym rozmiarze okna. Wyśrodkowanie na płótnie
o proporcjach okna głównego robi `layout.py`: bez przetwarzania obrazu i bez
nowej zależności.

Jeden haczyk, i to on decyduje o kształcie zmiany. Dziś SVG z adnotacjami leży
`inset: 0` nad `.shot`, a `img` wypełnia ten box — więc oba układy współrzędnych
się pokrywają. Gdy obrazek przestanie wypełniać `.shot`, adnotacje rozjadą się
o wielkość marginesu. Dlatego `img` i `svg` trafiają do wewnętrznego `.plate`
o rozmiarze obrazka, wyśrodkowanego w `.shot`. Dla stron nieletterboxowanych
`.plate` ma `width: 100%` i renderuje się identycznie jak dotąd.

`GuidePage` dostaje `canvas_size` obok istniejącego `screenshot_size`.
Letterboxing włącza się wtedy i tylko wtedy, gdy oba są znane i różne — czyli
sam fakt „to jest popup" nie jest nigdzie zapisywany jako flaga. Rozmiar
wystarcza.

## Obsługa błędów

Popup, który nie pojawi się mimo `opens_popup: true`, to twardy `GuideError`
z `plik:linia` i fragmentem YAML — tak jak każdy inny niespełniony namiar
w `guide`. Bez cichego pomijania: zamrożona flaga mówi, że okno *ma* się otworzyć,
a PDF bez tej strony byłby przewodnikiem, który gubi połowę instrukcji.

Wywołanie `enter_popup` bez wstrzykniętej fabryki `Recorder`-ów jest błędem
programisty, nie scenariusza, więc leci `RuntimeError`.

## Testy

Pakiet ma własny strażnik szwów (`tests/unit/guide/test_capture_seams.py`),
który wymaga, by nazwa patchowana na `capture` była wołana z `capture`. Nowy
przełącznik okien mieszka na `_Capture`, więc go nie dotyczy — ale testy nie
mogą go obchodzić name-importem w siostrzanym module.

Nowe przypadki:

* przejście na popup po kroku z `opens_popup` i powrót na `closeWindow`;
* zrzuty po przełączeniu pochodzą z popupu, nie z okna głównego;
* ślad kursora zeruje się na każdej zmianie okna (strzałka między oknami nie ma sensu);
* brak popupu mimo flagi → `GuideError` z `plik:linia`;
* `render_html` letterboksuje wtedy i tylko wtedy, gdy `canvas_size ≠ screenshot_size`,
a adnotacje pozostają w układzie obrazka;
* `scan_for_blockers` przestaje odrzucać popupy, nadal odrzuca nieznane komendy
i nierozwiązane kroki obowiązkowe.

## Zakres

Zmiana dotyczy wyłącznie `guidebot_recorder/guide/`. Nie ruszamy `render`,
przejść float/slide ani kadrowania wideo.

Pliki: `prolog.py`, `replay.py`, `capture.py`, `model.py`, `layout.py`, `guide.py`.
Wszystkie zostają grubo poniżej limitu 600 linii; żadna funkcja nie zbliża się
do CC 10.
20 changes: 19 additions & 1 deletion guidebot_recorder/guide/capture.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
_approach_target,
_Capture,
_click_or_hover_frame,
_close_window_page,
_gate_page,
_navigate_page,
_scroll_page,
Expand Down Expand Up @@ -95,6 +96,7 @@
"text": _text_page,
"scroll": _scroll_page,
"wait": _wait_page,
"closeWindow": _close_window_page,
}


Expand Down Expand Up @@ -161,6 +163,7 @@ def _append_step_page(run: _StepRun) -> None:
bounds=bounds,
),
screenshot_size=run.size,
canvas_size=cap.canvas,
)
)

Expand All @@ -181,6 +184,11 @@ async def _action_page(run: _StepRun) -> None:
return
await cap.recorder.apply_readiness(run.action.expect)
_append_step_page(run)
# Last, and the order is the point: this step's page shows the window the
# reader clicked *in*, photographed before the click. Only once that page is
# built does the popup become the window the following steps are read against.
if run.popup is not None:
cap.enter_popup(run.popup)


async def capture_pages(
Expand All @@ -195,6 +203,7 @@ async def capture_pages(
pause_on_error: bool = False,
sensitive_values: Iterable[str] = (),
selects: Selects | None = None,
make_recorder: Callable[[Page], Recorder] | None = None,
) -> list[GuidePage]:
"""Replay the compiled scenario, keeping one annotated frame per step.

Expand All @@ -203,6 +212,11 @@ async def capture_pages(
— the guide's half of the readiness barrier compile and render also take.
See :meth:`~guidebot_recorder.guide.replay._Capture.await_selects_ready` for
where it is taken and why.

``make_recorder`` builds the ``Recorder`` for a popup window; omit it for a
scenario that never opens one. It is injected rather than built here because a
``Recorder`` needs the caller's ``Overlay`` — see
:meth:`~guidebot_recorder.guide.replay._Capture.enter_popup`.
"""

cap = _Capture(
Expand All @@ -216,6 +230,7 @@ async def capture_pages(
pause_on_error=pause_on_error,
sensitive_values=sensitive_values,
selects=selects,
make_recorder=make_recorder,
)
# Wrapping the iterator (rather than `bar.update(1)` as render does) is what
# keeps the count honest here: this loop leaves through a dozen early returns
Expand All @@ -239,7 +254,10 @@ async def capture_pages(
except Exception as exc:
if pause_on_error:
await pause_for_inspection(
page,
# The window that failed, not the one the run started in.
# A step inside a popup that leaves the main window paused
# shows the developer a page with nothing wrong on it.
cap.page,
"guide",
index,
run.kind,
Expand Down
24 changes: 20 additions & 4 deletions guidebot_recorder/guide/guide.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,12 @@

from pathlib import Path

from playwright.async_api import Browser, Frame
from playwright.async_api import Browser, Frame, Page

from guidebot_recorder.chrome import SHELL_URL, Chrome
from guidebot_recorder.chrome.framing import install_framing
from guidebot_recorder.guide.capture import capture_pages
from guidebot_recorder.guide.layout import render_html
from guidebot_recorder.guide.layout import fold_narration, render_html
from guidebot_recorder.guide.pdf import html_to_pdf
from guidebot_recorder.guide.prolog import GuideError, scan_for_blockers
from guidebot_recorder.overlay.overlay import Overlay
Expand Down Expand Up @@ -92,6 +92,17 @@ async def run_guide(
# otherwise the recorder drives the page directly (frame=None -> page).
recorder = Recorder(page, overlay, frame=site_frame, type_delay_ms=None)

def make_popup_recorder(popup: Page) -> Recorder:
"""The popup's recorder — deliberately without a ``frame``.

The shell iframe belongs to the main window only; a popup is its own
top-level document, so the recorder drives the page directly. Its
chrome bar, if any, is the legacy in-DOM one that ``chrome.js`` mounts
on popup-site documents under ``bare_popups=False``.
"""

return Recorder(popup, overlay, frame=None, type_delay_ms=None)

shots_dir = out_pdf.parent / (out_pdf.stem + "_shots")
pages = await capture_pages(
scenario,
Expand All @@ -104,10 +115,15 @@ async def run_guide(
pause_on_error=pause_on_error,
sensitive_values=sensitive_values,
selects=selects,
make_recorder=make_popup_recorder,
)
finally:
await context.close()

html = render_html(pages, title=cfg.title)
# Fold before rendering *and* before counting: narration with no still of its
# own rides on the previous picture, so it stops being a page. Counting the
# unfolded list here is what made `guide` report eight pages of a five-page PDF.
sheets = fold_narration(pages)
html = render_html(sheets, title=cfg.title)
await html_to_pdf(browser, html, out_pdf)
return len(pages)
return len(sheets)
Loading
Loading