@cat-factory/app 0.75.2 → 0.76.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,10 @@
1
1
  <script setup lang="ts">
2
- import { computed, onMounted, onUnmounted, watch } from 'vue'
2
+ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
3
3
  import type {
4
4
  Block,
5
5
  FrontendBackendBinding,
6
6
  FrontendConfig,
7
+ FrontendConfigRecommendation,
7
8
  FrontendEnvInjection,
8
9
  FrontendPackageManager,
9
10
  FrontendServeMode,
@@ -15,12 +16,16 @@ import type {
15
16
  // and its backend bindings. Each binding names an env var the frontend reads for an upstream
16
17
  // URL and where that URL resolves — a bound SERVICE frame's ephemeral env (the service under
17
18
  // test), or WireMock. The bindings ARE the board's frontend→service links. Persisted as a
18
- // serialized FrontendConfig on the block via the shared updateBlock PATCH.
19
+ // serialized FrontendConfig on the block via the shared updateBlock PATCH. The fields are grouped
20
+ // into collapsible sections (all collapsed by default), with a repo-driven "Detect from repo"
21
+ // affordance that proposes most of them for the user to review + apply.
19
22
  const props = defineProps<{ block: Block }>()
20
23
 
21
24
  const board = useBoardStore()
22
25
  const auth = useAuthStore()
23
26
  const preview = usePreviewStore()
27
+ const infra = useInfraConfigStore()
28
+ const github = useGitHubStore()
24
29
  const { t } = useI18n()
25
30
 
26
31
  // A browsable preview needs a long-lived host serve, so it is a local/node runtime capability
@@ -97,6 +102,77 @@ function removeBinding(index: number) {
97
102
  save({ backendBindings: bindings.value.filter((_, i) => i !== index) })
98
103
  }
99
104
 
105
+ // ---- Collapsible groups (all collapsed by default) --------------------------------------
106
+ const showBuild = ref(false)
107
+ const showServe = ref(false)
108
+ const showMocking = ref(false)
109
+ const showEnv = ref(false)
110
+ const showBindings = ref(false)
111
+ const showPreview = ref(false)
112
+
113
+ // ---- Detect from repo (preview → apply) --------------------------------------------------
114
+ // Read the frontend repo checkout-free and propose a NON-BINDING recommended config. The result
115
+ // is held as a preview the user reviews; NOTHING is persisted until they click Apply. The frontend
116
+ // frame links to its repo the same way a service does (`github_repos.block_id`).
117
+ const repoLink = computed(() => github.repoForBlock(props.block.id))
118
+ const detecting = ref(false)
119
+ const detectError = ref(false)
120
+ const detectResult = ref<FrontendConfigRecommendation | null>(null)
121
+
122
+ // A detection result is scoped to the inspected block — clear it (and any error) when the
123
+ // selection changes, so block B never shows block A's stale recommendation.
124
+ watch(
125
+ () => props.block.id,
126
+ () => {
127
+ detectResult.value = null
128
+ detectError.value = false
129
+ },
130
+ )
131
+
132
+ async function detectFromRepo() {
133
+ const repo = repoLink.value
134
+ if (!repo) {
135
+ detectError.value = true
136
+ return
137
+ }
138
+ detecting.value = true
139
+ detectError.value = false
140
+ detectResult.value = null
141
+ try {
142
+ const result = await infra.detectFrontendConfig({
143
+ owner: repo.owner,
144
+ repo: repo.name,
145
+ ...(config.value.directory ? { directory: config.value.directory } : {}),
146
+ })
147
+ detectResult.value = result
148
+ // When nothing was detected the "none" hint tells the user to set the frontend directory —
149
+ // that field lives in the (collapsed) Build group, so open it so the advice is actionable.
150
+ if (!result.detected) showBuild.value = true
151
+ } catch {
152
+ detectError.value = true
153
+ } finally {
154
+ detecting.value = false
155
+ }
156
+ }
157
+
158
+ // Apply the recommendation: overlay the proposed scalar fields, but APPEND backend bindings
159
+ // (never overwrite existing rows or their `source` — that would wipe configured service links).
160
+ // Only env-var names not already present are added.
161
+ function applyDetection() {
162
+ const rec = detectResult.value
163
+ if (!rec) return
164
+ const { backendBindings: proposed, ...rest } = rec.config
165
+ const existing = bindings.value
166
+ const existingNames = new Set(existing.map((b) => b.envVar.trim()).filter(Boolean))
167
+ const added = (proposed ?? []).filter((b) => b.envVar && !existingNames.has(b.envVar.trim()))
168
+ save({ ...rest, backendBindings: [...existing, ...added] })
169
+ // Open the groups so the user sees what changed.
170
+ showBuild.value = true
171
+ showServe.value = true
172
+ if (added.length) showBindings.value = true
173
+ detectResult.value = null
174
+ }
175
+
100
176
  // ---- Browsable preview (live runtime state, distinct from the persisted toggle) ----------
101
177
  // The live preview is a separate resource fetched from the preview endpoints; the store keys it
102
178
  // by frame id and self-polls while it is `starting`. Only relevant on a preview-capable runtime
@@ -140,139 +216,290 @@ onUnmounted(() => preview.stopPolling(props.block.id))
140
216
  {{ t('inspector.frontendConfig.hint') }}
141
217
  </p>
142
218
 
143
- <!-- Package manager -->
144
- <div class="space-y-1">
145
- <span class="text-[11px] text-slate-400">{{
146
- t('inspector.frontendConfig.packageManager')
147
- }}</span>
148
- <div class="flex flex-wrap gap-1">
219
+ <!-- Detect from repo: propose a config the user reviews + applies. Non-binding — nothing is
220
+ persisted until Apply. Only shown when the frame is linked to a repo. -->
221
+ <div
222
+ v-if="repoLink"
223
+ class="space-y-2 rounded border border-slate-800 bg-slate-900/40 p-2"
224
+ data-testid="frontend-detect"
225
+ >
226
+ <div class="flex items-center justify-between gap-2">
227
+ <span class="text-[11px] text-slate-400">{{
228
+ t('inspector.frontendConfig.detect.title')
229
+ }}</span>
149
230
  <UButton
150
- v-for="pm in PACKAGE_MANAGERS"
151
- :key="pm"
152
- :color="packageManager === pm ? 'primary' : 'neutral'"
153
- :variant="packageManager === pm ? 'soft' : 'ghost'"
154
231
  size="xs"
155
- @click="save({ packageManager: pm })"
232
+ variant="soft"
233
+ color="primary"
234
+ icon="i-lucide-wand-sparkles"
235
+ :loading="detecting"
236
+ data-testid="frontend-detect-button"
237
+ @click="detectFromRepo"
156
238
  >
157
- {{ pm }}
239
+ {{ t('inspector.frontendConfig.detect.button') }}
158
240
  </UButton>
159
241
  </div>
160
- </div>
242
+ <p class="text-[11px] leading-snug text-slate-500">
243
+ {{ t('inspector.frontendConfig.detect.hint') }}
244
+ </p>
245
+
246
+ <p v-if="detectError" class="text-[11px] text-rose-300/80">
247
+ {{ t('inspector.frontendConfig.detect.error') }}
248
+ </p>
249
+
250
+ <template v-if="detectResult && !detecting">
251
+ <p v-if="!detectResult.detected" class="text-[11px] text-amber-300/80">
252
+ {{ t('inspector.frontendConfig.detect.none') }}
253
+ </p>
161
254
 
162
- <!-- Build: install command + build script + output dir -->
163
- <div class="space-y-1">
164
- <label class="text-[11px] text-slate-400">{{
165
- t('inspector.frontendConfig.installCommand')
166
- }}</label>
167
- <UInput
168
- :model-value="config.installCommand ?? ''"
169
- size="xs"
170
- class="font-mono"
171
- maxlength="400"
172
- placeholder="pnpm install --frozen-lockfile"
173
- @blur="(e: FocusEvent) => saveText('installCommand', (e.target as HTMLInputElement).value)"
174
- @keydown.enter="
175
- (e: KeyboardEvent) => saveText('installCommand', (e.target as HTMLInputElement).value)
176
- "
177
- />
255
+ <ul v-if="detectResult.notes.length" class="space-y-0.5">
256
+ <li
257
+ v-for="(n, i) in detectResult.notes"
258
+ :key="i"
259
+ class="flex items-start gap-1.5 text-[11px] leading-snug text-slate-500"
260
+ >
261
+ <span :class="n.confidence === 'high' ? 'text-emerald-400/70' : 'text-amber-400/70'">
262
+ {{
263
+ n.confidence === 'high'
264
+ ? t('inspector.frontendConfig.detect.confidenceHigh')
265
+ : t('inspector.frontendConfig.detect.confidenceLow')
266
+ }}
267
+ </span>
268
+ <span>{{ n.message }}</span>
269
+ </li>
270
+ </ul>
271
+
272
+ <div v-if="detectResult.detected" class="flex flex-wrap gap-1">
273
+ <UButton
274
+ size="xs"
275
+ variant="soft"
276
+ color="primary"
277
+ icon="i-lucide-check"
278
+ data-testid="frontend-detect-apply"
279
+ @click="applyDetection"
280
+ >
281
+ {{ t('inspector.frontendConfig.detect.apply') }}
282
+ </UButton>
283
+ <UButton size="xs" variant="ghost" color="neutral" @click="detectResult = null">
284
+ {{ t('inspector.frontendConfig.detect.dismiss') }}
285
+ </UButton>
286
+ </div>
287
+ </template>
178
288
  </div>
179
289
 
180
- <div class="grid grid-cols-2 gap-2">
181
- <div class="space-y-1">
182
- <label class="text-[11px] text-slate-400">{{
183
- t('inspector.frontendConfig.buildScript')
184
- }}</label>
185
- <UInput
186
- :model-value="config.buildScript ?? ''"
187
- size="xs"
188
- class="font-mono"
189
- maxlength="200"
190
- placeholder="build"
191
- @blur="(e: FocusEvent) => saveText('buildScript', (e.target as HTMLInputElement).value)"
192
- @keydown.enter="
193
- (e: KeyboardEvent) => saveText('buildScript', (e.target as HTMLInputElement).value)
194
- "
195
- />
196
- </div>
197
- <div class="space-y-1">
198
- <label class="text-[11px] text-slate-400">{{
199
- t('inspector.frontendConfig.outputDir')
200
- }}</label>
201
- <UInput
202
- :model-value="config.outputDir ?? ''"
203
- size="xs"
204
- class="font-mono"
205
- maxlength="400"
206
- placeholder="dist"
207
- @blur="(e: FocusEvent) => saveText('outputDir', (e.target as HTMLInputElement).value)"
208
- @keydown.enter="
209
- (e: KeyboardEvent) => saveText('outputDir', (e.target as HTMLInputElement).value)
210
- "
290
+ <!-- Build: package manager + frontend directory + install command + build script + output dir -->
291
+ <div class="border-t border-slate-800 pt-2">
292
+ <button
293
+ type="button"
294
+ class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
295
+ @click="showBuild = !showBuild"
296
+ >
297
+ <UIcon
298
+ :name="showBuild ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
299
+ class="h-3.5 w-3.5"
211
300
  />
301
+ {{ t('inspector.frontendConfig.groups.build') }}
302
+ </button>
303
+
304
+ <div v-if="showBuild" class="mt-2 space-y-3">
305
+ <div class="space-y-1">
306
+ <span class="text-[11px] text-slate-400">{{
307
+ t('inspector.frontendConfig.packageManager')
308
+ }}</span>
309
+ <div class="flex flex-wrap gap-1">
310
+ <UButton
311
+ v-for="pm in PACKAGE_MANAGERS"
312
+ :key="pm"
313
+ :color="packageManager === pm ? 'primary' : 'neutral'"
314
+ :variant="packageManager === pm ? 'soft' : 'ghost'"
315
+ size="xs"
316
+ @click="save({ packageManager: pm })"
317
+ >
318
+ {{ pm }}
319
+ </UButton>
320
+ </div>
321
+ </div>
322
+
323
+ <div class="space-y-1">
324
+ <label class="text-[11px] text-slate-400">{{
325
+ t('inspector.frontendConfig.directory')
326
+ }}</label>
327
+ <UInput
328
+ :model-value="config.directory ?? ''"
329
+ size="xs"
330
+ class="font-mono"
331
+ maxlength="400"
332
+ placeholder="frontend/"
333
+ @blur="(e: FocusEvent) => saveText('directory', (e.target as HTMLInputElement).value)"
334
+ @keydown.enter="
335
+ (e: KeyboardEvent) => saveText('directory', (e.target as HTMLInputElement).value)
336
+ "
337
+ />
338
+ <p class="text-[11px] leading-snug text-slate-500">
339
+ {{ t('inspector.frontendConfig.directoryHint') }}
340
+ </p>
341
+ </div>
342
+
343
+ <div class="space-y-1">
344
+ <label class="text-[11px] text-slate-400">{{
345
+ t('inspector.frontendConfig.installCommand')
346
+ }}</label>
347
+ <UInput
348
+ :model-value="config.installCommand ?? ''"
349
+ size="xs"
350
+ class="font-mono"
351
+ maxlength="400"
352
+ placeholder="pnpm install --frozen-lockfile"
353
+ @blur="
354
+ (e: FocusEvent) => saveText('installCommand', (e.target as HTMLInputElement).value)
355
+ "
356
+ @keydown.enter="
357
+ (e: KeyboardEvent) => saveText('installCommand', (e.target as HTMLInputElement).value)
358
+ "
359
+ />
360
+ </div>
361
+
362
+ <div class="grid grid-cols-2 gap-2">
363
+ <div class="space-y-1">
364
+ <label class="text-[11px] text-slate-400">{{
365
+ t('inspector.frontendConfig.buildScript')
366
+ }}</label>
367
+ <UInput
368
+ :model-value="config.buildScript ?? ''"
369
+ size="xs"
370
+ class="font-mono"
371
+ maxlength="200"
372
+ placeholder="build"
373
+ @blur="
374
+ (e: FocusEvent) => saveText('buildScript', (e.target as HTMLInputElement).value)
375
+ "
376
+ @keydown.enter="
377
+ (e: KeyboardEvent) => saveText('buildScript', (e.target as HTMLInputElement).value)
378
+ "
379
+ />
380
+ </div>
381
+ <div class="space-y-1">
382
+ <label class="text-[11px] text-slate-400">{{
383
+ t('inspector.frontendConfig.outputDir')
384
+ }}</label>
385
+ <UInput
386
+ :model-value="config.outputDir ?? ''"
387
+ size="xs"
388
+ class="font-mono"
389
+ maxlength="400"
390
+ placeholder="dist"
391
+ @blur="(e: FocusEvent) => saveText('outputDir', (e.target as HTMLInputElement).value)"
392
+ @keydown.enter="
393
+ (e: KeyboardEvent) => saveText('outputDir', (e.target as HTMLInputElement).value)
394
+ "
395
+ />
396
+ </div>
397
+ </div>
212
398
  </div>
213
399
  </div>
214
400
 
215
401
  <!-- Serve: mode (static vs command) + serve script (command mode) + port -->
216
- <div class="space-y-1">
217
- <span class="text-[11px] text-slate-400">{{ t('inspector.frontendConfig.serveMode') }}</span>
218
- <div class="flex flex-wrap gap-1">
219
- <UButton
220
- :color="serveMode === 'static' ? 'primary' : 'neutral'"
221
- :variant="serveMode === 'static' ? 'soft' : 'ghost'"
222
- size="xs"
223
- @click="save({ serveMode: 'static' })"
224
- >
225
- {{ t('inspector.frontendConfig.serveStatic') }}
226
- </UButton>
227
- <UButton
228
- :color="serveMode === 'command' ? 'primary' : 'neutral'"
229
- :variant="serveMode === 'command' ? 'soft' : 'ghost'"
230
- size="xs"
231
- @click="save({ serveMode: 'command' })"
232
- >
233
- {{ t('inspector.frontendConfig.serveCommand') }}
234
- </UButton>
235
- </div>
236
- <p class="text-[11px] leading-snug text-slate-500">
237
- {{ t('inspector.frontendConfig.serveModeHint') }}
238
- </p>
239
- </div>
402
+ <div class="border-t border-slate-800 pt-2">
403
+ <button
404
+ type="button"
405
+ class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
406
+ @click="showServe = !showServe"
407
+ >
408
+ <UIcon
409
+ :name="showServe ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
410
+ class="h-3.5 w-3.5"
411
+ />
412
+ {{ t('inspector.frontendConfig.groups.serve') }}
413
+ </button>
414
+
415
+ <div v-if="showServe" class="mt-2 space-y-3">
416
+ <div class="space-y-1">
417
+ <span class="text-[11px] text-slate-400">{{
418
+ t('inspector.frontendConfig.serveMode')
419
+ }}</span>
420
+ <div class="flex flex-wrap gap-1">
421
+ <UButton
422
+ :color="serveMode === 'static' ? 'primary' : 'neutral'"
423
+ :variant="serveMode === 'static' ? 'soft' : 'ghost'"
424
+ size="xs"
425
+ @click="save({ serveMode: 'static' })"
426
+ >
427
+ {{ t('inspector.frontendConfig.serveStatic') }}
428
+ </UButton>
429
+ <UButton
430
+ :color="serveMode === 'command' ? 'primary' : 'neutral'"
431
+ :variant="serveMode === 'command' ? 'soft' : 'ghost'"
432
+ size="xs"
433
+ @click="save({ serveMode: 'command' })"
434
+ >
435
+ {{ t('inspector.frontendConfig.serveCommand') }}
436
+ </UButton>
437
+ </div>
438
+ <!-- Explain each mode, and disambiguate from the separate envInjection axis. -->
439
+ <p class="text-[11px] leading-snug text-slate-500">
440
+ <span class="text-slate-400">{{ t('inspector.frontendConfig.serveStatic') }}:</span>
441
+ {{ t('inspector.frontendConfig.serveStaticDesc') }}
442
+ </p>
443
+ <p class="text-[11px] leading-snug text-slate-500">
444
+ <span class="text-slate-400">{{ t('inspector.frontendConfig.serveCommand') }}:</span>
445
+ {{ t('inspector.frontendConfig.serveCommandDesc') }}
446
+ </p>
447
+ <p class="text-[11px] leading-snug text-slate-500/80">
448
+ {{ t('inspector.frontendConfig.serveEnvAxisNote') }}
449
+ </p>
450
+ </div>
240
451
 
241
- <div v-if="serveMode === 'command'" class="space-y-1">
242
- <label class="text-[11px] text-slate-400">{{
243
- t('inspector.frontendConfig.serveScript')
244
- }}</label>
245
- <UInput
246
- :model-value="config.serveScript ?? ''"
247
- size="xs"
248
- class="font-mono"
249
- maxlength="200"
250
- placeholder="preview"
251
- @blur="(e: FocusEvent) => saveText('serveScript', (e.target as HTMLInputElement).value)"
252
- @keydown.enter="
253
- (e: KeyboardEvent) => saveText('serveScript', (e.target as HTMLInputElement).value)
254
- "
255
- />
452
+ <div v-if="serveMode === 'command'" class="space-y-1">
453
+ <label class="text-[11px] text-slate-400">{{
454
+ t('inspector.frontendConfig.serveScript')
455
+ }}</label>
456
+ <UInput
457
+ :model-value="config.serveScript ?? ''"
458
+ size="xs"
459
+ class="font-mono"
460
+ maxlength="200"
461
+ placeholder="preview"
462
+ @blur="(e: FocusEvent) => saveText('serveScript', (e.target as HTMLInputElement).value)"
463
+ @keydown.enter="
464
+ (e: KeyboardEvent) => saveText('serveScript', (e.target as HTMLInputElement).value)
465
+ "
466
+ />
467
+ </div>
468
+
469
+ <div class="space-y-1">
470
+ <label class="text-[11px] text-slate-400">{{
471
+ t('inspector.frontendConfig.servePort')
472
+ }}</label>
473
+ <UInput
474
+ :model-value="config.servePort != null ? String(config.servePort) : ''"
475
+ type="number"
476
+ min="1"
477
+ max="65535"
478
+ step="1"
479
+ size="xs"
480
+ class="font-mono"
481
+ placeholder="4173"
482
+ @blur="(e: FocusEvent) => saveServePort((e.target as HTMLInputElement).value)"
483
+ />
484
+ </div>
485
+ </div>
256
486
  </div>
257
487
 
258
- <div class="grid grid-cols-2 gap-2">
259
- <div class="space-y-1">
260
- <label class="text-[11px] text-slate-400">{{
261
- t('inspector.frontendConfig.servePort')
262
- }}</label>
263
- <UInput
264
- :model-value="config.servePort != null ? String(config.servePort) : ''"
265
- type="number"
266
- min="1"
267
- max="65535"
268
- step="1"
269
- size="xs"
270
- class="font-mono"
271
- placeholder="4173"
272
- @blur="(e: FocusEvent) => saveServePort((e.target as HTMLInputElement).value)"
488
+ <!-- Mocking: WireMock mappings dir -->
489
+ <div class="border-t border-slate-800 pt-2">
490
+ <button
491
+ type="button"
492
+ class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
493
+ @click="showMocking = !showMocking"
494
+ >
495
+ <UIcon
496
+ :name="showMocking ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
497
+ class="h-3.5 w-3.5"
273
498
  />
274
- </div>
275
- <div class="space-y-1">
499
+ {{ t('inspector.frontendConfig.groups.mocking') }}
500
+ </button>
501
+
502
+ <div v-if="showMocking" class="mt-2 space-y-1">
276
503
  <label class="text-[11px] text-slate-400">{{
277
504
  t('inspector.frontendConfig.mockMappingsPath')
278
505
  }}</label>
@@ -289,185 +516,225 @@ onUnmounted(() => preview.stopPolling(props.block.id))
289
516
  (e: KeyboardEvent) => saveText('mockMappingsPath', (e.target as HTMLInputElement).value)
290
517
  "
291
518
  />
292
- <p class="col-span-2 text-[11px] leading-snug text-slate-500">
519
+ <p class="text-[11px] leading-snug text-slate-500">
293
520
  {{ t('inspector.frontendConfig.mockMappingsHint') }}
294
521
  </p>
295
522
  </div>
296
523
  </div>
297
524
 
298
525
  <!-- Env injection: build-time env vars vs a runtime window.env shim -->
299
- <div class="space-y-1">
300
- <span class="text-[11px] text-slate-400">{{
301
- t('inspector.frontendConfig.envInjection')
302
- }}</span>
303
- <div class="flex flex-wrap gap-1">
304
- <UButton
305
- :color="envInjection === 'build' ? 'primary' : 'neutral'"
306
- :variant="envInjection === 'build' ? 'soft' : 'ghost'"
307
- size="xs"
308
- @click="save({ envInjection: 'build' })"
309
- >
310
- {{ t('inspector.frontendConfig.envBuild') }}
311
- </UButton>
312
- <UButton
313
- :color="envInjection === 'runtime' ? 'primary' : 'neutral'"
314
- :variant="envInjection === 'runtime' ? 'soft' : 'ghost'"
315
- size="xs"
316
- @click="save({ envInjection: 'runtime' })"
317
- >
318
- {{ t('inspector.frontendConfig.envRuntime') }}
319
- </UButton>
526
+ <div class="border-t border-slate-800 pt-2">
527
+ <button
528
+ type="button"
529
+ class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
530
+ @click="showEnv = !showEnv"
531
+ >
532
+ <UIcon
533
+ :name="showEnv ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
534
+ class="h-3.5 w-3.5"
535
+ />
536
+ {{ t('inspector.frontendConfig.groups.envInjection') }}
537
+ </button>
538
+
539
+ <div v-if="showEnv" class="mt-2 space-y-1">
540
+ <div class="flex flex-wrap gap-1">
541
+ <UButton
542
+ :color="envInjection === 'build' ? 'primary' : 'neutral'"
543
+ :variant="envInjection === 'build' ? 'soft' : 'ghost'"
544
+ size="xs"
545
+ @click="save({ envInjection: 'build' })"
546
+ >
547
+ {{ t('inspector.frontendConfig.envBuild') }}
548
+ </UButton>
549
+ <UButton
550
+ :color="envInjection === 'runtime' ? 'primary' : 'neutral'"
551
+ :variant="envInjection === 'runtime' ? 'soft' : 'ghost'"
552
+ size="xs"
553
+ @click="save({ envInjection: 'runtime' })"
554
+ >
555
+ {{ t('inspector.frontendConfig.envRuntime') }}
556
+ </UButton>
557
+ </div>
558
+ <p class="text-[11px] leading-snug text-slate-500">
559
+ {{ t('inspector.frontendConfig.envInjectionHint') }}
560
+ </p>
320
561
  </div>
321
- <p class="text-[11px] leading-snug text-slate-500">
322
- {{ t('inspector.frontendConfig.envInjectionHint') }}
323
- </p>
324
562
  </div>
325
563
 
326
564
  <!-- Backend bindings: env var → upstream. These double as the board's frontend→service links. -->
327
- <div class="space-y-2 border-t border-slate-800 pt-2">
328
- <div class="flex items-center justify-between">
329
- <span class="text-[11px] font-semibold uppercase tracking-wide text-slate-400">
330
- {{ t('inspector.frontendConfig.bindings.title') }}
331
- </span>
332
- <UButton
333
- size="xs"
334
- variant="ghost"
335
- color="neutral"
336
- icon="i-lucide-plus"
337
- @click="addBinding"
565
+ <div class="border-t border-slate-800 pt-2">
566
+ <button
567
+ type="button"
568
+ class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
569
+ @click="showBindings = !showBindings"
570
+ >
571
+ <UIcon
572
+ :name="showBindings ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
573
+ class="h-3.5 w-3.5"
338
574
  />
339
- </div>
340
- <p class="text-[11px] leading-snug text-slate-500">
341
- {{ t('inspector.frontendConfig.bindings.hint') }}
342
- </p>
575
+ {{ t('inspector.frontendConfig.groups.bindings') }}
576
+ <span v-if="bindings.length" class="font-normal normal-case text-slate-600"
577
+ >({{ bindings.length }})</span
578
+ >
579
+ </button>
343
580
 
344
- <div v-if="bindings.length" class="space-y-1.5">
345
- <div v-for="(b, i) in bindings" :key="i" class="flex items-center gap-1">
346
- <UInput
347
- :model-value="b.envVar"
348
- size="xs"
349
- class="flex-1 font-mono"
350
- maxlength="200"
351
- placeholder="PUB_BACKEND_URL"
352
- @blur="(e: FocusEvent) => setBindingEnvVar(i, (e.target as HTMLInputElement).value)"
353
- @keydown.enter="
354
- (e: KeyboardEvent) => setBindingEnvVar(i, (e.target as HTMLInputElement).value)
355
- "
356
- />
357
- <USelect
358
- :model-value="sourceValue(b)"
359
- :items="sourceItems"
360
- size="xs"
361
- class="flex-1"
362
- @update:model-value="(v: string) => setBindingSource(i, v)"
363
- />
581
+ <div v-if="showBindings" class="mt-2 space-y-2">
582
+ <div class="flex items-center justify-between">
583
+ <p class="text-[11px] leading-snug text-slate-500">
584
+ {{ t('inspector.frontendConfig.bindings.hint') }}
585
+ </p>
364
586
  <UButton
365
587
  size="xs"
366
588
  variant="ghost"
367
589
  color="neutral"
368
- icon="i-lucide-x"
369
- :title="t('inspector.frontendConfig.bindings.remove')"
370
- @click="removeBinding(i)"
590
+ icon="i-lucide-plus"
591
+ @click="addBinding"
371
592
  />
372
593
  </div>
373
- </div>
374
- <div v-else class="text-[11px] text-slate-500">
375
- {{ t('inspector.frontendConfig.bindings.empty') }}
594
+
595
+ <div v-if="bindings.length" class="space-y-1.5">
596
+ <div v-for="(b, i) in bindings" :key="i" class="flex items-center gap-1">
597
+ <UInput
598
+ :model-value="b.envVar"
599
+ size="xs"
600
+ class="flex-1 font-mono"
601
+ maxlength="200"
602
+ placeholder="PUB_BACKEND_URL"
603
+ @blur="(e: FocusEvent) => setBindingEnvVar(i, (e.target as HTMLInputElement).value)"
604
+ @keydown.enter="
605
+ (e: KeyboardEvent) => setBindingEnvVar(i, (e.target as HTMLInputElement).value)
606
+ "
607
+ />
608
+ <USelect
609
+ :model-value="sourceValue(b)"
610
+ :items="sourceItems"
611
+ size="xs"
612
+ class="flex-1"
613
+ @update:model-value="(v: string) => setBindingSource(i, v)"
614
+ />
615
+ <UButton
616
+ size="xs"
617
+ variant="ghost"
618
+ color="neutral"
619
+ icon="i-lucide-x"
620
+ :title="t('inspector.frontendConfig.bindings.remove')"
621
+ @click="removeBinding(i)"
622
+ />
623
+ </div>
624
+ </div>
625
+ <div v-else class="text-[11px] text-slate-500">
626
+ {{ t('inspector.frontendConfig.bindings.empty') }}
627
+ </div>
376
628
  </div>
377
629
  </div>
378
630
 
379
631
  <!-- Browsable preview (local/node only; the Worker reports it unsupported). -->
380
632
  <div class="border-t border-slate-800 pt-2">
381
- <UCheckbox
382
- :model-value="previewSupported && config.previewEnabled === true"
383
- :label="t('inspector.frontendConfig.previewEnabled')"
384
- :disabled="!previewSupported"
385
- size="xs"
386
- @update:model-value="
387
- (v: boolean | 'indeterminate') => save({ previewEnabled: v === true ? true : undefined })
388
- "
389
- />
390
- <p class="mt-1 text-[11px] leading-snug text-slate-500">
391
- {{
392
- previewSupported
393
- ? t('inspector.frontendConfig.previewHint')
394
- : t('inspector.frontendConfig.previewUnsupported')
395
- }}
396
- </p>
633
+ <button
634
+ type="button"
635
+ class="flex w-full items-center gap-1.5 text-start text-[11px] font-semibold uppercase tracking-wide text-slate-500 hover:text-slate-300"
636
+ @click="showPreview = !showPreview"
637
+ >
638
+ <UIcon
639
+ :name="showPreview ? 'i-lucide-chevron-down' : 'i-lucide-chevron-right'"
640
+ class="h-3.5 w-3.5"
641
+ />
642
+ {{ t('inspector.frontendConfig.groups.preview') }}
643
+ </button>
644
+
645
+ <div v-if="showPreview" class="mt-2">
646
+ <UCheckbox
647
+ :model-value="previewSupported && config.previewEnabled === true"
648
+ :label="t('inspector.frontendConfig.previewEnabled')"
649
+ :disabled="!previewSupported"
650
+ size="xs"
651
+ @update:model-value="
652
+ (v: boolean | 'indeterminate') =>
653
+ save({ previewEnabled: v === true ? true : undefined })
654
+ "
655
+ />
656
+ <p class="mt-1 text-[11px] leading-snug text-slate-500">
657
+ {{
658
+ previewSupported
659
+ ? t('inspector.frontendConfig.previewHint')
660
+ : t('inspector.frontendConfig.previewUnsupported')
661
+ }}
662
+ </p>
397
663
 
398
- <!-- Live preview control: start / open (clickable URL) / stop, reflecting the runtime state. -->
399
- <div v-if="previewActive" class="mt-2 space-y-1.5" data-testid="preview-panel">
400
- <div class="flex items-center gap-2">
401
- <span
402
- class="text-[11px] font-medium"
403
- :class="{
404
- 'text-emerald-400': previewStatus === 'ready',
405
- 'text-amber-400': previewStatus === 'starting',
406
- 'text-rose-400': previewStatus === 'failed',
407
- 'text-slate-400': previewStatus === 'stopped',
408
- }"
409
- data-testid="preview-status"
664
+ <!-- Live preview control: start / open (clickable URL) / stop, reflecting the runtime state. -->
665
+ <div v-if="previewActive" class="mt-2 space-y-1.5" data-testid="preview-panel">
666
+ <div class="flex items-center gap-2">
667
+ <span
668
+ class="text-[11px] font-medium"
669
+ :class="{
670
+ 'text-emerald-400': previewStatus === 'ready',
671
+ 'text-amber-400': previewStatus === 'starting',
672
+ 'text-rose-400': previewStatus === 'failed',
673
+ 'text-slate-400': previewStatus === 'stopped',
674
+ }"
675
+ data-testid="preview-status"
676
+ >
677
+ {{ previewStatusLabel }}
678
+ </span>
679
+
680
+ <UButton
681
+ v-if="previewStatus === 'ready' && previewState?.url"
682
+ :to="previewState.url"
683
+ target="_blank"
684
+ rel="noopener"
685
+ size="xs"
686
+ variant="soft"
687
+ color="primary"
688
+ trailing-icon="i-lucide-external-link"
689
+ data-testid="preview-url"
690
+ >
691
+ {{ t('inspector.frontendConfig.preview.open') }}
692
+ </UButton>
693
+ </div>
694
+
695
+ <div class="flex flex-wrap gap-1">
696
+ <UButton
697
+ v-if="previewStatus === 'stopped' || previewStatus === 'failed'"
698
+ size="xs"
699
+ variant="soft"
700
+ color="primary"
701
+ icon="i-lucide-play"
702
+ :loading="previewBusy"
703
+ data-testid="preview-start"
704
+ @click="preview.start(props.block.id)"
705
+ >
706
+ {{ t('inspector.frontendConfig.preview.start') }}
707
+ </UButton>
708
+ <UButton
709
+ v-if="previewStatus === 'ready' || previewStatus === 'starting'"
710
+ size="xs"
711
+ variant="ghost"
712
+ color="neutral"
713
+ icon="i-lucide-square"
714
+ :loading="previewBusy"
715
+ data-testid="preview-stop"
716
+ @click="preview.stop(props.block.id)"
717
+ >
718
+ {{ t('inspector.frontendConfig.preview.stop') }}
719
+ </UButton>
720
+ </div>
721
+
722
+ <p
723
+ v-if="previewStatus === 'failed' && previewState?.error"
724
+ class="text-[11px] leading-snug text-rose-400"
725
+ data-testid="preview-error"
410
726
  >
411
- {{ previewStatusLabel }}
412
- </span>
727
+ {{ previewState.error }}
728
+ </p>
413
729
 
414
- <UButton
415
- v-if="previewStatus === 'ready' && previewState?.url"
416
- :to="previewState.url"
417
- target="_blank"
418
- rel="noopener"
419
- size="xs"
420
- variant="soft"
421
- color="primary"
422
- trailing-icon="i-lucide-external-link"
423
- data-testid="preview-url"
730
+ <p
731
+ v-if="previewRequestError"
732
+ class="text-[11px] leading-snug text-rose-400"
733
+ data-testid="preview-request-error"
424
734
  >
425
- {{ t('inspector.frontendConfig.preview.open') }}
426
- </UButton>
735
+ {{ previewRequestError }}
736
+ </p>
427
737
  </div>
428
-
429
- <div class="flex flex-wrap gap-1">
430
- <UButton
431
- v-if="previewStatus === 'stopped' || previewStatus === 'failed'"
432
- size="xs"
433
- variant="soft"
434
- color="primary"
435
- icon="i-lucide-play"
436
- :loading="previewBusy"
437
- data-testid="preview-start"
438
- @click="preview.start(props.block.id)"
439
- >
440
- {{ t('inspector.frontendConfig.preview.start') }}
441
- </UButton>
442
- <UButton
443
- v-if="previewStatus === 'ready' || previewStatus === 'starting'"
444
- size="xs"
445
- variant="ghost"
446
- color="neutral"
447
- icon="i-lucide-square"
448
- :loading="previewBusy"
449
- data-testid="preview-stop"
450
- @click="preview.stop(props.block.id)"
451
- >
452
- {{ t('inspector.frontendConfig.preview.stop') }}
453
- </UButton>
454
- </div>
455
-
456
- <p
457
- v-if="previewStatus === 'failed' && previewState?.error"
458
- class="text-[11px] leading-snug text-rose-400"
459
- data-testid="preview-error"
460
- >
461
- {{ previewState.error }}
462
- </p>
463
-
464
- <p
465
- v-if="previewRequestError"
466
- class="text-[11px] leading-snug text-rose-400"
467
- data-testid="preview-request-error"
468
- >
469
- {{ previewRequestError }}
470
- </p>
471
738
  </div>
472
739
  </div>
473
740
  </div>