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/CHANGELOG.md ADDED
@@ -0,0 +1,862 @@
1
+ # Changelog
2
+
3
+ All notable changes to Steppy are documented here. The format is based on
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project aims to
5
+ follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
+
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.0] - 2026-10-22
10
+
11
+ The first public release, under the Apache-2.0 licence. This entry captures
12
+ everything since 0.1.2, the last tagged build: a pre-release hardening pass,
13
+ in-app tours and the other new commands, grouped by theme rather than by
14
+ individual commit.
15
+
16
+ ### Added
17
+ - **In-app tours (opt-in).** The scenarios that test and document a flow now also
18
+ compile into guided popovers over the live application — and CI replays every
19
+ tour. Enable `tours:` in `config.yaml` and add `tour: true` to a scenario:
20
+ `steppy generate` writes the tour files (`output/tours/<build-id>/` plus a flat
21
+ copy into your app's assets) and appends `test_<slug>__tour` to the scenario's
22
+ test, which follows the tour the way a user would and fails — naming the tour,
23
+ the step and the test-id — when the app no longer matches it. `steppy tours`
24
+ writes the files alone (`--check` gates CI on a committed app-assets copy whose
25
+ content is stale, compared for the build id that copy records, so a fresh
26
+ checkout under `git-sha` passes); `steppy build` (static) republishes them for
27
+ remote mode. A step points at one test-id value,
28
+ never a selector; text comes from `guide-text` or a new `tour-text`, with
29
+ `tour: skip|keep`, `tour-advance` and `tour-placement` to adjust inference. The
30
+ overlay is `<steppy-tour-host>`, a new `@steppy/player/tour` entry with
31
+ type declarations: data-only tour files validated whole, test-id lookup in the
32
+ light DOM, top-layer stacking, keyboard and screen-reader support, resume
33
+ across page loads, remote loading pinned by SRI, and no inline script or style
34
+ (strict CSP and Trusted Types work). Full reference: `docs/tours.md`; design:
35
+ `docs/design/in-app-tours.md`; a working demo: `fixtures/tour-demo/`. Also part
36
+ of the feature:
37
+ - **Localization.** One file per tour and locale with text in it; text falls
38
+ back exact tag → base language, never to another language (a step with no
39
+ text shows a generic hint in its own language). The overlay picks the file
40
+ from the host's `locale` or `<html lang>`, follows a language switch
41
+ mid-tour (including one made while the tour loads), and sets `lang`/`dir`
42
+ on the popover (right-to-left languages mirror). Buttons and hints ship in
43
+ English, French, Dutch and German; other tour languages get a
44
+ `chrome-strings-missing` warning and take the host's `strings` property.
45
+ New warnings `locale-not-emitted` and "did you mean" for locale keys that
46
+ differ only in case or `-`/`_`.
47
+ - **Phones, drawers and dialogs.** On a small screen the popover is a sheet at
48
+ the top or bottom, away from what the user taps; it follows a drawer that
49
+ slides in and a target scrolled into view. A step with nothing to point at
50
+ (a `confirm` kept with `tour: keep`) is centred, except over an open app
51
+ dialog: there it becomes a sheet, so it never hides the question it is
52
+ about.
53
+ - **Remote files are pinned by the bundle.** When a bundled manifest of the same
54
+ build exists, any other manifest (a guide site or a same-origin proxy) only
55
+ locates files: tour, locale and SRI hash come from the bundle.
56
+ - **Writes into your app are guarded.** An `app-assets` folder is never a
57
+ project root or a folder Steppy owns, a foreign `tours.manifest.json` stops
58
+ the run, and only Steppy tour files are ever overwritten or deleted.
59
+ - New config key `output.tours_dir` (default `./output/tours`); the generated
60
+ `tests/e2e/_steppy_tours.py` replay helper; `steppy guide --all` and
61
+ `steppy validate` compile tours too.
62
+ - With tours enabled, generated tests set `__STEPPY_TOURS_DISABLED__` in
63
+ every browser context (an app that auto-starts a tour cannot cover what a test
64
+ clicks); `steppy run --guide` deselects the replays.
65
+ - The static build gains a `tours` key in the site `manifest.json`, a CORS block
66
+ for `/tours/*` in `_headers`, and reserves the guide slug `tours`.
67
+ - `steppy tours --check` says when committed tour files differ only in line
68
+ endings (git converted them on checkout, which regenerating cannot fix);
69
+ `guide-origin` accepts a URL and keeps its origin; `STEPPY_TOUR_EXPECT_BUILD_ID`
70
+ reaches the replays under `--workers N`.
71
+ - **Angular adapter, `@steppy/angular`** (`packages/angular`, Angular 20–22,
72
+ zone.js or zoneless): `provideSteppyTours()`, a `<steppy-tours>`
73
+ component placed once in the shell, a `SteppyTours` service (`start`,
74
+ `stop`, `next`; `active`, `current` and `lastError` signals; an `events`
75
+ stream) and a `steppyTourStart` directive. It reaches entry routes through
76
+ the Angular router inside the zone — a refused or redirected navigation fails
77
+ the tour at once — keeps the overlay's work outside the zone, follows the
78
+ app's i18n through a locale signal, and loads nothing on the server.
79
+ `fixtures/tour-demo-angular/` is the demo again in Angular, PrimeNG and
80
+ ngx-translate, driven unchanged by the plain demo's scenarios and replays.
81
+ - `@steppy/player` and `@steppy/angular` are published to npm. The player
82
+ gains type declarations for the guide entry too, a `typesVersions` fallback
83
+ for the tour entry, LICENSE, NOTICE and the third-party licenses in the
84
+ package, and a `check:pack` script that fails when `package.json` points at
85
+ a file `npm pack` would not ship. The `./manifest` subpath, which never
86
+ resolved, is gone. The release workflow publishes both once an `NPM_TOKEN`
87
+ secret exists.
88
+ - **Steppy is on PyPI**, as `steppy-dev`: `pip install steppy-dev`.
89
+ - **Steppy is open source under Apache-2.0.** `LICENSE` + `NOTICE` at the root,
90
+ declared as a PEP 639 `license = "Apache-2.0"` expression (hence
91
+ `hatchling>=1.27`) and shipped in the wheel's `dist-info/licenses/`. The npm
92
+ packages move to the same license (the player was `UNLICENSED`, the wrappers
93
+ MIT). The player bundles embed Lit (BSD-3-Clause) and Zod (MIT), whose full
94
+ texts now ship beside them as `_player_bundle/THIRD_PARTY_LICENSES.txt`.
95
+ `SECURITY.md` gains a private-reporting policy and `CONTRIBUTING.md` states the
96
+ inbound license. `pyproject.toml` now carries a description, readme, keywords
97
+ and classifiers.
98
+ - **`tests/test_no_private_names.py`** — scans every file git would publish for
99
+ client project names, private machine paths and the former example domain.
100
+ The deny-list lives in a gitignored local file rather than in the repository,
101
+ because a list there, even hashed, would publish what it guards; without the
102
+ file the check skips.
103
+ - **Neutral example domain.** Every example, fixture, test and doc that used the
104
+ former example domain now uses a generic one: roles `editor` and
105
+ `reviewer`, records `customer`, `invoice` and `product`. French and Dutch
106
+ strings were translated rather than swapped word for word. The demo fixture's
107
+ role and guide are now `fixtures/demo-guide/**/editor/invoices`.
108
+ - **CI:** a ruff lint + format job, a Python 3.12/3.13 matrix, and a check that the
109
+ committed player bundles match a fresh build of `packages/player/src/` (the
110
+ build is reproducible, so a mismatch means someone forgot to re-vendor).
111
+ - **`.gitattributes`** forcing LF on every OS, so the byte-mirror guard tests
112
+ cannot fail on a Windows checkout.
113
+
114
+ - **Six SEO follow-ups**, each of which the sitemap change surfaced and
115
+ deliberately left alone.
116
+ - **`manifest.json` and `<role>.nav.json` no longer advertise a mode with no
117
+ page.** Both emitted a guide's declared `modes` verbatim, so a guide
118
+ declaring `video` without `steppy build --video` told any consumer there was
119
+ a video page to link. The Angular toggle and the guide page's mode row
120
+ already filtered it; all four now go through one `_emitted_modes` helper.
121
+ *Behaviour change for anyone reading either file.*
122
+ - **Published PDFs are no longer the only indexable form of a `noindex`
123
+ page.** `handbook/<locale>.pdf` is Chromium printing a page that is
124
+ deliberately out of the index, and `<slug>/<locale>.pdf` competes with its
125
+ own HTML — but a PDF has no `<head>` to carry a robots meta. Every static
126
+ build now emits a `_headers` sidecar (`X-Robots-Tag: noindex` on `/*.pdf`
127
+ public, `/*` private). **It does nothing on GitHub or GitLab Pages**, which
128
+ cannot serve custom headers and are what the shipped CI scaffolds target;
129
+ Netlify and Cloudflare Pages honour it. Same bargain as `.nojekyll`.
130
+ - **A build warning when `seo.baseUrl` carries a path.** `robots.txt` and
131
+ `_headers` are honoured only at the origin root, and a GitHub Pages *project*
132
+ site lives under `/<repo>/` — so the correct `baseUrl` there silently
133
+ disarmed both. Canonical links, `hreflang` and `sitemap.xml` were and are
134
+ unaffected.
135
+ - **The capture side validates locales too.** `config.yaml`'s `locales:`,
136
+ `steppy run --locales` and `steppy guide --locale` now reject a tag that is
137
+ not usable BCP-47, matching what `steppy build` has done since the hreflang
138
+ work. Each of them becomes a screenshot directory segment the other side
139
+ reads back, so accepting one only moved the failure somewhere it could not
140
+ be explained. `--layouts` is deliberately still unvalidated.
141
+ - **Regional locales resolve content through their bare language.**
142
+ `guide-text: { fr: … }` now serves `fr-BE` instead of failing the build with
143
+ `Missing guide-text for locale 'fr-BE'`, and the same rule reaches the
144
+ interactive manifest (which silently emitted nulls for the identical miss)
145
+ and the capture-time i18n switch. An exact `fr-BE:` key still wins.
146
+ *Behaviour change: a build that used to stop now succeeds.*
147
+ - **A locale the app cannot be switched into is warned about, not skipped.**
148
+ When `i18n.localstorage.values` has no entry for the active locale — not even
149
+ by language — the run previously returned quietly and captured a full set of
150
+ screenshots **in the app's default language**, filed under the locale's own
151
+ directory. Nothing at any stage reported it. The branch also had no test.
152
+ - **`interactive.html` links the page it canonicals to.** All three renderings
153
+ of a guide now carry the same format row from one shared partial, replacing
154
+ the interactive page's missing link and the video page's hand-rolled one
155
+ (whose label did not match `MODE_LABELS`). `.steppy-mode-links` also gained
156
+ the CSS it never had — the row had been rendering as a bulleted list.
157
+ - Internal: the three duplicated "link to the site index" ternaries in the
158
+ templates collapse to one `_index_href`, including the dead one that compared
159
+ `default_locale` with itself.
160
+ - **Standards-correct `sitemap.xml`, and one canonical URL per guide** (static
161
+ target, `seo.mode: 'public'`). Two problems, one change.
162
+ - **The sitemap is generated rather than templated.** `src/steppy/sitemap.py`
163
+ is a new stdlib-only leaf that builds the document with
164
+ `xml.etree.ElementTree`; `templates/static/sitemap.xml.j2` is no longer
165
+ bundled. Escaping is the weaker half of the argument — Jinja's `| e` and
166
+ ElementTree behave identically on element text — but the template
167
+ interpolated `hreflang="{{ alt.hreflang }}"` with **no filter at all** under
168
+ an `autoescape=False` environment, from a `locales` list nothing validated.
169
+ The stronger half: a schema-bearing machine format should have a generator,
170
+ `<lastmod>` becomes structural rather than textual, and the whole thing is
171
+ now a pure function with its own unit tests. Characters no XML document can
172
+ carry are rejected outright instead of silently emitted.
173
+ - **A project `templates/static/sitemap.xml.j2` still wins.** Nothing is
174
+ bundled, but an override is still the way to reach the parts of the protocol
175
+ Steppy does not emit (`<image:image>` over a guide's screenshots,
176
+ `<video:video>`, a `<sitemapindex>` split), and the template context is
177
+ unchanged, so any existing override keeps working untouched.
178
+ - **`interactive.html` and `video.html` now canonical to the guide's
179
+ default-locale step-by-step page** and are excluded from the sitemap. They
180
+ render the same steps in a different shell, so a guide was occupying three
181
+ or four near-identical indexable URLs.
182
+ - **The site root is canonical as `https://…/`, not `https://…/index.html`.**
183
+ `index.html` is still written and still serves those bytes; only the URL
184
+ Steppy declares changed, so the home page stops being indexed twice.
185
+ Internal links deliberately still point at `index.html` — the canonical does
186
+ the consolidating, and rewriting them would break opening a build over
187
+ `file://`.
188
+ - **Guides get a `<slug>/index.html` redirect stub**, which groups, onboarding
189
+ and the handbook already had. A bare `/<slug>/` used to 404. In public mode
190
+ every stub now carries an absolute canonical and, deliberately, no
191
+ `noindex`: a noindex page canonicalling into an indexable cluster is a known
192
+ footgun, and a zero-delay meta refresh already reads as a redirect.
193
+ - **New `seo.lastmod`** — a W3C datetime stamped on every sitemap entry.
194
+ Unset by default, which keeps a build byte-reproducible. There is
195
+ deliberately no `'auto'` mode: `actions/checkout` stamps every file at clone
196
+ time, so an mtime-derived value reads as "now" on every build. Feed it
197
+ `git log -1 --format=%cI`. `<changefreq>` and `<priority>` are still not
198
+ emitted — Google and Bing ignore them.
199
+ - **`locales` entries are validated as BCP-47** at config load, because one
200
+ malformed `hreflang` makes a search engine discard the *whole* alternates
201
+ cluster. The tag you write is kept **verbatim** in filenames and screenshot
202
+ directories — it is also your app's i18n key and your `guide-text:` map key
203
+ — while `hreflang`, `<html lang>` and `og:locale` get the canonical
204
+ spelling. So `fr_BE` is accepted and produces `<html lang="fr-BE">` at
205
+ `…/fr_BE.html`. Two locales that normalise to the same tag are rejected, as
206
+ is a locale that also names a layout.
207
+ - **`pick_locale` falls back to the bare language subtag.** Without it a site
208
+ on `['fr-BE', 'nl-BE']` whose `LocalizedText` maps are written `{fr:…, nl:…}`
209
+ served the *French* meta description on every `nl-BE` page while declaring
210
+ `lang="nl-BE"` — two URLs, identical text, different declared languages,
211
+ which is exactly what makes a crawler drop the cluster.
212
+ - **Behaviour change:** `og:locale` is now emitted only for a
213
+ `language_TERRITORY` locale. A public `openGraph: true` site on `locales:
214
+ ['fr']` loses `<meta property="og:locale" content="fr">` — that value was
215
+ never a valid Open Graph locale, and inventing `fr_FR` would be a guess.
216
+ - **Exclusion groups (`exclusive:` in scenario frontmatter)** — mark scenarios that
217
+ must never run at the same time. Two scenarios naming the same group are
218
+ serialised against each other under `steppy run --workers N`; different groups
219
+ still run concurrently, and ungrouped scenarios are untouched. Work stealing
220
+ assumed every scenario was independent of every other; the only remedy for two
221
+ that are not was `--workers 1`, which serialises the whole suite.
222
+ - **A partition, not a protocol.** The coordinator collapses each group into a
223
+ single queue task before any container exists, and one worker runs its members
224
+ sequentially — one pytest process each, exactly as for an ungrouped scenario.
225
+ So there is no lock to leak when a worker is killed, no lease, no timeout to
226
+ tune, and no worker that ever waits on another. The only run-time primitive is
227
+ the O_EXCL claim that was already there.
228
+ - **Results, logs, JUnit and events stay keyed per scenario** even inside a
229
+ group, which is why failure artifacts, the last-failed cache, run history, the
230
+ flaky verdict and the renderer needed no changes at all — and why a worker
231
+ dying mid-group reduces to "which member has no result file".
232
+ - It costs wall clock: a run can never finish faster than its heaviest group's
233
+ total duration, so the seed order now weights a group by the **sum** of its
234
+ members' recorded medians. `steppy validate` warns about a group with a single
235
+ member, which is what a typo looks like.
236
+ - Two hazards closed on the way: a selection that collapses to one task used to
237
+ hit a branch that returned **0 without running anything** (the most obvious
238
+ first use of the feature — "every scenario hits one staging database" — would
239
+ have gone green having executed nothing), and a task that no worker ever
240
+ claimed was invisible to both orphan detection and result collection. Both now
241
+ fail the build, on both exit-code branches. A scenario that never started and
242
+ then passed on retry is reported as passed, not FLAKY, so the history
243
+ `steppy flaky` ranks from stays honest.
244
+ - **`steppy report`** — render one run to a self-contained static site: outcomes,
245
+ failure causes grouped so one broken selector across forty scenarios reads as
246
+ one row, **the failing step named** with its timeline and captures, coverage
247
+ buckets, a versioned `report.json` and a shields.io `badge.json`. Publishing it
248
+ to GitHub or GitLab Pages is a copy; both shipped CI templates carry the job,
249
+ commented out, with the warnings that decision deserves.
250
+ - **Three inputs, one report.** A live `output/` tree, a `.tar.zst`/`.tar.gz`
251
+ bundle, or a directory somebody unpacked one into. Archives are read in two
252
+ passes — `inspect_bundle` for the manifest, then one sequential streaming pass
253
+ for exactly the members requested — because the zstd stream is non-seekable
254
+ and there is no rewinding.
255
+ - **It never parses a path.** `bundle create` already stamped
256
+ scenario/locale/layout/step/frame onto every manifest row, so the whole report
257
+ is a manifest transform. Two read-side rules follow from a bundle possibly
258
+ coming from elsewhere: destinations are computed from validated components
259
+ rather than taken from a manifest path (streaming tar members bypasses
260
+ Python's extraction filters, and an arcname is attacker-controlled), and no
261
+ absolute filesystem path reaches `report.json` or the page footer, both of
262
+ which get published.
263
+ - **Honest about what it cannot know.** A `capabilities` block distinguishes "no
264
+ per-step timeline was recorded" from "the timeline was empty", a run with no
265
+ outcomes reads `unknown` rather than `passed`, and coverage rebuilt from a
266
+ detached bundle's raw records refuses to report assert *strength* — the
267
+ scenario files needed to count assert steps never travel, so every endpoint
268
+ would otherwise read as weakly covered.
269
+ - **The exit code describes rendering, not the run.** A report of a failing run
270
+ exits 0. A report step that goes red for a red run is a step people delete.
271
+ - `--screenshots failed` by default: the guide site is where every capture
272
+ lives, and `all` on a 4-locale × 2-layout corpus is hundreds of megabytes
273
+ pushed to Pages on every run. Captures identical across layouts copy once.
274
+ - **`coverage.non_ui`** — declare in committed config which endpoints and routes
275
+ can never have a UI test (`[<METHOD> ]<glob>`; health checks, webhooks, OAuth
276
+ callbacks, internal cron). The equivalent triage flags live in the gitignored
277
+ `.steppy/coverage.db`, so a fresh CI checkout had none and every such endpoint
278
+ counted against the denominator — a percentage wrong for reasons nobody could
279
+ fix from CI. Applied as a read-time overlay that never writes to the store, so
280
+ merge and refresh still never touch `triage`; an explicit `ui_testable` flag
281
+ still outranks a pattern, and a pattern matching nothing is reported rather than
282
+ silently inflating the number. Settles the badge as
283
+ **`tested / (declared − non_ui)`**, subtracted from *both* sides so it cannot
284
+ exceed 100%. `steppy coverage report` gained `--badge` / `--badge-surface`.
285
+ - **A third packaged Claude skill, `steppy-ci-report`**, installed by `steppy init`
286
+ beside `steppy-scenario-author` and `steppy-template`. The skills are split by
287
+ **job**, and shipping results is a third one: neither existing skill had a CI
288
+ section at all, and the coverage material in the author skill was four lines
289
+ that predated `non_ui`, the badge and the report. It covers the CI loop, the
290
+ report and its three inputs, publishing to Pages with the warnings that decision
291
+ deserves, the coverage map end to end (record → merge → refresh → gaps), the
292
+ badge formula, the four coverage tiers, and a troubleshooting table for the
293
+ symptoms that are otherwise silent — "badge says n/a", "coverage reads lower in
294
+ CI than locally", "report has no step detail". `steppy-template` gained the run
295
+ report's stylesheet (including the two ways `report.css` bites: it is parsed as a
296
+ template, and its environment autoescapes), and the author skill now routes
297
+ shipping questions rather than half-answering them.
298
+ - **A fourth packaged Claude skill, `steppy-app-tours`**, for the half of in-app
299
+ tours that happens in the app's own code: serving the tour files, installing the
300
+ packages, `@steppy/angular` (and Angular without it), plain JavaScript, React and
301
+ Vue, starting tours with entry params, the language and chrome strings, where
302
+ test-ids go in components, CSP and Trusted Types, events, the kill switch, and a
303
+ troubleshooting table for the running app. The author skill had eight lines on it,
304
+ and they were installed in the Steppy project, which is often not where the app's
305
+ code is. `steppy init --app-dir DIR` installs the skill into the app's repository
306
+ and records it in the project's manifest (as `../<app>/…`), so `steppy update`
307
+ refreshes it with the rest. The author skill's tour table gained the four
308
+ diagnostics it was missing (`no-tours`, `app-url-unresolved`,
309
+ `layout-media-overlap`, `app-assets-skipped`). A new guard,
310
+ `tests/test_skills_sync.py`, fails when a visible command, a tour diagnostic, or
311
+ the overlay's or the adapter's public API grows without the skills.
312
+ - **`steppy init --ci-gitlab`** and a shipped `templates/ci/gitlab-ci.yml`. A
313
+ second boolean rather than `--ci <vendor>`, because `--ci` is one people already
314
+ type. Worth knowing: GitLab Pages can be restricted to project members, which
315
+ is the one place it beats GitHub's for publishing a run report.
316
+ - **`steppy video cache`** — report, prune or clear the two render caches.
317
+ `.steppy/tts-cache/` and `.steppy/video-cache/` are content-addressed, which
318
+ is what makes them correct and what makes them grow forever: every edited
319
+ sentence leaves its old clip behind, a Piper upgrade orphans all of them at
320
+ once, and nothing ever collected them. `--prune-older-than <days>` and
321
+ `--max-size <MB>` evict least-recently-**used** entries first (the lookup
322
+ paths now touch what they read, so a clip rebuilt into every release stays
323
+ young), `--clear` empties, `--tts`/`--video` scope any of those to one cache,
324
+ and stale staging files are always swept. An entry is its primary file *plus*
325
+ its sidecars — dropping a `.mp4` and orphaning its `.vtt` would leave the
326
+ directory looking populated while every lookup missed. Mechanics live in the
327
+ stdlib-only leaf `steppy/video_cache.py`.
328
+ - **`steppy bundle upload`** — push one run's results to a Steppy control plane.
329
+ The client half of the content-addressed protocol the bundle contract
330
+ describes: the manifest is offered, the server returns presigned PUT URLs
331
+ **only for digests it does not already hold**, and finalize verifies every one
332
+ of them before ingestion is queued. On a real 240 MiB capture (3204 files
333
+ across 4 locales and 2 layouts) that is 130 MiB sent the first time and
334
+ **nothing at all** the second.
335
+ - **No archive is written.** The protocol is per-file, so a `.tar.zst` would
336
+ be created only to be read back and discarded. With no argument the command
337
+ uploads straight from `output/`; pass it a `.tar.zst`/`.tar.gz` to upload
338
+ one an earlier CI step built, and the two produce an identical digest set.
339
+ - **The endpoint comes from `--url` → `STEPPY_CLOUD_URL` → a new optional
340
+ `cloud.url` in `config.yaml`. The token comes from `--token` →
341
+ `STEPPY_CLOUD_TOKEN` and nowhere else** — there is deliberately no config
342
+ key for it, because that file is committed. Same line
343
+ `_check_credential_hygiene` already draws for app credentials.
344
+ - **Interrupted uploads resume for free.** The offer is keyed on the run and
345
+ reports what is already held, so running the command again sends only what
346
+ is missing; a presigned URL that expires mid-upload triggers one re-offer
347
+ and carries on. The bundle's own run id is the idempotency key, so a retry
348
+ attaches to the run the first attempt created rather than burning a second
349
+ run number.
350
+ - Steppy still works with **no control plane at all**, and nothing outside
351
+ `steppy.cloud` may assume one exists. The new package is lazy-imported and
352
+ guarded (`tests/test_no_cloud_import_on_default_path.py`), which keeps both
353
+ the `httpx` import cost and that boundary off the default path.
354
+ - **Results bundles** (`steppy run --bundle` + `steppy bundle create|inspect|verify`).
355
+ One run's artifacts as a single portable file: a `.tar.zst` holding the `output/`
356
+ tree **exactly as Steppy wrote it** — no path rewriting, so `tar -xf` followed by
357
+ `steppy guide` or `steppy build` works unchanged on a downloaded CI artifact —
358
+ plus a `manifest.json` describing and hashing every file, a merged `junit.xml`,
359
+ and per-test `events/*.jsonl`. Useful with nothing else installed: a
360
+ self-describing run archive to attach to a build. The manifest is a **versioned
361
+ wire contract** (`src/steppy/contracts/`, published as
362
+ `contracts/bundle-manifest.schema.json`), deliberately `extra="allow"` where the
363
+ config models are `extra="forbid"` — a config typo must fail loudly, a newer
364
+ producer's extra field must not.
365
+ - **Two opt-in artifact sinks, because a plain run produces neither.** Per-test
366
+ event records were previously `STEPPY-EVT::` lines scraped from container
367
+ stdout in parallel mode only, and JUnit XML was single-worker-only at a
368
+ user-configured path. `--bundle` turns on both, keyed by run id
369
+ (`output/events/<run-id>/`, `output/junit/<run-id>/`) so a previous run's
370
+ records can never end up in this run's bundle, and per *scenario* rather than
371
+ per worker so N workers sharing one `output/` mount do not overwrite each
372
+ other. Off by default: it changes the pytest invocation, and `steppy run` with
373
+ no flags stays byte-identical.
374
+ - **Attribution is read from pytest, never parsed out of a node id.** The events
375
+ plugin takes `(scenario, locale, layout)` from `item.callspec.params` and the
376
+ module's baked `SCENARIO`. Parsing would be wrong rather than merely awkward:
377
+ a cross-role scenario parametrizes two page fixtures and reads
378
+ `[fr-mobile-fr-mobile]`, and a locale like `fr-BE` contains the id's own
379
+ separator. Artifacts named after a *sanitised* node id (failure screenshots,
380
+ traces, videos) are attributed through a forward map instead, since that
381
+ sanitiser is lossy and cannot be inverted.
382
+ - **Archives are byte-reproducible**, which is what makes the content-addressed
383
+ upload protocol worth having — an unchanged run must not re-send everything.
384
+ Members sorted, timestamps and ownership normalised, PAX format, no directory
385
+ entries, the run's creation time taken from the run rather than the clock, and
386
+ the gzip header stripped of both its timestamp and the embedded output
387
+ filename.
388
+ - `truncated` in the manifest means the size budget dropped something that
389
+ belonged in the bundle — never an artifact the user excluded by flag. Without
390
+ that distinction a consumer cannot tell "step 7 never ran" from "we dropped
391
+ step 7's screenshot".
392
+ - New optional extra: `pip install 'steppy-dev[bundle]'` adds `zstandard` for
393
+ `.tar.zst`. Without it bundles fall back to `.tar.gz` automatically — the free
394
+ path must not depend on an extra — though an explicitly named `.tar.zst` output
395
+ errors rather than silently producing gzip content.
396
+ - **Per-step events, so a bundle names the step a test died in.**
397
+ `manifest.tests[].failed_step` was declared from the start and could never be
398
+ populated: the event stream was test-granular, and a consumer's only fallback
399
+ was "steps with a screenshot were reached" — a guess that breaks as soon as
400
+ screenshots are off. Every generated step body is now bracketed by
401
+ `_steppy_mark_step` / `_steppy_end_step`, gated on `STEPPY_STEP_EVENTS=1`
402
+ (which `--bundle` sets; export `STEPPY_STEP_EVENTS=0` to opt out). The
403
+ markers cost one env lookup each when the gate is off, and they are emitted
404
+ unconditionally so a generated file never depends on the environment it was
405
+ generated in.
406
+ - **The unclosed marker is the answer**, rather than "the last step
407
+ reached". A step that raises never reaches its `_steppy_end_step`, so it
408
+ stays open; a test that dies in fixture teardown closed every step it ran
409
+ and correctly reports no failed step at all. The number reported is the
410
+ scenario's own step number, so steps skipped by `only-in:`/`layouts:`
411
+ cannot shift it — their markers are inside the guard and never run.
412
+ - Bracketing is a `bracket_step` filter in `generator.py`, **not** two lines
413
+ in the template, because whether a captured step body ends with a newline
414
+ turns out to depend on which action branch rendered — a branch whose last
415
+ literal is followed by `{%- elif %}` has it stripped when the template is
416
+ lexed. A template-level marker therefore glued onto the previous statement
417
+ for `wait` and nothing else, a `SyntaxError` in exactly the scenarios that
418
+ used it. `tests/test_step_events_codegen.py` now brackets every action in
419
+ `VALID_ACTIONS`.
420
+ - Regenerating adds two lines per step to every `test_*.py`. Runtime
421
+ behaviour is unchanged with the gate off.
422
+ - **Video guide mode** (`modes: ['video']` + `--video` on `steppy guide` and
423
+ `steppy build`). A third guide mode that *synthesizes* narrated walkthrough
424
+ videos from artifacts a run already captured — one MP4 per (guide × locale ×
425
+ layout), on **both** build targets. Nothing is screen-recorded: a new
426
+ `packages/player/src/video/` module lays the interactive manifest onto an
427
+ explicit millisecond timeline whose state at any instant is a pure function of
428
+ its inputs, and `steppy/video.py` steps that timeline frame by frame in
429
+ headless Chromium, piping stills to ffmpeg. So a guide renders identically on
430
+ a laptop and on a throttled CI runner, which is what makes "regenerated on
431
+ every release" a claim the tool can cash. Frames that change nothing (holds,
432
+ voiceover pauses — most of a narrated video) reuse the previous still instead
433
+ of re-screenshotting, and whole renders are content-addressed in
434
+ `.steppy/video-cache/`, so an unchanged guide costs nothing on the next build.
435
+ Each MP4 ships with a WebVTT caption track and a poster frame; the static
436
+ target emits a `<slug>/video.html` player page and Angular a
437
+ `<slug>-video.component.ts` wired into the existing mode toggle.
438
+ - **Narration is dependency-injected from day one.** Voiceover text is the
439
+ per-locale `guide-text` already authored — no new authoring surface — and
440
+ the engine is chosen entirely in `config.yaml`. The default `none` gives
441
+ burned-in captions with no install, no network and no bill; `piper` (free,
442
+ offline, neural) and `espeak` run as subprocesses; ElevenLabs, OpenAI, Azure
443
+ and Google are *data rows* over one generic HTTP provider rather than a
444
+ class each, so an unsupported vendor needs only `video.tts.http` filled in,
445
+ and a third party can register one through a `steppy.tts_providers` entry
446
+ point. No code path branches on vendor. Clips are cached by content (keyed
447
+ including the engine version, since a local engine's output shifts between
448
+ builds) so a paid provider is never re-billed for unchanged text, and each
449
+ step is stretched to fit its voiceover so narration is never cut off.
450
+ - Piper is invoked as a subprocess and never imported: piper1-gpl is GPL-3.0,
451
+ so it stays a user-installed tool rather than a dependency of a
452
+ permissively-licensed wheel — the same posture already taken with ffmpeg.
453
+ - Two gates by design: the mode is the content decision, `--video` the cost
454
+ one. Declaring the mode without the flag is inert; passing the flag with no
455
+ declaring guide warns and does nothing. New `steppy video check` verifies
456
+ ffmpeg, Chromium, the provider and the API key in about five seconds, rather
457
+ than letting each fail separately partway through a long render.
458
+ - Fixed in passing: `interactive.html` was emitted and listed in `sitemap.xml`
459
+ but linked from nowhere. Static guide pages now carry a mode-links row
460
+ surfacing whichever of the interactive tour and the video the guide declares.
461
+ - **PDF export** (`--pdf` / `--handbook` on `steppy guide` and `steppy build`).
462
+ `--pdf` prints every generated guide page through headless Chromium — the
463
+ templates' existing `@media print` rules are the PDF layout, so a themed
464
+ project's PDFs inherit its theme. `--handbook` assembles every guide into one
465
+ document (cover, contents, each guide on a fresh page) as HTML **and** PDF:
466
+ per (role, locale, layout) for `steppy guide`, per locale for `steppy build`,
467
+ where it is linked from the index and each page gains a "PDF" link. Paper
468
+ settings live in a new `pdf:` config block (format, margins, scale,
469
+ backgrounds, footer page numbers). Static build target only; a dry run never
470
+ launches a browser. Print CSS gained a `--steppy-print-max-image-height` cap
471
+ (Chromium can't split an image, so a tall full-page capture used to be clipped
472
+ at the page break) and hides the site chrome, which also fixes Ctrl+P on a
473
+ published guide site.
474
+ - **Scenario-level `layouts:` now narrows `steppy build` too.** The frontmatter
475
+ declaration already scoped capture; the build still rendered every configured
476
+ layout for every guide, so a desktop-only flow shipped a "Mobile" toggle that
477
+ swapped between identical screenshots. Each guide is now built for
478
+ `layouts ∩ scenario.layouts` — one wrapper, no toggle, one screenshot copy,
479
+ a single-layout interactive manifest — and `manifest.json` reports the
480
+ effective layouts per guide. A disjoint declaration is now a build error.
481
+ - **Parallel-run reliability & triage** — six changes aimed at making a
482
+ several-hundred-scenario `steppy run --workers N` produce a short, honest,
483
+ actionable summary:
484
+ - **Report flags now reach the workers.** `--trace`, `--video`, `--retries`
485
+ and the `--` pytest passthrough were silently dropped in parallel mode —
486
+ resolved host-side but never forwarded to the containers. They now travel as
487
+ `STEPPY_TRACE` / `STEPPY_VIDEO` / `STEPPY_RETRIES` / `STEPPY_PYTEST_EXTRA`.
488
+ The worker image bakes in the `[retry,report]` extras so `--reruns` actually
489
+ works in-container.
490
+ - **Retry pass + FLAKY classification** (`parallel.retry`, `--retry-pass N`).
491
+ After the queue drains, failed scenarios are re-run at reduced concurrency
492
+ (default: 1 worker) in the still-running stacks. Fail-then-pass is reported
493
+ as **FLAKY** in its own section and does **not** fail the build
494
+ (`--strict-flaky` opts into strict); fail-twice is a real failure. Orphaned
495
+ scenarios (worker died mid-run) are retried instead of being a hard loss.
496
+ Flaky scenarios are excluded from the `lastfailed` cache, so `steppy rerun`
497
+ only re-runs genuine failures.
498
+ - **Failures grouped by cause.** The summary leads with a *Failure causes*
499
+ block that buckets failures by a normalized fingerprint of their tail —
500
+ forty scenarios sharing one broken selector print as one line, not forty
501
+ stack traces. `--verbose` restores the full per-scenario dump.
502
+ - **Run history + `steppy flaky`.** Parallel runs append one record per
503
+ scenario to `.steppy/history.jsonl` (outside `output/`, so cleanup can't
504
+ wipe it). `steppy flaky` ranks repeat offenders by flake rate with
505
+ median/p95 durations; `--last`, `--min-rate`, `--json`, `--prune`.
506
+ - **Contention reduction.** `parallel.isolate_auth` gives each worker a
507
+ private, pre-seeded `.auth` dir instead of sharing one read-write across all
508
+ N (mutually exclusive with `pre_auth`). The queue now honours seed order and
509
+ the coordinator seeds longest-first from recorded durations, so a slow
510
+ scenario is no longer claimed last and left running alone.
511
+ - **Circuit breaker** (`parallel.circuit_breaker`, `--fail-fast/--no-fail-fast`).
512
+ Aborts a run once ≥10 scenarios have completed and ≥90% of them failed —
513
+ that is infrastructure, not tests, and it should not cost the full wall
514
+ clock. On by default; skips the retry pass when it trips.
515
+ - **`steppy update`** — reconciles a scaffolded project with the installed Steppy
516
+ version: refreshes Steppy-owned assets (config schema, Claude skills, CI workflow)
517
+ from the manifest — pristine files silently, user-edited files alongside as
518
+ `<file>.new` — then reports deprecated usage and changelog highlights. A skill
519
+ shipped after the project was scaffolded is installed (not into a project
520
+ scaffolded with `--no-skills`, nor one whose skills were all deleted), and the
521
+ skill folders people copied in by hand before Steppy packaged them (`steppy`,
522
+ `steppy-angular-export`) are reported, never removed. `steppy update --check` is
523
+ a report-only, CI-able compat scan (non-zero exit on breakage).
524
+ - **`steppy init`** — scaffolds a new project: a commented `config.yaml` (with a
525
+ `# yaml-language-server: $schema=…` header for editor autocomplete), a runnable
526
+ example scenario, `credentials.yaml.example`, a generated `steppy-config.schema.json`,
527
+ and a `.steppy/scaffold.json` manifest; appends the right `.gitignore` entries and
528
+ never overwrites existing files. `--ci` also writes a GitHub Actions workflow.
529
+ Re-running it is safe, and is how a project set up by hand adopts the scaffold:
530
+ it merges the manifest rather than replacing it, and leaves the starter scenario
531
+ out when `scenarios/` already has some.
532
+ - **Config JSON Schema** — `steppy-config.schema.json`, generated from `SteppyConfig`
533
+ and drift-guarded by a test, turns `extra = "forbid"` from hostile into helpful
534
+ (inline autocomplete + typo squiggles in YAML-aware editors).
535
+ - **CI reporting** — a `report:` config block (JUnit XML, HTML report via the
536
+ `steppy-dev[report]` extra, Playwright trace) and `steppy run --trace`; a bundled
537
+ GitHub Actions template.
538
+ - **Auth** — `form` auth mode (a rename of `auth0`, which works against any
539
+ fillable login form); IdP selector presets (`auth0`, `keycloak`); implemented
540
+ `token` `header` injection.
541
+ - **Parallel pre-authentication** — an optional `parallel.pre_auth` block (`command`,
542
+ `timeout`, `image`) runs a login command **in-network** before `steppy run --workers N`
543
+ dispatches, gated (Compose `service_completed_successfully`) to finish before any worker
544
+ claims a scenario and deduped once per run across stacks (O_EXCL lock). Fixes
545
+ `mode: storage-state` auth in worker mode, where a host-captured, origin-bound
546
+ `.auth/<role>.json` can't authenticate a worker reaching the app at its `internal_url`.
547
+ Unset ⇒ dispatch is byte-identical to before.
548
+ - **Selector lint** — `steppy validate` warns on brittle selectors (positional
549
+ pseudo-classes, deep chains, text-coupled, and structure-only tag chains) across
550
+ every author-written CSS surface (`selector`, `within`, `guide-cursor.target`, and
551
+ `capture` `from.selector`), with combinator-aware (`>`/`+`/`~`) hop counting —
552
+ without failing.
553
+ - **Scenario-format version** — optional `steppy:` frontmatter key with a supported-
554
+ version check, so future format changes can be detected rather than mistaken for typos.
555
+ - **Security** — `${VAR}` config interpolation, credential-hygiene warnings, opt-in
556
+ shell capture, screenshot redaction (`screenshot.mask`); see `SECURITY.md`.
557
+ - **UI-kit presets** — `preset: primeng | material | primevue | mui | vuetify | shadcn`
558
+ for the component-driver blocks. `primeng` is authoritative; the rest are best-effort
559
+ starter kits to refine against real markup.
560
+ - **React/Vue guide embedding** — `@steppy/react` and `@steppy/vue`, thin starter wrapper
561
+ packages around the framework-agnostic `<steppy-guide>` web component (props →
562
+ attributes, `steppy:*` events → callbacks/emits). Pair them with the `static` build
563
+ target to embed interactive guides in any React/Vue app.
564
+ - **`steppy record`** — a click-to-Markdown recorder: wraps `playwright codegen` and
565
+ post-processes its output into a **draft** `scenarios/<slug>.md`. Never bakes secrets —
566
+ a username/password/submit sequence collapses to `- action: login`, password/token
567
+ fields become `${VAR}` refs (resolved at run time by a new `_interp_val` conftest
568
+ helper) and drive an auto-suggested `input[type='password']` screenshot mask; the
569
+ selector lint runs on the output. Loads `.auth/<role>.json` for an authenticated
570
+ session. A foundational V1 (CLI plumbing) — the client-facing recorder is a later phase.
571
+ - **Docs** — `STEPPY-REFERENCE.md` (the authoritative scenario-format spec),
572
+ `SECURITY.md`, `CONTRIBUTING.md`, this `CHANGELOG.md`; README Troubleshooting,
573
+ Continuous integration, and Real-world scenarios sections.
574
+ - **Example scenarios** — the in-repo `scenarios/` set now covers every action
575
+ family and is validated by a CI test so it can't rot.
576
+ - **Opt-in video recording** — `report.video` (`off`/`on`/`on-failure`)
577
+ and `steppy run --video` record a Playwright video, saving `output/videos/<test>.webm`
578
+ (retain-on-failure by default). Off by default ⇒ `run` output byte-identical.
579
+ - **Flaky-retry** — `report.retries: N` (or `steppy run --retries N`) reruns
580
+ each failed test up to N times via the new `steppy-dev[retry]` extra (pytest-rerunfailures);
581
+ warns and skips when the plugin is absent, never fails the run.
582
+ - **TOTP/MFA login** — automated multi-factor login: a `totp_secret`
583
+ (env-ref) on app credentials + a `totp_selector` on `auth.form` (with `auth0`/`keycloak`
584
+ preset starters) fills a time-based one-time code after the password submit. Implemented
585
+ with a stdlib RFC 6238 helper — **no new dependency**, and the generated conftest stays
586
+ self-contained (consumers need nothing extra).
587
+ - **Brand.** A mark (a checked box) and an app icon in `docs/brand/`, with the
588
+ palette Pin / Lichen / Balise / Granite. The run report carries the mark, a
589
+ favicon and the palette. The generated guide sites keep their neutral styling
590
+ on purpose, because they document the user's application, not Steppy.
591
+
592
+ ### Changed
593
+ - **The Python package is now named `steppy-dev`.** `steppy` on PyPI is an
594
+ unrelated project, so pip needs another name; the command, the import and
595
+ everything else stay `steppy`. Every install line and extras hint now says
596
+ `steppy-dev` (`pip install 'steppy-dev[report]'`, `steppy-dev @ git+…`),
597
+ including the CI workflows `steppy init --ci` scaffolds, which `steppy update`
598
+ refreshes. **If you installed an earlier build**, run `pip uninstall steppy`
599
+ before installing this one, since both claim the `steppy` folder and command.
600
+ Also rename the package in any requirement line that points at the git
601
+ repository: pip refuses a `@ git+…` line whose name does not match the package
602
+ it builds.
603
+ - **The guide player and video renderer bundles were rebuilt.** They now share
604
+ their tokens, strings, Markdown renderer and tooltip placement with the in-app
605
+ tour overlay (`packages/player/src/ui/`). The guide's styles are byte-identical;
606
+ cached videos re-render once, because the renderer's hash is part of the cache
607
+ key. A regional locale now falls back to its base language for the player's
608
+ chrome strings (`fr-BE` reads the French ones; it used to read English).
609
+ - **`STEPPY_RUN_ID` is now sortable in parallel mode too.** The coordinator used to
610
+ export its Compose-project id (a bare `uuid4().hex[:8]`) as the run identity, but
611
+ `coverage.merge.latest_run_id` resolves "the newest recorded run" by taking the
612
+ lexicographically last directory name — correct for the timestamped id the
613
+ single-worker path mints, arbitrary for a random one. The two are now separate:
614
+ a short `stack_id` names the Compose projects and the tmp dir, and the run's
615
+ identity is the same sortable `<timestamp>-<hex>` form everywhere. **Visible
616
+ effect:** under `--workers N --coverage`, `output/coverage/raw/<dir>` is named
617
+ differently than before, and `steppy coverage merge` with no `--run-id` now picks
618
+ the right one.
619
+ - **Default build target is now `static`** (was `angular`). The static site is
620
+ framework-agnostic and embeds anywhere, including via the new `@steppy/react` /
621
+ `@steppy/vue` wrapper packages. **Migration:** projects that build Angular components
622
+ must now set `target: 'angular'` explicitly in `steppy.guide.config.ts` — a build
623
+ config that omitted `target` and relied on the Angular default will otherwise emit a
624
+ static site.
625
+ - **Auth mode `auth0` → `form`** and **step action `auth0-login` → `login`** — the
626
+ old spellings remain kept-forever deprecated aliases (see `src/steppy/compat.py`).
627
+ - **Template tree** — the bundled `src/steppy/templates/` is now generated from the
628
+ canonical root `templates/` at build time; edit only the root tree.
629
+ - **Internal maintainability** (output byte-identical) — the screenshot-emit
630
+ predicate that was copy-pasted 36× in `test_scenario.py.j2` is now one registered
631
+ `emits_screenshot` Jinja filter; the scenario/locale/layout selection logic shared
632
+ by single-worker and parallel runs was extracted into `src/steppy/selection.py` so
633
+ `--workers N` can't drift from `--workers 1`; and the escaping-fuzz suite was
634
+ extended to frontmatter (title/description docstrings, seed path) and config layout
635
+ names, hardening those source-baked sites against quote/backslash injection.
636
+ - **De-branding** — CLI help, example config/scenarios, and fixtures use generic
637
+ `admin`/`editor`/`viewer` roles.
638
+ - **Versions aligned** — the Python package, npm types package, and player now share
639
+ one version, guarded by a test and the release workflow.
640
+
641
+ ### Fixed
642
+ - **Guide text no longer turns protocol-relative links into links.** The player's
643
+ Markdown renderer accepted `[label](//host/path)` and `[label](/\host)`, which
644
+ browsers resolve to another site; both now stay literal text, like any other
645
+ refused URL.
646
+ - **The release workflow now vendors `steppy-video.js`.** It copied only two of
647
+ the three player bundles, so a release wheel shipped whatever video renderer
648
+ happened to be committed; the wheel check now requires all three. Releases also
649
+ attach an sdist.
650
+ - `contracts/report.schema.json` regenerated. It was out of sync with the models
651
+ after a pydantic upgrade, so the drift guard failed.
652
+ - **A multi-role scenario ran once per *pairing* of its roles' locales and layouts,
653
+ and the mixed runs filed screenshots in the wrong directory.** The
654
+ `pytest_generate_tests` hook parametrized each role page fixture with a
655
+ separate call, and separate calls on independent argnames multiply. Under
656
+ `--layouts desktop,mobile`, a two-role scenario collected 4 items and a
657
+ three-role one 8. With `--locales fr,nl` as well, a two-role scenario collected
658
+ 16, one page in French and the other in Dutch. The extra items were also wrong:
659
+ `CURRENT_LOCALE`/`CURRENT_LAYOUT` are one global that every role fixture
660
+ writes, so the last one set up decided the screenshot directory for a page
661
+ built at the other role's viewport. The artefact was a desktop capture under
662
+ `fr/mobile/` whose sidecar claimed `viewport: {390, 844}` beside a
663
+ `bbox.x` of 1435. All role pages now go under one `parametrize` call and
664
+ move in lockstep. A real suite under `--layouts desktop,mobile` went from 1118
665
+ collected items to 616, and single-role ids are byte-identical. As hardening,
666
+ the sidecar's `viewport` (and the in-viewport test in multi-frame fill-form)
667
+ now reads `page.viewport_size`, the space the bboxes were actually measured
668
+ in, and falls back to the layout lookup only for a context without a
669
+ viewport. *Behaviour change: multi-role tests collect fewer items, and
670
+ their node ids lose the repeat (`[fr-mobile]`, not `[fr-mobile-fr-mobile]`), so
671
+ a single-worker `lastfailed` cache written before the upgrade will not match
672
+ them on the first `steppy rerun`.*
673
+ - **Files under `output/coverage/` that were not raw records were silently dropped
674
+ from every bundle.** `classify._coverage_file` returned `None` for anything not
675
+ matching `raw/<run_id>/…`, and the walker reads `None` as "belongs to a different
676
+ run, skip entirely" — the reading it was written for. So `output/coverage/gaps.json`,
677
+ and any file dropped there by hand, vanished without a warning, contradicting
678
+ that module's own rule that carrying a file nobody labelled beats dropping one
679
+ nobody noticed. `None` is now reserved for the previous-run case, `gaps.json`
680
+ ships as the new `coverage_gaps` kind (never dropped by the size budget — it is
681
+ kilobytes, and the only way a detached bundle can report a real coverage
682
+ surface), and anything else lands as `other`. Found by writing the report that
683
+ needed to read it, which is what the results-bundle acceptance test is for.
684
+ - **A non-ASCII character in Steppy's own output could kill the command.** Steppy's
685
+ summaries use `→`, `×` and `…`; on a Windows console still running a legacy code
686
+ page `sys.stdout` is cp1252, and the first arrow raised `UnicodeEncodeError` from
687
+ inside Rich — so `steppy coverage merge` and every other command with an arrow in
688
+ it died at the moment it tried to report success, with a charmap traceback that
689
+ said nothing about the run. The shared console now asks for UTF-8 with
690
+ `errors="replace"`, guarded on every axis.
691
+ - **The interactive player's `sidebar` template no longer degrades with step
692
+ count.** The left rail had no height cap and its timeline no scroll, so rail
693
+ height was linear in the number of steps: at 19 steps — the longest guides in a
694
+ real project — a ~1200px rail stood beside a 394px screenshot, ~800px of dead
695
+ column, worsening with every step added. The rail is now capped and its
696
+ *timeline* is what scrolls, so the head (title, meta, switchers) and foot
697
+ (prev/next/counter) stay pinned however long the guide is. The load-bearing
698
+ line is `min-height: 0` on the timeline: a flex item's default `min-height:
699
+ auto` refuses to shrink below its content, so without it the cap is inert and
700
+ nothing scrolls. Sticky went on the **rail**, not the preview — a tall mobile
701
+ capture renders ~1363px, and sticking *that* would pin its top and put its
702
+ bottom permanently out of reach, whereas sticking the rail keeps navigation in
703
+ view while the image scrolls and is a no-op the rest of the time. The preview
704
+ is now centred in its grid area, which turns the remaining slack into padding
705
+ instead of a void below the image; this is safe for the cursor and hotspot
706
+ because they are positioned inside `.steppy-stage` and scaled from the image's
707
+ live rect, so they travel with it. Advancing scrolls the current row into view
708
+ (`block: 'nearest'`), which is required once the timeline scrolls and also
709
+ absorbs the reflow from the description being injected into only the current
710
+ row. Rail width, rail cap and sticky offset are now `--steppy-*` tokens: the
711
+ shadow DOM exposes no `part=`, so without them a host that cannot live with a
712
+ default has to wait for a release. The static `interactive.html` shell widened
713
+ from `1024px` to `min(1440px, 100vw - 32px)` — at 1024px the sidebar spent 340
714
+ of it on the rail and rendered a 1440px capture at 44%; it now reaches ~73%,
715
+ and the overlay/inline stage goes from 990px to essentially native. That makes
716
+ the interactive page deliberately wider than the 900px editorial guide pages:
717
+ it is an app-like widget, not prose.
718
+ - **Interactive guides can express the desktop/mobile twin-branch pattern again,
719
+ and each layout now gets its own wording.** `prepare_interactive_manifest`
720
+ required every layout to cover the *same scenario step indices*, which
721
+ complementary `layouts:` whitelists — one logical step authored twice because
722
+ the DOM path differs — can never satisfy; it failed 18 of 24 guide-enabled
723
+ scenarios in a real project. Divergent indices are the pattern working as
724
+ intended; the merge is positional, and what the manifest genuinely cannot
725
+ express is a divergent step *count* per layout, which the pre-existing count
726
+ guard already caught and now reports with the per-layout coverage breakdown.
727
+ Underneath that, the merge had been taking `title`/`text`/`subtitle` from the
728
+ canonical (default-layout) branch alone, so mobile viewers read "click the row
729
+ of the table" beneath a screenshot of a card list — silently, in 34 of 199
730
+ steps in the same project. `captures[locale][layout]` now carries an optional
731
+ atomic `content: {title, text, subtitle}` override, emitted only where it
732
+ differs from the step level, so a guide with no twins stays byte-identical and
733
+ the video render cache does not churn. Manifest version `1.0` → `1.1` (the
734
+ player gates on the major, so old manifests still load — but `Capture` is a
735
+ strict schema, so emitter and vendored bundle must ship together). Twins that
736
+ disagree on `action` now warn instead of failing the build; the cursor-presence
737
+ check stays fatal, because that one really does change playback.
738
+ - **A killed video/narration render can no longer poison a cache permanently.**
739
+ ffmpeg wrote both the cached `.mp4` and the cached `.wav` *directly to the key
740
+ path*, and both lookups only asked `is_file()` — so a Ctrl-C, a full disk or an
741
+ encoder crash left a truncated artifact that every later run copied out and
742
+ reported as a hit, with `ok=True, cached=True`, forever. Every cache write now
743
+ stages a sibling temp file and `os.replace`s it into position. A hit is also
744
+ validated rather than trusted: a truncated WAV keeps its 44-byte header, so
745
+ `getnframes()` still reports the *intended* length and the timeline would be
746
+ sized against audio that is not there — the payload is now compared against the
747
+ bytes on disk, and a bad entry is discarded and re-made instead of raising a
748
+ bare `wave.Error` that killed the build.
749
+ - **Re-captured screenshots now invalidate the cached video.** The render
750
+ fingerprint hashed the manifest, which carries screenshot *URLs* — so a
751
+ recapture after a UI change that did not move the target element (a colour, a
752
+ logo, a label outside the bbox) produced the same key, and the build shipped a
753
+ video of the old UI and called it cached. Screenshot bytes are now hashed in,
754
+ memoized per batch so a guides × locales × layouts corpus reads each file once.
755
+ - **Setting an API key no longer re-encodes every video in the project.** The
756
+ fingerprint dumped the whole `VideoConfig`, nested `tts` block included, so
757
+ `api_key`, `timeout`, `ffmpeg_path` and `cache_dir` all invalidated renders
758
+ they cannot change a pixel of. They are excluded by an explicit denylist —
759
+ a field added later still invalidates by default.
760
+ - **Editing `video.tts.http` now re-synthesizes.** The HTTP engine's `version()`
761
+ returned only the model name, so changing the request body (ElevenLabs
762
+ `stability`/`style`, Azure SSML prosody, a different `format`) left every
763
+ existing clip a hit even though the audio would differ. It now folds in a hash
764
+ of the resolved spec — taken *pre-substitution*, so it carries `{api_key}`
765
+ placeholders rather than the secret and rotating a key does not churn the cache.
766
+ - **Re-wrapping a `guide-text` line is no longer a re-bill.** Narration text is
767
+ whitespace-normalized before it is sent and hashed, so a hard wrap moved by one
768
+ word costs nothing. (One-time full miss on the first render after upgrade.)
769
+ - **`video.tts.cache: false` no longer bills once per layout.** Narration does
770
+ not vary by layout, but each job re-synthesized from scratch with the on-disk
771
+ cache off; a batch now shares a process-local memo, so "do not persist" stops
772
+ meaning "pay N times".
773
+ - **`tts-cache/work/` no longer grows forever.** It kept every pre-transcode
774
+ container a provider ever returned; the normalized WAV is the artifact, so the
775
+ raw file is now deleted once it has been converted.
776
+ - A guide whose narration is empty now emits an empty WebVTT track instead of no
777
+ file at all — the embedding page advertises a `<track src=…>` decided at build
778
+ time, long before the render knows whether there is anything to say, so the
779
+ missing file was a 404.
780
+ - **`steppy-config.schema.json` no longer drifts on a pydantic upgrade.** The
781
+ committed schema is a generated artifact whose bytes are byte-compared against
782
+ the models by a guard test — but `pydantic>=2.0.0` is unbounded, and two of its
783
+ outputs vary across 2.x releases (`additionalProperties: true` on open dicts,
784
+ and `0` vs `0.0` in numeric bounds). Both leaked into the file, so the guard
785
+ failed for anyone whose pydantic differed from whoever last regenerated it,
786
+ reporting a model change that had not happened. The generator now canonicalises
787
+ those away, so the schema depends on the models and nothing else.
788
+ - Unescaped scenario strings (apostrophes, quotes) no longer break generated code.
789
+ - `steppy validate`/`list`/`generate` no longer report success as failure
790
+ (`typer.Exit` handling); unexpected errors show a friendly message, full traceback
791
+ only under `--verbose` / `STEPPY_DEBUG=1`.
792
+ - Malformed step lines and missing required fields now fail at `validate` time
793
+ instead of silently dropping intent.
794
+
795
+ ### For contributors
796
+ - **Releases can publish to PyPI.** A `publish-pypi` job in `release.yml` uploads
797
+ the wheel and sdist the release job built, through Trusted Publishing, once the
798
+ `PYPI_PUBLISH` repository variable is `true`. The release job also runs
799
+ `twine check --strict` on them first. The one-time setup for PyPI and npm is in
800
+ CONTRIBUTING.md, "Releasing". `tests/test_distribution_name.py` fails when an
801
+ install hint names plain `steppy`.
802
+ - **In-app tours bring their own guards**: a stdlib-only-leaves check, the
803
+ tours lazy-import guard, Python↔TypeScript name sync, a shared golden case
804
+ table run by both the producer check and the overlay's validator, a docs sync
805
+ test, a vendored-bundle pin (`tests/test_player_bundle_files.py`), the overlay's
806
+ size budget in the `player` CI job, and a new `tour-e2e` CI job running the
807
+ Chromium suites. The step loop of `test_scenario.py.j2` moved into a
808
+ `step_body` macro — byte-identical output, proven by `tests/_codegen_snapshot.py`
809
+ — so the tour replay renders exactly the same action code.
810
+ - **The tour browser suites measure what the user sees.** Both demos gained a
811
+ phone layout (a menu drawer instead of the header links), a long page and a
812
+ confirmation, and every demo scenario runs at 1280x800 and 390x844, replays
813
+ included. `tests/integration/tour_geometry.py` checks each step in the real
814
+ browser: the spotlight frames the target, the bubble stays on screen and off
815
+ it, a click at the target reaches it, and no open dialog is covered — by mouse
816
+ on a computer and by touch on a phone, through the drawer, Escape in it, a
817
+ focus trap that pulls focus back, and a language switch.
818
+ - **The Angular adapter is tested three ways**: the CLI's Vitest builder runs a
819
+ zoneless suite and a zone.js suite (`npm --prefix packages/angular test`) against
820
+ a fake overlay and the real one; `tests/test_angular_adapter_sync.py` keeps its
821
+ tag, event names and property writes in step with the overlay; and the
822
+ `tour-angular-e2e` CI job runs the generated replays and the router, zone and
823
+ i18n checks against the Angular demo in Chromium
824
+ (`tests/integration/test_tour_angular_it.py`). A new `angular-adapter` job
825
+ builds the package and dry-runs its `npm pack`.
826
+ - **The lint gate is pinned, and now actually runnable.** `uv run ruff check .` —
827
+ the command CONTRIBUTING documents — failed with "program not found", so people
828
+ reached for whatever ruff was on PATH. That is not harmless: the formatter
829
+ changed how it wraps a long `assert x, "msg"` in 0.9, and an older ruff does not
830
+ skip the rule, it reformats the other way, quietly rewriting files the repo just
831
+ formatted. Ruff now resolves from a `dev` dependency group, `required-version`
832
+ refuses an incompatible one, and the lint rule set is **explicit** rather than
833
+ inherited — ruff 0.16 widened its defaults and took the unchanged tree from
834
+ clean to 745 errors on upgrade. Markdown is excluded from the formatter, which
835
+ 0.16 would otherwise rewrite (the docs are full of deliberately-bad "BEFORE"
836
+ snippets that exist to be ugly).
837
+ - **`config.output.screenshots_dir` and `guides_dir` actually do something now**
838
+ They were validated, schema'd and scaffolded — and read by
839
+ nothing, so setting one changed exactly nothing while four independent copies
840
+ of the same hardcoded literal decided where artifacts really went. The capture
841
+ root joined the rest of the capture-path grammar in `captures.py`, and every
842
+ consumer resolves through it: the generated conftest (at *runtime*, like
843
+ `coverage.records_dir` — so editing `config.yaml` needs no re-`generate`, and
844
+ the generated file stays identical for every project), `steppy guide`'s two
845
+ path flags, `steppy build` (explicit `screenshotsDir` still wins), the
846
+ parallel failure harvest, and `steppy bundle create`. A project that sets
847
+ nothing keeps byte-identical paths.
848
+
849
+ Pointing a root outside `output/` is allowed and honoured, but three things
850
+ only ever see `output/` and each now says so: a bundle warns the artifacts are
851
+ not in the archive, `--workers N` warns only `output/` is mounted back from
852
+ the container, and the config comments name the CI upload and `.gitignore`.
853
+
854
+ `output.reports_dir` is **deprecated and inert** — nothing ever wrote
855
+ `output/reports` (traces and videos are top-level; `report.junit_xml` /
856
+ `report.html` carry their own paths). It still parses, so no existing config
857
+ stops loading; `steppy validate` warns and `steppy update --check` reports it.
858
+ `output:` also gained `extra: forbid`, so a `screenshot_dir:` typo now fails
859
+ loudly instead of being swallowed.
860
+
861
+ [Unreleased]: https://github.com/Steppy-dev/steppy/compare/v0.3.0...HEAD
862
+ [0.3.0]: https://github.com/Steppy-dev/steppy/releases/tag/v0.3.0