@cat-factory/app 0.213.1 → 0.214.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 (47) hide show
  1. package/README.md +107 -13
  2. package/app/components/observability/StepMetricsBar.vue +11 -0
  3. package/app/components/panels/AgentStepDetail.vue +14 -0
  4. package/app/components/panels/MergerResultView.vue +20 -2
  5. package/app/components/panels/ObservabilityPanel.vue +57 -0
  6. package/app/components/panels/ResultWindowShell.vue +77 -0
  7. package/app/components/panels/StepReproductionReport.vue +167 -0
  8. package/app/components/tutorial/TutorialCatalogue.vue +14 -1
  9. package/app/components/tutorial/TutorialNudge.vue +107 -0
  10. package/app/components/tutorial/TutorialOverlay.vue +92 -11
  11. package/app/composables/api/execution.ts +5 -2
  12. package/app/composables/api/tutorial.ts +25 -0
  13. package/app/composables/useApi.ts +2 -0
  14. package/app/composables/usePipelineErrorToast.ts +4 -0
  15. package/app/composables/useTutorialNudge.ts +77 -0
  16. package/app/composables/useTutorialSync.ts +141 -0
  17. package/app/modular/external-tools.spec.ts +1 -0
  18. package/app/modular/nav-contributions.spec.ts +2 -0
  19. package/app/modular/nav-contributions.ts +11 -0
  20. package/app/modular/nav-gates.ts +10 -0
  21. package/app/modular/registry.spec.ts +1 -0
  22. package/app/modular/tutorial-tours.spec.ts +55 -4
  23. package/app/modular/tutorial-tours.ts +231 -9
  24. package/app/pages/index.vue +20 -1
  25. package/app/stores/tutorial.prompt.ts +59 -0
  26. package/app/stores/tutorial.record.ts +191 -0
  27. package/app/stores/tutorial.spec.ts +207 -0
  28. package/app/stores/tutorial.ts +78 -91
  29. package/app/stores/workspace/hydrate.ts +5 -0
  30. package/app/types/domain.ts +3 -0
  31. package/app/types/reproduction.ts +11 -0
  32. package/app/utils/observability.spec.ts +44 -1
  33. package/app/utils/observability.ts +50 -0
  34. package/app/utils/reproduction.ts +51 -0
  35. package/app/utils/tutorial.spec.ts +255 -0
  36. package/app/utils/tutorial.ts +173 -0
  37. package/i18n/locales/de.json +147 -6
  38. package/i18n/locales/en.json +151 -6
  39. package/i18n/locales/es.json +147 -6
  40. package/i18n/locales/fr.json +147 -6
  41. package/i18n/locales/he.json +147 -6
  42. package/i18n/locales/it.json +147 -6
  43. package/i18n/locales/ja.json +147 -6
  44. package/i18n/locales/pl.json +147 -6
  45. package/i18n/locales/tr.json +147 -6
  46. package/i18n/locales/uk.json +147 -6
  47. package/package.json +2 -2
@@ -0,0 +1,51 @@
1
+ import type { ReproductionStatus } from '~/types/reproduction'
2
+
3
+ // Presentation for the BUGFIX REPRODUCTION PROOF verdict.
4
+ //
5
+ // The lookup is by a wire value, so it lives here as an EXHAUSTIVE `Record` keyed off the
6
+ // contracts union rather than as a t() call over a key assembled at the call site. The typed-key
7
+ // check cannot see a runtime-assembled key, so a fourth verdict added to the union would ship as a
8
+ // blank chip on exactly the surface whose job is to say what was and was not proven; keyed this
9
+ // way it fails to compile until the copy exists.
10
+
11
+ export interface ReproductionStatusPresentation {
12
+ /** Short chip copy: the verdict as one or two words. */
13
+ chip: string
14
+ /** One sentence a reviewer decides on. */
15
+ verdict: string
16
+ icon: string
17
+ /** Whether this verdict IS proof — the only one a collapsed row states completely. */
18
+ proven: boolean
19
+ }
20
+
21
+ export const REPRODUCTION_STATUS_KEYS: Record<ReproductionStatus, ReproductionStatusPresentation> =
22
+ {
23
+ reproduced: {
24
+ chip: 'panels.stepDetail.reproduction.status.reproduced',
25
+ verdict: 'panels.stepDetail.reproduction.verdict.reproduced',
26
+ icon: 'i-lucide-bug-off',
27
+ proven: true,
28
+ },
29
+ inconclusive: {
30
+ chip: 'panels.stepDetail.reproduction.status.inconclusive',
31
+ verdict: 'panels.stepDetail.reproduction.verdict.inconclusive',
32
+ icon: 'i-lucide-bug',
33
+ proven: false,
34
+ },
35
+ declared_infeasible: {
36
+ chip: 'panels.stepDetail.reproduction.status.declared_infeasible',
37
+ verdict: 'panels.stepDetail.reproduction.verdict.declared_infeasible',
38
+ icon: 'i-lucide-clipboard-list',
39
+ proven: false,
40
+ },
41
+ }
42
+
43
+ /**
44
+ * The two checkouts the same command was run against. A literal pair rather than a lookup: it is
45
+ * the feature's own vocabulary (the tree BEFORE the change and the tree after it), not a wire
46
+ * union that can grow.
47
+ */
48
+ export const REPRODUCTION_TREE_KEYS = {
49
+ base: 'panels.stepDetail.reproduction.tree.base',
50
+ final: 'panels.stepDetail.reproduction.tree.final',
51
+ } as const
@@ -1,10 +1,15 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import en from '../../i18n/locales/en.json'
3
3
  import {
4
+ boardStateFingerprint,
4
5
  computeCoachMarkLayout,
5
6
  isLaunchOffer,
6
7
  launchActionFor,
7
8
  needsReveal,
9
+ newlyAvailableTour,
10
+ nextTourAfter,
11
+ readyTourIds,
12
+ resolveNudge,
8
13
  resolveTourCatalogue,
9
14
  resolveTours,
10
15
  sortTours,
@@ -162,6 +167,256 @@ describe('resolveTourCatalogue', () => {
162
167
  })
163
168
  })
164
169
 
170
+ describe('nextTourAfter', () => {
171
+ const ready = (...ids: string[]) => ids.map((id, index) => tour(id, (index + 1) * 10))
172
+ const none = () => false
173
+
174
+ it('offers the next unfinished tour after the one just completed', () => {
175
+ const next = nextTourAfter(ready('a', 'b', 'c'), { justFinishedId: 'a', isCompleted: none })
176
+ expect(next?.id).toBe('b')
177
+ })
178
+
179
+ it('never offers the tour just finished, even before it is recorded as complete', () => {
180
+ // The handoff renders on the finish card, BEFORE Done writes the completion, so
181
+ // `isCompleted` still says no about the tour the user is looking at.
182
+ const next = nextTourAfter(ready('a', 'b'), { justFinishedId: 'b', isCompleted: none })
183
+ expect(next?.id).toBe('a')
184
+ })
185
+
186
+ it('skips tours already completed', () => {
187
+ const next = nextTourAfter(ready('a', 'b', 'c'), {
188
+ justFinishedId: 'a',
189
+ isCompleted: (id) => id === 'b',
190
+ })
191
+ expect(next?.id).toBe('c')
192
+ })
193
+
194
+ it('prefers the launch-offer arc over a lower-ordered catalogue-only tour', () => {
195
+ // The rule ordering alone would get wrong. A deployment's reference tour at order 1 must
196
+ // not cut into the delivery loop, which is the chain the handoff exists to keep moving.
197
+ const shelf = { ...tour('shelf', 1), offeredAtLaunch: false as const }
198
+ const next = nextTourAfter([shelf, tour('loop', 50)], {
199
+ justFinishedId: 'x',
200
+ isCompleted: none,
201
+ })
202
+ expect(next?.id).toBe('loop')
203
+ })
204
+
205
+ it('falls back to a catalogue-only tour once the arc is exhausted', () => {
206
+ // Finishing the delivery loop is exactly when the platform half becomes the right thing
207
+ // to point at, so the preference is an ordering, not a filter.
208
+ const shelf = { ...tour('shelf', 60), offeredAtLaunch: false as const }
209
+ const next = nextTourAfter([shelf, tour('loop', 10)], {
210
+ justFinishedId: 'loop',
211
+ isCompleted: none,
212
+ })
213
+ expect(next?.id).toBe('shelf')
214
+ })
215
+
216
+ it('offers nothing when every other tour is done', () => {
217
+ // Absence is a legitimate answer here, unlike in the catalogue: this is an offer, and the
218
+ // finish card keeps its plain Done.
219
+ expect(nextTourAfter(ready('a', 'b'), { justFinishedId: 'a', isCompleted: () => true })).toBe(
220
+ null,
221
+ )
222
+ expect(nextTourAfter([], { justFinishedId: 'a', isCompleted: none })).toBe(null)
223
+ })
224
+ })
225
+
226
+ describe('newlyAvailableTour', () => {
227
+ const entries = (...tours: TutorialTour[]) => resolveTourCatalogue(tours, gates(true))
228
+ const open = { declined: false, isCompleted: () => false, wasNudged: () => false }
229
+
230
+ it('offers a tour that has just become ready', () => {
231
+ const catalogue = entries(withSteps('a', [step('one')]), withSteps('b', [step('one')]))
232
+ const offer = newlyAvailableTour({
233
+ catalogue,
234
+ previouslyReady: new Set(['a']),
235
+ ...open,
236
+ })
237
+ expect(offer?.id).toBe('b')
238
+ })
239
+
240
+ it('says nothing about a tour that was already ready', () => {
241
+ // The transition rule. Fired on the standing state this would greet every board load with
242
+ // an offer about a walkthrough that has been available for weeks.
243
+ const catalogue = entries(withSteps('a', [step('one')]))
244
+ expect(newlyAvailableTour({ catalogue, previouslyReady: new Set(['a']), ...open })).toBe(null)
245
+ })
246
+
247
+ it('ignores a tour that is still blocked', () => {
248
+ const catalogue = resolveTourCatalogue(
249
+ [withSteps('a', [step('one')], [needsAdvanced])],
250
+ gates(false),
251
+ )
252
+ expect(newlyAvailableTour({ catalogue, previouslyReady: new Set(), ...open })).toBe(null)
253
+ })
254
+
255
+ it('leaves the catalogue-only half alone', () => {
256
+ // `offeredAtLaunch: false` declares a tour as reference material someone comes and gets;
257
+ // interrupting them with it is the thing that declaration rules out.
258
+ const shelf = { ...withSteps('shelf', [step('one')]), offeredAtLaunch: false as const }
259
+ expect(
260
+ newlyAvailableTour({ catalogue: entries(shelf), previouslyReady: new Set(), ...open }),
261
+ ).toBe(null)
262
+ })
263
+
264
+ it('never re-offers a tour already offered or already completed', () => {
265
+ const catalogue = entries(withSteps('a', [step('one')]))
266
+ expect(
267
+ newlyAvailableTour({
268
+ catalogue,
269
+ previouslyReady: new Set(),
270
+ ...open,
271
+ wasNudged: (id) => id === 'a',
272
+ }),
273
+ ).toBe(null)
274
+ expect(
275
+ newlyAvailableTour({
276
+ catalogue,
277
+ previouslyReady: new Set(),
278
+ ...open,
279
+ isCompleted: (id) => id === 'a',
280
+ }),
281
+ ).toBe(null)
282
+ })
283
+
284
+ it('says nothing at all to a user who declined', () => {
285
+ // "No thanks" answered the question about guided tours, not about when it was asked.
286
+ const catalogue = entries(withSteps('a', [step('one')]))
287
+ expect(
288
+ newlyAvailableTour({ catalogue, previouslyReady: new Set(), ...open, declined: true }),
289
+ ).toBe(null)
290
+ })
291
+
292
+ it('offers the lowest-ordered tour when several become ready at once', () => {
293
+ const catalogue = entries(
294
+ { ...withSteps('later', [step('one')]), order: 50 },
295
+ { ...withSteps('earlier', [step('one')]), order: 20 },
296
+ )
297
+ const offer = newlyAvailableTour({ catalogue, previouslyReady: new Set(), ...open })
298
+ expect(offer?.id).toBe('earlier')
299
+ })
300
+ })
301
+
302
+ describe('boardStateFingerprint', () => {
303
+ /** Only the fields the fingerprint reads, plus the two it must ignore. */
304
+ const worldGates = (over: Partial<NavGates> = {}) =>
305
+ ({
306
+ boardHasService: true,
307
+ boardHasTask: true,
308
+ boardHasRun: true,
309
+ boardHasOpenDecision: false,
310
+ boardHasPendingApproval: false,
311
+ boardHasFinishedRun: false,
312
+ boardHasFailedRun: false,
313
+ githubAvailable: true,
314
+ canWriteBoard: true,
315
+ ...over,
316
+ }) as NavGates
317
+
318
+ it('changes when the world moves', () => {
319
+ expect(boardStateFingerprint(worldGates({ boardHasOpenDecision: true }))).not.toBe(
320
+ boardStateFingerprint(worldGates()),
321
+ )
322
+ })
323
+
324
+ it('does NOT change when a permission or a capability resolves', () => {
325
+ // The distinction the contextual offer turns on. A run parking is something that HAPPENED; a
326
+ // probe answering is the app finding out about itself, and only the first is a moment to
327
+ // interrupt someone about. Every gate here loads asynchronously, so without this the app's own
328
+ // startup is indistinguishable from the user having done something.
329
+ expect(
330
+ boardStateFingerprint(worldGates({ githubAvailable: false, canWriteBoard: false })),
331
+ ).toBe(boardStateFingerprint(worldGates()))
332
+ })
333
+ })
334
+
335
+ describe('resolveNudge', () => {
336
+ const entries = (...tours: TutorialTour[]) => resolveTourCatalogue(tours, gates(true))
337
+ const open = { declined: false, isCompleted: () => false, wasNudged: () => false }
338
+ const catalogue = entries(withSteps('a', [step('one')]), withSteps('b', [step('one')]))
339
+ const AFTER = 'world-moved'
340
+ const held = (...ids: string[]) => ({ ready: new Set(ids), boardState: 'world-before' })
341
+
342
+ it('seeds nothing and offers nothing before the board is up', () => {
343
+ // Every gate reads a store something fills asynchronously, so BEFORE the snapshot lands
344
+ // nothing is ready — and a baseline taken then makes the app's own startup a transition.
345
+ expect(
346
+ resolveNudge({ boardReady: false, catalogue, boardState: AFTER, previous: null, ...open }),
347
+ ).toEqual({ baseline: null, offer: null })
348
+ })
349
+
350
+ it('seeds the standing state on the first resolution once the board is up, silently', () => {
351
+ const { baseline, offer } = resolveNudge({
352
+ boardReady: true,
353
+ catalogue,
354
+ boardState: AFTER,
355
+ previous: null,
356
+ ...open,
357
+ })
358
+ expect([...(baseline?.ready ?? [])]).toEqual(['a', 'b'])
359
+ expect(baseline?.boardState).toBe(AFTER)
360
+ expect(offer).toBeNull()
361
+ })
362
+
363
+ it('offers what crossed into ready when the world also moved', () => {
364
+ const { baseline, offer } = resolveNudge({
365
+ boardReady: true,
366
+ catalogue,
367
+ boardState: AFTER,
368
+ previous: held('a'),
369
+ ...open,
370
+ })
371
+ expect(offer?.id).toBe('b')
372
+ // Advanced even though it produced an offer, so a tour flickering ready → blocked → ready
373
+ // (which live run gates do) cannot re-offer itself.
374
+ expect([...(baseline?.ready ?? [])]).toEqual(['a', 'b'])
375
+ })
376
+
377
+ it('says NOTHING when readiness widened but the world did not move', () => {
378
+ // The failure this guard exists for, and the general form of it: a permission resolving or a
379
+ // capability probe answering makes tours takeable that were only "blocked" because the app had
380
+ // not found out yet. Offered there, the mechanism greets every board load — which is the thing
381
+ // it was built to replace. The baseline still advances, so the widening is absorbed silently.
382
+ const { baseline, offer } = resolveNudge({
383
+ boardReady: true,
384
+ catalogue,
385
+ boardState: 'world-before',
386
+ previous: held('a'),
387
+ ...open,
388
+ })
389
+ expect(offer).toBeNull()
390
+ expect([...(baseline?.ready ?? [])]).toEqual(['a', 'b'])
391
+ })
392
+
393
+ it('DISCARDS the baseline when the board goes away, so the next one re-seeds', () => {
394
+ // A board switch re-inits the workspace store, and the incoming board legitimately satisfies a
395
+ // different set of tours. Carrying the old baseline across would report every one of them as
396
+ // having just become takeable.
397
+ expect(
398
+ resolveNudge({
399
+ boardReady: false,
400
+ catalogue,
401
+ boardState: AFTER,
402
+ previous: held('a'),
403
+ ...open,
404
+ }).baseline,
405
+ ).toBeNull()
406
+ })
407
+ })
408
+
409
+ describe('readyTourIds', () => {
410
+ it('is exactly the ids resolveTours would return', () => {
411
+ const tours = [
412
+ withSteps('ready', [step('one')]),
413
+ withSteps('blocked', [step('one')], [needsAdvanced]),
414
+ ]
415
+ const catalogue = resolveTourCatalogue(tours, gates(false))
416
+ expect([...readyTourIds(catalogue)]).toEqual(resolveTours(tours, gates(false)).map((t) => t.id))
417
+ })
418
+ })
419
+
165
420
  describe('tourState / launchActionFor', () => {
166
421
  const state = (over: Partial<Parameters<typeof tourState>[0]>) =>
167
422
  tourState({ active: false, resumable: false, completed: false, ...over })
@@ -286,6 +286,179 @@ export function isLaunchOffer(tour: TutorialTour): boolean {
286
286
  return tour.offeredAtLaunch !== false
287
287
  }
288
288
 
289
+ /** The ids a catalogue resolution says can be started right now. */
290
+ export function readyTourIds(catalogue: readonly TutorialCatalogueEntry[]): Set<string> {
291
+ return new Set(
292
+ catalogue.filter((entry) => entry.availability === 'ready').map((entry) => entry.tour.id),
293
+ )
294
+ }
295
+
296
+ /**
297
+ * The tour that just became takeable, for the contextual offer, or null when nothing did.
298
+ *
299
+ * The catalogue made every walkthrough reachable; this is what reaches the user who needs one
300
+ * WITHOUT going looking. The trigger is deliberately not a per-surface hook ("when a run parks,
301
+ * mention `answer-park`"): every tour already declares, as its `requires`, the exact predicate
302
+ * that means "you can take this now". So one rule over the resolved catalogue covers the whole
303
+ * catalog and inherits `navRequirementDrift` unchanged, where a hand-wired trigger per surface
304
+ * would be a second copy of each requirement to keep in step.
305
+ *
306
+ * Three rules, and the first is the one that is easy to get wrong:
307
+ *
308
+ * - It fires on a TRANSITION into `ready`, never on the standing state, which is why the caller
309
+ * must SEED `previouslyReady` from a resolution taken once the board is up AND may only offer
310
+ * where the world itself moved (see {@link resolveNudge}, which owns both halves). Fired on
311
+ * the standing state it would nudge about everything already available on every board load,
312
+ * which is the launch prompt with none of its manners. The transition rule also means the
313
+ * permission-gated platform tours (ready from the first render on any board) naturally never
314
+ * reach it, and only the board-state tours — a run parked, a run failed, a PR ready to
315
+ * merge — can.
316
+ * - Only the launch-offer arc, for the reason `offeredAtLaunch` exists: a tour declared as
317
+ * reference material someone comes and gets is not one to interrupt them with. Reusing that
318
+ * declaration rather than inventing a second opt-out keeps a consumer deployment's tour
319
+ * behaving here exactly as it does in the prompt.
320
+ * - Never twice for the same tour (`wasNudged`) and never one already completed. A contextual
321
+ * offer that returns is a nag, and this one is unusually well placed to become one: the gates
322
+ * it reads flip several times per run.
323
+ *
324
+ * And nothing at all for a user who DECLINED. "No thanks" was an answer about guided tours, not
325
+ * about the startup timing of the question, so a mechanism that goes on offering them anyway is
326
+ * overriding the one explicit preference this feature collects. They keep the catalogue, which is
327
+ * where someone who changed their mind goes; `resetProgress` is the way back to being asked.
328
+ */
329
+ export function newlyAvailableTour(input: {
330
+ catalogue: readonly TutorialCatalogueEntry[]
331
+ previouslyReady: ReadonlySet<string>
332
+ declined: boolean
333
+ isCompleted: (tourId: string) => boolean
334
+ wasNudged: (tourId: string) => boolean
335
+ }): TutorialTour | null {
336
+ if (input.declined) return null
337
+ const candidate = input.catalogue.find(
338
+ (entry) =>
339
+ entry.availability === 'ready' &&
340
+ isLaunchOffer(entry.tour) &&
341
+ !input.previouslyReady.has(entry.tour.id) &&
342
+ !input.isCompleted(entry.tour.id) &&
343
+ !input.wasNudged(entry.tour.id),
344
+ )
345
+ return candidate?.tour ?? null
346
+ }
347
+
348
+ /**
349
+ * The gates that describe the WORLD: facts about this board that a person or a run changed.
350
+ *
351
+ * Everything else `NavGates` carries is a fact about the DEPLOYMENT or the VIEWER (a permission, a
352
+ * wired integration, the interface tier), and the difference is what {@link resolveNudge} turns on.
353
+ */
354
+ const BOARD_STATE_GATES = [
355
+ 'boardHasService',
356
+ 'boardHasTask',
357
+ 'boardHasRun',
358
+ 'boardHasOpenDecision',
359
+ 'boardHasPendingApproval',
360
+ 'boardHasFinishedRun',
361
+ 'boardHasFailedRun',
362
+ ] as const satisfies readonly (keyof NavGates)[]
363
+
364
+ /**
365
+ * A comparable stamp of the board-state gates, so "did the world move" is one `!==`.
366
+ *
367
+ * A string of bits rather than a Set of what is true: the question is only whether this differs
368
+ * from the last look, and a stamp makes that answerable without caring which bit moved.
369
+ */
370
+ export function boardStateFingerprint(gates: NavGates): string {
371
+ return BOARD_STATE_GATES.map((gate) => (gates[gate] ? '1' : '0')).join('')
372
+ }
373
+
374
+ /**
375
+ * One step of the contextual offer's state machine: the baseline to carry forward, and the tour
376
+ * to offer (if any) from this resolution of the catalogue.
377
+ *
378
+ * Pure, and separate from {@link newlyAvailableTour}, because the BASELINE is the part that was
379
+ * wrong when this shipped and the part a reactive wrapper cannot be trusted with. A transition
380
+ * rule is only as good as what it is measured against, and this one was measured against an app
381
+ * that had not finished starting: every gate reads a store something fills asynchronously, so a
382
+ * baseline taken too early records "nothing is takeable" and the app's own startup then reads as a
383
+ * transition. That turns the mechanism into exactly the every-board-load greeting it exists to
384
+ * prevent ("here is a new walkthrough", about a run that finished a fortnight ago).
385
+ *
386
+ * Two guards, because the first alone was not enough and the second is the general form of why:
387
+ *
388
+ * - `boardReady` gates any baseline at all on the workspace snapshot having been fanned out, so
389
+ * board state is in place before anything is recorded as the standing state. It also DISCARDS
390
+ * the baseline when the board goes away, so switching boards re-seeds against the new one
391
+ * rather than reporting everything it happens to satisfy as having just changed.
392
+ * - `boardState` (a {@link boardStateFingerprint}) is what an offer actually requires to have
393
+ * MOVED. Readiness widening is not the same as the world changing: a permission resolving, or
394
+ * a capability probe answering, makes tours takeable that were "blocked" only because the app
395
+ * did not know yet, and the app finding out about itself is not a moment to interrupt anyone
396
+ * about. Those resolutions advance the baseline SILENTLY. This is the guard that generalises:
397
+ * it needs no list of which stores load late, because none of them describe the world.
398
+ *
399
+ * So an offer needs both halves — a tour that just became takeable, and a world that just moved —
400
+ * and the baseline advances on every resolution either way, so a tour flickering
401
+ * ready → blocked → ready (which live run gates do) cannot re-offer itself.
402
+ */
403
+ export function resolveNudge(input: {
404
+ boardReady: boolean
405
+ catalogue: readonly TutorialCatalogueEntry[]
406
+ boardState: string
407
+ previous: { ready: ReadonlySet<string>; boardState: string } | null
408
+ declined: boolean
409
+ isCompleted: (tourId: string) => boolean
410
+ wasNudged: (tourId: string) => boolean
411
+ }): { baseline: { ready: Set<string>; boardState: string } | null; offer: TutorialTour | null } {
412
+ if (!input.boardReady) return { baseline: null, offer: null }
413
+ const baseline = { ready: readyTourIds(input.catalogue), boardState: input.boardState }
414
+ if (input.previous === null) return { baseline, offer: null }
415
+ if (input.previous.boardState === input.boardState) return { baseline, offer: null }
416
+ return {
417
+ baseline,
418
+ offer: newlyAvailableTour({ ...input, previouslyReady: input.previous.ready }),
419
+ }
420
+ }
421
+
422
+ /**
423
+ * The tour to hand the user off to when they finish `justFinishedId`, or null when there is
424
+ * nothing left to offer.
425
+ *
426
+ * The catalog is a COURSE, not a list: each delivery-loop tour produces the state the next
427
+ * one requires, so finishing one is the single most reliable moment at which another became
428
+ * takeable. Without a handoff the walkthrough that the user's own last action unlocked is
429
+ * reachable only from the catalogue, and the launch prompt cannot bring it up either: starting
430
+ * any tour writes `decision: 'accepted'`, which is what stops the prompt auto-opening for good.
431
+ * So the finish card is the ONLY place the product can still say "and now this one".
432
+ *
433
+ * Two rules make it an offer rather than a list:
434
+ *
435
+ * - Launch-offer tours come FIRST, whatever their `order` (see {@link TutorialTour.offeredAtLaunch}).
436
+ * The delivery loop is the arc someone taking a tour is on; a deployment's catalogue-only
437
+ * tour with a low `order` must not jump in front of it. Ordering alone is not that
438
+ * guarantee — it only happens to be true of the built-ins' numbering.
439
+ * - It offers exactly one, and only a READY one. A list is what the catalogue is for, and a
440
+ * blocked tour named here would ask the user to go and do something at the moment they
441
+ * finished doing something.
442
+ *
443
+ * Absence is a legitimate answer, unlike in the catalogue: this is an offer, so having nothing
444
+ * to suggest means the card keeps its plain Done. The caller still shows the way to the
445
+ * catalogue, so the card never dead-ends.
446
+ *
447
+ * `ready` is deliberately read LIVE by the caller rather than from the tour's held script,
448
+ * which is the one place the "resolve once and HOLD" rule must not apply: completing
449
+ * `first-task` is exactly what makes `run-task` ready, so a candidate list frozen at tour
450
+ * start would be empty precisely when this exists to be useful.
451
+ */
452
+ export function nextTourAfter(
453
+ ready: readonly TutorialTour[],
454
+ input: { justFinishedId: string; isCompleted: (tourId: string) => boolean },
455
+ ): TutorialTour | null {
456
+ const fresh = sortTours(ready).filter(
457
+ (tour) => tour.id !== input.justFinishedId && !input.isCompleted(tour.id),
458
+ )
459
+ return fresh.find(isLaunchOffer) ?? fresh[0] ?? null
460
+ }
461
+
289
462
  /**
290
463
  * Where a tour stands for this user: the state the catalogue badges and the action label
291
464
  * derive from. Camel-cased because the values ARE the i18n leaf keys