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.
Files changed (177) hide show
  1. steppy/CHANGELOG.md +862 -0
  2. steppy/__init__.py +9 -0
  3. steppy/__main__.py +13 -0
  4. steppy/_cli_common.py +183 -0
  5. steppy/_docker/Dockerfile.installed +53 -0
  6. steppy/_parallel/__init__.py +7 -0
  7. steppy/_parallel/compose.py +349 -0
  8. steppy/_parallel/event_bus.py +418 -0
  9. steppy/_parallel/failures.py +267 -0
  10. steppy/_parallel/fingerprint.py +141 -0
  11. steppy/_parallel/image.py +380 -0
  12. steppy/_parallel/lastfailed.py +125 -0
  13. steppy/_parallel/lifecycle.py +90 -0
  14. steppy/_parallel/preauth.py +109 -0
  15. steppy/_parallel/progress.py +101 -0
  16. steppy/_parallel/queue.py +385 -0
  17. steppy/_parallel/renderer.py +489 -0
  18. steppy/_parallel/retry.py +316 -0
  19. steppy/_player_bundle/THIRD_PARTY_LICENSES.txt +158 -0
  20. steppy/_player_bundle/__init__.py +100 -0
  21. steppy/_player_bundle/steppy-cursor-legacy.js +2 -0
  22. steppy/_player_bundle/steppy-cursor-legacy.js.map +1 -0
  23. steppy/_player_bundle/steppy-player.js +5419 -0
  24. steppy/_player_bundle/steppy-player.js.map +1 -0
  25. steppy/_player_bundle/steppy-video.js +472 -0
  26. steppy/_player_bundle/steppy-video.js.map +1 -0
  27. steppy/_templating.py +117 -0
  28. steppy/assertions.py +251 -0
  29. steppy/auth.py +493 -0
  30. steppy/build.py +2798 -0
  31. steppy/build_models.py +669 -0
  32. steppy/buildid.py +134 -0
  33. steppy/bundle/__init__.py +47 -0
  34. steppy/bundle/archive.py +239 -0
  35. steppy/bundle/classify.py +329 -0
  36. steppy/bundle/create.py +490 -0
  37. steppy/bundle/hashing.py +28 -0
  38. steppy/bundle/manifest.py +334 -0
  39. steppy/bundle/results.py +546 -0
  40. steppy/bundle_cli.py +455 -0
  41. steppy/captures.py +207 -0
  42. steppy/cli.py +2489 -0
  43. steppy/cloud/__init__.py +58 -0
  44. steppy/cloud/client.py +267 -0
  45. steppy/cloud/upload.py +497 -0
  46. steppy/compat.py +109 -0
  47. steppy/config.py +231 -0
  48. steppy/config_schema.py +87 -0
  49. steppy/contracts/__init__.py +54 -0
  50. steppy/contracts/bundle.py +215 -0
  51. steppy/contracts/report.py +346 -0
  52. steppy/contracts/tours.py +505 -0
  53. steppy/coordinator.py +1352 -0
  54. steppy/coverage/__init__.py +9 -0
  55. steppy/coverage/gaps.py +190 -0
  56. steppy/coverage/manifest.py +193 -0
  57. steppy/coverage/merge.py +363 -0
  58. steppy/coverage/normalize.py +174 -0
  59. steppy/coverage/report.py +178 -0
  60. steppy/coverage/sourcemap.py +219 -0
  61. steppy/coverage/store.py +473 -0
  62. steppy/coverage/triage.py +151 -0
  63. steppy/coverage_cli.py +265 -0
  64. steppy/durations.py +48 -0
  65. steppy/exclusive.py +178 -0
  66. steppy/flaky_cli.py +169 -0
  67. steppy/generator.py +633 -0
  68. steppy/guide.py +1467 -0
  69. steppy/history.py +352 -0
  70. steppy/init_cli.py +330 -0
  71. steppy/locales.py +282 -0
  72. steppy/migrate/__init__.py +35 -0
  73. steppy/migrate/apply.py +194 -0
  74. steppy/migrate/plan.py +199 -0
  75. steppy/migrate/rules.py +465 -0
  76. steppy/migrate/verify.py +163 -0
  77. steppy/migrate_cli.py +152 -0
  78. steppy/models.py +2539 -0
  79. steppy/naming.py +37 -0
  80. steppy/parser.py +475 -0
  81. steppy/pdf.py +193 -0
  82. steppy/presets.py +284 -0
  83. steppy/record_cli.py +796 -0
  84. steppy/report/__init__.py +37 -0
  85. steppy/report/assets.py +197 -0
  86. steppy/report/build.py +261 -0
  87. steppy/report/coverage_source.py +277 -0
  88. steppy/report/model.py +423 -0
  89. steppy/report/render.py +303 -0
  90. steppy/report/source.py +238 -0
  91. steppy/report_cli.py +173 -0
  92. steppy/runmeta.py +114 -0
  93. steppy/selection.py +183 -0
  94. steppy/sitemap.py +205 -0
  95. steppy/skills/steppy-app-tours/SKILL.md +448 -0
  96. steppy/skills/steppy-ci-report/SKILL.md +567 -0
  97. steppy/skills/steppy-scenario-author/SKILL.md +2176 -0
  98. steppy/skills/steppy-template/SKILL.md +676 -0
  99. steppy/templates/_steppy_events.py +291 -0
  100. steppy/templates/_steppy_tours.py +404 -0
  101. steppy/templates/angular/component-interactive.ts.j2 +109 -0
  102. steppy/templates/angular/component-video.ts.j2 +112 -0
  103. steppy/templates/angular/component.ts.j2 +229 -0
  104. steppy/templates/angular/content.ts.j2 +31 -0
  105. steppy/templates/angular/gitignore.j2 +3 -0
  106. steppy/templates/angular/group.component.ts.j2 +39 -0
  107. steppy/templates/angular/onboarding.component.ts.j2 +43 -0
  108. steppy/templates/angular/routes.ts.j2 +34 -0
  109. steppy/templates/ci/github-actions.yml +284 -0
  110. steppy/templates/ci/gitlab-ci.yml +178 -0
  111. steppy/templates/conftest.py.j2 +2405 -0
  112. steppy/templates/guide.book.html.j2 +145 -0
  113. steppy/templates/guide.fragment.html.j2 +257 -0
  114. steppy/templates/guide.html.j2 +301 -0
  115. steppy/templates/guide.md.j2 +18 -0
  116. steppy/templates/init/config.yaml +124 -0
  117. steppy/templates/init/credentials.yaml.example +30 -0
  118. steppy/templates/init/scenarios/example--first-flow.md +53 -0
  119. steppy/templates/report/_head.html.j2 +14 -0
  120. steppy/templates/report/_mark.html.j2 +3 -0
  121. steppy/templates/report/coverage.html.j2 +124 -0
  122. steppy/templates/report/index.html.j2 +203 -0
  123. steppy/templates/report/report.css +401 -0
  124. steppy/templates/report/scenario.html.j2 +137 -0
  125. steppy/templates/static/_headers.j2 +30 -0
  126. steppy/templates/static/_mode_links.html.j2 +22 -0
  127. steppy/templates/static/_seo_head.html.j2 +37 -0
  128. steppy/templates/static/cursor.js.j2 +10 -0
  129. steppy/templates/static/group.html.j2 +78 -0
  130. steppy/templates/static/guides.css +678 -0
  131. steppy/templates/static/index.html.j2 +79 -0
  132. steppy/templates/static/interactive.html.j2 +100 -0
  133. steppy/templates/static/onboarding.html.j2 +64 -0
  134. steppy/templates/static/page.html.j2 +145 -0
  135. steppy/templates/static/robots.txt.j2 +7 -0
  136. steppy/templates/static/video.html.j2 +135 -0
  137. steppy/templates/test_scenario.py.j2 +767 -0
  138. steppy/testids.py +166 -0
  139. steppy/totp.py +26 -0
  140. steppy/tour_spec.py +163 -0
  141. steppy/tours/__init__.py +22 -0
  142. steppy/tours/actions.py +402 -0
  143. steppy/tours/compiler.py +144 -0
  144. steppy/tours/diagnostics.py +137 -0
  145. steppy/tours/emit.py +239 -0
  146. steppy/tours/ids.py +40 -0
  147. steppy/tours/inference.py +399 -0
  148. steppy/tours/model.py +120 -0
  149. steppy/tours/paths.py +126 -0
  150. steppy/tours/pipeline.py +277 -0
  151. steppy/tours/publish.py +124 -0
  152. steppy/tours/replay.py +234 -0
  153. steppy/tours/routes.py +175 -0
  154. steppy/tours/settings.py +164 -0
  155. steppy/tours/text.py +237 -0
  156. steppy/tours_cli.py +219 -0
  157. steppy/tts/__init__.py +280 -0
  158. steppy/tts/cache.py +185 -0
  159. steppy/tts/presets.py +125 -0
  160. steppy/tts/providers/__init__.py +6 -0
  161. steppy/tts/providers/http.py +184 -0
  162. steppy/tts/providers/local.py +207 -0
  163. steppy/tts/providers/null.py +28 -0
  164. steppy/tts/registry.py +201 -0
  165. steppy/tts/track.py +158 -0
  166. steppy/tts/types.py +94 -0
  167. steppy/update_cli.py +460 -0
  168. steppy/video.py +730 -0
  169. steppy/video_cache.py +239 -0
  170. steppy/video_cli.py +271 -0
  171. steppy/worker.py +301 -0
  172. steppy_dev-0.3.0.dist-info/METADATA +1335 -0
  173. steppy_dev-0.3.0.dist-info/RECORD +177 -0
  174. steppy_dev-0.3.0.dist-info/WHEEL +4 -0
  175. steppy_dev-0.3.0.dist-info/entry_points.txt +2 -0
  176. steppy_dev-0.3.0.dist-info/licenses/LICENSE +202 -0
  177. 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