@cat-factory/app 0.201.0 → 0.202.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.
- package/README.md +68 -0
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +46 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +53 -0
- package/app/components/tutorial/TutorialOverlay.vue +287 -38
- package/app/components/tutorial/TutorialPrompt.vue +19 -7
- package/app/modular/tutorial-tours.spec.ts +97 -10
- package/app/stores/tutorial.spec.ts +92 -0
- package/app/stores/tutorial.ts +74 -3
- package/app/utils/agentOutput.ts +5 -2
- package/app/utils/tutorial.spec.ts +59 -1
- package/app/utils/tutorial.ts +67 -1
- package/i18n/locales/de.json +2 -0
- package/i18n/locales/en.json +8 -0
- package/i18n/locales/es.json +2 -0
- package/i18n/locales/fr.json +2 -0
- package/i18n/locales/he.json +2 -0
- package/i18n/locales/it.json +2 -0
- package/i18n/locales/ja.json +2 -0
- package/i18n/locales/pl.json +2 -0
- package/i18n/locales/tr.json +2 -0
- package/i18n/locales/uk.json +2 -0
- package/package.json +6 -7
|
@@ -133,3 +133,95 @@ describe('useTutorialStore tours', () => {
|
|
|
133
133
|
expect(tutorial.isCompleted('made-up')).toBe(false)
|
|
134
134
|
})
|
|
135
135
|
})
|
|
136
|
+
|
|
137
|
+
describe('useTutorialStore resuming a broken-off tour', () => {
|
|
138
|
+
it('offers to resume a tour abandoned part-way', () => {
|
|
139
|
+
// Esc and Skip are both cheap to hit — one by accident, one to get the overlay out of the
|
|
140
|
+
// way for a moment — and before this the position they discarded was the whole walkthrough.
|
|
141
|
+
const tutorial = useTutorialStore()
|
|
142
|
+
tutorial.startTour('board-basics')
|
|
143
|
+
tutorial.setStepIndex(3)
|
|
144
|
+
tutorial.stopTour()
|
|
145
|
+
expect(tutorial.interruptedAt('board-basics')).toBe(3)
|
|
146
|
+
|
|
147
|
+
tutorial.resumeTour('board-basics')
|
|
148
|
+
expect(tutorial.activeTourId).toBe('board-basics')
|
|
149
|
+
expect(tutorial.stepIndex).toBe(3)
|
|
150
|
+
// Consumed: the tour is running again, so there is no longer a position to go back to.
|
|
151
|
+
expect(tutorial.interruptedAt('board-basics')).toBeNull()
|
|
152
|
+
})
|
|
153
|
+
|
|
154
|
+
it('offers nothing for a tour abandoned on its very first step', () => {
|
|
155
|
+
// Resuming and starting are the same thing there, so a Resume label would be noise.
|
|
156
|
+
const tutorial = useTutorialStore()
|
|
157
|
+
tutorial.startTour('board-basics')
|
|
158
|
+
tutorial.stopTour()
|
|
159
|
+
expect(tutorial.interruptedAt('board-basics')).toBeNull()
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('keeps the resume point scoped to the tour it belongs to', () => {
|
|
163
|
+
const tutorial = useTutorialStore()
|
|
164
|
+
tutorial.startTour('run-task')
|
|
165
|
+
tutorial.setStepIndex(2)
|
|
166
|
+
tutorial.stopTour()
|
|
167
|
+
expect(tutorial.interruptedAt('first-task')).toBeNull()
|
|
168
|
+
// Resuming a DIFFERENT tour degrades to a plain start rather than resuming the wrong one.
|
|
169
|
+
tutorial.resumeTour('first-task')
|
|
170
|
+
expect(tutorial.activeTourId).toBe('first-task')
|
|
171
|
+
expect(tutorial.stepIndex).toBe(0)
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('discards the resume point when the same tour is started from the top', () => {
|
|
175
|
+
const tutorial = useTutorialStore()
|
|
176
|
+
tutorial.startTour('board-basics')
|
|
177
|
+
tutorial.setStepIndex(3)
|
|
178
|
+
tutorial.stopTour()
|
|
179
|
+
tutorial.startTour('board-basics')
|
|
180
|
+
expect(tutorial.stepIndex).toBe(0)
|
|
181
|
+
expect(tutorial.interruptedAt('board-basics')).toBeNull()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
it('keeps another tour’s resume point when a different tour is started from the top', () => {
|
|
185
|
+
// Starting a tour discards ITS own stale position, not somebody else's. Glancing at
|
|
186
|
+
// another tour and pressing Esc at step 0 must not cost the position you were coming
|
|
187
|
+
// back to — that one loses the single slot only when this tour is broken off past step 0.
|
|
188
|
+
const tutorial = useTutorialStore()
|
|
189
|
+
tutorial.startTour('board-basics')
|
|
190
|
+
tutorial.setStepIndex(3)
|
|
191
|
+
tutorial.stopTour()
|
|
192
|
+
|
|
193
|
+
tutorial.startTour('run-task')
|
|
194
|
+
tutorial.stopTour()
|
|
195
|
+
expect(tutorial.interruptedAt('board-basics')).toBe(3)
|
|
196
|
+
|
|
197
|
+
// ...and it does lose it the moment the other tour is broken off past its first step.
|
|
198
|
+
tutorial.startTour('run-task')
|
|
199
|
+
tutorial.setStepIndex(1)
|
|
200
|
+
tutorial.stopTour()
|
|
201
|
+
expect(tutorial.interruptedAt('board-basics')).toBeNull()
|
|
202
|
+
expect(tutorial.interruptedAt('run-task')).toBe(1)
|
|
203
|
+
})
|
|
204
|
+
|
|
205
|
+
it('leaves no resume point behind a completed tour', () => {
|
|
206
|
+
// It would sit beside that tour's own Completed badge, offering to resume what just finished.
|
|
207
|
+
const tutorial = useTutorialStore()
|
|
208
|
+
tutorial.startTour('board-basics')
|
|
209
|
+
tutorial.setStepIndex(3)
|
|
210
|
+
tutorial.stopTour()
|
|
211
|
+
tutorial.resumeTour('board-basics')
|
|
212
|
+
tutorial.setStepIndex(4)
|
|
213
|
+
tutorial.completeTour()
|
|
214
|
+
expect(tutorial.interruptedAt('board-basics')).toBeNull()
|
|
215
|
+
expect(tutorial.isCompleted('board-basics')).toBe(true)
|
|
216
|
+
})
|
|
217
|
+
|
|
218
|
+
it('leaves no resume point when the runtime bails out on an unusable position', () => {
|
|
219
|
+
// The overlay could not resolve the tour at all; handing that position back would put the
|
|
220
|
+
// user straight into the same dead overlay.
|
|
221
|
+
const tutorial = useTutorialStore()
|
|
222
|
+
tutorial.startTour('gone-away')
|
|
223
|
+
tutorial.setStepIndex(2)
|
|
224
|
+
tutorial.stopTour({ resumable: false })
|
|
225
|
+
expect(tutorial.interruptedAt('gone-away')).toBeNull()
|
|
226
|
+
})
|
|
227
|
+
})
|
package/app/stores/tutorial.ts
CHANGED
|
@@ -38,6 +38,18 @@ export const useTutorialStore = defineStore(
|
|
|
38
38
|
const promptAutoOpened = ref(false)
|
|
39
39
|
const activeTourId = ref<string | null>(null)
|
|
40
40
|
const stepIndex = ref(0)
|
|
41
|
+
/**
|
|
42
|
+
* Where a tour the user broke off was left, so the prompt can offer to RESUME it rather
|
|
43
|
+
* than only to start it again from step one.
|
|
44
|
+
*
|
|
45
|
+
* Session-only for the same reason the cursor is: a tour is anchored to live DOM, and a
|
|
46
|
+
* position replayed across a reload would point step N at a board that has not reached
|
|
47
|
+
* that state. Within one session the board is still exactly where the tour left it, so the
|
|
48
|
+
* position is good — which matters because breaking off is easy and cheap (Esc, or Skip to
|
|
49
|
+
* get the overlay out of the way for a moment) while the cost of it was the whole
|
|
50
|
+
* walkthrough.
|
|
51
|
+
*/
|
|
52
|
+
const interrupted = ref<{ tourId: string; stepIndex: number } | null>(null)
|
|
41
53
|
|
|
42
54
|
/** A tour is currently running (the overlay mounts off this). */
|
|
43
55
|
const touring = computed(() => activeTourId.value !== null)
|
|
@@ -95,6 +107,36 @@ export const useTutorialStore = defineStore(
|
|
|
95
107
|
promptOpen.value = false
|
|
96
108
|
activeTourId.value = tourId
|
|
97
109
|
stepIndex.value = 0
|
|
110
|
+
// Starting from the top is an explicit choice to discard THIS tour's old position;
|
|
111
|
+
// leaving the record in place would offer Resume again the moment this attempt is
|
|
112
|
+
// broken off at step 0, pointing at a position the user already walked away from.
|
|
113
|
+
//
|
|
114
|
+
// A DIFFERENT tour's position is not this action's to discard. It is still exactly what
|
|
115
|
+
// its own Resume offer needs, and it will lose the single slot soon enough — the moment
|
|
116
|
+
// this tour is broken off past step 0. Clearing it here instead means glancing at
|
|
117
|
+
// another tour and pressing Esc silently costs the position you were coming back to.
|
|
118
|
+
if (interrupted.value?.tourId === tourId) interrupted.value = null
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Pick a broken-off tour back up where it stopped. Falls back to a plain start when the
|
|
123
|
+
* saved position is for a DIFFERENT tour (or gone), so a caller never has to check first
|
|
124
|
+
* and a stale offer degrades to the ordinary behaviour instead of resuming the wrong tour.
|
|
125
|
+
*
|
|
126
|
+
* The index is not validated here: the store deliberately knows nothing about which tours
|
|
127
|
+
* exist or how many steps they have, so the overlay clamps it against the script it holds.
|
|
128
|
+
*/
|
|
129
|
+
function resumeTour(tourId: string) {
|
|
130
|
+
const at = interrupted.value
|
|
131
|
+
if (!at || at.tourId !== tourId) {
|
|
132
|
+
startTour(tourId)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
decision.value = 'accepted'
|
|
136
|
+
promptOpen.value = false
|
|
137
|
+
activeTourId.value = tourId
|
|
138
|
+
stepIndex.value = at.stepIndex
|
|
139
|
+
interrupted.value = null
|
|
98
140
|
}
|
|
99
141
|
|
|
100
142
|
/** Move the step cursor; the overlay owns bounds/skip logic and never goes below 0. */
|
|
@@ -102,19 +144,45 @@ export const useTutorialStore = defineStore(
|
|
|
102
144
|
stepIndex.value = Math.max(0, index)
|
|
103
145
|
}
|
|
104
146
|
|
|
105
|
-
/**
|
|
106
|
-
|
|
147
|
+
/** Clear the live cursor. Shared by the two ways a tour ends, which differ only in what
|
|
148
|
+
* they leave behind (a resume point vs. a completion). */
|
|
149
|
+
function clearCursor() {
|
|
107
150
|
activeTourId.value = null
|
|
108
151
|
stepIndex.value = 0
|
|
109
152
|
}
|
|
110
153
|
|
|
154
|
+
/**
|
|
155
|
+
* Abandon the running tour without marking it complete: Skip, Esc, or a runtime that
|
|
156
|
+
* could not resolve the tour at all.
|
|
157
|
+
*
|
|
158
|
+
* Records where it stopped so the prompt can offer to resume — except from the very first
|
|
159
|
+
* step, where resuming and starting are the same thing and an offer to "resume" would be
|
|
160
|
+
* noise. `resumable: false` is for the runtime's own bail-outs, which stop BECAUSE the
|
|
161
|
+
* position is unusable and must not hand it back.
|
|
162
|
+
*/
|
|
163
|
+
function stopTour(options?: { resumable?: boolean }) {
|
|
164
|
+
const id = activeTourId.value
|
|
165
|
+
if (id !== null && stepIndex.value > 0 && options?.resumable !== false) {
|
|
166
|
+
interrupted.value = { tourId: id, stepIndex: stepIndex.value }
|
|
167
|
+
}
|
|
168
|
+
clearCursor()
|
|
169
|
+
}
|
|
170
|
+
|
|
111
171
|
/** Finish the running tour: record completion (idempotent) and clear the cursor. */
|
|
112
172
|
function completeTour() {
|
|
113
173
|
const id = activeTourId.value
|
|
114
174
|
if (id && !completedTourIds.value.includes(id)) {
|
|
115
175
|
completedTourIds.value = [...completedTourIds.value, id]
|
|
116
176
|
}
|
|
117
|
-
|
|
177
|
+
// A finished tour has no position left to resume, and an offer to resume the walkthrough
|
|
178
|
+
// the user just completed would sit beside its own Completed badge.
|
|
179
|
+
if (id !== null && interrupted.value?.tourId === id) interrupted.value = null
|
|
180
|
+
clearCursor()
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Where this tour was broken off, if it was; null otherwise (the Resume affordance). */
|
|
184
|
+
function interruptedAt(tourId: string): number | null {
|
|
185
|
+
return interrupted.value?.tourId === tourId ? interrupted.value.stepIndex : null
|
|
118
186
|
}
|
|
119
187
|
|
|
120
188
|
function isCompleted(tourId: string): boolean {
|
|
@@ -128,6 +196,7 @@ export const useTutorialStore = defineStore(
|
|
|
128
196
|
promptAutoOpened,
|
|
129
197
|
activeTourId,
|
|
130
198
|
stepIndex,
|
|
199
|
+
interrupted,
|
|
131
200
|
touring,
|
|
132
201
|
maybeOfferOnLaunch,
|
|
133
202
|
openPrompt,
|
|
@@ -135,10 +204,12 @@ export const useTutorialStore = defineStore(
|
|
|
135
204
|
deferPrompt,
|
|
136
205
|
decline,
|
|
137
206
|
startTour,
|
|
207
|
+
resumeTour,
|
|
138
208
|
setStepIndex,
|
|
139
209
|
stopTour,
|
|
140
210
|
completeTour,
|
|
141
211
|
isCompleted,
|
|
212
|
+
interruptedAt,
|
|
142
213
|
}
|
|
143
214
|
},
|
|
144
215
|
{ persist: { pick: ['decision', 'completedTourIds'] } },
|
package/app/utils/agentOutput.ts
CHANGED
|
@@ -10,7 +10,10 @@
|
|
|
10
10
|
// the rendered document at each heading into sections we can collapse
|
|
11
11
|
// independently and link from a ToC. That split is done over the parsed DOM, so
|
|
12
12
|
// it is independent of markdown-it's token internals.
|
|
13
|
-
|
|
13
|
+
// markdown-it 15 ships its own types (the retired `@types/markdown-it` stopped at 14): the
|
|
14
|
+
// default export is the CONSTRUCTOR and the instance type is the same-named type export, so
|
|
15
|
+
// the two need distinct local names where the old merged declaration allowed one.
|
|
16
|
+
import MarkdownIt, { type MarkdownIt as MarkdownItInstance } from 'markdown-it'
|
|
14
17
|
|
|
15
18
|
/**
|
|
16
19
|
* Stamp every TOP-LEVEL block element with its source line range
|
|
@@ -21,7 +24,7 @@ import MarkdownIt from 'markdown-it'
|
|
|
21
24
|
* tracked over the flat token stream) so a comment targets a whole paragraph/list/
|
|
22
25
|
* heading rather than a nested fragment.
|
|
23
26
|
*/
|
|
24
|
-
function sourceLinePlugin(md:
|
|
27
|
+
function sourceLinePlugin(md: MarkdownItInstance): void {
|
|
25
28
|
md.core.ruler.push('source_lines', (state) => {
|
|
26
29
|
let depth = 0
|
|
27
30
|
for (const token of state.tokens) {
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest'
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
computeCoachMarkLayout,
|
|
4
|
+
needsReveal,
|
|
5
|
+
resolveTours,
|
|
6
|
+
sortTours,
|
|
7
|
+
visibleArea,
|
|
8
|
+
} from '~/utils/tutorial'
|
|
3
9
|
import type { TutorialStep, TutorialTour } from '~/utils/tutorial'
|
|
4
10
|
import type { NavGates } from '~/modular/nav-contributions'
|
|
5
11
|
|
|
@@ -113,3 +119,55 @@ describe('computeCoachMarkLayout', () => {
|
|
|
113
119
|
expect(layout.left).toBe(10)
|
|
114
120
|
})
|
|
115
121
|
})
|
|
122
|
+
|
|
123
|
+
describe('needsReveal', () => {
|
|
124
|
+
const viewport = { width: 1000, height: 800 }
|
|
125
|
+
|
|
126
|
+
it('leaves a fully visible anchor alone', () => {
|
|
127
|
+
expect(needsReveal({ top: 100, left: 100, width: 120, height: 40 }, viewport)).toBe(false)
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
it('reveals an anchor scrolled or panned clean off screen', () => {
|
|
131
|
+
// The case the runtime could not see: an element off the viewport still has layout boxes,
|
|
132
|
+
// so it passed the visibility check and the ring was drawn at coordinates nobody can see.
|
|
133
|
+
expect(needsReveal({ top: -400, left: 100, width: 120, height: 40 }, viewport)).toBe(true)
|
|
134
|
+
expect(needsReveal({ top: 100, left: 1400, width: 120, height: 40 }, viewport)).toBe(true)
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('reveals a small anchor that is only slightly on screen', () => {
|
|
138
|
+
// 25% of its width inside the right edge: enough to have a rect, not enough to point at.
|
|
139
|
+
expect(needsReveal({ top: 100, left: 970, width: 120, height: 40 }, viewport)).toBe(true)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
it('leaves an anchor BIGGER than the viewport alone while it fills the screen', () => {
|
|
143
|
+
// `board-canvas` and `sidebar` can never clear a fraction of their own area, so measuring
|
|
144
|
+
// against that would pan the camera on every step that points at one of them.
|
|
145
|
+
expect(needsReveal({ top: -200, left: -200, width: 2000, height: 1600 }, viewport)).toBe(false)
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it('reveals an oversized anchor that has left the screen anyway', () => {
|
|
149
|
+
expect(needsReveal({ top: -1700, left: 0, width: 2000, height: 1600 }, viewport)).toBe(true)
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('never reveals a zero-area anchor', () => {
|
|
153
|
+
// There is no position to bring anywhere, and treating it as off-screen would make every
|
|
154
|
+
// degenerate rect trigger a camera move.
|
|
155
|
+
expect(needsReveal({ top: 0, left: 0, width: 0, height: 0 }, viewport)).toBe(false)
|
|
156
|
+
})
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
describe('visibleArea', () => {
|
|
160
|
+
const viewport = { width: 1000, height: 800 }
|
|
161
|
+
|
|
162
|
+
it('is the full area when the rect is inside', () => {
|
|
163
|
+
expect(visibleArea({ top: 10, left: 10, width: 100, height: 50 }, viewport)).toBe(5000)
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
it('is the clipped area when the rect straddles an edge', () => {
|
|
167
|
+
expect(visibleArea({ top: 10, left: -60, width: 100, height: 50 }, viewport)).toBe(40 * 50)
|
|
168
|
+
})
|
|
169
|
+
|
|
170
|
+
it('is zero for a rect with no overlap at all', () => {
|
|
171
|
+
expect(visibleArea({ top: 10, left: 2000, width: 100, height: 50 }, viewport)).toBe(0)
|
|
172
|
+
})
|
|
173
|
+
})
|
package/app/utils/tutorial.ts
CHANGED
|
@@ -115,9 +115,38 @@ export interface TutorialTour {
|
|
|
115
115
|
/** How long the overlay polls for a step's anchor before auto-skipping the step. */
|
|
116
116
|
export const DEFAULT_TARGET_WAIT_MS = 4000
|
|
117
117
|
|
|
118
|
-
/**
|
|
118
|
+
/**
|
|
119
|
+
* How often the overlay re-queries the DOM while it is still HUNTING for a step's anchor.
|
|
120
|
+
* Fast on purpose — the anchor can appear at any moment (a modal mounting, a live event
|
|
121
|
+
* landing a card) and every tick spent waiting is time the user stares at a "looking for
|
|
122
|
+
* it" note — and bounded on purpose, by the step's own {@link DEFAULT_TARGET_WAIT_MS}-ish
|
|
123
|
+
* budget, after which the step is skipped and the hunt stops.
|
|
124
|
+
*/
|
|
119
125
|
export const TARGET_TRACK_INTERVAL_MS = 150
|
|
120
126
|
|
|
127
|
+
/**
|
|
128
|
+
* How often the overlay re-queries once it HAS an anchor.
|
|
129
|
+
*
|
|
130
|
+
* Anchored tracking is event-driven (scroll, resize, element resize, canvas pan/zoom), so
|
|
131
|
+
* this interval is only the backstop for movement nothing reports — and it is the tick that
|
|
132
|
+
* runs for the whole length of a tour, where the hunting one above is bounded by a step's
|
|
133
|
+
* wait budget. It also re-RESOLVES the selector rather than re-measuring the cached element,
|
|
134
|
+
* which is what lets a step re-anchor when the control it names is replaced underneath it.
|
|
135
|
+
*/
|
|
136
|
+
export const TARGET_IDLE_INTERVAL_MS = 400
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* How much of an anchor has to be on screen before the overlay leaves the viewport alone.
|
|
140
|
+
*
|
|
141
|
+
* Measured against `min(anchorArea, viewportArea)`, not against the anchor's own area, because
|
|
142
|
+
* the catalog points at controls of wildly different sizes: `add-task-submit` is a button that
|
|
143
|
+
* must be almost wholly visible to be pointed at, while `board-canvas` and `sidebar` are bigger
|
|
144
|
+
* than the viewport and can NEVER clear a fraction of their own area. Taking the smaller of the
|
|
145
|
+
* two means "mostly visible" for a small control and "filling a good part of the screen" for a
|
|
146
|
+
* large one, which is the same judgement in both cases.
|
|
147
|
+
*/
|
|
148
|
+
export const MIN_VISIBLE_RATIO = 0.5
|
|
149
|
+
|
|
121
150
|
/** Deterministic tour-list order: `order`, then `id`. */
|
|
122
151
|
export function sortTours(tours: readonly TutorialTour[]): TutorialTour[] {
|
|
123
152
|
return [...tours].sort((a, b) => a.order - b.order || a.id.localeCompare(b.id))
|
|
@@ -156,6 +185,43 @@ export interface TutorialRect {
|
|
|
156
185
|
height: number
|
|
157
186
|
}
|
|
158
187
|
|
|
188
|
+
/**
|
|
189
|
+
* How much of `rect` lies inside the viewport, in square pixels. Zero when they don't overlap
|
|
190
|
+
* at all, which is the case that matters: an anchor scrolled or panned off screen.
|
|
191
|
+
*/
|
|
192
|
+
export function visibleArea(
|
|
193
|
+
rect: TutorialRect,
|
|
194
|
+
viewport: { width: number; height: number },
|
|
195
|
+
): number {
|
|
196
|
+
const overlapWidth = Math.min(rect.left + rect.width, viewport.width) - Math.max(rect.left, 0)
|
|
197
|
+
const overlapHeight = Math.min(rect.top + rect.height, viewport.height) - Math.max(rect.top, 0)
|
|
198
|
+
return Math.max(0, overlapWidth) * Math.max(0, overlapHeight)
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Does the overlay have to bring this anchor into view before the step can read correctly?
|
|
203
|
+
*
|
|
204
|
+
* The runtime accepts any element with layout boxes, and an element scrolled out of a panel or
|
|
205
|
+
* panned off the board canvas still HAS them — so without this check the highlight ring is
|
|
206
|
+
* drawn at off-screen coordinates while `computeCoachMarkLayout` clamps the tooltip to a
|
|
207
|
+
* viewport edge, leaving the user reading "click this" beside nothing at all. It bites the
|
|
208
|
+
* most-travelled steps hardest: the two `task-card` steps anchor whichever card is first in
|
|
209
|
+
* the DOM, which on a populated board is the one least likely to be the one on screen.
|
|
210
|
+
*
|
|
211
|
+
* Pure, so the threshold is pinned by unit tests rather than eyeballed against a real board.
|
|
212
|
+
* A zero-area anchor never needs revealing: there is no position to bring anywhere, and
|
|
213
|
+
* treating it as off-screen would make every degenerate rect trigger a canvas pan.
|
|
214
|
+
*/
|
|
215
|
+
export function needsReveal(
|
|
216
|
+
rect: TutorialRect,
|
|
217
|
+
viewport: { width: number; height: number },
|
|
218
|
+
): boolean {
|
|
219
|
+
const area = rect.width * rect.height
|
|
220
|
+
if (area <= 0) return false
|
|
221
|
+
const required = MIN_VISIBLE_RATIO * Math.min(area, viewport.width * viewport.height)
|
|
222
|
+
return visibleArea(rect, viewport) < required
|
|
223
|
+
}
|
|
224
|
+
|
|
159
225
|
export interface CoachMarkLayout {
|
|
160
226
|
top: number
|
|
161
227
|
left: number
|
package/i18n/locales/de.json
CHANGED
|
@@ -6127,6 +6127,7 @@
|
|
|
6127
6127
|
"intro": "Geführte Touren zeigen dir die App direkt auf diesem Bildschirm: Sie heben die echten Bedienelemente hervor und sagen dir, was du anklicken sollst.",
|
|
6128
6128
|
"start": "Starten",
|
|
6129
6129
|
"restart": "Wiederholen",
|
|
6130
|
+
"resume": "Fortsetzen",
|
|
6130
6131
|
"completed": "Abgeschlossen",
|
|
6131
6132
|
"decline": "Nein danke",
|
|
6132
6133
|
"later": "Vielleicht später",
|
|
@@ -6139,6 +6140,7 @@
|
|
|
6139
6140
|
"done": "Fertig",
|
|
6140
6141
|
"progress": "Schritt {current} von {total}",
|
|
6141
6142
|
"ariaLabel": "Tutorial-Schritt",
|
|
6143
|
+
"announcement": "Schritt {current} von {total}. {title}. {body}",
|
|
6142
6144
|
"abridged": "1 Schritt wurde übersprungen: dieses Element gehört nicht zu diesem Board. | {count} Schritte wurden übersprungen: diese Elemente gehören nicht zu diesem Board.",
|
|
6143
6145
|
"searching": "Suche das hervorgehobene Element...",
|
|
6144
6146
|
"clickHint": "Klicke auf das hervorgehobene Element, um fortzufahren"
|
package/i18n/locales/en.json
CHANGED
|
@@ -6331,6 +6331,10 @@
|
|
|
6331
6331
|
"intro": "Guided tours walk you through the app right on this screen: they highlight the actual controls and tell you what to click.",
|
|
6332
6332
|
"start": "Start",
|
|
6333
6333
|
"restart": "Repeat",
|
|
6334
|
+
"resume": "Resume",
|
|
6335
|
+
"@resume": {
|
|
6336
|
+
"description": "Verb, on a button: pick a guided tour back up from where it was broken off. Not the noun (CV/resume)."
|
|
6337
|
+
},
|
|
6334
6338
|
"completed": "Completed",
|
|
6335
6339
|
"decline": "No thanks",
|
|
6336
6340
|
"later": "Maybe later",
|
|
@@ -6343,6 +6347,10 @@
|
|
|
6343
6347
|
"done": "Finish",
|
|
6344
6348
|
"progress": "Step {current} of {total}",
|
|
6345
6349
|
"ariaLabel": "Tutorial step",
|
|
6350
|
+
"announcement": "Step {current} of {total}. {title}. {body}",
|
|
6351
|
+
"@announcement": {
|
|
6352
|
+
"description": "Screen-reader-only announcement read out on every step change; it is never shown on screen. {title} and {body} are the step's own heading and full sentence, so the separators around them have to be real sentence punctuation in the target language."
|
|
6353
|
+
},
|
|
6346
6354
|
"abridged": "1 step was skipped: that control is not part of this board. | {count} steps were skipped: those controls are not part of this board.",
|
|
6347
6355
|
"@abridged": {
|
|
6348
6356
|
"description": "Shown on a tour's final card when steps were skipped. Needs the plural forms of the target language (pl/uk take three: one | few | many); {count} is the number of skipped steps."
|
package/i18n/locales/es.json
CHANGED
|
@@ -6115,6 +6115,7 @@
|
|
|
6115
6115
|
"intro": "Los recorridos guiados te muestran la aplicación directamente en esta pantalla: resaltan los controles reales y te dicen dónde hacer clic.",
|
|
6116
6116
|
"start": "Empezar",
|
|
6117
6117
|
"restart": "Repetir",
|
|
6118
|
+
"resume": "Reanudar",
|
|
6118
6119
|
"completed": "Completado",
|
|
6119
6120
|
"decline": "No, gracias",
|
|
6120
6121
|
"later": "Quizás más tarde",
|
|
@@ -6127,6 +6128,7 @@
|
|
|
6127
6128
|
"done": "Finalizar",
|
|
6128
6129
|
"progress": "Paso {current} de {total}",
|
|
6129
6130
|
"ariaLabel": "Paso del tutorial",
|
|
6131
|
+
"announcement": "Paso {current} de {total}. {title}. {body}",
|
|
6130
6132
|
"abridged": "Se omitió 1 paso: ese control no forma parte de este tablero. | Se omitieron {count} pasos: esos controles no forman parte de este tablero.",
|
|
6131
6133
|
"searching": "Buscando el control resaltado...",
|
|
6132
6134
|
"clickHint": "Haz clic en el control resaltado para continuar"
|
package/i18n/locales/fr.json
CHANGED
|
@@ -6115,6 +6115,7 @@
|
|
|
6115
6115
|
"intro": "Les visites guidées vous montrent l'application directement à l'écran : elles mettent en évidence les vrais contrôles et vous indiquent où cliquer.",
|
|
6116
6116
|
"start": "Commencer",
|
|
6117
6117
|
"restart": "Refaire",
|
|
6118
|
+
"resume": "Reprendre",
|
|
6118
6119
|
"completed": "Terminée",
|
|
6119
6120
|
"decline": "Non merci",
|
|
6120
6121
|
"later": "Peut-être plus tard",
|
|
@@ -6127,6 +6128,7 @@
|
|
|
6127
6128
|
"done": "Terminer",
|
|
6128
6129
|
"progress": "Étape {current} sur {total}",
|
|
6129
6130
|
"ariaLabel": "Étape du tutoriel",
|
|
6131
|
+
"announcement": "Étape {current} sur {total}. {title}. {body}",
|
|
6130
6132
|
"abridged": "1 étape a été ignorée : ce contrôle ne fait pas partie de ce tableau. | {count} étapes ont été ignorées : ces contrôles ne font pas partie de ce tableau.",
|
|
6131
6133
|
"searching": "Recherche du contrôle mis en évidence...",
|
|
6132
6134
|
"clickHint": "Cliquez sur le contrôle mis en évidence pour continuer"
|
package/i18n/locales/he.json
CHANGED
|
@@ -6126,6 +6126,7 @@
|
|
|
6126
6126
|
"intro": "סיורים מודרכים מציגים את האפליקציה ישירות על המסך: הם מדגישים את הפקדים האמיתיים ואומרים לך על מה ללחוץ.",
|
|
6127
6127
|
"start": "התחל",
|
|
6128
6128
|
"restart": "התחל שוב",
|
|
6129
|
+
"resume": "המשך",
|
|
6129
6130
|
"completed": "הושלם",
|
|
6130
6131
|
"decline": "לא תודה",
|
|
6131
6132
|
"later": "אולי מאוחר יותר",
|
|
@@ -6138,6 +6139,7 @@
|
|
|
6138
6139
|
"done": "סיום",
|
|
6139
6140
|
"progress": "שלב {current} מתוך {total}",
|
|
6140
6141
|
"ariaLabel": "שלב במדריך",
|
|
6142
|
+
"announcement": "שלב {current} מתוך {total}. {title}. {body}",
|
|
6141
6143
|
"abridged": "דילגנו על שלב אחד: הפקד הזה אינו חלק מהלוח הזה. | דילגנו על {count} שלבים: הפקדים האלה אינם חלק מהלוח הזה.",
|
|
6142
6144
|
"searching": "מחפש את הפקד המודגש...",
|
|
6143
6145
|
"clickHint": "לחץ על הפקד המודגש כדי להמשיך"
|
package/i18n/locales/it.json
CHANGED
|
@@ -6127,6 +6127,7 @@
|
|
|
6127
6127
|
"intro": "I tour guidati ti mostrano l’applicazione direttamente sullo schermo: evidenziano i controlli reali e ti dicono dove cliccare.",
|
|
6128
6128
|
"start": "Inizia",
|
|
6129
6129
|
"restart": "Ripeti",
|
|
6130
|
+
"resume": "Riprendi",
|
|
6130
6131
|
"completed": "Completato",
|
|
6131
6132
|
"decline": "No grazie",
|
|
6132
6133
|
"later": "Forse più tardi",
|
|
@@ -6139,6 +6140,7 @@
|
|
|
6139
6140
|
"done": "Fine",
|
|
6140
6141
|
"progress": "Passaggio {current} di {total}",
|
|
6141
6142
|
"ariaLabel": "Passaggio del tutorial",
|
|
6143
|
+
"announcement": "Passaggio {current} di {total}. {title}. {body}",
|
|
6142
6144
|
"abridged": "1 passaggio è stato saltato: quel controllo non fa parte di questa board. | {count} passaggi sono stati saltati: quei controlli non fanno parte di questa board.",
|
|
6143
6145
|
"searching": "Ricerca del controllo evidenziato...",
|
|
6144
6146
|
"clickHint": "Clicca sul controllo evidenziato per continuare"
|
package/i18n/locales/ja.json
CHANGED
|
@@ -6127,6 +6127,7 @@
|
|
|
6127
6127
|
"intro": "ガイドツアーは、この画面上でアプリの使い方を案内します。実際のコントロールをハイライトし、どこをクリックすればよいかをお知らせします。",
|
|
6128
6128
|
"start": "開始",
|
|
6129
6129
|
"restart": "もう一度",
|
|
6130
|
+
"resume": "再開",
|
|
6130
6131
|
"completed": "完了",
|
|
6131
6132
|
"decline": "結構です",
|
|
6132
6133
|
"later": "あとで",
|
|
@@ -6139,6 +6140,7 @@
|
|
|
6139
6140
|
"done": "完了",
|
|
6140
6141
|
"progress": "ステップ {current} / {total}",
|
|
6141
6142
|
"ariaLabel": "チュートリアルのステップ",
|
|
6143
|
+
"announcement": "ステップ {current} / {total}。{title}。{body}",
|
|
6142
6144
|
"abridged": "1 つのステップをスキップしました。その操作はこのボードにはありません。 | {count} 個のステップをスキップしました。それらの操作はこのボードにはありません。",
|
|
6143
6145
|
"searching": "ハイライトされたコントロールを探しています...",
|
|
6144
6146
|
"clickHint": "続行するには、ハイライトされたコントロールをクリックしてください"
|
package/i18n/locales/pl.json
CHANGED
|
@@ -6115,6 +6115,7 @@
|
|
|
6115
6115
|
"intro": "Interaktywne przewodniki pokazują aplikację bezpośrednio na tym ekranie: podświetlają prawdziwe elementy interfejsu i mówią, co kliknąć.",
|
|
6116
6116
|
"start": "Rozpocznij",
|
|
6117
6117
|
"restart": "Powtórz",
|
|
6118
|
+
"resume": "Wznów",
|
|
6118
6119
|
"completed": "Ukończono",
|
|
6119
6120
|
"decline": "Nie, dziękuję",
|
|
6120
6121
|
"later": "Może później",
|
|
@@ -6127,6 +6128,7 @@
|
|
|
6127
6128
|
"done": "Zakończ",
|
|
6128
6129
|
"progress": "Krok {current} z {total}",
|
|
6129
6130
|
"ariaLabel": "Krok samouczka",
|
|
6131
|
+
"announcement": "Krok {current} z {total}. {title}. {body}",
|
|
6130
6132
|
"abridged": "Pominięto 1 krok: tego elementu nie ma na tej tablicy. | Pominięto {count} kroki: tych elementów nie ma na tej tablicy. | Pominięto {count} kroków: tych elementów nie ma na tej tablicy.",
|
|
6131
6133
|
"searching": "Szukanie podświetlonego elementu...",
|
|
6132
6134
|
"clickHint": "Kliknij podświetlony element, aby kontynuować"
|
package/i18n/locales/tr.json
CHANGED
|
@@ -6127,6 +6127,7 @@
|
|
|
6127
6127
|
"intro": "Rehberli turlar uygulamayı doğrudan bu ekranda gösterir: gerçek denetimleri vurgular ve nereye tıklayacağınızı söyler.",
|
|
6128
6128
|
"start": "Başla",
|
|
6129
6129
|
"restart": "Tekrarla",
|
|
6130
|
+
"resume": "Devam et",
|
|
6130
6131
|
"completed": "Tamamlandı",
|
|
6131
6132
|
"decline": "Hayır, teşekkürler",
|
|
6132
6133
|
"later": "Belki daha sonra",
|
|
@@ -6139,6 +6140,7 @@
|
|
|
6139
6140
|
"done": "Bitir",
|
|
6140
6141
|
"progress": "Adım {current} / {total}",
|
|
6141
6142
|
"ariaLabel": "Eğitim adımı",
|
|
6143
|
+
"announcement": "Adım {current} / {total}. {title}. {body}",
|
|
6142
6144
|
"abridged": "1 adım atlandı: bu denetim bu panonun parçası değil. | {count} adım atlandı: bu denetimler bu panonun parçası değil.",
|
|
6143
6145
|
"searching": "Vurgulanan denetim aranıyor...",
|
|
6144
6146
|
"clickHint": "Devam etmek için vurgulanan denetime tıklayın"
|
package/i18n/locales/uk.json
CHANGED
|
@@ -6115,6 +6115,7 @@
|
|
|
6115
6115
|
"intro": "Інтерактивні тури показують застосунок просто на цьому екрані: вони підсвічують справжні елементи керування й підказують, що натискати.",
|
|
6116
6116
|
"start": "Почати",
|
|
6117
6117
|
"restart": "Повторити",
|
|
6118
|
+
"resume": "Продовжити",
|
|
6118
6119
|
"completed": "Завершено",
|
|
6119
6120
|
"decline": "Ні, дякую",
|
|
6120
6121
|
"later": "Можливо, пізніше",
|
|
@@ -6127,6 +6128,7 @@
|
|
|
6127
6128
|
"done": "Готово",
|
|
6128
6129
|
"progress": "Крок {current} з {total}",
|
|
6129
6130
|
"ariaLabel": "Крок навчального туру",
|
|
6131
|
+
"announcement": "Крок {current} з {total}. {title}. {body}",
|
|
6130
6132
|
"abridged": "Пропущено 1 крок: цього елемента немає на цій дошці. | Пропущено {count} кроки: цих елементів немає на цій дошці. | Пропущено {count} кроків: цих елементів немає на цій дошці.",
|
|
6131
6133
|
"searching": "Пошук підсвіченого елемента...",
|
|
6132
6134
|
"clickHint": "Натисніть підсвічений елемент, щоб продовжити"
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.202.0",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
"@modular-vue/runtime": "^1.4.1",
|
|
26
26
|
"@modular-vue/vue": "^1.4.1",
|
|
27
27
|
"@nuxt/ui": "^4.10.0",
|
|
28
|
-
"@nuxtjs/i18n": "^10.
|
|
28
|
+
"@nuxtjs/i18n": "^10.6.0",
|
|
29
29
|
"@pinia/nuxt": "^1.0.1",
|
|
30
30
|
"@toad-contracts/core": "0.4.0",
|
|
31
31
|
"@toad-contracts/frontend-http-client": "0.3.2",
|
|
@@ -33,25 +33,24 @@
|
|
|
33
33
|
"@vue-flow/background": "^1.3.2",
|
|
34
34
|
"@vue-flow/core": "^1.48.2",
|
|
35
35
|
"@vue-flow/node-resizer": "^1.5.1",
|
|
36
|
-
"@vueuse/core": "^14.
|
|
37
|
-
"markdown-it": "^
|
|
36
|
+
"@vueuse/core": "^14.4.0",
|
|
37
|
+
"markdown-it": "^15.0.0",
|
|
38
38
|
"pinia": "^4.0.2",
|
|
39
39
|
"pinia-plugin-persistedstate": "^4.7.1",
|
|
40
40
|
"valibot": "^1.4.2",
|
|
41
41
|
"vue": "3.5.40",
|
|
42
42
|
"wretch": "^3.0.9",
|
|
43
|
-
"@cat-factory/contracts": "0.210.
|
|
43
|
+
"@cat-factory/contracts": "0.210.1"
|
|
44
44
|
},
|
|
45
45
|
"devDependencies": {
|
|
46
46
|
"@toad-contracts/testing": "0.3.2",
|
|
47
|
-
"@types/markdown-it": "^14.1.2",
|
|
48
47
|
"happy-dom": "^20.11.1",
|
|
49
48
|
"msw": "^2.15.0",
|
|
50
49
|
"nuxt": "^4.5.1",
|
|
51
50
|
"typescript": "^6.0.3",
|
|
52
51
|
"vitest": "^4.1.10",
|
|
53
52
|
"vue-i18n-extract": "^2.0.7",
|
|
54
|
-
"vue-tsc": "^3.3.
|
|
53
|
+
"vue-tsc": "^3.3.9"
|
|
55
54
|
},
|
|
56
55
|
"peerDependencies": {
|
|
57
56
|
"nuxt": "^4.5.0"
|