@k2b/cloud 0.38.0 → 0.40.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/README.md +8 -0
- package/package.json +12 -5
- package/scripts/build-canvas-workers.ts +109 -29
- package/scripts/build.ts +3 -5
- package/src/_internal/app-favicon.ts +4 -2
- 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/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +63 -16
- package/src/_internal/font-preloads.ts +2 -1
- 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/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.browser-harness.tsx +57 -38
- package/src/access/PermissionEditor.tsx +32 -7
- package/src/access/ResourceApiKeys.tsx +5 -1
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/approval-routes.ts +5 -2
- package/src/ai/approvals.ts +139 -28
- package/src/ai/background-review.ts +72 -0
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/action-sentences.ts +1 -44
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +45 -163
- package/src/ai/chat/view-block.tsx +22 -7
- package/src/ai/chat-task-contracts.ts +31 -3
- package/src/ai/chat-task-reasons-migrate.ts +75 -0
- package/src/ai/chat-task-reasons.ts +92 -0
- package/src/ai/chat-tasks.ts +438 -78
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +14 -0
- package/src/ai/migrate.ts +39 -4
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/request-context.ts +7 -1
- package/src/ai/routes.ts +172 -2
- package/src/ai/runtime.ts +13 -27
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +232 -27
- package/src/ai/timeline.ts +9 -1
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +67 -0
- package/src/ai/turn-abort.ts +33 -0
- package/src/ai/types.ts +40 -4
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-core-settings.ts +190 -139
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +6 -2
- package/src/api/app-approval.ts +54 -6
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +3 -1
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +7 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/CloudResourceSearch.tsx +10 -6
- package/src/browser/app-approval.ts +60 -6
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +145 -0
- package/src/browser/composer-insert-messages.ts +31 -0
- package/src/browser/composer-insert.ts +160 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +35 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +756 -0
- package/src/browser/panel.ts +65 -0
- package/src/browser/resource-picker.tsx +12 -1
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +159 -12
- package/src/contracts/app.ts +14 -0
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +2 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/server/services/access.ts +5 -1
- package/src/services/accounts/posix.ts +2 -1
- package/src/services/app-approval.ts +533 -90
- package/src/services/approvals/device.ts +9 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +32 -3
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/browser.ts +16 -5
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- 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/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -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 +19 -17
- package/src/services/settings/namespace.ts +2 -1
- package/src/services/settings/store.ts +83 -11
- package/src/services/webauthn.ts +12 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/app-presentation.ts +1 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared/redirect.ts +12 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +95 -10
- package/src/ssr/Layout.tsx +66 -10
- package/src/ssr/LayoutHeader.tsx +15 -9
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/LayoutRail.tsx +2 -2
- package/src/ssr/LayoutTabBar.tsx +29 -0
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +35 -0
- package/src/ssr/PanelRuntime.island.tsx +94 -0
- package/src/ssr/PanelSlot.tsx +158 -0
- package/src/ssr/PanelTrigger.tsx +49 -0
- package/src/ssr/ShellRuntime.island.tsx +69 -5
- package/src/ssr/app-appearance.ts +3 -2
- package/src/ssr/app-color.ts +105 -0
- package/src/ssr/app-launchpad.tsx +18 -1
- package/src/ssr/app-navigation.ts +28 -0
- package/src/ssr/compact-shell.baseline.json +5270 -0
- package/src/ssr/compact-shell.browser-harness.ts +439 -0
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +5 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +159 -0
- package/src/ssr/platform-messages.ts +48 -0
- package/src/ssr/rail-navigation.ts +44 -0
- package/src/ssr/shell-data.tsx +5 -0
- package/src/ssr/shell-shortcuts.ts +13 -2
- package/src/styles/effects.css +201 -11
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +314 -63
- package/src/styles/panel.css +416 -0
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +36 -13
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +22 -43
|
@@ -0,0 +1,439 @@
|
|
|
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 = (head = false) =>
|
|
120
|
+
createComponent(AppWorkspace, {
|
|
121
|
+
class: "min-h-0 flex-1",
|
|
122
|
+
get children() {
|
|
123
|
+
return [
|
|
124
|
+
head ? createComponent(AppWorkspace.Head, { primary: { label: "New task", icon: "ti ti-plus", href: "?new=task" } }) : null,
|
|
125
|
+
createComponent(AppWorkspace.Content, {
|
|
126
|
+
get children() {
|
|
127
|
+
return [
|
|
128
|
+
createComponent(AppWorkspace.Main, {
|
|
129
|
+
get children() {
|
|
130
|
+
return Array.from({ length: 80 }, (_, index) =>
|
|
131
|
+
createComponent(Button, {
|
|
132
|
+
variant: head ? "ghost" : undefined,
|
|
133
|
+
class: "list-row",
|
|
134
|
+
align: "start",
|
|
135
|
+
children: `Task ${index + 1}`,
|
|
136
|
+
}),
|
|
137
|
+
);
|
|
138
|
+
},
|
|
139
|
+
}),
|
|
140
|
+
createComponent(AppWorkspace.Detail, {
|
|
141
|
+
id: "task-detail",
|
|
142
|
+
open: true,
|
|
143
|
+
get children() {
|
|
144
|
+
return createComponent(Button, { "data-workspace-detail-close": "", children: "Close task" });
|
|
145
|
+
},
|
|
146
|
+
}),
|
|
147
|
+
];
|
|
148
|
+
},
|
|
149
|
+
}),
|
|
150
|
+
];
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
/** A sidebar-first overview that is the page itself, as Mail and Notebooks render. Invented content. */
|
|
155
|
+
const overviewWorkspace = () =>
|
|
156
|
+
createComponent(AppWorkspace, {
|
|
157
|
+
mobileSurface: "flush",
|
|
158
|
+
flat: true,
|
|
159
|
+
resizable: false,
|
|
160
|
+
get children() {
|
|
161
|
+
return [
|
|
162
|
+
createComponent(AppWorkspace.Sidebar, {
|
|
163
|
+
id: "overview-navigation",
|
|
164
|
+
label: "Mailboxes",
|
|
165
|
+
get children() {
|
|
166
|
+
return createComponent(AppWorkspace.SidebarDesktop, {
|
|
167
|
+
get children() {
|
|
168
|
+
return createComponent(AppWorkspace.SidebarBody, {
|
|
169
|
+
get children() {
|
|
170
|
+
return ["Personal", "Projects", "Support"].map((name) =>
|
|
171
|
+
createComponent(AppWorkspace.SidebarItem, { href: `#${name.toLowerCase()}`, children: name }),
|
|
172
|
+
);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
},
|
|
178
|
+
}),
|
|
179
|
+
createComponent(AppWorkspace.Content, {
|
|
180
|
+
get children() {
|
|
181
|
+
return createComponent(AppWorkspace.Main, {
|
|
182
|
+
get children() {
|
|
183
|
+
return Array.from({ length: 80 }, (_, index) =>
|
|
184
|
+
createComponent(Button, { class: "overview-row", align: "start", children: `Message ${index + 1}` }),
|
|
185
|
+
);
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
},
|
|
189
|
+
}),
|
|
190
|
+
];
|
|
191
|
+
},
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
const server = new Hono()
|
|
195
|
+
.get(
|
|
196
|
+
"/",
|
|
197
|
+
async (c, next) => {
|
|
198
|
+
c.set("runtime" as never, { apps } as never);
|
|
199
|
+
c.set("user" as never, person as never);
|
|
200
|
+
await next();
|
|
201
|
+
},
|
|
202
|
+
...probe.ssr((c) => () => createComponent(Layout, { c: c as unknown as LayoutContextArg, title: "Start", children: "Start content" })),
|
|
203
|
+
)
|
|
204
|
+
.get(
|
|
205
|
+
"/app/mail/:folder",
|
|
206
|
+
async (c, next) => {
|
|
207
|
+
// `?name=` renames Mail, for a long app name in the phone header.
|
|
208
|
+
const name = c.req.query("name");
|
|
209
|
+
const catalog = c.req.query("catalog") ? [...extraApps, ...apps] : apps;
|
|
210
|
+
c.set("runtime" as never, { apps: name ? catalog.map((app) => (app.id === "mail" ? { ...app, name } : app)) : catalog } as never);
|
|
211
|
+
// A missing last name shows the profile notice above the app.
|
|
212
|
+
c.set("user" as never, (c.req.query("notice") ? { ...person, sn: "" } : person) as never);
|
|
213
|
+
await next();
|
|
214
|
+
},
|
|
215
|
+
...probe.ssr((c) => {
|
|
216
|
+
const folder = c.req.param("folder") ?? "inbox";
|
|
217
|
+
const title = folder[0]!.toUpperCase() + folder.slice(1);
|
|
218
|
+
if (c.req.query("navigation"))
|
|
219
|
+
return () =>
|
|
220
|
+
createComponent(Layout, {
|
|
221
|
+
c: c as unknown as LayoutContextArg,
|
|
222
|
+
title: [{ title: "Home", href: "/" }, { title: "Mail" }],
|
|
223
|
+
get children() {
|
|
224
|
+
return [
|
|
225
|
+
createComponent(WorkspaceNavigation, { label: "Mail", items: [{ id: "inbox", label: "Inbox", href: "/app/mail/inbox" }] }),
|
|
226
|
+
overviewWorkspace(),
|
|
227
|
+
];
|
|
228
|
+
},
|
|
229
|
+
});
|
|
230
|
+
// `?overview=` keeps regular Layout props, including its page scroller's gutter.
|
|
231
|
+
if (c.req.query("overview"))
|
|
232
|
+
return () =>
|
|
233
|
+
createComponent(Layout, {
|
|
234
|
+
c: c as unknown as LayoutContextArg,
|
|
235
|
+
title: [{ title: "Home", href: "/" }, { title: "Mail" }],
|
|
236
|
+
get children() {
|
|
237
|
+
return overviewWorkspace();
|
|
238
|
+
},
|
|
239
|
+
});
|
|
240
|
+
// `?plain=` renders a regular page without a workspace or scroll area: `main` is its page scroller.
|
|
241
|
+
if (c.req.query("plain"))
|
|
242
|
+
return () =>
|
|
243
|
+
createComponent(Layout, {
|
|
244
|
+
c: c as unknown as LayoutContextArg,
|
|
245
|
+
title: [{ title: "Home", href: "/" }, { title: "Settings" }],
|
|
246
|
+
get children() {
|
|
247
|
+
return Array.from({ length: 60 }, (_, index) =>
|
|
248
|
+
createComponent(Button, { class: "plain-row w-full", align: "start", children: `Setting ${index + 1}` }),
|
|
249
|
+
);
|
|
250
|
+
},
|
|
251
|
+
});
|
|
252
|
+
// `?cards=` renders a page without a workspace that scrolls its content in a ScrollArea, as
|
|
253
|
+
// Capabilities and Weather do.
|
|
254
|
+
if (c.req.query("cards"))
|
|
255
|
+
return () =>
|
|
256
|
+
createComponent(Layout, {
|
|
257
|
+
c: c as unknown as LayoutContextArg,
|
|
258
|
+
fullWidth: true,
|
|
259
|
+
title: [{ title: "Home", href: "/" }, { title: "Capabilities" }],
|
|
260
|
+
get children() {
|
|
261
|
+
return createComponent(ScrollArea, {
|
|
262
|
+
class: "min-h-0 min-w-0 flex-1",
|
|
263
|
+
get children() {
|
|
264
|
+
return Array.from({ length: 60 }, (_, index) =>
|
|
265
|
+
createComponent(Button, { class: "card-row w-full", align: "start", children: `App ${index + 1}` }),
|
|
266
|
+
);
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
},
|
|
270
|
+
});
|
|
271
|
+
// `?list=` renders the task list: `scroll` in a page that grows with its content, `page` in a full page.
|
|
272
|
+
const list = c.req.query("list");
|
|
273
|
+
if (list)
|
|
274
|
+
return () =>
|
|
275
|
+
createComponent(Layout, {
|
|
276
|
+
c: c as unknown as LayoutContextArg,
|
|
277
|
+
fullWidth: true,
|
|
278
|
+
fullPage: list === "page",
|
|
279
|
+
title: [{ title: "Home", href: "/" }, { title: "Tasks" }],
|
|
280
|
+
get children() {
|
|
281
|
+
return listWorkspace(c.req.query("head") === "1");
|
|
282
|
+
},
|
|
283
|
+
});
|
|
284
|
+
return () =>
|
|
285
|
+
createComponent(Layout, {
|
|
286
|
+
c: c as unknown as LayoutContextArg,
|
|
287
|
+
fullWidth: true,
|
|
288
|
+
fullPage: true,
|
|
289
|
+
title: [{ title: "Home", href: "/" }, { title: "Mail", href: "/app/mail" }, { title }],
|
|
290
|
+
get children() {
|
|
291
|
+
return workspace(folder);
|
|
292
|
+
},
|
|
293
|
+
});
|
|
294
|
+
}),
|
|
295
|
+
)
|
|
296
|
+
.get(
|
|
297
|
+
"/public",
|
|
298
|
+
async (c, next) => {
|
|
299
|
+
c.set("runtime" as never, { apps } as never);
|
|
300
|
+
await next();
|
|
301
|
+
},
|
|
302
|
+
...probe.ssr(
|
|
303
|
+
(c) => () =>
|
|
304
|
+
createComponent(Layout, {
|
|
305
|
+
c: c as unknown as LayoutContextArg,
|
|
306
|
+
title: [{ title: "Home", href: "/" }, { title: c.req.query("title") ?? "Public tools" }],
|
|
307
|
+
get children() {
|
|
308
|
+
if (c.req.query("cards"))
|
|
309
|
+
return createComponent(ScrollArea, {
|
|
310
|
+
get children() {
|
|
311
|
+
return Array.from({ length: 60 }, (_, index) =>
|
|
312
|
+
createComponent(Button, { class: "card-row", children: `App ${index + 1}` }),
|
|
313
|
+
);
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
return c.req.query("overview") ? overviewWorkspace() : "Public content";
|
|
317
|
+
},
|
|
318
|
+
}),
|
|
319
|
+
),
|
|
320
|
+
);
|
|
321
|
+
|
|
322
|
+
let browser: Browser;
|
|
323
|
+
let css: string;
|
|
324
|
+
let controller: string;
|
|
325
|
+
const spies: { mockRestore(): void }[] = [];
|
|
326
|
+
// Register hooks in each importing test file; Bun caches this module across files.
|
|
327
|
+
// Keeping hooks at module scope would leave the second file with a closed browser.
|
|
328
|
+
/**
|
|
329
|
+
* Each suite passes `launchBrowser` from packages/ui/test/browser itself, so the test runner and the
|
|
330
|
+
* browser-launch check find it as a browser suite and the gate also runs it in WebKit.
|
|
331
|
+
*/
|
|
332
|
+
export function installCompactShellHarness(launch: typeof launchBrowser) {
|
|
333
|
+
// Several tests walk every device, each in a fresh browser context. Bun scopes the default timeout to
|
|
334
|
+
// the test file that sets it, and this module runs once per process, so each suite sets it here.
|
|
335
|
+
setDefaultTimeout(30_000);
|
|
336
|
+
beforeAll(async () => {
|
|
337
|
+
spies.push(
|
|
338
|
+
spyOn(railSnapshot, "readRailSnapshot").mockResolvedValue(defaultRailPreferences()),
|
|
339
|
+
spyOn(settingsMiddleware, "preloadLayoutAnnouncements").mockResolvedValue(undefined),
|
|
340
|
+
);
|
|
341
|
+
const build = await Bun.build({ entrypoints: [resolve(import.meta.dir, "../../../../styles.css")], plugins: [tailwind] });
|
|
342
|
+
if (!build.success) throw new AggregateError(build.logs, "Could not compile the global stylesheet.");
|
|
343
|
+
css = await build.outputs[0]!.text();
|
|
344
|
+
// Cloud installs the shared workspace controller once for the document; the
|
|
345
|
+
// test installs the same export from the shipped browser build.
|
|
346
|
+
const entry = resolve(import.meta.dir, "compact-shell.controller.ts");
|
|
347
|
+
const bundle = await Bun.build({
|
|
348
|
+
entrypoints: [entry],
|
|
349
|
+
files: {
|
|
350
|
+
[entry]: `import { installAppWorkspaceController } from ${JSON.stringify(resolve(import.meta.dir, "../../../ui/dist/browser/index.js"))};
|
|
351
|
+
window.installController = () => installAppWorkspaceController();`,
|
|
352
|
+
},
|
|
353
|
+
target: "browser",
|
|
354
|
+
format: "iife",
|
|
355
|
+
});
|
|
356
|
+
if (!bundle.success) throw new AggregateError(bundle.logs, "Could not bundle the workspace controller.");
|
|
357
|
+
controller = await bundle.outputs[0]!.text();
|
|
358
|
+
browser = await launch();
|
|
359
|
+
}, 60_000);
|
|
360
|
+
afterAll(async () => {
|
|
361
|
+
for (const spy of spies.splice(0)) spy.mockRestore();
|
|
362
|
+
await browser?.close();
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
export type Device = { name: string; width: number; height: number; touch: boolean };
|
|
367
|
+
export const phone: Device = { name: "phone", width: 390, height: 844, touch: true };
|
|
368
|
+
export const ipadSplitView: Device = { name: "iPad Split View", width: 694, height: 834, touch: true };
|
|
369
|
+
export const ipadPortrait: Device = { name: "iPad portrait", width: 834, height: 1194, touch: true };
|
|
370
|
+
export const smallLaptop: Device = { name: "small laptop", width: 1024, height: 768, touch: false };
|
|
371
|
+
export const ipadLandscape: Device = { name: "iPad landscape", width: 1194, height: 834, touch: true };
|
|
372
|
+
export const laptop: Device = { name: "laptop", width: 1440, height: 900, touch: false };
|
|
373
|
+
export const desktop: Device = { name: "desktop", width: 1920, height: 1080, touch: false };
|
|
374
|
+
export const devices = [phone, ipadSplitView, ipadPortrait, smallLaptop, ipadLandscape, laptop, desktop];
|
|
375
|
+
export const tabletBoundary: Device = { name: "tablet boundary", width: 1023, height: 768, touch: false };
|
|
376
|
+
|
|
377
|
+
// 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.
|
|
378
|
+
export const cardInset = (width: number) => Math.round(Math.min(8, Math.max(4, 4 + (width - 1024) / 104)));
|
|
379
|
+
export const cardRadius = (width: number) => Math.round(Math.min(14, Math.max(10, 10 + (width - 1024) / 104)));
|
|
380
|
+
|
|
381
|
+
export type Options = {
|
|
382
|
+
language?: "en" | "de";
|
|
383
|
+
theme?: "light" | "dark";
|
|
384
|
+
notice?: boolean;
|
|
385
|
+
style?: string;
|
|
386
|
+
insets?: Partial<Record<"top" | "right" | "bottom" | "left", number>>;
|
|
387
|
+
};
|
|
388
|
+
// Match the existing app-canvas safe-area fixture: engines cannot override env().
|
|
389
|
+
export const withInsets = (stylesheet: string, insets: Options["insets"] = {}) => {
|
|
390
|
+
for (const [side, inset] of Object.entries(insets)) {
|
|
391
|
+
const pattern = new RegExp(`env\\(\\s*safe-area-inset-${side}\\s*(?:,\\s*0px\\s*)?\\)`, "g");
|
|
392
|
+
if (!pattern.test(stylesheet)) throw new Error(`Missing safe-area-inset-${side}`);
|
|
393
|
+
stylesheet = stylesheet.replace(pattern, `${inset}px`);
|
|
394
|
+
}
|
|
395
|
+
return stylesheet;
|
|
396
|
+
};
|
|
397
|
+
export const html = async (path: string, options: Options = {}) => {
|
|
398
|
+
const headers = {
|
|
399
|
+
"Accept-Language": options.language ?? "en",
|
|
400
|
+
Cookie: `theme=${options.theme ?? "light"}`,
|
|
401
|
+
};
|
|
402
|
+
const markup = await (await server.request(path, { headers })).text();
|
|
403
|
+
const clean = new HTMLRewriter()
|
|
404
|
+
.on('script[src], script[type="module"]', { element: (element) => void element.remove() })
|
|
405
|
+
.on('link[rel="stylesheet"], link[rel="preload"], link[rel="modulepreload"]', { element: (element) => void element.remove() })
|
|
406
|
+
.transform(markup);
|
|
407
|
+
return clean.replace(
|
|
408
|
+
"</head>",
|
|
409
|
+
`<style>${withInsets(css, options.insets)}</style><style>*,*::before,*::after{transition:none!important;animation:none!important}${options.style ?? ""}</style></head>`,
|
|
410
|
+
);
|
|
411
|
+
};
|
|
412
|
+
|
|
413
|
+
/** Attaches the controller as Cloud's island does after the first paint, and lets its first frame run. */
|
|
414
|
+
export const attach = async (page: Page) => {
|
|
415
|
+
await page.addScriptTag({ content: controller });
|
|
416
|
+
await page.evaluate(async () => {
|
|
417
|
+
(window as unknown as { installController: () => void }).installController();
|
|
418
|
+
await new Promise((settle) => requestAnimationFrame(() => requestAnimationFrame(settle)));
|
|
419
|
+
});
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
export const open = async (
|
|
423
|
+
device: Device,
|
|
424
|
+
path: string,
|
|
425
|
+
options: Options & { attached?: boolean } = {},
|
|
426
|
+
): Promise<{ context: BrowserContext; page: Page }> => {
|
|
427
|
+
// iPads and phones report a coarse pointer without hover, as Safari does. A page set from markup has
|
|
428
|
+
// no cookies in WebKit, so the theme script follows the system scheme; it matches the requested theme.
|
|
429
|
+
const context = await browser.newContext({
|
|
430
|
+
viewport: { width: device.width, height: device.height },
|
|
431
|
+
hasTouch: device.touch,
|
|
432
|
+
isMobile: device.touch,
|
|
433
|
+
colorScheme: options.theme ?? "light",
|
|
434
|
+
});
|
|
435
|
+
const page = await context.newPage();
|
|
436
|
+
await page.setContent(await html(path, options));
|
|
437
|
+
if (options.attached !== false) await attach(page);
|
|
438
|
+
return { context, page };
|
|
439
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { DataTable, prompts, SettingsModal } from "@k2b/ui";
|
|
2
|
+
import { render } from "solid-js/web";
|
|
3
|
+
|
|
4
|
+
const host = document.createElement("div");
|
|
5
|
+
document.querySelector(".cloud-app-canvas")!.append(host);
|
|
6
|
+
|
|
7
|
+
const SelectedTable = () => (
|
|
8
|
+
<DataTable
|
|
9
|
+
rows={[{ id: "selected", name: "Selected file" }]}
|
|
10
|
+
columns={[{ id: "name", header: "Name", value: "name" }]}
|
|
11
|
+
getRowId={(row) => row.id}
|
|
12
|
+
selectedRowId="selected"
|
|
13
|
+
surface="plain"
|
|
14
|
+
/>
|
|
15
|
+
);
|
|
16
|
+
|
|
17
|
+
render(
|
|
18
|
+
() => (
|
|
19
|
+
<button
|
|
20
|
+
type="button"
|
|
21
|
+
onClick={() =>
|
|
22
|
+
void prompts.dialog<void>(
|
|
23
|
+
(close) => (
|
|
24
|
+
<div class="dialog-fixed-frame flex min-h-0 flex-col overflow-hidden">
|
|
25
|
+
<SettingsModal title="App settings" onClose={() => close()}>
|
|
26
|
+
<SettingsModal.Tab id="general" title="General">
|
|
27
|
+
<SelectedTable />
|
|
28
|
+
</SettingsModal.Tab>
|
|
29
|
+
<SettingsModal.Tab id="files" title="Files">
|
|
30
|
+
<SelectedTable />
|
|
31
|
+
</SettingsModal.Tab>
|
|
32
|
+
</SettingsModal>
|
|
33
|
+
</div>
|
|
34
|
+
),
|
|
35
|
+
{ surface: "bare", header: false, size: "large" },
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
>
|
|
39
|
+
Open app settings
|
|
40
|
+
</button>
|
|
41
|
+
),
|
|
42
|
+
host,
|
|
43
|
+
);
|
package/src/ssr/help-messages.ts
CHANGED
|
@@ -20,6 +20,8 @@ export const helpMessages = i18n.define({
|
|
|
20
20
|
shortcutsInApp: ({ app }: { app: string }) => `In ${app}`,
|
|
21
21
|
shortcutsInThisApp: "In this app",
|
|
22
22
|
shortcutsWithSelection: "With a selection",
|
|
23
|
+
helpNotAvailable: "This Help is not available",
|
|
24
|
+
helpNotAvailableDetail: "You may not have access to it, or it no longer exists.",
|
|
23
25
|
noArticles: "No Help articles here yet",
|
|
24
26
|
reload: "Reload",
|
|
25
27
|
noArticlesDetail: "This app has not published Help yet.",
|
|
@@ -79,6 +81,8 @@ export const helpMessages = i18n.define({
|
|
|
79
81
|
shortcutsInApp: ({ app }) => `In ${app}`,
|
|
80
82
|
shortcutsInThisApp: "In dieser App",
|
|
81
83
|
shortcutsWithSelection: "Mit Auswahl",
|
|
84
|
+
helpNotAvailable: "Diese Hilfe ist nicht verfügbar",
|
|
85
|
+
helpNotAvailableDetail: "Vielleicht hast du keinen Zugriff, oder es gibt sie nicht mehr.",
|
|
82
86
|
noArticles: "Hier gibt es noch keine Hilfeartikel",
|
|
83
87
|
reload: "Neu laden",
|
|
84
88
|
noArticlesDetail: "Diese App hat noch keine Hilfe veröffentlicht.",
|
package/src/ssr/help.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
import type { Context } from "hono";
|
|
2
|
+
import { buildHelpDocumentPayload } from "../server/help-payload";
|
|
2
3
|
import { getLocale } from "../server/locale";
|
|
3
4
|
import { createHelpReader } from "../services/help";
|
|
4
5
|
import type { HelpManifestResult } from "../services/help/types";
|
|
5
6
|
import { logger } from "../services/logging";
|
|
7
|
+
import type { HelpDocumentPayload } from "../shared/help";
|
|
6
8
|
import { resolveCurrentApp } from "./app-appearance";
|
|
9
|
+
import type { HelpArticleParts } from "./layout-help-article";
|
|
10
|
+
import { helpArticlePartsServer } from "./layout-help-article.server";
|
|
7
11
|
import { getRuntimeContext } from "./runtime";
|
|
8
12
|
|
|
9
13
|
const manifests = new WeakMap<object, HelpManifestResult | null>();
|
|
@@ -30,3 +34,19 @@ export const preloadLayoutHelp = async (c: Context, appId?: string): Promise<Hel
|
|
|
30
34
|
return null;
|
|
31
35
|
}
|
|
32
36
|
};
|
|
37
|
+
|
|
38
|
+
export type LayoutHelpArticle = { payload: HelpDocumentPayload; parts: HelpArticleParts };
|
|
39
|
+
|
|
40
|
+
/** An authorized initial article; failures leave the browser's on-demand reader available. */
|
|
41
|
+
export const preloadLayoutHelpArticle = async (c: Context, appId: string, documentId: string): Promise<LayoutHelpArticle | null> => {
|
|
42
|
+
if (!c.get("user")) return null;
|
|
43
|
+
try {
|
|
44
|
+
const article = await createHelpReader(getLocale(c), c.get("actor")).read({ appId, documentId });
|
|
45
|
+
if (!article) return null;
|
|
46
|
+
const payload = buildHelpDocumentPayload(article, c.req.header("User-Agent") ?? "");
|
|
47
|
+
return { payload, parts: helpArticlePartsServer(payload.html) };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
log.warn("Help article unavailable", { appId, documentId, error: error instanceof Error ? error.message : String(error) });
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
};
|
package/src/ssr/hints.ts
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { isHintId, mergeSeenHints, parseSeenHintsCookieHeader, seenHintsSetCookie } from "../contracts/seen-hints";
|
|
3
|
+
import { userFromActor } from "../server/actor";
|
|
4
|
+
|
|
5
|
+
/** Ids marked seen earlier in the same request, so a second mark or a later check sees them too. */
|
|
6
|
+
const marked = new WeakMap<Context, string[]>();
|
|
7
|
+
|
|
8
|
+
const seenIds = (c: Context): string[] => marked.get(c) ?? parseSeenHintsCookieHeader(c.req.header("Cookie"));
|
|
9
|
+
|
|
10
|
+
/** The caller checks access to the new path; anonymous visitors never see a hint. */
|
|
11
|
+
export const hintOpen = (c: Context, id: string, options: { allowed: boolean }): boolean => {
|
|
12
|
+
if (!isHintId(id)) throw new Error(`Invalid hint id: ${id}`);
|
|
13
|
+
return options.allowed && !!userFromActor(c.get("actor")) && !seenIds(c).includes(id);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Call on the new path too: using it counts as having seen the hint. Each call writes the whole list seen so far in
|
|
18
|
+
* this request, so with several calls the browser keeps the last cookie and with it every id.
|
|
19
|
+
*/
|
|
20
|
+
export const markHintSeen = (c: Context, id: string): void => {
|
|
21
|
+
const ids = mergeSeenHints(seenIds(c), id);
|
|
22
|
+
marked.set(c, ids);
|
|
23
|
+
const secure = new URL(c.req.url).protocol === "https:" || c.req.header("x-forwarded-proto")?.split(",")[0]?.trim() === "https";
|
|
24
|
+
c.header("Set-Cookie", seenHintsSetCookie(ids, secure), { append: true });
|
|
25
|
+
};
|
package/src/ssr/index.ts
CHANGED
|
@@ -4,18 +4,23 @@
|
|
|
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";
|
|
14
|
+
export { hintOpen, markHintSeen } from "./hints";
|
|
12
15
|
export { default as Layout } from "./Layout";
|
|
13
16
|
export {
|
|
14
17
|
default as MinimalLayout,
|
|
15
18
|
type MinimalLayoutPreferencePosition,
|
|
16
19
|
type MinimalLayoutProps,
|
|
17
20
|
} from "./MinimalLayout";
|
|
21
|
+
export { default as PanelLayout, type PanelLayoutProps } from "./PanelLayout";
|
|
18
22
|
export { default as PwaLayout, type PwaLayoutProps } from "./PwaLayout";
|
|
23
|
+
export { readPanelContext } from "./panel-context";
|
|
19
24
|
export { requestPath } from "./request-path";
|
|
20
25
|
export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
|
|
21
26
|
export type { UrlFilterField } from "./url-filter";
|
package/src/ssr/islands/index.ts
CHANGED
|
@@ -4,19 +4,20 @@ export { provideWorkspaceNavigation } from "../workspace-navigation";
|
|
|
4
4
|
export { default as SearchBar } from "./SearchBar";
|
|
5
5
|
|
|
6
6
|
import { AppLaunchpadButton, AppLaunchpadProvider } from "../app-launchpad";
|
|
7
|
-
import { LayoutHelpPage } from "../LayoutHelp";
|
|
7
|
+
import { LayoutHelpPage, LayoutHelpPanel } from "../LayoutHelp";
|
|
8
8
|
import { LayoutHelpDocuments } from "../layout-help-registry";
|
|
9
9
|
|
|
10
10
|
export type { AppLaunchpadApp, AppLaunchpadLegalLink } from "../app-launchpad";
|
|
11
11
|
export { AppLaunchpadButton, AppLaunchpadProvider, openAppLaunchpad, setAppLaunchpadContext } from "../app-launchpad";
|
|
12
|
-
export type { LayoutHelpPageProps } from "../LayoutHelp";
|
|
13
|
-
export { LayoutHelpPage, openLayoutHelpDialog } from "../LayoutHelp";
|
|
12
|
+
export type { LayoutHelpPageProps, LayoutHelpPanelProps } from "../LayoutHelp";
|
|
13
|
+
export { LayoutHelpPage, LayoutHelpPanel, openLayoutHelpDialog } from "../LayoutHelp";
|
|
14
14
|
export type { LayoutHelpDocumentsProps } from "../layout-help-registry";
|
|
15
15
|
export { LayoutHelpDocuments } from "../layout-help-registry";
|
|
16
16
|
|
|
17
17
|
export const Layout = {
|
|
18
18
|
HelpDocuments: LayoutHelpDocuments,
|
|
19
19
|
HelpPage: LayoutHelpPage,
|
|
20
|
+
HelpPanel: LayoutHelpPanel,
|
|
20
21
|
AppLaunchpadButton,
|
|
21
22
|
AppLaunchpadProvider,
|
|
22
23
|
};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PanelConsentSnapshot } from "../contracts/panel-context";
|
|
1
2
|
import type { RailSnapshot } from "../contracts/rail-preferences";
|
|
2
3
|
import type { User } from "../contracts/shared";
|
|
3
4
|
import type { LayoutAnnouncementsState } from "../server/middleware/settings";
|
|
@@ -5,6 +6,7 @@ import type { RuntimeContext } from "./runtime";
|
|
|
5
6
|
|
|
6
7
|
export type LayoutContext = {
|
|
7
8
|
get(key: "user"): User | undefined;
|
|
9
|
+
get(key: "panelConsent"): PanelConsentSnapshot | undefined;
|
|
8
10
|
get(key: "railPreferences"): RailSnapshot | undefined;
|
|
9
11
|
get(key: "page"): { theme?: "light" | "dark"; title?: string; pwa?: { name: string } };
|
|
10
12
|
get(key: "runtime"): RuntimeContext;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import type * as Htmlparser2 from "htmlparser2";
|
|
2
|
+
import type { HelpArticleParts } from "./layout-help-article";
|
|
3
|
+
|
|
4
|
+
// Keep the same synchronous import path as sanitize-html (see ai/readable-html.ts).
|
|
5
|
+
const { parseDocument, DomUtils, ElementType }: typeof Htmlparser2 = require("htmlparser2");
|
|
6
|
+
type HtmlNode = ReturnType<typeof parseDocument>["children"][number];
|
|
7
|
+
|
|
8
|
+
const insideDetails = (node: HtmlNode): boolean => {
|
|
9
|
+
for (let parent = node.parent; parent; parent = parent.parent) if ("name" in parent && parent.name === "details") return true;
|
|
10
|
+
return false;
|
|
11
|
+
};
|
|
12
|
+
const spokenText = (node: HtmlNode): string => {
|
|
13
|
+
if (node.type === ElementType.Text) return node.data;
|
|
14
|
+
if (!("children" in node)) return "";
|
|
15
|
+
return node.children
|
|
16
|
+
.filter((child) => !("attribs" in child) || child.attribs["aria-hidden"] !== "true")
|
|
17
|
+
.map(spokenText)
|
|
18
|
+
.join("");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** Server-only equivalent of the browser's inert-template splitter. Never import into an island. */
|
|
22
|
+
export const helpArticlePartsServer = (html: string): HelpArticleParts => {
|
|
23
|
+
const body = parseDocument(html);
|
|
24
|
+
const first = body.children.find((node) => "attribs" in node);
|
|
25
|
+
const lead = first?.name === "p" && !first.attribs.class ? DomUtils.getOuterHTML(first) : undefined;
|
|
26
|
+
if (lead && first) DomUtils.removeElement(first);
|
|
27
|
+
const elements = DomUtils.findAll(() => true, body.children);
|
|
28
|
+
return {
|
|
29
|
+
lead,
|
|
30
|
+
body: DomUtils.getInnerHTML(body),
|
|
31
|
+
sections: elements
|
|
32
|
+
.filter((element) => element.name === "h2" && "id" in element.attribs && !insideDetails(element))
|
|
33
|
+
.map((element) => ({ id: element.attribs.id ?? "", label: spokenText(element).replace(/\s+/g, " ").trim() }))
|
|
34
|
+
.filter((section) => section.label),
|
|
35
|
+
steps: elements.filter((element) => {
|
|
36
|
+
const list = element.parent;
|
|
37
|
+
const wrapper = list?.parent;
|
|
38
|
+
return (
|
|
39
|
+
element.name === "li" &&
|
|
40
|
+
list &&
|
|
41
|
+
"name" in list &&
|
|
42
|
+
list.name === "ol" &&
|
|
43
|
+
wrapper &&
|
|
44
|
+
"attribs" in wrapper &&
|
|
45
|
+
(wrapper.attribs.class ?? "").split(/\s+/).includes("help-steps") &&
|
|
46
|
+
!insideDetails(element)
|
|
47
|
+
);
|
|
48
|
+
}).length,
|
|
49
|
+
};
|
|
50
|
+
};
|