@cat-factory/app 0.195.2 → 0.196.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.
Files changed (39) hide show
  1. package/README.md +10 -3
  2. package/app/components/brainstorm/BrainstormWindow.vue +11 -4
  3. package/app/components/clarity/ClarityReviewWindow.vue +11 -4
  4. package/app/components/initiative/InitiativePlanReview.vue +44 -37
  5. package/app/components/initiative/InitiativeTrackerWindow.vue +12 -9
  6. package/app/components/layout/SideBar.vue +13 -2
  7. package/app/components/layout/UiModeSwitcher.vue +66 -39
  8. package/app/components/panels/ResultWindowShell.logic.spec.ts +174 -0
  9. package/app/components/panels/ResultWindowShell.logic.ts +31 -0
  10. package/app/components/panels/ResultWindowShell.vue +37 -8
  11. package/app/components/panels/StepMetadataCard.vue +16 -0
  12. package/app/components/panels/StepRunMeta.vue +18 -0
  13. package/app/components/pipeline/PipelineBuilder.vue +46 -0
  14. package/app/components/prReview/PrReviewWindow.vue +15 -7
  15. package/app/components/requirements/RequirementsReviewWindow.vue +15 -5
  16. package/app/components/spec/ServiceSpecWindow.vue +7 -4
  17. package/app/components/testing/TestReportWindow.vue +11 -6
  18. package/app/composables/api/errors.ts +7 -0
  19. package/app/composables/usePipelineErrorToast.spec.ts +119 -5
  20. package/app/composables/usePipelineErrorToast.ts +140 -10
  21. package/app/composables/useStepPromptVariant.spec.ts +75 -0
  22. package/app/composables/useStepPromptVariant.ts +50 -0
  23. package/app/modular/nav-contributions.ts +3 -3
  24. package/app/stores/agents.spec.ts +30 -0
  25. package/app/stores/agents.ts +33 -1
  26. package/app/stores/pipelines/draftStepConfig.ts +24 -1
  27. package/app/stores/workspace/hydrate.ts +3 -0
  28. package/app/types/domain.ts +1 -0
  29. package/i18n/locales/de.json +25 -2
  30. package/i18n/locales/en.json +37 -2
  31. package/i18n/locales/es.json +25 -2
  32. package/i18n/locales/fr.json +25 -2
  33. package/i18n/locales/he.json +25 -2
  34. package/i18n/locales/it.json +25 -2
  35. package/i18n/locales/ja.json +25 -2
  36. package/i18n/locales/pl.json +25 -2
  37. package/i18n/locales/tr.json +25 -2
  38. package/i18n/locales/uk.json +25 -2
  39. package/package.json +2 -2
package/README.md CHANGED
@@ -77,11 +77,18 @@ a fixed order, first match wins:
77
77
  set the in-app switcher is a read-only indicator, since a preference the resolver ignores
78
78
  would be a lie. An unrecognised value is ignored rather than failing the boot.
79
79
  2. **The user's own choice**, persisted client-side (the `uiMode` store) and changed from the
80
- switcher at the bottom of the sidebar — or from the **command palette** entry, which is
81
- deliberately _not_ an advanced item: basic is the default, so the route back to the
82
- advanced half has to exist inside basic mode.
80
+ switcher at the top of the sidebar, under the board switcher — or from the **command
81
+ palette** entry, which is deliberately _not_ an advanced item: basic is the default, so the
82
+ route back to the advanced half has to exist inside basic mode.
83
83
  3. **`basic`.**
84
84
 
85
+ That switcher is a **segmented control showing both tiers**, above the fold rather than in the
86
+ footer, because basic is the shipped default and it is most users' only sight of the tier: a
87
+ dropdown states the current mode but not that another one exists, so the half of the product it
88
+ gates stays invisible to anyone who does not open menus to see what is in them. In the collapsed
89
+ rail it degrades to one button that flips the tier (with only two modes a toggle is
90
+ unambiguous), keeping the current tier's name under the glyph.
91
+
85
92
  The sidebar can independently be **collapsed to an icon rail** (the toggle at its top, lg+
86
93
  only — below `lg` the navbar is already an off-canvas drawer). The rail preference is
87
94
  **per-tier**: basic _defaults_ to railed and advanced to expanded, and each tier remembers its
@@ -254,7 +254,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
254
254
  "
255
255
  :subtitle="block?.title"
256
256
  variant="centered"
257
- width="5xl"
257
+ width="full"
258
258
  @close="close"
259
259
  >
260
260
  <template v-if="session" #header-extras>
@@ -361,14 +361,18 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
361
361
  {{ t(STATUS_LABELS[item.status]) }}
362
362
  </UBadge>
363
363
  </div>
364
- <p class="mt-1 whitespace-pre-line text-sm text-slate-400">
364
+ <!-- The proposal itself is prose, so it takes the measure even though the card
365
+ around it takes the span (see the shell's `width` prop: the unit is the
366
+ paragraph, not the section). The badge row above and the choose/dismiss
367
+ control below are what the full width is actually for. -->
368
+ <p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-400">
365
369
  {{ item.detail }}
366
370
  </p>
367
371
 
368
372
  <!-- recorded choice -->
369
373
  <div
370
374
  v-if="item.reply"
371
- class="mt-2 rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
375
+ class="mt-2 max-w-3xl rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
372
376
  >
373
377
  <span class="text-[10px] uppercase tracking-wide text-slate-500">
374
378
  {{ t('brainstorm.yourChoice') }}
@@ -446,7 +450,10 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
446
450
  {{ incorporated ? docNoun : t('brainstorm.docDraft', { doc: docNoun }) }}
447
451
  </span>
448
452
  </div>
449
- <div v-for="s in outline.sections" :key="s.id" class="mb-2">
453
+ <!-- The same reading measure the options' own prose takes above (see the shell's
454
+ `width` prop): the window is `full`-width now, and this is continuous prose that
455
+ would otherwise run to 200-character lines. -->
456
+ <div v-for="s in outline.sections" :key="s.id" class="mb-2 max-w-3xl">
450
457
  <button
451
458
  v-if="s.title"
452
459
  class="group flex w-full items-center gap-2 text-start"
@@ -310,7 +310,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
310
310
  :title="t('clarity.title')"
311
311
  :subtitle="block?.title"
312
312
  variant="centered"
313
- width="5xl"
313
+ width="full"
314
314
  @close="close"
315
315
  >
316
316
  <template v-if="review" #header-extras>
@@ -418,7 +418,11 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
418
418
  {{ t(STATUS_LABELS[item.status]) }}
419
419
  </UBadge>
420
420
  </div>
421
- <p class="mt-1 whitespace-pre-line text-sm text-slate-400">
421
+ <!-- The reviewer's question is prose, so it takes the measure even though the
422
+ card around it takes the span (see the shell's `width` prop: the unit is the
423
+ paragraph, not the section). The badge row above and the answer control
424
+ below are what the full width is actually for. -->
425
+ <p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-400">
422
426
  {{ item.detail }}
423
427
  </p>
424
428
 
@@ -426,7 +430,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
426
430
  the answer lives in the textarea below, seeded from the reply) -->
427
431
  <div
428
432
  v-if="item.reply && item.status !== 'open' && item.status !== 'answered'"
429
- class="mt-2 rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
433
+ class="mt-2 max-w-3xl rounded-md border-s-2 border-slate-700 bg-slate-950/40 px-3 py-1.5 text-sm text-slate-300"
430
434
  >
431
435
  <span class="text-[10px] uppercase tracking-wide text-slate-500">
432
436
  {{ t('clarity.answerLabel') }}
@@ -490,7 +494,10 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
490
494
  {{ incorporated ? t('clarity.docHeading') : t('clarity.docHeadingDraft') }}
491
495
  </span>
492
496
  </div>
493
- <div v-for="s in outline.sections" :key="s.id" class="mb-2">
497
+ <!-- The same reading measure the findings' own prose takes above (see the shell's
498
+ `width` prop): the window is `full`-width now, and this is continuous prose that
499
+ would otherwise run to 200-character lines. -->
500
+ <div v-for="s in outline.sections" :key="s.id" class="mb-2 max-w-3xl">
494
501
  <button
495
502
  v-if="s.title"
496
503
  class="group flex w-full items-center gap-2 text-start"
@@ -224,45 +224,52 @@ async function copyPlan() {
224
224
  class="min-h-0 min-w-0 flex-1 overflow-y-auto px-5 py-4"
225
225
  @scroll="onScroll"
226
226
  >
227
- <!-- No `max-w-*` reading measure here: with the outline and the review rail both taking a
228
- fixed column out of the shell's `5xl`, this one is ~490px wide at every size that renders
229
- it, so a cap would only ever be dead markup. -->
230
- <section
231
- v-for="s in outline.sections"
232
- :id="s.id"
233
- :key="s.id"
234
- :ref="(el) => (sectionEls[s.id] = el as HTMLElement | null)"
235
- class="scroll-mt-2"
236
- >
237
- <button
238
- v-if="s.depth > 0"
239
- class="group flex w-full items-center gap-1.5 rounded py-0.5 text-start transition hover:text-white"
240
- :aria-expanded="!collapsed[s.id]"
241
- @click="toggle(s.id)"
227
+ <!-- The reading measure, which the shell's `full` width made load-bearing: this column used
228
+ to be ~490px at every size that rendered it (a fixed outline and a fixed rail out of a
229
+ `5xl` shell), so a cap was dead markup and the comment here said so. On a window that now
230
+ spans the viewport it is the only thing between the plan and 200-character lines, and it
231
+ is the step reader's own measure (`AgentStepDetail`, `mx-auto max-w-3xl` over the same
232
+ 13px `.reader-prose`) rather than a second opinion about how wide prose should be. The
233
+ leftover width is the document's margins; the LAYOUT is what the extra space bought —
234
+ outline and rail no longer competing with the plan for one 5xl card. -->
235
+ <div class="mx-auto w-full max-w-3xl">
236
+ <section
237
+ v-for="s in outline.sections"
238
+ :id="s.id"
239
+ :key="s.id"
240
+ :ref="(el) => (sectionEls[s.id] = el as HTMLElement | null)"
241
+ class="scroll-mt-2"
242
242
  >
243
- <UIcon
244
- name="i-lucide-chevron-right"
245
- class="h-3.5 w-3.5 shrink-0 text-slate-500 transition-transform group-hover:text-slate-300"
246
- :class="collapsed[s.id] ? '' : 'rotate-90'"
247
- />
248
- <span
249
- class="font-semibold text-slate-100"
250
- :class="s.depth <= 1 ? 'text-base' : s.depth === 2 ? 'text-sm' : 'text-[13px]'"
251
- v-html="s.titleHtml"
243
+ <button
244
+ v-if="s.depth > 0"
245
+ class="group flex w-full items-center gap-1.5 rounded py-0.5 text-start transition hover:text-white"
246
+ :aria-expanded="!collapsed[s.id]"
247
+ @click="toggle(s.id)"
248
+ >
249
+ <UIcon
250
+ name="i-lucide-chevron-right"
251
+ class="h-3.5 w-3.5 shrink-0 text-slate-500 transition-transform group-hover:text-slate-300"
252
+ :class="collapsed[s.id] ? '' : 'rotate-90'"
253
+ />
254
+ <span
255
+ class="font-semibold text-slate-100"
256
+ :class="s.depth <= 1 ? 'text-base' : s.depth === 2 ? 'text-sm' : 'text-[13px]'"
257
+ v-html="s.titleHtml"
258
+ />
259
+ </button>
260
+ <!-- `review-mode` carries the click-to-comment affordance, so it tracks the same RBAC
261
+ gate the composer does — a viewer gets the document, not hover targets that lead
262
+ nowhere. -->
263
+ <!-- eslint-disable-next-line vue/no-v-html -->
264
+ <div
265
+ v-show="!collapsed[s.id]"
266
+ class="reader-prose mt-0.5 text-[13px] leading-relaxed text-slate-300"
267
+ :class="[s.depth > 0 ? 'ps-5' : '', canExecute ? 'review-mode' : '']"
268
+ @click="onProseClick"
269
+ v-html="s.bodyHtml"
252
270
  />
253
- </button>
254
- <!-- `review-mode` carries the click-to-comment affordance, so it tracks the same RBAC
255
- gate the composer does — a viewer gets the document, not hover targets that lead
256
- nowhere. -->
257
- <!-- eslint-disable-next-line vue/no-v-html -->
258
- <div
259
- v-show="!collapsed[s.id]"
260
- class="reader-prose mt-0.5 text-[13px] leading-relaxed text-slate-300"
261
- :class="[s.depth > 0 ? 'ps-5' : '', canExecute ? 'review-mode' : '']"
262
- @click="onProseClick"
263
- v-html="s.bodyHtml"
264
- />
265
- </section>
271
+ </section>
272
+ </div>
266
273
  </div>
267
274
 
268
275
  <!-- Review rail: what the human is being asked, the anchored comments so far, and the two
@@ -245,7 +245,7 @@ async function savePolicy() {
245
245
  icon-class="bg-indigo-500/15 text-indigo-300"
246
246
  :title="initiative?.title ?? block?.title ?? t('initiative.tracker.title')"
247
247
  :subtitle="t('initiative.tracker.subtitle')"
248
- width="5xl"
248
+ width="full"
249
249
  testid="initiative-tracker-window"
250
250
  @close="close"
251
251
  >
@@ -353,12 +353,15 @@ async function savePolicy() {
353
353
  </div>
354
354
  </section>
355
355
 
356
- <!-- Goal & constraints -->
356
+ <!-- Goal & constraints. The planner's own prose, so it takes the reading measure the
357
+ shell's `full` width obliges (see the `width` prop): the phase/item rows below are
358
+ structure that reads better at the full span, but these are paragraphs and bullets
359
+ of agent-written text that would otherwise run the width of the display. -->
357
360
  <section v-if="initiative.goal" class="mb-4">
358
361
  <h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
359
362
  {{ t('initiative.tracker.goal') }}
360
363
  </h3>
361
- <p class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
364
+ <p class="max-w-3xl whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
362
365
  {{ initiative.goal }}
363
366
  </p>
364
367
  </section>
@@ -366,7 +369,7 @@ async function savePolicy() {
366
369
  <h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
367
370
  {{ t('initiative.tracker.constraints') }}
368
371
  </h3>
369
- <ul class="list-inside list-disc text-[13px] text-slate-300">
372
+ <ul class="max-w-3xl list-inside list-disc text-[13px] text-slate-300">
370
373
  <li v-for="(c, i) in initiative.constraints" :key="i">{{ c }}</li>
371
374
  </ul>
372
375
  </section>
@@ -374,7 +377,7 @@ async function savePolicy() {
374
377
  <h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
375
378
  {{ t('initiative.tracker.nonGoals') }}
376
379
  </h3>
377
- <ul class="list-inside list-disc text-[13px] text-slate-300">
380
+ <ul class="max-w-3xl list-inside list-disc text-[13px] text-slate-300">
378
381
  <li v-for="(g, i) in initiative.nonGoals" :key="i">{{ g }}</li>
379
382
  </ul>
380
383
  </section>
@@ -382,7 +385,7 @@ async function savePolicy() {
382
385
  <h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
383
386
  {{ t('initiative.tracker.analysis') }}
384
387
  </h3>
385
- <p class="whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
388
+ <p class="max-w-3xl whitespace-pre-wrap text-[13px] leading-relaxed text-slate-300">
386
389
  {{ initiative.analysisSummary }}
387
390
  </p>
388
391
  </section>
@@ -450,7 +453,7 @@ async function savePolicy() {
450
453
  })
451
454
  }}
452
455
  </div>
453
- <div v-if="item.note" class="mt-0.5 text-[10px] text-amber-300/80">
456
+ <div v-if="item.note" class="mt-0.5 max-w-3xl text-[10px] text-amber-300/80">
454
457
  {{ item.note }}
455
458
  </div>
456
459
  <div
@@ -592,7 +595,7 @@ async function savePolicy() {
592
595
  <h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
593
596
  {{ t('initiative.tracker.deviations') }}
594
597
  </h3>
595
- <ul class="list-inside list-disc text-[13px] text-slate-300">
598
+ <ul class="max-w-3xl list-inside list-disc text-[13px] text-slate-300">
596
599
  <li v-for="d in initiative.deviations" :key="d.id">
597
600
  <code v-if="d.itemId" class="text-slate-400">{{ d.itemId }}</code>
598
601
  {{ d.description }}
@@ -688,7 +691,7 @@ async function savePolicy() {
688
691
  <h3 class="mb-1 text-[11px] font-semibold uppercase tracking-wide text-slate-500">
689
692
  {{ t('initiative.tracker.caveats') }}
690
693
  </h3>
691
- <ul class="list-inside list-disc text-[13px] text-slate-300">
694
+ <ul class="max-w-3xl list-inside list-disc text-[13px] text-slate-300">
692
695
  <li v-for="(c, i) in initiative.caveats" :key="i">{{ c }}</li>
693
696
  </ul>
694
697
  </section>
@@ -9,7 +9,8 @@
9
9
  // Two orthogonal ways this panel shrinks. WHICH destinations exist is the interface
10
10
  // TIER (basic hides the `advanced` contributions, filtered upstream in `navSlotFilter`);
11
11
  // how much room they take is the COLLAPSE state (the icon-only rail). Basic mode starts
12
- // railed, but either can be changed independently from the footer switcher / the toggle.
12
+ // railed, but either can be changed independently from the tier switcher at the top /
13
+ // the rail toggle.
13
14
  import { useEventListener, useScrollLock } from '@vueuse/core'
14
15
  import BoardSwitcher from '~/components/layout/BoardSwitcher.vue'
15
16
  import LanguageSwitcher from '~/components/layout/LanguageSwitcher.vue'
@@ -179,6 +180,17 @@ watch(
179
180
 
180
181
  <BoardSwitcher :collapsed="railed" />
181
182
 
183
+ <!-- The interface tier sits ABOVE the destinations it gates, not in the footer: basic is the
184
+ shipped default, so this row is most users' only sight of the tier, and below the fold in
185
+ a scrolled navbar it is a thin thread to hang the advanced half of the product on. The
186
+ wrapper is what keeps the control and its hint together — the aside's own `gap-4` would
187
+ otherwise push them apart. Kept OUT of the `onNavAction` group deliberately: switching
188
+ tiers opens nothing, and closing the compact drawer would hide the destinations the
189
+ switch just revealed. -->
190
+ <div class="space-y-1">
191
+ <UiModeSwitcher :collapsed="railed" />
192
+ </div>
193
+
182
194
  <div class="contents" @click="onNavAction">
183
195
  <!-- Command bar launcher (⌘K) — the primary way to create blocks / pipelines
184
196
  and reach every action below. -->
@@ -236,7 +248,6 @@ watch(
236
248
  </div>
237
249
 
238
250
  <div class="mt-auto space-y-2">
239
- <UiModeSwitcher :collapsed="railed" />
240
251
  <LanguageSwitcher :collapsed="railed" />
241
252
  <UserMenu :collapsed="railed" />
242
253
  </div>
@@ -1,14 +1,23 @@
1
1
  <script setup lang="ts">
2
- import type { DropdownMenuItem } from '@nuxt/ui'
3
2
  import { computed } from 'vue'
4
3
  import { UI_MODES, type UiMode } from '~/utils/uiMode'
5
4
 
6
- // Interface-tier picker, shown at the sidebar bottom next to the language switcher (the
7
- // same shape, deliberately: both are per-user shell preferences rather than board state).
8
- // Basic mode hides the power-user destinations and the less-used run options; advanced
9
- // shows everything. The user's pick is persisted client-side unless the deployment pinned
10
- // the tier via NUXT_PUBLIC_UI_MODE, in which case the row is a read-only indicator, since
11
- // writing a preference the resolver ignores would be a lie (see `stores/uiMode.ts`).
5
+ // Interface-tier picker, shown at the TOP of the sidebar under the board switcher. Basic mode
6
+ // hides the power-user destinations and the less-used run options; advanced shows everything.
7
+ //
8
+ // It is a SEGMENTED control rather than a dropdown, and it sits above the fold rather than in the
9
+ // footer, because basic is the shipped default and this row is most users' only sight of the tier:
10
+ // a dropdown states the current mode but not that another one exists, so the half of the product
11
+ // it gates is discoverable only to someone who already opens menus to see what is in them. Showing
12
+ // both segments makes the choice — and the fact that there IS a choice — legible at rest.
13
+ //
14
+ // The user's pick is persisted client-side — unless the deployment pinned the tier via
15
+ // NUXT_PUBLIC_UI_MODE, in which case the row is a read-only indicator, since writing a preference
16
+ // the resolver ignores would be a lie (see `stores/uiMode.ts`).
17
+ //
18
+ // `collapsed` is the icon-only rail (basic mode's own default), where two segments do not fit at
19
+ // 3.5rem. It degrades to ONE button that flips the tier directly — with only two modes a toggle is
20
+ // unambiguous — keeping the current tier's NAME under the glyph rather than an icon alone.
12
21
  withDefaults(defineProps<{ collapsed?: boolean }>(), { collapsed: false })
13
22
 
14
23
  const uiMode = useUiModeStore()
@@ -27,28 +36,20 @@ const MODE_HINTS: Record<UiMode, string> = {
27
36
 
28
37
  const currentLabel = computed(() => t(MODE_LABELS[uiMode.mode]))
29
38
  const icon = computed(() => (uiMode.isAdvanced ? 'i-lucide-toggle-right' : 'i-lucide-toggle-left'))
30
- /** Tooltip for the collapsed rail (and the pinned row): mode + why it can't be changed. */
31
- const title = computed(() =>
32
- uiMode.envPinned
33
- ? `${t('uiMode.switcher')}: ${currentLabel.value} (${t('uiMode.pinned')})`
34
- : `${t('uiMode.switcher')}: ${currentLabel.value}`,
39
+ /** The tier the rail button would switch TO, so its tooltip names the act and not the state. */
40
+ const otherMode = computed<UiMode>(() => (uiMode.isAdvanced ? 'basic' : 'advanced'))
41
+ /** Tooltip for the pinned row (both variants): mode + why it can't be changed. */
42
+ const pinnedTitle = computed(
43
+ () => `${t('uiMode.switcher')}: ${currentLabel.value} (${t('uiMode.pinned')})`,
35
44
  )
36
-
37
- const items = computed<DropdownMenuItem[][]>(() => [
38
- UI_MODES.map((mode) => ({
39
- label: t(MODE_LABELS[mode]),
40
- icon: mode === uiMode.mode ? 'i-lucide-check' : undefined,
41
- onSelect: () => uiMode.setMode(mode),
42
- })),
43
- ])
44
45
  </script>
45
46
 
46
47
  <template>
47
- <!-- Pinned by the deployment: no dropdown, just the current tier. -->
48
+ <!-- Pinned by the deployment: no control, just the current tier. -->
48
49
  <div
49
50
  v-if="uiMode.envPinned"
50
51
  data-testid="ui-mode-pinned"
51
- :title="title"
52
+ :title="pinnedTitle"
52
53
  class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/40 p-2 text-start"
53
54
  :class="collapsed ? 'justify-center' : ''"
54
55
  >
@@ -62,25 +63,51 @@ const items = computed<DropdownMenuItem[][]>(() => [
62
63
  <UIcon v-if="!collapsed" name="i-lucide-lock" class="h-3.5 w-3.5 shrink-0 text-slate-600" />
63
64
  </div>
64
65
 
65
- <UDropdownMenu v-else :items="items" :content="{ side: 'top', align: 'start' }">
66
- <button
67
- type="button"
68
- data-testid="ui-mode-switcher"
66
+ <!-- Rail: one button, flips the tier. The label rides under the glyph so the rail still says
67
+ which tier is on — the audience that needs to find the advanced half is exactly the one
68
+ sitting in basic mode's collapsed default. -->
69
+ <button
70
+ v-else-if="collapsed"
71
+ type="button"
72
+ data-testid="ui-mode-toggle"
73
+ :aria-label="t('uiMode.switchTo', { mode: t(MODE_LABELS[otherMode]) })"
74
+ :title="t('uiMode.switchTo', { mode: t(MODE_LABELS[otherMode]) })"
75
+ class="flex w-full flex-col items-center gap-0.5 rounded-lg border border-slate-700 bg-slate-900/60 px-1 py-1.5 transition hover:border-indigo-500/60 hover:bg-slate-800/60"
76
+ @click="uiMode.toggleMode()"
77
+ >
78
+ <UIcon :name="icon" class="h-4 w-4 shrink-0 text-indigo-400" />
79
+ <span class="w-full truncate text-center text-[9px] font-medium uppercase text-slate-300">
80
+ {{ currentLabel }}
81
+ </span>
82
+ </button>
83
+
84
+ <div v-else data-testid="ui-mode-switcher" class="w-full">
85
+ <div class="mb-1 px-1 text-[10px] uppercase tracking-wide text-slate-500">
86
+ {{ t('uiMode.switcher') }}
87
+ </div>
88
+ <div
89
+ role="group"
69
90
  :aria-label="t('uiMode.switcher')"
70
- :title="title"
71
- class="flex w-full items-center gap-2 rounded-lg border border-slate-800 bg-slate-900/60 p-2 text-start transition hover:bg-slate-800/60"
72
- :class="collapsed ? 'justify-center' : ''"
91
+ class="flex w-full gap-1 rounded-lg border border-slate-700 bg-slate-900/60 p-1"
73
92
  >
74
- <UIcon :name="icon" class="h-4 w-4 shrink-0 text-slate-400" />
75
- <div v-if="!collapsed" class="min-w-0 flex-1">
76
- <div class="truncate text-[10px] uppercase tracking-wide text-slate-500">
77
- {{ t('uiMode.switcher') }}
78
- </div>
79
- <div class="truncate text-xs font-medium text-white">{{ currentLabel }}</div>
80
- </div>
81
- <UIcon v-if="!collapsed" name="i-lucide-chevron-up" class="h-4 w-4 shrink-0 text-slate-500" />
82
- </button>
83
- </UDropdownMenu>
93
+ <button
94
+ v-for="mode in UI_MODES"
95
+ :key="mode"
96
+ type="button"
97
+ :data-testid="`ui-mode-option-${mode}`"
98
+ :aria-pressed="mode === uiMode.mode"
99
+ class="flex-1 truncate rounded-md px-2 py-1 text-xs font-medium transition"
100
+ :class="
101
+ mode === uiMode.mode
102
+ ? 'bg-indigo-500/20 text-indigo-200 ring-1 ring-indigo-500/50'
103
+ : 'text-slate-400 hover:bg-slate-800/70 hover:text-slate-200'
104
+ "
105
+ @click="uiMode.setMode(mode)"
106
+ >
107
+ {{ t(MODE_LABELS[mode]) }}
108
+ </button>
109
+ </div>
110
+ </div>
84
111
 
85
112
  <!-- The one-line "what this tier gives you", so the choice is self-explanatory. Dropped in
86
113
  the collapsed rail, where the tooltip above carries the mode instead. -->
@@ -0,0 +1,174 @@
1
+ import { existsSync, readdirSync, readFileSync } from 'node:fs'
2
+ import { resolve } from 'node:path'
3
+ import { describe, expect, it } from 'vitest'
4
+ import {
5
+ PROSE_MEASURE_CLASS,
6
+ RESULT_WINDOW_WIDTH_CLASS,
7
+ type ResultWindowWidth,
8
+ } from './ResultWindowShell.logic'
9
+
10
+ // The width decision is a per-window LAYOUT judgement that lives on each window's
11
+ // `<ResultWindowShell width="…">`, which means nothing in the type system can ask a window author
12
+ // to justify it — and the `full` bucket carries an obligation (continuous prose takes its own
13
+ // reading measure) the shell explicitly cannot enforce on its slot content. That combination is
14
+ // what this file pins, the way `nav-contributions.spec.ts` pins the advanced-nav set: a table
15
+ // naming every window's bucket AND the reason, asserted against what the components actually pass.
16
+ //
17
+ // It caught its own motivating bug — the tracker, spec and tester windows moved to `full` with no
18
+ // measure anywhere in them, so their agent-written summaries ran the width of the display.
19
+
20
+ /** The default the shell applies when a window passes no `width` (`withDefaults`). */
21
+ const SHELL_DEFAULT_WIDTH: ResultWindowWidth = '3xl'
22
+
23
+ /**
24
+ * Every `ResultWindowShell` consumer, its bucket, and why that bucket. The reason is the point:
25
+ * `full` is for a body that lays out in COLUMNS, where the width buys visible layout, and a
26
+ * one-column verdict or reader keeps a bucket. A row that cannot state which of those it is has
27
+ * not made the decision.
28
+ */
29
+ const WINDOWS: Record<string, { width: ResultWindowWidth; why: string }> = {
30
+ 'brainstorm/BrainstormWindow.vue': {
31
+ width: 'full',
32
+ why: 'options column + the choose/dismiss action rail',
33
+ },
34
+ 'clarity/ClarityReviewWindow.vue': {
35
+ width: 'full',
36
+ why: 'findings column + the answer/dismiss action rail',
37
+ },
38
+ 'consensus/ConsensusSessionWindow.vue': {
39
+ width: '5xl',
40
+ why: 'one stacked column of participant prose — no rail competing with it, so width would only lengthen lines',
41
+ },
42
+ 'docs/DocInterviewWindow.vue': { width: '3xl', why: 'one column of interview question + answer' },
43
+ 'followUp/FollowUpWindow.vue': { width: '3xl', why: 'a short list of follow-up items' },
44
+ 'forkDecision/ForkDecisionWindow.vue': {
45
+ width: '3xl',
46
+ why: 'two proposals and a choice between them',
47
+ },
48
+ 'gates/GateResultView.vue': { width: '3xl', why: 'a gate verdict — one column, short' },
49
+ 'humanTest/HumanTestWindow.vue': {
50
+ width: '3xl',
51
+ why: 'one instruction plus a pass/fail control',
52
+ },
53
+ 'initiative/InitiativePlanningWindow.vue': {
54
+ width: '4xl',
55
+ why: 'a planning-progress readout, one column',
56
+ },
57
+ 'initiative/InitiativeTrackerWindow.vue': {
58
+ width: 'full',
59
+ why: 'tracker column + run-metadata rail, and it hands its whole body to the three-column plan review while a plan is parked',
60
+ },
61
+ 'judge/JudgeResultView.vue': { width: '3xl', why: 'a rubric verdict — one column, short' },
62
+ 'panels/GenericStructuredResultView.vue': {
63
+ width: '4xl',
64
+ why: 'the fallback structured-result reader, one column of sections',
65
+ },
66
+ 'panels/MergerResultView.vue': {
67
+ width: '3xl',
68
+ why: 'the merge verdict and its scores — one column, short (the slice-5 pilot)',
69
+ },
70
+ 'prReview/PrReviewWindow.vue': {
71
+ width: 'full',
72
+ why: 'per-file findings with paths, line numbers and suggested fixes, + a rail',
73
+ },
74
+ 'ralph/RalphLoopResultView.vue': { width: '3xl', why: 'a loop status readout, one column' },
75
+ 'requirements/RequirementsReviewWindow.vue': {
76
+ width: 'full',
77
+ why: 'findings column + the answer/dismiss action rail',
78
+ },
79
+ 'spec/ServiceSpecWindow.vue': {
80
+ width: 'full',
81
+ why: 'module/feature-group nav column + the spec detail column',
82
+ },
83
+ 'testing/TestReportWindow.vue': {
84
+ width: 'full',
85
+ why: 'scenario/outcome/concern tree + run-metadata rail',
86
+ },
87
+ 'visualConfirm/VisualConfirmationWindow.vue': {
88
+ width: '5xl',
89
+ why: 'a screenshot grid whose full-bleed reading is the nested ArtifactLightbox, not this window',
90
+ },
91
+ }
92
+
93
+ // Anchored on the vitest root (`frontend/app`) rather than `import.meta.url`: the `happy-dom`
94
+ // environment replaces the global `URL`, and `fileURLToPath` then rejects its own file: URLs.
95
+ const componentsDir = resolve(process.cwd(), 'app/components')
96
+
97
+ /** Every component that mounts the shell, keyed by its path relative to `app/components`. */
98
+ function findConsumers(): Map<string, string> {
99
+ const found = new Map<string, string>()
100
+ for (const entry of readdirSync(componentsDir, { recursive: true, encoding: 'utf8' })) {
101
+ const rel = entry.replace(/\\/g, '/')
102
+ // The shell itself names its own tag in its header comment — it is not a consumer.
103
+ if (!rel.endsWith('.vue') || rel.endsWith('panels/ResultWindowShell.vue')) continue
104
+ const source = readFileSync(`${componentsDir}/${rel}`, 'utf8')
105
+ if (source.includes('<ResultWindowShell')) found.set(rel, source)
106
+ }
107
+ return found
108
+ }
109
+
110
+ /** The bucket a component passes, or the shell's default when it passes none. */
111
+ function declaredWidth(source: string): ResultWindowWidth {
112
+ const match = /<ResultWindowShell[\s\S]*?\swidth="([^"]+)"/.exec(source)
113
+ return (match?.[1] as ResultWindowWidth | undefined) ?? SHELL_DEFAULT_WIDTH
114
+ }
115
+
116
+ describe('result-window width buckets', () => {
117
+ const consumers = findConsumers()
118
+
119
+ it('finds the shell consumers at all (the scan is doing real work)', () => {
120
+ expect(existsSync(componentsDir), `no components dir at ${componentsDir}`).toBe(true)
121
+ expect(consumers.size).toBeGreaterThan(10)
122
+ })
123
+
124
+ it('covers every consumer, with no stale rows', () => {
125
+ expect([...consumers.keys()].sort()).toEqual(Object.keys(WINDOWS).sort())
126
+ })
127
+
128
+ it('matches what each window actually passes', () => {
129
+ const actual = [...consumers.entries()].map(([file, source]) => [file, declaredWidth(source)])
130
+ const declared = Object.entries(WINDOWS).map(([file, row]) => [file, row.width])
131
+ expect(Object.fromEntries(actual)).toEqual(Object.fromEntries(declared))
132
+ })
133
+
134
+ it('makes every window state a reason for its bucket', () => {
135
+ for (const [file, row] of Object.entries(WINDOWS)) {
136
+ expect(row.why.length, `${file} must say why it takes \`${row.width}\``).toBeGreaterThan(20)
137
+ }
138
+ })
139
+
140
+ // The obligation the shell cannot enforce on its own slot content: a `full` window's continuous
141
+ // prose carries the step reader's measure, or the extra width lands as 200-character lines. A
142
+ // window with none anywhere is the shape this catches — it either has no prose (say so by
143
+ // pointing at the measure it does not need) or it forgot.
144
+ it('holds every `full` window to the prose reading measure', () => {
145
+ for (const [file, row] of Object.entries(WINDOWS)) {
146
+ if (row.width !== 'full') continue
147
+ const source = consumers.get(file)
148
+ expect(
149
+ source?.includes(PROSE_MEASURE_CLASS),
150
+ `${file} is \`full\`, so its continuous prose must carry \`${PROSE_MEASURE_CLASS}\``,
151
+ ).toBe(true)
152
+ }
153
+ })
154
+ })
155
+
156
+ describe('RESULT_WINDOW_WIDTH_CLASS', () => {
157
+ it('caps every bucket at its own name and leaves `full` uncapped', () => {
158
+ expect(RESULT_WINDOW_WIDTH_CLASS).toEqual({
159
+ '3xl': 'max-w-3xl',
160
+ '4xl': 'max-w-4xl',
161
+ '5xl': 'max-w-5xl',
162
+ // Not a bigger number: the panel's `w-full` spans the backdrop and the variant's gutter
163
+ // (`m-4` / `p-4`) is the only inset. A cap here would reintroduce the indefensible number
164
+ // `full` exists to avoid.
165
+ full: 'max-w-none',
166
+ })
167
+ })
168
+
169
+ it('shares one measure with the step reader', () => {
170
+ // `AgentStepDetail` reads prose at `mx-auto max-w-3xl`; the windows must not hold a second
171
+ // opinion about how wide prose should be.
172
+ expect(PROSE_MEASURE_CLASS).toBe('max-w-3xl')
173
+ })
174
+ })