steppy-dev 0.3.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- steppy/CHANGELOG.md +862 -0
- steppy/__init__.py +9 -0
- steppy/__main__.py +13 -0
- steppy/_cli_common.py +183 -0
- steppy/_docker/Dockerfile.installed +53 -0
- steppy/_parallel/__init__.py +7 -0
- steppy/_parallel/compose.py +349 -0
- steppy/_parallel/event_bus.py +418 -0
- steppy/_parallel/failures.py +267 -0
- steppy/_parallel/fingerprint.py +141 -0
- steppy/_parallel/image.py +380 -0
- steppy/_parallel/lastfailed.py +125 -0
- steppy/_parallel/lifecycle.py +90 -0
- steppy/_parallel/preauth.py +109 -0
- steppy/_parallel/progress.py +101 -0
- steppy/_parallel/queue.py +385 -0
- steppy/_parallel/renderer.py +489 -0
- steppy/_parallel/retry.py +316 -0
- steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
- steppy/_player_bundle/__init__.py +100 -0
- steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
- steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
- steppy/_player_bundle/steppy-player.js +5419 -0
- steppy/_player_bundle/steppy-player.js.map +1 -0
- steppy/_player_bundle/steppy-video.js +472 -0
- steppy/_player_bundle/steppy-video.js.map +1 -0
- steppy/_templating.py +117 -0
- steppy/assertions.py +251 -0
- steppy/auth.py +493 -0
- steppy/build.py +2798 -0
- steppy/build_models.py +669 -0
- steppy/buildid.py +134 -0
- steppy/bundle/__init__.py +47 -0
- steppy/bundle/archive.py +239 -0
- steppy/bundle/classify.py +329 -0
- steppy/bundle/create.py +490 -0
- steppy/bundle/hashing.py +28 -0
- steppy/bundle/manifest.py +334 -0
- steppy/bundle/results.py +546 -0
- steppy/bundle_cli.py +455 -0
- steppy/captures.py +207 -0
- steppy/cli.py +2489 -0
- steppy/cloud/__init__.py +58 -0
- steppy/cloud/client.py +267 -0
- steppy/cloud/upload.py +497 -0
- steppy/compat.py +109 -0
- steppy/config.py +231 -0
- steppy/config_schema.py +87 -0
- steppy/contracts/__init__.py +54 -0
- steppy/contracts/bundle.py +215 -0
- steppy/contracts/report.py +346 -0
- steppy/contracts/tours.py +505 -0
- steppy/coordinator.py +1352 -0
- steppy/coverage/__init__.py +9 -0
- steppy/coverage/gaps.py +190 -0
- steppy/coverage/manifest.py +193 -0
- steppy/coverage/merge.py +363 -0
- steppy/coverage/normalize.py +174 -0
- steppy/coverage/report.py +178 -0
- steppy/coverage/sourcemap.py +219 -0
- steppy/coverage/store.py +473 -0
- steppy/coverage/triage.py +151 -0
- steppy/coverage_cli.py +265 -0
- steppy/durations.py +48 -0
- steppy/exclusive.py +178 -0
- steppy/flaky_cli.py +169 -0
- steppy/generator.py +633 -0
- steppy/guide.py +1467 -0
- steppy/history.py +352 -0
- steppy/init_cli.py +330 -0
- steppy/locales.py +282 -0
- steppy/migrate/__init__.py +35 -0
- steppy/migrate/apply.py +194 -0
- steppy/migrate/plan.py +199 -0
- steppy/migrate/rules.py +465 -0
- steppy/migrate/verify.py +163 -0
- steppy/migrate_cli.py +152 -0
- steppy/models.py +2539 -0
- steppy/naming.py +37 -0
- steppy/parser.py +475 -0
- steppy/pdf.py +193 -0
- steppy/presets.py +284 -0
- steppy/record_cli.py +796 -0
- steppy/report/__init__.py +37 -0
- steppy/report/assets.py +197 -0
- steppy/report/build.py +261 -0
- steppy/report/coverage_source.py +277 -0
- steppy/report/model.py +423 -0
- steppy/report/render.py +303 -0
- steppy/report/source.py +238 -0
- steppy/report_cli.py +173 -0
- steppy/runmeta.py +114 -0
- steppy/selection.py +183 -0
- steppy/sitemap.py +205 -0
- steppy/skills/steppy-app-tours/SKILL.md +448 -0
- steppy/skills/steppy-ci-report/SKILL.md +567 -0
- steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
- steppy/skills/steppy-template/SKILL.md +676 -0
- steppy/templates/_steppy_events.py +291 -0
- steppy/templates/_steppy_tours.py +404 -0
- steppy/templates/angular/component-interactive.ts.j2 +109 -0
- steppy/templates/angular/component-video.ts.j2 +112 -0
- steppy/templates/angular/component.ts.j2 +229 -0
- steppy/templates/angular/content.ts.j2 +31 -0
- steppy/templates/angular/gitignore.j2 +3 -0
- steppy/templates/angular/group.component.ts.j2 +39 -0
- steppy/templates/angular/onboarding.component.ts.j2 +43 -0
- steppy/templates/angular/routes.ts.j2 +34 -0
- steppy/templates/ci/github-actions.yml +284 -0
- steppy/templates/ci/gitlab-ci.yml +178 -0
- steppy/templates/conftest.py.j2 +2405 -0
- steppy/templates/guide.book.html.j2 +145 -0
- steppy/templates/guide.fragment.html.j2 +257 -0
- steppy/templates/guide.html.j2 +301 -0
- steppy/templates/guide.md.j2 +18 -0
- steppy/templates/init/config.yaml +124 -0
- steppy/templates/init/credentials.yaml.example +30 -0
- steppy/templates/init/scenarios/example--first-flow.md +53 -0
- steppy/templates/report/_head.html.j2 +14 -0
- steppy/templates/report/_mark.html.j2 +3 -0
- steppy/templates/report/coverage.html.j2 +124 -0
- steppy/templates/report/index.html.j2 +203 -0
- steppy/templates/report/report.css +401 -0
- steppy/templates/report/scenario.html.j2 +137 -0
- steppy/templates/static/_headers.j2 +30 -0
- steppy/templates/static/_mode_links.html.j2 +22 -0
- steppy/templates/static/_seo_head.html.j2 +37 -0
- steppy/templates/static/cursor.js.j2 +10 -0
- steppy/templates/static/group.html.j2 +78 -0
- steppy/templates/static/guides.css +678 -0
- steppy/templates/static/index.html.j2 +79 -0
- steppy/templates/static/interactive.html.j2 +100 -0
- steppy/templates/static/onboarding.html.j2 +64 -0
- steppy/templates/static/page.html.j2 +145 -0
- steppy/templates/static/robots.txt.j2 +7 -0
- steppy/templates/static/video.html.j2 +135 -0
- steppy/templates/test_scenario.py.j2 +767 -0
- steppy/testids.py +166 -0
- steppy/totp.py +26 -0
- steppy/tour_spec.py +163 -0
- steppy/tours/__init__.py +22 -0
- steppy/tours/actions.py +402 -0
- steppy/tours/compiler.py +144 -0
- steppy/tours/diagnostics.py +137 -0
- steppy/tours/emit.py +239 -0
- steppy/tours/ids.py +40 -0
- steppy/tours/inference.py +399 -0
- steppy/tours/model.py +120 -0
- steppy/tours/paths.py +126 -0
- steppy/tours/pipeline.py +277 -0
- steppy/tours/publish.py +124 -0
- steppy/tours/replay.py +234 -0
- steppy/tours/routes.py +175 -0
- steppy/tours/settings.py +164 -0
- steppy/tours/text.py +237 -0
- steppy/tours_cli.py +219 -0
- steppy/tts/__init__.py +280 -0
- steppy/tts/cache.py +185 -0
- steppy/tts/presets.py +125 -0
- steppy/tts/providers/__init__.py +6 -0
- steppy/tts/providers/http.py +184 -0
- steppy/tts/providers/local.py +207 -0
- steppy/tts/providers/null.py +28 -0
- steppy/tts/registry.py +201 -0
- steppy/tts/track.py +158 -0
- steppy/tts/types.py +94 -0
- steppy/update_cli.py +460 -0
- steppy/video.py +730 -0
- steppy/video_cache.py +239 -0
- steppy/video_cli.py +271 -0
- steppy/worker.py +301 -0
- steppy_dev-0.3.0.dist-info/METADATA +1335 -0
- steppy_dev-0.3.0.dist-info/RECORD +177 -0
- steppy_dev-0.3.0.dist-info/WHEEL +4 -0
- steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
- steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
- steppy_dev-0.3.0.dist-info/licenses/NOTICE +13 -0
steppy/guide.py
ADDED
|
@@ -0,0 +1,1467 @@
|
|
|
1
|
+
"""User guide assembler for Steppy - creates Markdown and HTML guides from test screenshots."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Callable, Optional
|
|
8
|
+
|
|
9
|
+
import jinja2
|
|
10
|
+
from markdown_it import MarkdownIt
|
|
11
|
+
|
|
12
|
+
from ._player_bundle import get_legacy_cursor_iife
|
|
13
|
+
from ._templating import make_template_env
|
|
14
|
+
from .captures import _has_captures, step_stem
|
|
15
|
+
from .models import Scenario, Step, SteppyConfig
|
|
16
|
+
from .parser import parse_scenario_file
|
|
17
|
+
|
|
18
|
+
# ``_has_captures`` / ``step_stem`` are imported (not defined) here: they moved to
|
|
19
|
+
# the stdlib-only ``captures`` leaf, which also owns their inverse, because the
|
|
20
|
+
# results bundler has to parse the same grammar and cannot afford this module's
|
|
21
|
+
# jinja2 + markdown_it import cost on the default CLI path. Importing rather than
|
|
22
|
+
# re-declaring keeps ``from .guide import step_stem`` (build.py:28) working and
|
|
23
|
+
# keeps one definition, per the FLAWS §4.1 single-source rule.
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
_md = MarkdownIt("commonmark", {"html": False, "breaks": True, "linkify": True})
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _markdown_inline(text: Optional[str]) -> str:
|
|
30
|
+
"""Render inline markdown to HTML. Strips the wrapping <p> so the result
|
|
31
|
+
is safe to embed inside block elements like <div class="step-content">."""
|
|
32
|
+
if text is None:
|
|
33
|
+
return ""
|
|
34
|
+
return _md.renderInline(str(text))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def register_jinja_filters(env: jinja2.Environment) -> None:
|
|
38
|
+
"""Register Steppy's shared Jinja2 filters on an environment."""
|
|
39
|
+
env.filters["markdown"] = _markdown_inline
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
# Actions whose steps render without a cursor in the guide. Non-visual work
|
|
43
|
+
# (asserts, navigation, waits, auth) has no meaningful cursor behavior.
|
|
44
|
+
_HIDDEN_CURSOR_ACTIONS = {
|
|
45
|
+
"navigate",
|
|
46
|
+
"assert",
|
|
47
|
+
"wait",
|
|
48
|
+
"wait-for",
|
|
49
|
+
"wait-for-text",
|
|
50
|
+
"login",
|
|
51
|
+
"keyboard",
|
|
52
|
+
"capture",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _bbox_from_sidecar(sidecar: Optional[dict]) -> Optional[dict]:
|
|
57
|
+
"""Extract the single-target bbox from a sidecar. None if missing/malformed."""
|
|
58
|
+
if not sidecar:
|
|
59
|
+
return None
|
|
60
|
+
target = sidecar.get("target")
|
|
61
|
+
if not target or target.get("kind") != "single":
|
|
62
|
+
return None
|
|
63
|
+
return target.get("bbox")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _multi_bboxes_from_sidecar(sidecar: Optional[dict]) -> list[Optional[dict]]:
|
|
67
|
+
"""Extract per-field bboxes from a multi-target sidecar, preserving order."""
|
|
68
|
+
if not sidecar:
|
|
69
|
+
return []
|
|
70
|
+
target = sidecar.get("target")
|
|
71
|
+
if not target or target.get("kind") != "multi":
|
|
72
|
+
return []
|
|
73
|
+
return [f.get("bbox") for f in target.get("fields", [])]
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# Actions whose cursor is fully described by "one kind, one selector, maybe one
|
|
77
|
+
# value": action -> (cursor kind, the Step attribute holding the typed value, or
|
|
78
|
+
# None when this behavior emits no ``value`` key at all).
|
|
79
|
+
#
|
|
80
|
+
# Adding an action that animates like a click/type/hover is a row here; only
|
|
81
|
+
# genuinely bespoke shapes (fill-form's multi-target frames) need real code.
|
|
82
|
+
_SIMPLE_CURSOR: dict[str, tuple[str, Optional[str]]] = {
|
|
83
|
+
# select-option opens a component dropdown; the cursor lands on the trigger
|
|
84
|
+
# selector, same as a click. toggle clicks a switch/segment; pick-date and
|
|
85
|
+
# pick-time click the field to open a calendar/clock widget; expand clicks a
|
|
86
|
+
# fieldset legend. All land on (or within) the host selector like a click,
|
|
87
|
+
# positioned by the runtime sidecar bbox — no manual guide-cursor needed.
|
|
88
|
+
"click": ("click", None),
|
|
89
|
+
"select": ("click", None),
|
|
90
|
+
"select-option": ("click", None),
|
|
91
|
+
"confirm": ("click", None),
|
|
92
|
+
"toggle": ("click", None),
|
|
93
|
+
"pick-date": ("click", None),
|
|
94
|
+
"pick-time": ("click", None),
|
|
95
|
+
"expand": ("click", None),
|
|
96
|
+
"upload": ("click", None),
|
|
97
|
+
"hover": ("hover", None),
|
|
98
|
+
"scroll": ("hover", None),
|
|
99
|
+
"fill": ("type", "value"),
|
|
100
|
+
# fill-date's literal is resolved at test run time, so the guide shows the
|
|
101
|
+
# date expression rather than a baked-in date.
|
|
102
|
+
"fill-date": ("type", "date"),
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
# The confirm action resolves its target button imperatively (the dialog
|
|
106
|
+
# accept/reject button has no stable selector), so its cursor relies on the
|
|
107
|
+
# runtime-captured sidecar bbox rather than a CSS selector.
|
|
108
|
+
_NO_SELECTOR_CURSOR = {"confirm"}
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _cursor_from_override(override, sidecar: Optional[dict]) -> dict:
|
|
112
|
+
"""Cursor dict for a scenario-authored ``guide-cursor:`` block.
|
|
113
|
+
|
|
114
|
+
``override.behavior`` is already ``Literal["click","type","hover"]`` (see
|
|
115
|
+
``GuideCursorOverride`` in models.py), i.e. it *is* the cursor kind.
|
|
116
|
+
"""
|
|
117
|
+
return {
|
|
118
|
+
"kind": override.behavior,
|
|
119
|
+
"target": {
|
|
120
|
+
"selector": override.target,
|
|
121
|
+
"bbox": _bbox_from_sidecar(sidecar),
|
|
122
|
+
},
|
|
123
|
+
"value": override.value,
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _fillform_cursor(step: Step, sidecar: Optional[dict]) -> Optional[dict]:
|
|
128
|
+
"""Cursor for a fill-form step — the one genuinely multi-target shape.
|
|
129
|
+
|
|
130
|
+
Progressive capture emits one frame per field with a single-target sidecar
|
|
131
|
+
pointing at that field, plus a final settled frame with ``target: null``.
|
|
132
|
+
Detect the shape by inspecting the sidecar so each frame gets the right
|
|
133
|
+
cursor.
|
|
134
|
+
"""
|
|
135
|
+
target = (sidecar or {}).get("target") if sidecar else None
|
|
136
|
+
if step.interactive_progressive:
|
|
137
|
+
if target is None:
|
|
138
|
+
# Settled "all filled" frame — no cursor.
|
|
139
|
+
return None
|
|
140
|
+
if target.get("kind") == "single":
|
|
141
|
+
sel = target.get("selector")
|
|
142
|
+
value = (step.fields or {}).get(sel) if sel else None
|
|
143
|
+
return {
|
|
144
|
+
"kind": "type",
|
|
145
|
+
"target": {
|
|
146
|
+
"selector": sel,
|
|
147
|
+
"bbox": target.get("bbox"),
|
|
148
|
+
},
|
|
149
|
+
"value": value,
|
|
150
|
+
}
|
|
151
|
+
# Sidecar exists but isn't single-target — fall through to multi
|
|
152
|
+
# so we never end up with a frame whose cursor silently disappears.
|
|
153
|
+
fields = step.fields or {}
|
|
154
|
+
bboxes = _multi_bboxes_from_sidecar(sidecar)
|
|
155
|
+
targets = []
|
|
156
|
+
for i, (sel, val) in enumerate(fields.items()):
|
|
157
|
+
targets.append(
|
|
158
|
+
{
|
|
159
|
+
"selector": sel,
|
|
160
|
+
"bbox": bboxes[i] if i < len(bboxes) else None,
|
|
161
|
+
"value": val,
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
return {"kind": "type-multi", "targets": targets}
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _compute_cursor_behavior(step: Step, sidecar: Optional[dict]) -> Optional[dict]:
|
|
168
|
+
"""Map a scenario step + its sidecar metadata to a cursor behavior dict.
|
|
169
|
+
|
|
170
|
+
Returns None when the cursor must stay hidden for this step. Otherwise a
|
|
171
|
+
JSON-serializable dict consumed by the guide HTML template's JS driver.
|
|
172
|
+
"""
|
|
173
|
+
action = step.action
|
|
174
|
+
|
|
175
|
+
if action in _HIDDEN_CURSOR_ACTIONS:
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
# Scenario-authored override. `custom` has no native cursor at all, so the
|
|
179
|
+
# override is its only source. `upload` accepts one because hidden file
|
|
180
|
+
# inputs are common (visible trigger + chosen behavior), but falls back to a
|
|
181
|
+
# click on the input selector itself.
|
|
182
|
+
if step.guide_cursor and action in ("custom", "call", "upload"):
|
|
183
|
+
return _cursor_from_override(step.guide_cursor, sidecar)
|
|
184
|
+
if action in ("custom", "call"):
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
if action == "fill-form":
|
|
188
|
+
return _fillform_cursor(step, sidecar)
|
|
189
|
+
|
|
190
|
+
spec = _SIMPLE_CURSOR.get(action)
|
|
191
|
+
if spec is None:
|
|
192
|
+
return None
|
|
193
|
+
kind, value_attr = spec
|
|
194
|
+
|
|
195
|
+
behavior = {
|
|
196
|
+
"kind": kind,
|
|
197
|
+
"target": {
|
|
198
|
+
"selector": None if action in _NO_SELECTOR_CURSOR else step.selector,
|
|
199
|
+
"bbox": _bbox_from_sidecar(sidecar),
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
# Absent value_attr means this behavior emits no "value" key at all, which
|
|
203
|
+
# is distinct from emitting "value": None.
|
|
204
|
+
if value_attr is not None:
|
|
205
|
+
behavior["value"] = getattr(step, value_attr)
|
|
206
|
+
return behavior
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
# 1.1 added the optional per-capture ``content`` override (layout-varying guide
|
|
210
|
+
# text). The player gates on the MAJOR only, so a 1.0 manifest still loads —
|
|
211
|
+
# but the reverse is not true: ``Capture`` is a strict Zod object, so a 1.1
|
|
212
|
+
# manifest fed to a 1.0 player bundle fails validation outright. Emitter and
|
|
213
|
+
# vendored bundle must always ship together.
|
|
214
|
+
INTERACTIVE_MANIFEST_VERSION = "1.1"
|
|
215
|
+
|
|
216
|
+
# Layout key used in the manifest when the build has no layouts configured.
|
|
217
|
+
# Mirrors the synthetic key the player's Zod schema expects.
|
|
218
|
+
_LAYOUTLESS_KEY = "_"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _normalize_cursor_for_manifest(cursor: Optional[dict]) -> Optional[dict]:
|
|
222
|
+
"""Reshape ``_compute_cursor_behavior`` output into the strict manifest
|
|
223
|
+
cursor shape. Drops None-valued optional keys so the JSON we emit matches
|
|
224
|
+
the Zod schema's ``.strict()`` + ``.optional()`` contract (Zod's
|
|
225
|
+
``optional()`` rejects ``null``, only ``undefined``/absent keys).
|
|
226
|
+
|
|
227
|
+
Returns None when ``cursor`` is None (non-visual step / no cursor)."""
|
|
228
|
+
if cursor is None:
|
|
229
|
+
return None
|
|
230
|
+
kind = cursor.get("kind")
|
|
231
|
+
if kind == "type-multi":
|
|
232
|
+
out_targets: list[dict] = []
|
|
233
|
+
for t in cursor.get("targets") or []:
|
|
234
|
+
entry: dict = {"bbox": t.get("bbox")}
|
|
235
|
+
sel = t.get("selector")
|
|
236
|
+
if sel is not None:
|
|
237
|
+
entry["selector"] = sel
|
|
238
|
+
val = t.get("value")
|
|
239
|
+
if val is not None:
|
|
240
|
+
entry["value"] = val
|
|
241
|
+
out_targets.append(entry)
|
|
242
|
+
return {"kind": "type-multi", "targets": out_targets}
|
|
243
|
+
|
|
244
|
+
target = cursor.get("target") or {}
|
|
245
|
+
out_target: dict = {"bbox": target.get("bbox")}
|
|
246
|
+
sel = target.get("selector")
|
|
247
|
+
if sel is not None:
|
|
248
|
+
out_target["selector"] = sel
|
|
249
|
+
out: dict = {"kind": kind, "target": out_target}
|
|
250
|
+
if kind == "type":
|
|
251
|
+
val = cursor.get("value")
|
|
252
|
+
if val is not None:
|
|
253
|
+
out["value"] = val
|
|
254
|
+
return out
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def _cursor_has_complete_bboxes(cursor: Optional[dict]) -> bool:
|
|
258
|
+
"""True when every bbox referenced by the cursor dict is non-empty."""
|
|
259
|
+
if cursor is None:
|
|
260
|
+
return True
|
|
261
|
+
if cursor.get("kind") == "type-multi":
|
|
262
|
+
targets = cursor.get("targets") or []
|
|
263
|
+
return bool(targets) and all(t.get("bbox") for t in targets)
|
|
264
|
+
target = cursor.get("target") or {}
|
|
265
|
+
return bool(target.get("bbox"))
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _filter_type_multi_for_frame(
|
|
269
|
+
behavior: Optional[dict],
|
|
270
|
+
viewport: Optional[dict],
|
|
271
|
+
already_animated: set,
|
|
272
|
+
) -> Optional[dict]:
|
|
273
|
+
"""Return a copy of a ``type-multi`` cursor behavior keeping only targets
|
|
274
|
+
that (a) are in-viewport for this frame and (b) have not yet been animated
|
|
275
|
+
in an earlier frame.
|
|
276
|
+
|
|
277
|
+
``already_animated`` is mutated in place: every selector kept for this
|
|
278
|
+
frame is added to it so subsequent frames skip it. This ensures a form
|
|
279
|
+
field visible in the overlap zone between two frames is animated exactly
|
|
280
|
+
once — in the first frame it appears in.
|
|
281
|
+
"""
|
|
282
|
+
if not behavior or behavior.get("kind") != "type-multi":
|
|
283
|
+
return behavior
|
|
284
|
+
if not viewport:
|
|
285
|
+
return behavior
|
|
286
|
+
vh = viewport.get("height")
|
|
287
|
+
vw = viewport.get("width")
|
|
288
|
+
if not vh or not vw:
|
|
289
|
+
return behavior
|
|
290
|
+
kept = []
|
|
291
|
+
for t in behavior.get("targets") or []:
|
|
292
|
+
bbox = t.get("bbox")
|
|
293
|
+
if not bbox:
|
|
294
|
+
continue
|
|
295
|
+
sel = t.get("selector")
|
|
296
|
+
if sel is not None and sel in already_animated:
|
|
297
|
+
continue
|
|
298
|
+
cy = bbox.get("y", 0) + bbox.get("height", 0) / 2
|
|
299
|
+
cx = bbox.get("x", 0) + bbox.get("width", 0) / 2
|
|
300
|
+
if 0 <= cy <= vh and 0 <= cx <= vw:
|
|
301
|
+
kept.append(t)
|
|
302
|
+
if sel is not None:
|
|
303
|
+
already_animated.add(sel)
|
|
304
|
+
if not kept:
|
|
305
|
+
return None
|
|
306
|
+
return {"kind": "type-multi", "targets": kept}
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
class GuideAssembler:
|
|
310
|
+
"""Assembles user guides from scenario files and captured screenshots."""
|
|
311
|
+
|
|
312
|
+
def __init__(
|
|
313
|
+
self,
|
|
314
|
+
config: SteppyConfig,
|
|
315
|
+
templates_dir: Optional[Path] = None,
|
|
316
|
+
project_root: Optional[Path] = None,
|
|
317
|
+
):
|
|
318
|
+
"""
|
|
319
|
+
Initialize the guide assembler.
|
|
320
|
+
|
|
321
|
+
Args:
|
|
322
|
+
config: Validated Steppy configuration
|
|
323
|
+
templates_dir: Explicit template directory (error if missing).
|
|
324
|
+
project_root: Root whose ``templates/`` overrides the bundled ones.
|
|
325
|
+
Defaults to the current working directory.
|
|
326
|
+
|
|
327
|
+
See ``_templating.template_search_paths`` for the precedence rule,
|
|
328
|
+
which is shared with the generator and build assembler.
|
|
329
|
+
"""
|
|
330
|
+
self.config = config
|
|
331
|
+
|
|
332
|
+
# Memo for resolve_capture_segments. Instance-scoped, and positive
|
|
333
|
+
# answers only — see resolve_capture_segments / clear_capture_cache.
|
|
334
|
+
self._scenario_dir_cache: dict[
|
|
335
|
+
tuple[str, str, Optional[str], Optional[str]],
|
|
336
|
+
tuple[Optional[str], Optional[str]],
|
|
337
|
+
] = {}
|
|
338
|
+
|
|
339
|
+
# Memo for _parse_scenarios_dir, same lifetime/rationale.
|
|
340
|
+
self._scenarios_cache: dict[str, list[Scenario]] = {}
|
|
341
|
+
|
|
342
|
+
self.env = make_template_env(templates_dir, project_root)
|
|
343
|
+
register_jinja_filters(self.env)
|
|
344
|
+
|
|
345
|
+
def _filter_steps_by_role(
|
|
346
|
+
self,
|
|
347
|
+
scenario: Scenario,
|
|
348
|
+
role: str,
|
|
349
|
+
layout: Optional[str] = None,
|
|
350
|
+
) -> list[tuple[int, Step]]:
|
|
351
|
+
"""
|
|
352
|
+
Filter steps to only include those with screenshots for the given role.
|
|
353
|
+
|
|
354
|
+
When ``layout`` is provided, additionally drops steps whose ``layouts``
|
|
355
|
+
whitelist excludes it. Steps marked ``only-in: test`` are always
|
|
356
|
+
dropped (guides only ever render the guide-mode artifact set).
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
scenario: Parsed scenario
|
|
360
|
+
role: Role name (e.g., "editor", "reviewer")
|
|
361
|
+
layout: Optional layout name (e.g., "desktop", "mobile"). When None,
|
|
362
|
+
no layout filtering is applied.
|
|
363
|
+
|
|
364
|
+
Returns:
|
|
365
|
+
``(original_index, step)`` pairs for the steps that have screenshots
|
|
366
|
+
for this role and (if specified) run in the requested layout.
|
|
367
|
+
``original_index`` is 1-based and refers to the step's position in
|
|
368
|
+
the unfiltered ``scenario.steps`` — callers need it to locate
|
|
369
|
+
screenshots on disk, and recovering it afterwards would mean a
|
|
370
|
+
``list.index()`` scan whose ``==`` is a full pydantic deep compare.
|
|
371
|
+
"""
|
|
372
|
+
filtered = []
|
|
373
|
+
for original_index, step in enumerate(scenario.steps, start=1):
|
|
374
|
+
# Skip steps without screenshots
|
|
375
|
+
if step.screenshot is False or step.screenshot is None:
|
|
376
|
+
continue
|
|
377
|
+
|
|
378
|
+
# Include if screenshot is "all" or matches the role
|
|
379
|
+
if step.screenshot != "all" and step.screenshot != role:
|
|
380
|
+
continue
|
|
381
|
+
|
|
382
|
+
# Drop test-only steps (guide assembly is the guide-mode artifact set)
|
|
383
|
+
if step.only_in == "test":
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
# Drop layout-restricted steps that don't match the requested layout
|
|
387
|
+
if layout and step.layouts and layout not in step.layouts:
|
|
388
|
+
continue
|
|
389
|
+
|
|
390
|
+
filtered.append((original_index, step))
|
|
391
|
+
|
|
392
|
+
return filtered
|
|
393
|
+
|
|
394
|
+
def _discover_layouts(
|
|
395
|
+
self,
|
|
396
|
+
scenario_slug: str,
|
|
397
|
+
screenshots_dir: Path,
|
|
398
|
+
locale: Optional[str] = None,
|
|
399
|
+
) -> list[str]:
|
|
400
|
+
"""Discover layout subdirs under ``<slug>/<locale>/`` (or ``<slug>/``
|
|
401
|
+
when ``locale`` is None). A "layout" is any subdir that itself contains
|
|
402
|
+
``step-*.png`` files.
|
|
403
|
+
|
|
404
|
+
Returns ``[]`` when no per-layout subdirs exist — the project either
|
|
405
|
+
captured flat (``<slug>/step-NN.png``) or per-locale only
|
|
406
|
+
(``<slug>/<locale>/step-NN.png``). The caller treats ``[]`` as the
|
|
407
|
+
single-layout / back-compat path.
|
|
408
|
+
|
|
409
|
+
Used by ``generate_guides_for_tag`` to render one guide per
|
|
410
|
+
(locale, layout) when ``steppy run --guide`` was driven with
|
|
411
|
+
``screenshot.layouts`` configured, so screenshots in
|
|
412
|
+
``<slug>/<locale>/<layout>/`` get picked up automatically.
|
|
413
|
+
"""
|
|
414
|
+
if locale:
|
|
415
|
+
base = screenshots_dir / scenario_slug / locale
|
|
416
|
+
# Under a locale dir every capture-bearing subdir is a layout.
|
|
417
|
+
not_layouts: set[str] = set()
|
|
418
|
+
else:
|
|
419
|
+
base = screenshots_dir / scenario_slug
|
|
420
|
+
# At the flat base a per-locale capture dir and a per-layout one are
|
|
421
|
+
# structurally identical (`<slug>/fr/` vs `<slug>/desktop/`), so name
|
|
422
|
+
# is the only signal: anything named like a configured locale is a
|
|
423
|
+
# locale dir, not a layout.
|
|
424
|
+
not_layouts = set(self.config.locales or ())
|
|
425
|
+
if not base.is_dir():
|
|
426
|
+
return []
|
|
427
|
+
return sorted(child.name for child in base.iterdir() if child.name not in not_layouts and _has_captures(child))
|
|
428
|
+
|
|
429
|
+
def resolve_capture_segments(
|
|
430
|
+
self,
|
|
431
|
+
scenario_slug: str,
|
|
432
|
+
screenshots_dir: Path,
|
|
433
|
+
locale: Optional[str] = None,
|
|
434
|
+
layout: Optional[str] = None,
|
|
435
|
+
) -> tuple[Optional[str], Optional[str]]:
|
|
436
|
+
"""Which ``(locale, layout)`` path segments actually exist on disk.
|
|
437
|
+
|
|
438
|
+
**Single source of truth for capture-directory shape.** Three consumers
|
|
439
|
+
have to agree on this, or a build silently produces broken images:
|
|
440
|
+
|
|
441
|
+
* the READ side — :meth:`_resolve_scenario_dir`, i.e. which PNGs and
|
|
442
|
+
bbox sidecars the guide HTML is actually built from;
|
|
443
|
+
* the COPY side — ``build.py``'s ``_copy_screenshots``, i.e. where
|
|
444
|
+
those files land in the build's assets tree;
|
|
445
|
+
* the URL side — ``build.py``'s ``make_url_fn``, i.e. what the
|
|
446
|
+
``<img src>`` says.
|
|
447
|
+
|
|
448
|
+
They used to answer it independently and disagreed in three ways, most
|
|
449
|
+
damagingly when ``layouts:`` was configured but the screenshots on disk
|
|
450
|
+
had no per-layout subdirectory: the URL gained a ``/<layout>/`` segment
|
|
451
|
+
that the copier never created, so every screenshot 404'd while the build
|
|
452
|
+
reported success. Returning the *segments* (rather than a resolved Path)
|
|
453
|
+
is what lets the copier mirror them into the assets tree and the URL
|
|
454
|
+
builder join them into a URL, from one decision.
|
|
455
|
+
|
|
456
|
+
Resolution order, most specific first — **locale beats layout**, because
|
|
457
|
+
the locale determines the UI language actually visible in the capture,
|
|
458
|
+
and showing the wrong language is worse than showing the wrong viewport:
|
|
459
|
+
|
|
460
|
+
1. ``<slug>/<locale>/<layout>/`` → ``(locale, layout)``
|
|
461
|
+
2. ``<slug>/<locale>/`` → ``(locale, None)``
|
|
462
|
+
3. ``<slug>/<layout>/`` → ``(None, layout)``
|
|
463
|
+
4. otherwise → ``(None, None)`` (flat/legacy)
|
|
464
|
+
|
|
465
|
+
Falling back to a coarser shape is deliberate: single-locale and
|
|
466
|
+
single-layout projects keep building without re-capturing.
|
|
467
|
+
|
|
468
|
+
A rung matches only when the directory actually holds captures (see
|
|
469
|
+
:func:`_has_captures`) — merely existing is not enough. Two shapes
|
|
470
|
+
depend on that: an empty ``<slug>/<locale>/`` left behind by a crashed
|
|
471
|
+
capture must not shadow a working coarser one, and a
|
|
472
|
+
``<slug>/<locale>/`` that holds only a ``<layout>/`` subdir holds no
|
|
473
|
+
captures of its own.
|
|
474
|
+
|
|
475
|
+
Memoized — the answer depends only on the four arguments, which are
|
|
476
|
+
constant for a whole ``prepare_guide_steps`` call, yet the read-side
|
|
477
|
+
callers (screenshot path, frame path, sidecar) each ask per step. A
|
|
478
|
+
*negative* answer is never memoized: nothing stops a caller from holding
|
|
479
|
+
an assembler across a capture, and pinning "nothing on disk" for the
|
|
480
|
+
object's lifetime would make it blind to screenshots written afterwards.
|
|
481
|
+
Use :meth:`clear_capture_cache` to drop positive answers too.
|
|
482
|
+
"""
|
|
483
|
+
cache_key = (scenario_slug, str(screenshots_dir), locale, layout)
|
|
484
|
+
cached = self._scenario_dir_cache.get(cache_key)
|
|
485
|
+
if cached is not None:
|
|
486
|
+
return cached
|
|
487
|
+
|
|
488
|
+
base = screenshots_dir / scenario_slug
|
|
489
|
+
if locale and layout and _has_captures(base / locale / layout):
|
|
490
|
+
segments = (locale, layout)
|
|
491
|
+
elif locale and _has_captures(base / locale):
|
|
492
|
+
segments = (locale, None)
|
|
493
|
+
elif layout and _has_captures(base / layout):
|
|
494
|
+
segments = (None, layout)
|
|
495
|
+
else:
|
|
496
|
+
# Flat/legacy, or nothing captured yet. Deliberately not memoized —
|
|
497
|
+
# see the docstring.
|
|
498
|
+
return (None, None)
|
|
499
|
+
|
|
500
|
+
self._scenario_dir_cache[cache_key] = segments
|
|
501
|
+
return segments
|
|
502
|
+
|
|
503
|
+
def clear_capture_cache(self) -> None:
|
|
504
|
+
"""Forget memoized capture-directory resolutions.
|
|
505
|
+
|
|
506
|
+
Call after writing screenshots if this assembler was built beforehand.
|
|
507
|
+
"""
|
|
508
|
+
self._scenario_dir_cache.clear()
|
|
509
|
+
|
|
510
|
+
def _resolve_scenario_dir(
|
|
511
|
+
self,
|
|
512
|
+
scenario_slug: str,
|
|
513
|
+
screenshots_dir: Path,
|
|
514
|
+
locale: Optional[str] = None,
|
|
515
|
+
layout: Optional[str] = None,
|
|
516
|
+
) -> Path:
|
|
517
|
+
"""The directory holding this scenario's screenshots — read side.
|
|
518
|
+
|
|
519
|
+
Thin wrapper over :meth:`resolve_capture_segments`, which owns the rules.
|
|
520
|
+
"""
|
|
521
|
+
segments = self.resolve_capture_segments(scenario_slug, screenshots_dir, locale, layout)
|
|
522
|
+
path = screenshots_dir / scenario_slug
|
|
523
|
+
for segment in segments:
|
|
524
|
+
if segment:
|
|
525
|
+
path = path / segment
|
|
526
|
+
return path
|
|
527
|
+
|
|
528
|
+
def _get_screenshot_path(
|
|
529
|
+
self,
|
|
530
|
+
scenario_slug: str,
|
|
531
|
+
step_index: int,
|
|
532
|
+
screenshots_dir: Path,
|
|
533
|
+
locale: Optional[str] = None,
|
|
534
|
+
layout: Optional[str] = None,
|
|
535
|
+
) -> Optional[Path]:
|
|
536
|
+
"""
|
|
537
|
+
Get the path to a screenshot file if it exists.
|
|
538
|
+
|
|
539
|
+
Args:
|
|
540
|
+
scenario_slug: Scenario slug (e.g., "cross--order-lifecycle")
|
|
541
|
+
step_index: Step index (1-based)
|
|
542
|
+
screenshots_dir: Base screenshots directory
|
|
543
|
+
locale: Locale code; resolves the per-locale subdir if it exists.
|
|
544
|
+
layout: Layout name; resolves the per-layout subdir if it exists.
|
|
545
|
+
|
|
546
|
+
Returns:
|
|
547
|
+
Path to screenshot if it exists, None otherwise
|
|
548
|
+
"""
|
|
549
|
+
screenshot_dir = self._resolve_scenario_dir(scenario_slug, screenshots_dir, locale, layout)
|
|
550
|
+
screenshot_path = screenshot_dir / f"{step_stem(step_index)}.png"
|
|
551
|
+
|
|
552
|
+
if screenshot_path.exists():
|
|
553
|
+
return screenshot_path
|
|
554
|
+
return None
|
|
555
|
+
|
|
556
|
+
def _load_sidecar(
|
|
557
|
+
self,
|
|
558
|
+
scenario_slug: str,
|
|
559
|
+
step_index: int,
|
|
560
|
+
screenshots_dir: Path,
|
|
561
|
+
frame: Optional[str] = None,
|
|
562
|
+
locale: Optional[str] = None,
|
|
563
|
+
layout: Optional[str] = None,
|
|
564
|
+
) -> Optional[dict]:
|
|
565
|
+
"""Load a sidecar JSON if present. None on any failure.
|
|
566
|
+
|
|
567
|
+
When ``frame`` is None, loads ``step-NN.json``. When ``frame`` is set
|
|
568
|
+
(e.g. ``"a"``, ``"b"``), loads ``step-NN-{frame}.json``. ``locale``
|
|
569
|
+
and ``layout`` pick the appropriate subdirs when they exist.
|
|
570
|
+
"""
|
|
571
|
+
stem = step_stem(step_index, frame)
|
|
572
|
+
path = self._resolve_scenario_dir(scenario_slug, screenshots_dir, locale, layout) / f"{stem}.json"
|
|
573
|
+
if not path.exists():
|
|
574
|
+
return None
|
|
575
|
+
try:
|
|
576
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
577
|
+
except Exception:
|
|
578
|
+
return None
|
|
579
|
+
|
|
580
|
+
def _frame_screenshot_path(
|
|
581
|
+
self,
|
|
582
|
+
scenario_slug: str,
|
|
583
|
+
step_index: int,
|
|
584
|
+
screenshots_dir: Path,
|
|
585
|
+
frame: Optional[str] = None,
|
|
586
|
+
locale: Optional[str] = None,
|
|
587
|
+
layout: Optional[str] = None,
|
|
588
|
+
) -> Optional[Path]:
|
|
589
|
+
"""Return the PNG path for a (step, frame) pair if it exists."""
|
|
590
|
+
stem = step_stem(step_index, frame)
|
|
591
|
+
p = self._resolve_scenario_dir(scenario_slug, screenshots_dir, locale, layout) / f"{stem}.png"
|
|
592
|
+
return p if p.exists() else None
|
|
593
|
+
|
|
594
|
+
def prepare_guide_steps(
|
|
595
|
+
self,
|
|
596
|
+
scenario: Scenario,
|
|
597
|
+
role: str,
|
|
598
|
+
locale: str,
|
|
599
|
+
screenshots_dir: Path,
|
|
600
|
+
screenshot_url_fn: "Callable[[str, int, Optional[str]], str] | None" = None,
|
|
601
|
+
layout: Optional[str] = None,
|
|
602
|
+
) -> list[dict]:
|
|
603
|
+
"""Prepare guide step dicts for template rendering.
|
|
604
|
+
|
|
605
|
+
This is the shared logic used by both ``generate_guide`` (standalone HTML)
|
|
606
|
+
and the ``steppy build`` pipeline (Angular fragments).
|
|
607
|
+
|
|
608
|
+
Args:
|
|
609
|
+
scenario: Parsed scenario
|
|
610
|
+
role: Role name (e.g., "editor", "reviewer")
|
|
611
|
+
locale: Locale code (e.g., "fr", "en")
|
|
612
|
+
screenshots_dir: Base screenshots directory
|
|
613
|
+
screenshot_url_fn: Custom screenshot URL resolver. Called with
|
|
614
|
+
``(scenario_slug, step_index, frame_key)`` and must return
|
|
615
|
+
the URL/path string to use in the template. If ``None``,
|
|
616
|
+
uses the default relative-path convention.
|
|
617
|
+
layout: Optional layout name (e.g., "desktop", "mobile"). When
|
|
618
|
+
provided, only steps that run in this layout are included and
|
|
619
|
+
screenshots are loaded from the per-layout subdirectory.
|
|
620
|
+
|
|
621
|
+
Returns:
|
|
622
|
+
List of step dicts ready for Jinja2 template rendering.
|
|
623
|
+
|
|
624
|
+
Raises:
|
|
625
|
+
ValueError: If screenshots are missing or guide-text is missing
|
|
626
|
+
for the requested locale.
|
|
627
|
+
"""
|
|
628
|
+
filtered_steps = self._filter_steps_by_role(scenario, role, layout=layout)
|
|
629
|
+
|
|
630
|
+
if not filtered_steps:
|
|
631
|
+
layout_suffix = f" / layout '{layout}'" if layout else ""
|
|
632
|
+
raise ValueError(
|
|
633
|
+
f"No steps with screenshots found for role '{role}'{layout_suffix} in scenario '{scenario.slug}'"
|
|
634
|
+
)
|
|
635
|
+
|
|
636
|
+
# Check that all steps have guide-text for this locale
|
|
637
|
+
missing_guide_text_indices = []
|
|
638
|
+
for original_index, step in filtered_steps:
|
|
639
|
+
if not step.guide_text or not step.guide_text.get(locale):
|
|
640
|
+
missing_guide_text_indices.append(original_index)
|
|
641
|
+
|
|
642
|
+
if missing_guide_text_indices:
|
|
643
|
+
raise ValueError(f"Missing guide-text for locale '{locale}' in steps: {missing_guide_text_indices}")
|
|
644
|
+
|
|
645
|
+
def _default_screenshot_url(slug: str, step_index: int, frame_key: Optional[str]) -> str:
|
|
646
|
+
stem = step_stem(step_index, frame_key)
|
|
647
|
+
return f"../../../screenshots/{slug}/{stem}.png"
|
|
648
|
+
|
|
649
|
+
url_fn = screenshot_url_fn or _default_screenshot_url
|
|
650
|
+
|
|
651
|
+
guide_steps: list[dict] = []
|
|
652
|
+
for original_index, filtered_step in filtered_steps:
|
|
653
|
+
# Detect multi-frame capture (fill-form: step-NN-a, -b, -c, …)
|
|
654
|
+
frame_keys: list[Optional[str]] = []
|
|
655
|
+
for letter in "abcdefghijklmnopqrstuvwxyz":
|
|
656
|
+
if self._frame_screenshot_path(
|
|
657
|
+
scenario.slug,
|
|
658
|
+
original_index,
|
|
659
|
+
screenshots_dir,
|
|
660
|
+
frame=letter,
|
|
661
|
+
locale=locale,
|
|
662
|
+
layout=layout,
|
|
663
|
+
):
|
|
664
|
+
frame_keys.append(letter)
|
|
665
|
+
else:
|
|
666
|
+
break
|
|
667
|
+
if not frame_keys:
|
|
668
|
+
frame_keys = [None]
|
|
669
|
+
if not self._get_screenshot_path(
|
|
670
|
+
scenario.slug,
|
|
671
|
+
original_index,
|
|
672
|
+
screenshots_dir,
|
|
673
|
+
locale=locale,
|
|
674
|
+
layout=layout,
|
|
675
|
+
):
|
|
676
|
+
layout_suffix = f" / layout '{layout}'" if layout else ""
|
|
677
|
+
raise ValueError(
|
|
678
|
+
f"Missing screenshot for step {original_index}{layout_suffix} in scenario '{scenario.slug}'"
|
|
679
|
+
)
|
|
680
|
+
|
|
681
|
+
frames = []
|
|
682
|
+
already_animated: set = set()
|
|
683
|
+
for fk in frame_keys:
|
|
684
|
+
screenshot_ref = url_fn(scenario.slug, original_index, fk)
|
|
685
|
+
sidecar = self._load_sidecar(
|
|
686
|
+
scenario.slug,
|
|
687
|
+
original_index,
|
|
688
|
+
screenshots_dir,
|
|
689
|
+
frame=fk,
|
|
690
|
+
locale=locale,
|
|
691
|
+
layout=layout,
|
|
692
|
+
)
|
|
693
|
+
cursor_behavior = _compute_cursor_behavior(filtered_step, sidecar)
|
|
694
|
+
viewport = (sidecar or {}).get("viewport")
|
|
695
|
+
if len(frame_keys) > 1:
|
|
696
|
+
cursor_behavior = _filter_type_multi_for_frame(cursor_behavior, viewport, already_animated)
|
|
697
|
+
frames.append(
|
|
698
|
+
{
|
|
699
|
+
"screenshot": screenshot_ref,
|
|
700
|
+
"viewport": viewport,
|
|
701
|
+
"cursor": cursor_behavior,
|
|
702
|
+
}
|
|
703
|
+
)
|
|
704
|
+
|
|
705
|
+
first = frames[0]
|
|
706
|
+
guide_content = filtered_step.guide_text.get(locale)
|
|
707
|
+
guide_steps.append(
|
|
708
|
+
{
|
|
709
|
+
"index": len(guide_steps) + 1,
|
|
710
|
+
"title": guide_content.title if guide_content else "",
|
|
711
|
+
"subtitle": guide_content.subtitle if guide_content else None,
|
|
712
|
+
"text": guide_content.text if guide_content else None,
|
|
713
|
+
"screenshot": first["screenshot"],
|
|
714
|
+
"viewport": first["viewport"],
|
|
715
|
+
"cursor": first["cursor"],
|
|
716
|
+
"frames": frames,
|
|
717
|
+
}
|
|
718
|
+
)
|
|
719
|
+
|
|
720
|
+
return guide_steps
|
|
721
|
+
|
|
722
|
+
def prepare_interactive_manifest(
|
|
723
|
+
self,
|
|
724
|
+
scenario: Scenario,
|
|
725
|
+
role: str,
|
|
726
|
+
screenshots_dir: Path,
|
|
727
|
+
locales: list[str],
|
|
728
|
+
layouts: list[str],
|
|
729
|
+
default_locale: str,
|
|
730
|
+
default_layout: Optional[str],
|
|
731
|
+
title: dict[str, str],
|
|
732
|
+
cursor_timings: dict,
|
|
733
|
+
screenshot_url_fn: "Callable[[str, int, Optional[str], str, Optional[str]], str]",
|
|
734
|
+
manifest_version: str = INTERACTIVE_MANIFEST_VERSION,
|
|
735
|
+
cursor_mode: Optional[str] = None,
|
|
736
|
+
default_template: str = "overlay",
|
|
737
|
+
) -> dict:
|
|
738
|
+
"""Build an interactive guide manifest from a scenario.
|
|
739
|
+
|
|
740
|
+
Reuses ``prepare_guide_steps`` per (locale, layout) pair and merges the
|
|
741
|
+
results into the manifest shape consumed by ``<steppy-guide>`` and
|
|
742
|
+
validated by [packages/player/src/manifest/schema.ts].
|
|
743
|
+
|
|
744
|
+
Pairs are merged by list POSITION, not by scenario step number, because
|
|
745
|
+
the twin-branch authoring pattern writes one logical step twice — once
|
|
746
|
+
per layout — when the DOM path differs (a sidebar link on desktop, a
|
|
747
|
+
hamburger drawer on mobile). Position ``i`` is therefore "step i of this
|
|
748
|
+
layout's path"; toggling layout mid-guide keeps the step index, which in
|
|
749
|
+
a complementary-whitelist scenario may land on a different logical
|
|
750
|
+
moment. That is the intended semantics and the only one a single
|
|
751
|
+
``steps[]`` array can express. What the manifest cannot express is a
|
|
752
|
+
divergent step *count* per layout, which is fatal.
|
|
753
|
+
|
|
754
|
+
Args:
|
|
755
|
+
scenario: Parsed scenario (must have ``guide.enabled`` set true upstream).
|
|
756
|
+
role: Role name (must be one of ``config.apps``).
|
|
757
|
+
screenshots_dir: Base screenshots directory.
|
|
758
|
+
locales: Locales to include in the manifest. Order is preserved.
|
|
759
|
+
layouts: Layouts to include. Empty list signals a layout-less build;
|
|
760
|
+
the manifest emits a synthetic ``"_"`` layout key.
|
|
761
|
+
default_locale: Must appear in ``locales``.
|
|
762
|
+
default_layout: Must appear in ``layouts`` when non-empty, else None.
|
|
763
|
+
title: LocalizedText for the guide title (locale → string).
|
|
764
|
+
cursor_timings: Cursor timing block (typically
|
|
765
|
+
``scenario.guide.cursor.model_dump()``).
|
|
766
|
+
screenshot_url_fn: Callable that resolves a screenshot URL for a
|
|
767
|
+
given (slug, step_index, frame_key, locale, layout). The
|
|
768
|
+
``layout`` arg is None when ``layouts`` is empty.
|
|
769
|
+
manifest_version: Manifest format version. Defaults to the supported
|
|
770
|
+
version; bumped when the schema changes incompatibly.
|
|
771
|
+
cursor_mode: Optional cursor presentation override merged into the
|
|
772
|
+
manifest's ``cursor`` block when set.
|
|
773
|
+
default_template: Per-guide default presentation template
|
|
774
|
+
(``sidebar`` | ``overlay`` | ``inline``). Emitted as the
|
|
775
|
+
top-level ``defaultTemplate``; the player resolves attr →
|
|
776
|
+
persisted → this → ``overlay``.
|
|
777
|
+
|
|
778
|
+
Returns:
|
|
779
|
+
A JSON-serializable manifest dict.
|
|
780
|
+
|
|
781
|
+
Raises:
|
|
782
|
+
ValueError: On any inconsistency that the schema would reject
|
|
783
|
+
(empty locales, default_locale not in locales, divergent step
|
|
784
|
+
counts across (locale, layout), missing bbox on an interactive
|
|
785
|
+
step, etc.). The error message names the failing step so the
|
|
786
|
+
author can fix the scenario.
|
|
787
|
+
"""
|
|
788
|
+
if not locales:
|
|
789
|
+
raise ValueError("prepare_interactive_manifest: locales must be non-empty")
|
|
790
|
+
if default_locale not in locales:
|
|
791
|
+
raise ValueError(
|
|
792
|
+
f"prepare_interactive_manifest: default_locale '{default_locale}' is not in locales {locales}"
|
|
793
|
+
)
|
|
794
|
+
if layouts:
|
|
795
|
+
if default_layout is None:
|
|
796
|
+
raise ValueError("prepare_interactive_manifest: default_layout must be set when layouts is non-empty")
|
|
797
|
+
if default_layout not in layouts:
|
|
798
|
+
raise ValueError(
|
|
799
|
+
f"prepare_interactive_manifest: default_layout '{default_layout}' is not in layouts {layouts}"
|
|
800
|
+
)
|
|
801
|
+
elif default_layout is not None:
|
|
802
|
+
raise ValueError("prepare_interactive_manifest: default_layout must be None when layouts is empty")
|
|
803
|
+
|
|
804
|
+
# Title must cover every declared locale (matches schema rule).
|
|
805
|
+
missing_titles = [loc for loc in locales if loc not in title]
|
|
806
|
+
if missing_titles:
|
|
807
|
+
raise ValueError(
|
|
808
|
+
f"prepare_interactive_manifest: title is missing locales {missing_titles}; declared locales: {locales}"
|
|
809
|
+
)
|
|
810
|
+
|
|
811
|
+
# Layout dimension: iterate None when layouts is empty (passes through
|
|
812
|
+
# to prepare_guide_steps unchanged), key the manifest under "_" instead.
|
|
813
|
+
layout_iter: list[Optional[str]] = list(layouts) if layouts else [None]
|
|
814
|
+
|
|
815
|
+
def layout_key_for(layout: Optional[str]) -> str:
|
|
816
|
+
return layout if layout is not None else _LAYOUTLESS_KEY
|
|
817
|
+
|
|
818
|
+
# Call prepare_guide_steps once per (locale, layout). Wrap the 5-arg
|
|
819
|
+
# URL fn into the 3-arg signature prepare_guide_steps expects.
|
|
820
|
+
per_pair: dict[tuple[str, Optional[str]], list[dict]] = {}
|
|
821
|
+
for locale in locales:
|
|
822
|
+
for layout in layout_iter:
|
|
823
|
+
|
|
824
|
+
def _bound(
|
|
825
|
+
slug: str,
|
|
826
|
+
step_idx: int,
|
|
827
|
+
frame_key: Optional[str],
|
|
828
|
+
_locale: str = locale,
|
|
829
|
+
_layout: Optional[str] = layout,
|
|
830
|
+
) -> str:
|
|
831
|
+
return screenshot_url_fn(slug, step_idx, frame_key, _locale, _layout)
|
|
832
|
+
|
|
833
|
+
pair_steps = self.prepare_guide_steps(
|
|
834
|
+
scenario=scenario,
|
|
835
|
+
role=role,
|
|
836
|
+
locale=locale,
|
|
837
|
+
screenshots_dir=screenshots_dir,
|
|
838
|
+
screenshot_url_fn=_bound,
|
|
839
|
+
layout=layout,
|
|
840
|
+
)
|
|
841
|
+
per_pair[(locale, layout)] = pair_steps
|
|
842
|
+
|
|
843
|
+
# Per-layout filtered step lists — the join table for the loop below.
|
|
844
|
+
# Position `i` is the same MANIFEST step in every layout, but NOT
|
|
845
|
+
# necessarily the same SCENARIO step: the desktop/mobile twin-branch
|
|
846
|
+
# pattern deliberately authors one logical step twice (a sidebar link on
|
|
847
|
+
# desktop, a hamburger drawer on mobile), so the layouts cover
|
|
848
|
+
# complementary `original_index` values by design. Divergent indices are
|
|
849
|
+
# therefore expected, not an error; what the manifest cannot express is
|
|
850
|
+
# a divergent step COUNT (see the guard below).
|
|
851
|
+
#
|
|
852
|
+
# Layout is the only filter dimension — locale never affects
|
|
853
|
+
# `_filter_steps_by_role` — so one call per layout covers every
|
|
854
|
+
# (locale, layout) pair, and `canonical_filtered` reuses this map rather
|
|
855
|
+
# than filtering a third time.
|
|
856
|
+
per_layout_filtered: dict[Optional[str], list[tuple[int, Step]]] = {
|
|
857
|
+
layout: self._filter_steps_by_role(scenario, role, layout=layout) for layout in layout_iter
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
# Step counts must agree across every (locale, layout) pair: the manifest
|
|
861
|
+
# is ONE step list with a layout toggle, and the loop below indexes every
|
|
862
|
+
# pair positionally, so an extra capture in one layout has no step slot
|
|
863
|
+
# to live in. Locale does not affect step filtering, so a mismatch on
|
|
864
|
+
# that axis means something else went wrong (e.g. a capture shape that
|
|
865
|
+
# resolved differently per locale) — fatal either way.
|
|
866
|
+
counts = {pair: len(steps) for pair, steps in per_pair.items()}
|
|
867
|
+
if len(set(counts.values())) > 1:
|
|
868
|
+
breakdown = ", ".join(f"({loc},{lay or '_'})={n}" for (loc, lay), n in counts.items())
|
|
869
|
+
coverage = "; ".join(
|
|
870
|
+
f"{lay or '_'} covers scenario steps {[i for i, _ in steps]}"
|
|
871
|
+
for lay, steps in per_layout_filtered.items()
|
|
872
|
+
)
|
|
873
|
+
raise ValueError(
|
|
874
|
+
f"Interactive manifest for '{scenario.slug}': non-uniform step "
|
|
875
|
+
f"coverage across (locale, layout) pairs ({breakdown}). {coverage}. "
|
|
876
|
+
f"Interactive mode renders one step list with a layout toggle, so "
|
|
877
|
+
f"every layout must contribute the same number of captured steps. "
|
|
878
|
+
f"A step restricted with 'layouts: [desktop]' needs a matching "
|
|
879
|
+
f"'layouts: [mobile]' twin so the counts balance."
|
|
880
|
+
)
|
|
881
|
+
total = next(iter(set(counts.values())))
|
|
882
|
+
|
|
883
|
+
# Cursor 'interactive' shape must agree across pairs. Per-frame bboxes
|
|
884
|
+
# are allowed to differ (different viewport, different element rect)
|
|
885
|
+
# but whether the step is interactive at all must not.
|
|
886
|
+
canonical_pair = (default_locale, default_layout)
|
|
887
|
+
canonical_steps = per_pair[canonical_pair]
|
|
888
|
+
canonical_filtered = per_layout_filtered[default_layout]
|
|
889
|
+
|
|
890
|
+
# Positional-join smell test. Layout twins always share an action, so a
|
|
891
|
+
# divergence here means the join lined up two unrelated steps and the
|
|
892
|
+
# layout-invariant fields (action, interactive, screenshotWhen,
|
|
893
|
+
# waitAfter — all sourced from the canonical layout) are wrong for the
|
|
894
|
+
# other layouts. Warn rather than raise: none of those fields drives
|
|
895
|
+
# player behaviour (`action` only rides the `steppy:step` event
|
|
896
|
+
# payload), and the one that does matter — whether the step waits for a
|
|
897
|
+
# click — is separately and fatally guarded by the cursor-presence check
|
|
898
|
+
# further down. Raising here would re-create the build breakage that
|
|
899
|
+
# over-strict cross-layout validation caused in the first place.
|
|
900
|
+
if len(layout_iter) > 1:
|
|
901
|
+
for i in range(total):
|
|
902
|
+
actions = {lay or "_": steps[i][1].action for lay, steps in per_layout_filtered.items()}
|
|
903
|
+
if len(set(actions.values())) > 1:
|
|
904
|
+
indices = {lay or "_": steps[i][0] for lay, steps in per_layout_filtered.items()}
|
|
905
|
+
print(
|
|
906
|
+
f"WARNING: interactive manifest for '{scenario.slug}' step {i + 1}: "
|
|
907
|
+
f"layouts disagree on action ({actions}) at scenario steps {indices}. "
|
|
908
|
+
f"Check that the layout twins are paired in source order.",
|
|
909
|
+
file=sys.stderr,
|
|
910
|
+
)
|
|
911
|
+
|
|
912
|
+
def _content_for(step_obj: Step, loc: str) -> tuple[Optional[str], Optional[str], Optional[str]]:
|
|
913
|
+
"""``(title, text, subtitle)`` for one step in one locale.
|
|
914
|
+
|
|
915
|
+
``title`` is the short per-step heading (sidebar timeline); ``text``
|
|
916
|
+
is the instruction body. Both are nullable where guide-text is
|
|
917
|
+
absent (like ``subtitle``); the player falls back to ``text``, and
|
|
918
|
+
``text`` itself falls back to ``title`` — which is why this cannot
|
|
919
|
+
read ``prepare_guide_steps`` output, where that second fallback does
|
|
920
|
+
not exist.
|
|
921
|
+
"""
|
|
922
|
+
gc = step_obj.guide_text.get(loc) if step_obj.guide_text else None
|
|
923
|
+
if gc is None:
|
|
924
|
+
return (None, None, None)
|
|
925
|
+
return (gc.title, gc.text if gc.text is not None else gc.title, gc.subtitle)
|
|
926
|
+
|
|
927
|
+
manifest_steps: list[dict] = []
|
|
928
|
+
for i in range(total):
|
|
929
|
+
canonical_step = canonical_steps[i]
|
|
930
|
+
original_index, scenario_step = canonical_filtered[i]
|
|
931
|
+
canonical_cursor = canonical_step.get("cursor")
|
|
932
|
+
|
|
933
|
+
# Step-level text is the canonical layout's, resolved at build time
|
|
934
|
+
# (not at runtime — the spec's "one manifest, all locales inline"
|
|
935
|
+
# rule). Layouts whose wording differs override it per capture,
|
|
936
|
+
# below.
|
|
937
|
+
title_per_locale: dict[str, Optional[str]] = {}
|
|
938
|
+
text_per_locale: dict[str, Optional[str]] = {}
|
|
939
|
+
subtitle_per_locale: dict[str, Optional[str]] = {}
|
|
940
|
+
for locale in locales:
|
|
941
|
+
(
|
|
942
|
+
title_per_locale[locale],
|
|
943
|
+
text_per_locale[locale],
|
|
944
|
+
subtitle_per_locale[locale],
|
|
945
|
+
) = _content_for(scenario_step, locale)
|
|
946
|
+
|
|
947
|
+
# Reject empty/missing text for any locale — the schema would too.
|
|
948
|
+
empty_text = [loc for loc, t in text_per_locale.items() if not t]
|
|
949
|
+
if empty_text:
|
|
950
|
+
raise ValueError(
|
|
951
|
+
f"Interactive manifest for '{scenario.slug}' step "
|
|
952
|
+
f"{original_index}: empty text for locales {empty_text}"
|
|
953
|
+
)
|
|
954
|
+
|
|
955
|
+
# captures[locale][layout].frames[]
|
|
956
|
+
captures: dict[str, dict[str, dict]] = {}
|
|
957
|
+
step_is_interactive = canonical_cursor is not None
|
|
958
|
+
for locale in locales:
|
|
959
|
+
captures[locale] = {}
|
|
960
|
+
for layout in layout_iter:
|
|
961
|
+
pair_steps = per_pair[(locale, layout)]
|
|
962
|
+
src = pair_steps[i]
|
|
963
|
+
src_cursor_for_step = src.get("cursor")
|
|
964
|
+
|
|
965
|
+
# Cursor "shape" (None vs not-None) must be uniform across
|
|
966
|
+
# pairs for a given step.
|
|
967
|
+
if (src_cursor_for_step is None) != (canonical_cursor is None):
|
|
968
|
+
raise ValueError(
|
|
969
|
+
f"Interactive manifest for '{scenario.slug}' step "
|
|
970
|
+
f"{original_index}: cursor presence differs between "
|
|
971
|
+
f"({default_locale},{default_layout or '_'}) and "
|
|
972
|
+
f"({locale},{layout or '_'}). All pairs must agree."
|
|
973
|
+
)
|
|
974
|
+
|
|
975
|
+
manifest_frames: list[dict] = []
|
|
976
|
+
for src_frame in src["frames"]:
|
|
977
|
+
viewport = src_frame.get("viewport") or {}
|
|
978
|
+
raw_cursor = src_frame.get("cursor")
|
|
979
|
+
norm_cursor = _normalize_cursor_for_manifest(raw_cursor)
|
|
980
|
+
|
|
981
|
+
if step_is_interactive and norm_cursor is not None:
|
|
982
|
+
if not _cursor_has_complete_bboxes(norm_cursor):
|
|
983
|
+
raise ValueError(
|
|
984
|
+
f"Interactive manifest for "
|
|
985
|
+
f"'{scenario.slug}' step {original_index} "
|
|
986
|
+
f"({locale}/{layout or '_'}): cursor target "
|
|
987
|
+
f"is missing a bbox. The sidecar JSON may "
|
|
988
|
+
f"be missing or the selector failed at "
|
|
989
|
+
f"capture time."
|
|
990
|
+
)
|
|
991
|
+
|
|
992
|
+
capture_w = viewport.get("width")
|
|
993
|
+
capture_h = viewport.get("height")
|
|
994
|
+
if not capture_w or not capture_h:
|
|
995
|
+
raise ValueError(
|
|
996
|
+
f"Interactive manifest for '{scenario.slug}' "
|
|
997
|
+
f"step {original_index} ({locale}/"
|
|
998
|
+
f"{layout or '_'}): missing viewport "
|
|
999
|
+
f"dimensions in sidecar."
|
|
1000
|
+
)
|
|
1001
|
+
|
|
1002
|
+
manifest_frames.append(
|
|
1003
|
+
{
|
|
1004
|
+
"screenshot": src_frame["screenshot"],
|
|
1005
|
+
"captureSize": {
|
|
1006
|
+
"width": capture_w,
|
|
1007
|
+
"height": capture_h,
|
|
1008
|
+
},
|
|
1009
|
+
"cursor": norm_cursor,
|
|
1010
|
+
}
|
|
1011
|
+
)
|
|
1012
|
+
capture: dict = {"frames": manifest_frames}
|
|
1013
|
+
|
|
1014
|
+
# Layout-varying guide text rides in the layout-scoped bag,
|
|
1015
|
+
# beside the frames it describes. The twin-branch pattern
|
|
1016
|
+
# authors one logical step twice — "click the row of the
|
|
1017
|
+
# table" on desktop, "tap the card" on mobile — and only the
|
|
1018
|
+
# canonical branch's wording reaches the step level, so
|
|
1019
|
+
# without this the mobile viewer reads desktop's
|
|
1020
|
+
# instructions under a mobile screenshot.
|
|
1021
|
+
#
|
|
1022
|
+
# Emitted ONLY when it differs from the step-level triple:
|
|
1023
|
+
# every guide with no layout twins (and every single-layout
|
|
1024
|
+
# or layout-less build, where canonical IS the only layout)
|
|
1025
|
+
# then stays byte-identical, which is also what keeps the
|
|
1026
|
+
# video render cache from churning.
|
|
1027
|
+
layout_content = _content_for(per_layout_filtered[layout][i][1], locale)
|
|
1028
|
+
if layout_content != (
|
|
1029
|
+
title_per_locale[locale],
|
|
1030
|
+
text_per_locale[locale],
|
|
1031
|
+
subtitle_per_locale[locale],
|
|
1032
|
+
):
|
|
1033
|
+
if not layout_content[1]:
|
|
1034
|
+
raise ValueError(
|
|
1035
|
+
f"Interactive manifest for '{scenario.slug}' step "
|
|
1036
|
+
f"{per_layout_filtered[layout][i][0]} ({locale}/{layout or '_'}): "
|
|
1037
|
+
f"empty text. Every layout twin needs its own guide-text."
|
|
1038
|
+
)
|
|
1039
|
+
capture["content"] = {
|
|
1040
|
+
"title": layout_content[0],
|
|
1041
|
+
"text": layout_content[1],
|
|
1042
|
+
"subtitle": layout_content[2],
|
|
1043
|
+
}
|
|
1044
|
+
captures[locale][layout_key_for(layout)] = capture
|
|
1045
|
+
|
|
1046
|
+
manifest_steps.append(
|
|
1047
|
+
{
|
|
1048
|
+
"id": f"step-{i + 1}",
|
|
1049
|
+
"index": i + 1,
|
|
1050
|
+
"action": scenario_step.action,
|
|
1051
|
+
"interactive": step_is_interactive,
|
|
1052
|
+
"screenshotWhen": scenario_step.screenshot_when or "after",
|
|
1053
|
+
"waitAfter": int(scenario_step.wait_after or 0),
|
|
1054
|
+
"title": title_per_locale,
|
|
1055
|
+
"text": text_per_locale,
|
|
1056
|
+
"subtitle": subtitle_per_locale,
|
|
1057
|
+
"captures": captures,
|
|
1058
|
+
}
|
|
1059
|
+
)
|
|
1060
|
+
|
|
1061
|
+
# Cursor block: pass through the timings unchanged and merge the
|
|
1062
|
+
# optional ``mode`` only when set. Absent ``mode`` keeps the manifest
|
|
1063
|
+
# byte-identical to pre-feature output so existing snapshots survive.
|
|
1064
|
+
cursor_block: dict = dict(cursor_timings)
|
|
1065
|
+
if cursor_mode is not None:
|
|
1066
|
+
cursor_block["mode"] = cursor_mode
|
|
1067
|
+
|
|
1068
|
+
return {
|
|
1069
|
+
"version": manifest_version,
|
|
1070
|
+
"slug": scenario.slug,
|
|
1071
|
+
"mode": "interactive",
|
|
1072
|
+
"role": role,
|
|
1073
|
+
"title": dict(title),
|
|
1074
|
+
"locales": list(locales),
|
|
1075
|
+
"defaultLocale": default_locale,
|
|
1076
|
+
"layouts": list(layouts),
|
|
1077
|
+
"defaultLayout": default_layout,
|
|
1078
|
+
"cursor": cursor_block,
|
|
1079
|
+
"defaultTemplate": default_template,
|
|
1080
|
+
"steps": manifest_steps,
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
@staticmethod
|
|
1084
|
+
def _relative_url_fn(capture_dir: Path, target_dir: Path) -> Callable[[str, int, Optional[str]], str]:
|
|
1085
|
+
"""Screenshot-URL builder for a page written to ``target_dir`` reading
|
|
1086
|
+
captures from ``capture_dir``.
|
|
1087
|
+
|
|
1088
|
+
``--output-dir`` and ``--screenshots-dir`` are independent CLI options,
|
|
1089
|
+
so no fixed ``../../..`` prefix can be correct: it depends on both paths
|
|
1090
|
+
AND on which capture shape resolved (flat / locale / locale+layout).
|
|
1091
|
+
Shared by the per-guide pages and the handbook, which sit in the same
|
|
1092
|
+
directory and therefore need the identical URLs.
|
|
1093
|
+
"""
|
|
1094
|
+
|
|
1095
|
+
def fn(slug: str, step_index: int, frame_key: Optional[str]) -> str:
|
|
1096
|
+
stem = step_stem(step_index, frame_key)
|
|
1097
|
+
try:
|
|
1098
|
+
relative = os.path.relpath(capture_dir.resolve(), target_dir.resolve())
|
|
1099
|
+
except ValueError:
|
|
1100
|
+
# Windows: no relative path exists across drives. Emit a real
|
|
1101
|
+
# file:// URL rather than a bare `D:/...` path — the latter is
|
|
1102
|
+
# not a URL, so a browser resolves it against the document and
|
|
1103
|
+
# every image comes out blank (the video renderer loads its
|
|
1104
|
+
# harness over file://, where this bites hardest).
|
|
1105
|
+
return (capture_dir / f"{stem}.png").resolve().as_uri()
|
|
1106
|
+
return f"{Path(relative).as_posix()}/{stem}.png"
|
|
1107
|
+
|
|
1108
|
+
return fn
|
|
1109
|
+
|
|
1110
|
+
def scenario_by_slug(self, scenarios_dir: Path, slug: str) -> Scenario:
|
|
1111
|
+
"""Look a parsed scenario up by slug, reusing the per-assembler cache.
|
|
1112
|
+
|
|
1113
|
+
``generate_guides_for_tag`` returns results keyed by slug but not the
|
|
1114
|
+
scenarios themselves; callers that need to go back to the source (the
|
|
1115
|
+
video pass, which builds its own manifest) would otherwise re-parse the
|
|
1116
|
+
whole directory.
|
|
1117
|
+
"""
|
|
1118
|
+
for scenario in self._parse_scenarios_dir(scenarios_dir):
|
|
1119
|
+
if scenario.slug == slug:
|
|
1120
|
+
return scenario
|
|
1121
|
+
raise ValueError(f"no scenario with slug '{slug}' in {scenarios_dir}")
|
|
1122
|
+
|
|
1123
|
+
def prepare_video_manifest(
|
|
1124
|
+
self,
|
|
1125
|
+
scenario: Scenario,
|
|
1126
|
+
role: str,
|
|
1127
|
+
locale: str,
|
|
1128
|
+
output_dir: Path,
|
|
1129
|
+
screenshots_dir: Path,
|
|
1130
|
+
layout: Optional[str] = None,
|
|
1131
|
+
title: Optional[str] = None,
|
|
1132
|
+
) -> dict:
|
|
1133
|
+
"""Manifest for one (locale, layout), for the ``steppy guide --video`` path.
|
|
1134
|
+
|
|
1135
|
+
``steppy build`` has a build config to derive URLs from; ``steppy guide``
|
|
1136
|
+
has only ``--output-dir`` and ``--screenshots-dir``, and no ``guide.json``
|
|
1137
|
+
on disk at all. So the manifest is built here, in the class that owns the
|
|
1138
|
+
capture-shape resolution — routing through the same ``_relative_url_fn``
|
|
1139
|
+
the HTML pages use, so the video harness (which sits in that same
|
|
1140
|
+
directory) resolves exactly the screenshots the page would.
|
|
1141
|
+
|
|
1142
|
+
Note this validates more strictly than :meth:`generate_guide`: a step
|
|
1143
|
+
missing a bbox or a locale missing ``guide-text`` raises here while the
|
|
1144
|
+
HTML guide renders happily. Callers should treat a failure as "skip this
|
|
1145
|
+
video", not "abort the run".
|
|
1146
|
+
"""
|
|
1147
|
+
target_dir = output_dir / role / locale
|
|
1148
|
+
if layout:
|
|
1149
|
+
target_dir = target_dir / layout
|
|
1150
|
+
capture_dir = self._resolve_scenario_dir(scenario.slug, screenshots_dir, locale, layout)
|
|
1151
|
+
relative_url = self._relative_url_fn(capture_dir, target_dir)
|
|
1152
|
+
|
|
1153
|
+
def screenshot_url(slug, step_index, frame_key, _locale, _layout):
|
|
1154
|
+
return relative_url(slug, step_index, frame_key)
|
|
1155
|
+
|
|
1156
|
+
return self.prepare_interactive_manifest(
|
|
1157
|
+
scenario=scenario,
|
|
1158
|
+
role=role,
|
|
1159
|
+
screenshots_dir=screenshots_dir,
|
|
1160
|
+
locales=[locale],
|
|
1161
|
+
layouts=[layout] if layout else [],
|
|
1162
|
+
default_locale=locale,
|
|
1163
|
+
default_layout=layout,
|
|
1164
|
+
title={locale: title or scenario.title or scenario.slug},
|
|
1165
|
+
cursor_timings=scenario.guide.cursor.model_dump() if scenario.guide else {},
|
|
1166
|
+
screenshot_url_fn=screenshot_url,
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
def generate_guide(
|
|
1170
|
+
self,
|
|
1171
|
+
scenario: Scenario,
|
|
1172
|
+
role: str,
|
|
1173
|
+
locale: str,
|
|
1174
|
+
output_dir: Path,
|
|
1175
|
+
screenshots_dir: Path,
|
|
1176
|
+
layout: Optional[str] = None,
|
|
1177
|
+
) -> dict[str, str]:
|
|
1178
|
+
"""
|
|
1179
|
+
Generate Markdown and HTML guides for a scenario, role, and locale.
|
|
1180
|
+
|
|
1181
|
+
Args:
|
|
1182
|
+
scenario: Parsed scenario
|
|
1183
|
+
role: Role name (e.g., "editor", "reviewer")
|
|
1184
|
+
locale: Locale code (e.g., "fr", "en")
|
|
1185
|
+
output_dir: Base output directory (e.g., output/guides)
|
|
1186
|
+
screenshots_dir: Base screenshots directory (e.g., output/screenshots)
|
|
1187
|
+
layout: Optional layout name (e.g., "desktop", "mobile"). When
|
|
1188
|
+
provided, screenshots are loaded from
|
|
1189
|
+
``<slug>/<locale>/<layout>/`` and the output path includes a
|
|
1190
|
+
``<layout>`` segment so per-layout guides don't overwrite each
|
|
1191
|
+
other. When None, behavior matches the pre-layout era.
|
|
1192
|
+
|
|
1193
|
+
Returns:
|
|
1194
|
+
Dict with keys "markdown" and "html" containing file paths
|
|
1195
|
+
|
|
1196
|
+
Raises:
|
|
1197
|
+
ValueError: If screenshots are missing or guide-text is missing for locale
|
|
1198
|
+
"""
|
|
1199
|
+
# Output paths must be known BEFORE rendering the steps: the image URLs
|
|
1200
|
+
# are relative to the page's own directory. Per-layout output gets its
|
|
1201
|
+
# own subdir so each (locale, layout) combination ships an independent
|
|
1202
|
+
# .md + .html pair.
|
|
1203
|
+
role_locale_dir = output_dir / role / locale
|
|
1204
|
+
if layout:
|
|
1205
|
+
target_dir = role_locale_dir / layout
|
|
1206
|
+
else:
|
|
1207
|
+
target_dir = role_locale_dir
|
|
1208
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
1209
|
+
|
|
1210
|
+
# Screenshot URLs are computed from the page's real location to the
|
|
1211
|
+
# capture directory that will actually be read. ``--output-dir`` and
|
|
1212
|
+
# ``--screenshots-dir`` are independent CLI options, so no fixed
|
|
1213
|
+
# ``../../..`` prefix can be correct: it depends on both paths AND on
|
|
1214
|
+
# which capture shape resolved (flat / locale / locale+layout).
|
|
1215
|
+
capture_dir = self._resolve_scenario_dir(scenario.slug, screenshots_dir, locale, layout)
|
|
1216
|
+
_relative_screenshot_url = self._relative_url_fn(capture_dir, target_dir)
|
|
1217
|
+
|
|
1218
|
+
guide_steps = self.prepare_guide_steps(
|
|
1219
|
+
scenario,
|
|
1220
|
+
role,
|
|
1221
|
+
locale,
|
|
1222
|
+
screenshots_dir,
|
|
1223
|
+
screenshot_url_fn=_relative_screenshot_url,
|
|
1224
|
+
layout=layout,
|
|
1225
|
+
)
|
|
1226
|
+
|
|
1227
|
+
# Get the app config for the role to extract the display name
|
|
1228
|
+
app_config = self.config.apps.get(role)
|
|
1229
|
+
role_display_name = app_config.name if app_config else role.title()
|
|
1230
|
+
|
|
1231
|
+
markdown_path = target_dir / f"{scenario.slug}.md"
|
|
1232
|
+
html_path = target_dir / f"{scenario.slug}.html"
|
|
1233
|
+
|
|
1234
|
+
# Render Markdown guide
|
|
1235
|
+
md_template = self.env.get_template("guide.md.j2")
|
|
1236
|
+
markdown_content = md_template.render(
|
|
1237
|
+
title=scenario.title,
|
|
1238
|
+
role=role_display_name,
|
|
1239
|
+
steps=guide_steps,
|
|
1240
|
+
)
|
|
1241
|
+
markdown_path.write_text(markdown_content, encoding="utf-8")
|
|
1242
|
+
|
|
1243
|
+
# Render HTML guide
|
|
1244
|
+
html_template = self.env.get_template("guide.html.j2")
|
|
1245
|
+
html_content = html_template.render(
|
|
1246
|
+
title=scenario.title,
|
|
1247
|
+
role=role_display_name,
|
|
1248
|
+
locale=locale,
|
|
1249
|
+
steps=guide_steps,
|
|
1250
|
+
cursor_timings=scenario.guide.cursor.model_dump(),
|
|
1251
|
+
cursor_iife=get_legacy_cursor_iife(),
|
|
1252
|
+
)
|
|
1253
|
+
html_path.write_text(html_content, encoding="utf-8")
|
|
1254
|
+
|
|
1255
|
+
return {
|
|
1256
|
+
"markdown": str(markdown_path),
|
|
1257
|
+
"html": str(html_path),
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
def _parse_scenarios_dir(self, scenarios_dir: Path) -> list[Scenario]:
|
|
1261
|
+
"""Parse every scenario in a directory, once per assembler.
|
|
1262
|
+
|
|
1263
|
+
``generate_guides_for_tag`` is called once per ``user-guide-*`` tag and
|
|
1264
|
+
used to re-glob and re-parse the whole directory each time, discarding
|
|
1265
|
+
all but the tagged scenarios. Parsing is not cheap (frontmatter, a
|
|
1266
|
+
PyYAML pass per nested block, full ``Step`` validation), so an N-tag
|
|
1267
|
+
project paid it N times over.
|
|
1268
|
+
|
|
1269
|
+
Cached like ``_resolve_scenario_dir``: an assembler is built after the
|
|
1270
|
+
scenarios on disk are final and only reads them.
|
|
1271
|
+
"""
|
|
1272
|
+
key = str(scenarios_dir)
|
|
1273
|
+
cached = self._scenarios_cache.get(key)
|
|
1274
|
+
if cached is None:
|
|
1275
|
+
cached = [parse_scenario_file(f) for f in scenarios_dir.glob("*.md")]
|
|
1276
|
+
self._scenarios_cache[key] = cached
|
|
1277
|
+
return cached
|
|
1278
|
+
|
|
1279
|
+
def _role_for_tag(self, tag: str) -> str:
|
|
1280
|
+
"""``user-guide-editor`` → ``editor``, validated against configured roles."""
|
|
1281
|
+
if not tag.startswith("user-guide-"):
|
|
1282
|
+
raise ValueError(f"Tag must be in format 'user-guide-{{role}}', got: {tag}")
|
|
1283
|
+
role = tag[len("user-guide-") :]
|
|
1284
|
+
if role not in self.config.apps:
|
|
1285
|
+
raise ValueError(f"Unknown role '{role}'. Available roles: {list(self.config.apps.keys())}")
|
|
1286
|
+
return role
|
|
1287
|
+
|
|
1288
|
+
def generate_guides_for_tag(
|
|
1289
|
+
self,
|
|
1290
|
+
tag: str,
|
|
1291
|
+
locales: list[str],
|
|
1292
|
+
scenarios_dir: Path,
|
|
1293
|
+
output_dir: Path,
|
|
1294
|
+
screenshots_dir: Path,
|
|
1295
|
+
) -> dict[str, dict]:
|
|
1296
|
+
"""
|
|
1297
|
+
Generate guides for all scenarios matching a user-guide tag.
|
|
1298
|
+
|
|
1299
|
+
Args:
|
|
1300
|
+
tag: Tag like "user-guide-editor" or "user-guide-reviewer"
|
|
1301
|
+
locales: List of locale codes to generate
|
|
1302
|
+
scenarios_dir: Directory containing scenario files
|
|
1303
|
+
output_dir: Base output directory for guides
|
|
1304
|
+
screenshots_dir: Base screenshots directory
|
|
1305
|
+
|
|
1306
|
+
Returns:
|
|
1307
|
+
Dict mapping scenario slugs to generation results
|
|
1308
|
+
|
|
1309
|
+
Raises:
|
|
1310
|
+
ValueError: If tag doesn't match user-guide-{role} pattern
|
|
1311
|
+
"""
|
|
1312
|
+
role = self._role_for_tag(tag)
|
|
1313
|
+
|
|
1314
|
+
# Find all scenarios with this tag
|
|
1315
|
+
results = {}
|
|
1316
|
+
for scenario in self._parse_scenarios_dir(scenarios_dir):
|
|
1317
|
+
if tag not in scenario.tags:
|
|
1318
|
+
continue
|
|
1319
|
+
|
|
1320
|
+
scenario_results = {}
|
|
1321
|
+
for locale in locales:
|
|
1322
|
+
# Auto-discover per-layout subdirs under <slug>/<locale>/. When
|
|
1323
|
+
# screenshots were captured with ``screenshot.layouts``
|
|
1324
|
+
# configured, this returns the layout names; otherwise it
|
|
1325
|
+
# returns [] and we render a single guide for the locale.
|
|
1326
|
+
#
|
|
1327
|
+
# Fall back to the locale-less probe: `steppy run --screenshots
|
|
1328
|
+
# --layouts desktop,mobile` (no --locales) writes
|
|
1329
|
+
# <slug>/<layout>/ with no locale segment, and this command
|
|
1330
|
+
# always has a locale (the CLI defaults it to ["fr"]). Without
|
|
1331
|
+
# the fallback that whole capture shape is invisible and every
|
|
1332
|
+
# scenario reports "Missing screenshot" for PNGs on disk.
|
|
1333
|
+
layouts = self._discover_layouts(
|
|
1334
|
+
scenario.slug,
|
|
1335
|
+
screenshots_dir,
|
|
1336
|
+
locale=locale,
|
|
1337
|
+
) or self._discover_layouts(
|
|
1338
|
+
scenario.slug,
|
|
1339
|
+
screenshots_dir,
|
|
1340
|
+
locale=None,
|
|
1341
|
+
)
|
|
1342
|
+
# Iterate at least once: [None] when no layouts so the
|
|
1343
|
+
# back-compat single-guide-per-locale path stays unchanged.
|
|
1344
|
+
for layout in layouts or [None]:
|
|
1345
|
+
# Result key encodes both dimensions when layouts exist so
|
|
1346
|
+
# the CLI can show "scenario (fr/desktop)" without losing
|
|
1347
|
+
# per-layout granularity.
|
|
1348
|
+
result_key = f"{locale}/{layout}" if layout else locale
|
|
1349
|
+
try:
|
|
1350
|
+
paths = self.generate_guide(
|
|
1351
|
+
scenario,
|
|
1352
|
+
role,
|
|
1353
|
+
locale,
|
|
1354
|
+
output_dir,
|
|
1355
|
+
screenshots_dir,
|
|
1356
|
+
layout=layout,
|
|
1357
|
+
)
|
|
1358
|
+
scenario_results[result_key] = {
|
|
1359
|
+
"status": "success",
|
|
1360
|
+
"markdown": paths["markdown"],
|
|
1361
|
+
"html": paths["html"],
|
|
1362
|
+
# Carried for the PDF footer (`--pdf`), which has
|
|
1363
|
+
# only this dict to work from.
|
|
1364
|
+
"title": scenario.title,
|
|
1365
|
+
}
|
|
1366
|
+
except Exception as e:
|
|
1367
|
+
scenario_results[result_key] = {
|
|
1368
|
+
"status": "error",
|
|
1369
|
+
"error": str(e),
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
results[scenario.slug] = scenario_results
|
|
1373
|
+
|
|
1374
|
+
return results
|
|
1375
|
+
|
|
1376
|
+
def generate_handbook(
|
|
1377
|
+
self,
|
|
1378
|
+
tag: str,
|
|
1379
|
+
locale: str,
|
|
1380
|
+
scenarios_dir: Path,
|
|
1381
|
+
output_dir: Path,
|
|
1382
|
+
screenshots_dir: Path,
|
|
1383
|
+
layout: Optional[str] = None,
|
|
1384
|
+
*,
|
|
1385
|
+
title: Optional[str] = None,
|
|
1386
|
+
subtitle: Optional[str] = None,
|
|
1387
|
+
toc_label: str = "Contents",
|
|
1388
|
+
on_skip: Optional[Callable[[str, str], None]] = None,
|
|
1389
|
+
) -> Optional[Path]:
|
|
1390
|
+
"""Assemble every guide for one (tag, locale, layout) into a single
|
|
1391
|
+
"handbook" HTML page — a cover, a table of contents, then each guide.
|
|
1392
|
+
|
|
1393
|
+
Written next to the per-guide pages so the screenshot URLs are the same
|
|
1394
|
+
relative paths (see :meth:`_relative_url_fn`). The result is a real
|
|
1395
|
+
artifact, not a temp file: it is a legitimate single-page read *and*
|
|
1396
|
+
what ``--handbook`` prints to PDF.
|
|
1397
|
+
|
|
1398
|
+
Guides are ordered by slug — deterministic, unlike ``Path.glob``.
|
|
1399
|
+
A scenario that can't be assembled (missing capture / guide-text) is
|
|
1400
|
+
skipped rather than losing the whole handbook; ``on_skip(slug, reason)``
|
|
1401
|
+
reports it. Returns the written path, or ``None`` when nothing could be
|
|
1402
|
+
assembled.
|
|
1403
|
+
"""
|
|
1404
|
+
role = self._role_for_tag(tag)
|
|
1405
|
+
|
|
1406
|
+
target_dir = output_dir / role / locale
|
|
1407
|
+
if layout:
|
|
1408
|
+
target_dir = target_dir / layout
|
|
1409
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
1410
|
+
|
|
1411
|
+
fragment_tpl = self.env.get_template("guide.fragment.html.j2")
|
|
1412
|
+
entries: list[dict] = []
|
|
1413
|
+
for scenario in sorted(self._parse_scenarios_dir(scenarios_dir), key=lambda s: s.slug or ""):
|
|
1414
|
+
if tag not in scenario.tags:
|
|
1415
|
+
continue
|
|
1416
|
+
capture_dir = self._resolve_scenario_dir(scenario.slug, screenshots_dir, locale, layout)
|
|
1417
|
+
try:
|
|
1418
|
+
guide_steps = self.prepare_guide_steps(
|
|
1419
|
+
scenario,
|
|
1420
|
+
role,
|
|
1421
|
+
locale,
|
|
1422
|
+
screenshots_dir,
|
|
1423
|
+
screenshot_url_fn=self._relative_url_fn(capture_dir, target_dir),
|
|
1424
|
+
layout=layout,
|
|
1425
|
+
)
|
|
1426
|
+
except Exception as e:
|
|
1427
|
+
if on_skip:
|
|
1428
|
+
on_skip(scenario.slug or str(scenario.file_path), str(e))
|
|
1429
|
+
continue
|
|
1430
|
+
entries.append(
|
|
1431
|
+
{
|
|
1432
|
+
"slug": scenario.slug,
|
|
1433
|
+
"title": scenario.title,
|
|
1434
|
+
# role=None: the cover carries the role once; a badge above
|
|
1435
|
+
# every chapter would just be noise.
|
|
1436
|
+
"html": fragment_tpl.render(
|
|
1437
|
+
title=scenario.title,
|
|
1438
|
+
role=None,
|
|
1439
|
+
steps=guide_steps,
|
|
1440
|
+
include_styles=False,
|
|
1441
|
+
),
|
|
1442
|
+
}
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
if not entries:
|
|
1446
|
+
return None
|
|
1447
|
+
|
|
1448
|
+
app_config = self.config.apps.get(role)
|
|
1449
|
+
role_display_name = app_config.name if app_config else role.title()
|
|
1450
|
+
|
|
1451
|
+
book_tpl = self.env.get_template("guide.book.html.j2")
|
|
1452
|
+
html_content = book_tpl.render(
|
|
1453
|
+
title=title or role_display_name,
|
|
1454
|
+
subtitle=subtitle,
|
|
1455
|
+
# The cover title already names the role; only badge it when the
|
|
1456
|
+
# caller overrode the title with something else.
|
|
1457
|
+
role=role_display_name if title else None,
|
|
1458
|
+
locale=locale,
|
|
1459
|
+
guides=entries,
|
|
1460
|
+
toc_label=toc_label,
|
|
1461
|
+
# Same loader the static target uses, so a project's own
|
|
1462
|
+
# templates/static/guides.css override styles the handbook too.
|
|
1463
|
+
guides_css=self.env.get_template("static/guides.css").render(),
|
|
1464
|
+
)
|
|
1465
|
+
handbook_path = target_dir / f"{role}-handbook.html"
|
|
1466
|
+
handbook_path.write_text(html_content, encoding="utf-8")
|
|
1467
|
+
return handbook_path
|