@k2b/cloud 0.39.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 +8 -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 +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +36 -15
- package/src/_internal/font-preloads.ts +2 -1
- 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.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 +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/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 +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 +190 -39
- package/src/ai/index.ts +2 -0
- package/src/ai/migrate.ts +11 -2
- 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/routes.ts +145 -1
- 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 +227 -24
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +24 -2
- package/src/ai/types.ts +28 -3
- 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.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- 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 +6 -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/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 +120 -13
- 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/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/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +7 -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 +11 -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/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/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/Layout.tsx +25 -2
- 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/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/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 +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +2 -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 +53 -4
- package/src/ssr/platform-messages.ts +18 -0
- package/src/ssr/shell-data.tsx +2 -1
- package/src/styles/effects.css +198 -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
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
|
@@ -11,6 +11,7 @@ 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";
|
|
15
16
|
export {
|
|
16
17
|
default as MinimalLayout,
|
|
@@ -19,6 +20,7 @@ export {
|
|
|
19
20
|
} from "./MinimalLayout";
|
|
20
21
|
export { default as PanelLayout, type PanelLayoutProps } from "./PanelLayout";
|
|
21
22
|
export { default as PwaLayout, type PwaLayoutProps } from "./PwaLayout";
|
|
23
|
+
export { readPanelContext } from "./panel-context";
|
|
22
24
|
export { requestPath } from "./request-path";
|
|
23
25
|
export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
|
|
24
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
|
+
};
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import type
|
|
1
|
+
import { HELP_VIEW_LIMIT, type HelpKind } from "../shared/help";
|
|
2
2
|
import { SHELL_SHORTCUT_ORDER } from "./shell-shortcuts";
|
|
3
3
|
|
|
4
4
|
/** The concept asks for at most eight tasks as tiles; more task articles follow as a list. */
|
|
5
5
|
export const HELP_START_TILE_LIMIT = 8;
|
|
6
6
|
/** "For this view" shows two or three articles the page names. */
|
|
7
|
-
export
|
|
7
|
+
export { HELP_VIEW_LIMIT } from "../shared/help";
|
|
8
8
|
export const HELP_START_GROUP_KINDS = ["start", "reference", "troubleshoot"] as const;
|
|
9
9
|
export type HelpStartGroupKind = (typeof HELP_START_GROUP_KINDS)[number];
|
|
10
10
|
export type HelpStartTopic = { id: string; title: string; order?: number; kind?: HelpKind };
|
|
@@ -41,19 +41,42 @@ export const helpStart = <T extends HelpStartTopic>(topics: readonly T[], viewId
|
|
|
41
41
|
};
|
|
42
42
|
|
|
43
43
|
export type HelpShortcutScope = "everywhere" | "app" | "selection";
|
|
44
|
+
// Three groups, each as scannable as HELP_START_TILE_LIMIT (8) task tiles: 3 x 8 rows.
|
|
45
|
+
// Today's largest real page is well below this (shell 3 + Spaces about 8).
|
|
46
|
+
export const HELP_START_SHORTCUT_LIMIT = 24;
|
|
47
|
+
// Qualified command ids share CAPABILITY_QUALIFIED_ID_MAX_LENGTH.
|
|
48
|
+
export const HELP_SHORTCUT_ID_MAX_LENGTH = 180;
|
|
49
|
+
// One row label: the platform's CapabilityLink title / human-readable label bound.
|
|
50
|
+
export const HELP_SHORTCUT_TITLE_MAX_LENGTH = 120;
|
|
51
|
+
// Row tooltip: the platform's plain-text Action review message bound.
|
|
52
|
+
export const HELP_SHORTCUT_DESCRIPTION_MAX_LENGTH = 1000;
|
|
53
|
+
// command-shortcuts grammar: five distinct modifier tokens "ctrl+alt+shift+meta+mod+" (24 chars)
|
|
54
|
+
// plus the longest standard KeyboardEvent.key name "TVAudioDescriptionMixDown" (25 chars).
|
|
55
|
+
export const HELP_SHORTCUT_KEYS_MAX_LENGTH = 49;
|
|
56
|
+
export type HelpShortcut = { id: string; title: string; description?: string; shortcut: string; scope?: "selection" | "page" };
|
|
44
57
|
export type HelpShortcutCommand = { id: string; title: string; description?: string; shortcut?: string; scope?: "selection" | "page" };
|
|
45
58
|
/**
|
|
46
59
|
* Group commands with a shortcut: scope "selection" -> "selection"; id starting with "cloud." -> "everywhere"; the rest -> "app".
|
|
47
60
|
* Group order everywhere, app, selection; empty groups are absent. Inside "everywhere", the ids of SHELL_SHORTCUT_ORDER come first in that
|
|
48
61
|
* order (so rows rendered on the server keep their place when the browser adds more), then the rest in input order; other groups keep input order.
|
|
49
|
-
* Commands without a shortcut are dropped.
|
|
62
|
+
* Commands without a shortcut, with empty ids/titles or exceeding the string bounds are dropped.
|
|
63
|
+
* Keep at most HELP_START_SHORTCUT_LIMIT rows across groups in group order.
|
|
50
64
|
*/
|
|
51
65
|
export const helpShortcutGroups = <T extends HelpShortcutCommand>(
|
|
52
66
|
commands: readonly T[],
|
|
53
67
|
): { scope: HelpShortcutScope; shortcuts: (T & { shortcut: string })[] }[] => {
|
|
54
68
|
const groups: Record<HelpShortcutScope, (T & { shortcut: string })[]> = { everywhere: [], app: [], selection: [] };
|
|
55
69
|
for (const command of commands) {
|
|
56
|
-
if (
|
|
70
|
+
if (
|
|
71
|
+
!command.shortcut ||
|
|
72
|
+
!command.id ||
|
|
73
|
+
!command.title ||
|
|
74
|
+
command.id.length > HELP_SHORTCUT_ID_MAX_LENGTH ||
|
|
75
|
+
command.title.length > HELP_SHORTCUT_TITLE_MAX_LENGTH ||
|
|
76
|
+
(command.description?.length ?? 0) > HELP_SHORTCUT_DESCRIPTION_MAX_LENGTH ||
|
|
77
|
+
command.shortcut.length > HELP_SHORTCUT_KEYS_MAX_LENGTH
|
|
78
|
+
)
|
|
79
|
+
continue;
|
|
57
80
|
const scope = command.scope === "selection" ? "selection" : command.id.startsWith("cloud.") ? "everywhere" : "app";
|
|
58
81
|
groups[scope].push({ ...command, shortcut: command.shortcut });
|
|
59
82
|
}
|
|
@@ -62,7 +85,10 @@ export const helpShortcutGroups = <T extends HelpShortcutCommand>(
|
|
|
62
85
|
return index < 0 ? SHELL_SHORTCUT_ORDER.length : index;
|
|
63
86
|
};
|
|
64
87
|
groups.everywhere.sort((a, b) => rank(a.id) - rank(b.id));
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
88
|
+
let remaining = HELP_START_SHORTCUT_LIMIT;
|
|
89
|
+
return (["everywhere", "app", "selection"] as const).flatMap((scope) => {
|
|
90
|
+
const shortcuts = groups[scope].slice(0, remaining);
|
|
91
|
+
remaining -= shortcuts.length;
|
|
92
|
+
return shortcuts.length ? [{ scope, shortcuts }] : [];
|
|
93
|
+
});
|
|
68
94
|
};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { readPanelCookie } from "../_internal/panel-cookie";
|
|
3
|
+
import { getLocale } from "../server/locale";
|
|
4
|
+
import { panelContextConsent } from "../services/panel-context-consent";
|
|
5
|
+
import { visiblePanelProviders } from "./app-navigation";
|
|
6
|
+
import { getRuntimeContext } from "./runtime";
|
|
7
|
+
|
|
8
|
+
/** One read, only for a host page with the slot open on a consent provider. Fail closed when the store is unavailable. */
|
|
9
|
+
export async function preloadPanelConsent(c: Context): Promise<void> {
|
|
10
|
+
const user = c.get("user");
|
|
11
|
+
if (!user || !c.get("runtime")) return;
|
|
12
|
+
const providers = visiblePanelProviders(getRuntimeContext(c).apps, user, getLocale(c));
|
|
13
|
+
const state = readPanelCookie(
|
|
14
|
+
c.req.header("Cookie"),
|
|
15
|
+
providers.map((provider) => provider.id),
|
|
16
|
+
);
|
|
17
|
+
const provider = state.open && providers.find((provider) => provider.id === state.provider);
|
|
18
|
+
if (!provider || !provider.consent) return;
|
|
19
|
+
// The provider's own page below its panel path renders in PanelLayout, which has no slot and never asks.
|
|
20
|
+
const scope = new URL(provider.href, "https://panel.invalid").pathname.replace(/\/$/, "");
|
|
21
|
+
if (c.req.path === scope || c.req.path.startsWith(`${scope}/`)) return;
|
|
22
|
+
let answer: { allowed: boolean; revision: number } | null = null;
|
|
23
|
+
try {
|
|
24
|
+
answer = await panelContextConsent.get(user.id, provider.id);
|
|
25
|
+
} catch {
|
|
26
|
+
// The shell shows the unanswered question, without context in the URL or messages.
|
|
27
|
+
}
|
|
28
|
+
c.set("panelConsent", { provider: provider.id, allowed: answer?.allowed ?? null, revision: answer?.revision ?? 0 });
|
|
29
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { PANEL_CONTEXT_PARAM, type PanelPageContext, parsePanelContextParam } from "../contracts/panel-context";
|
|
2
|
+
|
|
3
|
+
/** Untrusted URL input: a selector only. Read through your server using the session's actor/accessSubject. */
|
|
4
|
+
export function readPanelContext(c: { req: { raw: { url: string } } }): PanelPageContext | null {
|
|
5
|
+
try {
|
|
6
|
+
return parsePanelContextParam(new URL(c.req.raw.url).searchParams.get(PANEL_CONTEXT_PARAM));
|
|
7
|
+
} catch {
|
|
8
|
+
return null;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
@@ -2,12 +2,35 @@ import { AppWorkspace } from "@k2b/ui";
|
|
|
2
2
|
import { Hono } from "hono";
|
|
3
3
|
import { defineApp } from "../_internal/define-app";
|
|
4
4
|
import { person } from "../_internal/define-app.test-fixture";
|
|
5
|
+
import { createMePanelConsentRoutes } from "../api/me-panel-consent";
|
|
5
6
|
import type { RuntimeAppMeta } from "../contracts/app";
|
|
6
7
|
import type { User } from "../contracts/shared";
|
|
7
8
|
import Layout from "./Layout";
|
|
8
9
|
import PanelLayout from "./PanelLayout";
|
|
10
|
+
import { readPanelContext } from "./panel-context";
|
|
9
11
|
import type { RuntimeContext } from "./runtime";
|
|
10
12
|
|
|
13
|
+
type ConsentAnswer = { allowed: boolean; revision: number };
|
|
14
|
+
export const consentAnswers = new Map<string, Map<string, ConsentAnswer>>();
|
|
15
|
+
export const fixtureConsent = {
|
|
16
|
+
get: async (userId: string, provider: string) => consentAnswers.get(userId)?.get(provider) ?? null,
|
|
17
|
+
list: async (userId: string, providers: string[]) =>
|
|
18
|
+
Object.fromEntries(
|
|
19
|
+
providers.flatMap((id) => {
|
|
20
|
+
const answer = consentAnswers.get(userId)?.get(id);
|
|
21
|
+
return answer === undefined ? [] : [[id, answer]];
|
|
22
|
+
}),
|
|
23
|
+
),
|
|
24
|
+
set: async (userId: string, provider: string, allowed: boolean, revision: number) => {
|
|
25
|
+
const answers = consentAnswers.get(userId) ?? new Map<string, ConsentAnswer>();
|
|
26
|
+
if ((answers.get(provider)?.revision ?? 0) !== revision) return null;
|
|
27
|
+
const saved = { allowed, revision: revision + 1 };
|
|
28
|
+
answers.set(provider, saved);
|
|
29
|
+
consentAnswers.set(userId, answers);
|
|
30
|
+
return saved;
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
11
34
|
const apps: RuntimeAppMeta[] = ["fixture", "second"].map((id) => ({
|
|
12
35
|
id,
|
|
13
36
|
name: id === "fixture" ? "Fixture" : "Second",
|
|
@@ -29,13 +52,21 @@ type Environment = { Variables: { runtime: RuntimeContext; user: User } };
|
|
|
29
52
|
export const server = new Hono<Environment>()
|
|
30
53
|
.use(async (c, next) => {
|
|
31
54
|
c.set("runtime", { apps });
|
|
32
|
-
c.
|
|
55
|
+
const id = c.req
|
|
56
|
+
.header("cookie")
|
|
57
|
+
?.split(";")
|
|
58
|
+
.map((value) => value.trim())
|
|
59
|
+
.find((value) => value.startsWith("fixture_person="))
|
|
60
|
+
?.slice("fixture_person=".length);
|
|
61
|
+
c.set("user", { ...person, ...(id ? { id } : {}) });
|
|
33
62
|
await next();
|
|
34
63
|
})
|
|
64
|
+
.route("/api/me", createMePanelConsentRoutes(fixtureConsent))
|
|
35
65
|
.get(
|
|
36
66
|
"/app/:provider/panel",
|
|
37
67
|
...probe.ssr<Environment>((c) => () => (
|
|
38
68
|
<PanelLayout c={c} title="Fixture panel">
|
|
69
|
+
<output id="panel-context-probe">{JSON.stringify(readPanelContext(c))}</output>
|
|
39
70
|
<label>
|
|
40
71
|
Panel input
|
|
41
72
|
<input aria-label="Panel input" />
|
|
@@ -52,7 +83,7 @@ export const server = new Hono<Environment>()
|
|
|
52
83
|
.get(
|
|
53
84
|
"/app/fixture/long",
|
|
54
85
|
...probe.ssr<Environment>((c) => () => (
|
|
55
|
-
<Layout c={c} title="Long fixture">
|
|
86
|
+
<Layout context={{ scope: { type: "fixture.place", id: "place1" } }} c={c} title="Long fixture">
|
|
56
87
|
<button type="button" id="open-panel">
|
|
57
88
|
Open fixture
|
|
58
89
|
</button>
|
|
@@ -67,7 +98,7 @@ export const server = new Hono<Environment>()
|
|
|
67
98
|
.get(
|
|
68
99
|
"/app/fixture/:page?",
|
|
69
100
|
...probe.ssr<Environment>((c) => () => (
|
|
70
|
-
<Layout c={c} fullWidth fullPage title="Panel fixture">
|
|
101
|
+
<Layout context={{ scope: { type: "fixture.place", id: "place1" } }} c={c} fullWidth fullPage title="Panel fixture">
|
|
71
102
|
<AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
|
|
72
103
|
<AppWorkspace.Sidebar id="panel-navigation" label="Navigation">
|
|
73
104
|
<AppWorkspace.SidebarDesktop>
|
|
@@ -87,6 +118,9 @@ export const server = new Hono<Environment>()
|
|
|
87
118
|
<a href="/app/fixture/next" id="next-page">
|
|
88
119
|
Next page
|
|
89
120
|
</a>
|
|
121
|
+
<button type="button" id="change-context">
|
|
122
|
+
Change place
|
|
123
|
+
</button>
|
|
90
124
|
<textarea aria-label="App editor" />
|
|
91
125
|
</AppWorkspace.Main>
|
|
92
126
|
<AppWorkspace.Detail id="panel-fixture-detail" width="lg" open>
|
|
@@ -103,7 +137,22 @@ export const server = new Hono<Environment>()
|
|
|
103
137
|
|
|
104
138
|
/** Runs inline during parsing and again immediately after mounting the real runtime. */
|
|
105
139
|
export function panelGeometry() {
|
|
106
|
-
return [
|
|
140
|
+
return [
|
|
141
|
+
".layout-shell-content",
|
|
142
|
+
"#cloud-panel",
|
|
143
|
+
".k2b-app-workspace",
|
|
144
|
+
".k2b-app-workspace__main",
|
|
145
|
+
".k2b-app-workspace__detail",
|
|
146
|
+
"[data-app-workspace-detail-toggle]",
|
|
147
|
+
].map((selector) => {
|
|
148
|
+
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
|
149
|
+
return rect && { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** The question, provider frame and app area stay in the same reserved rectangles through hydration. */
|
|
154
|
+
export function consentGeometry() {
|
|
155
|
+
return ["[data-cloud-panel-consent]", "[data-cloud-panel-frame]", "[data-cloud-app-area]"].map((selector) => {
|
|
107
156
|
const rect = document.querySelector(selector)?.getBoundingClientRect();
|
|
108
157
|
return rect && { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
|
109
158
|
});
|
|
@@ -19,6 +19,15 @@ export const platformMessages = i18n.define({
|
|
|
19
19
|
panelLoading: ({ label }: { label: string }) => `${label} is loading`,
|
|
20
20
|
enlargePanel: "Enlarge panel",
|
|
21
21
|
shrinkPanel: "Shrink panel",
|
|
22
|
+
panelConsentTitle: ({ app }: { app: string }) => `May ${app} see where you are?`,
|
|
23
|
+
panelConsentBody: ({ app }: { app: string }) =>
|
|
24
|
+
`${app} only learns which place you are at. It can read only what you may read yourself. You can change this in the ⋯ menu at any time.`,
|
|
25
|
+
panelConsentAllow: "Allow",
|
|
26
|
+
panelConsentDeny: "Don't share",
|
|
27
|
+
panelMenu: "Panel menu",
|
|
28
|
+
panelFrom: ({ app }: { app: string }) => `Panel from ${app}`,
|
|
29
|
+
panelShareContext: "Share context",
|
|
30
|
+
panelConsentSaveError: "Not saved. Try again.",
|
|
22
31
|
printFallbackTitle: "This view does not print well.",
|
|
23
32
|
printFallbackHint: "Open the item you want to print, or use the app's export.",
|
|
24
33
|
admin: "Admin",
|
|
@@ -108,6 +117,15 @@ export const platformMessages = i18n.define({
|
|
|
108
117
|
panelLoading: ({ label }: { label: string }) => `${label} lädt`,
|
|
109
118
|
enlargePanel: "Panel vergrößern",
|
|
110
119
|
shrinkPanel: "Panel verkleinern",
|
|
120
|
+
panelConsentTitle: ({ app }: { app: string }) => `Darf ${app} sehen, wo du gerade bist?`,
|
|
121
|
+
panelConsentBody: ({ app }: { app: string }) =>
|
|
122
|
+
`${app} erfährt nur, an welchem Ort du gerade bist. Lesen kann es nur, was du selbst lesen darfst. Du kannst das im Menü ⋯ jederzeit ändern.`,
|
|
123
|
+
panelConsentAllow: "Erlauben",
|
|
124
|
+
panelConsentDeny: "Nicht teilen",
|
|
125
|
+
panelMenu: "Panel-Menü",
|
|
126
|
+
panelFrom: ({ app }: { app: string }) => `Panel von ${app}`,
|
|
127
|
+
panelShareContext: "Kontext teilen",
|
|
128
|
+
panelConsentSaveError: "Nicht gespeichert. Versuche es erneut.",
|
|
111
129
|
printFallbackTitle: "Diese Ansicht lässt sich nicht gut drucken.",
|
|
112
130
|
printFallbackHint: "Öffne den Eintrag, den du drucken möchtest, oder nutze den Export der App.",
|
|
113
131
|
admin: "Administration",
|
package/src/ssr/shell-data.tsx
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { KeyboardPlatform } from "@k2b/ui";
|
|
2
2
|
import { createContext, type JSX, useContext } from "solid-js";
|
|
3
3
|
import type { AppSearchLink } from "../contracts/app";
|
|
4
|
+
import type { PanelConsentSnapshot, PanelPageContext } from "../contracts/panel-context";
|
|
4
5
|
import type { RailSnapshot } from "../contracts/rail-preferences";
|
|
5
6
|
import type { CloudThemePreference } from "../shared/theme";
|
|
6
7
|
import type { AppLaunchpadLegalLink } from "./app-launchpad";
|
|
@@ -8,7 +9,7 @@ import type { PanelProvider } from "./app-navigation";
|
|
|
8
9
|
import type { RailApp } from "./rail-navigation";
|
|
9
10
|
|
|
10
11
|
export type ShellData = {
|
|
11
|
-
panel?: { providers: PanelProvider[]; provider?: string };
|
|
12
|
+
panel?: { providers: PanelProvider[]; provider?: string; context: PanelPageContext | null; consent?: PanelConsentSnapshot };
|
|
12
13
|
/** The authorized navigation catalog, sorted for the request locale: the rail, the app grid and the phone menu show it. */
|
|
13
14
|
apps: RailApp[];
|
|
14
15
|
/** Signed-in pages: the person's rail preferences. */
|