@k2b/cloud 0.39.0 → 0.41.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 +11 -4
- package/src/_internal/app-favicon.ts +4 -2
- 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 +217 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +49 -18
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/setup-lock.ts +103 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +2 -2
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/background-review.ts +72 -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 +464 -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 +680 -0
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +456 -0
- package/src/ai/chat/turn-view.tsx +10 -160
- package/src/ai/chat/view-block.tsx +15 -5
- package/src/ai/chat-task-contracts.ts +4 -1
- package/src/ai/chat-task-reasons-migrate.ts +7 -0
- package/src/ai/chat-task-reasons.ts +1 -1
- package/src/ai/chat-tasks.ts +55 -25
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +198 -39
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/index.ts +3 -0
- package/src/ai/migrate.ts +26 -2
- package/src/ai/open-questions.ts +172 -0
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/question-text.ts +8 -0
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/routes.ts +195 -5
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +245 -24
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +36 -8
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +43 -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-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 +2 -0
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/app-approval.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +67 -12
- 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 +6 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +30 -3
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +159 -36
- 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 +166 -262
- package/src/approvals/approval-card.ts +68 -0
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/app-approval.ts +54 -5
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +45 -3
- package/src/browser/composer-insert.ts +10 -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 +11 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +412 -10
- package/src/browser/panel.ts +37 -0
- 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 +123 -16
- 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/seen-hints.ts +35 -0
- package/src/contracts/shared.ts +3 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +13 -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/services/accounts/app.ts +23 -3
- package/src/services/accounts/users.ts +12 -1
- package/src/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +49 -31
- package/src/services/approvals/index.ts +7 -5
- package/src/services/approvals/list.ts +27 -6
- package/src/services/approvals/question.ts +68 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/auth-flows/password-reset.ts +32 -6
- 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 +13 -0
- 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/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/markdown.ts +160 -2
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +296 -41
- package/src/services/session/recent.ts +13 -0
- package/src/services/webauthn.ts +12 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -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 +10 -2
- package/src/ssr/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/Layout.tsx +25 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +222 -419
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +9 -1
- package/src/ssr/PanelRuntime.island.tsx +7 -0
- package/src/ssr/PanelSlot.tsx +53 -3
- package/src/ssr/ShellRuntime.island.tsx +11 -2
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/app-color.ts +14 -9
- package/src/ssr/app-navigation.ts +16 -3
- package/src/ssr/compact-shell.browser-harness.ts +30 -22
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +21 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +3 -0
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-idle.ts +9 -0
- package/src/ssr/panel-slot.browser-harness.tsx +53 -4
- package/src/ssr/platform-messages.ts +18 -0
- package/src/ssr/shell-data.tsx +2 -1
- package/src/styles/approvals.css +6 -0
- package/src/styles/effects.css +213 -9
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +49 -12
- package/src/styles/panel.css +47 -4
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +17 -11
- 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 +17 -22
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isOwnAppPath } from "../_internal/app-path";
|
|
2
2
|
import { hasAnyAppRole } from "../_internal/app-roles";
|
|
3
3
|
import type { RuntimeAppMeta } from "../contracts/app";
|
|
4
|
+
import { panelProviderNeedsConsent } from "../contracts/panel-context-plain";
|
|
4
5
|
import { isPwaPartId } from "../contracts/pwa-paths";
|
|
5
6
|
import type { User } from "../contracts/shared";
|
|
6
7
|
import { resolveAppPresentation } from "../shared/app-presentation";
|
|
@@ -54,7 +55,7 @@ export const visiblePwaParts = (apps: readonly RuntimeAppMeta[], user: User | un
|
|
|
54
55
|
.sort((a, b) => a.name.localeCompare(b.name, locale) || a.id.localeCompare(b.id));
|
|
55
56
|
};
|
|
56
57
|
|
|
57
|
-
export type PanelProvider = { id: string; label: string; icon: string; href: string };
|
|
58
|
+
export type PanelProvider = { id: string; label: string; icon: string; href: string; appName: string; consent: boolean };
|
|
58
59
|
|
|
59
60
|
/** Authorized presentation only: the panel route still authorizes every request. */
|
|
60
61
|
export const visiblePanelProviders = (apps: readonly RuntimeAppMeta[], user: User | undefined, locale: string): PanelProvider[] => {
|
|
@@ -62,8 +63,20 @@ export const visiblePanelProviders = (apps: readonly RuntimeAppMeta[], user: Use
|
|
|
62
63
|
return apps
|
|
63
64
|
.flatMap((app) => {
|
|
64
65
|
if (!app.panel || !isOwnAppPath(app.panel.href, app.routes) || !hasAnyAppRole(user, app.panel.requiresRoles)) return [];
|
|
65
|
-
const
|
|
66
|
-
|
|
66
|
+
const presentation = resolveAppPresentation(app, locale);
|
|
67
|
+
const panel = presentation.panel;
|
|
68
|
+
return panel
|
|
69
|
+
? [
|
|
70
|
+
{
|
|
71
|
+
id: app.id,
|
|
72
|
+
label: panel.label,
|
|
73
|
+
icon: panel.icon,
|
|
74
|
+
href: panel.href,
|
|
75
|
+
appName: presentation.name,
|
|
76
|
+
consent: panelProviderNeedsConsent(app.id),
|
|
77
|
+
},
|
|
78
|
+
]
|
|
79
|
+
: [];
|
|
67
80
|
})
|
|
68
81
|
.sort((a, b) => a.label.localeCompare(b.label, locale) || a.id.localeCompare(b.id));
|
|
69
82
|
};
|
|
@@ -116,30 +116,38 @@ const workspace = (selected: string) =>
|
|
|
116
116
|
* A task-list page: a long list in Main and an open detail that the server renders with its own close
|
|
117
117
|
* control, as Contacts and Capabilities do. Invented content.
|
|
118
118
|
*/
|
|
119
|
-
const listWorkspace = () =>
|
|
119
|
+
const listWorkspace = (head = false) =>
|
|
120
120
|
createComponent(AppWorkspace, {
|
|
121
121
|
class: "min-h-0 flex-1",
|
|
122
122
|
get children() {
|
|
123
|
-
return
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
];
|
|
143
151
|
},
|
|
144
152
|
});
|
|
145
153
|
|
|
@@ -270,7 +278,7 @@ const server = new Hono()
|
|
|
270
278
|
fullPage: list === "page",
|
|
271
279
|
title: [{ title: "Home", href: "/" }, { title: "Tasks" }],
|
|
272
280
|
get children() {
|
|
273
|
-
return listWorkspace();
|
|
281
|
+
return listWorkspace(c.req.query("head") === "1");
|
|
274
282
|
},
|
|
275
283
|
});
|
|
276
284
|
return () =>
|
|
@@ -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,20 @@ export const preloadLayoutHelp = async (c: Context, appId?: string): Promise<Hel
|
|
|
30
34
|
return null;
|
|
31
35
|
}
|
|
32
36
|
};
|
|
37
|
+
|
|
38
|
+
/** Article HTML crosses the island boundary once, as parts. */
|
|
39
|
+
export type LayoutHelpArticle = { payload: Omit<HelpDocumentPayload, "html">; parts: HelpArticleParts };
|
|
40
|
+
|
|
41
|
+
/** An authorized initial article; failures leave the browser's on-demand reader available. */
|
|
42
|
+
export const preloadLayoutHelpArticle = async (c: Context, appId: string, documentId: string): Promise<LayoutHelpArticle | null> => {
|
|
43
|
+
if (!c.get("user")) return null;
|
|
44
|
+
try {
|
|
45
|
+
const article = await createHelpReader(getLocale(c), c.get("actor")).read({ appId, documentId });
|
|
46
|
+
if (!article) return null;
|
|
47
|
+
const { html, ...payload } = buildHelpDocumentPayload(article, c.req.header("User-Agent") ?? "");
|
|
48
|
+
return { payload, parts: helpArticlePartsServer(html) };
|
|
49
|
+
} catch (error) {
|
|
50
|
+
log.warn("Help article unavailable", { appId, documentId, error: error instanceof Error ? error.message : String(error) });
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
};
|
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
|
@@ -11,7 +11,9 @@ export {
|
|
|
11
11
|
visiblePanelProviders,
|
|
12
12
|
visiblePwaParts,
|
|
13
13
|
} from "./app-navigation";
|
|
14
|
+
export { hintOpen, markHintSeen } from "./hints";
|
|
14
15
|
export { default as Layout } from "./Layout";
|
|
16
|
+
export { LayoutHelpPanel, type LayoutHelpPanelProps } from "./LayoutHelpPanel.server";
|
|
15
17
|
export {
|
|
16
18
|
default as MinimalLayout,
|
|
17
19
|
type MinimalLayoutPreferencePosition,
|
|
@@ -19,6 +21,7 @@ export {
|
|
|
19
21
|
} from "./MinimalLayout";
|
|
20
22
|
export { default as PanelLayout, type PanelLayoutProps } from "./PanelLayout";
|
|
21
23
|
export { default as PwaLayout, type PwaLayoutProps } from "./PwaLayout";
|
|
24
|
+
export { readPanelContext } from "./panel-context";
|
|
22
25
|
export { requestPath } from "./request-path";
|
|
23
26
|
export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
|
|
24
27
|
export type { UrlFilterField } from "./url-filter";
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { Button, IconButton } from "@k2b/ui";
|
|
2
|
+
import type { JSX } from "solid-js";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The same button and tooltip as Dropdown.Trigger, without loading the menu implementation. It names no aria-controls:
|
|
6
|
+
* the menu it would point to exists only once the real Dropdown replaces it on activation.
|
|
7
|
+
*/
|
|
8
|
+
export function HelpPanelMenuTrigger(props: {
|
|
9
|
+
label: string;
|
|
10
|
+
onClick?: JSX.EventHandler<HTMLButtonElement, MouseEvent>;
|
|
11
|
+
onKeyDown?: JSX.EventHandler<HTMLButtonElement, KeyboardEvent>;
|
|
12
|
+
onPointerEnter?: JSX.EventHandler<HTMLButtonElement, PointerEvent>;
|
|
13
|
+
onPointerDown?: JSX.EventHandler<HTMLButtonElement, PointerEvent>;
|
|
14
|
+
onFocus?: JSX.EventHandler<HTMLButtonElement, FocusEvent>;
|
|
15
|
+
}) {
|
|
16
|
+
return (
|
|
17
|
+
<span class="k2b-dropdown ">
|
|
18
|
+
<IconButton {...props} class="k2b-dropdown__trigger " aria-expanded="false" aria-haspopup="menu" data-help-menu="">
|
|
19
|
+
<i class="ti ti-dots" aria-hidden="true" />
|
|
20
|
+
</IconButton>
|
|
21
|
+
</span>
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
export function HelpPanelSectionTrigger(props: { label: string }) {
|
|
25
|
+
return (
|
|
26
|
+
<span class="k2b-dropdown ">
|
|
27
|
+
<Button
|
|
28
|
+
aria-expanded="false"
|
|
29
|
+
aria-haspopup="menu"
|
|
30
|
+
aria-label={props.label}
|
|
31
|
+
class=" k2b-dropdown__trigger "
|
|
32
|
+
size="sm"
|
|
33
|
+
tooltip={false}
|
|
34
|
+
variant="secondary"
|
|
35
|
+
data-help-sections=""
|
|
36
|
+
>
|
|
37
|
+
<i class="ti ti-list" aria-hidden="true" />
|
|
38
|
+
<span>{props.label}</span>
|
|
39
|
+
<i class="ti ti-chevron-down" aria-hidden="true" />
|
|
40
|
+
</Button>
|
|
41
|
+
</span>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { HelpArticleLink, HelpDocumentManifest, HelpDocumentPayload } from "../shared/help";
|
|
2
|
+
import { HELP_ACCESS_LEVELS, HELP_KINDS } from "../shared/help";
|
|
3
|
+
import type { HelpPanelState } from "./layout-help-panel-state";
|
|
4
|
+
|
|
5
|
+
const optionalString = (value: object, key: string): string | undefined => {
|
|
6
|
+
const field = Reflect.get(value, key);
|
|
7
|
+
return typeof field === "string" ? field : undefined;
|
|
8
|
+
};
|
|
9
|
+
/** A linked article from the Help API, or nothing when the server sent something else. */
|
|
10
|
+
const articleLink = (value: unknown): HelpArticleLink | undefined => {
|
|
11
|
+
if (!value || typeof value !== "object") return undefined;
|
|
12
|
+
const [appId, appName, documentId, title, url] = ["appId", "appName", "documentId", "title", "url"].map((key) =>
|
|
13
|
+
optionalString(value, key),
|
|
14
|
+
);
|
|
15
|
+
if (!appId || !appName || !documentId || !title || !url?.startsWith("/")) return undefined;
|
|
16
|
+
const [accent, description, icon] = ["accent", "description", "icon"].map((key) => optionalString(value, key));
|
|
17
|
+
return { appId, appName, accent, documentId, title, description, icon, url };
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const loadPanelDocument = async (
|
|
21
|
+
state: HelpPanelState,
|
|
22
|
+
topic: HelpDocumentManifest,
|
|
23
|
+
signal: AbortSignal,
|
|
24
|
+
t: { requestFailed: string; invalidDocument: string },
|
|
25
|
+
) => {
|
|
26
|
+
const cacheKey = `${topic.locale ?? "en"}:${topic.url}`;
|
|
27
|
+
const cached = state.cache.get(cacheKey);
|
|
28
|
+
if (cached) return cached;
|
|
29
|
+
|
|
30
|
+
const response = await fetch(topic.url, { signal, headers: { Accept: "application/json" } });
|
|
31
|
+
if (!response.ok) throw new Error(t.requestFailed);
|
|
32
|
+
const value: unknown = await response.json();
|
|
33
|
+
if (
|
|
34
|
+
!value ||
|
|
35
|
+
typeof value !== "object" ||
|
|
36
|
+
!("id" in value) ||
|
|
37
|
+
value.id !== topic.id ||
|
|
38
|
+
!("html" in value) ||
|
|
39
|
+
!("markdown" in value) ||
|
|
40
|
+
!("title" in value) ||
|
|
41
|
+
("locale" in value && value.locale !== undefined && typeof value.locale !== "string") ||
|
|
42
|
+
(topic.locale !== undefined && "locale" in value && value.locale !== undefined && value.locale !== topic.locale) ||
|
|
43
|
+
typeof value.html !== "string" ||
|
|
44
|
+
typeof value.markdown !== "string" ||
|
|
45
|
+
typeof value.title !== "string"
|
|
46
|
+
) {
|
|
47
|
+
throw new Error(t.invalidDocument);
|
|
48
|
+
}
|
|
49
|
+
const kind = Reflect.get(value, "kind");
|
|
50
|
+
const needs = Reflect.get(value, "needs");
|
|
51
|
+
const related = Reflect.get(value, "related");
|
|
52
|
+
const next = articleLink(Reflect.get(value, "next"));
|
|
53
|
+
const document: HelpDocumentPayload = {
|
|
54
|
+
id: topic.id,
|
|
55
|
+
title: value.title,
|
|
56
|
+
html: value.html,
|
|
57
|
+
markdown: value.markdown,
|
|
58
|
+
locale: "locale" in value && typeof value.locale === "string" ? value.locale : (topic.locale ?? "en"),
|
|
59
|
+
// A Core older than the app's Help reader sends no article facts; the article then shows none.
|
|
60
|
+
kind: HELP_KINDS.find((candidate) => candidate === kind) ?? "task",
|
|
61
|
+
needs: HELP_ACCESS_LEVELS.find((level) => level === needs),
|
|
62
|
+
...(next ? { next } : {}),
|
|
63
|
+
related: Array.isArray(related) ? related.flatMap((link) => articleLink(link) ?? []) : [],
|
|
64
|
+
};
|
|
65
|
+
state.storeDocument(topic, document);
|
|
66
|
+
return document;
|
|
67
|
+
};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
const focused = new WeakMap<Element, string>();
|
|
2
|
+
/** Module evaluation precedes SSR's destructive island mount. */
|
|
3
|
+
export function capturePanelFocus() {
|
|
4
|
+
if (typeof document === "undefined") return;
|
|
5
|
+
const element = document.activeElement;
|
|
6
|
+
const wrapper = element?.closest("solid-island");
|
|
7
|
+
if (!wrapper || !(element instanceof HTMLElement)) return;
|
|
8
|
+
const topic = element.dataset.helpTopic;
|
|
9
|
+
const selector =
|
|
10
|
+
topic !== undefined
|
|
11
|
+
? `[data-help-topic="${CSS.escape(topic)}"]`
|
|
12
|
+
: element.matches("[data-help-menu]")
|
|
13
|
+
? "[data-help-menu]"
|
|
14
|
+
: element.matches(".help-head-back")
|
|
15
|
+
? ".help-head-back"
|
|
16
|
+
: undefined;
|
|
17
|
+
if (selector) focused.set(wrapper, selector);
|
|
18
|
+
}
|
|
19
|
+
export function restorePanelFocus(element: Element) {
|
|
20
|
+
const wrapper = element.closest("solid-island");
|
|
21
|
+
const selector = wrapper && focused.get(wrapper);
|
|
22
|
+
if (!wrapper || !selector) return;
|
|
23
|
+
focused.delete(wrapper);
|
|
24
|
+
wrapper.querySelector<HTMLElement>(selector)?.focus({ preventScroll: true });
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** One provider reader per document; production island wrappers intentionally omit data-file. */
|
|
28
|
+
export function panelElement(): HTMLElement {
|
|
29
|
+
const element = document.querySelector<HTMLElement>('.help-shell[data-surface="panel"]');
|
|
30
|
+
if (!element) throw new Error("Missing Help panel");
|
|
31
|
+
return element;
|
|
32
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { clipboard } from "@k2b/stdlib/solid";
|
|
2
|
+
import { Dropdown, toast, useLocale } from "@k2b/ui";
|
|
3
|
+
import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
|
|
4
|
+
import { helpMessages } from "./help-messages";
|
|
5
|
+
import { formatHelpBundleMarkdown, formatHelpDocumentMarkdown } from "./layout-help-markdown";
|
|
6
|
+
import { loadPanelDocument } from "./layout-help-panel-document";
|
|
7
|
+
import type { HelpPanelState } from "./layout-help-panel-state";
|
|
8
|
+
import { layoutHelpTopicHref } from "./layout-help-url";
|
|
9
|
+
|
|
10
|
+
export type MenuActivation = { key: string; focus: boolean; pointer?: boolean };
|
|
11
|
+
export function HelpPanelMenu(props: { state: HelpPanelState; activation: MenuActivation }) {
|
|
12
|
+
const state = props.state;
|
|
13
|
+
const locale = useLocale();
|
|
14
|
+
const t = () => helpMessages.resolve([locale()]).t;
|
|
15
|
+
const write = (text: string) => navigator.clipboard.writeText(text);
|
|
16
|
+
const writer = clipboard.createWriter({ write, copiedFor: 1800 });
|
|
17
|
+
const allWriter = clipboard.createWriter({ write, copiedFor: 1800 });
|
|
18
|
+
createEffect(() => state.setAnnouncement(writer.wasCopied() ? t().articleCopiedAnnouncement : ""));
|
|
19
|
+
onCleanup(() => state.setAnnouncement(""));
|
|
20
|
+
const [copying, setCopying] = createSignal(false);
|
|
21
|
+
let controller: AbortController | undefined;
|
|
22
|
+
let trigger: HTMLButtonElement | undefined;
|
|
23
|
+
const topic = () => state.data.documents.find((topic) => topic.id === state.activeId());
|
|
24
|
+
const payload = () => {
|
|
25
|
+
const active = topic();
|
|
26
|
+
return active ? state.cachedDocument(active) : undefined;
|
|
27
|
+
};
|
|
28
|
+
const copyArticle = async () => {
|
|
29
|
+
const active = topic();
|
|
30
|
+
const document = payload();
|
|
31
|
+
if (!active || !document) return;
|
|
32
|
+
// Invoke the clipboard writer before the first await: Safari needs the menu item's user activation.
|
|
33
|
+
if (await writer.copy(formatHelpDocumentMarkdown({ ...active, markdown: document.markdown }))) {
|
|
34
|
+
toast.success(t().articleCopied);
|
|
35
|
+
} else
|
|
36
|
+
toast.error(t().copyFailed({ message: writer.error()?.message ?? t().copyFallback }), {
|
|
37
|
+
action: { label: t().tryAgain, onClick: () => void copyArticle() },
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const copyAll = async () => {
|
|
41
|
+
if (!state.data.documents.length || copying()) return;
|
|
42
|
+
controller?.abort();
|
|
43
|
+
const current = new AbortController();
|
|
44
|
+
controller = current;
|
|
45
|
+
setCopying(true);
|
|
46
|
+
const progress = toast(t().preparing, { progress: "indeterminate", duration: 0, iconClass: "ti ti-markdown" });
|
|
47
|
+
try {
|
|
48
|
+
const documents = await Promise.all(
|
|
49
|
+
state.data.documents.map(async (topic) => ({
|
|
50
|
+
...topic,
|
|
51
|
+
markdown: (await loadPanelDocument(state, topic, current.signal, t())).markdown,
|
|
52
|
+
})),
|
|
53
|
+
);
|
|
54
|
+
if (!(await allWriter.copy(formatHelpBundleMarkdown(documents)))) throw allWriter.error() ?? new Error(t().copyFallback);
|
|
55
|
+
progress.dismiss();
|
|
56
|
+
toast.success(t().helpCopied);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
progress.dismiss();
|
|
59
|
+
if (!(error instanceof DOMException && error.name === "AbortError"))
|
|
60
|
+
toast.error(t().copyAllFailed({ message: error instanceof Error ? error.message : t().copyFallback }), {
|
|
61
|
+
action: { label: t().tryAgain, onClick: () => void copyAll() },
|
|
62
|
+
});
|
|
63
|
+
} finally {
|
|
64
|
+
if (controller === current) {
|
|
65
|
+
controller = undefined;
|
|
66
|
+
setCopying(false);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
const [quiet, setQuiet] = createSignal(!!props.activation.pointer);
|
|
71
|
+
onCleanup(() => controller?.abort());
|
|
72
|
+
onMount(() => {
|
|
73
|
+
if (props.activation.focus) trigger?.focus({ preventScroll: true });
|
|
74
|
+
trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: props.activation.key, bubbles: true, cancelable: true }));
|
|
75
|
+
});
|
|
76
|
+
const items = () => [
|
|
77
|
+
{
|
|
78
|
+
label: t().copyAsMarkdown,
|
|
79
|
+
icon: "ti ti-markdown",
|
|
80
|
+
...(copying() || (state.view() === "article" ? !payload() : !state.data.documents.length)
|
|
81
|
+
? { disabled: true as const }
|
|
82
|
+
: { action: () => void (state.view() === "article" ? copyArticle() : copyAll()) }),
|
|
83
|
+
},
|
|
84
|
+
...(state.data.pageBase
|
|
85
|
+
? [
|
|
86
|
+
{
|
|
87
|
+
label: t().openAsPage,
|
|
88
|
+
icon: "ti ti-app-window",
|
|
89
|
+
href: layoutHelpTopicHref(state.data.pageBase, state.view() === "article" ? state.activeId() : null),
|
|
90
|
+
},
|
|
91
|
+
]
|
|
92
|
+
: []),
|
|
93
|
+
];
|
|
94
|
+
return (
|
|
95
|
+
<Dropdown.Root label={t().moreActions} position="bottom-left" items={items()}>
|
|
96
|
+
<Dropdown.Trigger
|
|
97
|
+
ref={(element) => {
|
|
98
|
+
trigger = element;
|
|
99
|
+
}}
|
|
100
|
+
iconOnly
|
|
101
|
+
label={t().moreActions}
|
|
102
|
+
variant="ghost"
|
|
103
|
+
data-help-menu=""
|
|
104
|
+
// A press on the light trigger counts as a press here: like Dropdown.Trigger's own, its hint stays closed until
|
|
105
|
+
// the pointer leaves.
|
|
106
|
+
tooltip={quiet() ? false : undefined}
|
|
107
|
+
onPointerLeave={() => setQuiet(false)}
|
|
108
|
+
>
|
|
109
|
+
<i class="ti ti-dots" aria-hidden="true" />
|
|
110
|
+
</Dropdown.Trigger>
|
|
111
|
+
</Dropdown.Root>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// Keep importOnDemand() from ../browser/reload out: its eager toast import would break the panel bundle budget.
|
|
2
|
+
export const helpPanelModules = {
|
|
3
|
+
menu: () => import("./layout-help-panel-menu"),
|
|
4
|
+
search: () => import("./layout-help-panel-search"),
|
|
5
|
+
reader: () => import("./layout-help-panel-reader"),
|
|
6
|
+
document: () => import("./layout-help-panel-document"),
|
|
7
|
+
};
|