@cat-factory/app 0.196.0 → 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.
- package/README.md +10 -3
- package/app/components/brainstorm/BrainstormWindow.vue +11 -4
- package/app/components/clarity/ClarityReviewWindow.vue +11 -4
- package/app/components/initiative/InitiativePlanReview.vue +44 -37
- package/app/components/initiative/InitiativeTrackerWindow.vue +12 -9
- package/app/components/layout/SideBar.vue +13 -2
- package/app/components/layout/UiModeSwitcher.vue +66 -39
- package/app/components/panels/ResultWindowShell.logic.spec.ts +174 -0
- package/app/components/panels/ResultWindowShell.logic.ts +31 -0
- package/app/components/panels/ResultWindowShell.vue +37 -8
- package/app/components/prReview/PrReviewWindow.vue +15 -7
- package/app/components/requirements/RequirementsReviewWindow.vue +15 -5
- package/app/components/spec/ServiceSpecWindow.vue +7 -4
- package/app/components/testing/TestReportWindow.vue +11 -6
- package/app/modular/nav-contributions.ts +3 -3
- package/i18n/locales/de.json +1 -0
- package/i18n/locales/en.json +4 -0
- package/i18n/locales/es.json +1 -0
- package/i18n/locales/fr.json +1 -0
- package/i18n/locales/he.json +1 -0
- package/i18n/locales/it.json +1 -0
- package/i18n/locales/ja.json +1 -0
- package/i18n/locales/pl.json +1 -0
- package/i18n/locales/tr.json +1 -0
- package/i18n/locales/uk.json +1 -0
- package/package.json +1 -1
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
|
|
81
|
-
deliberately _not_ an advanced item: basic is the default, so 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="
|
|
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
|
-
|
|
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
|
-
|
|
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="
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<!--
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
:
|
|
241
|
-
|
|
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
|
-
<
|
|
244
|
-
|
|
245
|
-
class="
|
|
246
|
-
:
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
</
|
|
254
|
-
|
|
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="
|
|
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
|
|
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
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
-
/**
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
|
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="
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
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
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// The result-window width vocabulary, extracted from `ResultWindowShell.vue` so it can be
|
|
2
|
+
// asserted (see `ResultWindowShell.logic.spec.ts`, which pins every window's bucket against a
|
|
3
|
+
// table naming its reason — the shape `nav-contributions.spec.ts` uses for the advanced-nav set).
|
|
4
|
+
//
|
|
5
|
+
// WHICH bucket a window takes, and the reading-measure obligation `full` carries, are documented
|
|
6
|
+
// on the shell's `width` prop — that is what a window author reads. This module owns only the
|
|
7
|
+
// vocabulary and its class mapping.
|
|
8
|
+
|
|
9
|
+
/** Card width buckets — see `ResultWindowShell.vue`'s `width` prop for what picks `full`. */
|
|
10
|
+
export type ResultWindowWidth = '3xl' | '4xl' | '5xl' | 'full'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The bucket → cap mapping. `full` is deliberately `max-w-none` rather than a bigger number:
|
|
14
|
+
* the panel's `w-full` then spans the backdrop, which the variant insets by one gutter (`m-4`
|
|
15
|
+
* stretched, `p-4` centered), so the window fills the screen and still reads as a window rather
|
|
16
|
+
* than a repaint of the app. A `Record` over the union, so a new bucket fails to compile until
|
|
17
|
+
* it is mapped.
|
|
18
|
+
*/
|
|
19
|
+
export const RESULT_WINDOW_WIDTH_CLASS: Record<ResultWindowWidth, string> = {
|
|
20
|
+
'3xl': 'max-w-3xl',
|
|
21
|
+
'4xl': 'max-w-4xl',
|
|
22
|
+
'5xl': 'max-w-5xl',
|
|
23
|
+
full: 'max-w-none',
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The reading measure a `full` window puts on a run of continuous prose — the step reader's own
|
|
28
|
+
* (`AgentStepDetail`, `mx-auto max-w-3xl` over the same 13px `.reader-prose`), so the surfaces
|
|
29
|
+
* cannot drift into two opinions about how wide prose should be.
|
|
30
|
+
*/
|
|
31
|
+
export const PROSE_MEASURE_CLASS = 'max-w-3xl'
|
|
@@ -26,6 +26,10 @@ import StepRestartControl from '~/components/panels/StepRestartControl.vue'
|
|
|
26
26
|
import StepEffortReport from '~/components/panels/StepEffortReport.vue'
|
|
27
27
|
import StepValidationReport from '~/components/panels/StepValidationReport.vue'
|
|
28
28
|
import { effortBand, effortHint } from '~/utils/effort'
|
|
29
|
+
import {
|
|
30
|
+
RESULT_WINDOW_WIDTH_CLASS,
|
|
31
|
+
type ResultWindowWidth,
|
|
32
|
+
} from '~/components/panels/ResultWindowShell.logic'
|
|
29
33
|
|
|
30
34
|
/** A pipeline step reference — passed by step-result windows to surface the shared
|
|
31
35
|
* "restart from here" control. `StepRestartControl` self-hides for an off-path open
|
|
@@ -42,8 +46,38 @@ const props = withDefaults(
|
|
|
42
46
|
/** Header title (the accessible dialog name) + optional secondary line. */
|
|
43
47
|
title: string
|
|
44
48
|
subtitle?: string
|
|
45
|
-
/**
|
|
46
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Card width bucket + backdrop layout (the two pre-slice-5 chrome variants).
|
|
51
|
+
*
|
|
52
|
+
* `full` is the REVIEW/READING bucket: the panel takes the whole viewport minus the
|
|
53
|
+
* shell's own gutter, the shape the full-bleed step reader (`AgentStepDetail`) already
|
|
54
|
+
* has. It is for a window whose body lays out in COLUMNS — rails plus a fluid main
|
|
55
|
+
* column — where the width buys visible layout: the outline and the review rail stop
|
|
56
|
+
* competing with the document, a findings list stops wrapping every card, a diff or a
|
|
57
|
+
* results table stops scrolling sideways. A window that is one column of prose or a
|
|
58
|
+
* short verdict keeps a bucket: stretching two paragraphs across an ultrawide reads
|
|
59
|
+
* worse, not better.
|
|
60
|
+
*
|
|
61
|
+
* The obligation that comes with it: CONTINUOUS PROSE inside a `full` window carries its
|
|
62
|
+
* own reading measure (`PROSE_MEASURE_CLASS`, the step reader's own, over the same 13px
|
|
63
|
+
* `.reader-prose`), or the width lands as 200-character lines.
|
|
64
|
+
*
|
|
65
|
+
* The unit that obligation attaches to is the PARAGRAPH, not the section — which is the
|
|
66
|
+
* distinction to get right, because "a findings list reads better at the full span" is
|
|
67
|
+
* true of the LIST and false of the prose inside each row. A list's rows, badge rows,
|
|
68
|
+
* control rows, tables, Gherkin blocks, log tails and inputs all take the span; a
|
|
69
|
+
* finding's detail, a recorded answer, an investigator's justification and a summary
|
|
70
|
+
* paragraph are prose wherever they sit, and take the measure. Sizing by section is how a
|
|
71
|
+
* card whose answer control is STACKED under its question — every finding card here —
|
|
72
|
+
* ends up arguing that its question is "beside" something and keeping 200-character lines.
|
|
73
|
+
*
|
|
74
|
+
* What `full` costs: click-outside effectively goes, since the backdrop is then only the
|
|
75
|
+
* shell's own gutter. That is the same trade the full-bleed reader already makes (it has
|
|
76
|
+
* no backdrop close at all), and Escape plus the header's close button — the two paths a
|
|
77
|
+
* keyboard and a pointer user actually reach for — are untouched. A window that wants
|
|
78
|
+
* click-outside to stay hittable is a window that should have kept a bucket.
|
|
79
|
+
*/
|
|
80
|
+
width?: ResultWindowWidth
|
|
47
81
|
variant?: 'stretch' | 'centered'
|
|
48
82
|
/** Provide on step-result windows to show the shared restart control; omit on gates
|
|
49
83
|
* and block-keyed windows (no restart mid-gate / pre-run). */
|
|
@@ -119,18 +153,13 @@ const chipClass = computed(() =>
|
|
|
119
153
|
effortReport.value ? CHIP_CLASS[effortBand(effortReport.value.difficulty)] : '',
|
|
120
154
|
)
|
|
121
155
|
|
|
122
|
-
const WIDTH: Record<'3xl' | '4xl' | '5xl', string> = {
|
|
123
|
-
'3xl': 'max-w-3xl',
|
|
124
|
-
'4xl': 'max-w-4xl',
|
|
125
|
-
'5xl': 'max-w-5xl',
|
|
126
|
-
}
|
|
127
156
|
const backdropClass = computed(() => [
|
|
128
157
|
'fixed inset-0 z-50 flex max-h-[100dvh] justify-center bg-slate-950/70 backdrop-blur-sm',
|
|
129
158
|
props.variant === 'centered' ? 'items-center p-4' : 'items-stretch',
|
|
130
159
|
])
|
|
131
160
|
const panelClass = computed(() => [
|
|
132
161
|
'flex w-full flex-col overflow-hidden rounded-2xl border border-slate-800 bg-slate-900 shadow-2xl',
|
|
133
|
-
|
|
162
|
+
RESULT_WINDOW_WIDTH_CLASS[props.width],
|
|
134
163
|
props.variant === 'centered' ? 'max-h-[90dvh]' : 'm-4',
|
|
135
164
|
])
|
|
136
165
|
</script>
|
|
@@ -266,7 +266,7 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
266
266
|
icon-class="bg-indigo-500/15 text-indigo-300"
|
|
267
267
|
:title="block ? t('prReview.titleWithBlock', { title: block.title }) : t('prReview.title')"
|
|
268
268
|
:subtitle="t('prReview.subtitle')"
|
|
269
|
-
width="
|
|
269
|
+
width="full"
|
|
270
270
|
testid="pr-review-window"
|
|
271
271
|
@close="close"
|
|
272
272
|
>
|
|
@@ -525,10 +525,11 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
525
525
|
</p>
|
|
526
526
|
</div>
|
|
527
527
|
|
|
528
|
-
<!-- The reviewer's overall assessment.
|
|
528
|
+
<!-- The reviewer's overall assessment. Prose, so it takes the reading measure (see the
|
|
529
|
+
shell's `width` prop) — this window is `full`-width. -->
|
|
529
530
|
<p
|
|
530
531
|
v-if="state?.summary"
|
|
531
|
-
class="mb-3 rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
|
|
532
|
+
class="mb-3 max-w-3xl rounded-md bg-slate-800/50 px-3 py-2 text-[12px] text-slate-300"
|
|
532
533
|
>
|
|
533
534
|
<span class="text-slate-500">{{ t('prReview.summaryLabel') }}</span>
|
|
534
535
|
{{ state.summary }}
|
|
@@ -579,7 +580,7 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
579
580
|
<h3 class="mb-1.5 text-[11px] font-semibold uppercase tracking-wide text-slate-400">
|
|
580
581
|
{{ g.title }}
|
|
581
582
|
</h3>
|
|
582
|
-
<p v-if="g.rationale" class="mb-1.5 text-[11px] text-slate-500">
|
|
583
|
+
<p v-if="g.rationale" class="mb-1.5 max-w-3xl text-[11px] text-slate-500">
|
|
583
584
|
{{ g.rationale }}
|
|
584
585
|
</p>
|
|
585
586
|
<article
|
|
@@ -672,15 +673,22 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
672
673
|
· {{ t('prReview.line', { line: f.line }) }}</template
|
|
673
674
|
>
|
|
674
675
|
</p>
|
|
676
|
+
<!-- The reviewer's prose — what the finding is, what to do about it, and the
|
|
677
|
+
investigator's verdict below. Each takes the reading measure even though
|
|
678
|
+
the card around it takes the span (see the shell's `width` prop: the unit
|
|
679
|
+
is the paragraph, not the section). This window went from the NARROWEST
|
|
680
|
+
bucket to `full`, so these are the three paragraphs the width would
|
|
681
|
+
otherwise have stretched furthest; the path/line row, the badges and the
|
|
682
|
+
per-finding actions are what it is actually for. -->
|
|
675
683
|
<p
|
|
676
|
-
class="mt-1 whitespace-pre-wrap text-[12px] text-slate-300"
|
|
684
|
+
class="mt-1 max-w-3xl whitespace-pre-wrap text-[12px] text-slate-300"
|
|
677
685
|
:class="isRetracted(f) ? 'line-through' : ''"
|
|
678
686
|
>
|
|
679
687
|
{{ f.detail }}
|
|
680
688
|
</p>
|
|
681
689
|
<p
|
|
682
690
|
v-if="f.suggestedFix"
|
|
683
|
-
class="mt-1 whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
|
|
691
|
+
class="mt-1 max-w-3xl whitespace-pre-wrap rounded-md bg-slate-800/50 px-2 py-1 text-[11px] text-slate-300"
|
|
684
692
|
>
|
|
685
693
|
<span class="text-slate-500">{{ t('prReview.suggestedFix') }}</span>
|
|
686
694
|
{{ f.suggestedFix }}
|
|
@@ -691,7 +699,7 @@ async function onDismiss(id: string): Promise<void> {
|
|
|
691
699
|
<p
|
|
692
700
|
v-if="f.challenge?.justification"
|
|
693
701
|
data-testid="pr-review-finding-justification"
|
|
694
|
-
class="mt-1.5 whitespace-pre-wrap rounded-md px-2 py-1 text-[11px]"
|
|
702
|
+
class="mt-1.5 max-w-3xl whitespace-pre-wrap rounded-md px-2 py-1 text-[11px]"
|
|
695
703
|
:class="
|
|
696
704
|
isRetracted(f)
|
|
697
705
|
? 'bg-rose-500/10 text-rose-200'
|
|
@@ -653,7 +653,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
653
653
|
:subtitle="block?.title"
|
|
654
654
|
:step-ref="{ instanceId, stepIndex }"
|
|
655
655
|
variant="centered"
|
|
656
|
-
width="
|
|
656
|
+
width="full"
|
|
657
657
|
@close="close"
|
|
658
658
|
>
|
|
659
659
|
<template v-if="review" #header-extras>
|
|
@@ -796,7 +796,11 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
796
796
|
{{ STATUS_LABELS[item.status] }}
|
|
797
797
|
</UBadge>
|
|
798
798
|
</div>
|
|
799
|
-
|
|
799
|
+
<!-- The reviewer's question is prose, so it takes the measure even though the
|
|
800
|
+
card around it takes the span (see the shell's `width` prop: the unit is
|
|
801
|
+
the paragraph, not the section). The badge row above and the mode buttons
|
|
802
|
+
and textarea below are what the full width is actually for. -->
|
|
803
|
+
<p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-400">
|
|
800
804
|
{{ item.detail }}
|
|
801
805
|
</p>
|
|
802
806
|
|
|
@@ -804,7 +808,7 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
804
808
|
ones the answer lives in the textarea below, seeded from the reply) -->
|
|
805
809
|
<div
|
|
806
810
|
v-if="item.reply && item.status !== 'open' && item.status !== 'answered'"
|
|
807
|
-
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"
|
|
811
|
+
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"
|
|
808
812
|
>
|
|
809
813
|
<span class="text-[10px] uppercase tracking-wide text-slate-500">
|
|
810
814
|
{{ t('requirements.answerLabel') }}
|
|
@@ -925,7 +929,9 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
925
929
|
>
|
|
926
930
|
{{ GROUNDING_LABELS[rec.groundedIn] }}
|
|
927
931
|
</UBadge>
|
|
928
|
-
|
|
932
|
+
<!-- The Writer's suggested answer — agent prose, so it takes the
|
|
933
|
+
measure like the finding's own question above it. -->
|
|
934
|
+
<p class="mt-1 max-w-3xl whitespace-pre-line text-sm text-slate-300">
|
|
929
935
|
{{ rec.recommendedText }}
|
|
930
936
|
</p>
|
|
931
937
|
<div class="mt-2 flex flex-wrap items-center gap-2">
|
|
@@ -1037,7 +1043,11 @@ async function resolveExceeded(choice: 'extra-round' | 'proceed' | 'stop-reset')
|
|
|
1037
1043
|
}}
|
|
1038
1044
|
</span>
|
|
1039
1045
|
</button>
|
|
1040
|
-
|
|
1046
|
+
<!-- The same reading measure the findings' own prose takes above (see the shell's
|
|
1047
|
+
`width` prop): the window is `full`-width now, and this is continuous prose that
|
|
1048
|
+
would otherwise run to 200-character lines. Left-aligned rather than centred, so
|
|
1049
|
+
it starts where every finding above it starts. -->
|
|
1050
|
+
<div v-show="!docCollapsed" class="max-w-3xl">
|
|
1041
1051
|
<div v-for="s in outline.sections" :key="s.id" class="mb-2">
|
|
1042
1052
|
<button
|
|
1043
1053
|
v-if="s.title"
|
|
@@ -185,7 +185,7 @@ function kindLabel(item: RequirementItem): string {
|
|
|
185
185
|
:title="t('spec.title')"
|
|
186
186
|
:subtitle="block ? spec?.service || block.title : undefined"
|
|
187
187
|
variant="centered"
|
|
188
|
-
width="
|
|
188
|
+
width="full"
|
|
189
189
|
@close="close"
|
|
190
190
|
>
|
|
191
191
|
<!-- view toggle: Gherkin only when the spec (and its feature files) are on main -->
|
|
@@ -318,7 +318,10 @@ function kindLabel(item: RequirementItem): string {
|
|
|
318
318
|
<!-- service overview -->
|
|
319
319
|
<template v-if="selected === null">
|
|
320
320
|
<h2 class="text-lg font-semibold text-white">{{ spec?.service }}</h2>
|
|
321
|
-
|
|
321
|
+
<!-- The service's own prose, so it takes the reading measure the shell's `full` width
|
|
322
|
+
obliges (see the `width` prop). The requirement rows and Gherkin blocks below keep
|
|
323
|
+
the full span — they are structure, not paragraphs. -->
|
|
324
|
+
<p v-if="spec?.summary" class="mt-2 max-w-3xl whitespace-pre-line text-sm text-slate-300">
|
|
322
325
|
{{ spec.summary }}
|
|
323
326
|
</p>
|
|
324
327
|
<p v-else class="mt-2 text-sm text-slate-500">{{ t('spec.noSummary') }}</p>
|
|
@@ -352,7 +355,7 @@ function kindLabel(item: RequirementItem): string {
|
|
|
352
355
|
{{ selectedModule?.name }}
|
|
353
356
|
</div>
|
|
354
357
|
<h2 class="text-lg font-semibold text-white">{{ selectedGroup.name }}</h2>
|
|
355
|
-
<p v-if="selectedGroup.summary" class="mt-1 text-sm text-slate-400">
|
|
358
|
+
<p v-if="selectedGroup.summary" class="mt-1 max-w-3xl text-sm text-slate-400">
|
|
356
359
|
{{ selectedGroup.summary }}
|
|
357
360
|
</p>
|
|
358
361
|
|
|
@@ -500,7 +503,7 @@ function kindLabel(item: RequirementItem): string {
|
|
|
500
503
|
<UIcon name="i-lucide-shield-check" class="h-3.5 w-3.5" />
|
|
501
504
|
{{ t('spec.domainRules') }}
|
|
502
505
|
</div>
|
|
503
|
-
<ul class="space-y-1.5">
|
|
506
|
+
<ul class="max-w-3xl space-y-1.5">
|
|
504
507
|
<li
|
|
505
508
|
v-for="rule in selectedGroup.rules ?? []"
|
|
506
509
|
:key="rule.id"
|
|
@@ -313,7 +313,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
313
313
|
:title="headerTitle"
|
|
314
314
|
:subtitle="t('testing.subtitle')"
|
|
315
315
|
:step-ref="{ instanceId, stepIndex }"
|
|
316
|
-
width="
|
|
316
|
+
width="full"
|
|
317
317
|
testid="tester-report-window"
|
|
318
318
|
@close="close"
|
|
319
319
|
>
|
|
@@ -489,7 +489,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
489
489
|
:icon="a.outcome === 'completed' ? 'i-lucide-wrench' : 'i-lucide-circle-x'"
|
|
490
490
|
:icon-class="a.outcome === 'completed' ? 'text-amber-300' : 'text-rose-400'"
|
|
491
491
|
/>
|
|
492
|
-
<p v-if="a.summary" class="mt-1 text-[12px] leading-snug text-slate-400">
|
|
492
|
+
<p v-if="a.summary" class="mt-1 max-w-3xl text-[12px] leading-snug text-slate-400">
|
|
493
493
|
{{ a.summary }}
|
|
494
494
|
</p>
|
|
495
495
|
<div v-if="a.concerns && a.concerns.length" class="mt-1.5">
|
|
@@ -607,8 +607,13 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
607
607
|
</div>
|
|
608
608
|
|
|
609
609
|
<template v-else>
|
|
610
|
-
<!-- Summary
|
|
611
|
-
|
|
610
|
+
<!-- Summary — the tester's own prose, so it takes the reading measure the shell's `full`
|
|
611
|
+
width obliges (see the `width` prop). The scenario rows and log tails below keep the
|
|
612
|
+
full span. -->
|
|
613
|
+
<p
|
|
614
|
+
v-if="report.summary"
|
|
615
|
+
class="mb-4 max-w-3xl text-[13px] leading-relaxed text-slate-300"
|
|
616
|
+
>
|
|
612
617
|
{{ report.summary }}
|
|
613
618
|
</p>
|
|
614
619
|
|
|
@@ -675,7 +680,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
675
680
|
/>
|
|
676
681
|
<div class="min-w-0">
|
|
677
682
|
<span class="text-[13px] text-slate-200">{{ o.name }}</span>
|
|
678
|
-
<p v-if="o.detail" class="text-[12px] leading-snug text-slate-400">
|
|
683
|
+
<p v-if="o.detail" class="max-w-3xl text-[12px] leading-snug text-slate-400">
|
|
679
684
|
{{ o.detail }}
|
|
680
685
|
</p>
|
|
681
686
|
</div>
|
|
@@ -705,7 +710,7 @@ const GROUP_STATUS_META: Record<ScenarioGroup['status'], { icon: string; text: s
|
|
|
705
710
|
{{ SEVERITY_LABELS[c.severity] }}
|
|
706
711
|
</span>
|
|
707
712
|
</div>
|
|
708
|
-
<p v-if="c.detail" class="text-[12px] leading-snug text-slate-400">
|
|
713
|
+
<p v-if="c.detail" class="max-w-3xl text-[12px] leading-snug text-slate-400">
|
|
709
714
|
{{ c.detail }}
|
|
710
715
|
</p>
|
|
711
716
|
</div>
|
|
@@ -460,9 +460,9 @@ export const NAV_CONTRIBUTIONS: readonly NavContribution[] = [
|
|
|
460
460
|
{
|
|
461
461
|
// Deliberately NOT `advanced`: this is the way BACK. Basic mode is the shipped default, so
|
|
462
462
|
// for most users the sidebar switcher is their first sight of the tier — and in the basic
|
|
463
|
-
// rail it
|
|
464
|
-
// product on. The palette is reachable in both tiers, searchable by name, and
|
|
465
|
-
// "the primary way to reach every action", so the tier belongs in it.
|
|
463
|
+
// rail it collapses to a single toggle, which is a thin thread to hang the entire advanced
|
|
464
|
+
// half of the product on. The palette is reachable in both tiers, searchable by name, and
|
|
465
|
+
// is already "the primary way to reach every action", so the tier belongs in it.
|
|
466
466
|
id: 'ui-mode',
|
|
467
467
|
labelKey: 'layout.commandBar.cmd.toggleUiMode',
|
|
468
468
|
icon: 'i-lucide-toggle-right',
|
package/i18n/locales/de.json
CHANGED
|
@@ -4542,6 +4542,7 @@
|
|
|
4542
4542
|
"advanced": "Erweitert",
|
|
4543
4543
|
"basicHint": "Nur die alltäglichen Werkzeuge. Für alles auf „Erweitert“ umschalten.",
|
|
4544
4544
|
"advancedHint": "Alle Bereiche und alle Ausführungsoptionen.",
|
|
4545
|
+
"switchTo": "Zu „{mode}“ wechseln",
|
|
4545
4546
|
"pinned": "Von dieser Installation festgelegt"
|
|
4546
4547
|
},
|
|
4547
4548
|
"common": {
|
package/i18n/locales/en.json
CHANGED
|
@@ -32,6 +32,10 @@
|
|
|
32
32
|
"advanced": "Advanced",
|
|
33
33
|
"basicHint": "Everyday tools only. Switch to Advanced to see everything.",
|
|
34
34
|
"advancedHint": "Every destination and every run option.",
|
|
35
|
+
"switchTo": "Switch to {mode}",
|
|
36
|
+
"@switchTo": {
|
|
37
|
+
"description": "Tooltip on the collapsed-sidebar tier button. {mode} is the OTHER interface tier name, i.e. the Basic or Advanced value from this same section - inflect the surrounding words to agree with it."
|
|
38
|
+
},
|
|
35
39
|
"pinned": "Set by this deployment"
|
|
36
40
|
},
|
|
37
41
|
"common": {
|
package/i18n/locales/es.json
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"advanced": "Avanzada",
|
|
30
30
|
"basicHint": "Solo las herramientas del día a día. Cambia a Avanzada para verlo todo.",
|
|
31
31
|
"advancedHint": "Todas las secciones y todas las opciones de ejecución.",
|
|
32
|
+
"switchTo": "Cambiar a {mode}",
|
|
32
33
|
"pinned": "Definido por este despliegue"
|
|
33
34
|
},
|
|
34
35
|
"common": {
|
package/i18n/locales/fr.json
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"advanced": "Avancée",
|
|
30
30
|
"basicHint": "Uniquement les outils du quotidien. Passez en mode avancé pour tout afficher.",
|
|
31
31
|
"advancedHint": "Toutes les sections et toutes les options d’exécution.",
|
|
32
|
+
"switchTo": "Passer en {mode}",
|
|
32
33
|
"pinned": "Défini par ce déploiement"
|
|
33
34
|
},
|
|
34
35
|
"common": {
|
package/i18n/locales/he.json
CHANGED
package/i18n/locales/it.json
CHANGED
|
@@ -4542,6 +4542,7 @@
|
|
|
4542
4542
|
"advanced": "Avanzata",
|
|
4543
4543
|
"basicHint": "Solo gli strumenti di uso quotidiano. Passa ad Avanzata per vedere tutto.",
|
|
4544
4544
|
"advancedHint": "Tutte le sezioni e tutte le opzioni di esecuzione.",
|
|
4545
|
+
"switchTo": "Passa alla modalità {mode}",
|
|
4545
4546
|
"pinned": "Impostato da questo deployment"
|
|
4546
4547
|
},
|
|
4547
4548
|
"common": {
|
package/i18n/locales/ja.json
CHANGED
package/i18n/locales/pl.json
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"advanced": "Zaawansowany",
|
|
30
30
|
"basicHint": "Tylko codzienne narzędzia. Przełącz na zaawansowany, aby zobaczyć wszystko.",
|
|
31
31
|
"advancedHint": "Wszystkie sekcje i wszystkie opcje uruchamiania.",
|
|
32
|
+
"switchTo": "Przełącz na {mode}",
|
|
32
33
|
"pinned": "Ustawione przez to wdrożenie"
|
|
33
34
|
},
|
|
34
35
|
"common": {
|
package/i18n/locales/tr.json
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"advanced": "Gelişmiş",
|
|
30
30
|
"basicHint": "Yalnızca günlük araçlar. Her şeyi görmek için Gelişmiş’e geçin.",
|
|
31
31
|
"advancedHint": "Tüm bölümler ve tüm çalıştırma seçenekleri.",
|
|
32
|
+
"switchTo": "{mode} moduna geç",
|
|
32
33
|
"pinned": "Bu dağıtım tarafından belirlendi"
|
|
33
34
|
},
|
|
34
35
|
"common": {
|
package/i18n/locales/uk.json
CHANGED
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
"advanced": "Розширений",
|
|
30
30
|
"basicHint": "Лише щоденні інструменти. Перейдіть на розширений, щоб побачити все.",
|
|
31
31
|
"advancedHint": "Усі розділи та всі параметри запуску.",
|
|
32
|
+
"switchTo": "Перейти на {mode}",
|
|
32
33
|
"pinned": "Визначено цим розгортанням"
|
|
33
34
|
},
|
|
34
35
|
"common": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cat-factory/app",
|
|
3
|
-
"version": "0.196.
|
|
3
|
+
"version": "0.196.1",
|
|
4
4
|
"description": "Reusable Nuxt layer for the Agent Architecture Board SPA (components, stores, composables, pages). Consume it from a thin deployment app via `extends: ['@cat-factory/app']` and point it at your backend with NUXT_PUBLIC_API_BASE. See deploy/frontend for an example.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|