@k2b/cloud 0.37.0 → 0.39.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.
- package/package.json +8 -3
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-path.ts +25 -0
- package/src/_internal/app-presentation.ts +4 -0
- package/src/_internal/canvas-worker.ts +5 -3
- package/src/_internal/define-app.ts +28 -2
- package/src/_internal/help-catalog.ts +123 -4
- package/src/_internal/help.ts +27 -6
- package/src/_internal/panel-cookie.ts +39 -0
- package/src/_internal/registry-validation.ts +21 -26
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +30 -5
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/Avatar.tsx +20 -2
- package/src/account/ui.ts +1 -1
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/browser.ts +11 -0
- package/src/ai/chart-block.ts +44 -13
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/chart-block.tsx +2 -11
- package/src/ai/chat/messages.ts +10 -0
- package/src/ai/chat/turn-view.tsx +35 -3
- package/src/ai/chat/view-block.tsx +7 -2
- package/src/ai/chat/web-tools.tsx +23 -6
- package/src/ai/chat-task-contracts.ts +28 -3
- package/src/ai/chat-task-reasons-migrate.ts +68 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +460 -66
- package/src/ai/default-tool-contracts.ts +30 -13
- package/src/ai/fetch-file-tool.ts +37 -3
- package/src/ai/index.ts +12 -0
- package/src/ai/migrate.ts +46 -3
- package/src/ai/readable-html.ts +1057 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +27 -1
- package/src/ai/runtime.ts +12 -27
- package/src/ai/store.ts +5 -3
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-wording.ts +45 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +12 -1
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-outgoing-mail.ts +4 -2
- package/src/api/app-approval.ts +9 -2
- package/src/api/approvals.ts +1 -1
- package/src/api/help.ts +23 -4
- package/src/api/index.ts +2 -1
- package/src/api/mcp.ts +8 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/FileChooser.tsx +11 -3
- package/src/browser/app-approval.ts +7 -2
- package/src/browser/choose-files.browser-harness.tsx +4 -1
- package/src/browser/composer-catalog.ts +103 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +150 -0
- package/src/browser/file-chooser-messages.ts +4 -0
- package/src/browser/file-providers.ts +14 -1
- package/src/browser/panel-messages.ts +24 -0
- package/src/browser/panel-slot.ts +354 -0
- package/src/browser/panel.ts +28 -0
- package/src/browser/provider-folder.tsx +58 -5
- package/src/browser/resource-picker.tsx +17 -2
- package/src/cli/admin/workflows.ts +1 -0
- package/src/config/env.ts +14 -0
- package/src/contracts/app-approval.ts +49 -9
- package/src/contracts/app.ts +14 -0
- package/src/contracts/file-provider.ts +48 -1
- package/src/contracts/outgoing-mail.ts +8 -1
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/shared.ts +2 -0
- package/src/server/help.ts +136 -10
- package/src/server/services/access.ts +5 -1
- package/src/server/services/geo.ts +21 -4
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/allowed-network-fetch.ts +189 -0
- package/src/services/app-approval.ts +158 -38
- package/src/services/approvals/device.ts +2 -0
- package/src/services/help/index.ts +46 -5
- package/src/services/help/store.ts +26 -5
- package/src/services/help/types.ts +10 -2
- package/src/services/index.ts +22 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/outgoing-mail/dispatcher.ts +7 -2
- package/src/services/outgoing-mail/resolve-host.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +18 -3
- package/src/services/outgoing-mail/transport.ts +24 -13
- package/src/services/pdf/image-protocol.ts +135 -0
- package/src/services/pdf/image-variants.ts +124 -0
- package/src/services/pdf/image-worker.ts +31 -0
- package/src/services/pdf/image.ts +109 -0
- package/src/services/pdf/index.ts +9 -0
- package/src/services/settings/api.ts +11 -6
- package/src/services/settings/app.ts +5 -4
- package/src/services/settings/core-settings.ts +4 -3
- package/src/services/settings/index.ts +67 -43
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/help.ts +41 -0
- package/src/shared/index.ts +9 -1
- package/src/shared/markdown/extensions/guided-help.ts +208 -64
- package/src/shared/markdown/formula.ts +8 -8
- package/src/shared/markdown/index.ts +72 -11
- package/src/shared/redirect.ts +12 -0
- package/src/ssr/CloudShell.island.tsx +86 -9
- package/src/ssr/Layout.tsx +62 -11
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
- package/src/ssr/LayoutHelp.tsx +569 -410
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/PanelLayout.tsx +27 -0
- package/src/ssr/PanelRuntime.island.tsx +87 -0
- package/src/ssr/PanelSlot.tsx +108 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +72 -25
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +100 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +15 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +431 -0
- package/src/ssr/help-messages.ts +60 -38
- package/src/ssr/help.ts +3 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/layout-help-article.ts +41 -0
- package/src/ssr/layout-help-navigation.ts +0 -12
- package/src/ssr/layout-help-registry.tsx +4 -11
- package/src/ssr/layout-help-search.ts +9 -12
- package/src/ssr/layout-help-start.ts +68 -0
- package/src/ssr/panel-slot.browser-harness.tsx +110 -0
- package/src/ssr/platform-messages.ts +34 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +11 -0
- package/src/ssr/shell-shortcuts.ts +19 -0
- package/src/styles/effects.css +689 -185
- package/src/styles/global.css +269 -55
- package/src/styles/panel.css +373 -0
- package/src/styles/resource-search.css +25 -0
- package/src/styles/tokens.css +23 -6
- package/src/styles/utilities-navigation.css +5 -21
- package/src/workflows/store/events.ts +24 -0
- package/src/workflows/store/index.ts +8 -0
- package/src/workflows/store/observability.ts +6 -1
- package/src/workflows/store/retention.ts +157 -0
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import { afterAll, beforeAll, setDefaultTimeout, spyOn } from "bun:test";
|
|
2
|
+
import { mkdtempSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { resolve } from "node:path";
|
|
5
|
+
import { createConfig } from "@k2b/ssr";
|
|
6
|
+
import tailwind from "bun-plugin-tailwind";
|
|
7
|
+
import type { Browser, BrowserContext, Page } from "playwright";
|
|
8
|
+
import { createComponent } from "solid-js";
|
|
9
|
+
import type { launchBrowser } from "../../../ui/test/browser";
|
|
10
|
+
|
|
11
|
+
// The shell's geometry at every width — which chrome exists, where the app
|
|
12
|
+
// area ends, which edges draw lines — is decided by real layout, so a real
|
|
13
|
+
// engine loads the server-rendered Layout with the global stylesheet and the
|
|
14
|
+
// shared workspace controller, as Cloud serves them.
|
|
15
|
+
const root = mkdtempSync(resolve(tmpdir(), "cloud-compact-shell-browser-"));
|
|
16
|
+
const { plugin } = createConfig({ dev: true, rootDir: root });
|
|
17
|
+
Bun.plugin(plugin());
|
|
18
|
+
process.once("exit", () => rmSync(root, { recursive: true, force: true }));
|
|
19
|
+
|
|
20
|
+
const { Hono } = await import("hono");
|
|
21
|
+
const { AppWorkspace, Button, ScrollArea } = await import("@k2b/ui");
|
|
22
|
+
const { defineApp } = await import("../_internal/define-app");
|
|
23
|
+
const { person } = await import("../_internal/define-app.test-fixture");
|
|
24
|
+
const railSnapshot = await import("../services/rail-snapshot");
|
|
25
|
+
const settingsMiddleware = await import("../server/middleware/settings");
|
|
26
|
+
const { defaultRailPreferences } = await import("../contracts/rail-preferences");
|
|
27
|
+
const { default: WorkspaceNavigation } = await import("./WorkspaceNavigation.island");
|
|
28
|
+
const { default: Layout } = await import("./Layout");
|
|
29
|
+
|
|
30
|
+
const probe = defineApp({
|
|
31
|
+
id: "compact-shell-probe",
|
|
32
|
+
name: "Compact shell probe",
|
|
33
|
+
icon: "ti ti-layout",
|
|
34
|
+
description: "Shell geometry probe",
|
|
35
|
+
baseUrl: "http://compact-shell-probe:3000",
|
|
36
|
+
routes: ["/app/mail"],
|
|
37
|
+
});
|
|
38
|
+
const extraApps = ["Amber", "Birch", "Cedar", "Dune", "Elm"].map((name) => ({
|
|
39
|
+
id: name.toLowerCase(),
|
|
40
|
+
name,
|
|
41
|
+
icon: "ti ti-box",
|
|
42
|
+
description: `${name} demo`,
|
|
43
|
+
routes: [`/app/${name.toLowerCase()}`],
|
|
44
|
+
nav: { href: `/app/${name.toLowerCase()}`, section: "primary" },
|
|
45
|
+
}));
|
|
46
|
+
const apps = [
|
|
47
|
+
{
|
|
48
|
+
id: "mail",
|
|
49
|
+
name: "Mail",
|
|
50
|
+
icon: "ti ti-mail",
|
|
51
|
+
description: "Read and write mail",
|
|
52
|
+
routes: ["/app/mail"],
|
|
53
|
+
nav: { href: "/app/mail", section: "primary" },
|
|
54
|
+
},
|
|
55
|
+
{
|
|
56
|
+
id: "pwa",
|
|
57
|
+
name: "Phone app",
|
|
58
|
+
icon: "ti ti-device-mobile",
|
|
59
|
+
description: "Phone app",
|
|
60
|
+
routes: ["/pwa"],
|
|
61
|
+
nav: { href: "/pwa", section: "hidden" },
|
|
62
|
+
},
|
|
63
|
+
];
|
|
64
|
+
|
|
65
|
+
type LayoutContextArg = Parameters<typeof Layout>[0]["c"];
|
|
66
|
+
|
|
67
|
+
/** A mail-like workspace: navigation, a list pane beside the reader, and a detail panel. Invented content. */
|
|
68
|
+
const workspace = (selected: string) =>
|
|
69
|
+
createComponent(AppWorkspace, {
|
|
70
|
+
mobileSurface: "flush",
|
|
71
|
+
class: "min-h-0 flex-1",
|
|
72
|
+
get children() {
|
|
73
|
+
return [
|
|
74
|
+
createComponent(AppWorkspace.Sidebar, {
|
|
75
|
+
id: "mail-navigation",
|
|
76
|
+
label: "Mailboxes",
|
|
77
|
+
get children() {
|
|
78
|
+
return createComponent(AppWorkspace.SidebarDesktop, {
|
|
79
|
+
get children() {
|
|
80
|
+
return createComponent(AppWorkspace.SidebarBody, {
|
|
81
|
+
get children() {
|
|
82
|
+
return ["inbox", "archive", "sent"].map((id) =>
|
|
83
|
+
createComponent(AppWorkspace.SidebarItem, {
|
|
84
|
+
href: `#${id}`,
|
|
85
|
+
icon: "ti ti-inbox",
|
|
86
|
+
active: id === selected,
|
|
87
|
+
children: id[0]!.toUpperCase() + id.slice(1),
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
},
|
|
93
|
+
});
|
|
94
|
+
},
|
|
95
|
+
}),
|
|
96
|
+
createComponent(AppWorkspace.Content, {
|
|
97
|
+
get children() {
|
|
98
|
+
return [
|
|
99
|
+
createComponent(AppWorkspace.Main, {
|
|
100
|
+
get children() {
|
|
101
|
+
return [
|
|
102
|
+
createComponent(AppWorkspace.MainPane, { id: "messages", label: "Messages", children: "Quarterly numbers" }),
|
|
103
|
+
`Reader for ${selected}`,
|
|
104
|
+
];
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
createComponent(AppWorkspace.Detail, { id: "message-detail", open: true, width: "sm", children: "Sender details" }),
|
|
108
|
+
];
|
|
109
|
+
},
|
|
110
|
+
}),
|
|
111
|
+
];
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* A task-list page: a long list in Main and an open detail that the server renders with its own close
|
|
117
|
+
* control, as Contacts and Capabilities do. Invented content.
|
|
118
|
+
*/
|
|
119
|
+
const listWorkspace = () =>
|
|
120
|
+
createComponent(AppWorkspace, {
|
|
121
|
+
class: "min-h-0 flex-1",
|
|
122
|
+
get children() {
|
|
123
|
+
return createComponent(AppWorkspace.Content, {
|
|
124
|
+
get children() {
|
|
125
|
+
return [
|
|
126
|
+
createComponent(AppWorkspace.Main, {
|
|
127
|
+
get children() {
|
|
128
|
+
return Array.from({ length: 80 }, (_, index) =>
|
|
129
|
+
createComponent(Button, { class: "list-row", align: "start", children: `Task ${index + 1}` }),
|
|
130
|
+
);
|
|
131
|
+
},
|
|
132
|
+
}),
|
|
133
|
+
createComponent(AppWorkspace.Detail, {
|
|
134
|
+
id: "task-detail",
|
|
135
|
+
open: true,
|
|
136
|
+
get children() {
|
|
137
|
+
return createComponent(Button, { "data-workspace-detail-close": "", children: "Close task" });
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
];
|
|
141
|
+
},
|
|
142
|
+
});
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
/** A sidebar-first overview that is the page itself, as Mail and Notebooks render. Invented content. */
|
|
147
|
+
const overviewWorkspace = () =>
|
|
148
|
+
createComponent(AppWorkspace, {
|
|
149
|
+
mobileSurface: "flush",
|
|
150
|
+
flat: true,
|
|
151
|
+
resizable: false,
|
|
152
|
+
get children() {
|
|
153
|
+
return [
|
|
154
|
+
createComponent(AppWorkspace.Sidebar, {
|
|
155
|
+
id: "overview-navigation",
|
|
156
|
+
label: "Mailboxes",
|
|
157
|
+
get children() {
|
|
158
|
+
return createComponent(AppWorkspace.SidebarDesktop, {
|
|
159
|
+
get children() {
|
|
160
|
+
return createComponent(AppWorkspace.SidebarBody, {
|
|
161
|
+
get children() {
|
|
162
|
+
return ["Personal", "Projects", "Support"].map((name) =>
|
|
163
|
+
createComponent(AppWorkspace.SidebarItem, { href: `#${name.toLowerCase()}`, children: name }),
|
|
164
|
+
);
|
|
165
|
+
},
|
|
166
|
+
});
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
},
|
|
170
|
+
}),
|
|
171
|
+
createComponent(AppWorkspace.Content, {
|
|
172
|
+
get children() {
|
|
173
|
+
return createComponent(AppWorkspace.Main, {
|
|
174
|
+
get children() {
|
|
175
|
+
return Array.from({ length: 80 }, (_, index) =>
|
|
176
|
+
createComponent(Button, { class: "overview-row", align: "start", children: `Message ${index + 1}` }),
|
|
177
|
+
);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
}),
|
|
182
|
+
];
|
|
183
|
+
},
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
const server = new Hono()
|
|
187
|
+
.get(
|
|
188
|
+
"/",
|
|
189
|
+
async (c, next) => {
|
|
190
|
+
c.set("runtime" as never, { apps } as never);
|
|
191
|
+
c.set("user" as never, person as never);
|
|
192
|
+
await next();
|
|
193
|
+
},
|
|
194
|
+
...probe.ssr((c) => () => createComponent(Layout, { c: c as unknown as LayoutContextArg, title: "Start", children: "Start content" })),
|
|
195
|
+
)
|
|
196
|
+
.get(
|
|
197
|
+
"/app/mail/:folder",
|
|
198
|
+
async (c, next) => {
|
|
199
|
+
// `?name=` renames Mail, for a long app name in the phone header.
|
|
200
|
+
const name = c.req.query("name");
|
|
201
|
+
const catalog = c.req.query("catalog") ? [...extraApps, ...apps] : apps;
|
|
202
|
+
c.set("runtime" as never, { apps: name ? catalog.map((app) => (app.id === "mail" ? { ...app, name } : app)) : catalog } as never);
|
|
203
|
+
// A missing last name shows the profile notice above the app.
|
|
204
|
+
c.set("user" as never, (c.req.query("notice") ? { ...person, sn: "" } : person) as never);
|
|
205
|
+
await next();
|
|
206
|
+
},
|
|
207
|
+
...probe.ssr((c) => {
|
|
208
|
+
const folder = c.req.param("folder") ?? "inbox";
|
|
209
|
+
const title = folder[0]!.toUpperCase() + folder.slice(1);
|
|
210
|
+
if (c.req.query("navigation"))
|
|
211
|
+
return () =>
|
|
212
|
+
createComponent(Layout, {
|
|
213
|
+
c: c as unknown as LayoutContextArg,
|
|
214
|
+
title: [{ title: "Home", href: "/" }, { title: "Mail" }],
|
|
215
|
+
get children() {
|
|
216
|
+
return [
|
|
217
|
+
createComponent(WorkspaceNavigation, { label: "Mail", items: [{ id: "inbox", label: "Inbox", href: "/app/mail/inbox" }] }),
|
|
218
|
+
overviewWorkspace(),
|
|
219
|
+
];
|
|
220
|
+
},
|
|
221
|
+
});
|
|
222
|
+
// `?overview=` keeps regular Layout props, including its page scroller's gutter.
|
|
223
|
+
if (c.req.query("overview"))
|
|
224
|
+
return () =>
|
|
225
|
+
createComponent(Layout, {
|
|
226
|
+
c: c as unknown as LayoutContextArg,
|
|
227
|
+
title: [{ title: "Home", href: "/" }, { title: "Mail" }],
|
|
228
|
+
get children() {
|
|
229
|
+
return overviewWorkspace();
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
// `?plain=` renders a regular page without a workspace or scroll area: `main` is its page scroller.
|
|
233
|
+
if (c.req.query("plain"))
|
|
234
|
+
return () =>
|
|
235
|
+
createComponent(Layout, {
|
|
236
|
+
c: c as unknown as LayoutContextArg,
|
|
237
|
+
title: [{ title: "Home", href: "/" }, { title: "Settings" }],
|
|
238
|
+
get children() {
|
|
239
|
+
return Array.from({ length: 60 }, (_, index) =>
|
|
240
|
+
createComponent(Button, { class: "plain-row w-full", align: "start", children: `Setting ${index + 1}` }),
|
|
241
|
+
);
|
|
242
|
+
},
|
|
243
|
+
});
|
|
244
|
+
// `?cards=` renders a page without a workspace that scrolls its content in a ScrollArea, as
|
|
245
|
+
// Capabilities and Weather do.
|
|
246
|
+
if (c.req.query("cards"))
|
|
247
|
+
return () =>
|
|
248
|
+
createComponent(Layout, {
|
|
249
|
+
c: c as unknown as LayoutContextArg,
|
|
250
|
+
fullWidth: true,
|
|
251
|
+
title: [{ title: "Home", href: "/" }, { title: "Capabilities" }],
|
|
252
|
+
get children() {
|
|
253
|
+
return createComponent(ScrollArea, {
|
|
254
|
+
class: "min-h-0 min-w-0 flex-1",
|
|
255
|
+
get children() {
|
|
256
|
+
return Array.from({ length: 60 }, (_, index) =>
|
|
257
|
+
createComponent(Button, { class: "card-row w-full", align: "start", children: `App ${index + 1}` }),
|
|
258
|
+
);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
// `?list=` renders the task list: `scroll` in a page that grows with its content, `page` in a full page.
|
|
264
|
+
const list = c.req.query("list");
|
|
265
|
+
if (list)
|
|
266
|
+
return () =>
|
|
267
|
+
createComponent(Layout, {
|
|
268
|
+
c: c as unknown as LayoutContextArg,
|
|
269
|
+
fullWidth: true,
|
|
270
|
+
fullPage: list === "page",
|
|
271
|
+
title: [{ title: "Home", href: "/" }, { title: "Tasks" }],
|
|
272
|
+
get children() {
|
|
273
|
+
return listWorkspace();
|
|
274
|
+
},
|
|
275
|
+
});
|
|
276
|
+
return () =>
|
|
277
|
+
createComponent(Layout, {
|
|
278
|
+
c: c as unknown as LayoutContextArg,
|
|
279
|
+
fullWidth: true,
|
|
280
|
+
fullPage: true,
|
|
281
|
+
title: [{ title: "Home", href: "/" }, { title: "Mail", href: "/app/mail" }, { title }],
|
|
282
|
+
get children() {
|
|
283
|
+
return workspace(folder);
|
|
284
|
+
},
|
|
285
|
+
});
|
|
286
|
+
}),
|
|
287
|
+
)
|
|
288
|
+
.get(
|
|
289
|
+
"/public",
|
|
290
|
+
async (c, next) => {
|
|
291
|
+
c.set("runtime" as never, { apps } as never);
|
|
292
|
+
await next();
|
|
293
|
+
},
|
|
294
|
+
...probe.ssr(
|
|
295
|
+
(c) => () =>
|
|
296
|
+
createComponent(Layout, {
|
|
297
|
+
c: c as unknown as LayoutContextArg,
|
|
298
|
+
title: [{ title: "Home", href: "/" }, { title: c.req.query("title") ?? "Public tools" }],
|
|
299
|
+
get children() {
|
|
300
|
+
if (c.req.query("cards"))
|
|
301
|
+
return createComponent(ScrollArea, {
|
|
302
|
+
get children() {
|
|
303
|
+
return Array.from({ length: 60 }, (_, index) =>
|
|
304
|
+
createComponent(Button, { class: "card-row", children: `App ${index + 1}` }),
|
|
305
|
+
);
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
return c.req.query("overview") ? overviewWorkspace() : "Public content";
|
|
309
|
+
},
|
|
310
|
+
}),
|
|
311
|
+
),
|
|
312
|
+
);
|
|
313
|
+
|
|
314
|
+
let browser: Browser;
|
|
315
|
+
let css: string;
|
|
316
|
+
let controller: string;
|
|
317
|
+
const spies: { mockRestore(): void }[] = [];
|
|
318
|
+
// Register hooks in each importing test file; Bun caches this module across files.
|
|
319
|
+
// Keeping hooks at module scope would leave the second file with a closed browser.
|
|
320
|
+
/**
|
|
321
|
+
* Each suite passes `launchBrowser` from packages/ui/test/browser itself, so the test runner and the
|
|
322
|
+
* browser-launch check find it as a browser suite and the gate also runs it in WebKit.
|
|
323
|
+
*/
|
|
324
|
+
export function installCompactShellHarness(launch: typeof launchBrowser) {
|
|
325
|
+
// Several tests walk every device, each in a fresh browser context. Bun scopes the default timeout to
|
|
326
|
+
// the test file that sets it, and this module runs once per process, so each suite sets it here.
|
|
327
|
+
setDefaultTimeout(30_000);
|
|
328
|
+
beforeAll(async () => {
|
|
329
|
+
spies.push(
|
|
330
|
+
spyOn(railSnapshot, "readRailSnapshot").mockResolvedValue(defaultRailPreferences()),
|
|
331
|
+
spyOn(settingsMiddleware, "preloadLayoutAnnouncements").mockResolvedValue(undefined),
|
|
332
|
+
);
|
|
333
|
+
const build = await Bun.build({ entrypoints: [resolve(import.meta.dir, "../../../../styles.css")], plugins: [tailwind] });
|
|
334
|
+
if (!build.success) throw new AggregateError(build.logs, "Could not compile the global stylesheet.");
|
|
335
|
+
css = await build.outputs[0]!.text();
|
|
336
|
+
// Cloud installs the shared workspace controller once for the document; the
|
|
337
|
+
// test installs the same export from the shipped browser build.
|
|
338
|
+
const entry = resolve(import.meta.dir, "compact-shell.controller.ts");
|
|
339
|
+
const bundle = await Bun.build({
|
|
340
|
+
entrypoints: [entry],
|
|
341
|
+
files: {
|
|
342
|
+
[entry]: `import { installAppWorkspaceController } from ${JSON.stringify(resolve(import.meta.dir, "../../../ui/dist/browser/index.js"))};
|
|
343
|
+
window.installController = () => installAppWorkspaceController();`,
|
|
344
|
+
},
|
|
345
|
+
target: "browser",
|
|
346
|
+
format: "iife",
|
|
347
|
+
});
|
|
348
|
+
if (!bundle.success) throw new AggregateError(bundle.logs, "Could not bundle the workspace controller.");
|
|
349
|
+
controller = await bundle.outputs[0]!.text();
|
|
350
|
+
browser = await launch();
|
|
351
|
+
}, 60_000);
|
|
352
|
+
afterAll(async () => {
|
|
353
|
+
for (const spy of spies.splice(0)) spy.mockRestore();
|
|
354
|
+
await browser?.close();
|
|
355
|
+
});
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
export type Device = { name: string; width: number; height: number; touch: boolean };
|
|
359
|
+
export const phone: Device = { name: "phone", width: 390, height: 844, touch: true };
|
|
360
|
+
export const ipadSplitView: Device = { name: "iPad Split View", width: 694, height: 834, touch: true };
|
|
361
|
+
export const ipadPortrait: Device = { name: "iPad portrait", width: 834, height: 1194, touch: true };
|
|
362
|
+
export const smallLaptop: Device = { name: "small laptop", width: 1024, height: 768, touch: false };
|
|
363
|
+
export const ipadLandscape: Device = { name: "iPad landscape", width: 1194, height: 834, touch: true };
|
|
364
|
+
export const laptop: Device = { name: "laptop", width: 1440, height: 900, touch: false };
|
|
365
|
+
export const desktop: Device = { name: "desktop", width: 1920, height: 1080, touch: false };
|
|
366
|
+
export const devices = [phone, ipadSplitView, ipadPortrait, smallLaptop, ipadLandscape, laptop, desktop];
|
|
367
|
+
export const tabletBoundary: Device = { name: "tablet boundary", width: 1023, height: 768, touch: false };
|
|
368
|
+
|
|
369
|
+
// The signed-in card's spacing and radius in whole pixels: 4 and 10 px at 1024 px, 8 and 14 px from 1440 px.
|
|
370
|
+
export const cardInset = (width: number) => Math.round(Math.min(8, Math.max(4, 4 + (width - 1024) / 104)));
|
|
371
|
+
export const cardRadius = (width: number) => Math.round(Math.min(14, Math.max(10, 10 + (width - 1024) / 104)));
|
|
372
|
+
|
|
373
|
+
export type Options = {
|
|
374
|
+
language?: "en" | "de";
|
|
375
|
+
theme?: "light" | "dark";
|
|
376
|
+
notice?: boolean;
|
|
377
|
+
style?: string;
|
|
378
|
+
insets?: Partial<Record<"top" | "right" | "bottom" | "left", number>>;
|
|
379
|
+
};
|
|
380
|
+
// Match the existing app-canvas safe-area fixture: engines cannot override env().
|
|
381
|
+
export const withInsets = (stylesheet: string, insets: Options["insets"] = {}) => {
|
|
382
|
+
for (const [side, inset] of Object.entries(insets)) {
|
|
383
|
+
const pattern = new RegExp(`env\\(\\s*safe-area-inset-${side}\\s*(?:,\\s*0px\\s*)?\\)`, "g");
|
|
384
|
+
if (!pattern.test(stylesheet)) throw new Error(`Missing safe-area-inset-${side}`);
|
|
385
|
+
stylesheet = stylesheet.replace(pattern, `${inset}px`);
|
|
386
|
+
}
|
|
387
|
+
return stylesheet;
|
|
388
|
+
};
|
|
389
|
+
export const html = async (path: string, options: Options = {}) => {
|
|
390
|
+
const headers = {
|
|
391
|
+
"Accept-Language": options.language ?? "en",
|
|
392
|
+
Cookie: `theme=${options.theme ?? "light"}`,
|
|
393
|
+
};
|
|
394
|
+
const markup = await (await server.request(path, { headers })).text();
|
|
395
|
+
const clean = new HTMLRewriter()
|
|
396
|
+
.on('script[src], script[type="module"]', { element: (element) => void element.remove() })
|
|
397
|
+
.on('link[rel="stylesheet"], link[rel="preload"], link[rel="modulepreload"]', { element: (element) => void element.remove() })
|
|
398
|
+
.transform(markup);
|
|
399
|
+
return clean.replace(
|
|
400
|
+
"</head>",
|
|
401
|
+
`<style>${withInsets(css, options.insets)}</style><style>*,*::before,*::after{transition:none!important;animation:none!important}${options.style ?? ""}</style></head>`,
|
|
402
|
+
);
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
/** Attaches the controller as Cloud's island does after the first paint, and lets its first frame run. */
|
|
406
|
+
export const attach = async (page: Page) => {
|
|
407
|
+
await page.addScriptTag({ content: controller });
|
|
408
|
+
await page.evaluate(async () => {
|
|
409
|
+
(window as unknown as { installController: () => void }).installController();
|
|
410
|
+
await new Promise((settle) => requestAnimationFrame(() => requestAnimationFrame(settle)));
|
|
411
|
+
});
|
|
412
|
+
};
|
|
413
|
+
|
|
414
|
+
export const open = async (
|
|
415
|
+
device: Device,
|
|
416
|
+
path: string,
|
|
417
|
+
options: Options & { attached?: boolean } = {},
|
|
418
|
+
): Promise<{ context: BrowserContext; page: Page }> => {
|
|
419
|
+
// iPads and phones report a coarse pointer without hover, as Safari does. A page set from markup has
|
|
420
|
+
// no cookies in WebKit, so the theme script follows the system scheme; it matches the requested theme.
|
|
421
|
+
const context = await browser.newContext({
|
|
422
|
+
viewport: { width: device.width, height: device.height },
|
|
423
|
+
hasTouch: device.touch,
|
|
424
|
+
isMobile: device.touch,
|
|
425
|
+
colorScheme: options.theme ?? "light",
|
|
426
|
+
});
|
|
427
|
+
const page = await context.newPage();
|
|
428
|
+
await page.setContent(await html(path, options));
|
|
429
|
+
if (options.attached !== false) await attach(page);
|
|
430
|
+
return { context, page };
|
|
431
|
+
};
|
package/src/ssr/help-messages.ts
CHANGED
|
@@ -5,38 +5,50 @@ export const helpMessages = i18n.define({
|
|
|
5
5
|
baseLocale: "en",
|
|
6
6
|
messages: {
|
|
7
7
|
en: {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
8
|
+
helpFor: ({ app }: { app: string }) => `Help for ${app}`,
|
|
9
|
+
moreActions: "More Help actions",
|
|
10
|
+
copyAsMarkdown: "Copy as Markdown",
|
|
11
|
+
openAsPage: "Open as page",
|
|
12
|
+
copyFailed: ({ message }: { message: string }) => `Could not copy the article: ${message}`,
|
|
13
|
+
forThisView: "For this view",
|
|
14
|
+
whatToDo: "What do you want to do?",
|
|
15
|
+
groupStart: "Get started",
|
|
16
|
+
groupReference: "Look up",
|
|
17
|
+
groupTroubleshoot: "Solve problems",
|
|
18
|
+
keyboardShortcuts: "Keyboard shortcuts",
|
|
19
|
+
shortcutsEverywhere: "Everywhere",
|
|
20
|
+
shortcutsInApp: ({ app }: { app: string }) => `In ${app}`,
|
|
21
|
+
shortcutsInThisApp: "In this app",
|
|
22
|
+
shortcutsWithSelection: "With a selection",
|
|
23
|
+
noArticles: "No Help articles here yet",
|
|
24
|
+
reload: "Reload",
|
|
25
|
+
noArticlesDetail: "This app has not published Help yet.",
|
|
26
|
+
helpUnavailable: "Help could not be loaded",
|
|
27
|
+
helpUnavailableDetail: "Reload the page to try again.",
|
|
28
|
+
titlesOnly: "Titles only",
|
|
29
|
+
searchTitlesOnly: "Full-text search is not available right now. These results match titles and descriptions only.",
|
|
11
30
|
searchHelp: "Search help",
|
|
12
|
-
section: "Section",
|
|
13
31
|
help: "Help",
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
32
|
+
youNeed: "You need",
|
|
33
|
+
accessView: "View",
|
|
34
|
+
accessEdit: "Edit",
|
|
35
|
+
accessManage: "Manage",
|
|
36
|
+
steps: ({ count }: { count: number }) => `${count} ${count === 1 ? "step" : "steps"}`,
|
|
37
|
+
articleFacts: "About this article",
|
|
38
|
+
next: "Next",
|
|
39
|
+
related: "Related",
|
|
19
40
|
back: "Back",
|
|
20
|
-
backToHelp: "Back to help",
|
|
21
41
|
backToTopics: "Back to help topics",
|
|
22
42
|
actions: "Help actions",
|
|
23
43
|
copied: "Copied",
|
|
24
44
|
helpCopied: "Help copied as Markdown",
|
|
25
|
-
copyAllHelp: "Copy all help as Markdown",
|
|
26
45
|
articleCopied: "Article copied as Markdown",
|
|
27
46
|
copyArticle: "Copy article as Markdown",
|
|
28
|
-
openWindow: "Open help in a new browser window",
|
|
29
|
-
openFullPage: "Open full-page help",
|
|
30
47
|
closeHelp: "Close help",
|
|
31
48
|
close: "Close",
|
|
32
|
-
heading: "How can we help?",
|
|
33
|
-
headingDescription: "Find a task, concept, or shortcut for the current app.",
|
|
34
49
|
preparing: "Preparing…",
|
|
35
|
-
copyAllShort: "Copy all as Markdown",
|
|
36
50
|
copyAllFailed: ({ message }: { message: string }) => `Could not copy all help: ${message}`,
|
|
37
51
|
searchPlaceholder: "Search help…",
|
|
38
|
-
startHere: "Start here",
|
|
39
|
-
allTopics: "All topics",
|
|
40
52
|
results: ({ count }: { count: number }) => `${count} ${count === 1 ? "result" : "results"}`,
|
|
41
53
|
noResults: "No help topic matches this search.",
|
|
42
54
|
copyMarkdown: "Copy Markdown",
|
|
@@ -45,7 +57,6 @@ export const helpMessages = i18n.define({
|
|
|
45
57
|
tryAgain: "Try again",
|
|
46
58
|
onThisPage: "On this page",
|
|
47
59
|
articleCopiedAnnouncement: "Article copied as Markdown.",
|
|
48
|
-
allCopiedAnnouncement: "All help copied as Markdown.",
|
|
49
60
|
searchFailed: "Help could not be searched. Try again.",
|
|
50
61
|
requestFailed: "Help could not be loaded. Try again.",
|
|
51
62
|
invalidDocument: "Help server returned an invalid document",
|
|
@@ -53,38 +64,50 @@ export const helpMessages = i18n.define({
|
|
|
53
64
|
copyFallback: "Could not copy help",
|
|
54
65
|
},
|
|
55
66
|
de: {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
67
|
+
helpFor: ({ app }) => `Hilfe zu ${app}`,
|
|
68
|
+
moreActions: "Weitere Hilfeaktionen",
|
|
69
|
+
copyAsMarkdown: "Als Markdown kopieren",
|
|
70
|
+
openAsPage: "Als Seite öffnen",
|
|
71
|
+
copyFailed: ({ message }) => `Der Artikel konnte nicht kopiert werden: ${message}`,
|
|
72
|
+
forThisView: "Für diese Ansicht",
|
|
73
|
+
whatToDo: "Was möchtest du tun?",
|
|
74
|
+
groupStart: "Loslegen",
|
|
75
|
+
groupReference: "Nachschlagen",
|
|
76
|
+
groupTroubleshoot: "Probleme lösen",
|
|
77
|
+
keyboardShortcuts: "Tastaturkürzel",
|
|
78
|
+
shortcutsEverywhere: "Überall",
|
|
79
|
+
shortcutsInApp: ({ app }) => `In ${app}`,
|
|
80
|
+
shortcutsInThisApp: "In dieser App",
|
|
81
|
+
shortcutsWithSelection: "Mit Auswahl",
|
|
82
|
+
noArticles: "Hier gibt es noch keine Hilfeartikel",
|
|
83
|
+
reload: "Neu laden",
|
|
84
|
+
noArticlesDetail: "Diese App hat noch keine Hilfe veröffentlicht.",
|
|
85
|
+
helpUnavailable: "Die Hilfe konnte nicht geladen werden",
|
|
86
|
+
helpUnavailableDetail: "Lade die Seite neu, um es erneut zu versuchen.",
|
|
87
|
+
titlesOnly: "Nur Titel",
|
|
88
|
+
searchTitlesOnly: "Die Volltextsuche ist gerade nicht verfügbar. Diese Treffer passen nur zu Titeln und Beschreibungen.",
|
|
59
89
|
searchHelp: "Hilfe durchsuchen",
|
|
60
|
-
section: "Abschnitt",
|
|
61
90
|
help: "Hilfe",
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
91
|
+
youNeed: "Du brauchst",
|
|
92
|
+
accessView: "Ansehen",
|
|
93
|
+
accessEdit: "Bearbeiten",
|
|
94
|
+
accessManage: "Verwalten",
|
|
95
|
+
steps: ({ count }) => i18n.plural(count, "de", { one: "1 Schritt", other: `${count} Schritte` }),
|
|
96
|
+
articleFacts: "Zu diesem Artikel",
|
|
97
|
+
next: "Als Nächstes",
|
|
98
|
+
related: "Passt dazu",
|
|
67
99
|
back: "Zurück",
|
|
68
|
-
backToHelp: "Zurück zur Hilfe",
|
|
69
100
|
backToTopics: "Zurück zu den Hilfethemen",
|
|
70
101
|
actions: "Hilfeaktionen",
|
|
71
102
|
copied: "Kopiert",
|
|
72
103
|
helpCopied: "Hilfe als Markdown kopiert",
|
|
73
|
-
copyAllHelp: "Gesamte Hilfe als Markdown kopieren",
|
|
74
104
|
articleCopied: "Artikel als Markdown kopiert",
|
|
75
105
|
copyArticle: "Artikel als Markdown kopieren",
|
|
76
|
-
openWindow: "Hilfe in einem neuen Browserfenster öffnen",
|
|
77
|
-
openFullPage: "Hilfe als ganze Seite öffnen",
|
|
78
106
|
closeHelp: "Hilfe schließen",
|
|
79
107
|
close: "Schließen",
|
|
80
|
-
heading: "Wobei brauchst du Hilfe?",
|
|
81
|
-
headingDescription: "Finde eine Aufgabe, ein Konzept oder ein Tastaturkürzel für die aktuelle App.",
|
|
82
108
|
preparing: "Wird vorbereitet…",
|
|
83
|
-
copyAllShort: "Alles als Markdown kopieren",
|
|
84
109
|
copyAllFailed: ({ message }) => `Die gesamte Hilfe konnte nicht kopiert werden: ${message}`,
|
|
85
110
|
searchPlaceholder: "Hilfe durchsuchen…",
|
|
86
|
-
startHere: "Hier starten",
|
|
87
|
-
allTopics: "Alle Themen",
|
|
88
111
|
results: ({ count }) => i18n.plural(count, "de", { one: "1 Ergebnis", other: `${count} Ergebnisse` }),
|
|
89
112
|
noResults: "Kein Hilfethema passt zu dieser Suche.",
|
|
90
113
|
copyMarkdown: "Markdown kopieren",
|
|
@@ -93,7 +116,6 @@ export const helpMessages = i18n.define({
|
|
|
93
116
|
tryAgain: "Erneut versuchen",
|
|
94
117
|
onThisPage: "Auf dieser Seite",
|
|
95
118
|
articleCopiedAnnouncement: "Artikel als Markdown kopiert.",
|
|
96
|
-
allCopiedAnnouncement: "Gesamte Hilfe als Markdown kopiert.",
|
|
97
119
|
searchFailed: "Die Hilfe konnte nicht durchsucht werden. Versuche es erneut.",
|
|
98
120
|
requestFailed: "Die Hilfe konnte nicht geladen werden. Versuche es erneut.",
|
|
99
121
|
invalidDocument: "Der Hilfeserver hat ein ungültiges Dokument zurückgegeben",
|
package/src/ssr/help.ts
CHANGED
|
@@ -7,8 +7,10 @@ import { resolveCurrentApp } from "./app-appearance";
|
|
|
7
7
|
import { getRuntimeContext } from "./runtime";
|
|
8
8
|
|
|
9
9
|
const manifests = new WeakMap<object, HelpManifestResult | null>();
|
|
10
|
+
const failedContexts = new WeakSet<object>();
|
|
10
11
|
const log = logger("help:ssr");
|
|
11
12
|
export const getLayoutHelp = (c: object): HelpManifestResult | null => manifests.get(c) ?? null;
|
|
13
|
+
export const layoutHelpFailed = (c: object): boolean => failedContexts.has(c);
|
|
12
14
|
|
|
13
15
|
/** Help metadata for the current or an explicit app; `null` without a user or when the user may not see the app. */
|
|
14
16
|
export const preloadLayoutHelp = async (c: Context, appId?: string): Promise<HelpManifestResult | null> => {
|
|
@@ -22,6 +24,7 @@ export const preloadLayoutHelp = async (c: Context, appId?: string): Promise<Hel
|
|
|
22
24
|
return manifest;
|
|
23
25
|
} catch (error) {
|
|
24
26
|
// An unavailable Help store must not prevent an unrelated application page from rendering.
|
|
27
|
+
failedContexts.add(c);
|
|
25
28
|
log.warn("Help metadata unavailable", { appId: id, error: error instanceof Error ? error.message : String(error) });
|
|
26
29
|
manifests.set(c, null);
|
|
27
30
|
return null;
|
package/src/ssr/index.ts
CHANGED
|
@@ -4,9 +4,11 @@
|
|
|
4
4
|
export { default as AdminLayout } from "./AdminLayout";
|
|
5
5
|
export {
|
|
6
6
|
hasDedicatedRuntimeRoute,
|
|
7
|
+
type PanelProvider,
|
|
7
8
|
resolveRuntimeRoute,
|
|
8
9
|
type VisiblePwaPart,
|
|
9
10
|
visibleNavigationApps,
|
|
11
|
+
visiblePanelProviders,
|
|
10
12
|
visiblePwaParts,
|
|
11
13
|
} from "./app-navigation";
|
|
12
14
|
export { default as Layout } from "./Layout";
|
|
@@ -15,6 +17,7 @@ export {
|
|
|
15
17
|
type MinimalLayoutPreferencePosition,
|
|
16
18
|
type MinimalLayoutProps,
|
|
17
19
|
} from "./MinimalLayout";
|
|
20
|
+
export { default as PanelLayout, type PanelLayoutProps } from "./PanelLayout";
|
|
18
21
|
export { default as PwaLayout, type PwaLayoutProps } from "./PwaLayout";
|
|
19
22
|
export { requestPath } from "./request-path";
|
|
20
23
|
export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** What the article head, the table of contents, and the step count read from the rendered Help HTML. */
|
|
2
|
+
export type HelpArticleParts = {
|
|
3
|
+
/** The first paragraph: the article's short answer. */
|
|
4
|
+
lead?: string;
|
|
5
|
+
body: string;
|
|
6
|
+
sections: { id: string; label: string }[];
|
|
7
|
+
steps: number;
|
|
8
|
+
};
|
|
9
|
+
|
|
10
|
+
/** The text a screen reader reads: key names instead of key caps, no icons or decorative separators. */
|
|
11
|
+
const spokenText = (element: Element): string => {
|
|
12
|
+
const copy = element.cloneNode(true) as Element;
|
|
13
|
+
for (const hidden of copy.querySelectorAll('[aria-hidden="true"]')) hidden.remove();
|
|
14
|
+
return copy.textContent?.replace(/\s+/g, " ").trim() ?? "";
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Splits trusted Help HTML before it renders, so the head shows the short answer, the step count, and the
|
|
19
|
+
* section menu in the same frame as the body and nothing moves afterwards.
|
|
20
|
+
*/
|
|
21
|
+
export const helpArticleParts = (html: string): HelpArticleParts => {
|
|
22
|
+
// A template parses inert markup: nothing in it loads or runs.
|
|
23
|
+
const template = document.createElement("template");
|
|
24
|
+
template.innerHTML = html;
|
|
25
|
+
const body = template.content;
|
|
26
|
+
const first = body.firstElementChild;
|
|
27
|
+
// A bold-only paragraph is an eyebrow (`help-eyebrow`), not the short answer.
|
|
28
|
+
const lead = first?.tagName === "P" && !first.className ? first.outerHTML : undefined;
|
|
29
|
+
if (lead) first?.remove();
|
|
30
|
+
return {
|
|
31
|
+
lead,
|
|
32
|
+
body: template.innerHTML,
|
|
33
|
+
// A heading inside collapsed details is not a section of the page.
|
|
34
|
+
sections: Array.from(body.querySelectorAll("h2[id]"))
|
|
35
|
+
.filter((heading) => !heading.closest("details"))
|
|
36
|
+
.map((heading) => ({ id: heading.id, label: spokenText(heading) }))
|
|
37
|
+
.filter((section) => section.label),
|
|
38
|
+
// Steps inside a collapsed `details` block are an aside, not the task.
|
|
39
|
+
steps: Array.from(body.querySelectorAll(".help-steps > ol > li")).filter((step) => !step.closest("details")).length,
|
|
40
|
+
};
|
|
41
|
+
};
|