@cat-factory/app 0.54.0 → 0.54.2

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.
@@ -9,6 +9,7 @@ import { agentKindMeta } from '~/utils/catalog'
9
9
  // grading is scheduled by the engine and run by the background sweep, never from here.
10
10
  const ui = useUiStore()
11
11
  const kaizen = useKaizenStore()
12
+ const { t, d } = useI18n()
12
13
 
13
14
  const open = computed(() => ui.kaizenScreenOpen)
14
15
 
@@ -31,7 +32,7 @@ function meta(kind: string) {
31
32
  return agentKindMeta(kind)
32
33
  }
33
34
  function when(ms: number): string {
34
- return new Date(ms).toLocaleString()
35
+ return d(new Date(ms), 'long')
35
36
  }
36
37
  function gradeTone(g: KaizenGrading): string {
37
38
  if (g.status === 'failed') return 'text-slate-500'
@@ -42,10 +43,10 @@ function gradeTone(g: KaizenGrading): string {
42
43
  return 'text-rose-400'
43
44
  }
44
45
  function statusLabel(g: KaizenGrading): string {
45
- if (g.status === 'scheduled') return 'Scheduled'
46
- if (g.status === 'running') return 'Grading…'
47
- if (g.status === 'failed') return 'Failed'
48
- return g.grade != null ? `${g.grade}/5` : 'Graded'
46
+ if (g.status === 'scheduled') return t('kaizen.status.scheduled')
47
+ if (g.status === 'running') return t('kaizen.status.grading')
48
+ if (g.status === 'failed') return t('kaizen.status.failed')
49
+ return g.grade != null ? t('kaizen.gradeValue', { grade: g.grade }) : t('kaizen.status.graded')
49
50
  }
50
51
  </script>
51
52
 
@@ -63,9 +64,9 @@ function statusLabel(g: KaizenGrading): string {
63
64
  <UIcon name="i-lucide-sparkles" class="h-5 w-5 text-teal-400" />
64
65
  </div>
65
66
  <div class="min-w-0">
66
- <h1 class="truncate text-base font-semibold text-white">Kaizen</h1>
67
+ <h1 class="truncate text-base font-semibold text-white">{{ t('kaizen.title') }}</h1>
67
68
  <p class="truncate text-xs text-slate-500">
68
- Continuous-improvement grading of agent runs
69
+ {{ t('kaizen.subtitle') }}
69
70
  </p>
70
71
  </div>
71
72
  <div class="ml-auto flex items-center gap-2">
@@ -77,10 +78,10 @@ function statusLabel(g: KaizenGrading): string {
77
78
  :loading="kaizen.loadingOverview"
78
79
  @click="kaizen.loadOverview()"
79
80
  >
80
- Refresh
81
+ {{ t('kaizen.refresh') }}
81
82
  </UButton>
82
83
  <UButton icon="i-lucide-x" size="xs" color="neutral" variant="ghost" @click="close">
83
- Close
84
+ {{ t('common.close') }}
84
85
  </UButton>
85
86
  </div>
86
87
  </header>
@@ -89,7 +90,7 @@ function statusLabel(g: KaizenGrading): string {
89
90
  v-if="kaizen.available === false"
90
91
  class="flex flex-1 items-center justify-center text-sm text-slate-500"
91
92
  >
92
- Kaizen is not configured on this deployment.
93
+ {{ t('kaizen.notConfigured') }}
93
94
  </div>
94
95
 
95
96
  <div v-else class="grid flex-1 grid-cols-1 gap-6 overflow-auto p-6 lg:grid-cols-3">
@@ -97,12 +98,13 @@ function statusLabel(g: KaizenGrading): string {
97
98
  <section class="lg:col-span-1">
98
99
  <h2 class="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-200">
99
100
  <UIcon name="i-lucide-badge-check" class="h-4 w-4 text-emerald-400" />
100
- Verified combos
101
- <span class="text-xs font-normal text-slate-500">({{ kaizen.verifiedCount }})</span>
101
+ {{ t('kaizen.verifiedCombos.title') }}
102
+ <span class="text-xs font-normal text-slate-500">{{
103
+ t('kaizen.verifiedCombos.count', { count: kaizen.verifiedCount })
104
+ }}</span>
102
105
  </h2>
103
106
  <p class="mb-3 text-[11px] text-slate-500">
104
- A prompt + agent + model combination that graded 4 or 5 with no recommendations five
105
- times in a row. These are no longer graded.
107
+ {{ t('kaizen.verifiedCombos.hint') }}
106
108
  </p>
107
109
  <ul class="space-y-2">
108
110
  <li
@@ -125,15 +127,20 @@ function statusLabel(g: KaizenGrading): string {
125
127
  class="ml-auto h-3.5 w-3.5 text-emerald-400"
126
128
  />
127
129
  <span v-else class="ml-auto text-[11px] text-slate-500">
128
- {{ c.consecutiveHighGrades }}/5
130
+ {{ t('kaizen.verifiedCombos.progress', { count: c.consecutiveHighGrades }) }}
129
131
  </span>
130
132
  </div>
131
133
  <div class="mt-1 truncate text-[11px] text-slate-500" :title="c.model">
132
- {{ c.model }} · prompt v{{ c.promptVersion }}
134
+ {{
135
+ t('kaizen.verifiedCombos.modelPrompt', {
136
+ model: c.model,
137
+ version: c.promptVersion,
138
+ })
139
+ }}
133
140
  </div>
134
141
  </li>
135
142
  <li v-if="kaizen.verified.length === 0" class="text-xs text-slate-600">
136
- No combos yet.
143
+ {{ t('kaizen.verifiedCombos.empty') }}
137
144
  </li>
138
145
  </ul>
139
146
  </section>
@@ -142,17 +149,19 @@ function statusLabel(g: KaizenGrading): string {
142
149
  <section class="lg:col-span-2">
143
150
  <h2 class="mb-2 flex items-center gap-2 text-sm font-semibold text-slate-200">
144
151
  <UIcon name="i-lucide-history" class="h-4 w-4 text-teal-400" />
145
- Grading history
152
+ {{ t('kaizen.history.title') }}
146
153
  </h2>
147
154
  <div class="overflow-hidden rounded-lg border border-slate-800">
148
155
  <table class="w-full text-left text-xs">
149
156
  <thead class="bg-slate-900/60 text-[11px] uppercase tracking-wide text-slate-500">
150
157
  <tr>
151
- <th class="px-3 py-2 font-medium">When</th>
152
- <th class="px-3 py-2 font-medium">Agent</th>
153
- <th class="px-3 py-2 font-medium">Model</th>
154
- <th class="px-3 py-2 font-medium">Grade</th>
155
- <th class="px-3 py-2 font-medium">Recommendations</th>
158
+ <th class="px-3 py-2 font-medium">{{ t('kaizen.history.col.when') }}</th>
159
+ <th class="px-3 py-2 font-medium">{{ t('kaizen.history.col.agent') }}</th>
160
+ <th class="px-3 py-2 font-medium">{{ t('kaizen.history.col.model') }}</th>
161
+ <th class="px-3 py-2 font-medium">{{ t('kaizen.history.col.grade') }}</th>
162
+ <th class="px-3 py-2 font-medium">
163
+ {{ t('kaizen.history.col.recommendations') }}
164
+ </th>
156
165
  </tr>
157
166
  </thead>
158
167
  <tbody class="divide-y divide-slate-800/70">
@@ -168,7 +177,9 @@ function statusLabel(g: KaizenGrading): string {
168
177
  :style="{ color: meta(g.agentKind).color }"
169
178
  />
170
179
  <span class="text-slate-200">{{ meta(g.agentKind).label }}</span>
171
- <span class="text-slate-600">v{{ g.promptVersion }}</span>
180
+ <span class="text-slate-600">{{
181
+ t('kaizen.promptVersion', { version: g.promptVersion })
182
+ }}</span>
172
183
  </span>
173
184
  </td>
174
185
  <td class="max-w-[12rem] truncate px-3 py-2 text-slate-400" :title="g.model">
@@ -187,7 +198,7 @@ function statusLabel(g: KaizenGrading): string {
187
198
  </tr>
188
199
  <tr v-if="kaizen.history.length === 0">
189
200
  <td colspan="5" class="px-3 py-6 text-center text-slate-600">
190
- No gradings yet.
201
+ {{ t('kaizen.history.empty') }}
191
202
  </td>
192
203
  </tr>
193
204
  </tbody>
@@ -13,6 +13,7 @@ const props = defineProps<{
13
13
  }>()
14
14
 
15
15
  const kaizen = useKaizenStore()
16
+ const { t } = useI18n()
16
17
 
17
18
  const grading = computed(() => {
18
19
  if (!props.instanceId || props.stepIndex == null) return null
@@ -45,50 +46,52 @@ const tone = computed(() => {
45
46
  <section v-if="grading" class="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
46
47
  <div class="flex items-center gap-2">
47
48
  <UIcon name="i-lucide-sparkles" class="h-4 w-4 text-teal-400" />
48
- <h3 class="text-sm font-semibold text-slate-200">Kaizen grading</h3>
49
+ <h3 class="text-sm font-semibold text-slate-200">{{ t('kaizen.grading.title') }}</h3>
49
50
  <span class="ml-auto flex items-center gap-1.5 text-xs">
50
51
  <template v-if="grading.status === 'scheduled'">
51
52
  <UIcon name="i-lucide-clock" class="h-3.5 w-3.5 text-slate-500" />
52
- <span class="text-slate-400">Scheduled</span>
53
+ <span class="text-slate-400">{{ t('kaizen.status.scheduled') }}</span>
53
54
  </template>
54
55
  <template v-else-if="grading.status === 'running'">
55
56
  <UIcon name="i-lucide-loader-circle" class="h-3.5 w-3.5 animate-spin text-teal-400" />
56
- <span class="text-teal-300">Grading…</span>
57
+ <span class="text-teal-300">{{ t('kaizen.status.grading') }}</span>
57
58
  </template>
58
59
  <template v-else-if="grading.status === 'failed'">
59
60
  <UIcon name="i-lucide-circle-alert" class="h-3.5 w-3.5 text-rose-400" />
60
- <span class="text-rose-400">Failed</span>
61
+ <span class="text-rose-400">{{ t('kaizen.status.failed') }}</span>
61
62
  </template>
62
63
  <template v-else>
63
- <span class="font-semibold" :class="tone">{{ grading.grade }}/5</span>
64
+ <span class="font-semibold" :class="tone">{{
65
+ t('kaizen.gradeValue', { grade: grading.grade })
66
+ }}</span>
64
67
  </template>
65
68
  </span>
66
69
  </div>
67
70
 
68
71
  <p v-if="grading.status === 'scheduled'" class="mt-2 text-[11px] text-slate-500">
69
- A Kaizen grading is queued for this step. It runs in the background after the run.
72
+ {{ t('kaizen.scheduledHint') }}
70
73
  </p>
71
74
 
72
75
  <template v-else-if="grading.status === 'complete'">
73
76
  <p v-if="grading.summary" class="mt-2 text-xs text-slate-300">{{ grading.summary }}</p>
74
77
  <div v-if="grading.recommendations.length" class="mt-2">
75
78
  <p class="text-[11px] font-medium uppercase tracking-wide text-slate-500">
76
- Recommendations
79
+ {{ t('kaizen.recommendations') }}
77
80
  </p>
78
81
  <ul class="mt-1 list-disc space-y-0.5 pl-4 text-xs text-slate-300">
79
82
  <li v-for="(r, i) in grading.recommendations" :key="i">{{ r }}</li>
80
83
  </ul>
81
84
  </div>
82
85
  <p v-else class="mt-2 text-[11px] text-emerald-400/80">
83
- Smooth interaction — nothing to improve.
86
+ {{ t('kaizen.noImprovements') }}
84
87
  </p>
85
88
  <p v-if="grading.graderModel" class="mt-2 text-[10px] text-slate-600">
86
- Graded by {{ grading.graderModel }}
89
+ {{ t('kaizen.gradedBy', { model: grading.graderModel }) }}
87
90
  </p>
88
91
  </template>
89
92
 
90
93
  <p v-else-if="grading.status === 'failed'" class="mt-2 text-[11px] text-rose-400/80">
91
- {{ grading.error ?? 'The grading could not be completed.' }}
94
+ {{ grading.error ?? t('kaizen.failedFallback') }}
92
95
  </p>
93
96
  </section>
94
97
  </template>
@@ -28,6 +28,8 @@ const emit = defineEmits<{
28
28
  (e: 'update:index', value: number): void
29
29
  }>()
30
30
 
31
+ const { t, n } = useI18n()
32
+
31
33
  const MIN_SCALE = 1
32
34
  const MAX_SCALE = 8
33
35
  const scale = ref(1)
@@ -161,32 +163,36 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
161
163
  class="fixed inset-0 z-[60] flex flex-col bg-slate-950/95 backdrop-blur-sm focus:outline-none"
162
164
  role="dialog"
163
165
  aria-modal="true"
164
- :aria-label="current ? `Screenshot: ${current.label}` : 'Screenshot viewer'"
166
+ :aria-label="
167
+ current
168
+ ? t('media.lightbox.ariaLabel', { label: current.label })
169
+ : t('media.lightbox.ariaLabelFallback')
170
+ "
165
171
  @click.self="close"
166
172
  >
167
173
  <!-- Toolbar -->
168
174
  <div class="flex items-center gap-3 border-b border-slate-800/60 px-4 py-2.5">
169
175
  <span class="min-w-0 flex-1 truncate text-[13px] font-medium text-slate-200">
170
- {{ current?.label ?? 'Screenshot' }}
176
+ {{ current?.label ?? t('media.lightbox.fallbackTitle') }}
171
177
  </span>
172
178
  <span v-if="total > 1" class="shrink-0 text-[12px] tabular-nums text-slate-400">
173
- {{ index + 1 }} / {{ total }}
179
+ {{ t('media.lightbox.counter', { current: index + 1, total }) }}
174
180
  </span>
175
181
  <div class="flex shrink-0 items-center gap-1">
176
182
  <button
177
183
  class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200 disabled:opacity-40"
178
- title="Zoom out (-)"
184
+ :title="t('media.lightbox.zoomOut')"
179
185
  :disabled="scale <= MIN_SCALE"
180
186
  @click="zoomBy(1 / 1.25)"
181
187
  >
182
188
  <UIcon name="i-lucide-zoom-out" class="h-4 w-4" />
183
189
  </button>
184
- <span class="w-10 text-center text-[11px] tabular-nums text-slate-500"
185
- >{{ Math.round(scale * 100) }}%</span
186
- >
190
+ <span class="w-10 text-center text-[11px] tabular-nums text-slate-500">{{
191
+ n(scale, 'percent')
192
+ }}</span>
187
193
  <button
188
194
  class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200 disabled:opacity-40"
189
- title="Zoom in (+)"
195
+ :title="t('media.lightbox.zoomIn')"
190
196
  :disabled="scale >= MAX_SCALE"
191
197
  @click="zoomBy(1.25)"
192
198
  >
@@ -194,14 +200,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
194
200
  </button>
195
201
  <button
196
202
  class="rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
197
- title="Reset (0)"
203
+ :title="t('media.lightbox.reset')"
198
204
  @click="resetView"
199
205
  >
200
206
  <UIcon name="i-lucide-maximize" class="h-4 w-4" />
201
207
  </button>
202
208
  <button
203
209
  class="ml-1 rounded-md p-1.5 text-slate-400 hover:bg-slate-800 hover:text-slate-200"
204
- title="Close (Esc)"
210
+ :title="t('media.lightbox.close')"
205
211
  @click="close"
206
212
  >
207
213
  <UIcon name="i-lucide-x" class="h-4 w-4" />
@@ -218,7 +224,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
218
224
  <button
219
225
  v-if="total > 1"
220
226
  class="absolute left-3 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 p-2 text-slate-300 hover:bg-slate-800 hover:text-white"
221
- title="Previous ()"
227
+ :title="t('media.lightbox.prev')"
222
228
  @click="go(-1)"
223
229
  >
224
230
  <UIcon name="i-lucide-chevron-left" class="h-5 w-5" />
@@ -248,21 +254,21 @@ onBeforeUnmount(() => window.removeEventListener('keydown', onKey, true))
248
254
  :class="state === 'error' ? '' : 'animate-spin'"
249
255
  />
250
256
  <p class="text-[12px]">
251
- {{ state === 'error' ? 'Failed to load image.' : 'Loading…' }}
257
+ {{ state === 'error' ? t('media.lightbox.failed') : t('media.lightbox.loading') }}
252
258
  </p>
253
259
  <button
254
260
  v-if="state === 'error' && current"
255
261
  class="text-[12px] text-amber-300 hover:underline"
256
262
  @click="props.blobs.retry(current.artifactId)"
257
263
  >
258
- Retry
264
+ {{ t('common.retry') }}
259
265
  </button>
260
266
  </div>
261
267
 
262
268
  <button
263
269
  v-if="total > 1"
264
270
  class="absolute right-3 top-1/2 z-10 -translate-y-1/2 rounded-full bg-slate-900/80 p-2 text-slate-300 hover:bg-slate-800 hover:text-white"
265
- title="Next ()"
271
+ :title="t('media.lightbox.next')"
266
272
  @click="go(1)"
267
273
  >
268
274
  <UIcon name="i-lucide-chevron-right" class="h-5 w-5" />
@@ -23,6 +23,8 @@ const emit = defineEmits<{
23
23
  (e: 'uploadReference', file: File): void
24
24
  }>()
25
25
 
26
+ const { t } = useI18n()
27
+
26
28
  type Mode = 'side-by-side' | 'overlay' | 'swipe' | 'diff'
27
29
 
28
30
  const actualUrl = computed(() => props.blobs.urlFor(props.actualId))
@@ -32,14 +34,15 @@ const hasBoth = computed(() => !!actualUrl.value && !!refUrl.value)
32
34
  const diffFailed = ref(false)
33
35
  const MODES = computed<{ id: Mode; icon: string; label: string }[]>(() => {
34
36
  const base: { id: Mode; icon: string; label: string }[] = [
35
- { id: 'side-by-side', icon: 'i-lucide-columns-2', label: 'Side by side' },
37
+ { id: 'side-by-side', icon: 'i-lucide-columns-2', label: t('media.compare.mode.sideBySide') },
36
38
  ]
37
39
  if (hasBoth.value) {
38
40
  base.push(
39
- { id: 'overlay', icon: 'i-lucide-layers', label: 'Overlay' },
40
- { id: 'swipe', icon: 'i-lucide-flip-horizontal-2', label: 'Swipe' },
41
+ { id: 'overlay', icon: 'i-lucide-layers', label: t('media.compare.mode.overlay') },
42
+ { id: 'swipe', icon: 'i-lucide-flip-horizontal-2', label: t('media.compare.mode.swipe') },
41
43
  )
42
- if (!diffFailed.value) base.push({ id: 'diff', icon: 'i-lucide-contrast', label: 'Difference' })
44
+ if (!diffFailed.value)
45
+ base.push({ id: 'diff', icon: 'i-lucide-contrast', label: t('media.compare.mode.diff') })
43
46
  }
44
47
  return base
45
48
  })
@@ -162,13 +165,19 @@ function onRefInput(e: Event) {
162
165
  <!-- SIDE BY SIDE -->
163
166
  <div v-if="mode === 'side-by-side'" class="grid grid-cols-2 gap-3">
164
167
  <figure class="space-y-1">
165
- <figcaption class="text-[10px] uppercase tracking-wide text-slate-500">Actual</figcaption>
168
+ <figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
169
+ {{ t('media.compare.actual') }}
170
+ </figcaption>
166
171
  <button
167
172
  v-if="actualUrl"
168
173
  class="block w-full overflow-hidden rounded border border-slate-800 hover:border-slate-600"
169
174
  @click="actualId && emit('expand', actualId)"
170
175
  >
171
- <img :src="actualUrl" :alt="`${view} (actual)`" class="w-full cursor-zoom-in" />
176
+ <img
177
+ :src="actualUrl"
178
+ :alt="t('media.compare.actualAlt', { view })"
179
+ class="w-full cursor-zoom-in"
180
+ />
172
181
  </button>
173
182
  <div
174
183
  v-else
@@ -176,29 +185,33 @@ function onRefInput(e: Event) {
176
185
  >
177
186
  {{
178
187
  props.blobs.statusFor(actualId) === 'error'
179
- ? 'Failed to load'
188
+ ? t('media.compare.failedToLoad')
180
189
  : actualId
181
- ? 'Loading…'
182
- : 'Not captured'
190
+ ? t('media.compare.loading')
191
+ : t('media.compare.notCaptured')
183
192
  }}
184
193
  </div>
185
194
  </figure>
186
195
 
187
196
  <figure class="space-y-1">
188
197
  <figcaption class="text-[10px] uppercase tracking-wide text-slate-500">
189
- Reference
198
+ {{ t('media.compare.reference') }}
190
199
  </figcaption>
191
200
  <button
192
201
  v-if="refUrl"
193
202
  class="group relative block w-full overflow-hidden rounded border border-slate-800 hover:border-slate-600"
194
203
  @click="referenceId && emit('expand', referenceId)"
195
204
  >
196
- <img :src="refUrl" :alt="`${view} (reference)`" class="w-full cursor-zoom-in" />
205
+ <img
206
+ :src="refUrl"
207
+ :alt="t('media.compare.referenceAlt', { view })"
208
+ class="w-full cursor-zoom-in"
209
+ />
197
210
  <span
198
211
  class="absolute bottom-1 right-1 rounded bg-slate-950/80 px-1.5 py-0.5 text-[10px] text-slate-300 opacity-0 group-hover:opacity-100"
199
212
  @click.stop="refInput?.click()"
200
213
  >
201
- Replace
214
+ {{ t('media.compare.replace') }}
202
215
  </span>
203
216
  </button>
204
217
  <!-- Drop zone when no reference yet -->
@@ -216,7 +229,7 @@ function onRefInput(e: Event) {
216
229
  @drop.prevent="onDrop"
217
230
  >
218
231
  <UIcon name="i-lucide-image-up" class="h-5 w-5" />
219
- <span>Drop or click to add a reference</span>
232
+ <span>{{ t('media.compare.dropHint') }}</span>
220
233
  </div>
221
234
  </figure>
222
235
  </div>
@@ -224,17 +237,17 @@ function onRefInput(e: Event) {
224
237
  <!-- OVERLAY (onion-skin) -->
225
238
  <div v-else-if="mode === 'overlay'" class="space-y-2">
226
239
  <div class="relative w-full overflow-hidden rounded border border-slate-800">
227
- <img :src="refUrl" :alt="`${view} (reference)`" class="w-full" />
240
+ <img :src="refUrl" :alt="t('media.compare.referenceAlt', { view })" class="w-full" />
228
241
  <!-- object-contain so a differing aspect ratio onion-skins undistorted over the reference. -->
229
242
  <img
230
243
  :src="actualUrl"
231
- :alt="`${view} (actual)`"
244
+ :alt="t('media.compare.actualAlt', { view })"
232
245
  class="absolute inset-0 h-full w-full object-contain"
233
246
  :style="{ opacity: overlayOpacity / 100 }"
234
247
  />
235
248
  </div>
236
249
  <div class="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
237
- <span>Reference</span>
250
+ <span>{{ t('media.compare.reference') }}</span>
238
251
  <input
239
252
  v-model.number="overlayOpacity"
240
253
  type="range"
@@ -242,7 +255,7 @@ function onRefInput(e: Event) {
242
255
  max="100"
243
256
  class="flex-1 accent-amber-500"
244
257
  />
245
- <span>Actual</span>
258
+ <span>{{ t('media.compare.actual') }}</span>
246
259
  </div>
247
260
  </div>
248
261
 
@@ -256,7 +269,7 @@ function onRefInput(e: Event) {
256
269
  @pointerup="onSwipeUp"
257
270
  @pointercancel="onSwipeUp"
258
271
  >
259
- <img :src="refUrl" :alt="`${view} (reference)`" class="block w-full" />
272
+ <img :src="refUrl" :alt="t('media.compare.referenceAlt', { view })" class="block w-full" />
260
273
  <div
261
274
  class="absolute inset-0 overflow-hidden"
262
275
  :style="{ clipPath: `inset(0 ${100 - splitPct}% 0 0)` }"
@@ -265,7 +278,7 @@ function onRefInput(e: Event) {
265
278
  ratio doesn't stretch it; the split then compares like-for-like. -->
266
279
  <img
267
280
  :src="actualUrl"
268
- :alt="`${view} (actual)`"
281
+ :alt="t('media.compare.actualAlt', { view })"
269
282
  class="absolute inset-0 block h-full w-full object-contain"
270
283
  />
271
284
  </div>
@@ -278,18 +291,18 @@ function onRefInput(e: Event) {
278
291
  </div>
279
292
  <span
280
293
  class="absolute left-1 top-1 rounded bg-slate-950/70 px-1 text-[9px] uppercase text-slate-300"
281
- >Actual</span
294
+ >{{ t('media.compare.actual') }}</span
282
295
  >
283
296
  <span
284
297
  class="absolute right-1 top-1 rounded bg-slate-950/70 px-1 text-[9px] uppercase text-slate-300"
285
- >Reference</span
298
+ >{{ t('media.compare.reference') }}</span
286
299
  >
287
300
  </div>
288
301
 
289
302
  <!-- DIFF (canvas) -->
290
303
  <div v-else-if="mode === 'diff'" class="space-y-1">
291
304
  <canvas ref="diffCanvas" class="w-full rounded border border-slate-800 bg-black" />
292
- <p class="text-[10px] text-slate-500">Identical pixels appear black; differences glow.</p>
305
+ <p class="text-[10px] text-slate-500">{{ t('media.compare.diffHint') }}</p>
293
306
  </div>
294
307
 
295
308
  <!-- Hidden file input shared by replace/drop zone -->
@@ -6,10 +6,16 @@
6
6
  // panels' drawer, or `executionId` for a run's "Infrastructure attempts" drawer (which
7
7
  // surfaces that run's container/runner/env attempts). Loaded on mount + re-loadable.
8
8
  import { onMounted } from 'vue'
9
- import type { ProvisioningOperation, ProvisioningSubsystem } from '~/types/provisioningLogs'
9
+ import type {
10
+ ProvisioningOperation,
11
+ ProvisioningOutcome,
12
+ ProvisioningSubsystem,
13
+ } from '~/types/provisioningLogs'
10
14
 
11
15
  const props = defineProps<{ subsystem?: ProvisioningSubsystem; executionId?: string }>()
12
16
 
17
+ const { t, d } = useI18n()
18
+
13
19
  const store = useProvisioningLogsStore()
14
20
  const state = computed(() =>
15
21
  props.executionId
@@ -24,17 +30,24 @@ function reload() {
24
30
 
25
31
  onMounted(reload)
26
32
 
27
- const OPERATION_LABEL: Record<ProvisioningOperation, string> = {
28
- provision: 'Spin up',
29
- teardown: 'Tear down',
30
- status: 'Status check',
31
- dispatch: 'Spin up',
32
- release: 'Tear down',
33
- 'poll-failure': 'Health check',
34
- }
33
+ // Exhaustive enum→label maps of literal `t(...)` keys (keeps the typed-key drift guard
34
+ // live for these runtime-indexed lookups).
35
+ const OPERATION_LABEL = computed<Record<ProvisioningOperation, string>>(() => ({
36
+ provision: t('provisioning.operation.provision'),
37
+ teardown: t('provisioning.operation.teardown'),
38
+ status: t('provisioning.operation.status'),
39
+ dispatch: t('provisioning.operation.dispatch'),
40
+ release: t('provisioning.operation.release'),
41
+ 'poll-failure': t('provisioning.operation.poll-failure'),
42
+ }))
43
+
44
+ const OUTCOME_LABEL = computed<Record<ProvisioningOutcome, string>>(() => ({
45
+ success: t('provisioning.outcome.success'),
46
+ failure: t('provisioning.outcome.failure'),
47
+ }))
35
48
 
36
49
  function when(epochMs: number): string {
37
- return new Date(epochMs).toLocaleString()
50
+ return d(new Date(epochMs), 'long')
38
51
  }
39
52
  </script>
40
53
 
@@ -42,7 +55,7 @@ function when(epochMs: number): string {
42
55
  <div class="rounded-lg border border-slate-700 bg-slate-900/50">
43
56
  <div class="flex items-center justify-between border-b border-slate-800 px-3 py-2">
44
57
  <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
45
- Provisioning logs
58
+ {{ t('provisioning.title') }}
46
59
  </p>
47
60
  <UButton
48
61
  icon="i-lucide-rotate-ccw"
@@ -51,7 +64,7 @@ function when(epochMs: number): string {
51
64
  :loading="state.loading"
52
65
  @click="reload"
53
66
  >
54
- Refresh
67
+ {{ t('provisioning.refresh') }}
55
68
  </UButton>
56
69
  </div>
57
70
 
@@ -60,7 +73,7 @@ function when(epochMs: number): string {
60
73
  v-else-if="!state.loading && state.entries.length === 0"
61
74
  class="px-3 py-3 text-[12px] text-slate-500"
62
75
  >
63
- No provisioning attempts recorded yet.
76
+ {{ t('provisioning.empty') }}
64
77
  </p>
65
78
 
66
79
  <ul v-else class="max-h-80 divide-y divide-slate-800 overflow-auto">
@@ -79,7 +92,7 @@ function when(epochMs: number): string {
79
92
  ? 'bg-emerald-950/60 text-emerald-300'
80
93
  : 'bg-rose-950/60 text-rose-300'
81
94
  "
82
- >{{ entry.outcome }}</span
95
+ >{{ OUTCOME_LABEL[entry.outcome] }}</span
83
96
  >
84
97
  <span class="ml-auto text-[11px] text-slate-500">{{ when(entry.createdAt) }}</span>
85
98
  </div>