@asrunjs/asrun 0.7.0 → 0.9.0

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 (73) hide show
  1. package/.claude/commands/explore-case.md +6 -2
  2. package/.claude/commands/gen-spec.md +4 -1
  3. package/.claude/commands/heal-spec.md +18 -7
  4. package/.claude/skills/exploring-cases/SKILL.md +19 -8
  5. package/.claude/skills/exploring-cases/references/open-questions.md +2 -2
  6. package/.claude/skills/generating-specs/SKILL.md +85 -30
  7. package/AGENTS.md +23 -12
  8. package/README.md +25 -14
  9. package/bin/asrun.mjs +18 -2
  10. package/dist/lib/ffmpeg.js +21 -10
  11. package/dist/lib/migrations.d.ts +106 -0
  12. package/dist/lib/migrations.js +106 -0
  13. package/dist/lib/paths.d.ts +9 -0
  14. package/dist/lib/paths.js +12 -1
  15. package/dist/lib/registry.d.ts +31 -0
  16. package/dist/lib/registry.js +66 -0
  17. package/dist/lib/runtime-deps.d.ts +59 -0
  18. package/dist/lib/runtime-deps.js +161 -0
  19. package/dist/lib/tar.d.ts +5 -0
  20. package/dist/lib/tar.js +79 -0
  21. package/dist/lib/update-check.d.ts +52 -0
  22. package/dist/lib/update-check.js +223 -0
  23. package/dist/migrations/index.d.ts +23 -0
  24. package/dist/migrations/index.js +22 -0
  25. package/dist/migrations/manifest.json +1 -0
  26. package/dist/scripts/build.js +7 -0
  27. package/dist/scripts/cli.js +22 -4
  28. package/dist/scripts/doctor.js +51 -9
  29. package/dist/scripts/go.mjs +7 -5
  30. package/dist/scripts/init.d.ts +3 -2
  31. package/dist/scripts/init.js +73 -11
  32. package/dist/scripts/migrate.d.ts +1 -0
  33. package/dist/scripts/migrate.js +259 -0
  34. package/dist/scripts/run.js +2 -1
  35. package/dist/scripts/upgrade.d.ts +1 -0
  36. package/dist/scripts/upgrade.js +256 -0
  37. package/dist/ui/server/actions.js +9 -4
  38. package/dist/ui/server/app.js +18 -0
  39. package/dist/ui/server/index.js +3 -0
  40. package/dist/ui/server/update.d.ts +18 -0
  41. package/dist/ui/server/update.js +87 -0
  42. package/dist/ui/shared/ai-commands.d.ts +6 -3
  43. package/dist/ui/shared/ai-commands.js +11 -7
  44. package/dist/ui/shared/types.d.ts +25 -1
  45. package/docs/agents/framework-dev.md +74 -4
  46. package/docs/ai-workflow.md +38 -11
  47. package/docs/commands.md +74 -4
  48. package/docs/install.md +52 -16
  49. package/docs/theme-guide.html +93 -5
  50. package/docs/troubleshooting.md +22 -10
  51. package/docs/web-ui.md +28 -6
  52. package/package.json +2 -4
  53. package/templates/.claude/commands/explore-case.md +7 -2
  54. package/templates/.claude/commands/gen-spec.md +4 -1
  55. package/templates/.claude/commands/heal-spec.md +22 -12
  56. package/templates/AGENTS.md +1 -0
  57. package/templates/pnpm-workspace.yaml +3 -3
  58. package/ui/dist/assets/{dist-DZ7RiqmA.js → dist-B7HeglDd.js} +1 -1
  59. package/ui/dist/assets/{dist-DEPJZ3Me.js → dist-B9_GXR2G.js} +1 -1
  60. package/ui/dist/assets/{dist-CO4heSzh.js → dist-Bb36rNUw.js} +1 -1
  61. package/ui/dist/assets/{dist-jPS6HCJ5.js → dist-DevASegc.js} +1 -1
  62. package/ui/dist/assets/{dist-C-eCSFja.js → dist-DfBpjgZT.js} +1 -1
  63. package/ui/dist/assets/{dist-O_RVi4tP.js → dist-Dq4Vnw5N.js} +1 -1
  64. package/ui/dist/assets/{dist-CPLhRzom.js → dist-Dqc_tfzC.js} +1 -1
  65. package/ui/dist/assets/{dist-LixzGniF.js → dist-G9KNeh6u.js} +1 -1
  66. package/ui/dist/assets/{dist-BudNAjOv.js → dist-GvRUDL-2.js} +1 -1
  67. package/ui/dist/assets/{dist-vmf3fnkM.js → dist-WvvCZeeu.js} +1 -1
  68. package/ui/dist/assets/index-BJ5D2mFK.js +346 -0
  69. package/ui/dist/assets/index-BXY-eKBn.css +2 -0
  70. package/ui/dist/help/case-explore.png +0 -0
  71. package/ui/dist/index.html +2 -2
  72. package/ui/dist/assets/index-BIGt8Hfz.js +0 -338
  73. package/ui/dist/assets/index-CTmnF0Wp.css +0 -2
@@ -66,6 +66,74 @@ is relevant to a project that uses asrun as a dependency.
66
66
  - A project's rules never flow back here. The framework reads a workspace only through `ROOT` at run time; nothing
67
67
  under `templates/` or `.claude/skills/` is generated from a project.
68
68
 
69
+ ### Seeing the release notice without a release
70
+
71
+ `lib/update-check.ts` asks the registry for the newest version. To see the notice without publishing anything, make it
72
+ answer with a made-up version. The check is the same code the command line and the web UI run, so both show exactly
73
+ what a real release would produce.
74
+
75
+ ```powershell
76
+ # A framework checkout (the default in this repository): the notice says to update with git
77
+ $env:ASRUN_UPDATE_MOCK_LATEST = '9.9.9'
78
+ pnpm asrun doctor # stderr: ... Update the framework checkout: git pull
79
+ pnpm ui # top-bar badge, and the dialog offers git pull
80
+
81
+ # A registry install: the notice offers "pnpm asrun upgrade"
82
+ $env:ASRUN_UPDATE_MOCK_KIND = 'installed'
83
+ pnpm ui
84
+ ```
85
+
86
+ - `ASRUN_UPDATE_MOCK_LATEST=<version>` replaces the registry answer; `ASRUN_UPDATE_MOCK_KIND=installed|checkout`
87
+ replaces the install-kind detection. Each field falls back, in this order, to the environment, then to
88
+ `.tmp/update-mock.json` at the workspace root (in this repository that is `examples/demo/.tmp/update-mock.json`,
89
+ because `.env` points `ASRUN_ROOT` there; for example `{"latest": "9.9.9", "kind": "installed"}`). A mock is never
90
+ written to the `.tmp/update-check.json` cache, so deleting the variable or the file brings the real check straight
91
+ back.
92
+ - The environment is read when a process starts: a running `pnpm ui` must be restarted after changing it. The mock file
93
+ is read on every check, so editing it and pressing **Check again** (or `GET /api/update?refresh=1`) is enough.
94
+ - The UI marks a mocked answer with a **Dev mock** tag, and the API returns `mocked: true`.
95
+ - `ASRUN_NO_UPDATE_CHECK=1` still wins over both: it turns the notice off entirely.
96
+
97
+ `GET /api/update` answers with the state of the server's last check; `GET /api/update?refresh=1` checks again first
98
+ (it is what the dialog's **Check again** button calls).
99
+
100
+ ### Optional runtime packages
101
+
102
+ A dependency with an install script makes `pnpm dlx @asrunjs/asrun` stop at pnpm's build-approval picker: the dlx
103
+ sandbox does not read the workspace's `allowBuilds`, and in a pipeline nobody can answer the prompt. So a package that
104
+ is heavy and optional (the bundled `ffmpeg-static` today) is not a dependency at all:
105
+
106
+ - Add it to `RUNTIME_EXTRAS` in `lib/runtime-deps.ts`, with the line the person sees (`provides`), a `probe` command
107
+ that proves the machine already has the capability, and `builds: true` when the package runs an install script.
108
+ - `init` (and therefore `go`, whose third step runs it) writes the `allowBuilds` entry and installs the package as a
109
+ dependency of the workspace, only when the probe fails; `--no-extras` leaves it out. `BUILD_APPROVALS` is the one
110
+ list `doctor`, `go` and the template of `pnpm-workspace.yaml` follow.
111
+ - Resolve an extra from `ROOT` first, then from the framework's own install, the way `lib/ffmpeg.ts` does.
112
+ - `doctor` warns while an extra is missing, and `verify:pack` fails when one of them becomes a runtime dependency
113
+ again (that would bring the dlx prompt back). CI runs it on a packed tarball.
114
+
115
+ ### Workspace migrations
116
+
117
+ A release can require an *existing* workspace to change; `docs/commands.md` has the user's view. The registry is
118
+ `migrations/index.ts`, and the interface plus the helpers live in `lib/migrations.ts`:
119
+
120
+ - `version` is the release that introduces the change. `pnpm asrun migrate` runs an entry when
121
+ `migrations.since < version <= installed`, in version order, and moves `since` only after every entry of that
122
+ release has been processed, so a failed run repeats the release instead of skipping its other entries.
123
+ - `auto: true` with an `apply` runs after the install, when `detect` says the workspace is not migrated yet. Keep it
124
+ idempotent: a run that failed repeats the entry.
125
+ - Anything a person has to do is `manual: string[]`. The id is remembered in `.asrun.json` (`migrations.manual`) and
126
+ `doctor` keeps reminding until `pnpm asrun migrate --ack <id>`.
127
+ - `breaking: true` makes `pnpm asrun upgrade` ask before installing anything. The pre-install check reads
128
+ `dist/migrations/manifest.json` out of the target tarball, so `pnpm build` has to write it (`scripts/build.ts`
129
+ serialises `MIGRATIONS` with `toManifest`). `verify:pack` asserts that a packed tarball carries the manifest and that
130
+ the reader in `lib/tar.ts` finds it.
131
+ - Append only: never delete or renumber an entry, because a workspace on an older release may still need it. A change
132
+ that only concerns brand-new workspaces belongs in `templates/`, not in a migration.
133
+ - `scripts/migrate.ts` applies the *installed* package's registry, so it never runs an entry from a release that is not
134
+ installed. `scripts/upgrade.ts` installs first, then runs `init --update`, `migrate` and `doctor` through the newly
135
+ installed bin (`bin/asrun.mjs`), never through the process that started the upgrade.
136
+
69
137
  ### Installing this repository as a Git dependency (private)
70
138
 
71
139
  The npm package is the public channel for users. This repository is private, so a Git dependency works only on a
@@ -164,8 +232,10 @@ Every lead, label and tooltip is written for the person using the UI, never for
164
232
 
165
233
  Nothing bumps the version by hand. A merge to `main` runs `.github/workflows/release.yml`, and Release Please opens
166
234
  (or refreshes) one `chore(release): X.Y.Z` pull request holding the next `package.json` version and the matching
167
- `CHANGELOG.md` entry. Merging that pull request is the release: the same run tags the commit `vX.Y.Z` and publishes to
168
- npm.
235
+ `CHANGELOG.md` entry. Merging that pull request is the release: the same run tags the commit and publishes to npm.
236
+
237
+ - Tags carry the package component, which manifest mode adds by default: `asrun-v0.7.0`, not `v0.7.0`. The publish check
238
+ accepts either, so a hand-made `v0.7.0` still works.
169
239
 
170
240
  - The next version comes from the commit messages on `main`, and a squashed pull request title becomes that message.
171
241
  `fix:` is a patch, `feat:` a minor, `feat!:` a major (the project is past 0.x, so a breaking change releases
@@ -174,8 +244,8 @@ npm.
174
244
  - `templates/`, `docs/`, `ui/dist/` and `.claude/` are shipped files: a change to them needs `fix:` or `feat:`, not
175
245
  `docs:`, or no release is made and no workspace ever sees the new version (`pnpm asrun doctor` compares it).
176
246
  - `release.yml` is the only uploader. When an upload fails after the tag exists, run it by hand with the tag instead of
177
- re-running the failed job: `gh workflow run release.yml -f tag=v1.0.0` (`-f dry_run=true` packs without uploading).
178
- A re-run of the push run finds the release already made and skips publishing.
247
+ re-running the failed job: `gh workflow run release.yml -f tag=asrun-v1.0.0` (`-f dry_run=true` packs without
248
+ uploading). A re-run of the push run finds the release already made and skips publishing.
179
249
  - npm authentication is trusted publishing (OIDC, no secret): the workflow filename in the package's trusted publisher
180
250
  settings is `release.yml`. A private repository gets no provenance, so the package page shows no provenance badge.
181
251
 
@@ -8,16 +8,25 @@ so the procedure lives in one place and a project can refine it (see
8
8
  | Command | What it does |
9
9
  |---|---|
10
10
  | `/make-case <notes-or-path>` | Turn raw notes into case documents. Optional step. |
11
- | `/explore-case <case-id> <env> [--attended]` | Open the real pages and write what they show into the case. |
11
+ | `/explore-case <case-id> <env> [--attended] [--profiles a,b]` | Open the real pages and write what they show into the case. |
12
12
  | `/gen-spec <case-id> <env> [--force]` | Write `tests/<case-id>.spec.ts` and fill the case's `## Values`. |
13
- | `/heal-spec <case-id> <env> [--profiles a,b] [--report <run-id>] [--hints <file>]` | Diagnose a failing spec from its last report and fix it. |
13
+ | `/heal-spec <case-id> <env> [--profiles a,b] [--report <run-id>] [--hints <file>]` | Repair a failing spec, or refine a case and its spec on request, from the last report and what you asked for. |
14
14
 
15
15
  The environment is **required** by every command except `/make-case`, which does not open pages. There is no default.
16
16
 
17
+ `--profiles` exists on `/explore-case` and `/heal-spec`, not on `/gen-spec`: a spec runs once per profile in the
18
+ environment, so it is generated from every profile the case holds information for, never from a chosen subset.
19
+
17
20
  Every command ends with `RESULT: ready` or `RESULT: needs-input`. `needs-input` means somebody has to answer a
18
21
  question in the case file (or, in the web UI, in the job's **Answer** sheet). A one-shot run cannot ask in the chat:
19
22
  it writes its questions into the case and stops.
20
23
 
24
+ A one-shot run is a **build** run: it writes the case or the spec, never a plan. AsRun starts OpenCode with
25
+ `--agent build`, because OpenCode keeps the agent (build or plan) on the session and a plan session would only propose
26
+ changes and ask a question nobody can answer. Whatever mode a run finds itself in, it still ends with the `RESULT:`
27
+ line: a run that was not allowed to write files (plan mode, a read-only sandbox) is `needs-input`, not a plan and a
28
+ question.
29
+
21
30
  In the web UI you never type a command: choose the step in a case toolbar and press **Run AI**, or paste notes on
22
31
  **Cases > From memo**. The UI runs the selected AI tool and streams the output into the Jobs bar.
23
32
 
@@ -55,6 +64,11 @@ publishes, shares or deletes anything, in staging or in production. It:
55
64
  - lists the candidates instead of guessing a target (`## Targets`);
56
65
  - edits the case file only (plus reference screenshots under `.explore/targets/`).
57
66
 
67
+ With many profiles, `--profiles a,b` opens only those (ids from `profiles.yaml`, comma-separated). The others are not
68
+ opened and nothing is written about them, so a case can be explored in passes: A and B first, C later. Runs add up; the
69
+ case keeps what the earlier runs recorded. In the web UI the profile picker of the toolbar does the same: every profile
70
+ that is logged in in that environment is ticked, and the list you tick is remembered for the next run there.
71
+
58
72
  **To accept the suggestion, delete the whole block.** **To answer, write after `Answer:`.** Then run the command
59
73
  again. In the web UI, the run's **What to do next** bar offers **Answer**: the question list, an answer box,
60
74
  **Use the suggested defaults** and **Generate spec**. A continued job keeps the same conversation and permissions, and
@@ -92,8 +106,14 @@ Skip this for a `login: none` case. Sessions are per profile; see [profiles.md](
92
106
  ```
93
107
 
94
108
  The agent replays the case on the real pages with your saved sessions (it never writes data) and writes
95
- `tests/header-logo.spec.ts`. It fills the case's `## Values` for the profiles it explored and checks the files
96
- (`pnpm typecheck`, `pnpm case:check`; `pnpm spec:lint <case-id>` must have no failure).
109
+ `tests/header-logo.spec.ts`. It fills the case's `## Values` for the profiles the case holds information for and checks
110
+ the files (`pnpm typecheck`, `pnpm case:check`; `pnpm spec:lint <case-id>` must have no failure).
111
+
112
+ Its profile scope comes from the case, not from a selection: every profile id the case names — the keys of
113
+ `## Values`, a target card's `Reference <profile-id>:` line, an observation — plus `default`. That is why one run per
114
+ subset adds up: explore A and B first and C later, and `/gen-spec` covers all three. A profile the case says nothing
115
+ about runs on `default` alone, and the report names it. A profile that is not logged in is not dropped: its part is
116
+ written from the case and marked `not verified on the page`.
97
117
 
98
118
  It does **not** run the spec and does **not** create screenshot baselines: running is a separate step (6).
99
119
 
@@ -125,7 +145,7 @@ profile. Baselines are the source of truth for pixel checks.
125
145
  Then run all profiles and read the report: the video of each test, screenshots on failure, and the trace with the page
126
146
  and the network requests. See [commands.md](./commands.md) for filters and baselines.
127
147
 
128
- ## Healing a failing spec
148
+ ## Healing or refining a spec
129
149
 
130
150
  ```
131
151
  /heal-spec header-logo staging
@@ -133,14 +153,15 @@ and the network requests. See [commands.md](./commands.md) for filters and basel
133
153
 
134
154
  or **Heal spec** in the case toolbar, or **Heal this failure** on a failed result of the Reports page.
135
155
 
136
- The agent decides whether the test needs updating or whether it found a real product problem. It **never** edits an
137
- expectation just to make a failure disappear.
156
+ The command does two jobs: it repairs a spec that fails, and it refines a case and its spec when you ask for an
157
+ improvement. A passing last run is not a reason to refuse a request. It decides whether the test needs updating or
158
+ whether it found a real product problem, and it **never** edits an expectation just to make a failure disappear.
138
159
 
139
160
  - The environment is required. Only the profiles that are logged in there run (`--logged-in` leaves the others out
140
161
  instead of failing them).
141
- - The web UI first opens a dialog: which profiles to heal (every logged-in profile ticked) and an optional text box
142
- for what you want healed, with evidence or clues. The text is stored in `.tmp/hints/` and passed as `--hints`.
143
- Clues guide the diagnosis; they never lower the bar for a product finding.
162
+ - The web UI first opens a dialog: which profiles to run (every logged-in profile ticked) and an optional text box
163
+ for what should be repaired or improved, with evidence. The text is stored in `.tmp/hints/` and passed as `--hints`.
164
+ It guides the work; it never lowers the bar for a product finding.
144
165
  - It reads the existing report first:
145
166
 
146
167
  ```bash
@@ -149,7 +170,13 @@ expectation just to make a failure disappear.
149
170
 
150
171
  It prints status, failed step, error, call log and screenshots per profile, and whether the report is fresh or
151
172
  stale. The case is run again only when there is no report, the report is stale, it does not explain the failure, or
152
- a clue has to be checked. After editing the spec, it runs the case once to confirm.
173
+ a request has to be checked. It reads the pages (read-only) when a change has to be judged on them, and it never
174
+ performs a step that writes data. After editing the spec, it runs the case once to confirm.
175
+ - The case is the source of truth: a change to a step, an expectation, a value or a target is written into the case
176
+ first, then into the spec. A large change regenerates the spec from the case.
177
+ - **Review gate.** If the change leaves an open question or an unconfirmed observation in the case, healing stops
178
+ before the spec: you review the case, answer or accept what it asks, and run `/explore-case` again. The spec is
179
+ generated after that, not before.
153
180
 
154
181
  ## Why a generated spec can be trusted
155
182
 
package/docs/commands.md CHANGED
@@ -7,8 +7,10 @@ thin wrappers, so `pnpm asrun`, `pnpm signin` and the rest work as below. The sa
7
7
 
8
8
  | I want to... | Command |
9
9
  |---|---|
10
- | Install / update the framework | `pnpm dlx @asrunjs/asrun init --install`, `pnpm add -D @asrunjs/asrun@latest` |
10
+ | Install the framework in a project | `pnpm dlx @asrunjs/asrun init --install` |
11
+ | Upgrade the framework and migrate the workspace | `pnpm asrun upgrade [--version <v>] [--dry-run]` |
11
12
  | Prepare or check the workspace | `pnpm asrun init [--update]`, `pnpm asrun doctor` |
13
+ | See or apply the workspace migrations after a manual install | `pnpm asrun migrate [--list] [--dry-run]` |
12
14
  | Start over with fresh dependencies | `pnpm asrun reset [--yes] [--state] [--lockfile]` |
13
15
  | Start the web UI | `pnpm go`, `pnpm ui`, `pnpm ui:dev` |
14
16
  | See which profiles are logged in | `pnpm status [--env <env>]` |
@@ -168,19 +170,87 @@ pnpm ui:dev # Vite on :5173 with hot reload, API in watch mode
168
170
  The UI only listens on `127.0.0.1`. Set `ASRUN_UI_PORT` to change the port (`ASRUN_UI_WEB_PORT` for the Vite dev
169
171
  server). See [web-ui.md](./web-ui.md).
170
172
 
173
+ The UI checks the registry for a newer release when it starts and then once a day at 00:00 UTC (the answer is cached in
174
+ `.tmp/`, so the command line shares it). A newer release is printed on the terminal at startup and shows up as a badge
175
+ in the top bar; click it to see the version and the command that upgrades this install. `pnpm asrun upgrade` is skipped
176
+ there (it reads the registry itself). Set `ASRUN_NO_UPDATE_CHECK=1` to switch the check off, for a pipeline or a machine
177
+ without a registry.
178
+
179
+ ## Upgrading the framework
180
+
181
+ ```bash
182
+ pnpm asrun upgrade
183
+ pnpm asrun upgrade --dry-run
184
+ pnpm asrun upgrade --version 0.9.0
185
+ ```
186
+
187
+ `upgrade` runs four steps, in this order:
188
+
189
+ 1. resolves the target release (`latest`, or the dist-tag/version `--version <v>` names) and prints
190
+ `installed -> target`;
191
+ 2. reads the **migration manifest of the target release out of its tarball, before installing anything**, and prints
192
+ the migrations and breaking changes between the workspace's baseline (`.asrun.json` → `migrations.since`) and that
193
+ release. A breaking change needs a person's confirmation (type `upgrade`); an unattended run passes `--yes`. A
194
+ release that ships no manifest (it predates the manifest) cannot be checked in advance: `upgrade` says so and asks
195
+ before it installs;
196
+ 3. installs `@asrunjs/asrun@<target>` (it first exempts a fresh release from pnpm's release age, or pnpm would install
197
+ the previous one);
198
+ 4. runs `init --update`, `migrate` and `doctor` through the package that was just installed.
199
+
200
+ `--dry-run` stops after step 2. `--version` pins a release; `upgrade` never downgrades.
201
+
202
+ Every `asrun` command also checks the registry for a newer release, at most once a day (the answer is cached in `.tmp/`
203
+ and shared with the web UI), and prints one line on stderr before it starts:
204
+ `@asrunjs/asrun 0.8.0 is available (installed 0.7.0). Upgrade: pnpm asrun upgrade`. On a terminal the line carries an
205
+ `UPDATE` badge and highlights the version and the command; a pipe or a log gets the plain sentence. Inside the
206
+ framework's own git checkout the line names `git pull` instead, because there is no installed package to upgrade. A
207
+ failed check is silent. Set `ASRUN_NO_UPDATE_CHECK=1` to switch it off; `NO_COLOR` and `FORCE_COLOR` decide the colors
208
+ as they do everywhere else.
209
+
210
+ ### Migrations
211
+
212
+ A migration is a change an existing workspace must make when the framework moves it forward: a config entry that
213
+ moved, a file whose shape changed, a dependency range that has to follow the peer dependency. Anything `init --update`
214
+ refreshes by itself is not a migration. The registry is `migrations/index.ts` in the framework package, and the state
215
+ lives in `.asrun.json`:
216
+
217
+ ```json
218
+ "migrations": {"since": "0.7.0", "manual": ["0.8.0-rename-..."]}
219
+ ```
220
+
221
+ `since` is the release the workspace matches. It only moves forward when a migration succeeds, so an interrupted run
222
+ can simply be repeated. A migration marked `auto` runs by itself; one with `manual` steps is printed and remembered
223
+ until a person does it and acknowledges it. `init --update` never advances the baseline, which is why a manual
224
+ `pnpm add -D @asrunjs/asrun@latest` followed by `pnpm asrun init --update` still leaves its migrations pending.
225
+
226
+ ```bash
227
+ pnpm asrun migrate # apply what the installed release brings
228
+ pnpm asrun migrate --dry-run # show it, change nothing
229
+ pnpm asrun migrate --list # every migration of the installed release, and its state
230
+ pnpm asrun migrate --ack <id> # a manual step is done (--ack-all for every one)
231
+ pnpm asrun migrate --to <v> # stop at that release
232
+ ```
233
+
234
+ `migrate` never installs anything, and refuses to migrate beyond the installed release: the code that applies a
235
+ migration is the installed package. `doctor` warns while migrations or manual steps are outstanding.
236
+
171
237
  ## Workspace
172
238
 
173
239
  ```bash
174
- pnpm asrun init [--update] [--force] [--force <file>] [--reset-rules] [--root <dir>] [--no-env] [--install [spec]]
240
+ pnpm asrun init [--update] [--force] [--force <file>] [--reset-rules] [--root <dir>] [--no-env] [--no-extras] [--install [spec]]
175
241
  pnpm asrun doctor
242
+ pnpm asrun upgrade [--version <v>] [--dry-run] [--yes]
243
+ pnpm asrun migrate [--list] [--dry-run] [--yes] [--ack <id>|--ack-all]
176
244
  pnpm asrun reset [--root <dir>] [--dry-run] [--yes] [--state] [--lockfile]
177
245
  ```
178
246
 
179
247
  `init` writes the workspace files; `--update` refreshes only the generated ones, and `--force` rewrites every file
180
248
  init writes (a single file with `--force <file>`). `--force` still keeps `profiles.yaml` and `package.json`; name
181
- `--force profiles.yaml` to replace profiles, and add `--reset-rules` to replace `AGENTS.md` or `CLAUDE.md`.
249
+ `--force profiles.yaml` to replace profiles, and add `--reset-rules` to replace `AGENTS.md` or `CLAUDE.md`. It also
250
+ adds the `allowBuilds` entries `pnpm-workspace.yaml` lacks, and installs the optional packages this machine lacks
251
+ (`ffmpeg-static`; `--no-extras` skips them).
182
252
  `doctor` checks profiles, `tsconfig.json`, `playwright.config.ts`, the generated files, the installed version, the
183
- project rule table and the skill overrides.
253
+ project rule table, the skill overrides and the workspace migrations. `upgrade` and `migrate` are described above.
184
254
 
185
255
  `reset` removes `node_modules` and the files `init` writes, for a clean reinstall. It deletes a once-only file only
186
256
  when it is still identical to its template, so edited files stay; `cases/`, specs, baselines, `profiles.yaml`,
package/docs/install.md CHANGED
@@ -8,9 +8,9 @@ and is never edited: a project commits only its own test content.
8
8
 
9
9
  - **Node.js 22 or newer** and **pnpm**.
10
10
  - **ffmpeg** joins the recordings of one test (a new browser tab has its own video) into one video for the report.
11
- `pnpm go` installs it with Homebrew on macOS or winget on Windows; on Linux run `sudo apt install ffmpeg`. When it
12
- is missing, the optional `ffmpeg-static` package is used instead; if neither works, the report lists the videos
13
- separately. Not fatal.
11
+ `pnpm go` installs it with Homebrew on macOS or winget on Windows; on Linux run `sudo apt install ffmpeg`. When it is
12
+ missing, `pnpm go` / `pnpm asrun init` install the bundled `ffmpeg-static` package into the workspace; if neither
13
+ works, the report lists the videos separately. Not fatal.
14
14
  - **Claude Code or OpenCode** for the AI steps (make cases, explore, generate, heal). Everything else works without
15
15
  them; `pnpm go` warns when neither command is found.
16
16
 
@@ -22,11 +22,16 @@ pnpm go
22
22
  ```
23
23
 
24
24
  `init --install` scaffolds the workspace and then adds the dependency. Running `init` first matters on pnpm 12: it
25
- writes `pnpm-workspace.yaml` with the `allowBuilds` entry (`esbuild` through `tsx`, `ffmpeg-static`), and without it
26
- `pnpm add` exits with `ERR_PNPM_IGNORED_BUILDS`. The same file lists `@asrunjs/asrun` under `minimumReleaseAgeExclude`,
27
- so pnpm's 24-hour release age does not hold a fresh release back (see
28
- [troubleshooting.md](./troubleshooting.md#installing)). An existing `pnpm-workspace.yaml` that lacks either entry gets
29
- a note instead: add the block by hand (see "Manual install").
25
+ writes `pnpm-workspace.yaml` with the `allowBuilds` entry that `esbuild` needs, and without it `pnpm add` exits with
26
+ `ERR_PNPM_IGNORED_BUILDS`. The same file lists `@asrunjs/asrun` under `minimumReleaseAgeExclude`, so pnpm's 24-hour
27
+ release age does not hold a fresh release back (see
28
+ [troubleshooting.md](./troubleshooting.md#installing)). A `pnpm-workspace.yaml` that already exists gets the entries
29
+ it lacks: `init` adds them before it installs anything.
30
+
31
+ Optional packages are not part of the framework's dependency graph: a dependency with an install script would make
32
+ `pnpm dlx @asrunjs/asrun` stop at pnpm's build-approval picker, and the dlx sandbox does not read the workspace's
33
+ `allowBuilds`. So `init` installs `ffmpeg-static` into the workspace only when this machine has no ffmpeg, adding its
34
+ `allowBuilds` entry first. `--no-extras` skips that step.
30
35
 
31
36
  `init` also records `@asrunjs/asrun` (pinned to its own version) in the workspace `package.json`, so `pnpm install`
32
37
  installs the framework even without `--install`. When the install step itself fails — `pnpm` is not on `PATH`, the
@@ -37,7 +42,7 @@ registry is unreachable, or a build script is not approved — `init` names the
37
42
 
38
43
  1. checks Node (22+) and ffmpeg, and warns when no AI CLI is installed;
39
44
  2. installs the dependencies and the Chromium browser (`pnpm install` + `playwright install chromium`);
40
- 3. runs `asrun init` (workspace files and `.env`) and `asrun doctor`;
45
+ 3. runs `asrun init` (workspace files, `.env`, and the optional packages this machine lacks) and `asrun doctor`;
41
46
  4. starts the web UI at http://127.0.0.1:4173 and opens it.
42
47
 
43
48
  Run it again on any day: the install step then takes seconds, and if the UI is already running it only opens it.
@@ -57,17 +62,19 @@ pnpm asrun init
57
62
  pnpm go
58
63
  ```
59
64
 
60
- If pnpm refuses the build scripts of `esbuild` / `ffmpeg-static`, or installs the release before `latest`, add this to
65
+ If pnpm refuses the build script of `esbuild`, or installs the release before `latest`, add this to
61
66
  `pnpm-workspace.yaml` and run the install again:
62
67
 
63
68
  ```yaml
64
69
  allowBuilds:
65
70
  esbuild: true
66
- ffmpeg-static: true
67
71
  minimumReleaseAgeExclude:
68
72
  - '@asrunjs/asrun'
69
73
  ```
70
74
 
75
+ `pnpm asrun init` adds the `allowBuilds` entry of an optional package (for example `ffmpeg-static`) itself, right
76
+ before it installs that package, so this block only has to name what the framework's own dependencies need.
77
+
71
78
  To pin a version:
72
79
 
73
80
  ```bash
@@ -85,7 +92,9 @@ it (and `--reset-rules` for the project's rules).
85
92
  | Once | `AGENTS.md`, `CLAUDE.md`, `.mcp.json`, `opencode.jsonc`, `.claude/settings.json`, `tsconfig.json`, `pnpm-workspace.yaml`, `profiles.yaml`, `cases/_template.md`, `package.json` (scripts and devDependencies merged in) | kept |
86
93
 
87
94
  It also writes the asrun block in `.gitignore`, the `ASRUN_ROOT` line in `.env`, and `.asrun.json` (the marker with
88
- the framework version and the generated file list).
95
+ the framework version, the migration baseline and the generated file list). It appends the `allowBuilds` entries an
96
+ existing `pnpm-workspace.yaml` lacks, and installs the optional packages this machine lacks (`ffmpeg-static`, only
97
+ when there is no ffmpeg).
89
98
 
90
99
  Flags:
91
100
 
@@ -97,6 +106,7 @@ Flags:
97
106
  | `--force <path>` | overwrite one file init owns (repeatable) |
98
107
  | `--reset-rules` | allow `--force` on `AGENTS.md`, `CLAUDE.md` or `.claude/skills/**` |
99
108
  | `--no-env` | do not write `.env` |
109
+ | `--no-extras` | do not install the optional packages this machine lacks (`ffmpeg-static`) |
100
110
  | `--install [spec]` | also add the dependency (default `@asrunjs/asrun`) |
101
111
 
102
112
  `AGENTS.md`, `CLAUDE.md` and `.claude/skills/**` belong to the project. `init --force` keeps them unless
@@ -106,15 +116,40 @@ into, so your scripts and dependencies stay.
106
116
 
107
117
  ## Update
108
118
 
119
+ ```bash
120
+ pnpm asrun upgrade
121
+ ```
122
+
123
+ One command installs the newest release, refreshes the generated files to its templates, applies the workspace
124
+ migrations and runs `doctor`. Before it installs anything it reads the migration manifest of the target release and
125
+ prints the breaking changes and migrations between the workspace's baseline and that release; a breaking change needs
126
+ a person's confirmation (type `upgrade`), and an unattended run passes `--yes`. `--dry-run` shows the same plan and
127
+ changes nothing; `--version <v>` pins a release; it never downgrades.
128
+
129
+ `upgrade` arrives with this release, so a workspace that predates it updates once by hand:
130
+
109
131
  ```bash
110
132
  pnpm add -D @asrunjs/asrun@latest
111
133
  pnpm asrun init --update
134
+ pnpm asrun migrate
112
135
  pnpm asrun doctor
113
136
  ```
114
137
 
115
- `init --update` rewrites only the generated files and leaves project rules, case files, values and baselines alone.
116
- `init --force` rewrites every file init writes (see the flags above). `doctor` tells you when the workspace is
117
- behind the installed version.
138
+ After that, `pnpm asrun upgrade` is the command to remember. `init --update` alone only refreshes the generated files:
139
+ it never moves the migration baseline, so a manual install that skipped `migrate` still shows up in `doctor`, and
140
+ `pnpm asrun migrate` fixes it. `init --force` rewrites every file init writes (see the flags above). `doctor` tells
141
+ you when the workspace is behind the installed version, and what is still to migrate.
142
+
143
+ An upgrade never replaces what belongs to the project: `cases/`, `tests/` with their baselines, `profiles.yaml`,
144
+ `AGENTS.md`, `CLAUDE.md`, the skill overrides and `.claude/settings.json` stay as they are. A release that needs a
145
+ project-owned file to change says so as a migration with manual steps.
146
+
147
+ To go back to an older release, install it yourself and refresh:
148
+
149
+ ```bash
150
+ pnpm add -D @asrunjs/asrun@<version>
151
+ pnpm asrun init --update
152
+ ```
118
153
 
119
154
  There is no folder to move: the framework lives in `node_modules`, which the package manager owns. To change the
120
155
  framework itself, `pnpm patch @asrunjs/asrun` (a committed patch).
@@ -127,6 +162,7 @@ Run it after changing profiles, rules or overrides, and after an update. It chec
127
162
  - `tsconfig.json`, `playwright.config.ts` (its re-export of the framework config) and the generated files;
128
163
  - that `.gitignore` ignores `.auth/` and `reports/`;
129
164
  - whether the installed version is behind the package's templates, and whether `dist/` is out of date;
165
+ - the workspace migrations: the baseline in `.asrun.json`, what still applies, and the manual steps left open;
130
166
  - the project rule table in `AGENTS.md` §2 (unknown ids, a missing `Effect`, a non-negotiable rule);
131
167
  - the framework skills and the project overrides under `.claude/skills/<skill>.md`, and lists the ones it sees.
132
168
 
@@ -160,7 +196,7 @@ CLAUDE.md imports AGENTS.md for Claude Code
160
196
  .opencode/commands/ the same commands for OpenCode (it does not read .claude/commands)
161
197
  .mcp.json the playwright-test MCP server for Claude Code
162
198
  opencode.jsonc the same for OpenCode, plus an optional model
163
- .asrun.json marker: framework version and the generated file list
199
+ .asrun.json marker: framework version, migration baseline and the generated file list
164
200
  package.json the workspace scripts (`asrun`, `signin`, ...) and devDependencies
165
201
  .env ASRUN_ROOT (which workspace the commands use) and local settings
166
202
  node_modules/@asrunjs/asrun/ the framework (installed dependency; never edited)
@@ -257,6 +257,29 @@
257
257
  .side .item { padding: 8px 16px 8px 12px; border-bottom: 1px solid var(--line); border-left: 4px solid transparent; font-family: var(--mono); font-size: 14px; }
258
258
  .side .item.selected { background: var(--theme-bg); border-left-color: var(--theme); }
259
259
 
260
+ /* Filter bar (mirror components/filter-chips.tsx and components/search-field.tsx). */
261
+ .filters { display: flex; flex-wrap: wrap; align-items: center; gap: 4px; }
262
+ .filters .group-label { margin-right: 2px; font-size: 12px; color: var(--ink-3); }
263
+ .filter-chip {
264
+ display: inline-flex; align-items: center; gap: 4px; height: 24px; padding: 0 6px; border-radius: 2px;
265
+ background: var(--card); color: var(--ink-2); font-size: 12px; box-shadow: inset 0 0 0 1px var(--line-mid);
266
+ }
267
+ .filter-chip .count { color: var(--ink-3); }
268
+ .filter-chip.on {
269
+ background: var(--theme-btn); color: #fff; font-weight: 500;
270
+ box-shadow: inset 0 0 0 1px var(--theme-btn);
271
+ }
272
+ .filter-chip.on .count { color: rgb(255 255 255 / .75); }
273
+ .filter-chip.on.production { background: var(--jiang); box-shadow: inset 0 0 0 1px var(--jiang); }
274
+ .filter-chip.on.staging { background: var(--cheng); box-shadow: inset 0 0 0 1px var(--cheng); }
275
+ .filter-chip.on.other { background: var(--gold-deep); box-shadow: inset 0 0 0 1px var(--gold-deep); }
276
+ .filter-search { position: relative; width: 260px; max-width: 100%; }
277
+ .filter-search svg { position: absolute; left: 8px; top: 50%; transform: translateY(-50%); color: var(--ink-3); }
278
+ .filter-search .fake {
279
+ display: flex; align-items: center; height: 32px; padding: 0 8px 0 28px; border: 1px solid var(--line-mid);
280
+ border-radius: 4px; background: #fff; color: var(--ink-3); font-size: 14px;
281
+ }
282
+
260
283
  .seal-stamp {
261
284
  display: inline-flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: 3px;
262
285
  background: var(--seal); color: var(--paper); font-family: var(--serif); font-weight: 400; font-size: 16px; line-height: 1;
@@ -302,6 +325,7 @@
302
325
  <a href="#badges">Badges</a>
303
326
  <a href="#action-colors">Action colors</a>
304
327
  <a href="#dropdowns">Dropdowns</a>
328
+ <a href="#filter-bars">Filter bars</a>
305
329
  <a href="#multi-value">Multi-value fields</a>
306
330
  <a href="#icons">Icons</a>
307
331
  <a href="#tooltips">Tooltips</a>
@@ -503,6 +527,7 @@
503
527
  <tr><td><code>info</code> (light blue)</td><td>A view toggle that must be easy to find, not an action on data: "Full screen" / "Restore" in the Jobs drawer bar. Blue = read-only.</td></tr>
504
528
  <tr><td><code>secondary</code> (white)</td><td>Everything else: Verify, Visit, Discard, Copy, navigation.</td></tr>
505
529
  <tr><td><code>danger</code> (white, red text)</td><td>Cancel a running job, destructive confirmations.</td></tr>
530
+ <tr><td><code>invalid</code> (red field)</td><td>A control that is required and still empty, drawn as a button because it opens a panel instead of a native list (the environment picker of the case toolbar). The same red as an invalid dropdown; it is not an action.</td></tr>
506
531
  </tbody>
507
532
  </table>
508
533
  <p class="small muted">Hover lifts the button 1px; disabled is 50% opacity with no shadow. With an icon, add <code>gap-1.5</code> to the button.</p>
@@ -522,7 +547,7 @@
522
547
  <tbody>
523
548
  <tr><td><span class="badge green">green</span></td><td>ok: Logged in</td><td>Succeeded</td></tr>
524
549
  <tr><td><span class="badge red">red</span></td><td>expired, missing</td><td>Failed</td></tr>
525
- <tr><td><span class="badge amber">amber</span></td><td>unverified, error</td><td>Needs your input (an AI run that ended with <code>RESULT: needs-input</code>)</td></tr>
550
+ <tr><td><span class="badge amber">amber</span></td><td>unverified, error</td><td>Needs your input (an AI run that ended with <code>RESULT: needs-input</code>) or No result (a run that printed no <code>RESULT:</code> line at all)</td></tr>
526
551
  <tr><td><span class="badge blue">blue</span></td><td>-</td><td>Running</td></tr>
527
552
  <tr><td><span class="badge gray">gray</span></td><td>-</td><td>Cancelled</td></tr>
528
553
  </tbody>
@@ -584,7 +609,7 @@
584
609
  <tbody>
585
610
  <tr><td><code>explore-case</code></td><td>blue (qing)</td><td>Edits only the case file (wording, observations, open questions, inputs). The agent only reads pages; you do the steps that write data.</td></tr>
586
611
  <tr><td><code>gen-spec</code></td><td>amber (gold)</td><td>Writes: creates the spec and fills the case's <code>## Values</code>. Does not run the tests.</td></tr>
587
- <tr><td><code>heal-spec</code></td><td>red (seal)</td><td>Rewrites an existing spec and runs the tests.</td></tr>
612
+ <tr><td><code>heal-spec</code></td><td>red (seal)</td><td>Repairs the spec or refines the case and the spec, then runs the tests.</td></tr>
588
613
  </tbody>
589
614
  </table>
590
615
  <p class="small muted">
@@ -600,6 +625,14 @@
600
625
  <code>Select</code> is a button that draws its own list under it, in a <code>card-popup</code> panel, which is what
601
626
  gives every dropdown the same raised look.
602
627
  </p>
628
+ <p class="small muted">
629
+ Two controls put their label inside the button and open a panel with a title rather than a bare list: the
630
+ environment picker of the case toolbar (<code>Env: staging</code>, <code>components/env-picker.tsx</code>) and the
631
+ profile picker beside it (<code>Profiles: 2 of 3</code>, <code>components/profile-picker.tsx</code>). They are
632
+ buttons because their list needs a title and a line of explanation, not a <code>Select</code>; they keep this
633
+ section's height, <code>card-popup</code> panel, keyboard and click-outside rules. Do not turn them back into a
634
+ <code>Select</code> that repeats the label on every row.
635
+ </p>
603
636
  <div class="demo">
604
637
  <span class="select"><span class="fake">staging</span><svg class="chev" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg></span>
605
638
  <span class="select focus"><span class="fake">staging</span><svg class="chev" xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"></polyline></svg></span>
@@ -654,6 +687,60 @@
654
687
  </tbody>
655
688
  </table>
656
689
 
690
+ <h3 id="filter-bars">Filter bars</h3>
691
+ <p>
692
+ A list that can get long (the profiles of the workspace, the profiles of a case's Values table) gets a filter bar
693
+ above it: <code>SearchField</code> and <code>FilterChips</code> from <code>ui/web/src/components/</code>. They are
694
+ controls only; the page decides what a value means and filters its own rows.
695
+ </p>
696
+ <div class="demo" style="display:block">
697
+ <div class="filter-search" style="margin-bottom:8px">
698
+ <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"></circle><line x1="21" y1="21" x2="16.65" y2="16.65"></line></svg>
699
+ <span class="fake">atpil</span>
700
+ </div>
701
+ <div style="display:flex;flex-wrap:wrap;gap:8px 16px">
702
+ <div class="filters">
703
+ <span class="group-label">Env</span>
704
+ <span class="filter-chip">All <span class="count">17</span></span>
705
+ <span class="filter-chip on production">PRODUCTION <span class="count">7</span></span>
706
+ <span class="filter-chip on staging">STAGING <span class="count">8</span></span>
707
+ <span class="filter-chip">SANDBOX <span class="count">2</span></span>
708
+ </div>
709
+ <div class="filters">
710
+ <span class="group-label">Tag</span>
711
+ <span class="filter-chip">All <span class="count">17</span></span>
712
+ <span class="filter-chip on">atp <span class="count">6</span></span>
713
+ <span class="filter-chip">compass <span class="count">4</span></span>
714
+ <span class="filter-chip">corp <span class="count">4</span></span>
715
+ </div>
716
+ </div>
717
+ <p class="small muted" style="margin:8px 0 0">Showing 6 of 17 profiles · <span style="text-decoration:underline">Clear filters</span></p>
718
+ </div>
719
+ <p class="small muted">
720
+ Search "atpil" with the dashes left out, both environments pressed and one tag: the two groups are combined with
721
+ AND.
722
+ </p>
723
+ <pre class="card mono" style="overflow:auto">import {SearchField} from '../components/search-field';
724
+ import {FilterChips} from '../components/filter-chips';
725
+
726
+ &lt;SearchField value={query} onChange={setQuery} ariaLabel="Search profiles" placeholder="Search id, name..." /&gt;
727
+ &lt;FilterChips label="Env" selected={envFilter} onChange={setEnvFilter} allCount={profiles.length}
728
+ options={envs.map((env) =&gt; ({value: env, label: env.toUpperCase(), count: countOf(env), tone: envFillClass(env)}))} /&gt;</pre>
729
+ <table>
730
+ <thead><tr><th>Part</th><th>Rule</th></tr></thead>
731
+ <tbody>
732
+ <tr><td>Search</td><td>One <code>SearchField</code> at the top. It looks in id, name, account, environment, tags and address; every word of the query must match one of them. A compact copy without <code>-</code>, <code>_</code>, <code>.</code> or spaces lets <code>atpil</code> find <code>atp-il-staging</code>.</td></tr>
733
+ <tr><td>Groups</td><td>One <code>FilterChips</code> per kind of value (environment, tag). Inside a group a row matches <b>any</b> pressed value; the groups are combined with <b>AND</b>. An empty group filters nothing.</td></tr>
734
+ <tr><td>All</td><td>The first chip of a group clears that group only. It is pressed while the group has no selection, so "no filter" can always be told from "everything selected".</td></tr>
735
+ <tr><td>Counts</td><td>Each option shows how many rows it would leave, and <code>All</code> shows the group's total. Counts come from the unfiltered list, so they do not dance while a value is pressed.</td></tr>
736
+ <tr><td>Pressed look</td><td>Neutral chips take the page accent (<code>theme-btn</code>, white text). Environment chips pass <code>envFillClass(env)</code>, the same solid marker as <code>EnvBadge</code>, so color means the environment here too.</td></tr>
737
+ <tr><td>State</td><td>The chip is the button: <code>aria-pressed</code>, a tooltip with its name (and "pressed" while on), and the count as a second, quieter part of the label.</td></tr>
738
+ <tr><td>Feedback</td><td>While a filter is active, show <code>Showing N of M</code> and <b>Clear filters</b>. When nothing matches, the empty state repeats the clear action instead of leaving a blank table.</td></tr>
739
+ <tr><td>Not remembered</td><td>A filter is a way of looking, not a setting: do not put it in <code>localStorage</code>. A reload shows everything, so nothing looks missing after lunch.</td></tr>
740
+ <tr><td>In the case panel</td><td>The Values table adds a two-button <b>Rows: Keys / Profiles</b> switch beside the search as soon as a case names two profiles, and shows the search and the environment chips only when it names more than three. The filters and the switch belong to that case; opening another case starts unfiltered.</td></tr>
741
+ </tbody>
742
+ </table>
743
+
657
744
  <h2 id="multi-value">Multi-value fields</h2>
658
745
  <p>
659
746
  A field whose value is a list is never a <a href="#dropdowns">Dropdown</a> plus an "Add" text input: that mixes a
@@ -829,11 +916,12 @@
829
916
  </p>
830
917
  <p class="muted">
831
918
  The preview block that holds the editor caret is shaded with <code>theme-bg</code> at 60% and a 2px <code>theme</code> left
832
- border (an overlay, so it follows the page theme). The editor and the preview scroll together and a click in the preview
833
- moves the caret; the logic is in <code>ui/web/src/lib/preview-sync.ts</code>.
919
+ border (an overlay, so it follows the page theme). The overlay paints <b>behind</b> the text, never over it: the
920
+ highlighted block keeps its own text color instead of looking washed out. The editor and the preview scroll together
921
+ and a click in the preview moves the caret; the logic is in <code>ui/web/src/lib/preview-sync.ts</code>.
834
922
  </p>
835
923
  <p class="muted">
836
- The previous / next buttons of the case toolbar move the caret to the next marker and blink that marker for a moment in
924
+ The previous / next buttons at the right end of the editor toolbar move the caret to the next marker and blink that marker for a moment in
837
925
  both panes (<code>cm-kw-flash</code> in the editor, <code>kw-flash</code> over the <code>&lt;mark&gt;</code> of the
838
926
  document): <code>gold-glow</code> at 65% behind it and a 2px <code>dai-deep</code> ring with a wider
839
927
  <code>gold-glow</code> glow around it, the ring blinking on and off three times (0.45&nbsp;s each), then it goes away.