@cat-factory/app 0.213.0 → 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 +8 -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 +149 -6
  38. package/i18n/locales/en.json +153 -6
  39. package/i18n/locales/es.json +149 -6
  40. package/i18n/locales/fr.json +149 -6
  41. package/i18n/locales/he.json +149 -6
  42. package/i18n/locales/it.json +149 -6
  43. package/i18n/locales/ja.json +149 -6
  44. package/i18n/locales/pl.json +149 -6
  45. package/i18n/locales/tr.json +149 -6
  46. package/i18n/locales/uk.json +149 -6
  47. package/package.json +2 -2
@@ -0,0 +1,191 @@
1
+ import { ref } from 'vue'
2
+
3
+ /** The launch-prompt answer. `null` = never answered, so the app asks again next launch. */
4
+ export type TutorialDecision = 'accepted' | 'declined'
5
+
6
+ /** The server's copy of a user's record, as the snapshot delivers it. */
7
+ export interface RemoteTutorialRecord {
8
+ decision: TutorialDecision | null
9
+ completedTourIds: readonly string[]
10
+ nudgedTourIds: readonly string[]
11
+ }
12
+
13
+ /**
14
+ * The PERSISTED tier of the tutorial's state: the standing record of what this PERSON has done.
15
+ *
16
+ * Extracted from `stores/tutorial.ts` along the boundary that store's own docblock already draws.
17
+ * Everything here outlives a visit and is mirrored to the signed-in user's server row; everything
18
+ * left behind (the prompt, the catalogue, the step cursor, the resume point, which offer is on
19
+ * screen) is session state that a reload is right to discard. Splitting on that line also put every
20
+ * piece of the sync story in one module: the union rule, what counts as "this browser knows more",
21
+ * and the flag the mirror acts on.
22
+ *
23
+ * A factory rather than a second Pinia store, because the two tiers are one store's worth of state
24
+ * to every consumer: the returned refs go straight into the setup store's state, so
25
+ * `persist: { pick: [...] }` and every existing `tutorial.completedTourIds` read are unchanged.
26
+ */
27
+ export function createTutorialRecord() {
28
+ /** The saved launch-prompt answer. Written only by an explicit accept/decline/reset. */
29
+ const decision = ref<TutorialDecision | null>(null)
30
+ /** Ids of tours the user finished (reached the last step's Done). */
31
+ const completedTourIds = ref<string[]>([])
32
+ /**
33
+ * Ids the CONTEXTUAL offer has already been spent on (see `newlyAvailableTour`). Persisted beside
34
+ * the completions, because "we have mentioned this once" is a fact about the person, not about
35
+ * this visit: re-offering the parked-run walkthrough on every reload is a nag, and the gates it
36
+ * watches flip several times per run.
37
+ */
38
+ const nudgedTourIds = ref<string[]>([])
39
+ /**
40
+ * This browser holds progress the server row does not, so the mirror owes it a write.
41
+ *
42
+ * Session-lived and a FLAG rather than a direct call, because the merge below runs inside the
43
+ * snapshot fan-out, which is synchronous and store-only. `useTutorialSync` watches it.
44
+ */
45
+ const serverPushNeeded = ref(false)
46
+ /**
47
+ * Counts LOCAL changes to this record, and nothing else. Session-lived.
48
+ *
49
+ * The mirror watches this rather than the state itself, and the distinction is the whole reason
50
+ * it exists: adopting the server's ids in {@link mergeServerProgress} also changes the state, so
51
+ * a watcher over the state pushes the server's own row straight back at it on every fresh-browser
52
+ * board load. Counting only what the USER did means a write happens exactly when this browser has
53
+ * something to say.
54
+ */
55
+ const localRev = ref(0)
56
+ /**
57
+ * The local `decision` has changed and has not been mirrored yet, so it must survive a snapshot.
58
+ *
59
+ * Without this, a failed mirror write silently UNDOES the user's answer: they click "No thanks",
60
+ * the push fails, and the next snapshot re-adopts the older `accepted` from the server row,
61
+ * re-arming every contextual offer they just declined. The server row wins on `decision` because
62
+ * it is the shared record of the latest answer, but only where this browser is not itself holding
63
+ * a newer one that the server has not seen.
64
+ */
65
+ const decisionDirty = ref(false)
66
+
67
+ const isCompleted = (tourId: string) => completedTourIds.value.includes(tourId)
68
+ const wasNudged = (tourId: string) => nudgedTourIds.value.includes(tourId)
69
+
70
+ /** Record a finished walkthrough. Idempotent: the same tour taken twice is still one entry. */
71
+ function markCompleted(tourId: string) {
72
+ if (isCompleted(tourId)) return
73
+ completedTourIds.value = [...completedTourIds.value, tourId]
74
+ localRev.value += 1
75
+ }
76
+
77
+ /** Spend the contextual offer for a tour. Idempotent, so an offer can never be made twice. */
78
+ function markNudged(tourId: string) {
79
+ if (wasNudged(tourId)) return
80
+ nudgedTourIds.value = [...nudgedTourIds.value, tourId]
81
+ localRev.value += 1
82
+ }
83
+
84
+ /** Record an answer to the launch prompt, and ask the mirror to carry it. */
85
+ function answer(next: TutorialDecision) {
86
+ if (decision.value === next) return
87
+ decision.value = next
88
+ decisionDirty.value = true
89
+ localRev.value += 1
90
+ }
91
+
92
+ /** Starting a tour IS accepting the tutorial, however the user got there. */
93
+ function acceptOffer() {
94
+ answer('accepted')
95
+ }
96
+
97
+ /** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
98
+ function declineOffer() {
99
+ answer('declined')
100
+ }
101
+
102
+ /**
103
+ * Fold the signed-in user's SERVER copy into this browser's, and report whether the local copy
104
+ * held anything the server did not.
105
+ *
106
+ * A union rather than a replace, in the same direction and for the same reason the server merges:
107
+ * both lists are grow-only sets of things that HAPPENED, so neither side is ever right to un-say
108
+ * one. A replace here would lose a tour finished while the mirror write was failing; a replace on
109
+ * the server would lose a tour finished on another machine. `decision` is taken from the server
110
+ * when it HAS one and this browser is not holding an un-mirrored answer of its own
111
+ * ({@link decisionDirty}), because it is a preference someone re-answers rather than an
112
+ * accumulating fact — while a server row with no answer is not evidence that the local answer
113
+ * never happened.
114
+ *
115
+ * The return value closes the loop: `true` means this browser knows something the server does not,
116
+ * so the merged state is worth pushing back. Recomputing that comparison at the call site would be
117
+ * a second copy of the union rule. It is also what makes the server's un-guarded merge safe: the
118
+ * PUT's response goes through here too, so a merge that lost a concurrent writer's ids comes back
119
+ * missing something local, and the re-push is automatic rather than hoped for.
120
+ */
121
+ function mergeServerProgress(remote: RemoteTutorialRecord | null): boolean {
122
+ // No server copy at all (no accounts, no store wired, or the read degraded) is NOT a reason to
123
+ // push: there is nothing to reconcile against, and treating an absent row as an empty one would
124
+ // make every such board load write a mirror nothing reads.
125
+ if (!remote) return false
126
+ const union = (mine: string[], theirs: readonly string[]) => [...new Set([...theirs, ...mine])]
127
+ const completed = union(completedTourIds.value, remote.completedTourIds)
128
+ const nudged = union(nudgedTourIds.value, remote.nudgedTourIds)
129
+ const keepLocalDecision = decisionDirty.value || remote.decision === null
130
+ const localOnly =
131
+ completed.length > remote.completedTourIds.length ||
132
+ nudged.length > remote.nudgedTourIds.length ||
133
+ (keepLocalDecision && decision.value !== remote.decision)
134
+ completedTourIds.value = completed
135
+ nudgedTourIds.value = nudged
136
+ if (!keepLocalDecision) decision.value = remote.decision
137
+ if (localOnly) serverPushNeeded.value = true
138
+ return localOnly
139
+ }
140
+
141
+ /**
142
+ * The mirror has caught up: this browser's state is on the server, including its answer.
143
+ *
144
+ * Called BEFORE the response is reconciled, so that a response missing something local can flip
145
+ * {@link serverPushNeeded} back on and re-trigger the watcher. Clearing it after would look
146
+ * identical and would swallow exactly the retry that matters.
147
+ */
148
+ function markServerPushed() {
149
+ serverPushNeeded.value = false
150
+ decisionDirty.value = false
151
+ }
152
+
153
+ /**
154
+ * Forget the whole record, including the answered offer.
155
+ *
156
+ * The decision goes with it deliberately: "Reset" is asked for by someone handing the app to a
157
+ * colleague, demoing it, or re-walking the product after it changed, and every one of those wants
158
+ * the first-launch experience back, which a cleared completion list alone does not restore. The
159
+ * spent contextual offers go too, or a board that has already run something would never make
160
+ * those offers again to the colleague the app was just handed to.
161
+ *
162
+ * Deliberately does NOT bump {@link localRev}, and cancels any pending mirror write. The server
163
+ * side of a reset is a DELETE, and a PUT of the freshly-emptied state racing it would re-create
164
+ * the row the DELETE just removed — leaving "reset it" distinguishable from "never touched the
165
+ * tutorial", which is the one thing the reset has to get right.
166
+ */
167
+ function reset() {
168
+ completedTourIds.value = []
169
+ nudgedTourIds.value = []
170
+ decision.value = null
171
+ serverPushNeeded.value = false
172
+ decisionDirty.value = false
173
+ }
174
+
175
+ return {
176
+ decision,
177
+ completedTourIds,
178
+ nudgedTourIds,
179
+ serverPushNeeded,
180
+ localRev,
181
+ isCompleted,
182
+ wasNudged,
183
+ markCompleted,
184
+ markNudged,
185
+ acceptOffer,
186
+ declineOffer,
187
+ mergeServerProgress,
188
+ markServerPushed,
189
+ reset,
190
+ }
191
+ }
@@ -121,10 +121,16 @@ describe('useTutorialStore catalogue', () => {
121
121
  tutorial.setStepIndex(2)
122
122
  tutorial.stopTour()
123
123
 
124
+ tutorial.offerNudge('answer-park')
124
125
  tutorial.resetProgress()
125
126
  expect(tutorial.completedTourIds).toEqual([])
126
127
  expect(tutorial.interruptedAt('run-task')).toBeNull()
127
128
  expect(tutorial.decision).toBeNull()
129
+ // The spent contextual offers go too, or a board that has already run something would
130
+ // never make them again to the colleague the app was just handed to.
131
+ expect(tutorial.nudgedTourIds).toEqual([])
132
+ expect(tutorial.pendingNudgeId).toBeNull()
133
+ expect(tutorial.wasNudged('answer-park')).toBe(false)
128
134
  })
129
135
 
130
136
  it('leaves a running tour alone when progress is reset', () => {
@@ -300,3 +306,204 @@ describe('useTutorialStore resuming a broken-off tour', () => {
300
306
  expect(tutorial.interruptedAt('gone-away')).toBeNull()
301
307
  })
302
308
  })
309
+
310
+ describe('useTutorialStore contextual offer', () => {
311
+ it('raises the offer and spends the id in one act', () => {
312
+ // The guard against re-offering is the PERSISTED list, not the visible state, so an offer
313
+ // cannot be made twice however many times the live gates flip.
314
+ const tutorial = useTutorialStore()
315
+ tutorial.offerNudge('answer-park')
316
+ expect(tutorial.pendingNudgeId).toBe('answer-park')
317
+ expect(tutorial.wasNudged('answer-park')).toBe(true)
318
+ })
319
+
320
+ it('never re-raises an offer already spent, even once dismissed', () => {
321
+ const tutorial = useTutorialStore()
322
+ tutorial.offerNudge('answer-park')
323
+ tutorial.dismissNudge()
324
+ expect(tutorial.pendingNudgeId).toBeNull()
325
+ tutorial.offerNudge('answer-park')
326
+ expect(tutorial.pendingNudgeId).toBeNull()
327
+ expect(tutorial.nudgedTourIds).toEqual(['answer-park'])
328
+ })
329
+
330
+ it('holds one offer at a time, keeping the newer arrival', () => {
331
+ const tutorial = useTutorialStore()
332
+ tutorial.offerNudge('answer-park')
333
+ tutorial.offerNudge('diagnose-failure')
334
+ expect(tutorial.pendingNudgeId).toBe('diagnose-failure')
335
+ expect(tutorial.nudgedTourIds).toEqual(['answer-park', 'diagnose-failure'])
336
+ })
337
+ })
338
+
339
+ describe('useTutorialStore server reconciliation', () => {
340
+ it('unions the server copy into the local one, keeping what only this browser knew', () => {
341
+ // Both lists are grow-only sets of things that HAPPENED, so neither side may un-say one. A
342
+ // replace here loses a tour finished while the mirror write was failing.
343
+ const tutorial = useTutorialStore()
344
+ tutorial.startTour('board-basics')
345
+ tutorial.completeTour()
346
+ const pushNeeded = tutorial.mergeServerProgress({
347
+ decision: 'accepted',
348
+ completedTourIds: ['first-task'],
349
+ nudgedTourIds: [],
350
+ })
351
+ expect(tutorial.completedTourIds).toEqual(['first-task', 'board-basics'])
352
+ // This browser held something the server did not, so the mirror owes it a write.
353
+ expect(pushNeeded).toBe(true)
354
+ expect(tutorial.serverPushNeeded).toBe(true)
355
+ })
356
+
357
+ it('asks for no push when the server copy is already a superset', () => {
358
+ // The ordinary reload. Writing anyway would mean every board load posted a mirror nobody reads.
359
+ const tutorial = useTutorialStore()
360
+ expect(
361
+ tutorial.mergeServerProgress({
362
+ decision: 'accepted',
363
+ completedTourIds: ['board-basics'],
364
+ nudgedTourIds: ['answer-park'],
365
+ }),
366
+ ).toBe(false)
367
+ expect(tutorial.completedTourIds).toEqual(['board-basics'])
368
+ expect(tutorial.serverPushNeeded).toBe(false)
369
+ })
370
+
371
+ it('never duplicates an id both sides already hold', () => {
372
+ const tutorial = useTutorialStore()
373
+ tutorial.startTour('board-basics')
374
+ tutorial.completeTour()
375
+ tutorial.mergeServerProgress({
376
+ decision: null,
377
+ completedTourIds: ['board-basics'],
378
+ nudgedTourIds: [],
379
+ })
380
+ expect(tutorial.completedTourIds).toEqual(['board-basics'])
381
+ })
382
+
383
+ it('takes the server decision, but never clears a local one with an absent row', () => {
384
+ // The decision is a preference someone re-answers, so the shared record wins when it has an
385
+ // answer. A server row with no answer is not evidence that the local answer never happened.
386
+ const tutorial = useTutorialStore()
387
+ tutorial.decline()
388
+ tutorial.markServerPushed()
389
+ tutorial.mergeServerProgress({
390
+ decision: null,
391
+ completedTourIds: [],
392
+ nudgedTourIds: [],
393
+ })
394
+ expect(tutorial.decision).toBe('declined')
395
+ expect(tutorial.serverPushNeeded).toBe(true)
396
+
397
+ tutorial.markServerPushed()
398
+ tutorial.mergeServerProgress({
399
+ decision: 'accepted',
400
+ completedTourIds: [],
401
+ nudgedTourIds: [],
402
+ })
403
+ expect(tutorial.decision).toBe('accepted')
404
+ })
405
+
406
+ it('holds an UN-MIRRORED local answer against the server, so a failed push cannot undo it', () => {
407
+ // The mirror is best-effort, so a decline can be sitting in this browser and nowhere else. If
408
+ // the snapshot re-adopted the older server answer, "No thanks" would silently come back as
409
+ // "accepted" and every contextual offer the user just declined would re-arm.
410
+ const tutorial = useTutorialStore()
411
+ tutorial.decline()
412
+ expect(
413
+ tutorial.mergeServerProgress({
414
+ decision: 'accepted',
415
+ completedTourIds: [],
416
+ nudgedTourIds: [],
417
+ }),
418
+ ).toBe(true)
419
+ expect(tutorial.decision).toBe('declined')
420
+
421
+ // Once the answer HAS been mirrored, the shared record is authoritative again: another machine
422
+ // re-answering is a real change, and this browser has nothing newer to defend.
423
+ tutorial.markServerPushed()
424
+ tutorial.mergeServerProgress({ decision: 'accepted', completedTourIds: [], nudgedTourIds: [] })
425
+ expect(tutorial.decision).toBe('accepted')
426
+ })
427
+
428
+ it('treats an absent server copy as nothing to reconcile, not as an empty one', () => {
429
+ // No accounts, no store wired, or a degraded read. Pushing here would write a mirror on a
430
+ // deployment that has nowhere to put it; clearing would be worse.
431
+ const tutorial = useTutorialStore()
432
+ tutorial.startTour('board-basics')
433
+ tutorial.completeTour()
434
+ expect(tutorial.mergeServerProgress(null)).toBe(false)
435
+ expect(tutorial.completedTourIds).toEqual(['board-basics'])
436
+ expect(tutorial.serverPushNeeded).toBe(false)
437
+ })
438
+
439
+ it('clears the push request once the mirror has caught up', () => {
440
+ const tutorial = useTutorialStore()
441
+ tutorial.startTour('board-basics')
442
+ tutorial.completeTour()
443
+ tutorial.mergeServerProgress({ decision: null, completedTourIds: [], nudgedTourIds: [] })
444
+ expect(tutorial.serverPushNeeded).toBe(true)
445
+ tutorial.markServerPushed()
446
+ expect(tutorial.serverPushNeeded).toBe(false)
447
+ })
448
+
449
+ it('re-arms the push when a merge RESPONSE comes back missing something local', () => {
450
+ // What makes the server's un-guarded merge safe. Two concurrent merges can lose one writer's
451
+ // ids, and the loser finds out because the response it gets back is a row without them. The
452
+ // ordering is the load-bearing part: the push is marked done FIRST, so re-arming flips the flag
453
+ // and re-triggers the mirror rather than being swallowed by a later clear.
454
+ const tutorial = useTutorialStore()
455
+ tutorial.startTour('board-basics')
456
+ tutorial.completeTour()
457
+ tutorial.markServerPushed()
458
+ expect(tutorial.serverPushNeeded).toBe(false)
459
+ tutorial.mergeServerProgress({
460
+ decision: 'accepted',
461
+ completedTourIds: ['first-task'],
462
+ nudgedTourIds: [],
463
+ })
464
+ expect(tutorial.serverPushNeeded).toBe(true)
465
+ })
466
+ })
467
+
468
+ describe('useTutorialStore local revision (what the mirror watches)', () => {
469
+ it('counts only what the USER did, never what the server told us', () => {
470
+ // The mirror watches this instead of the state, because adopting the server's own ids is a
471
+ // state change too: watching the state posts the server's row straight back at it on every
472
+ // fresh-browser board load.
473
+ const tutorial = useTutorialStore()
474
+ const before = tutorial.localRev
475
+ tutorial.mergeServerProgress({
476
+ decision: 'accepted',
477
+ completedTourIds: ['board-basics', 'first-task'],
478
+ nudgedTourIds: ['answer-park'],
479
+ })
480
+ expect(tutorial.completedTourIds).toEqual(['board-basics', 'first-task'])
481
+ expect(tutorial.localRev).toBe(before)
482
+
483
+ tutorial.startTour('run-task')
484
+ tutorial.completeTour()
485
+ expect(tutorial.localRev).toBeGreaterThan(before)
486
+ })
487
+
488
+ it('does not bump on a re-recorded completion, an unchanged answer, or a reset', () => {
489
+ const tutorial = useTutorialStore()
490
+ // Two real changes: the accept `startTour` writes, and the completion.
491
+ tutorial.startTour('board-basics')
492
+ tutorial.completeTour()
493
+ const settled = tutorial.localRev
494
+ expect(settled).toBe(2)
495
+
496
+ // Repeating the same tour changes nothing that is persisted: the answer is already 'accepted'
497
+ // and the completion is already recorded, so there is nothing for the mirror to carry.
498
+ tutorial.startTour('board-basics')
499
+ tutorial.completeTour()
500
+ expect(tutorial.localRev).toBe(settled)
501
+
502
+ // A reset's server side is a DELETE. A push of the freshly-emptied state racing it would
503
+ // re-create the row the DELETE removed, leaving "reset it" distinguishable from "never touched
504
+ // the tutorial" — the one thing the reset has to get right.
505
+ tutorial.resetProgress()
506
+ expect(tutorial.localRev).toBe(settled)
507
+ expect(tutorial.serverPushNeeded).toBe(false)
508
+ })
509
+ })
@@ -1,41 +1,46 @@
1
1
  import { defineStore } from 'pinia'
2
2
  import { computed, ref } from 'vue'
3
+ import { createTutorialPrompt } from '~/stores/tutorial.prompt'
4
+ import { createTutorialRecord } from '~/stores/tutorial.record'
3
5
 
4
- /** The launch-prompt answer. `null` = never answered, so the app asks again next launch. */
5
- export type TutorialDecision = 'accepted' | 'declined'
6
+ // `TutorialDecision` is deliberately NOT re-exported from here even though it used to live here:
7
+ // both store modules are auto-import sources, so two exports of one name make Nuxt drop one of them
8
+ // with a build warning. It is reachable unqualified anywhere in the layer, from `tutorial.record.ts`
9
+ // where it is declared beside the state it describes.
6
10
 
7
11
  /**
8
12
  * The in-app tutorial state: the launch-prompt decision, per-tour completion, and the
9
13
  * live progress of whichever tour is running.
10
14
  *
11
- * Two tiers of state, split on purpose:
15
+ * Two tiers of state, split on purpose — and the split is now a real seam rather than a comment:
12
16
  *
13
- * - PERSISTED (`decision`, `completedTourIds`) — the user's explicit answer to "would you
14
- * like a tour?" and which tours they have finished. Browser-persisted like the interface
15
- * tier (`uiMode`): it is a per-person, per-browser preference, and `null` (never asked)
16
- * is a real state distinct from `declined` only an explicit answer stops the launch
17
- * prompt from returning, while closing it without answering merely defers it to the
18
- * next launch.
19
- * - SESSION-ONLY (`promptOpen`, `catalogueOpen`, `activeTourId`, `stepIndex`) a tour is anchored to live
20
- * DOM, so replaying progress across a reload would point step N at a board that hasn't
21
- * reached that state; a reloaded tour restarts from its beginning instead.
17
+ * - PERSISTED (`decision`, `completedTourIds`, `nudgedTourIds`) — the standing record of what this
18
+ * PERSON has done, owned by `createTutorialRecord` (`stores/tutorial.record.ts`) along with the
19
+ * server-reconciliation rules that go with it. `null` (never asked) is a real state distinct from
20
+ * `declined`: only an explicit answer stops the launch prompt returning, while closing it without
21
+ * answering merely defers it to the next launch.
22
+ * - SESSION-ONLY (`promptOpen`, `catalogueOpen`, `activeTourId`, `stepIndex`, `pendingNudgeId`) —
23
+ * owned here. A tour is anchored to live DOM, so replaying progress across a reload would point
24
+ * step N at a board that hasn't reached that state; a reloaded tour restarts from its beginning.
25
+ *
26
+ * The persisted half is a per-person fact rather than a per-browser one, so it is MIRRORED to the
27
+ * signed-in user's server row when the deployment has accounts (`useTutorialSync`), with this store
28
+ * staying the local cache the SPA reads. Persisting it here as well is not redundancy: it is what a
29
+ * deployment with auth disabled, and every load before the snapshot lands, runs on.
22
30
  *
23
31
  * The store deliberately knows nothing about WHICH tours exist: the catalog lives in the
24
- * `tutorialTours` slot (see `modular/tutorial-tours.ts`), so tours ship and evolve — first-
25
- * party and consumer-contributed alike — without this store changing. It tracks ids and a
26
- * step cursor; the overlay resolves definitions and drives the cursor.
32
+ * `tutorialTours` slot (see `modular/tutorial-tours.ts`), so tours ship and evolve — first-party and
33
+ * consumer-contributed alike — without this store changing. It tracks ids and a step cursor; the
34
+ * overlay resolves definitions and drives the cursor.
27
35
  */
28
36
  export const useTutorialStore = defineStore(
29
37
  'tutorial',
30
38
  () => {
31
- /** The saved launch-prompt answer. Written only by an explicit accept/decline/start. */
32
- const decision = ref<TutorialDecision | null>(null)
33
- /** Ids of tours the user finished (reached the last step's Done), persisted. */
34
- const completedTourIds = ref<string[]>([])
35
-
36
- const promptOpen = ref(false)
37
- /** Once-per-session guard for the launch auto-open; later opens are user-driven. */
38
- const promptAutoOpened = ref(false)
39
+ const record = createTutorialRecord()
40
+ // The launch offer's own four-exit state machine (`stores/tutorial.prompt.ts`), which needs to
41
+ // know only WHETHER an answer exists.
42
+ const prompt = createTutorialPrompt({ hasDecision: () => record.decision.value !== null })
43
+ const { promptOpen } = prompt
39
44
  /**
40
45
  * The tutorial catalogue (every tour this deployment ships, startable at any time) is
41
46
  * open. Always user-driven — nothing auto-opens it — which is why it carries none of the
@@ -58,6 +63,21 @@ export const useTutorialStore = defineStore(
58
63
  */
59
64
  const interrupted = ref<{ tourId: string; stepIndex: number } | null>(null)
60
65
 
66
+ /**
67
+ * The tour the contextual offer is currently holding out, or null.
68
+ *
69
+ * Session state, and separate from `nudgedTourIds` on purpose: the id is marked as SPENT the
70
+ * moment the offer is made, while what is on SCREEN survives being suppressed. The two most
71
+ * valuable moments to offer a tour (a run parked, a run failed) routinely arrive while a
72
+ * tutorial window or another tour is up, and dropping the offer there would lose the one chance
73
+ * this mechanism gets. Holding it means it appears as soon as the way is clear.
74
+ *
75
+ * The trade is deliberate: a reload before it is ever shown burns the offer. That beats the
76
+ * alternative of re-arming it, which turns one missed moment into a prompt that keeps coming
77
+ * back on a board whose gates flip constantly.
78
+ */
79
+ const pendingNudgeId = ref<string | null>(null)
80
+
61
81
  /** A tour is currently running (the overlay mounts off this). */
62
82
  const touring = computed(() => activeTourId.value !== null)
63
83
 
@@ -77,43 +97,6 @@ export const useTutorialStore = defineStore(
77
97
  */
78
98
  const ownWindowOpen = computed(() => promptOpen.value || catalogueOpen.value)
79
99
 
80
- /**
81
- * Auto-open the launch prompt, at most once per session and only while the user has
82
- * never answered it. Callers gate on the rest of the launch context (board ready, no
83
- * other startup advisory open) — see `pages/index.vue`.
84
- */
85
- function maybeOfferOnLaunch() {
86
- if (decision.value !== null || promptAutoOpened.value) return
87
- promptAutoOpened.value = true
88
- promptOpen.value = true
89
- }
90
-
91
- /** User-driven open (command palette), regardless of any saved decision. */
92
- function openPrompt() {
93
- promptOpen.value = true
94
- }
95
-
96
- /**
97
- * Withdraw an offer this store made, because something the user actually has to answer
98
- * (a startup advisory, the GitHub onboarding gate) opened on top of it — and re-arm, so
99
- * the offer returns once that surface is gone. Distinct from {@link closePrompt}: no
100
- * decision is written EITHER way, but a deferral was not the user's doing, so it must
101
- * not consume this session's one offer.
102
- *
103
- * Only ever withdraws the auto-opened prompt; a prompt the user opened themselves from
104
- * the palette is theirs to close.
105
- */
106
- function deferPrompt() {
107
- if (!promptAutoOpened.value) return
108
- promptOpen.value = false
109
- promptAutoOpened.value = false
110
- }
111
-
112
- /** Close without answering: no decision is written, so the next launch asks again. */
113
- function closePrompt() {
114
- promptOpen.value = false
115
- }
116
-
117
100
  /**
118
101
  * Open the catalogue. Closes the launch prompt WITHOUT answering it: browsing the full
119
102
  * list is not "no thanks" (it is the opposite), and the two are modals that would
@@ -131,7 +114,7 @@ export const useTutorialStore = defineStore(
131
114
 
132
115
  /** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
133
116
  function decline() {
134
- decision.value = 'declined'
117
+ record.declineOffer()
135
118
  promptOpen.value = false
136
119
  }
137
120
 
@@ -141,7 +124,7 @@ export const useTutorialStore = defineStore(
141
124
  * leaving `declined` in place would misdescribe what happened).
142
125
  */
143
126
  function startTour(tourId: string) {
144
- decision.value = 'accepted'
127
+ record.acceptOffer()
145
128
  promptOpen.value = false
146
129
  catalogueOpen.value = false
147
130
  activeTourId.value = tourId
@@ -171,7 +154,7 @@ export const useTutorialStore = defineStore(
171
154
  startTour(tourId)
172
155
  return
173
156
  }
174
- decision.value = 'accepted'
157
+ record.acceptOffer()
175
158
  promptOpen.value = false
176
159
  catalogueOpen.value = false
177
160
  activeTourId.value = tourId
@@ -211,9 +194,7 @@ export const useTutorialStore = defineStore(
211
194
  /** Finish the running tour: record completion (idempotent) and clear the cursor. */
212
195
  function completeTour() {
213
196
  const id = activeTourId.value
214
- if (id && !completedTourIds.value.includes(id)) {
215
- completedTourIds.value = [...completedTourIds.value, id]
216
- }
197
+ if (id) record.markCompleted(id)
217
198
  // A finished tour has no position left to resume, and an offer to resume the walkthrough
218
199
  // the user just completed would sit beside its own Completed badge.
219
200
  if (id !== null && interrupted.value?.tourId === id) interrupted.value = null
@@ -225,45 +206,50 @@ export const useTutorialStore = defineStore(
225
206
  return interrupted.value?.tourId === tourId ? interrupted.value.stepIndex : null
226
207
  }
227
208
 
228
- function isCompleted(tourId: string): boolean {
229
- return completedTourIds.value.includes(tourId)
209
+ /**
210
+ * Hold out the contextual offer for a tour that just became takeable.
211
+ *
212
+ * Spending the id and raising the offer are ONE action, so an offer can never be made twice
213
+ * however many times the gates flip: the guard is the persisted list, not the visible state.
214
+ * Idempotent, so a caller re-evaluating the catalogue needs no check of its own.
215
+ */
216
+ function offerNudge(tourId: string) {
217
+ if (record.wasNudged(tourId)) return
218
+ record.markNudged(tourId)
219
+ pendingNudgeId.value = tourId
220
+ }
221
+
222
+ /** Take the offer off screen. It is already spent, so it does not come back. */
223
+ function dismissNudge() {
224
+ pendingNudgeId.value = null
230
225
  }
231
226
 
232
227
  /**
233
- * Forget everything this browser remembers about the tutorial: which tours were finished,
234
- * where one was broken off, and the answer to the launch offer.
235
- *
236
- * The decision goes with it deliberately. "Reset" is asked for by someone handing the app
237
- * to a colleague, demoing it, or re-walking the product after it changed — and every one
238
- * of those wants the first-launch experience back, which a cleared completion list alone
239
- * does not restore. It does NOT re-open the prompt in this session: `promptAutoOpened` is
240
- * session state and stays spent, so the offer returns at the next launch rather than
241
- * appearing on top of the catalogue the user is still reading.
228
+ * Forget everything this browser remembers about the tutorial (see `record.reset`), plus the
229
+ * offer currently on screen.
242
230
  *
243
- * A running tour is left alone: this clears a record, it does not interrupt a walkthrough
244
- * the user is in the middle of (which would end it, unrecorded, on a click about history).
231
+ * It does NOT re-open the prompt in this session: `promptAutoOpened` is session state and stays
232
+ * spent, so the offer returns at the next launch rather than appearing on top of the catalogue
233
+ * the user is still reading. A running tour is left alone: this clears a record, it does not
234
+ * interrupt a walkthrough the user is in the middle of (which would end it, unrecorded, on a
235
+ * click about history).
245
236
  */
246
237
  function resetProgress() {
247
- completedTourIds.value = []
238
+ record.reset()
248
239
  interrupted.value = null
249
- decision.value = null
240
+ pendingNudgeId.value = null
250
241
  }
251
242
 
252
243
  return {
253
- decision,
254
- completedTourIds,
255
- promptOpen,
256
- promptAutoOpened,
244
+ ...record,
245
+ ...prompt,
257
246
  catalogueOpen,
258
247
  activeTourId,
259
248
  stepIndex,
260
249
  interrupted,
250
+ pendingNudgeId,
261
251
  touring,
262
252
  ownWindowOpen,
263
- maybeOfferOnLaunch,
264
- openPrompt,
265
- closePrompt,
266
- deferPrompt,
267
253
  openCatalogue,
268
254
  closeCatalogue,
269
255
  resetProgress,
@@ -273,9 +259,10 @@ export const useTutorialStore = defineStore(
273
259
  setStepIndex,
274
260
  stopTour,
275
261
  completeTour,
276
- isCompleted,
277
262
  interruptedAt,
263
+ offerNudge,
264
+ dismissNudge,
278
265
  }
279
266
  },
280
- { persist: { pick: ['decision', 'completedTourIds'] } },
267
+ { persist: { pick: ['decision', 'completedTourIds', 'nudgedTourIds'] } },
281
268
  )