@frockbot/plugin-shell 0.3.0 → 0.3.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.
Files changed (33) hide show
  1. package/package.json +32 -29
  2. package/src/backend-applets.test.ts +581 -0
  3. package/src/backend-applets.ts +959 -0
  4. package/src/backend-authoring.test.ts +61 -19
  5. package/src/backend-authoring.ts +52 -26
  6. package/src/backend-composition.ts +64 -0
  7. package/src/backend-computer.test.ts +128 -0
  8. package/src/backend-computer.ts +81 -0
  9. package/src/backend-configuration.test.ts +8 -1
  10. package/src/backend-iframe-ui.test.ts +29 -12
  11. package/src/backend-isolate.ts +31 -5
  12. package/src/backend-package-catalog.test.ts +13 -8
  13. package/src/backend-package-catalog.ts +8 -6
  14. package/src/backend-recovery-integration.test.ts +23 -0
  15. package/src/backend.ts +508 -5
  16. package/src/client/AppletCanvas.vue +679 -0
  17. package/src/client/FrockBotApp.vue +169 -15
  18. package/src/client/PackageEntryTrigger.vue +77 -0
  19. package/src/client/PackageIframeHost.vue +148 -47
  20. package/src/client/PackageIframeSettings.vue +8 -6
  21. package/src/client/PackageSurfacePage.vue +39 -0
  22. package/src/client/applets-client.test.ts +204 -0
  23. package/src/client/applets-client.ts +139 -0
  24. package/src/client/applets-state.ts +64 -0
  25. package/src/client/index.test.ts +13 -7
  26. package/src/client/index.ts +308 -1
  27. package/src/client/package-iframe-entries.test.ts +122 -0
  28. package/src/client/package-iframe-entries.ts +112 -0
  29. package/src/client/package-iframe-host-message.test.ts +3 -3
  30. package/src/client/package-iframe-host-message.ts +3 -3
  31. package/src/client/styles.css +107 -1
  32. package/src/composition-views.ts +31 -6
  33. package/src/shared.ts +52 -0
@@ -0,0 +1,679 @@
1
+ <script setup lang="ts">
2
+ /**
3
+ * The Applet canvas.
4
+ *
5
+ * The shell owns the frame: the header, the two states, the transition between
6
+ * them, and every loading, empty, and failure branch. The Applets Package owns
7
+ * only the page inside the frame, so how finished this feels never depends on
8
+ * what a Package shipped.
9
+ *
10
+ * Two states, per the plan's §5a. **Building** is the Applet's source as the
11
+ * Bot writes it, read from the Workspace store — nothing here wakes the
12
+ * Computer. **Ready** is the live Applet, which slides in over the code view
13
+ * once a generation is active. A publish that failed leaves the code view up
14
+ * with the failure inline and a way to try again; it never shows progress that
15
+ * is not happening.
16
+ */
17
+ import { UiIcon, UiIconButton, UiSkeleton } from "@frockbot/client-ui";
18
+ import { computed, inject, onBeforeUnmount, ref, watch } from "vue";
19
+ import { frockBotWebDataKey } from "../shared.js";
20
+ import { appletsBridgeStateV2 } from "./applets-state.js";
21
+ import { mostRecentlyChangedFileV1 } from "./applets-client.js";
22
+ import { packageIframePagesForSlotV1 } from "./package-iframe-entries.js";
23
+ import PackageIframeHost from "./PackageIframeHost.vue";
24
+
25
+ const RIGHT_PANEL_SLOT = "frockbot.right-panel";
26
+ /** A load that takes longer than this stops spinning and offers a retry. */
27
+ const LOAD_TIMEOUT_MS = 8_000;
28
+
29
+ const providedWeb = inject(frockBotWebDataKey);
30
+ if (!providedWeb) throw new Error("Applet canvas data was not provided");
31
+ const web = providedWeb;
32
+
33
+ const applet = computed(() => web.value.focusedApplet);
34
+ const appletId = computed(() => web.value.focusedAppletId ?? undefined);
35
+ const viewer = computed(() =>
36
+ web.value.appletViewer?.appletId === appletId.value
37
+ ? web.value.appletViewer
38
+ : undefined,
39
+ );
40
+ const source = computed(() => web.value.appletSource);
41
+ const build = computed(() => web.value.appletBuild);
42
+ const isRunning = computed(() => Boolean(web.value.activeRunId));
43
+ const canvasState = computed(() => web.value.appletCanvas);
44
+ const failure = computed(() => web.value.appletCanvasError);
45
+
46
+ /** The Applets Package's right-panel page, when its Composition carries one. */
47
+ const panelPage = computed(
48
+ () => packageIframePagesForSlotV1(web.value.packageUi, RIGHT_PANEL_SLOT)[0],
49
+ );
50
+ const states = computed(() => ({ applets: appletsBridgeStateV2(web.value) }));
51
+
52
+ /*
53
+ * Which view the header's toggle shows.
54
+ *
55
+ * It follows the Turn — a Turn that publishes lands on the Applet, a Turn that
56
+ * writes source lands on the code — until the User picks a side, and then it
57
+ * is theirs until they focus something else.
58
+ */
59
+ const userSelectedTab = ref<"app" | "code" | undefined>(undefined);
60
+ const followedTab = ref<"app" | "code">("code");
61
+ const tab = computed<"app" | "code">(() => {
62
+ if (userSelectedTab.value) return userSelectedTab.value;
63
+ if (!viewer.value) return "code";
64
+ return followedTab.value;
65
+ });
66
+ const canShowApp = computed(() => Boolean(viewer.value && panelPage.value));
67
+ const showingApp = computed(() => canShowApp.value && tab.value === "app");
68
+
69
+ watch(appletId, () => {
70
+ userSelectedTab.value = undefined;
71
+ followedTab.value = "code";
72
+ });
73
+ // A generation becoming active is the moment the Applet is worth looking at.
74
+ watch(
75
+ () => viewer.value?.generationId,
76
+ (generationId) => {
77
+ if (generationId) followedTab.value = "app";
78
+ },
79
+ );
80
+ // A Turn writing source is the moment the code is.
81
+ watch(
82
+ () => source.value?.files.map((file) => file.changedAt ?? file.generationId),
83
+ () => {
84
+ if (isRunning.value) followedTab.value = "code";
85
+ },
86
+ { deep: true },
87
+ );
88
+
89
+ /** The file the code view is on, following the most recently changed one. */
90
+ const openedPath = ref<string | undefined>(undefined);
91
+ const currentPath = computed(
92
+ () => openedPath.value ?? mostRecentlyChangedFileV1(source.value),
93
+ );
94
+ const currentFile = computed(() =>
95
+ source.value?.files.find((file) => file.path === currentPath.value),
96
+ );
97
+ const sortedFiles = computed(() =>
98
+ (source.value?.files ?? []).toSorted((left, right) =>
99
+ left.path.localeCompare(right.path),
100
+ ),
101
+ );
102
+ watch(appletId, () => {
103
+ openedPath.value = undefined;
104
+ });
105
+
106
+ /*
107
+ * A load that never lands.
108
+ *
109
+ * A spinner that runs forever tells a User nothing they can act on, so past
110
+ * the timeout the canvas says the read is taking too long and offers the one
111
+ * thing that helps.
112
+ */
113
+ const loadTimedOut = ref(false);
114
+ let loadTimer: ReturnType<typeof setTimeout> | undefined;
115
+ watch(
116
+ canvasState,
117
+ (state) => {
118
+ clearTimeout(loadTimer);
119
+ loadTimedOut.value = false;
120
+ if (state !== "loading") return;
121
+ loadTimer = setTimeout(() => {
122
+ loadTimedOut.value = true;
123
+ }, LOAD_TIMEOUT_MS);
124
+ },
125
+ { immediate: true },
126
+ );
127
+ onBeforeUnmount(() => clearTimeout(loadTimer));
128
+
129
+ const loading = computed(
130
+ () => canvasState.value === "loading" && !loadTimedOut.value,
131
+ );
132
+
133
+ /**
134
+ * A generation id is `<ISO time>:<hash prefix>`, which is exact and unreadable.
135
+ * The header says when it went live and keeps the short hash; the full id is
136
+ * the tooltip.
137
+ */
138
+ function generationLabel(generationId: string): string {
139
+ const separator = generationId.lastIndexOf(":");
140
+ const stamp = separator > 0 ? generationId.slice(0, separator) : "";
141
+ const hash = separator > 0 ? generationId.slice(separator + 1) : generationId;
142
+ const at = new Date(stamp);
143
+ if (Number.isNaN(at.getTime())) return `Live · ${hash.slice(0, 7)}`;
144
+ const when = at.toLocaleString(undefined, {
145
+ month: "short",
146
+ day: "numeric",
147
+ hour: "numeric",
148
+ minute: "2-digit",
149
+ });
150
+ return `Live since ${when} · ${hash.slice(0, 7)}`;
151
+ }
152
+
153
+ function selectTab(next: "app" | "code"): void {
154
+ userSelectedTab.value = next;
155
+ }
156
+
157
+ function openFile(path: string): void {
158
+ openedPath.value = path;
159
+ }
160
+
161
+ /** Retry is a re-focus: the same durable command, read back the same way. */
162
+ async function retry(): Promise<void> {
163
+ const id = appletId.value;
164
+ if (!id) return;
165
+ loadTimedOut.value = false;
166
+ await web.value.setFocusedApplet(id);
167
+ }
168
+
169
+ function openInNewTab(): void {
170
+ const url = viewer.value?.uiUrl;
171
+ if (url) window.open(url, "_blank", "noopener,noreferrer");
172
+ }
173
+
174
+ async function clearFocus(): Promise<void> {
175
+ await web.value.setFocusedApplet(null);
176
+ }
177
+ </script>
178
+
179
+ <template>
180
+ <section
181
+ class="applet-canvas"
182
+ :aria-label="`Applet ${applet?.displayName ?? ''}`"
183
+ :aria-busy="loading"
184
+ >
185
+ <!--
186
+ The Turn's own signal, as a 2px bar across the top of the panel: the
187
+ canvas says the Bot is working with the same signal the conversation
188
+ does, rather than inventing a second one.
189
+ -->
190
+ <div
191
+ v-if="isRunning"
192
+ class="applet-canvas-working"
193
+ role="status"
194
+ aria-label="The Bot is working"
195
+ />
196
+ <header class="applet-canvas-header">
197
+ <span class="applet-canvas-mark" aria-hidden="true"
198
+ ><UiIcon name="applets" size="sm"
199
+ /></span>
200
+ <div class="applet-canvas-title">
201
+ <strong>{{ applet?.displayName ?? "Applet" }}</strong>
202
+ <small v-if="viewer" :title="viewer.generationId">{{
203
+ generationLabel(viewer.generationId)
204
+ }}</small>
205
+ <small v-else>No published version yet</small>
206
+ </div>
207
+ <div
208
+ v-if="canShowApp"
209
+ class="applet-canvas-tabs"
210
+ role="tablist"
211
+ aria-label="Applet view"
212
+ >
213
+ <button
214
+ type="button"
215
+ role="tab"
216
+ :aria-selected="showingApp"
217
+ :class="{ 'applet-canvas-tab-active': showingApp }"
218
+ @click="selectTab('app')"
219
+ >
220
+ App
221
+ </button>
222
+ <button
223
+ type="button"
224
+ role="tab"
225
+ :aria-selected="!showingApp"
226
+ :class="{ 'applet-canvas-tab-active': !showingApp }"
227
+ @click="selectTab('code')"
228
+ >
229
+ Code
230
+ </button>
231
+ </div>
232
+ <UiIconButton
233
+ v-if="viewer"
234
+ icon="link"
235
+ label="Open this Applet in a new tab"
236
+ size="sm"
237
+ @click="openInNewTab"
238
+ />
239
+ <UiIconButton
240
+ icon="close"
241
+ label="Close this Applet"
242
+ size="sm"
243
+ @click="clearFocus"
244
+ />
245
+ </header>
246
+
247
+ <div class="applet-canvas-body">
248
+ <!-- Loading: the shape of what is coming, with a caption that says so. -->
249
+ <div v-if="loading" class="applet-canvas-loading">
250
+ <UiSkeleton width="60%" />
251
+ <UiSkeleton width="90%" />
252
+ <UiSkeleton width="80%" />
253
+ <UiSkeleton width="45%" />
254
+ <p>Reading this Applet…</p>
255
+ </div>
256
+
257
+ <!-- No Applet under the focus: the one thing worth doing about that. -->
258
+ <div v-else-if="!applet" class="applet-canvas-empty">
259
+ <span class="applet-canvas-empty-mark" aria-hidden="true"
260
+ ><UiIcon name="applets" size="lg"
261
+ /></span>
262
+ <h3>No Applet here yet</h3>
263
+ <p>
264
+ Ask this Bot to build one — a todo list, a tracker, whatever you keep
265
+ in your head. It writes it, publishes it, and it appears here.
266
+ </p>
267
+ </div>
268
+
269
+ <template v-else>
270
+ <!-- Building: the source as it is written, and the last check. -->
271
+ <div class="applet-canvas-code">
272
+ <div
273
+ v-if="failure"
274
+ class="applet-canvas-failure"
275
+ role="alert"
276
+ data-testid="applet-canvas-failure"
277
+ >
278
+ <span>{{ failure }}</span>
279
+ <button type="button" @click="retry">Try again</button>
280
+ </div>
281
+ <div
282
+ v-else-if="loadTimedOut"
283
+ class="applet-canvas-failure"
284
+ role="alert"
285
+ >
286
+ <span>This is taking longer than it should.</span>
287
+ <button type="button" @click="retry">Try again</button>
288
+ </div>
289
+ <p
290
+ v-if="build && build.status !== 'unknown'"
291
+ class="applet-canvas-build"
292
+ :class="`applet-canvas-build-${build.status}`"
293
+ >
294
+ <UiIcon
295
+ :name="build.status === 'passed' ? 'check' : 'close'"
296
+ size="sm"
297
+ />
298
+ <span
299
+ >{{ build.command === "build" ? "Build" : "Check" }}
300
+ {{ build.status
301
+ }}{{ build.summary ? `: ${build.summary}` : "" }}</span
302
+ >
303
+ </p>
304
+ <div v-if="sortedFiles.length > 0" class="applet-canvas-files">
305
+ <button
306
+ v-for="file in sortedFiles"
307
+ :key="file.path"
308
+ type="button"
309
+ class="applet-canvas-file"
310
+ :class="{
311
+ 'applet-canvas-file-active': file.path === currentPath,
312
+ }"
313
+ :aria-current="file.path === currentPath"
314
+ @click="openFile(file.path)"
315
+ >
316
+ {{ file.path }}
317
+ </button>
318
+ </div>
319
+ <p v-else class="applet-canvas-note">
320
+ This Applet has no source yet.
321
+ </p>
322
+ <pre v-if="currentFile" class="applet-canvas-source"><code>{{
323
+ currentFile.text
324
+ }}</code></pre>
325
+ <p v-if="source?.truncated" class="applet-canvas-note">
326
+ Only the first part of this Applet's source is shown.
327
+ </p>
328
+ </div>
329
+
330
+ <!--
331
+ Ready: the live Applet, over the code rather than instead of it, so
332
+ the toggle back is a slide and not a reload.
333
+ -->
334
+ <Transition name="applet-app">
335
+ <div v-if="showingApp" class="applet-canvas-app">
336
+ <PackageIframeHost
337
+ v-if="panelPage"
338
+ :contribution="panelPage.contribution"
339
+ :page="panelPage.page"
340
+ :slot="RIGHT_PANEL_SLOT"
341
+ :states="states"
342
+ layout="fill"
343
+ :attribution="false"
344
+ />
345
+ </div>
346
+ </Transition>
347
+ </template>
348
+ </div>
349
+ </section>
350
+ </template>
351
+
352
+ <style scoped>
353
+ .applet-canvas {
354
+ position: relative;
355
+ display: flex;
356
+ width: 100%;
357
+ height: 100%;
358
+ min-height: 0;
359
+ flex-direction: column;
360
+ background: var(--frock-surface);
361
+ }
362
+
363
+ /* The Turn's signal: 2px, across the top, and gone the moment it settles. */
364
+ .applet-canvas-working {
365
+ position: absolute;
366
+ z-index: 2;
367
+ top: 0;
368
+ right: 0;
369
+ left: 0;
370
+ height: 2px;
371
+ background: linear-gradient(
372
+ 90deg,
373
+ transparent,
374
+ var(--frock-action-primary),
375
+ transparent
376
+ );
377
+ background-size: 40% 100%;
378
+ background-repeat: no-repeat;
379
+ animation: applet-working 1.4s ease-in-out infinite;
380
+ }
381
+
382
+ @keyframes applet-working {
383
+ 0% {
384
+ background-position: -40% 0;
385
+ }
386
+
387
+ 100% {
388
+ background-position: 140% 0;
389
+ }
390
+ }
391
+
392
+ .applet-canvas-header {
393
+ display: flex;
394
+ height: var(--frock-titlebar-height);
395
+ flex: 0 0 auto;
396
+ align-items: center;
397
+ gap: 8px;
398
+ padding: 0 8px 0 12px;
399
+ border-bottom: 1px solid var(--frock-border);
400
+ }
401
+
402
+ .applet-canvas-mark {
403
+ display: grid;
404
+ width: var(--frock-avatar-sm);
405
+ height: var(--frock-avatar-sm);
406
+ flex: 0 0 auto;
407
+ place-items: center;
408
+ border-radius: 8px;
409
+ color: var(--frock-action-primary);
410
+ background: var(--frock-surface-subtle);
411
+ box-shadow: inset 0 0 0 1px var(--frock-border);
412
+ }
413
+
414
+ .applet-canvas-title {
415
+ display: flex;
416
+ min-width: 0;
417
+ flex: 1;
418
+ flex-direction: column;
419
+ }
420
+
421
+ .applet-canvas-title strong {
422
+ overflow: hidden;
423
+ color: var(--frock-text);
424
+ font-size: var(--frock-text-md);
425
+ font-weight: 600;
426
+ white-space: nowrap;
427
+ text-overflow: ellipsis;
428
+ }
429
+
430
+ .applet-canvas-title small {
431
+ overflow: hidden;
432
+ color: var(--frock-text-muted);
433
+ font-family: var(--frock-font-mono);
434
+ font-size: var(--frock-text-xs);
435
+ white-space: nowrap;
436
+ text-overflow: ellipsis;
437
+ }
438
+
439
+ .applet-canvas-tabs {
440
+ display: flex;
441
+ flex: 0 0 auto;
442
+ gap: 2px;
443
+ padding: 2px;
444
+ border-radius: var(--frock-radius-control);
445
+ background: var(--frock-surface-subtle);
446
+ }
447
+
448
+ .applet-canvas-tabs button {
449
+ height: var(--frock-control-sm);
450
+ padding: 0 10px;
451
+ border: 0;
452
+ border-radius: 8px;
453
+ color: var(--frock-text-muted);
454
+ background: transparent;
455
+ font-size: var(--frock-text-sm);
456
+ font-weight: 500;
457
+ cursor: pointer;
458
+ transition:
459
+ background-color var(--frock-motion-fast),
460
+ color var(--frock-motion-fast);
461
+ }
462
+
463
+ .applet-canvas-tabs button:hover {
464
+ color: var(--frock-text);
465
+ background: var(--frock-fill-hover);
466
+ }
467
+
468
+ .applet-canvas-tab-active,
469
+ .applet-canvas-tabs button.applet-canvas-tab-active {
470
+ color: var(--frock-text);
471
+ background: var(--frock-surface-raised);
472
+ }
473
+
474
+ .applet-canvas-body {
475
+ position: relative;
476
+ min-height: 0;
477
+ flex: 1;
478
+ }
479
+
480
+ .applet-canvas-code {
481
+ position: absolute;
482
+ display: flex;
483
+ overflow-y: auto;
484
+ flex-direction: column;
485
+ gap: 10px;
486
+ padding: 12px;
487
+ inset: 0;
488
+ scrollbar-color: var(--frock-scrollbar) transparent;
489
+ scrollbar-width: thin;
490
+ }
491
+
492
+ .applet-canvas-app {
493
+ position: absolute;
494
+ overflow: hidden;
495
+ background: var(--frock-surface);
496
+ inset: 0;
497
+ }
498
+
499
+ /*
500
+ * The Applet arriving over its own source. 240ms is long enough to read as a
501
+ * slide and short enough that the toggle still feels like a switch.
502
+ */
503
+ .applet-app-enter-active,
504
+ .applet-app-leave-active {
505
+ transition:
506
+ transform 240ms cubic-bezier(0.16, 1, 0.3, 1),
507
+ opacity 240ms cubic-bezier(0.16, 1, 0.3, 1);
508
+ }
509
+
510
+ .applet-app-enter-from,
511
+ .applet-app-leave-to {
512
+ opacity: 0;
513
+ transform: translateX(16px);
514
+ }
515
+
516
+ .applet-canvas-files {
517
+ display: flex;
518
+ flex: 0 0 auto;
519
+ flex-wrap: wrap;
520
+ gap: 4px;
521
+ }
522
+
523
+ .applet-canvas-file {
524
+ max-width: 100%;
525
+ height: var(--frock-control-sm);
526
+ overflow: hidden;
527
+ padding: 0 8px;
528
+ border: 1px solid var(--frock-border);
529
+ border-radius: var(--frock-radius-control);
530
+ color: var(--frock-text-muted);
531
+ background: transparent;
532
+ font-family: var(--frock-font-mono);
533
+ font-size: var(--frock-text-xs);
534
+ white-space: nowrap;
535
+ text-overflow: ellipsis;
536
+ cursor: pointer;
537
+ }
538
+
539
+ .applet-canvas-file:hover {
540
+ color: var(--frock-text);
541
+ background: var(--frock-fill-hover);
542
+ }
543
+
544
+ .applet-canvas-file-active {
545
+ color: var(--frock-text);
546
+ border-color: var(--frock-border-strong);
547
+ background: var(--frock-surface-subtle);
548
+ }
549
+
550
+ .applet-canvas-source {
551
+ margin: 0;
552
+ overflow-x: auto;
553
+ padding: 10px 12px;
554
+ border: 1px solid var(--frock-border);
555
+ border-radius: var(--frock-radius-card);
556
+ color: var(--frock-text);
557
+ background: var(--frock-surface-subtle);
558
+ font-family: var(--frock-font-mono);
559
+ font-size: var(--frock-text-sm);
560
+ line-height: var(--frock-leading-snug);
561
+ tab-size: 2;
562
+ white-space: pre;
563
+ }
564
+
565
+ .applet-canvas-build {
566
+ display: flex;
567
+ align-items: center;
568
+ gap: 6px;
569
+ margin: 0;
570
+ padding: 6px 10px;
571
+ border: 1px solid var(--frock-border);
572
+ border-radius: var(--frock-radius-control);
573
+ color: var(--frock-text-muted);
574
+ font-size: var(--frock-text-xs);
575
+ }
576
+
577
+ .applet-canvas-build-passed {
578
+ border-color: var(--frock-success-border);
579
+ color: var(--frock-success);
580
+ background: var(--frock-success-surface);
581
+ }
582
+
583
+ .applet-canvas-build-failed {
584
+ border-color: var(--frock-danger-border);
585
+ color: var(--frock-danger-text);
586
+ background: var(--frock-danger-surface);
587
+ }
588
+
589
+ .applet-canvas-failure {
590
+ display: flex;
591
+ align-items: center;
592
+ justify-content: space-between;
593
+ gap: 10px;
594
+ padding: 8px 10px;
595
+ border: 1px solid var(--frock-danger-border);
596
+ border-radius: var(--frock-radius-control);
597
+ color: var(--frock-danger-text);
598
+ background: var(--frock-danger-surface);
599
+ font-size: var(--frock-text-sm);
600
+ }
601
+
602
+ .applet-canvas-failure button {
603
+ height: var(--frock-control-sm);
604
+ flex: 0 0 auto;
605
+ padding: 0 10px;
606
+ border: 1px solid var(--frock-danger-border);
607
+ border-radius: var(--frock-radius-control);
608
+ color: var(--frock-text);
609
+ background: transparent;
610
+ font-size: var(--frock-text-sm);
611
+ cursor: pointer;
612
+ }
613
+
614
+ .applet-canvas-note {
615
+ margin: 0;
616
+ color: var(--frock-text-muted);
617
+ font-size: var(--frock-text-xs);
618
+ }
619
+
620
+ .applet-canvas-loading {
621
+ display: flex;
622
+ flex-direction: column;
623
+ gap: 10px;
624
+ padding: 16px;
625
+ }
626
+
627
+ .applet-canvas-loading p {
628
+ margin: 0;
629
+ color: var(--frock-text-muted);
630
+ font-size: var(--frock-text-xs);
631
+ }
632
+
633
+ .applet-canvas-empty {
634
+ display: flex;
635
+ height: 100%;
636
+ flex-direction: column;
637
+ align-items: center;
638
+ justify-content: center;
639
+ gap: 8px;
640
+ padding: 24px;
641
+ text-align: center;
642
+ }
643
+
644
+ .applet-canvas-empty-mark {
645
+ display: grid;
646
+ width: var(--frock-avatar-md);
647
+ height: var(--frock-avatar-md);
648
+ place-items: center;
649
+ border-radius: var(--frock-radius-control);
650
+ color: var(--frock-action-primary);
651
+ background: var(--frock-surface-subtle);
652
+ box-shadow: inset 0 0 0 1px var(--frock-border);
653
+ }
654
+
655
+ .applet-canvas-empty h3 {
656
+ margin: 0;
657
+ color: var(--frock-text);
658
+ font-size: var(--frock-text-lg);
659
+ font-weight: 600;
660
+ }
661
+
662
+ .applet-canvas-empty p {
663
+ max-width: 34ch;
664
+ margin: 0;
665
+ color: var(--frock-text-muted);
666
+ font-size: var(--frock-text-sm);
667
+ }
668
+
669
+ @media (prefers-reduced-motion: reduce) {
670
+ .applet-canvas-working {
671
+ animation: none;
672
+ }
673
+
674
+ .applet-app-enter-active,
675
+ .applet-app-leave-active {
676
+ transition: none;
677
+ }
678
+ }
679
+ </style>