@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.
- package/package.json +32 -29
- package/src/backend-applets.test.ts +581 -0
- package/src/backend-applets.ts +959 -0
- package/src/backend-authoring.test.ts +61 -19
- package/src/backend-authoring.ts +52 -26
- package/src/backend-composition.ts +64 -0
- package/src/backend-computer.test.ts +128 -0
- package/src/backend-computer.ts +81 -0
- package/src/backend-configuration.test.ts +8 -1
- package/src/backend-iframe-ui.test.ts +29 -12
- package/src/backend-isolate.ts +31 -5
- package/src/backend-package-catalog.test.ts +13 -8
- package/src/backend-package-catalog.ts +8 -6
- package/src/backend-recovery-integration.test.ts +23 -0
- package/src/backend.ts +508 -5
- package/src/client/AppletCanvas.vue +679 -0
- package/src/client/FrockBotApp.vue +169 -15
- package/src/client/PackageEntryTrigger.vue +77 -0
- package/src/client/PackageIframeHost.vue +148 -47
- package/src/client/PackageIframeSettings.vue +8 -6
- package/src/client/PackageSurfacePage.vue +39 -0
- package/src/client/applets-client.test.ts +204 -0
- package/src/client/applets-client.ts +139 -0
- package/src/client/applets-state.ts +64 -0
- package/src/client/index.test.ts +13 -7
- package/src/client/index.ts +308 -1
- package/src/client/package-iframe-entries.test.ts +122 -0
- package/src/client/package-iframe-entries.ts +112 -0
- package/src/client/package-iframe-host-message.test.ts +3 -3
- package/src/client/package-iframe-host-message.ts +3 -3
- package/src/client/styles.css +107 -1
- package/src/composition-views.ts +31 -6
- 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>
|