@cat-factory/app 0.159.3 → 0.160.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.
@@ -137,6 +137,15 @@ const groups = computed<IntegrationGroup[]>(() => {
137
137
  onClick: () => go(ui.openVendorCredentials),
138
138
  },
139
139
  ],
140
+ // Personal (individual-usage) subscriptions are per-USER, so they live in My setup —
141
+ // but this hub is where people look first when connecting "their Claude plan". A quiet
142
+ // pointer link keeps them findable without a workspace-scoped row competing here.
143
+ footerLink: {
144
+ key: 'personal-subs',
145
+ icon: 'i-lucide-user',
146
+ label: t('layout.integrationsHub.items.personalSubs.label'),
147
+ onClick: () => go(() => ui.openVendorCredentials('personal')),
148
+ },
140
149
  })
141
150
 
142
151
  // --- Source control --------------------------------------------------------
@@ -0,0 +1,49 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { resolveVerdictMeta, UNKNOWN_VERDICT_COLOR, VERDICT_COLORS } from './StepTestReport.logic'
3
+ import type { RequirementVerdictStatus } from '~/types/domain'
4
+
5
+ /**
6
+ * The tester panel's requirement → evidence dots. The verdict is three-valued precisely so that
7
+ * "we didn't check" and "it's broken" never render the same; these pin that a FOURTH, unknown
8
+ * value cannot quietly join either camp.
9
+ */
10
+ const LABELS: Record<RequirementVerdictStatus, string> = {
11
+ met: 'Met',
12
+ not_met: 'Not met',
13
+ not_covered: 'Not checked',
14
+ }
15
+
16
+ describe('resolveVerdictMeta', () => {
17
+ it('resolves each known status to its own label and colour', () => {
18
+ expect(resolveVerdictMeta('met', LABELS)).toEqual({ label: 'Met', color: '#22c55e' })
19
+ expect(resolveVerdictMeta('not_met', LABELS)).toEqual({ label: 'Not met', color: '#ef4444' })
20
+ expect(resolveVerdictMeta('not_covered', LABELS)).toEqual({
21
+ label: 'Not checked',
22
+ color: '#64748b',
23
+ })
24
+ })
25
+
26
+ it('gives the three known statuses three distinct colours', () => {
27
+ const colors = Object.values(VERDICT_COLORS)
28
+ expect(new Set(colors).size).toBe(colors.length)
29
+ })
30
+
31
+ it('falls back to the raw code for an unrecognised status', () => {
32
+ const meta = resolveVerdictMeta('inconclusive' as RequirementVerdictStatus, LABELS)
33
+ expect(meta).toEqual({ label: 'inconclusive', color: UNKNOWN_VERDICT_COLOR })
34
+ })
35
+
36
+ it('never lets an unrecognised status borrow a known colour', () => {
37
+ // The regression this guards: reusing `not_covered`'s grey made a contract violation read
38
+ // as "not checked", which is the one confusion the three-valued design forbids.
39
+ expect(Object.values(VERDICT_COLORS)).not.toContain(UNKNOWN_VERDICT_COLOR)
40
+ })
41
+
42
+ it('falls back when the status is known but its label is missing', () => {
43
+ const sparse = { met: 'Met' } as Record<RequirementVerdictStatus, string>
44
+ expect(resolveVerdictMeta('not_met', sparse)).toEqual({
45
+ label: 'not_met',
46
+ color: UNKNOWN_VERDICT_COLOR,
47
+ })
48
+ })
49
+ })
@@ -0,0 +1,49 @@
1
+ // Pure logic behind the tester panel's REQUIREMENT → EVIDENCE section (the in-app twin of the
2
+ // PR verification report's section of the same name).
3
+ //
4
+ // Kept out of the SFC on the seam `ServiceSpecWindow.logic.ts` / `AppOverlayHost.logic.ts`
5
+ // established, so the one rule worth pinning here — that an unrecognised status can never be
6
+ // mistaken for a known one — is unit-testable without mounting Nuxt.
7
+ import type { RequirementVerdictStatus } from '~/types/domain'
8
+
9
+ /** How one verdict renders: its translated label and the dot colour beside it. */
10
+ export interface VerdictMeta {
11
+ label: string
12
+ color: string
13
+ }
14
+
15
+ /**
16
+ * The colour an UNRECOGNISED status renders in. Deliberately distinct from all three known
17
+ * colours: an unknown status is a contract violation (version skew against a backend that grew
18
+ * a fourth one), not a fourth state, so borrowing `not_covered`'s grey would render "we have no
19
+ * idea" identically to "we didn't check" — precisely the collapse the three-valued verdict
20
+ * exists to prevent. Amber is the same "attention, but not a failure" tone the panel's severity
21
+ * scale already uses for `medium`.
22
+ */
23
+ export const UNKNOWN_VERDICT_COLOR = '#f59e0b'
24
+
25
+ /** Dot colour per known verdict. Exhaustive over the closed union (drift guard tier 2). */
26
+ export const VERDICT_COLORS: Record<RequirementVerdictStatus, string> = {
27
+ met: '#22c55e',
28
+ not_met: '#ef4444',
29
+ not_covered: '#64748b',
30
+ }
31
+
32
+ /**
33
+ * Resolve one verdict's presentation. Labels are passed in already translated (this module is
34
+ * pure, so it holds no `t`), which also keeps the catalog keys literal at the call site where
35
+ * the typed-key drift guard can see them.
36
+ *
37
+ * An unrecognised status falls back to the raw code plus {@link UNKNOWN_VERDICT_COLOR}: showing
38
+ * the code is more useful to the person who has to explain the skew than any invented label,
39
+ * and the distinct colour stops it reading as one of the three real answers.
40
+ */
41
+ export function resolveVerdictMeta(
42
+ status: RequirementVerdictStatus,
43
+ labels: Record<RequirementVerdictStatus, string>,
44
+ ): VerdictMeta {
45
+ const color = VERDICT_COLORS[status]
46
+ const label = labels[status]
47
+ if (!color || !label) return { label: status, color: UNKNOWN_VERDICT_COLOR }
48
+ return { label, color }
49
+ }
@@ -1,6 +1,7 @@
1
1
  <script setup lang="ts">
2
- import type { TestReport } from '~/types/domain'
2
+ import type { RequirementVerdictStatus, TestReport } from '~/types/domain'
3
3
  import type { TesterStepState } from '~/types/execution'
4
+ import { resolveVerdictMeta, type VerdictMeta } from './StepTestReport.logic'
4
5
 
5
6
  // A tester step's latest structured report (what was tested, the per-area outcomes,
6
7
  // the concerns it raised and the greenlight verdict) plus the fixer-loop phase.
@@ -22,6 +23,21 @@ const OUTCOME_COLOR: Record<string, string> = {
22
23
  failed: '#ef4444',
23
24
  skipped: '#64748b',
24
25
  }
26
+
27
+ // Per-spec-requirement verdict labels, keyed by the requirement id the service's in-repo `spec/`
28
+ // carries. THREE-VALUED on purpose: "we didn't check" and "it's broken" must never render the
29
+ // same, which is the whole reason the tester reports the list. Exhaustive `Record` over the
30
+ // closed union (drift guard tier 2) with literal `t()` keys, so a new status fails the
31
+ // typecheck rather than rendering a raw enum value. Colours and the unknown-status fallback live
32
+ // in the pure sibling, where the "never borrow a known colour" rule is unit-tested.
33
+ const VERDICT_LABELS: Record<RequirementVerdictStatus, string> = {
34
+ met: t('panels.testReport.requirementVerdicts.met'),
35
+ not_met: t('panels.testReport.requirementVerdicts.notMet'),
36
+ not_covered: t('panels.testReport.requirementVerdicts.notCovered'),
37
+ }
38
+ function verdictMeta(status: RequirementVerdictStatus): VerdictMeta {
39
+ return resolveVerdictMeta(status, VERDICT_LABELS)
40
+ }
25
41
  </script>
26
42
 
27
43
  <template>
@@ -65,6 +81,33 @@ const OUTCOME_COLOR: Record<string, string> = {
65
81
  </div>
66
82
  </div>
67
83
 
84
+ <!-- requirement → evidence, in-app twin of the PR verification report's section: which of
85
+ the service's written-down requirements this run actually ruled on, and what it saw -->
86
+ <div
87
+ v-if="report.requirementVerdicts?.length"
88
+ class="mb-3 space-y-1"
89
+ data-testid="test-report-requirement-verdicts"
90
+ >
91
+ <div class="text-[11px] text-slate-500">
92
+ {{ t('panels.testReport.requirementVerdicts.title') }}
93
+ </div>
94
+ <div
95
+ v-for="(verdict, i) in report.requirementVerdicts"
96
+ :key="i"
97
+ class="flex items-start gap-2 text-[12px]"
98
+ >
99
+ <span
100
+ class="mt-1 h-2 w-2 shrink-0 rounded-full"
101
+ :style="{ backgroundColor: verdictMeta(verdict.status).color }"
102
+ />
103
+ <span class="text-slate-300">
104
+ <span class="font-mono text-[11px] text-slate-400">{{ verdict.requirementId }}</span>
105
+ <span class="text-slate-500"> — {{ verdictMeta(verdict.status).label }}</span>
106
+ <span v-if="verdict.detail" class="text-slate-500"> · {{ verdict.detail }}</span>
107
+ </span>
108
+ </div>
109
+ </div>
110
+
68
111
  <div v-if="report.concerns.length" class="space-y-1">
69
112
  <div class="text-[11px] text-slate-500">{{ t('panels.testReport.concerns') }}</div>
70
113
  <div
@@ -27,10 +27,23 @@ interface Route {
27
27
  title: string
28
28
  body: string
29
29
  cta: string
30
+ badge?: string
30
31
  onSelect: () => void
31
32
  }
32
33
 
34
+ // The personal-subscription route leads: using an individual coding-plan subscription the
35
+ // developer already pays for (Claude / ChatGPT-Codex / GLM) is the most common setup, and
36
+ // it used to hide behind the generic "LLM vendors" route as the last tab, where new users
37
+ // missed it. It deep-links straight onto the vendor modal's `personal` tab.
33
38
  const routes = computed<Route[]>(() => [
39
+ {
40
+ icon: 'i-lucide-user',
41
+ title: t('providers.onboarding.routes.personal.title'),
42
+ body: t('providers.onboarding.routes.personal.body'),
43
+ cta: t('providers.onboarding.routes.personal.cta'),
44
+ badge: t('providers.onboarding.routes.personal.badge'),
45
+ onSelect: () => go(() => ui.openVendorCredentials('personal')),
46
+ },
34
47
  {
35
48
  icon: 'i-lucide-key-round',
36
49
  title: t('providers.onboarding.routes.keys.title'),
@@ -85,7 +98,12 @@ const routes = computed<Route[]>(() => [
85
98
  >
86
99
  <UIcon :name="r.icon" class="mt-0.5 h-5 w-5 shrink-0 text-indigo-300" />
87
100
  <div class="min-w-0 flex-1">
88
- <p class="text-sm font-semibold text-slate-100">{{ r.title }}</p>
101
+ <div class="flex items-center gap-2">
102
+ <p class="text-sm font-semibold text-slate-100">{{ r.title }}</p>
103
+ <UBadge v-if="r.badge" color="primary" variant="subtle" size="sm">
104
+ {{ r.badge }}
105
+ </UBadge>
106
+ </div>
89
107
  <p class="mt-0.5 text-[13px] leading-relaxed text-slate-400">{{ r.body }}</p>
90
108
  </div>
91
109
  <UButton
@@ -85,7 +85,9 @@ async function submit() {
85
85
 
86
86
  function goConnect() {
87
87
  personal.dismissPending()
88
- ui.openVendorCredentials()
88
+ // Deep-link onto the personal tab: this CTA promises the individual-subscription connect
89
+ // form, and the modal's default tab is the (unrelated) workspace pool.
90
+ ui.openVendorCredentials('personal')
89
91
  }
90
92
  </script>
91
93
 
@@ -209,6 +209,28 @@ function vendorLabel(v: SubscriptionVendor): string {
209
209
  <!-- Workspace pool (commercial coding-plan subscriptions) -->
210
210
  <template #pool>
211
211
  <div class="space-y-5">
212
+ <!-- The pool is the modal's default tab, but most individual developers arrive
213
+ looking for their OWN Claude / ChatGPT / GLM plan, which lives on the
214
+ `personal` tab — point them there before they read a pool form that can't
215
+ take their credential. -->
216
+ <div
217
+ class="flex items-center gap-3 rounded-lg border border-indigo-500/30 bg-indigo-950/30 px-3 py-2.5"
218
+ >
219
+ <UIcon name="i-lucide-user" class="h-5 w-5 shrink-0 text-indigo-300" />
220
+ <p class="min-w-0 flex-1 text-[13px] text-slate-300">
221
+ {{ t('providers.vendorCredentials.personalCallout.text') }}
222
+ </p>
223
+ <UButton
224
+ size="xs"
225
+ color="primary"
226
+ variant="subtle"
227
+ class="shrink-0"
228
+ @click="activeTab = 'personal'"
229
+ >
230
+ {{ t('providers.vendorCredentials.personalCallout.cta') }}
231
+ </UButton>
232
+ </div>
233
+
212
234
  <p class="text-sm text-slate-400">
213
235
  {{ t('providers.vendorCredentials.poolIntro') }}
214
236
  </p>
@@ -0,0 +1,108 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import {
3
+ filterRequirementsByState,
4
+ requirementState,
5
+ summarizeRequirementStates,
6
+ summarizeSpecStates,
7
+ } from './ServiceSpecWindow.logic'
8
+ import type { RequirementItem, SpecModule } from '~/types/spec'
9
+
10
+ /**
11
+ * The implementation-state view of the service-spec window (service-acceptance-criteria, slice 4).
12
+ * The axis only earns its keep if "agreed" and "observed to hold" never render the same, so these
13
+ * pin the counting, the defensive read of a missing state, and the filter's identity fast path.
14
+ */
15
+ const req = (id: string, state?: RequirementItem['state']): RequirementItem =>
16
+ ({
17
+ id,
18
+ title: id,
19
+ statement: `The system SHALL ${id}.`,
20
+ kind: 'functional',
21
+ priority: 'must',
22
+ ...(state ? { state } : {}),
23
+ }) as RequirementItem
24
+
25
+ describe('requirementState', () => {
26
+ it('reads an explicit established state', () => {
27
+ expect(requirementState(req('a', 'established'))).toBe('established')
28
+ })
29
+
30
+ it('treats an absent or unknown state as aspirational', () => {
31
+ expect(requirementState(req('a'))).toBe('aspirational')
32
+ expect(requirementState({ state: 'nonsense' } as unknown as RequirementItem)).toBe(
33
+ 'aspirational',
34
+ )
35
+ })
36
+ })
37
+
38
+ describe('summarizeRequirementStates', () => {
39
+ it('counts both halves and their total', () => {
40
+ expect(
41
+ summarizeRequirementStates([
42
+ req('a', 'established'),
43
+ req('b', 'aspirational'),
44
+ req('c', 'established'),
45
+ ]),
46
+ ).toEqual({ total: 3, established: 2, aspirational: 1 })
47
+ })
48
+
49
+ it('counts a requirement with no state as aspirational', () => {
50
+ expect(summarizeRequirementStates([req('a')])).toEqual({
51
+ total: 1,
52
+ established: 0,
53
+ aspirational: 1,
54
+ })
55
+ })
56
+
57
+ it('returns zeroes for an absent or empty list', () => {
58
+ const zero = { total: 0, established: 0, aspirational: 0 }
59
+ expect(summarizeRequirementStates(undefined)).toEqual(zero)
60
+ expect(summarizeRequirementStates([])).toEqual(zero)
61
+ })
62
+ })
63
+
64
+ describe('summarizeSpecStates', () => {
65
+ // The parsed wire type fills every optional with its default, so the sparse shapes below are
66
+ // cast: they stand for what a hand-written or partially-hydrated tree actually looks like at
67
+ // runtime, which is exactly the case the helpers are defensive about.
68
+ const modules = [
69
+ {
70
+ name: 'auth',
71
+ groups: [
72
+ { name: 'login', requirements: [req('a', 'established'), req('b')] },
73
+ { name: 'logout', requirements: [req('c', 'established')] },
74
+ ],
75
+ },
76
+ // A module whose only group has no requirements, and a module with no groups at all: both
77
+ // contribute nothing rather than throwing — a spec is legitimately sparse while it is
78
+ // being written.
79
+ { name: 'billing', groups: [{ name: 'invoices' }] },
80
+ { name: 'empty' },
81
+ ] as unknown as SpecModule[]
82
+
83
+ it('rolls the per-group counts up across the tree', () => {
84
+ expect(summarizeSpecStates(modules)).toEqual({ total: 3, established: 2, aspirational: 1 })
85
+ })
86
+
87
+ it('returns zeroes for an absent module list', () => {
88
+ expect(summarizeSpecStates(undefined)).toEqual({ total: 0, established: 0, aspirational: 0 })
89
+ })
90
+ })
91
+
92
+ describe('filterRequirementsByState', () => {
93
+ const list = [req('a', 'established'), req('b'), req('c', 'aspirational')]
94
+
95
+ it('returns the same array reference for the default filter', () => {
96
+ expect(filterRequirementsByState(list, 'all')).toBe(list)
97
+ })
98
+
99
+ it('keeps only the requested half', () => {
100
+ expect(filterRequirementsByState(list, 'established').map((r) => r.id)).toEqual(['a'])
101
+ expect(filterRequirementsByState(list, 'aspirational').map((r) => r.id)).toEqual(['b', 'c'])
102
+ })
103
+
104
+ it('handles an absent list', () => {
105
+ expect(filterRequirementsByState(undefined, 'established')).toEqual([])
106
+ expect(filterRequirementsByState(undefined, 'all')).toEqual([])
107
+ })
108
+ })
@@ -0,0 +1,71 @@
1
+ // Pure logic behind the service-spec window's IMPLEMENTATION-STATE view.
2
+ //
3
+ // `spec/` is prescriptive — it says what must be TRUE — and `requirementItem.state` is what
4
+ // lets it also say what is true YET (`aspirational` = agreed via the spec diff's PR review,
5
+ // `established` = a tester exercised its acceptance criteria and they passed). The agents
6
+ // already read that split off their checkout; this is the half a HUMAN reads, so the same
7
+ // window that lists a service's behaviour can say how much of it the service actually honours.
8
+ //
9
+ // Kept out of the SFC so the counting and filtering are unit-testable without mounting Nuxt.
10
+ import type { RequirementItem, RequirementState, SpecModule } from '~/types/spec'
11
+
12
+ /** Which half of the spec the reader wants to see. `all` is the default (nothing hidden). */
13
+ export type RequirementStateFilter = 'all' | RequirementState
14
+
15
+ /** Requirement counts for one scope (a group, or a whole spec). */
16
+ export interface RequirementStateSummary {
17
+ total: number
18
+ established: number
19
+ aspirational: number
20
+ }
21
+
22
+ /**
23
+ * Read a requirement's implementation state defensively. The wire schema defaults an absent
24
+ * value to `aspirational` at the parse boundary, but a requirement nobody has observed to hold
25
+ * is exactly what an unreadable/absent state means — so treating anything that is not literally
26
+ * `established` as aspirational is both the safe answer and the one the backend renders.
27
+ */
28
+ export function requirementState(item: Pick<RequirementItem, 'state'>): RequirementState {
29
+ return item.state === 'established' ? 'established' : 'aspirational'
30
+ }
31
+
32
+ /** Count a list of requirements by implementation state. */
33
+ export function summarizeRequirementStates(
34
+ requirements: readonly Pick<RequirementItem, 'state'>[] | undefined,
35
+ ): RequirementStateSummary {
36
+ const total = requirements?.length ?? 0
37
+ let established = 0
38
+ for (const req of requirements ?? []) {
39
+ if (requirementState(req) === 'established') established += 1
40
+ }
41
+ return { total, established, aspirational: total - established }
42
+ }
43
+
44
+ /** Roll the per-group counts up across the whole spec tree (the overview pane's headline). */
45
+ export function summarizeSpecStates(
46
+ modules: readonly SpecModule[] | undefined,
47
+ ): RequirementStateSummary {
48
+ const summary: RequirementStateSummary = { total: 0, established: 0, aspirational: 0 }
49
+ for (const module of modules ?? []) {
50
+ for (const group of module.groups ?? []) {
51
+ const groupSummary = summarizeRequirementStates(group.requirements)
52
+ summary.total += groupSummary.total
53
+ summary.established += groupSummary.established
54
+ summary.aspirational += groupSummary.aspirational
55
+ }
56
+ }
57
+ return summary
58
+ }
59
+
60
+ /**
61
+ * Apply the reader's state filter. `all` returns the SAME array reference (not a copy), so the
62
+ * default view costs nothing and a `v-for` key set never churns on an unrelated re-render.
63
+ */
64
+ export function filterRequirementsByState<T extends Pick<RequirementItem, 'state'>>(
65
+ requirements: readonly T[] | undefined,
66
+ filter: RequirementStateFilter,
67
+ ): readonly T[] {
68
+ const list = requirements ?? []
69
+ if (filter === 'all') return list
70
+ return list.filter((req) => requirementState(req) === filter)
71
+ }
@@ -10,9 +10,17 @@ import type {
10
10
  RequirementItem,
11
11
  RequirementKind,
12
12
  RequirementPriority,
13
+ RequirementState,
13
14
  SpecModule,
14
15
  } from '~/types/spec'
15
16
  import ResultWindowShell from '~/components/panels/ResultWindowShell.vue'
17
+ import {
18
+ filterRequirementsByState,
19
+ requirementState,
20
+ summarizeRequirementStates,
21
+ summarizeSpecStates,
22
+ type RequirementStateFilter,
23
+ } from './ServiceSpecWindow.logic'
16
24
 
17
25
  const { t } = useI18n()
18
26
  const board = useBoardStore()
@@ -20,6 +28,16 @@ const serviceSpec = useServiceSpecStore()
20
28
 
21
29
  type ViewMode = 'structured' | 'gherkin'
22
30
  const mode = ref<ViewMode>('structured')
31
+ // Which half of the spec the reader wants: everything, only what the service is observed to
32
+ // honour, or only what is agreed but not built yet.
33
+ //
34
+ // DELIBERATELY STICKY ACROSS GROUPS, and reset only on open (alongside the view mode and the
35
+ // selection). "Show me what this service has actually proven" is a question about the SERVICE,
36
+ // so re-answering it on every group click would defeat the filter the moment the reader
37
+ // navigates. The cost is that a group with no match renders empty, which is why that case says
38
+ // so and offers a way back rather than leaving the reader stranded in a filter they may have
39
+ // set several groups ago.
40
+ const stateFilter = ref<RequirementStateFilter>('all')
23
41
  // Selected feature group, keyed by its module + group index so a name collision can't
24
42
  // cross-select. Null = show the service overview.
25
43
  const selected = ref<{ m: number; g: number } | null>(null)
@@ -27,6 +45,7 @@ const selected = ref<{ m: number; g: number } | null>(null)
27
45
  const { open, blockId, close } = useResultView('service-spec', {
28
46
  onOpen: ({ blockId }) => {
29
47
  mode.value = 'structured'
48
+ stateFilter.value = 'all'
30
49
  selected.value = null
31
50
  void serviceSpec.load(blockId)
32
51
  },
@@ -109,9 +128,47 @@ const KIND_LABELS: Record<RequirementKind, string> = {
109
128
  constraint: t('spec.kind.constraint'),
110
129
  }
111
130
 
131
+ // Exhaustive implementation-state → presentation map. `established` is the only state that
132
+ // means "the service is observed to do this"; everything else is a behaviour that has been
133
+ // agreed and not yet seen to hold, which must never read as standing behaviour.
134
+ const STATE_META: Record<RequirementState, { label: string; chip: string; icon: string }> = {
135
+ established: {
136
+ label: t('spec.state.established'),
137
+ chip: 'success',
138
+ icon: 'i-lucide-circle-check',
139
+ },
140
+ aspirational: {
141
+ label: t('spec.state.aspirational'),
142
+ chip: 'neutral',
143
+ icon: 'i-lucide-circle-dashed',
144
+ },
145
+ }
146
+
147
+ // The state filter's three choices. The two state choices REUSE the badge labels rather than
148
+ // carrying their own catalog keys: a chip that reads differently from the badge it filters for
149
+ // is a translation bug waiting to happen, and one key per state cannot drift from itself. Only
150
+ // `all` needs a key of its own, and it is a literal `t()` so the typed-key drift guard stays live.
151
+ const STATE_FILTERS: { value: RequirementStateFilter; label: string }[] = [
152
+ { value: 'all', label: t('spec.state.filter.all') },
153
+ { value: 'established', label: STATE_META.established.label },
154
+ { value: 'aspirational', label: STATE_META.aspirational.label },
155
+ ]
156
+
157
+ // Service-wide rollup, shown on the overview pane: how much of the written-down behaviour the
158
+ // service is actually known to honour.
159
+ const specStates = computed(() => summarizeSpecStates(modules.value))
160
+ // The selected group's rollup + the requirements the filter admits.
161
+ const groupStates = computed(() => summarizeRequirementStates(selectedGroup.value?.requirements))
162
+ const visibleRequirements = computed(() =>
163
+ filterRequirementsByState(selectedGroup.value?.requirements, stateFilter.value),
164
+ )
165
+
112
166
  function reqCount(group: RequirementGroup): number {
113
167
  return group.requirements?.length ?? 0
114
168
  }
169
+ function stateMeta(item: RequirementItem) {
170
+ return STATE_META[requirementState(item)]
171
+ }
115
172
  function priorityMeta(item: RequirementItem) {
116
173
  return PRIORITY_META[item.priority] ?? PRIORITY_META.could
117
174
  }
@@ -265,6 +322,21 @@ function kindLabel(item: RequirementItem): string {
265
322
  {{ spec.summary }}
266
323
  </p>
267
324
  <p v-else class="mt-2 text-sm text-slate-500">{{ t('spec.noSummary') }}</p>
325
+ <!-- implementation-state rollup: how much of the written-down behaviour is observed
326
+ to hold, rather than merely agreed -->
327
+ <div
328
+ v-if="specStates.total > 0"
329
+ class="mt-4 flex flex-wrap items-center gap-2 text-xs"
330
+ data-testid="spec-state-rollup"
331
+ >
332
+ <UBadge color="success" variant="subtle" size="sm">
333
+ {{ t('spec.state.establishedCount', { count: specStates.established }) }}
334
+ </UBadge>
335
+ <UBadge color="neutral" variant="subtle" size="sm">
336
+ {{ t('spec.state.aspirationalCount', { count: specStates.aspirational }) }}
337
+ </UBadge>
338
+ <span class="text-slate-500">{{ t('spec.state.rollupHint') }}</span>
339
+ </div>
268
340
  <p class="mt-4 text-xs text-slate-500">
269
341
  {{
270
342
  hasGherkin
@@ -303,15 +375,84 @@ function kindLabel(item: RequirementItem): string {
303
375
  <div v-if="reqCount(selectedGroup) === 0" class="mt-4 text-sm text-slate-500">
304
376
  {{ t('spec.noRequirements') }}
305
377
  </div>
378
+ <!-- per-group implementation-state rollup + the filter over the two halves -->
379
+ <div
380
+ v-else
381
+ class="mt-4 flex flex-wrap items-center justify-between gap-2"
382
+ data-testid="spec-state-filter"
383
+ >
384
+ <div class="flex items-center gap-1.5 text-[11px] text-slate-500">
385
+ <UIcon :name="STATE_META.established.icon" class="h-3.5 w-3.5 text-emerald-400" />
386
+ {{
387
+ t('spec.state.groupRollup', {
388
+ established: groupStates.established,
389
+ total: groupStates.total,
390
+ })
391
+ }}
392
+ </div>
393
+ <div class="flex items-center rounded-lg border border-slate-700 p-0.5">
394
+ <UButton
395
+ v-for="option in STATE_FILTERS"
396
+ :key="option.value"
397
+ :color="stateFilter === option.value ? 'primary' : 'neutral'"
398
+ :variant="stateFilter === option.value ? 'soft' : 'ghost'"
399
+ size="xs"
400
+ :data-testid="`spec-state-filter-${option.value}`"
401
+ @click="
402
+ () => {
403
+ stateFilter = option.value
404
+ }
405
+ "
406
+ >
407
+ {{ option.label }}
408
+ </UButton>
409
+ </div>
410
+ </div>
411
+ <!-- the filter can legitimately empty a non-empty group; say so rather than
412
+ rendering a blank pane that reads like "no requirements". The filter is sticky
413
+ across groups, so the reader may have set it several groups ago — offer the way
414
+ back here rather than making them find the toggle again. -->
415
+ <div
416
+ v-if="reqCount(selectedGroup) > 0 && visibleRequirements.length === 0"
417
+ class="mt-4 flex flex-wrap items-center gap-2 text-sm text-slate-500"
418
+ data-testid="spec-state-filter-empty"
419
+ >
420
+ {{ t('spec.state.noneMatchFilter') }}
421
+ <UButton
422
+ color="primary"
423
+ variant="link"
424
+ size="xs"
425
+ class="p-0"
426
+ data-testid="spec-state-filter-reset"
427
+ @click="
428
+ () => {
429
+ stateFilter = 'all'
430
+ }
431
+ "
432
+ >
433
+ {{ t('spec.state.showAll') }}
434
+ </UButton>
435
+ </div>
306
436
  <ul class="mt-4 space-y-4">
307
437
  <li
308
- v-for="req in selectedGroup.requirements ?? []"
438
+ v-for="req in visibleRequirements"
309
439
  :key="req.id"
310
440
  class="rounded-lg border border-slate-800 bg-slate-900/60 p-4"
311
441
  >
312
442
  <div class="flex items-start justify-between gap-3">
313
443
  <h3 class="text-sm font-semibold text-slate-100">{{ req.title }}</h3>
314
444
  <div class="flex shrink-0 items-center gap-1.5">
445
+ <!-- implementation state: agreed vs observed to hold. The distinction the
446
+ build prompt and the tester act on, so a reader must see it too. -->
447
+ <UBadge
448
+ :color="stateMeta(req).chip as any"
449
+ variant="subtle"
450
+ size="sm"
451
+ :icon="stateMeta(req).icon"
452
+ :data-testid="`spec-requirement-state-${requirementState(req)}`"
453
+ >
454
+ {{ stateMeta(req).label }}
455
+ </UBadge>
315
456
  <UBadge :color="priorityMeta(req).chip as any" variant="subtle" size="sm">
316
457
  {{ priorityMeta(req).label }}
317
458
  </UBadge>
@@ -55,6 +55,8 @@ export type {
55
55
  TestConcern,
56
56
  TestOutcome,
57
57
  TestReport,
58
+ RequirementVerdict,
59
+ RequirementVerdictStatus,
58
60
  TestScreenshot,
59
61
  AgentKind,
60
62
  AgentCategory,
package/app/types/spec.ts CHANGED
@@ -10,6 +10,7 @@
10
10
  export type {
11
11
  RequirementPriority,
12
12
  RequirementKind,
13
+ RequirementState,
13
14
  AcceptanceCriterion,
14
15
  RequirementItem,
15
16
  DomainRule,