@cat-factory/app 0.196.0 → 0.197.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 +49 -3
- package/app/components/brainstorm/BrainstormWindow.vue +11 -4
- package/app/components/clarity/ClarityReviewWindow.vue +11 -4
- package/app/components/initiative/InitiativePlanReview.vue +44 -37
- package/app/components/initiative/InitiativeTrackerWindow.vue +12 -9
- package/app/components/layout/SideBar.vue +13 -2
- package/app/components/layout/UiModeSwitcher.vue +66 -39
- package/app/components/panels/ResultWindowShell.logic.spec.ts +174 -0
- package/app/components/panels/ResultWindowShell.logic.ts +31 -0
- package/app/components/panels/ResultWindowShell.vue +37 -8
- package/app/components/prReview/PrReviewWindow.vue +15 -7
- package/app/components/requirements/RequirementsReviewWindow.vue +15 -5
- package/app/components/spec/ServiceSpecWindow.vue +7 -4
- package/app/components/testing/TestReportWindow.vue +11 -6
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +126 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +92 -0
- package/app/components/tutorial/TutorialOverlay.vue +273 -0
- package/app/components/tutorial/TutorialPrompt.vue +102 -0
- package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
- package/app/composables/useNavContributions.ts +1 -0
- package/app/composables/usePipelineErrorToast.ts +6 -164
- package/app/composables/useTutorialTours.ts +18 -0
- package/app/modular/nav-contributions.spec.ts +4 -0
- package/app/modular/nav-contributions.ts +38 -3
- package/app/modular/nav-gates.ts +7 -0
- package/app/modular/registry.spec.ts +1 -0
- package/app/modular/registry.ts +3 -1
- package/app/modular/slots.ts +7 -0
- package/app/modular/tutorial-tours.spec.ts +107 -0
- package/app/modular/tutorial-tours.ts +147 -0
- package/app/pages/index.vue +61 -0
- package/app/stores/board/dependencies.ts +52 -0
- package/app/stores/board/placement.ts +4 -37
- package/app/stores/execution/pendingGates.ts +109 -0
- package/app/stores/execution.ts +7 -94
- package/app/stores/requirements/recommendations.ts +77 -0
- package/app/stores/requirements.ts +17 -43
- package/app/stores/tutorial.spec.ts +135 -0
- package/app/stores/tutorial.ts +145 -0
- package/app/stores/workspace/commands.ts +77 -0
- package/app/stores/workspace.ts +11 -50
- package/app/utils/tutorial.spec.ts +68 -0
- package/app/utils/tutorial.ts +192 -0
- package/i18n/locales/de.json +90 -2
- package/i18n/locales/en.json +96 -2
- package/i18n/locales/es.json +90 -2
- package/i18n/locales/fr.json +90 -2
- package/i18n/locales/he.json +90 -2
- package/i18n/locales/it.json +90 -2
- package/i18n/locales/ja.json +90 -2
- package/i18n/locales/pl.json +90 -2
- package/i18n/locales/tr.json +90 -2
- package/i18n/locales/uk.json +90 -2
- package/package.json +1 -1
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { DEFAULT_TARGET_WAIT_MS } from '~/utils/tutorial'
|
|
2
|
+
import type { TutorialStep } from '~/utils/tutorial'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The tour runtime's decision logic, extracted from `TutorialOverlay.vue` so it is
|
|
6
|
+
* unit-testable (the vitest setup has no SFC transform — same split as
|
|
7
|
+
* `AppOverlayHost.logic.ts`). The component keeps the DOM work (querying, measuring,
|
|
8
|
+
* listeners); everything that DECIDES something lives here.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which way the step cursor is travelling. It matters only for a step whose anchor
|
|
13
|
+
* never appears: a skip must continue in the direction the user was already going,
|
|
14
|
+
* or pressing Back onto a step whose control this deployment doesn't render would
|
|
15
|
+
* bounce them straight forward again — an unusable Back button.
|
|
16
|
+
*/
|
|
17
|
+
export type TutorialDirection = 'forward' | 'back'
|
|
18
|
+
|
|
19
|
+
/** What the overlay does with a step whose anchor never appeared. */
|
|
20
|
+
export type SkipOutcome = { kind: 'move'; index: number } | { kind: 'complete' }
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* What a `data-testid` may look like. Every one of the ~470 test ids in this layer is
|
|
24
|
+
* lowercase kebab-case, and the e2e suite's convention keeps it that way, so this rejects
|
|
25
|
+
* nothing real — which is what makes it usable as a GUARD rather than as escaping.
|
|
26
|
+
*
|
|
27
|
+
* A tour is DATA and a consumer deployment authors its own, so an id reaches
|
|
28
|
+
* `querySelector` from outside this package. Escaping it is the obvious move and the wrong
|
|
29
|
+
* one: `[data-testid="a\"b"]` is valid CSS that real selector engines disagree about (it
|
|
30
|
+
* throws in happy-dom), so an id with a quote could still take down the tracking interval —
|
|
31
|
+
* several times a second, for as long as the tour runs. Validating the shape instead means
|
|
32
|
+
* no selector is ever built from a string that could break one, and an id that fails simply
|
|
33
|
+
* finds no anchor, which the runtime already handles as a skipped step.
|
|
34
|
+
*/
|
|
35
|
+
export const TARGET_ID_PATTERN = /^[a-z0-9-]+$/
|
|
36
|
+
|
|
37
|
+
/** Is this a well-formed anchor id, i.e. safe to put in a selector? */
|
|
38
|
+
export function isSafeTargetId(id: string): boolean {
|
|
39
|
+
return TARGET_ID_PATTERN.test(id)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The `data-testid`s a step declares, in priority order (target first, then fallbacks). */
|
|
43
|
+
export function stepTargetIds(step: TutorialStep): string[] {
|
|
44
|
+
return [step.target, ...(step.altTargets ?? [])].filter((id): id is string => id !== undefined)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The selectors to try for a step: its declared ids, minus any that are malformed. */
|
|
48
|
+
export function stepTargetSelectors(step: TutorialStep): string[] {
|
|
49
|
+
return stepTargetIds(step)
|
|
50
|
+
.filter(isSafeTargetId)
|
|
51
|
+
.map((id) => `[data-testid="${id}"]`)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** How long this step may spend looking for its anchor before it is skipped. */
|
|
55
|
+
export function waitBudgetMs(step: TutorialStep): number {
|
|
56
|
+
return step.waitForTargetMs ?? DEFAULT_TARGET_WAIT_MS
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where the cursor goes when the current step's anchor never appeared.
|
|
61
|
+
*
|
|
62
|
+
* Travelling BACK off the first step has nowhere further back to go, so it reverses to
|
|
63
|
+
* forward rather than pinning the tour on an anchor that is never coming.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveSkip(
|
|
66
|
+
index: number,
|
|
67
|
+
direction: TutorialDirection,
|
|
68
|
+
total: number,
|
|
69
|
+
): SkipOutcome {
|
|
70
|
+
if (direction === 'back' && index > 0) return { kind: 'move', index: index - 1 }
|
|
71
|
+
return index + 1 < total ? { kind: 'move', index: index + 1 } : { kind: 'complete' }
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Does this real click count as the "now click this" step's advance? */
|
|
75
|
+
export function isTargetClickAdvance(
|
|
76
|
+
step: TutorialStep | null,
|
|
77
|
+
targetEl: { contains: (node: Node) => boolean } | null,
|
|
78
|
+
eventTarget: EventTarget | null,
|
|
79
|
+
): boolean {
|
|
80
|
+
if (!step || step.advanceOn !== 'target-click' || !targetEl) return false
|
|
81
|
+
return eventTarget instanceof Node && targetEl.contains(eventTarget)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A tour is ABRIDGED when it reached its end having skipped steps: the controls those
|
|
86
|
+
* steps point at aren't part of this board/role/deployment. The final card says so
|
|
87
|
+
* instead of congratulating the user on a walkthrough they never saw — absent and
|
|
88
|
+
* complete must not render the same.
|
|
89
|
+
*/
|
|
90
|
+
export function tourWasAbridged(skippedStepIds: ReadonlySet<string>): boolean {
|
|
91
|
+
return skippedStepIds.size > 0
|
|
92
|
+
}
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import {
|
|
3
|
+
computeCoachMarkLayout,
|
|
4
|
+
DEFAULT_TARGET_WAIT_MS,
|
|
5
|
+
TARGET_TRACK_INTERVAL_MS,
|
|
6
|
+
} from '~/utils/tutorial'
|
|
7
|
+
import type { CoachMarkLayout, TutorialRect, TutorialStep } from '~/utils/tutorial'
|
|
8
|
+
import {
|
|
9
|
+
isTargetClickAdvance,
|
|
10
|
+
resolveSkip,
|
|
11
|
+
stepTargetSelectors,
|
|
12
|
+
tourWasAbridged,
|
|
13
|
+
waitBudgetMs,
|
|
14
|
+
} from './TutorialOverlay.logic'
|
|
15
|
+
import type { TutorialDirection } from './TutorialOverlay.logic'
|
|
16
|
+
|
|
17
|
+
// The one shared tour runtime: resolves the running tour from the `tutorialTours` slot,
|
|
18
|
+
// anchors a highlight ring + tooltip to the current step's `data-testid`, and advances
|
|
19
|
+
// on Next or on a real click on the highlighted control. Mounted (from `pages/index.vue`)
|
|
20
|
+
// only while `tutorial.touring`, so all the DOM tracking below exists only mid-tour.
|
|
21
|
+
//
|
|
22
|
+
// Anchor tracking is a poll, not a one-shot query: board controls move (canvas pan/zoom,
|
|
23
|
+
// panels opening) and appear asynchronously (a step can point INTO the modal the previous
|
|
24
|
+
// step's click opens), so every tick re-queries and re-measures. A target that never
|
|
25
|
+
// appears within the step's wait SKIPS the step — controls are RBAC/tier/deployment
|
|
26
|
+
// dependent, and a tour is a set of opportunities, not a fixed script. Everything that
|
|
27
|
+
// DECIDES rather than measures lives in `TutorialOverlay.logic.ts`, which is unit-tested.
|
|
28
|
+
const { t } = useI18n()
|
|
29
|
+
const tutorial = useTutorialStore()
|
|
30
|
+
const { tours } = useTutorialTours()
|
|
31
|
+
|
|
32
|
+
const tour = computed(() => tours.value.find((x) => x.id === tutorial.activeTourId) ?? null)
|
|
33
|
+
const step = computed<TutorialStep | null>(() => tour.value?.steps[tutorial.stepIndex] ?? null)
|
|
34
|
+
const total = computed(() => tour.value?.steps.length ?? 0)
|
|
35
|
+
const isLast = computed(() => tour.value !== null && tutorial.stepIndex >= total.value - 1)
|
|
36
|
+
|
|
37
|
+
// The running tour vanished from the slot (a gate flipped, a consumer module withdrew it)
|
|
38
|
+
// or the cursor ran past the end: end the tour instead of rendering a dead overlay.
|
|
39
|
+
watch(
|
|
40
|
+
() => [tour.value, step.value] as const,
|
|
41
|
+
([tr, st]) => {
|
|
42
|
+
if (tutorial.touring && (!tr || !st)) tutorial.stopTour()
|
|
43
|
+
},
|
|
44
|
+
{ immediate: true },
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
const targetEl = ref<HTMLElement | null>(null)
|
|
48
|
+
const targetRect = ref<TutorialRect | null>(null)
|
|
49
|
+
const cardEl = ref<HTMLElement | null>(null)
|
|
50
|
+
const layout = ref<CoachMarkLayout>({ top: -9999, left: -9999, placement: 'center' })
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Wall-clock deadline for the current step's anchor search. A deadline rather than a
|
|
54
|
+
* per-tick counter: `measure()` also runs on resize and on every step change, so counting
|
|
55
|
+
* invocations let a resize drag burn a "4000 ms" budget in a fraction of that time.
|
|
56
|
+
*/
|
|
57
|
+
const searchDeadline = ref(0)
|
|
58
|
+
/** Which way `skipMissingStep` travels — see `resolveSkip`. */
|
|
59
|
+
const direction = ref<TutorialDirection>('forward')
|
|
60
|
+
/** Steps this run gave up on, so the final card can be honest about an abridged tour. */
|
|
61
|
+
const skippedStepIds = ref<Set<string>>(new Set())
|
|
62
|
+
|
|
63
|
+
/** A targeted step whose anchor hasn't been found yet (renders the waiting note). */
|
|
64
|
+
const searching = computed(() => step.value?.target !== undefined && targetRect.value === null)
|
|
65
|
+
const abridged = computed(() => tourWasAbridged(skippedStepIds.value))
|
|
66
|
+
|
|
67
|
+
const viewport = () => ({ width: window.innerWidth, height: window.innerHeight })
|
|
68
|
+
/** The tooltip's own size, or a sensible guess before it has rendered once. */
|
|
69
|
+
const cardSize = () => ({
|
|
70
|
+
width: cardEl.value?.offsetWidth ?? 320,
|
|
71
|
+
height: cardEl.value?.offsetHeight ?? 180,
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
function queryTarget(s: TutorialStep): HTMLElement | null {
|
|
75
|
+
for (const selector of stepTargetSelectors(s)) {
|
|
76
|
+
const el = document.querySelector<HTMLElement>(selector)
|
|
77
|
+
// `getClientRects().length` distinguishes a mounted-but-hidden control (display:none
|
|
78
|
+
// drawer item) from a visible one; pointing at an invisible control helps nobody.
|
|
79
|
+
if (el && el.getClientRects().length > 0) return el
|
|
80
|
+
}
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Give up on a step whose anchor never appeared, continuing the user's own direction. */
|
|
85
|
+
function skipMissingStep(s: TutorialStep) {
|
|
86
|
+
skippedStepIds.value = new Set(skippedStepIds.value).add(s.id)
|
|
87
|
+
const outcome = resolveSkip(tutorial.stepIndex, direction.value, total.value)
|
|
88
|
+
if (outcome.kind === 'complete') tutorial.completeTour()
|
|
89
|
+
else tutorial.setStepIndex(outcome.index)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function measure() {
|
|
93
|
+
const s = step.value
|
|
94
|
+
if (!s) return
|
|
95
|
+
if (!s.target) {
|
|
96
|
+
targetEl.value = null
|
|
97
|
+
targetRect.value = null
|
|
98
|
+
} else {
|
|
99
|
+
const el = queryTarget(s)
|
|
100
|
+
targetEl.value = el
|
|
101
|
+
if (!el) {
|
|
102
|
+
targetRect.value = null
|
|
103
|
+
// Centered while searching: the card must not sit at the PREVIOUS step's anchor —
|
|
104
|
+
// nor at its off-screen initial position — pointing at nothing.
|
|
105
|
+
layout.value = computeCoachMarkLayout(null, cardSize(), viewport())
|
|
106
|
+
if (performance.now() >= searchDeadline.value) skipMissingStep(s)
|
|
107
|
+
return
|
|
108
|
+
}
|
|
109
|
+
const r = el.getBoundingClientRect()
|
|
110
|
+
targetRect.value = { top: r.top, left: r.left, width: r.width, height: r.height }
|
|
111
|
+
}
|
|
112
|
+
layout.value = computeCoachMarkLayout(
|
|
113
|
+
s.target ? targetRect.value : null,
|
|
114
|
+
cardSize(),
|
|
115
|
+
viewport(),
|
|
116
|
+
s.placement,
|
|
117
|
+
)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Re-arm per step: fresh wait budget, drop the stale anchor immediately (the ring must not
|
|
121
|
+
// linger on the previous control while the next anchor is located), and re-measure once
|
|
122
|
+
// the card has re-rendered its new copy (its size feeds the layout).
|
|
123
|
+
watch(step, async (s) => {
|
|
124
|
+
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
125
|
+
targetEl.value = null
|
|
126
|
+
targetRect.value = null
|
|
127
|
+
await nextTick()
|
|
128
|
+
measure()
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
function advance() {
|
|
132
|
+
direction.value = 'forward'
|
|
133
|
+
if (isLast.value) tutorial.completeTour()
|
|
134
|
+
else tutorial.setStepIndex(tutorial.stepIndex + 1)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function back() {
|
|
138
|
+
direction.value = 'back'
|
|
139
|
+
tutorial.setStepIndex(tutorial.stepIndex - 1)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// "Now click this" steps: watch real clicks (capture phase, so a stopPropagation inside a
|
|
143
|
+
// control can't hide them) and follow along AFTER the app has reacted — the deferral lets
|
|
144
|
+
// the real handler open its modal/submit its form before the tour moves its anchor.
|
|
145
|
+
function onDocumentClick(event: MouseEvent) {
|
|
146
|
+
if (isTargetClickAdvance(step.value, targetEl.value, event.target)) {
|
|
147
|
+
window.setTimeout(advance, 0)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** Esc ends the tour, matching every other dismissible surface in the app. */
|
|
152
|
+
function onKeydown(event: KeyboardEvent) {
|
|
153
|
+
if (event.key === 'Escape') tutorial.stopTour()
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let trackTimer: ReturnType<typeof setInterval> | undefined
|
|
157
|
+
onMounted(() => {
|
|
158
|
+
const s = step.value
|
|
159
|
+
searchDeadline.value = performance.now() + (s ? waitBudgetMs(s) : DEFAULT_TARGET_WAIT_MS)
|
|
160
|
+
document.addEventListener('click', onDocumentClick, true)
|
|
161
|
+
document.addEventListener('keydown', onKeydown)
|
|
162
|
+
window.addEventListener('resize', measure)
|
|
163
|
+
trackTimer = setInterval(measure, TARGET_TRACK_INTERVAL_MS)
|
|
164
|
+
measure()
|
|
165
|
+
})
|
|
166
|
+
onUnmounted(() => {
|
|
167
|
+
document.removeEventListener('click', onDocumentClick, true)
|
|
168
|
+
document.removeEventListener('keydown', onKeydown)
|
|
169
|
+
window.removeEventListener('resize', measure)
|
|
170
|
+
if (trackTimer !== undefined) clearInterval(trackTimer)
|
|
171
|
+
})
|
|
172
|
+
</script>
|
|
173
|
+
|
|
174
|
+
<template>
|
|
175
|
+
<!-- Teleported so board/panel stacking contexts can't clip the marks; z-[70] sits above
|
|
176
|
+
the app's modals (z-50s), since steps legitimately point INTO an open modal. -->
|
|
177
|
+
<Teleport to="body">
|
|
178
|
+
<div v-if="step" data-testid="tutorial-overlay">
|
|
179
|
+
<div
|
|
180
|
+
v-if="targetRect"
|
|
181
|
+
class="ring-primary-400 outline-primary-400/25 pointer-events-none fixed z-[70] rounded-lg outline-4 ring-2 transition-all duration-150"
|
|
182
|
+
:style="{
|
|
183
|
+
top: `${targetRect.top - 4}px`,
|
|
184
|
+
left: `${targetRect.left - 4}px`,
|
|
185
|
+
width: `${targetRect.width + 8}px`,
|
|
186
|
+
height: `${targetRect.height + 8}px`,
|
|
187
|
+
}"
|
|
188
|
+
data-testid="tutorial-highlight"
|
|
189
|
+
/>
|
|
190
|
+
<!-- `pointer-events-auto` AND the swallowed `pointerdown` are both required for the
|
|
191
|
+
steps that point INSIDE an open modal: Nuxt UI's modal is a reka-ui dismissable
|
|
192
|
+
layer, which sets `body { pointer-events: none }` (leaving this card inert) and
|
|
193
|
+
dismisses on a document-level pointerdown outside its own content (so a press on
|
|
194
|
+
this card would close the user's half-filled form instead of pressing a button). -->
|
|
195
|
+
<div
|
|
196
|
+
ref="cardEl"
|
|
197
|
+
role="dialog"
|
|
198
|
+
aria-live="polite"
|
|
199
|
+
:aria-label="t('tutorial.overlay.ariaLabel')"
|
|
200
|
+
class="pointer-events-auto fixed z-[70] w-80 max-w-[calc(100vw-16px)] rounded-xl border border-slate-700 bg-slate-900 p-4 shadow-2xl"
|
|
201
|
+
:style="{ top: `${layout.top}px`, left: `${layout.left}px` }"
|
|
202
|
+
data-testid="tutorial-tooltip"
|
|
203
|
+
@pointerdown.stop
|
|
204
|
+
>
|
|
205
|
+
<div class="mb-1 flex items-start justify-between gap-3">
|
|
206
|
+
<h3 class="text-sm font-semibold text-slate-100">{{ t(step.titleKey) }}</h3>
|
|
207
|
+
<span class="shrink-0 text-xs text-slate-500">
|
|
208
|
+
{{ t('tutorial.overlay.progress', { current: tutorial.stepIndex + 1, total }) }}
|
|
209
|
+
</span>
|
|
210
|
+
</div>
|
|
211
|
+
<p class="text-sm text-slate-300">{{ t(step.bodyKey) }}</p>
|
|
212
|
+
<p
|
|
213
|
+
v-if="searching"
|
|
214
|
+
class="mt-2 flex items-center gap-1.5 text-xs text-slate-400"
|
|
215
|
+
data-testid="tutorial-searching"
|
|
216
|
+
>
|
|
217
|
+
<UIcon name="i-lucide-loader" class="h-3.5 w-3.5 animate-spin" />
|
|
218
|
+
{{ t('tutorial.overlay.searching') }}
|
|
219
|
+
</p>
|
|
220
|
+
<p
|
|
221
|
+
v-else-if="step.advanceOn === 'target-click'"
|
|
222
|
+
class="text-primary-300 mt-2 text-xs"
|
|
223
|
+
data-testid="tutorial-click-hint"
|
|
224
|
+
>
|
|
225
|
+
{{ t('tutorial.overlay.clickHint') }}
|
|
226
|
+
</p>
|
|
227
|
+
<!-- Reaching the end having skipped steps is NOT the same as having been shown the
|
|
228
|
+
whole tour: say so rather than congratulating the user either way. -->
|
|
229
|
+
<p
|
|
230
|
+
v-if="isLast && abridged"
|
|
231
|
+
class="mt-2 text-xs text-amber-300/90"
|
|
232
|
+
data-testid="tutorial-abridged"
|
|
233
|
+
>
|
|
234
|
+
{{ t('tutorial.overlay.abridged', { count: skippedStepIds.size }, skippedStepIds.size) }}
|
|
235
|
+
</p>
|
|
236
|
+
<div class="mt-3 flex items-center justify-between gap-2">
|
|
237
|
+
<UButton
|
|
238
|
+
size="xs"
|
|
239
|
+
variant="ghost"
|
|
240
|
+
color="neutral"
|
|
241
|
+
data-testid="tutorial-skip"
|
|
242
|
+
@click="tutorial.stopTour()"
|
|
243
|
+
>
|
|
244
|
+
{{ t('tutorial.overlay.skip') }}
|
|
245
|
+
</UButton>
|
|
246
|
+
<div class="flex items-center gap-2">
|
|
247
|
+
<UButton
|
|
248
|
+
v-if="tutorial.stepIndex > 0"
|
|
249
|
+
size="xs"
|
|
250
|
+
variant="soft"
|
|
251
|
+
color="neutral"
|
|
252
|
+
data-testid="tutorial-back"
|
|
253
|
+
@click="back()"
|
|
254
|
+
>
|
|
255
|
+
{{ t('tutorial.overlay.back') }}
|
|
256
|
+
</UButton>
|
|
257
|
+
<!-- A click-to-advance step hides Next so the real control is the only way
|
|
258
|
+
forward (its Done form stays on a last step, which must be finishable). -->
|
|
259
|
+
<UButton
|
|
260
|
+
v-if="step.advanceOn !== 'target-click' || isLast"
|
|
261
|
+
size="xs"
|
|
262
|
+
color="primary"
|
|
263
|
+
data-testid="tutorial-next"
|
|
264
|
+
@click="advance()"
|
|
265
|
+
>
|
|
266
|
+
{{ isLast ? t('tutorial.overlay.done') : t('tutorial.overlay.next') }}
|
|
267
|
+
</UButton>
|
|
268
|
+
</div>
|
|
269
|
+
</div>
|
|
270
|
+
</div>
|
|
271
|
+
</div>
|
|
272
|
+
</Teleport>
|
|
273
|
+
</template>
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The tutorial launch prompt: asks once on first launch whether the user wants a guided
|
|
3
|
+
// tour, and doubles as the tour picker for later visits (command palette: "Take a tour").
|
|
4
|
+
// Lists whatever the merged `tutorialTours` slot offers this user (first-party + consumer
|
|
5
|
+
// tours, RBAC-gated per tour), so it grows with the catalog rather than hard-coding tours.
|
|
6
|
+
//
|
|
7
|
+
// The decision semantics live in the store: starting a tour or "No thanks" is SAVED (the
|
|
8
|
+
// prompt never auto-opens again), while closing without answering defers to next launch.
|
|
9
|
+
const { t } = useI18n()
|
|
10
|
+
const tutorial = useTutorialStore()
|
|
11
|
+
const { tours } = useTutorialTours()
|
|
12
|
+
|
|
13
|
+
const open = computed({
|
|
14
|
+
get: () => tutorial.promptOpen,
|
|
15
|
+
set: (v: boolean) => (v ? tutorial.openPrompt() : tutorial.closePrompt()),
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// Only an unanswered prompt offers the persistent "No thanks"; once a decision exists this
|
|
19
|
+
// is just a picker, and the only dismissal left is a plain close.
|
|
20
|
+
const undecided = computed(() => tutorial.decision === null)
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<UModal v-model:open="open" :title="t('tutorial.prompt.title')" :ui="{ content: 'max-w-lg' }">
|
|
25
|
+
<template #body>
|
|
26
|
+
<!-- NOTE for spec authors: this marks the modal's BODY, so it is the "prompt is
|
|
27
|
+
showing" signal, not a container for everything in the prompt. The footer's
|
|
28
|
+
decline/close buttons are in a SIBLING slot — address them from the page by
|
|
29
|
+
their own test ids rather than scoping a locator under this one. -->
|
|
30
|
+
<div class="space-y-4" data-testid="tutorial-prompt">
|
|
31
|
+
<p class="text-sm text-slate-300">{{ t('tutorial.prompt.intro') }}</p>
|
|
32
|
+
<ul class="space-y-2">
|
|
33
|
+
<li
|
|
34
|
+
v-for="tour in tours"
|
|
35
|
+
:key="tour.id"
|
|
36
|
+
class="flex items-center gap-3 rounded-lg border border-slate-800 bg-slate-900/60 p-3"
|
|
37
|
+
>
|
|
38
|
+
<UIcon
|
|
39
|
+
:name="tour.icon ?? 'i-lucide-compass'"
|
|
40
|
+
class="h-5 w-5 shrink-0 text-primary-400"
|
|
41
|
+
/>
|
|
42
|
+
<div class="min-w-0 flex-1">
|
|
43
|
+
<div class="flex items-center gap-2">
|
|
44
|
+
<span class="text-sm font-medium text-slate-100">{{ t(tour.titleKey) }}</span>
|
|
45
|
+
<UBadge
|
|
46
|
+
v-if="tutorial.isCompleted(tour.id)"
|
|
47
|
+
color="success"
|
|
48
|
+
variant="subtle"
|
|
49
|
+
size="sm"
|
|
50
|
+
data-testid="tutorial-tour-completed"
|
|
51
|
+
>
|
|
52
|
+
{{ t('tutorial.prompt.completed') }}
|
|
53
|
+
</UBadge>
|
|
54
|
+
</div>
|
|
55
|
+
<p class="text-xs text-slate-400">{{ t(tour.descriptionKey) }}</p>
|
|
56
|
+
</div>
|
|
57
|
+
<UButton
|
|
58
|
+
size="sm"
|
|
59
|
+
color="primary"
|
|
60
|
+
:variant="tutorial.isCompleted(tour.id) ? 'soft' : 'solid'"
|
|
61
|
+
:data-testid="`tutorial-start-${tour.id}`"
|
|
62
|
+
@click="tutorial.startTour(tour.id)"
|
|
63
|
+
>
|
|
64
|
+
{{
|
|
65
|
+
tutorial.isCompleted(tour.id)
|
|
66
|
+
? t('tutorial.prompt.restart')
|
|
67
|
+
: t('tutorial.prompt.start')
|
|
68
|
+
}}
|
|
69
|
+
</UButton>
|
|
70
|
+
</li>
|
|
71
|
+
</ul>
|
|
72
|
+
<!-- Every tour gated away (e.g. a viewer on a write-only catalog): say so rather
|
|
73
|
+
than showing an unexplained empty list. -->
|
|
74
|
+
<p v-if="tours.length === 0" class="text-sm text-slate-400">
|
|
75
|
+
{{ t('tutorial.prompt.empty') }}
|
|
76
|
+
</p>
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|
|
79
|
+
<template #footer>
|
|
80
|
+
<div class="flex w-full items-center justify-between gap-2">
|
|
81
|
+
<UButton
|
|
82
|
+
v-if="undecided"
|
|
83
|
+
color="neutral"
|
|
84
|
+
variant="ghost"
|
|
85
|
+
data-testid="tutorial-decline"
|
|
86
|
+
@click="tutorial.decline()"
|
|
87
|
+
>
|
|
88
|
+
{{ t('tutorial.prompt.decline') }}
|
|
89
|
+
</UButton>
|
|
90
|
+
<span v-else />
|
|
91
|
+
<UButton
|
|
92
|
+
color="neutral"
|
|
93
|
+
variant="soft"
|
|
94
|
+
data-testid="tutorial-close"
|
|
95
|
+
@click="tutorial.closePrompt()"
|
|
96
|
+
>
|
|
97
|
+
{{ undecided ? t('tutorial.prompt.later') : t('common.close') }}
|
|
98
|
+
</UButton>
|
|
99
|
+
</div>
|
|
100
|
+
</template>
|
|
101
|
+
</UModal>
|
|
102
|
+
</template>
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { ParsedConflict } from '~/composables/usePipelineErrorToast'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The bespoke conflict toasts: the five 409 reasons whose copy interpolates runtime detail and
|
|
5
|
+
* whose remedy is a one-click jump into the panel that fixes them. Split out of
|
|
6
|
+
* {@link usePipelineErrorToast} purely for size — it closes over the SAME toast / ui / i18n
|
|
7
|
+
* handles, so behaviour is identical to the former in-composable functions.
|
|
8
|
+
*/
|
|
9
|
+
export function createBespokeConflictToasts(deps: {
|
|
10
|
+
toast: ReturnType<typeof useToast>
|
|
11
|
+
ui: ReturnType<typeof useUiStore>
|
|
12
|
+
t: ReturnType<typeof useI18n>['t']
|
|
13
|
+
te: ReturnType<typeof useI18n>['te']
|
|
14
|
+
}): (conflict: ParsedConflict) => boolean {
|
|
15
|
+
const { toast, ui, t, te } = deps
|
|
16
|
+
// The headline case: a pipeline step's model has no usable provider. Name the
|
|
17
|
+
// offending model(s), explain no provider is available, and offer the one-click jump
|
|
18
|
+
// to the AI setup — the same remedy the startup "No AI model configured" banner gives.
|
|
19
|
+
function presentProvidersUnconfigured(conflict: ParsedConflict): void {
|
|
20
|
+
const models = Array.isArray(conflict.details.models) ? conflict.details.models : []
|
|
21
|
+
const list = models.join(', ')
|
|
22
|
+
toast.add({
|
|
23
|
+
title: t('errors.conflict.providersUnconfigured.title'),
|
|
24
|
+
description: list
|
|
25
|
+
? t('errors.conflict.providersUnconfigured.body', { models: list })
|
|
26
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
27
|
+
color: 'error',
|
|
28
|
+
icon: 'i-lucide-cpu',
|
|
29
|
+
// Stay until dismissed: an actionable toast whose remedy button vanishes on the ~5s
|
|
30
|
+
// auto-dismiss takes the one-click fix with it before the user can reach it.
|
|
31
|
+
duration: 0,
|
|
32
|
+
actions: [
|
|
33
|
+
{
|
|
34
|
+
label: t('errors.conflict.providersUnconfigured.action'),
|
|
35
|
+
icon: 'i-lucide-settings',
|
|
36
|
+
onClick: () => ui.openAiProviderSetup(),
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
})
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// A pipeline step relies on binary-artifact storage (the UI Tester uploads screenshots)
|
|
43
|
+
// but the account has none configured. Explain it and offer the jump to the content-storage
|
|
44
|
+
// settings — the same shape as the providers-unconfigured case above. Prefer the localized
|
|
45
|
+
// body (it carries no runtime interpolation) so non-English users see translated copy; the
|
|
46
|
+
// raw backend prose is only the last-resort fallback when the locale lacks the key.
|
|
47
|
+
function presentBinaryStorageUnconfigured(conflict: ParsedConflict): void {
|
|
48
|
+
toast.add({
|
|
49
|
+
title: t('errors.conflict.binaryStorageUnconfigured.title'),
|
|
50
|
+
description: te('errors.conflict.binaryStorageUnconfigured.body')
|
|
51
|
+
? t('errors.conflict.binaryStorageUnconfigured.body')
|
|
52
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
53
|
+
color: 'error',
|
|
54
|
+
icon: 'i-lucide-image',
|
|
55
|
+
// Sticky, like the providers-unconfigured toast above: keep the "Configure storage"
|
|
56
|
+
// remedy reachable instead of letting it auto-dismiss.
|
|
57
|
+
duration: 0,
|
|
58
|
+
actions: [
|
|
59
|
+
{
|
|
60
|
+
label: t('errors.conflict.binaryStorageUnconfigured.action'),
|
|
61
|
+
icon: 'i-lucide-settings',
|
|
62
|
+
onClick: () => ui.openContentStorageSettings(),
|
|
63
|
+
},
|
|
64
|
+
],
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A pipeline includes a Deployer, but the SERVICE's ephemeral-environment config (the in-repo
|
|
69
|
+
// "what/where") is incomplete for its declared type. Steer the user straight to THAT service's
|
|
70
|
+
// environment config — the compose wizard for docker-compose, the service inspector otherwise —
|
|
71
|
+
// falling back to the workspace infrastructure window if the frame id wasn't carried.
|
|
72
|
+
function presentDeployerServiceConfig(conflict: ParsedConflict): void {
|
|
73
|
+
const frameId =
|
|
74
|
+
typeof conflict.details.frameId === 'string' ? conflict.details.frameId : undefined
|
|
75
|
+
const provisionType =
|
|
76
|
+
typeof conflict.details.provisionType === 'string'
|
|
77
|
+
? conflict.details.provisionType
|
|
78
|
+
: undefined
|
|
79
|
+
const missing = Array.isArray(conflict.details.missing)
|
|
80
|
+
? conflict.details.missing.join(', ')
|
|
81
|
+
: ''
|
|
82
|
+
toast.add({
|
|
83
|
+
title: t('errors.conflict.deployerServiceConfig.title'),
|
|
84
|
+
description: missing
|
|
85
|
+
? t('errors.conflict.deployerServiceConfig.body', { missing })
|
|
86
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
87
|
+
color: 'error',
|
|
88
|
+
icon: 'i-lucide-server',
|
|
89
|
+
// Sticky, like the other actionable conflicts: keep the "Fix configuration" jump reachable.
|
|
90
|
+
duration: 0,
|
|
91
|
+
actions: [
|
|
92
|
+
{
|
|
93
|
+
label: t('errors.conflict.deployerServiceConfig.action'),
|
|
94
|
+
icon: 'i-lucide-settings',
|
|
95
|
+
onClick: () => {
|
|
96
|
+
if (frameId && provisionType === 'docker-compose') ui.openEnvironmentSetup(frameId)
|
|
97
|
+
else if (frameId) ui.select(frameId)
|
|
98
|
+
else ui.openProviderConnection('environment')
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
})
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// A pipeline includes a Deployer and the service config is sound, but no WORKSPACE handler
|
|
106
|
+
// resolves for the service's provision type (missing or ambiguous). Steer to the Infrastructure
|
|
107
|
+
// window's Test-environments tab. (Also raised by the Tester start gate — same fix applies.)
|
|
108
|
+
function presentProvisionTypeUnhandled(conflict: ParsedConflict): void {
|
|
109
|
+
const type =
|
|
110
|
+
typeof conflict.details.provisionType === 'string' ? conflict.details.provisionType : ''
|
|
111
|
+
toast.add({
|
|
112
|
+
title: t('errors.conflict.provisionTypeUnhandled.title'),
|
|
113
|
+
description: type
|
|
114
|
+
? t('errors.conflict.provisionTypeUnhandled.body', { type })
|
|
115
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
116
|
+
color: 'error',
|
|
117
|
+
icon: 'i-lucide-server-cog',
|
|
118
|
+
duration: 0,
|
|
119
|
+
actions: [
|
|
120
|
+
{
|
|
121
|
+
label: t('errors.conflict.provisionTypeUnhandled.action'),
|
|
122
|
+
icon: 'i-lucide-settings',
|
|
123
|
+
onClick: () => ui.openProviderConnection('environment'),
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
})
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// A pipeline includes a Deployer, the config is structurally complete, but the live connection
|
|
130
|
+
// probe of the resolved deployment integration failed (unreachable endpoint / apiserver, bad
|
|
131
|
+
// token). Surface the provider's failure detail and steer to the handler to fix + re-test it.
|
|
132
|
+
function presentDeployerConnectionFailed(conflict: ParsedConflict): void {
|
|
133
|
+
const detail = typeof conflict.details.detail === 'string' ? conflict.details.detail : undefined
|
|
134
|
+
toast.add({
|
|
135
|
+
title: t('errors.conflict.deployerConnectionFailed.title'),
|
|
136
|
+
description: detail
|
|
137
|
+
? t('errors.conflict.deployerConnectionFailed.body', { detail })
|
|
138
|
+
: (conflict.message ?? t('errors.conflict.fallbackMessage')),
|
|
139
|
+
color: 'error',
|
|
140
|
+
icon: 'i-lucide-plug',
|
|
141
|
+
duration: 0,
|
|
142
|
+
actions: [
|
|
143
|
+
{
|
|
144
|
+
label: t('errors.conflict.deployerConnectionFailed.action'),
|
|
145
|
+
icon: 'i-lucide-settings',
|
|
146
|
+
onClick: () => ui.openProviderConnection('environment'),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Dispatch the bespoke conflict reasons (a runtime-interpolated body + a "configure X" action,
|
|
154
|
+
* each with its own key namespace — the ones excluded from `CONFLICT_INFO`). Returns `true` when
|
|
155
|
+
* the reason was one of them (and the toast was raised), `false` to fall through to the generic
|
|
156
|
+
* map. The reason values are mutually exclusive, so dispatch order is irrelevant.
|
|
157
|
+
*/
|
|
158
|
+
function presentBespokeConflict(conflict: ParsedConflict): boolean {
|
|
159
|
+
switch (conflict.reason) {
|
|
160
|
+
case 'providers_unconfigured':
|
|
161
|
+
presentProvidersUnconfigured(conflict)
|
|
162
|
+
return true
|
|
163
|
+
case 'binary_storage_unconfigured':
|
|
164
|
+
presentBinaryStorageUnconfigured(conflict)
|
|
165
|
+
return true
|
|
166
|
+
case 'deployer_service_provisioning_incomplete':
|
|
167
|
+
presentDeployerServiceConfig(conflict)
|
|
168
|
+
return true
|
|
169
|
+
case 'provision_type_unhandled':
|
|
170
|
+
presentProvisionTypeUnhandled(conflict)
|
|
171
|
+
return true
|
|
172
|
+
case 'deployer_connection_test_failed':
|
|
173
|
+
presentDeployerConnectionFailed(conflict)
|
|
174
|
+
return true
|
|
175
|
+
default:
|
|
176
|
+
return false
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return presentBespokeConflict
|
|
181
|
+
}
|
|
@@ -47,6 +47,7 @@ export function useNavContributions() {
|
|
|
47
47
|
operatorDashboard: () => ui.openOperatorDashboard(),
|
|
48
48
|
reports: () => ui.openReports(),
|
|
49
49
|
shortcuts: () => ui.openShortcutsHelp(),
|
|
50
|
+
tutorial: () => useTutorialStore().openPrompt(),
|
|
50
51
|
// No-op under an env pin (`setMode` refuses), so the palette entry matches the sidebar
|
|
51
52
|
// switcher's read-only state rather than pretending to flip a tier the resolver fixes.
|
|
52
53
|
toggleUiMode: () => useUiModeStore().toggleMode(),
|