@cat-factory/app 0.46.9 → 0.46.11

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 (42) hide show
  1. package/app/components/board/AddTaskModal.vue +106 -88
  2. package/app/components/board/AgentFailureCard.vue +8 -5
  3. package/app/components/board/AgentStopButton.vue +8 -5
  4. package/app/components/board/BoardCanvas.vue +13 -9
  5. package/app/components/board/RecurringPipelineModal.vue +33 -23
  6. package/app/components/board/nodes/BlockNode.vue +50 -34
  7. package/app/components/board/nodes/DecisionBadge.vue +6 -3
  8. package/app/components/board/nodes/DraggableTask.vue +2 -1
  9. package/app/components/board/nodes/EpicNode.vue +10 -3
  10. package/app/components/board/nodes/ModuleFrame.vue +6 -5
  11. package/app/components/board/nodes/TaskCard.vue +40 -22
  12. package/app/components/board/nodes/TaskPipelineMini.vue +4 -3
  13. package/app/components/layout/BoardToolbar.vue +26 -19
  14. package/app/components/observability/StepMetricsBar.vue +31 -11
  15. package/app/components/observability/StepModelActivity.vue +3 -2
  16. package/app/components/panels/AgentStepDetail.vue +52 -39
  17. package/app/components/panels/DecisionModal.vue +12 -5
  18. package/app/components/panels/GenericStructuredResultView.vue +16 -6
  19. package/app/components/panels/InspectorPanel.vue +35 -28
  20. package/app/components/panels/ObservabilityPanel.vue +92 -49
  21. package/app/components/panels/StepMetadataCard.vue +87 -30
  22. package/app/components/panels/StepRestartControl.vue +4 -3
  23. package/app/components/panels/StepRunMeta.vue +23 -10
  24. package/app/components/panels/StepTestReport.vue +14 -11
  25. package/app/components/panels/inspector/ContainerSummary.vue +25 -14
  26. package/app/components/panels/inspector/EpicChildren.vue +7 -4
  27. package/app/components/panels/inspector/RecurringScheduleSettings.vue +60 -19
  28. package/app/components/panels/inspector/ServiceFragments.vue +3 -2
  29. package/app/components/panels/inspector/ServiceReleaseHealthConfig.vue +18 -13
  30. package/app/components/panels/inspector/ServiceTestConfig.vue +50 -39
  31. package/app/components/panels/inspector/TaskAgentConfig.vue +6 -5
  32. package/app/components/panels/inspector/TaskDependencies.vue +9 -4
  33. package/app/components/panels/inspector/TaskEstimateBadge.vue +14 -13
  34. package/app/components/panels/inspector/TaskExecution.vue +65 -31
  35. package/app/components/panels/inspector/TaskRunSettings.vue +93 -56
  36. package/app/components/panels/inspector/TaskStructure.vue +5 -4
  37. package/i18n/locales/en.json +606 -0
  38. package/i18n/locales/es.json +596 -2
  39. package/i18n/locales/fr.json +596 -2
  40. package/i18n/locales/pl.json +596 -2
  41. package/i18n/locales/uk.json +596 -2
  42. package/package.json +1 -1
@@ -13,6 +13,7 @@ const pipelines = usePipelinesStore()
13
13
  const recurring = useRecurringPipelinesStore()
14
14
  const tracker = useTrackerStore()
15
15
  const toast = useToast()
16
+ const { t } = useI18n()
16
17
 
17
18
  const open = computed({
18
19
  get: () => ui.addRecurringFrameId !== null,
@@ -54,7 +55,9 @@ const pipelineMenu = computed(() => [
54
55
  })),
55
56
  ])
56
57
  const selectedPipeline = computed(() => pipelines.getPipeline(pipelineId.value))
57
- const selectedPipelineLabel = computed(() => selectedPipeline.value?.name ?? 'Pick a pipeline')
58
+ const selectedPipelineLabel = computed(
59
+ () => selectedPipeline.value?.name ?? t('board.recurring.pickPipeline'),
60
+ )
58
61
 
59
62
  // Infer the template from the picked pipeline so the backend seeds the right block
60
63
  // description (and so we know to show the tracker config).
@@ -108,7 +111,7 @@ async function add() {
108
111
  ui.closeAddRecurring()
109
112
  } catch (e) {
110
113
  toast.add({
111
- title: 'Could not add recurring pipeline',
114
+ title: t('board.recurring.addFailedTitle'),
112
115
  description: e instanceof Error ? e.message : String(e),
113
116
  icon: 'i-lucide-triangle-alert',
114
117
  color: 'error',
@@ -120,24 +123,27 @@ async function add() {
120
123
  </script>
121
124
 
122
125
  <template>
123
- <UModal v-model:open="open" title="Add a recurring pipeline">
126
+ <UModal v-model:open="open" :title="t('board.recurring.title')">
124
127
  <template #body>
125
128
  <div class="space-y-4">
126
129
  <p v-if="frame" class="text-xs text-slate-400">
127
- Recurring pipeline on
128
- <span class="font-medium text-slate-200">{{ frame.title }}</span>
130
+ <i18n-t keypath="board.recurring.on" tag="span" scope="global">
131
+ <template #frame>
132
+ <span class="font-medium text-slate-200">{{ frame.title }}</span>
133
+ </template>
134
+ </i18n-t>
129
135
  </p>
130
136
 
131
- <UFormField label="Name" required>
137
+ <UFormField :label="t('board.recurring.name')" required>
132
138
  <UInput
133
139
  v-model="name"
134
- placeholder="e.g. Weekly dependency updates"
140
+ :placeholder="t('board.recurring.namePlaceholder')"
135
141
  autofocus
136
142
  class="w-full"
137
143
  />
138
144
  </UFormField>
139
145
 
140
- <UFormField label="Pipeline" required>
146
+ <UFormField :label="t('board.recurring.pipeline')" required>
141
147
  <UDropdownMenu :items="pipelineMenu" class="w-full">
142
148
  <UButton
143
149
  color="neutral"
@@ -152,12 +158,12 @@ async function add() {
152
158
  </UDropdownMenu>
153
159
  </UFormField>
154
160
 
155
- <UFormField label="Prompt">
161
+ <UFormField :label="t('board.recurring.prompt')">
156
162
  <UTextarea
157
163
  v-model="description"
158
164
  :rows="3"
159
165
  autoresize
160
- placeholder="What should each run do? Describe the work — the same prompt a normal task carries. Leave blank to use the pipeline's default."
166
+ :placeholder="t('board.recurring.promptPlaceholder')"
161
167
  class="w-full"
162
168
  />
163
169
  </UFormField>
@@ -166,11 +172,10 @@ async function add() {
166
172
 
167
173
  <div v-if="isTechDebt" class="space-y-3 rounded-lg border border-slate-800 p-3">
168
174
  <p class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
169
- Issue tracker
175
+ {{ t('board.recurring.issueTracker') }}
170
176
  </p>
171
177
  <p class="text-[11px] text-slate-500">
172
- The tech-debt pipeline files a ticket from its analysis before implementing. Choose
173
- where (saved for the whole workspace).
178
+ {{ t('board.recurring.issueTrackerHint') }}
174
179
  </p>
175
180
  <div class="flex gap-1">
176
181
  <UButton
@@ -180,7 +185,7 @@ async function add() {
180
185
  icon="i-lucide-github"
181
186
  @click="trackerKind = 'github'"
182
187
  >
183
- GitHub Issues
188
+ {{ t('board.recurring.githubIssues') }}
184
189
  </UButton>
185
190
  <UButton
186
191
  size="xs"
@@ -189,7 +194,7 @@ async function add() {
189
194
  icon="i-lucide-square-check"
190
195
  @click="trackerKind = 'jira'"
191
196
  >
192
- Jira
197
+ {{ t('board.recurring.jira') }}
193
198
  </UButton>
194
199
  <UButton
195
200
  size="xs"
@@ -198,27 +203,32 @@ async function add() {
198
203
  icon="i-lucide-square-kanban"
199
204
  @click="trackerKind = 'linear'"
200
205
  >
201
- Linear
206
+ {{ t('board.recurring.linear') }}
202
207
  </UButton>
203
208
  </div>
204
- <UFormField v-if="trackerKind === 'jira'" label="Jira project key">
205
- <UInput v-model="jiraProjectKey" placeholder="e.g. ENG" class="w-full" />
209
+ <UFormField v-if="trackerKind === 'jira'" :label="t('board.recurring.jiraProjectKey')">
210
+ <UInput
211
+ v-model="jiraProjectKey"
212
+ :placeholder="t('board.recurring.jiraProjectKeyPlaceholder')"
213
+ class="w-full"
214
+ />
206
215
  </UFormField>
207
- <UFormField v-if="trackerKind === 'linear'" label="Linear team id">
216
+ <UFormField v-if="trackerKind === 'linear'" :label="t('board.recurring.linearTeamId')">
208
217
  <UInput v-model="linearTeamId" placeholder="team_…" class="w-full" />
209
218
  </UFormField>
210
219
  </div>
211
220
 
212
221
  <p class="text-[11px] text-slate-500">
213
- A single recurring task is added inside the service; each run replaces the last. Its run
214
- history is visible in the inspector.
222
+ {{ t('board.recurring.footerHint') }}
215
223
  </p>
216
224
  </div>
217
225
  </template>
218
226
 
219
227
  <template #footer>
220
228
  <div class="flex w-full justify-end gap-2">
221
- <UButton color="neutral" variant="ghost" @click="ui.closeAddRecurring()">Cancel</UButton>
229
+ <UButton color="neutral" variant="ghost" @click="ui.closeAddRecurring()">{{
230
+ t('common.cancel')
231
+ }}</UButton>
222
232
  <UButton
223
233
  color="primary"
224
234
  icon="i-lucide-repeat"
@@ -226,7 +236,7 @@ async function add() {
226
236
  :disabled="!canAdd"
227
237
  @click="add"
228
238
  >
229
- Add recurring pipeline
239
+ {{ t('board.recurring.submit') }}
230
240
  </UButton>
231
241
  </div>
232
242
  </template>
@@ -22,6 +22,7 @@ const tasks = useTasksStore()
22
22
  const agentRuns = useAgentRunsStore()
23
23
  const services = useServicesStore()
24
24
  const reviews = useReviewStage()
25
+ const { t } = useI18n()
25
26
  const { lod } = useSemanticZoom()
26
27
  // Coarse-pointer (touch) bumps the frame-header actions from `xs` to `sm` so
27
28
  // they clear a comfortable tap target on phones without affecting mouse desktops.
@@ -57,15 +58,17 @@ const canvas = computed(() => board.containerSize(props.id))
57
58
  const frameStatus = computed<BlockStatus>(() => board.frameStatus(props.id))
58
59
  const statusMeta = computed(() => STATUS_META[frameStatus.value])
59
60
  const accent = computed(() => statusMeta.value.color)
60
- const FRAME_LABEL: Record<BlockStatus, string> = {
61
- planned: 'No tasks',
62
- ready: 'Live',
63
- in_progress: 'Active',
64
- blocked: 'Needs attention',
65
- pr_ready: 'Active',
66
- done: 'Live',
61
+ // Exhaustive (tier-2) status → label-key map: a new BlockStatus without a label
62
+ // fails the typecheck rather than leaking a raw key into the frame badge.
63
+ const FRAME_LABEL_KEYS: Record<BlockStatus, string> = {
64
+ planned: 'board.frame.status.planned',
65
+ ready: 'board.frame.status.ready',
66
+ in_progress: 'board.frame.status.in_progress',
67
+ blocked: 'board.frame.status.blocked',
68
+ pr_ready: 'board.frame.status.pr_ready',
69
+ done: 'board.frame.status.done',
67
70
  }
68
- const statusLabel = computed(() => FRAME_LABEL[frameStatus.value])
71
+ const statusLabel = computed(() => t(FRAME_LABEL_KEYS[frameStatus.value]))
69
72
 
70
73
  const selected = computed(() => ui.selectedBlockId === props.id)
71
74
  // Services are always expanded to their task canvas, at every zoom level: there is no
@@ -211,7 +214,7 @@ const ITEM_ICON: Record<string, string> = {
211
214
  v-if="blockApprovals.length"
212
215
  :count="blockApprovals.length"
213
216
  :compact="lod === 'far'"
214
- label="Approval needed"
217
+ :label="t('board.decisionBadge.approvalNeeded')"
215
218
  icon="i-lucide-shield-check"
216
219
  @open="openFirstApproval"
217
220
  />
@@ -232,13 +235,13 @@ const ITEM_ICON: Record<string, string> = {
232
235
  v-if="bootstrapping"
233
236
  name="i-lucide-loader-circle"
234
237
  class="ml-auto h-3.5 w-3.5 shrink-0 animate-spin text-amber-400"
235
- title="Bootstrapping…"
238
+ :title="t('board.frame.bootstrapping')"
236
239
  />
237
240
  <UIcon
238
241
  v-else-if="runFailed"
239
242
  name="i-lucide-alert-triangle"
240
243
  class="ml-auto h-3.5 w-3.5 shrink-0 text-rose-400"
241
- title="Run failed"
244
+ :title="t('board.frame.runFailed')"
242
245
  />
243
246
  <span v-else-if="hasTasks" class="ml-auto shrink-0 text-[11px] text-slate-300">
244
247
  {{ mergedTasks }}/{{ taskCount }}
@@ -259,7 +262,7 @@ const ITEM_ICON: Record<string, string> = {
259
262
  name="i-lucide-loader-circle"
260
263
  class="h-3.5 w-3.5 shrink-0 animate-spin text-amber-400"
261
264
  />
262
- <span class="text-amber-300">Bootstrapping…</span>
265
+ <span class="text-amber-300">{{ t('board.frame.bootstrapping') }}</span>
263
266
  <span v-if="bootstrapSubtasks" class="ml-auto text-amber-200/80">
264
267
  {{ bootstrapSubtasks.completed }}/{{ bootstrapSubtasks.total }}
265
268
  </span>
@@ -292,18 +295,18 @@ const ITEM_ICON: Record<string, string> = {
292
295
  variant="subtle"
293
296
  size="sm"
294
297
  class="shrink-0"
295
- title="Shared across workspaces in this org"
298
+ :title="t('board.frame.sharedTitle')"
296
299
  >
297
- Shared
300
+ {{ t('board.frame.shared') }}
298
301
  </UBadge>
299
302
  </div>
300
303
  <div class="flex items-center justify-between">
301
304
  <UBadge :color="statusMeta.chip as any" variant="subtle" size="sm">{{
302
305
  statusLabel
303
306
  }}</UBadge>
304
- <span class="text-[11px] text-slate-400"
305
- >{{ taskCount }} task{{ taskCount === 1 ? '' : 's' }}</span
306
- >
307
+ <span class="text-[11px] text-slate-400">{{
308
+ t('board.frame.taskCount', { count: taskCount }, taskCount)
309
+ }}</span>
307
310
  </div>
308
311
  <button
309
312
  type="button"
@@ -311,9 +314,13 @@ const ITEM_ICON: Record<string, string> = {
311
314
  @click.stop="toggleExpand"
312
315
  >
313
316
  <UIcon name="i-lucide-layers" class="h-3 w-3 text-slate-400" />
314
- <span v-if="hasTasks">{{ mergedTasks }}/{{ taskCount }} merged</span>
315
- <span v-else>No tasks yet</span>
316
- <span v-if="prTasks" class="text-emerald-400">· {{ prTasks }} PR</span>
317
+ <span v-if="hasTasks">{{
318
+ t('board.frame.mergedOfTotal', { merged: mergedTasks, total: taskCount })
319
+ }}</span>
320
+ <span v-else>{{ t('board.frame.noTasksYet') }}</span>
321
+ <span v-if="prTasks" class="text-emerald-400"
322
+ >· {{ t('board.frame.prCount', { count: prTasks }) }}</span
323
+ >
317
324
  <UIcon name="i-lucide-chevron-down" class="ml-auto h-3 w-3" />
318
325
  </button>
319
326
  </div>
@@ -333,9 +340,14 @@ const ITEM_ICON: Record<string, string> = {
333
340
  name="i-lucide-loader-circle"
334
341
  class="h-4 w-4 shrink-0 animate-spin text-amber-400"
335
342
  />
336
- <span class="text-amber-300">Bootstrapping repository…</span>
343
+ <span class="text-amber-300">{{ t('board.frame.bootstrappingRepository') }}</span>
337
344
  <span v-if="bootstrapSubtasks" class="ml-auto text-amber-200/80">
338
- {{ bootstrapSubtasks.completed }}/{{ bootstrapSubtasks.total }} steps
345
+ {{
346
+ t('board.frame.bootstrapStepsCount', {
347
+ completed: bootstrapSubtasks.completed,
348
+ total: bootstrapSubtasks.total,
349
+ })
350
+ }}
339
351
  </span>
340
352
  </div>
341
353
  <div class="mt-1.5 h-1 w-full overflow-hidden rounded bg-amber-900/40">
@@ -389,7 +401,7 @@ const ITEM_ICON: Record<string, string> = {
389
401
  the gap). -->
390
402
  <div
391
403
  class="nopan cursor-grab touch-none space-y-3 pb-3 active:cursor-grabbing"
392
- title="Drag service"
404
+ :title="t('board.frame.dragService')"
393
405
  @pointerdown="onFrameHandle"
394
406
  >
395
407
  <div class="flex items-start justify-between gap-2">
@@ -420,7 +432,7 @@ const ITEM_ICON: Record<string, string> = {
420
432
  variant="ghost"
421
433
  color="neutral"
422
434
  icon="i-lucide-plus"
423
- title="Add task"
435
+ :title="t('board.frame.addTaskTitle')"
424
436
  @click.stop="addTask"
425
437
  />
426
438
  <UButton
@@ -430,7 +442,7 @@ const ITEM_ICON: Record<string, string> = {
430
442
  variant="ghost"
431
443
  color="neutral"
432
444
  icon="i-lucide-ticket"
433
- title="Create task from issue"
445
+ :title="t('board.frame.createTaskFromIssueTitle')"
434
446
  @click.stop="createTaskFromIssue"
435
447
  />
436
448
  <UButton
@@ -439,7 +451,7 @@ const ITEM_ICON: Record<string, string> = {
439
451
  variant="ghost"
440
452
  color="neutral"
441
453
  icon="i-lucide-repeat"
442
- title="Add recurring pipeline"
454
+ :title="t('board.frame.addRecurringTitle')"
443
455
  @click.stop="addRecurring"
444
456
  />
445
457
  <UButton
@@ -448,18 +460,22 @@ const ITEM_ICON: Record<string, string> = {
448
460
  variant="ghost"
449
461
  color="neutral"
450
462
  icon="i-lucide-chevron-up"
451
- title="Collapse"
463
+ :title="t('board.frame.collapseTitle')"
452
464
  @click.stop="toggleExpand"
453
465
  />
454
466
  </div>
455
467
  </div>
456
468
 
457
469
  <div class="flex items-center gap-2 text-[10px] uppercase tracking-wide text-slate-500">
458
- <span>{{ mergedTasks }}/{{ taskCount }} implemented</span>
470
+ <span>{{
471
+ t('board.frame.implemented', { merged: mergedTasks, total: taskCount })
472
+ }}</span>
459
473
  <span v-if="modules.length"
460
- >· {{ modules.length }} module{{ modules.length === 1 ? '' : 's' }}</span
474
+ >· {{ t('board.frame.moduleCount', { count: modules.length }, modules.length) }}</span
475
+ >
476
+ <span v-if="prTasks" class="text-emerald-400"
477
+ >· {{ t('board.frame.prReadyCount', { count: prTasks }) }}</span
461
478
  >
462
- <span v-if="prTasks" class="text-emerald-400">· {{ prTasks }} PR ready</span>
463
479
  </div>
464
480
  </div>
465
481
 
@@ -478,7 +494,7 @@ const ITEM_ICON: Record<string, string> = {
478
494
  class="absolute inset-4 flex items-center justify-center gap-1 rounded-lg border border-dashed border-slate-700 text-[11px] text-slate-500 hover:border-slate-500 hover:text-slate-300"
479
495
  @click.stop="addTask"
480
496
  >
481
- <UIcon name="i-lucide-plus" class="h-3.5 w-3.5" /> Add the first task
497
+ <UIcon name="i-lucide-plus" class="h-3.5 w-3.5" /> {{ t('board.frame.addFirstTask') }}
482
498
  </button>
483
499
 
484
500
  <!-- resize handles (drag the borders to resize the service, Miro-style).
@@ -486,17 +502,17 @@ const ITEM_ICON: Record<string, string> = {
486
502
  same reason as the header handle above. -->
487
503
  <div
488
504
  class="nodrag nopan absolute right-0 top-0 h-full w-2 cursor-ew-resize touch-none hover:bg-sky-400/20 pointer-coarse:w-4"
489
- title="Drag to resize"
505
+ :title="t('board.frame.dragToResize')"
490
506
  @pointerdown="onResize($event, 'e')"
491
507
  />
492
508
  <div
493
509
  class="nodrag nopan absolute bottom-0 left-0 h-2 w-full cursor-ns-resize touch-none hover:bg-sky-400/20 pointer-coarse:h-4"
494
- title="Drag to resize"
510
+ :title="t('board.frame.dragToResize')"
495
511
  @pointerdown="onResize($event, 's')"
496
512
  />
497
513
  <div
498
514
  class="nodrag nopan absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize touch-none pointer-coarse:h-11 pointer-coarse:w-11"
499
- title="Drag to resize"
515
+ :title="t('board.frame.dragToResize')"
500
516
  @pointerdown="onResize($event, 'se')"
501
517
  >
502
518
  <span
@@ -1,5 +1,5 @@
1
1
  <script setup lang="ts">
2
- withDefaults(
2
+ const props = withDefaults(
3
3
  defineProps<{
4
4
  count?: number
5
5
  compact?: boolean
@@ -7,9 +7,12 @@ withDefaults(
7
7
  label?: string
8
8
  icon?: string
9
9
  }>(),
10
- { label: 'Decision needed', icon: 'i-lucide-circle-help' },
10
+ { icon: 'i-lucide-circle-help' },
11
11
  )
12
12
  defineEmits<{ (e: 'open'): void }>()
13
+
14
+ const { t } = useI18n()
15
+ const displayLabel = computed(() => props.label ?? t('board.decisionBadge.decisionNeeded'))
13
16
  </script>
14
17
 
15
18
  <template>
@@ -19,7 +22,7 @@ defineEmits<{ (e: 'open'): void }>()
19
22
  @click.stop="$emit('open')"
20
23
  >
21
24
  <UIcon :name="icon" class="h-3.5 w-3.5" />
22
- <span v-if="!compact">{{ label }}</span>
25
+ <span v-if="!compact">{{ displayLabel }}</span>
23
26
  <span v-if="count && count > 1" class="rounded-full bg-amber-950/30 px-1">
24
27
  {{ count }}
25
28
  </span>
@@ -4,6 +4,7 @@ import { useBlockDrag } from '~/composables/useBlockDrag'
4
4
 
5
5
  const props = defineProps<{ taskId: string }>()
6
6
  const board = useBoardStore()
7
+ const { t } = useI18n()
7
8
  const expansion = useTaskExpansionStore()
8
9
  const task = computed(() => board.getBlock(props.taskId))
9
10
  const { draggingId, startDrag } = useBlockDrag()
@@ -42,7 +43,7 @@ function onHandle(e: PointerEvent) {
42
43
  <!-- drag handle (`nopan` so the pane doesn't pan on a left-drag from here) -->
43
44
  <div
44
45
  class="nodrag nopan flex cursor-grab touch-none items-center justify-center rounded-t-lg border border-b-0 border-slate-700 bg-slate-800/80 py-px active:cursor-grabbing pointer-coarse:py-2"
45
- title="Drag task"
46
+ :title="t('board.frame.dragTask')"
46
47
  @pointerdown="onHandle"
47
48
  >
48
49
  <UIcon
@@ -10,6 +10,7 @@ const props = defineProps<{ id: string }>()
10
10
 
11
11
  const board = useBoardStore()
12
12
  const ui = useUiStore()
13
+ const { t } = useI18n()
13
14
 
14
15
  const block = computed<Block | undefined>(() => board.getBlock(props.id))
15
16
  const members = computed(() => board.epicMembers(props.id))
@@ -31,7 +32,9 @@ const selected = computed(() => ui.selectedBlockId === props.id)
31
32
  >
32
33
  <div class="flex items-center gap-1.5">
33
34
  <UIcon name="i-lucide-layers" class="h-3.5 w-3.5 shrink-0 text-violet-400" />
34
- <span class="text-[10px] font-semibold uppercase tracking-wide text-violet-300">Epic</span>
35
+ <span class="text-[10px] font-semibold uppercase tracking-wide text-violet-300">{{
36
+ t('board.epic.label')
37
+ }}</span>
35
38
  <span class="ml-auto text-[10px] text-slate-400">{{ done }}/{{ total }}</span>
36
39
  </div>
37
40
  <div class="mt-1 truncate text-sm font-medium text-slate-100" :title="block.title">
@@ -43,7 +46,11 @@ const selected = computed(() => ui.selectedBlockId === props.id)
43
46
  :style="{ width: total ? `${Math.round((done / total) * 100)}%` : '0%' }"
44
47
  />
45
48
  </div>
46
- <div v-if="active" class="mt-1 text-[10px] text-slate-400">{{ active }} active</div>
47
- <div v-else-if="total === 0" class="mt-1 text-[10px] text-slate-500">No tasks yet</div>
49
+ <div v-if="active" class="mt-1 text-[10px] text-slate-400">
50
+ {{ t('board.epic.activeCount', { count: active }) }}
51
+ </div>
52
+ <div v-else-if="total === 0" class="mt-1 text-[10px] text-slate-500">
53
+ {{ t('board.epic.noTasksYet') }}
54
+ </div>
48
55
  </div>
49
56
  </template>
@@ -7,6 +7,7 @@ import { useFrameResize } from '~/composables/useFrameResize'
7
7
  const props = defineProps<{ moduleId: string }>()
8
8
  const board = useBoardStore()
9
9
  const ui = useUiStore()
10
+ const { t } = useI18n()
10
11
 
11
12
  const mod = computed(() => board.getBlock(props.moduleId))
12
13
  const tasks = computed(() => board.tasksOf(props.moduleId))
@@ -61,10 +62,10 @@ function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
61
62
  />
62
63
  <span class="truncate text-[11px] font-semibold text-violet-100">{{ mod.title }}</span>
63
64
  <span v-if="inflight" class="ml-auto shrink-0 text-[9px] text-violet-300/70">
64
- {{ inflight }} task{{ inflight === 1 ? '' : 's' }}
65
+ {{ t('board.frame.taskCount', { count: inflight }, inflight) }}
65
66
  </span>
66
67
  <span v-else-if="total" class="ml-auto shrink-0 text-[9px] text-violet-300/70">
67
- {{ total }} task{{ total === 1 ? '' : 's' }}
68
+ {{ t('board.frame.taskCount', { count: total }, total) }}
68
69
  </span>
69
70
  </div>
70
71
 
@@ -77,17 +78,17 @@ function onResize(e: PointerEvent, edge: 'e' | 's' | 'se') {
77
78
  `nopan` (with `nodrag`) so resizing doesn't pan the pane. -->
78
79
  <div
79
80
  class="nodrag nopan absolute right-0 top-0 h-full w-2 cursor-ew-resize touch-none hover:bg-violet-400/20 pointer-coarse:w-4"
80
- title="Drag to resize"
81
+ :title="t('board.frame.dragToResize')"
81
82
  @pointerdown="onResize($event, 'e')"
82
83
  />
83
84
  <div
84
85
  class="nodrag nopan absolute bottom-0 left-0 h-2 w-full cursor-ns-resize touch-none hover:bg-violet-400/20 pointer-coarse:h-4"
85
- title="Drag to resize"
86
+ :title="t('board.frame.dragToResize')"
86
87
  @pointerdown="onResize($event, 's')"
87
88
  />
88
89
  <div
89
90
  class="nodrag nopan absolute bottom-0 right-0 h-4 w-4 cursor-nwse-resize touch-none pointer-coarse:h-11 pointer-coarse:w-11"
90
- title="Drag to resize"
91
+ :title="t('board.frame.dragToResize')"
91
92
  @pointerdown="onResize($event, 'se')"
92
93
  >
93
94
  <span