@cat-factory/app 0.241.3 → 0.242.1

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 CHANGED
@@ -58,6 +58,16 @@ over the WebSocket. How that sync works is written up in
58
58
  | `types/` | TypeScript domain unions (`domain.ts`) and wire types mirroring the contracts. |
59
59
  | `utils/` | Small pure helpers. |
60
60
 
61
+ ### The board's top overlay region has ONE owner
62
+
63
+ **A surface that appears at the top of the board renders as a member of `BoardTopOverlays`, and places nothing itself.** No `absolute`/`fixed`, no `top-*`, no z-index of its own: the band is a flex column, and it owns placement and stacking for everything in it. A member contributes only its card plus `pointer-events-auto` (the column is click-through, so its empty strip never intercepts clicks on the board underneath).
64
+
65
+ The reason this is a rule is that the alternative failed exactly once per surface. Each of the toolbar, the spend/connection/PAT banners and the four advisory banners used to anchor itself at `top-0` with its own z-index, so which one you could see came down to who picked the higher number: a standing advisory covered the zoom and fit controls outright, and the board-basics tour then ringed a control nobody could see. Tuning an offset onto one of them (`top-16`, sized against the toolbar pill) fixes the pair that was noticed and leaves the rest, and it goes stale the first time the pill wraps or grows a scrollbar. In one column the overlap is not tuned, it is unrepresentable, and a toolbar that grows pushes the banners down by exactly what it grew.
66
+
67
+ Order within the column is by what the user loses by not reading it now; the toolbar stays first so a tour anchor and the everyday zoom controls do not move as advisories come and go. Full-width page chrome (the translation-warning strip) is a different surface: it sits in NORMAL FLOW at the top of the shell, so it takes its own height rather than covering the row beneath it.
68
+
69
+ `app/components/layout/BoardTopOverlays.spec.ts` enforces the no-self-placement half, reading the member list from the component's own imports.
70
+
61
71
  ### A store must be instantiable outside a component `setup`
62
72
 
63
73
  A Pinia setup store runs its body on the FIRST `useStore()` anywhere in the app, and that
@@ -134,8 +134,11 @@ const decisionItems = computed(() =>
134
134
  </script>
135
135
 
136
136
  <template>
137
+ <!-- Positioning and stacking are owned by `BoardTopOverlays`, the one component that lays out
138
+ the board's top overlay region; this renders only the pill and re-enables pointer events
139
+ on it. Self-anchoring here is what let a banner cover the toolbar outright. -->
137
140
  <div
138
- class="absolute left-1/2 top-3 z-20 flex max-w-[calc(100vw-1rem)] -translate-x-1/2 items-center gap-1 overflow-x-auto rounded-full border border-slate-700 bg-slate-900/90 px-2 py-1.5 shadow-xl backdrop-blur"
141
+ class="pointer-events-auto flex max-w-full items-center gap-1 overflow-x-auto rounded-full border border-slate-700 bg-slate-900/90 px-2 py-1.5 shadow-xl backdrop-blur"
139
142
  >
140
143
  <!-- zoom controls -->
141
144
  <IconButton
@@ -0,0 +1,72 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath } from 'node:url'
4
+ import { describe, expect, it } from 'vitest'
5
+
6
+ /**
7
+ * `BoardTopOverlays` is the single owner of the board's top overlay region, and that only
8
+ * holds while its members render as members: a card in a flex column, with no placement of
9
+ * its own.
10
+ *
11
+ * The reason this is a test and not a note in the component is that the failure is SILENT and
12
+ * only visible on a deployment in the state the banner reports. A member that re-anchors
13
+ * itself (`absolute top-0`, a z-index of its own) leaves the column and lands back on top of
14
+ * whatever picked a lower number, which is how a standing advisory came to cover the zoom/fit
15
+ * controls outright, and the board-basics tour to ring a control nobody could see. Nothing
16
+ * else catches it: the layout still renders, no type is wrong, and the unit suite mounts no
17
+ * components.
18
+ *
19
+ * The member list is READ FROM THE COMPONENT rather than restated here, so a banner added to
20
+ * the column is covered by the same commit that adds it, and one removed from the column
21
+ * stops being checked instead of failing as a phantom.
22
+ */
23
+
24
+ const layoutDir = dirname(fileURLToPath(import.meta.url))
25
+ const band = readFileSync(join(layoutDir, 'BoardTopOverlays.vue'), 'utf8')
26
+
27
+ /** The components the band lays out, from its own imports. */
28
+ function bandMembers(): string[] {
29
+ return [...band.matchAll(/^import (\w+) from '~\/components\/layout\/(\w+)\.vue'$/gm)].map(
30
+ (m) => m[2] as string,
31
+ )
32
+ }
33
+
34
+ /** Every class token the file applies, from its static `class="…"` attributes. */
35
+ function classTokens(source: string): string[] {
36
+ return [...source.matchAll(/\bclass="([^"]*)"/g)].flatMap((m) => (m[1] as string).split(/\s+/))
37
+ }
38
+
39
+ /**
40
+ * A token that takes a member out of the column's flow. Responsive and state variants count
41
+ * (`sm:absolute`, `lg:top-0`): the overlap they cause is no less real for being conditional.
42
+ */
43
+ function selfPlacing(token: string): boolean {
44
+ const base = token.includes(':') ? (token.split(':').pop() as string) : token
45
+ return (
46
+ base === 'fixed' ||
47
+ base === 'absolute' ||
48
+ base === 'sticky' ||
49
+ /^-?(top|bottom|inset)-/.test(base) ||
50
+ base === 'inset-0' ||
51
+ base.startsWith('z-')
52
+ )
53
+ }
54
+
55
+ describe('BoardTopOverlays members', () => {
56
+ const members = bandMembers()
57
+
58
+ it('lays out every top-region surface, so none of them is left placing itself', () => {
59
+ // A relation over the source, not a count: the column's job is to be the ONLY placement
60
+ // authority in this region, and a pinned number would fail on every ordinary addition
61
+ // while saying nothing about what broke.
62
+ expect(members.length).toBeGreaterThan(0)
63
+ expect(new Set(members).size).toBe(members.length)
64
+ })
65
+
66
+ it.each(bandMembers())('%s places nothing itself', (member) => {
67
+ const offenders = classTokens(readFileSync(join(layoutDir, `${member}.vue`), 'utf8')).filter(
68
+ selfPlacing,
69
+ )
70
+ expect(offenders).toEqual([])
71
+ })
72
+ })
@@ -0,0 +1,90 @@
1
+ <script setup lang="ts">
2
+ import BoardToolbar from '~/components/layout/BoardToolbar.vue'
3
+ import ConnectionStatusBanner from '~/components/layout/ConnectionStatusBanner.vue'
4
+ import SpendWarningBanner from '~/components/layout/SpendWarningBanner.vue'
5
+ import GitHubPatBanner from '~/components/layout/GitHubPatBanner.vue'
6
+ import AiProvidersBanner from '~/components/layout/AiProvidersBanner.vue'
7
+ import ProviderConfigBanner from '~/components/layout/ProviderConfigBanner.vue'
8
+ import InfraSetupBanner from '~/components/layout/InfraSetupBanner.vue'
9
+ import DefaultTestEnvBanner from '~/components/layout/DefaultTestEnvBanner.vue'
10
+
11
+ // The single owner of the board's top overlay region: the toolbar pill, the corner nav
12
+ // trigger, and every advisory banner, laid out in ONE flex column.
13
+ //
14
+ // The column is the point. Each of these used to anchor itself (`absolute top-0`, its own
15
+ // z-index), which makes overlap a matter of who picked the higher number: the four advisory
16
+ // banners already deferred to a shared column, but the toolbar, the spend/connection/PAT
17
+ // banners and the nav trigger each positioned themselves. A standing advisory therefore
18
+ // covered the zoom/fit controls outright, for everyone, and the board-basics tour rang a
19
+ // control nobody could see. Stacking them in flow makes that overlap structurally impossible
20
+ // of merely tuned: no offset constant to keep in step with the pill's height, and a toolbar
21
+ // that grows (a wrapped row, a scrollbar on a narrow viewport) pushes the column down by
22
+ // exactly what it grew.
23
+ //
24
+ // The column is `pointer-events-none` so its empty strip never intercepts clicks on the board
25
+ // underneath; each member re-enables pointer events on its own card.
26
+ //
27
+ // The nav trigger sits INSIDE this region rather than beside it. It has to: this column owns
28
+ // the region's z-index, so an outside sibling could only be ordered against the whole column,
29
+ // where what is actually needed is for the trigger to stay above the toolbar pill (whose
30
+ // max-width reaches the corner on the narrowest viewports) and below a banner that has
31
+ // something urgent to say. Both are local orderings, and they only exist here.
32
+
33
+ // The live-stream state the connection strip renders. Passed down rather than resolved here:
34
+ // the page owns the single stream instance, and creating another would open a second socket.
35
+ defineProps<{
36
+ connected: boolean
37
+ everConnected: boolean
38
+ connectionFailed: boolean
39
+ }>()
40
+
41
+ const ui = useUiStore()
42
+ </script>
43
+
44
+ <template>
45
+ <div
46
+ class="pointer-events-none absolute inset-x-0 top-0 z-40 flex flex-col items-center gap-2 px-4 pt-3"
47
+ >
48
+ <!-- Compact-viewport nav trigger: the SideBar is an off-canvas drawer below lg, so surface
49
+ a hamburger to open it. Out of the column's flow (it is a corner control, not a
50
+ centered one) and above the toolbar pill it can overlap there. -->
51
+ <UButton
52
+ class="pointer-events-auto absolute start-3 top-3 z-10 lg:hidden"
53
+ icon="i-lucide-menu"
54
+ color="neutral"
55
+ variant="soft"
56
+ size="sm"
57
+ :aria-label="ui.mobileNavOpen ? $t('nav.closeMenu') : $t('nav.openMenu')"
58
+ data-testid="mobile-nav-toggle"
59
+ @click="ui.toggleMobileNav()"
60
+ />
61
+
62
+ <!-- FIRST, and deliberately so: the toolbar is standing board chrome, and a tour step
63
+ anchors it. Below the advisories it would move every time one appears or clears. -->
64
+ <BoardToolbar />
65
+
66
+ <!-- Then the advisories, most urgent first. Ordering is by what the user loses by not
67
+ reading it now, not by how loud the card is.
68
+ - Connection status: what is on screen right now may already be stale.
69
+ - Spend exceeded: runs are blocked until the budget moves.
70
+ - GitHub PAT (local mode): every repo-operating step will fail.
71
+ - AI readiness: no usable model source, or the default preset names unavailable models.
72
+ - Infrastructure provider: env/runner-pool wired but missing mandatory config.
73
+ - Infra setup: an executor / test env / storage this deployment needs is undefined, so
74
+ a class of agents cannot run.
75
+ - Default test environment: this BOARD has never chosen the provisioning mechanism its
76
+ new services should default to. Last, because it asks for a convenience default and
77
+ so yields to the prompts about things that are outright broken. -->
78
+ <ConnectionStatusBanner
79
+ :connected="connected"
80
+ :ever-connected="everConnected"
81
+ :connection-failed="connectionFailed"
82
+ />
83
+ <SpendWarningBanner />
84
+ <GitHubPatBanner />
85
+ <AiProvidersBanner />
86
+ <ProviderConfigBanner />
87
+ <InfraSetupBanner />
88
+ <DefaultTestEnvBanner />
89
+ </div>
90
+ </template>
@@ -59,10 +59,10 @@ onBeforeUnmount(clearTimer)
59
59
 
60
60
  <template>
61
61
  <Transition name="fade">
62
- <div
63
- v-if="reconnecting || offline"
64
- class="pointer-events-none absolute inset-x-0 top-0 z-50 flex justify-center px-4 pt-2"
65
- >
62
+ <!-- Positioning/stacking is owned by `BoardTopOverlays`; this renders only the pill, which
63
+ re-enables pointer events on itself (the row around it stays click-through, since it is
64
+ far wider than the pill it centres). -->
65
+ <div v-if="reconnecting || offline" class="pointer-events-none flex w-full justify-center">
66
66
  <div
67
67
  v-if="reconnecting"
68
68
  class="pointer-events-auto flex items-center gap-2 rounded-full border border-amber-500/60 bg-amber-950/90 px-3 py-1.5 text-xs text-amber-100 shadow-lg backdrop-blur"
@@ -16,7 +16,9 @@ const show = computed(() => !!setupUrl.value && !dismissed.value)
16
16
 
17
17
  <template>
18
18
  <Transition name="fade">
19
- <div v-if="show" class="absolute inset-x-0 top-0 z-50 flex justify-center px-4 pt-4">
19
+ <!-- Positioning/stacking is owned by `BoardTopOverlays`; this renders only its card and
20
+ re-enables pointer events on it. -->
21
+ <div v-if="show" class="pointer-events-auto w-full max-w-3xl">
20
22
  <div
21
23
  class="w-full max-w-3xl rounded-2xl border-2 border-amber-500/70 bg-amber-950/95 p-5 shadow-2xl backdrop-blur"
22
24
  role="alert"
@@ -32,10 +32,9 @@ async function resume() {
32
32
 
33
33
  <template>
34
34
  <Transition name="fade">
35
- <div
36
- v-if="exceeded && spend"
37
- class="absolute inset-x-0 top-0 z-50 flex justify-center px-4 pt-4"
38
- >
35
+ <!-- Positioning/stacking is owned by `BoardTopOverlays`; this renders only its card and
36
+ re-enables pointer events on it. -->
37
+ <div v-if="exceeded && spend" class="pointer-events-auto w-full max-w-3xl">
39
38
  <div
40
39
  class="w-full max-w-3xl rounded-2xl border-2 border-red-500/70 bg-red-950/95 p-5 shadow-2xl backdrop-blur"
41
40
  role="alert"
@@ -5,8 +5,10 @@ import { computed } from 'vue'
5
5
  // Shown whenever the active locale is NOT English: the non-English catalogs are
6
6
  // community/AI-provided and may be inaccurate, so warn the user and point them at the
7
7
  // repository to report mistakes or open a fix PR. Rendered as a slim full-width strip at
8
- // the very top (distinct from the centered config-warning cards below it, so they don't
9
- // overlap). Dismissal is persisted per-locale in localStorage: once dismissed for a locale
8
+ // the very top of the shell, IN NORMAL FLOW rather than fixed over the page: a strip that
9
+ // takes its own height cannot cover anything, where the fixed one sat over the board's top
10
+ // controls for the whole of every non-English session. Dismissal is persisted per-locale in
11
+ // localStorage: once dismissed for a locale
10
12
  // it stays hidden across reloads, but switching to a different (separately-translated)
11
13
  // locale shows it again, since that catalog is a fresh, independently-translated context.
12
14
  const REPO_URL = 'https://github.com/kibertoad/cat-factory'
@@ -29,7 +31,7 @@ function dismiss() {
29
31
  v-if="show"
30
32
  data-testid="translation-warning"
31
33
  role="alert"
32
- class="fixed inset-x-0 top-0 z-50 flex items-center gap-3 border-b border-amber-500/40 bg-amber-950/95 px-4 py-2 text-[13px] text-amber-100 shadow-lg backdrop-blur"
34
+ class="flex shrink-0 items-center gap-3 border-b border-amber-500/40 bg-amber-950/95 px-4 py-2 text-[13px] text-amber-100 shadow-lg backdrop-blur"
33
35
  >
34
36
  <UIcon name="i-lucide-languages" class="h-4 w-4 shrink-0 text-amber-400" />
35
37
  <p class="min-w-0 flex-1">
@@ -12,8 +12,8 @@
12
12
  // It composes NOTHING itself: `composeRunOutcome` (`~/utils/runOutcome`) is the pure reduction,
13
13
  // so the rules that matter (a regression is an `established` requirement observed to fail; an
14
14
  // absent producer never renders as a clean result) are unit-tested without mounting this. What
15
- // lives here is presentation only, plus the ONE fetch the card owns: the enclosing service's
16
- // spec, which turns the tester's requirement IDS into the requirement TITLES a reader came for.
15
+ // lives here is presentation only, plus the ONE fetch the card owns: the spec THIS RUN was
16
+ // judged against, which turns the tester's requirement IDS into the TITLES a reader came for.
17
17
  import { computed, onUnmounted, ref, watch } from 'vue'
18
18
  import type {
19
19
  OutcomeCheckKind,
@@ -47,32 +47,43 @@ const { t } = useI18n()
47
47
  const blobs = useArtifactBlobs()
48
48
  onUnmounted(() => blobs.revokeAll())
49
49
 
50
- // The shared seam contract. The `onOpen` loader fetches the ENCLOSING SERVICE's spec: the
51
- // requirement verdicts are keyed by the spec's own ids, and without it the coverage section can
52
- // only show ids (which it then says, rather than letting an id read as a title).
53
- const { open, blockId, instanceId, close } = useResultView('outcome', {
54
- onOpen: (view) => {
55
- const block = board.getBlock(view.blockId)
56
- const service = block ? board.serviceOf(block) : undefined
57
- if (service) void serviceSpec.load(service.id)
58
- },
59
- })
50
+ // The shared seam contract.
51
+ const { open, blockId, instanceId, close } = useResultView('outcome')
60
52
 
61
53
  const block = computed(() => (blockId.value ? board.getBlock(blockId.value) : undefined))
62
- const service = computed(() => (block.value ? board.serviceOf(block.value) : undefined))
63
- const instance = computed(() => {
54
+ const runId = computed(() => {
64
55
  // The run carried by the opener, else the block's own live run: a card opened from a
65
56
  // notification names the run, one opened from the board does not.
66
- const id = instanceId.value ?? block.value?.executionId ?? null
67
- return id ? (execution.getInstance(id) ?? null) : null
57
+ return instanceId.value ?? block.value?.executionId ?? null
68
58
  })
59
+ const instance = computed(() => (runId.value ? (execution.getInstance(runId.value) ?? null) : null))
60
+
61
+ // The ONE fetch this card owns: the spec THIS RUN was judged against. Requirement verdicts are
62
+ // keyed by the spec's own ids, and without the spec the coverage section can only show ids
63
+ // (which it then says, rather than letting an id read as a title).
64
+ //
65
+ // Keyed by the RUN, not by the enclosing service, and that is a correctness matter rather than a
66
+ // cache detail: the service read comes from the repo's default branch, so for as long as the
67
+ // run's pull request is open it is missing exactly the requirements the run added and the tester
68
+ // just ruled on. Every one of those verdicts joined against nothing and rendered as "not
69
+ // checked", and the card's counts contradicted `GET /api/v1/runs/:runId/outcome` for one run.
70
+ //
71
+ // A watch rather than the `onOpen` hook, because the run id can arrive after the block does (a
72
+ // card open on a task that starts a run) and the join must follow it.
73
+ watch(
74
+ runId,
75
+ (id) => {
76
+ if (id) void serviceSpec.loadForRun(id)
77
+ },
78
+ { immediate: true },
79
+ )
69
80
 
70
81
  const outcome = computed(() =>
71
82
  block.value
72
83
  ? composeRunOutcome({
73
84
  block: block.value,
74
85
  instance: instance.value,
75
- spec: service.value ? serviceSpec.viewFor(service.value.id) : null,
86
+ spec: runId.value ? serviceSpec.viewForRun(runId.value) : null,
76
87
  })
77
88
  : null,
78
89
  )
@@ -110,6 +121,7 @@ const REQUIREMENTS_GAP_KEYS: Record<RequirementsGap, string> = {
110
121
  no_tester_step: 'outcome.requirements.gap.no_tester_step',
111
122
  tester_not_reported: 'outcome.requirements.gap.tester_not_reported',
112
123
  no_verdicts: 'outcome.requirements.gap.no_verdicts',
124
+ no_requirements: 'outcome.requirements.gap.no_requirements',
113
125
  }
114
126
  const TESTS_GAP_KEYS: Record<TestsGap, string> = {
115
127
  run_unavailable: RUN_UNAVAILABLE_KEY,
@@ -128,7 +140,6 @@ const VISUALS_GAP_KEYS: Record<VisualsGap, string> = {
128
140
  */
129
141
  const SPEC_JOIN_KEYS: Record<Exclude<OutcomeSpecJoin, 'joined'>, string> = {
130
142
  not_read: 'outcome.requirements.spec.not_read',
131
- unmatched: 'outcome.requirements.spec.unmatched',
132
143
  }
133
144
 
134
145
  const VERDICT_META: Record<RequirementVerdictStatus, { color: string; key: string }> = {
@@ -186,9 +197,11 @@ const headerTitle = computed(() => outcome.value?.title ?? t('outcome.title'))
186
197
  const disposition = computed(() => outcome.value?.disposition ?? 'not_run')
187
198
 
188
199
  /**
189
- * The note under the requirement counts when the rows carry no spec titles, null when they do.
190
- * Resolved here so the `joined` exclusion is checked by the compiler once, rather than by a
191
- * template condition that would silently render nothing if the union grew.
200
+ * The note under the requirement counts when the section was NOT counted against the service's
201
+ * `spec/`, null when it was. It is a statement about the DENOMINATOR, not about missing titles:
202
+ * an unjoined section counts only what the tester chose to rule on and says nothing about what it
203
+ * skipped. Resolved here so the `joined` exclusion is checked by the compiler once, rather than by
204
+ * a template condition that would silently render nothing if the union grew.
192
205
  */
193
206
  const specNote = computed(() => {
194
207
  const requirements = outcome.value?.requirements
@@ -198,19 +211,22 @@ const specNote = computed(() => {
198
211
  return t(SPEC_JOIN_KEYS[requirements.spec])
199
212
  })
200
213
 
214
+ /** The requirement rows, in the composer's severity-first order. */
215
+ const requirementRows = computed(() => {
216
+ const requirements = outcome.value?.requirements
217
+ return requirements?.status === 'reported' ? requirements.entries : []
218
+ })
219
+
201
220
  /**
202
- * The requirement rows, each carrying whether its id is standing in for a title it has no way
203
- * to show. Marked per row ONLY where the section as a whole joined: an id sitting unmarked
204
- * between two named requirements reads as a requirement someone named after a slug, while a
205
- * marker on every row of a section the note above already explains is just noise.
221
+ * How many verdicts the tester returned against ids this service's `spec/` does not carry, or 0.
222
+ *
223
+ * Surfaced because the counts above are the SPEC's and this number is the difference between them
224
+ * and the tester's own tally. Unstated, a reader comparing the two reads the gap as one of the two
225
+ * being wrong, when it is really a spec that moved on under the tester.
206
226
  */
207
- const requirementRows = computed(() => {
227
+ const unmatchedVerdicts = computed(() => {
208
228
  const requirements = outcome.value?.requirements
209
- if (!requirements || requirements.status !== 'reported') return []
210
- return requirements.entries.map((entry) => ({
211
- ...entry,
212
- idOnly: requirements.spec === 'joined' && entry.title === null,
213
- }))
229
+ return requirements?.status === 'reported' ? requirements.unmatchedVerdicts : 0
214
230
  })
215
231
 
216
232
  /** The captured views, resolved to blobs as they arrive (the card shows them inline). */
@@ -373,8 +389,8 @@ function openTestReport() {
373
389
  }}
374
390
  </UBadge>
375
391
  </div>
376
- <!-- The ids are all there is: say WHICH reason, rather than letting a slug read as
377
- the name of a requirement (never read, versus read and naming none of these). -->
392
+ <!-- The coverage was not counted against the spec, so say what the numbers above do
393
+ and do not cover rather than letting them read as the whole picture. -->
378
394
  <p
379
395
  v-if="specNote"
380
396
  class="mb-2 text-[11px] leading-relaxed text-amber-300/90"
@@ -382,6 +398,16 @@ function openTestReport() {
382
398
  >
383
399
  {{ specNote }}
384
400
  </p>
401
+ <!-- The tester ruled on ids the spec does not carry, so its own tally and the counts
402
+ above legitimately differ. Said out loud, because the alternative is a reader
403
+ deciding which of the two numbers to distrust. -->
404
+ <p
405
+ v-if="unmatchedVerdicts > 0"
406
+ class="mb-2 text-[11px] leading-relaxed text-amber-300/90"
407
+ data-testid="outcome-unmatched-verdicts"
408
+ >
409
+ {{ t('outcome.requirements.unmatchedVerdicts', { count: unmatchedVerdicts }) }}
410
+ </p>
385
411
  <ul class="space-y-1.5">
386
412
  <li
387
413
  v-for="req in requirementRows"
@@ -396,18 +422,6 @@ function openTestReport() {
396
422
  <div class="min-w-0">
397
423
  <div class="flex flex-wrap items-center gap-1.5">
398
424
  <span class="text-[13px] text-slate-200">{{ req.title ?? req.id }}</span>
399
- <!-- This row's id is standing in for a title the spec does not have for it,
400
- beside rows that DO carry one. -->
401
- <UBadge
402
- v-if="req.idOnly"
403
- color="neutral"
404
- variant="subtle"
405
- size="sm"
406
- :title="t('outcome.requirements.idOnlyHint')"
407
- data-testid="outcome-requirement-id-only"
408
- >
409
- {{ t('outcome.requirements.idOnly') }}
410
- </UBadge>
411
425
  <UBadge
412
426
  v-if="req.regression"
413
427
  color="error"
@@ -1,6 +1,7 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
  import {
3
3
  boardNodeIdFor,
4
+ focusLeftCard,
4
5
  isSafeTargetId,
5
6
  isTargetClickAdvance,
6
7
  resolveSkip,
@@ -208,3 +209,41 @@ describe('shouldFocusCard', () => {
208
209
  expect(shouldFocusCard('target-click')).toBe(false)
209
210
  })
210
211
  })
212
+
213
+ describe('focusLeftCard', () => {
214
+ const card = () => {
215
+ const el = document.createElement('div')
216
+ const next = document.createElement('button')
217
+ el.appendChild(next)
218
+ document.body.appendChild(el)
219
+ return { el, next }
220
+ }
221
+
222
+ it('keeps the card focusable while focus is still inside it', () => {
223
+ // Tabbing from the card onto its own Next button must NOT drop `tabindex`: the card would
224
+ // become click-focusable again while the user is still in it, which is the exact state the
225
+ // attribute exists to avoid.
226
+ const { el, next } = card()
227
+ expect(focusLeftCard(el, next)).toBe(false)
228
+ expect(focusLeftCard(el, el)).toBe(false)
229
+ })
230
+
231
+ it('treats focus moving to anything outside the card as having left', () => {
232
+ const { el } = card()
233
+ const outside = document.createElement('input')
234
+ document.body.appendChild(outside)
235
+ expect(focusLeftCard(el, outside)).toBe(true)
236
+ })
237
+
238
+ it('treats focus falling to nothing as having left', () => {
239
+ // The `relatedTarget: null` case is not an edge: it is what happens when the pressed
240
+ // control unmounts under the press, which Back at step 1 does every time.
241
+ const { el } = card()
242
+ expect(focusLeftCard(el, null)).toBe(true)
243
+ })
244
+
245
+ it('reports left when there is no card', () => {
246
+ // An unmounted card holds no focus, so the flag must not be left standing.
247
+ expect(focusLeftCard(null, null)).toBe(true)
248
+ })
249
+ })
@@ -46,6 +46,29 @@ export function shouldFocusCard(cause: TutorialAdvanceCause): boolean {
46
46
  return cause !== 'target-click'
47
47
  }
48
48
 
49
+ /**
50
+ * Has focus actually LEFT the coach mark, as opposed to moving between its own controls?
51
+ *
52
+ * The card's `tabindex` is not a standing attribute (see `focusCard` in the component): it
53
+ * exists only while the card holds focus, because the same attribute that lets `focusCard`
54
+ * put focus here also makes the card CLICK-focusable, and a press that moves focus onto the
55
+ * card is what kills text selection on the steps pointing into an open modal. So the card
56
+ * drops the attribute the moment focus leaves, and this is the "leaves" test.
57
+ *
58
+ * Tabbing from the card onto its own Next button is NOT leaving: dropping the attribute
59
+ * there would make the card click-focusable again while the user is still inside it, which
60
+ * is the precise state the attribute is meant to be absent in.
61
+ *
62
+ * A `relatedTarget` of `null` (focus fell to `<body>`, as it does when the pressed control
63
+ * unmounts) counts as leaving. That is the honest reading: nothing inside the card holds
64
+ * focus any more.
65
+ */
66
+ export function focusLeftCard(card: Node | null, relatedTarget: EventTarget | null): boolean {
67
+ if (!card) return true
68
+ if (!(relatedTarget instanceof Node)) return true
69
+ return !card.contains(relatedTarget)
70
+ }
71
+
49
72
  /**
50
73
  * What a `data-testid` may look like. Every one of the ~470 test ids in this layer is
51
74
  * lowercase kebab-case, and the e2e suite's convention keeps it that way, so this rejects
@@ -11,6 +11,7 @@ import {
11
11
  import type { CoachMarkLayout, TutorialRect, TutorialStep, TutorialTour } from '~/utils/tutorial'
12
12
  import {
13
13
  boardNodeIdFor,
14
+ focusLeftCard,
14
15
  isTargetClickAdvance,
15
16
  resolveSkip,
16
17
  shouldFocusCard,
@@ -317,6 +318,28 @@ watch(step, async (s) => {
317
318
  measure({ requery: true })
318
319
  })
319
320
 
321
+ /**
322
+ * Whether the card currently carries `tabindex="-1"`.
323
+ *
324
+ * The attribute is NOT standing, and that is the whole design. It is what lets `focusCard`
325
+ * put focus on the card, and it equally makes the card CLICK-focusable, which is the half we
326
+ * never want: a press on the card's text focuses it, that focus move leaves an open modal's
327
+ * focus trap, the trap yanks focus back, and Chromium abandons the selection the press was
328
+ * starting (`selectstart` never fires). The one string a user reliably tries to copy, the
329
+ * sample repo slug in the add-service tour, could not be selected. `preventDefault` on the
330
+ * press is not the alternative: cancelling pointerdown or mousedown cancels the selection
331
+ * itself.
332
+ *
333
+ * So the card is focusable only across the window where it actually holds focus: applied by
334
+ * `focusCard` just before it focuses, dropped again by `onCardFocusOut`. Outside that window
335
+ * a press moves focus nowhere, on every input type, with no timing window to lose. Lifting
336
+ * the attribute for the DURATION OF A GESTURE cannot work instead, because the gesture has no
337
+ * reliable end event to restore on (a touch's compatibility `mousedown` arrives at touch END,
338
+ * long after any tick-scoped restore) and because `focusCard`'s own `await nextTick()` is a
339
+ * microtask that beats a macrotask restore, leaving `.focus()` a silent no-op.
340
+ */
341
+ const cardFocusable = ref(false)
342
+
320
343
  /**
321
344
  * Put focus on the tooltip so the tour's own controls are one Tab away. WHETHER to do that is
322
345
  * `shouldFocusCard`'s call, in the logic module, so it is pinned by a test — this function is
@@ -324,8 +347,22 @@ watch(step, async (s) => {
324
347
  */
325
348
  async function focusCard(cause: TutorialAdvanceCause) {
326
349
  if (!shouldFocusCard(cause)) return
350
+ cardFocusable.value = true
351
+ // One tick for BOTH the step's own re-render and the attribute above: `.focus()` on a card
352
+ // that is not yet focusable is a silent no-op, and focus then sits whereever the press left
353
+ // it (`<body>`, when the pressed control was a Back button that this step unmounts).
327
354
  await nextTick()
328
- cardEl.value?.focus({ preventScroll: true })
355
+ const el = cardEl.value
356
+ el?.focus({ preventScroll: true })
357
+ // Focus did not land: the card unmounted, or a modal's focus trap pulled it straight back.
358
+ // Clear the flag here rather than waiting for a `focusout` that will never fire, or the
359
+ // card is left click-focusable, which is exactly the state this is all avoiding.
360
+ if (!el || document.activeElement !== el) cardFocusable.value = false
361
+ }
362
+
363
+ /** Focus has left the card, so it stops being focusable until something focuses it again. */
364
+ function onCardFocusOut(event: FocusEvent) {
365
+ if (focusLeftCard(cardEl.value, event.relatedTarget)) cardFocusable.value = false
329
366
  }
330
367
 
331
368
  /**
@@ -520,20 +557,23 @@ onUnmounted(() => {
520
557
  this card would close the user's half-filled form instead of pressing a button). -->
521
558
  <!-- `tabindex="-1"` so `focusCard()` can put focus here when the tour starts and on every
522
559
  Next/Back — without it a keyboard user has to tab the whole page to reach Next, since
523
- this is teleported to the end of `body`. No `aria-modal`: a coach mark is NOT modal,
524
- and half the catalog asks the user to operate the real control behind it. No
525
- `aria-describedby` on the body either: the live region above already reads the body
526
- as part of a complete announcement, and pointing at it here would have every focus
527
- move read it a second time. -->
560
+ this is teleported to the end of `body`. BOUND rather than static, and absent while
561
+ the card does not hold focus: see `cardFocusable`, without which a press on the card's
562
+ text focuses it and an open modal's focus trap cancels the selection being started.
563
+ No `aria-modal`: a coach mark is NOT modal, and half the catalog asks the user to
564
+ operate the real control behind it. No `aria-describedby` on the body either: the
565
+ live region above already reads the body as part of a complete announcement, and
566
+ pointing at it here would have every focus move read it a second time. -->
528
567
  <div
529
568
  ref="cardEl"
530
569
  role="dialog"
531
- tabindex="-1"
570
+ :tabindex="cardFocusable ? -1 : undefined"
532
571
  :aria-label="t('tutorial.overlay.ariaLabel')"
533
572
  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 outline-none focus-visible:ring-2 focus-visible:ring-primary-400"
534
573
  :style="{ top: `${layout.top}px`, left: `${layout.left}px` }"
535
574
  data-testid="tutorial-tooltip"
536
575
  @pointerdown.stop
576
+ @focusout="onCardFocusOut"
537
577
  >
538
578
  <div class="mb-1 flex items-start justify-between gap-3">
539
579
  <h3 class="text-sm font-semibold text-slate-100">{{ t(step.titleKey) }}</h3>