@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 +10 -0
- package/app/components/layout/BoardToolbar.vue +4 -1
- package/app/components/layout/BoardTopOverlays.spec.ts +72 -0
- package/app/components/layout/BoardTopOverlays.vue +90 -0
- package/app/components/layout/ConnectionStatusBanner.vue +4 -4
- package/app/components/layout/GitHubPatBanner.vue +3 -1
- package/app/components/layout/SpendWarningBanner.vue +3 -4
- package/app/components/layout/TranslationWarningBanner.vue +5 -3
- package/app/components/outcome/OutcomeSummaryWindow.vue +59 -45
- package/app/components/tutorial/TutorialOverlay.logic.spec.ts +39 -0
- package/app/components/tutorial/TutorialOverlay.logic.ts +23 -0
- package/app/components/tutorial/TutorialOverlay.vue +47 -7
- package/app/composables/api/spec.ts +14 -4
- package/app/pages/index.vue +125 -164
- package/app/stores/serviceSpec.spec.ts +81 -0
- package/app/stores/serviceSpec.ts +55 -25
- package/app/utils/catalog.ts +7 -6
- package/app/utils/runOutcome.ts +38 -507
- package/i18n/locales/de.json +6 -7
- package/i18n/locales/en.json +6 -7
- package/i18n/locales/es.json +6 -7
- package/i18n/locales/fr.json +6 -7
- package/i18n/locales/he.json +6 -7
- package/i18n/locales/it.json +6 -7
- package/i18n/locales/ja.json +6 -7
- package/i18n/locales/pl.json +6 -7
- package/i18n/locales/tr.json +6 -7
- package/i18n/locales/uk.json +6 -7
- package/package.json +2 -2
- package/app/utils/runOutcome.spec.ts +0 -492
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="
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
|
9
|
-
//
|
|
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="
|
|
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
|
|
16
|
-
//
|
|
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.
|
|
51
|
-
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
190
|
-
*
|
|
191
|
-
*
|
|
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
|
-
*
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
|
227
|
+
const unmatchedVerdicts = computed(() => {
|
|
208
228
|
const requirements = outcome.value?.requirements
|
|
209
|
-
|
|
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
|
|
377
|
-
|
|
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
|
|
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`.
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
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>
|