@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.
Files changed (54) hide show
  1. package/README.md +49 -3
  2. package/app/components/brainstorm/BrainstormWindow.vue +11 -4
  3. package/app/components/clarity/ClarityReviewWindow.vue +11 -4
  4. package/app/components/initiative/InitiativePlanReview.vue +44 -37
  5. package/app/components/initiative/InitiativeTrackerWindow.vue +12 -9
  6. package/app/components/layout/SideBar.vue +13 -2
  7. package/app/components/layout/UiModeSwitcher.vue +66 -39
  8. package/app/components/panels/ResultWindowShell.logic.spec.ts +174 -0
  9. package/app/components/panels/ResultWindowShell.logic.ts +31 -0
  10. package/app/components/panels/ResultWindowShell.vue +37 -8
  11. package/app/components/prReview/PrReviewWindow.vue +15 -7
  12. package/app/components/requirements/RequirementsReviewWindow.vue +15 -5
  13. package/app/components/spec/ServiceSpecWindow.vue +7 -4
  14. package/app/components/testing/TestReportWindow.vue +11 -6
  15. package/app/components/tutorial/TutorialOverlay.logic.spec.ts +126 -0
  16. package/app/components/tutorial/TutorialOverlay.logic.ts +92 -0
  17. package/app/components/tutorial/TutorialOverlay.vue +273 -0
  18. package/app/components/tutorial/TutorialPrompt.vue +102 -0
  19. package/app/composables/pipelineErrorToast/bespokeConflicts.ts +181 -0
  20. package/app/composables/useNavContributions.ts +1 -0
  21. package/app/composables/usePipelineErrorToast.ts +6 -164
  22. package/app/composables/useTutorialTours.ts +18 -0
  23. package/app/modular/nav-contributions.spec.ts +4 -0
  24. package/app/modular/nav-contributions.ts +38 -3
  25. package/app/modular/nav-gates.ts +7 -0
  26. package/app/modular/registry.spec.ts +1 -0
  27. package/app/modular/registry.ts +3 -1
  28. package/app/modular/slots.ts +7 -0
  29. package/app/modular/tutorial-tours.spec.ts +107 -0
  30. package/app/modular/tutorial-tours.ts +147 -0
  31. package/app/pages/index.vue +61 -0
  32. package/app/stores/board/dependencies.ts +52 -0
  33. package/app/stores/board/placement.ts +4 -37
  34. package/app/stores/execution/pendingGates.ts +109 -0
  35. package/app/stores/execution.ts +7 -94
  36. package/app/stores/requirements/recommendations.ts +77 -0
  37. package/app/stores/requirements.ts +17 -43
  38. package/app/stores/tutorial.spec.ts +135 -0
  39. package/app/stores/tutorial.ts +145 -0
  40. package/app/stores/workspace/commands.ts +77 -0
  41. package/app/stores/workspace.ts +11 -50
  42. package/app/utils/tutorial.spec.ts +68 -0
  43. package/app/utils/tutorial.ts +192 -0
  44. package/i18n/locales/de.json +90 -2
  45. package/i18n/locales/en.json +96 -2
  46. package/i18n/locales/es.json +90 -2
  47. package/i18n/locales/fr.json +90 -2
  48. package/i18n/locales/he.json +90 -2
  49. package/i18n/locales/it.json +90 -2
  50. package/i18n/locales/ja.json +90 -2
  51. package/i18n/locales/pl.json +90 -2
  52. package/i18n/locales/tr.json +90 -2
  53. package/i18n/locales/uk.json +90 -2
  54. package/package.json +1 -1
@@ -0,0 +1,135 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { useTutorialStore } from '~/stores/tutorial'
3
+
4
+ describe('useTutorialStore launch prompt', () => {
5
+ it('offers the tutorial on launch while no decision was ever saved', () => {
6
+ const tutorial = useTutorialStore()
7
+ expect(tutorial.decision).toBeNull()
8
+ tutorial.maybeOfferOnLaunch()
9
+ expect(tutorial.promptOpen).toBe(true)
10
+ })
11
+
12
+ it('auto-opens at most once per session', () => {
13
+ const tutorial = useTutorialStore()
14
+ tutorial.maybeOfferOnLaunch()
15
+ tutorial.closePrompt()
16
+ // The advisory watcher re-fires whenever another modal closes; the guard must hold.
17
+ tutorial.maybeOfferOnLaunch()
18
+ expect(tutorial.promptOpen).toBe(false)
19
+ })
20
+
21
+ it('closing without answering saves nothing, so the next launch asks again', () => {
22
+ const tutorial = useTutorialStore()
23
+ tutorial.maybeOfferOnLaunch()
24
+ tutorial.closePrompt()
25
+ expect(tutorial.decision).toBeNull()
26
+ })
27
+
28
+ it('declining is saved and stops the launch offer for good', () => {
29
+ const tutorial = useTutorialStore()
30
+ tutorial.maybeOfferOnLaunch()
31
+ tutorial.decline()
32
+ expect(tutorial.decision).toBe('declined')
33
+ expect(tutorial.promptOpen).toBe(false)
34
+ // A fresh session (new auto-open guard) still must not offer: decision wins.
35
+ tutorial.promptAutoOpened = false
36
+ tutorial.maybeOfferOnLaunch()
37
+ expect(tutorial.promptOpen).toBe(false)
38
+ })
39
+
40
+ it('defers an offer an advisory landed on top of, and re-offers when it clears', () => {
41
+ const tutorial = useTutorialStore()
42
+ tutorial.maybeOfferOnLaunch()
43
+ // A startup advisory opened after the prompt: withdraw rather than stack.
44
+ tutorial.deferPrompt()
45
+ expect(tutorial.promptOpen).toBe(false)
46
+ expect(tutorial.decision).toBeNull()
47
+ // A deferral is not the user's answer, so it must not consume the session's offer.
48
+ tutorial.maybeOfferOnLaunch()
49
+ expect(tutorial.promptOpen).toBe(true)
50
+ })
51
+
52
+ it('never withdraws a prompt the user opened themselves', () => {
53
+ const tutorial = useTutorialStore()
54
+ tutorial.openPrompt()
55
+ tutorial.deferPrompt()
56
+ expect(tutorial.promptOpen).toBe(true)
57
+ })
58
+
59
+ it('a user-driven open works regardless of a saved decision', () => {
60
+ const tutorial = useTutorialStore()
61
+ tutorial.decline()
62
+ tutorial.openPrompt()
63
+ expect(tutorial.promptOpen).toBe(true)
64
+ })
65
+ })
66
+
67
+ describe('useTutorialStore tours', () => {
68
+ it('starting a tour records acceptance, closes the prompt, and resets the cursor', () => {
69
+ const tutorial = useTutorialStore()
70
+ tutorial.openPrompt()
71
+ tutorial.startTour('board-basics')
72
+ expect(tutorial.decision).toBe('accepted')
73
+ expect(tutorial.promptOpen).toBe(false)
74
+ expect(tutorial.activeTourId).toBe('board-basics')
75
+ expect(tutorial.stepIndex).toBe(0)
76
+ expect(tutorial.touring).toBe(true)
77
+ })
78
+
79
+ it('starting a tour after a decline flips the decision to accepted', () => {
80
+ // The user changed their mind via the palette; leaving `declined` in place would
81
+ // misdescribe what happened.
82
+ const tutorial = useTutorialStore()
83
+ tutorial.decline()
84
+ tutorial.startTour('board-basics')
85
+ expect(tutorial.decision).toBe('accepted')
86
+ })
87
+
88
+ it('the step cursor never goes below zero', () => {
89
+ const tutorial = useTutorialStore()
90
+ tutorial.startTour('board-basics')
91
+ tutorial.setStepIndex(-3)
92
+ expect(tutorial.stepIndex).toBe(0)
93
+ tutorial.setStepIndex(4)
94
+ expect(tutorial.stepIndex).toBe(4)
95
+ })
96
+
97
+ it('skipping (stopTour) abandons the tour without marking it complete', () => {
98
+ const tutorial = useTutorialStore()
99
+ tutorial.startTour('board-basics')
100
+ tutorial.setStepIndex(2)
101
+ tutorial.stopTour()
102
+ expect(tutorial.touring).toBe(false)
103
+ expect(tutorial.stepIndex).toBe(0)
104
+ expect(tutorial.isCompleted('board-basics')).toBe(false)
105
+ })
106
+
107
+ it('completing records the tour id once, idempotently', () => {
108
+ const tutorial = useTutorialStore()
109
+ tutorial.startTour('board-basics')
110
+ tutorial.completeTour()
111
+ expect(tutorial.isCompleted('board-basics')).toBe(true)
112
+ expect(tutorial.touring).toBe(false)
113
+
114
+ tutorial.startTour('board-basics')
115
+ tutorial.completeTour()
116
+ expect(tutorial.completedTourIds).toEqual(['board-basics'])
117
+ })
118
+
119
+ it('completing with no active tour records nothing', () => {
120
+ const tutorial = useTutorialStore()
121
+ tutorial.completeTour()
122
+ expect(tutorial.completedTourIds).toEqual([])
123
+ })
124
+
125
+ it('tracks completion per tour id', () => {
126
+ const tutorial = useTutorialStore()
127
+ tutorial.startTour('board-basics')
128
+ tutorial.completeTour()
129
+ tutorial.startTour('first-task')
130
+ tutorial.completeTour()
131
+ expect(tutorial.completedTourIds).toEqual(['board-basics', 'first-task'])
132
+ expect(tutorial.isCompleted('first-task')).toBe(true)
133
+ expect(tutorial.isCompleted('made-up')).toBe(false)
134
+ })
135
+ })
@@ -0,0 +1,145 @@
1
+ import { defineStore } from 'pinia'
2
+ import { computed, ref } from 'vue'
3
+
4
+ /** The launch-prompt answer. `null` = never answered, so the app asks again next launch. */
5
+ export type TutorialDecision = 'accepted' | 'declined'
6
+
7
+ /**
8
+ * The in-app tutorial state: the launch-prompt decision, per-tour completion, and the
9
+ * live progress of whichever tour is running.
10
+ *
11
+ * Two tiers of state, split on purpose:
12
+ *
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`, `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.
22
+ *
23
+ * 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.
27
+ */
28
+ export const useTutorialStore = defineStore(
29
+ 'tutorial',
30
+ () => {
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 activeTourId = ref<string | null>(null)
40
+ const stepIndex = ref(0)
41
+
42
+ /** A tour is currently running (the overlay mounts off this). */
43
+ const touring = computed(() => activeTourId.value !== null)
44
+
45
+ /**
46
+ * Auto-open the launch prompt, at most once per session and only while the user has
47
+ * never answered it. Callers gate on the rest of the launch context (board ready, no
48
+ * other startup advisory open) — see `pages/index.vue`.
49
+ */
50
+ function maybeOfferOnLaunch() {
51
+ if (decision.value !== null || promptAutoOpened.value) return
52
+ promptAutoOpened.value = true
53
+ promptOpen.value = true
54
+ }
55
+
56
+ /** User-driven open (command palette), regardless of any saved decision. */
57
+ function openPrompt() {
58
+ promptOpen.value = true
59
+ }
60
+
61
+ /**
62
+ * Withdraw an offer this store made, because something the user actually has to answer
63
+ * (a startup advisory, the GitHub onboarding gate) opened on top of it — and re-arm, so
64
+ * the offer returns once that surface is gone. Distinct from {@link closePrompt}: no
65
+ * decision is written EITHER way, but a deferral was not the user's doing, so it must
66
+ * not consume this session's one offer.
67
+ *
68
+ * Only ever withdraws the auto-opened prompt; a prompt the user opened themselves from
69
+ * the palette is theirs to close.
70
+ */
71
+ function deferPrompt() {
72
+ if (!promptAutoOpened.value) return
73
+ promptOpen.value = false
74
+ promptAutoOpened.value = false
75
+ }
76
+
77
+ /** Close without answering: no decision is written, so the next launch asks again. */
78
+ function closePrompt() {
79
+ promptOpen.value = false
80
+ }
81
+
82
+ /** The explicit "no thanks": saved, so the launch prompt never auto-opens again. */
83
+ function decline() {
84
+ decision.value = 'declined'
85
+ promptOpen.value = false
86
+ }
87
+
88
+ /**
89
+ * Begin a tour from its first step. Starting one IS accepting the tutorial (also when
90
+ * launched later from the palette after a decline — the user changed their mind, and
91
+ * leaving `declined` in place would misdescribe what happened).
92
+ */
93
+ function startTour(tourId: string) {
94
+ decision.value = 'accepted'
95
+ promptOpen.value = false
96
+ activeTourId.value = tourId
97
+ stepIndex.value = 0
98
+ }
99
+
100
+ /** Move the step cursor; the overlay owns bounds/skip logic and never goes below 0. */
101
+ function setStepIndex(index: number) {
102
+ stepIndex.value = Math.max(0, index)
103
+ }
104
+
105
+ /** Abandon the running tour without marking it complete (the Skip action). */
106
+ function stopTour() {
107
+ activeTourId.value = null
108
+ stepIndex.value = 0
109
+ }
110
+
111
+ /** Finish the running tour: record completion (idempotent) and clear the cursor. */
112
+ function completeTour() {
113
+ const id = activeTourId.value
114
+ if (id && !completedTourIds.value.includes(id)) {
115
+ completedTourIds.value = [...completedTourIds.value, id]
116
+ }
117
+ stopTour()
118
+ }
119
+
120
+ function isCompleted(tourId: string): boolean {
121
+ return completedTourIds.value.includes(tourId)
122
+ }
123
+
124
+ return {
125
+ decision,
126
+ completedTourIds,
127
+ promptOpen,
128
+ promptAutoOpened,
129
+ activeTourId,
130
+ stepIndex,
131
+ touring,
132
+ maybeOfferOnLaunch,
133
+ openPrompt,
134
+ closePrompt,
135
+ deferPrompt,
136
+ decline,
137
+ startTour,
138
+ setStepIndex,
139
+ stopTour,
140
+ completeTour,
141
+ isCompleted,
142
+ }
143
+ },
144
+ { persist: { pick: ['decision', 'completedTourIds'] } },
145
+ )
@@ -0,0 +1,77 @@
1
+ import type { Ref } from 'vue'
2
+ import type { WorkspaceListItem, WorkspaceSnapshot } from '~/types/domain'
3
+ import { useAccountsStore } from '~/stores/accounts'
4
+
5
+ /**
6
+ * Shared reactive state + injected dependencies the workspace-store board-CRUD factory closes
7
+ * over. Created once in the `workspace` store setup and threaded into
8
+ * {@link createWorkspaceCommands} so the split operations stay behaviourally identical to the
9
+ * original single-closure store — a size-only extraction mirroring `stores/workspace/hydrate.ts`
10
+ * and `stores/workspace/infraSetup.ts`, not a new seam.
11
+ */
12
+ export interface WorkspaceCommandContext {
13
+ api: ReturnType<typeof useApi>
14
+ workspaceId: Ref<string | null>
15
+ workspaces: Ref<WorkspaceListItem[]>
16
+ hydrate: (snapshot: WorkspaceSnapshot, boardSince?: number) => void
17
+ /** Open one of the active account's boards, creating one when it has none. */
18
+ resolveActiveBoard: () => Promise<void>
19
+ }
20
+
21
+ /** Open / create / rename / delete a board, plus switching the active account. */
22
+ export function createWorkspaceCommands(ctx: WorkspaceCommandContext) {
23
+ const { api, workspaceId, workspaces, hydrate, resolveActiveBoard } = ctx
24
+
25
+ /** Switch to another board (within reach of the active account). */
26
+ async function switchTo(id: string) {
27
+ if (id === workspaceId.value) return
28
+ hydrate(await api.getWorkspace(id))
29
+ }
30
+
31
+ /** Switch the active account, then open one of its boards (creating one if needed). */
32
+ async function selectAccount(id: string) {
33
+ const accounts = useAccountsStore()
34
+ if (id === accounts.activeAccountId) return
35
+ accounts.switchTo(id)
36
+ workspaceId.value = null
37
+ await resolveActiveBoard()
38
+ }
39
+
40
+ /** Create a new board in the active account and open it. */
41
+ async function create(name?: string, description?: string) {
42
+ const accounts = useAccountsStore()
43
+ const snapshot = await api.createWorkspace({
44
+ seed: false,
45
+ name,
46
+ description,
47
+ accountId: accounts.activeAccountId ?? undefined,
48
+ })
49
+ hydrate(snapshot)
50
+ return snapshot.workspace
51
+ }
52
+
53
+ /** Rename a board and/or update its description. */
54
+ async function update(id: string, patch: { name?: string; description?: string | null }) {
55
+ const updated = await api.updateWorkspace(id, patch)
56
+ const i = workspaces.value.findIndex((w) => w.id === id)
57
+ if (i >= 0) workspaces.value[i] = updated
58
+ return updated
59
+ }
60
+
61
+ /** Rename a board (kept for the existing rename callers). */
62
+ async function rename(id: string, name: string) {
63
+ return update(id, { name })
64
+ }
65
+
66
+ /** Delete a board; if it was active, fall back to another in the account. */
67
+ async function remove(id: string) {
68
+ await api.deleteWorkspace(id)
69
+ workspaces.value = workspaces.value.filter((w) => w.id !== id)
70
+ if (workspaceId.value === id) {
71
+ workspaceId.value = null
72
+ await resolveActiveBoard()
73
+ }
74
+ }
75
+
76
+ return { switchTo, selectAccount, create, update, rename, remove }
77
+ }
@@ -10,6 +10,7 @@ import type {
10
10
  import { useAccountsStore } from '~/stores/accounts'
11
11
  import { useBoardStore } from '~/stores/board'
12
12
  import { applySnapshotToStores, resetPerBoardCaches } from '~/stores/workspace/hydrate'
13
+ import { createWorkspaceCommands } from '~/stores/workspace/commands'
13
14
  import { createInfraSetupState } from '~/stores/workspace/infraSetup'
14
15
  import { markBoot } from '~/utils/bootMarks'
15
16
  import { retryWhileBackendUnreachable } from '~/utils/backendReady'
@@ -184,56 +185,16 @@ export const useWorkspaceStore = defineStore(
184
185
  }
185
186
  }
186
187
 
187
- /** Switch to another board (within reach of the active account). */
188
- async function switchTo(id: string) {
189
- if (id === workspaceId.value) return
190
- hydrate(await api.getWorkspace(id))
191
- }
192
-
193
- /** Switch the active account, then open one of its boards (creating one if needed). */
194
- async function selectAccount(id: string) {
195
- const accounts = useAccountsStore()
196
- if (id === accounts.activeAccountId) return
197
- accounts.switchTo(id)
198
- workspaceId.value = null
199
- await resolveActiveBoard()
200
- }
201
-
202
- /** Create a new board in the active account and open it. */
203
- async function create(name?: string, description?: string) {
204
- const accounts = useAccountsStore()
205
- const snapshot = await api.createWorkspace({
206
- seed: false,
207
- name,
208
- description,
209
- accountId: accounts.activeAccountId ?? undefined,
210
- })
211
- hydrate(snapshot)
212
- return snapshot.workspace
213
- }
214
-
215
- /** Rename a board and/or update its description. */
216
- async function update(id: string, patch: { name?: string; description?: string | null }) {
217
- const updated = await api.updateWorkspace(id, patch)
218
- const i = workspaces.value.findIndex((w) => w.id === id)
219
- if (i >= 0) workspaces.value[i] = updated
220
- return updated
221
- }
222
-
223
- /** Rename a board (kept for the existing rename callers). */
224
- async function rename(id: string, name: string) {
225
- return update(id, { name })
226
- }
227
-
228
- /** Delete a board; if it was active, fall back to another in the account. */
229
- async function remove(id: string) {
230
- await api.deleteWorkspace(id)
231
- workspaces.value = workspaces.value.filter((w) => w.id !== id)
232
- if (workspaceId.value === id) {
233
- workspaceId.value = null
234
- await resolveActiveBoard()
235
- }
236
- }
188
+ // Board CRUD (open / create / rename / delete) + the account switch, extracted into a
189
+ // cohesive factory over the state above — a size-only split mirroring `hydrate.ts` and
190
+ // `infraSetup.ts`.
191
+ const { switchTo, selectAccount, create, update, rename, remove } = createWorkspaceCommands({
192
+ api,
193
+ workspaceId,
194
+ workspaces,
195
+ hydrate,
196
+ resolveActiveBoard,
197
+ })
237
198
 
238
199
  // Monotonic guard for {@link refresh}: `board`-type stream events (and the on-connect resync)
239
200
  // each fire a full-snapshot refresh, and {@link hydrate} REPLACES the block list. Without
@@ -0,0 +1,68 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { computeCoachMarkLayout, sortTours } from '~/utils/tutorial'
3
+ import type { TutorialTour } from '~/utils/tutorial'
4
+
5
+ const tour = (id: string, order: number): TutorialTour => ({
6
+ id,
7
+ order,
8
+ titleKey: `tutorial.tours.${id}.title`,
9
+ descriptionKey: `tutorial.tours.${id}.description`,
10
+ steps: [],
11
+ })
12
+
13
+ describe('sortTours', () => {
14
+ it('orders by `order`, breaking ties on id, without mutating the input', () => {
15
+ const input = [tour('c', 20), tour('b', 10), tour('a', 20)]
16
+ const sorted = sortTours(input)
17
+ expect(sorted.map((t) => t.id)).toEqual(['b', 'a', 'c'])
18
+ expect(input.map((t) => t.id)).toEqual(['c', 'b', 'a'])
19
+ })
20
+ })
21
+
22
+ const viewport = { width: 1000, height: 800 }
23
+ const tooltip = { width: 300, height: 150 }
24
+
25
+ describe('computeCoachMarkLayout', () => {
26
+ it('centers when there is no anchor (intro / wrap-up steps)', () => {
27
+ const layout = computeCoachMarkLayout(null, tooltip, viewport)
28
+ expect(layout.placement).toBe('center')
29
+ expect(layout.left).toBe((1000 - 300) / 2)
30
+ expect(layout.top).toBe((800 - 150) / 2)
31
+ })
32
+
33
+ it('honours a preferred side that fits', () => {
34
+ const target = { top: 400, left: 500, width: 100, height: 40 }
35
+ const layout = computeCoachMarkLayout(target, tooltip, viewport, 'top')
36
+ expect(layout.placement).toBe('top')
37
+ expect(layout.top).toBe(400 - 12 - 150)
38
+ // Cross-axis centered on the anchor.
39
+ expect(layout.left).toBe(500 + 50 - 150)
40
+ })
41
+
42
+ it('falls back when the preferred side has no room', () => {
43
+ // Anchor at the very top: 'top' can't fit a 150px card, so bottom wins.
44
+ const target = { top: 10, left: 500, width: 100, height: 40 }
45
+ const layout = computeCoachMarkLayout(target, tooltip, viewport, 'top')
46
+ expect(layout.placement).toBe('bottom')
47
+ expect(layout.top).toBe(10 + 40 + 12)
48
+ })
49
+
50
+ it('clamps the cross-axis to the viewport instead of overflowing', () => {
51
+ // Anchor hugging the left edge: centering would push the card off-screen.
52
+ const target = { top: 400, left: 0, width: 40, height: 40 }
53
+ const layout = computeCoachMarkLayout(target, tooltip, viewport, 'bottom')
54
+ expect(layout.placement).toBe('bottom')
55
+ expect(layout.left).toBe(8)
56
+ })
57
+
58
+ it('degrades to a clamped bottom position when no side fits at all', () => {
59
+ // A viewport smaller than the card in every direction around the anchor.
60
+ const tinyViewport = { width: 320, height: 200 }
61
+ const target = { top: 80, left: 100, width: 120, height: 40 }
62
+ const layout = computeCoachMarkLayout(target, tooltip, tinyViewport, 'right')
63
+ expect(layout.placement).toBe('bottom')
64
+ // Clamped inside the viewport: overlap beats disappearing off-screen.
65
+ expect(layout.top).toBe(200 - 150 - 8)
66
+ expect(layout.left).toBe(10)
67
+ })
68
+ })