@k2b/cloud 0.38.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/package.json +6 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/panel-cookie.ts +39 -0
  9. package/src/_internal/registry-validation.ts +21 -26
  10. package/src/_internal/runtime-context.ts +1 -0
  11. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  12. package/src/access/PermissionEditor.tsx +30 -5
  13. package/src/access/ResourceApiKeys.tsx +5 -1
  14. package/src/ai/approval-routes.ts +5 -2
  15. package/src/ai/approvals.ts +139 -28
  16. package/src/ai/browser.ts +10 -0
  17. package/src/ai/chat/action-sentences.ts +1 -44
  18. package/src/ai/chat/turn-view.tsx +35 -3
  19. package/src/ai/chat/view-block.tsx +7 -2
  20. package/src/ai/chat-task-contracts.ts +28 -3
  21. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  22. package/src/ai/chat-task-reasons.ts +92 -0
  23. package/src/ai/chat-tasks.ts +396 -66
  24. package/src/ai/index.ts +12 -0
  25. package/src/ai/migrate.ts +28 -2
  26. package/src/ai/request-context.ts +7 -1
  27. package/src/ai/routes.ts +27 -1
  28. package/src/ai/runtime.ts +12 -27
  29. package/src/ai/store.ts +5 -3
  30. package/src/ai/timeline.ts +9 -1
  31. package/src/ai/tool-wording.ts +45 -0
  32. package/src/ai/turn-abort.ts +33 -0
  33. package/src/ai/types.ts +12 -1
  34. package/src/api/admin-core-settings.ts +190 -139
  35. package/src/api/admin-outgoing-mail.ts +4 -2
  36. package/src/api/app-approval.ts +9 -2
  37. package/src/api/approvals.ts +1 -1
  38. package/src/api/index.ts +2 -1
  39. package/src/browser/CloudResourceSearch.tsx +10 -6
  40. package/src/browser/app-approval.ts +7 -2
  41. package/src/browser/composer-catalog.ts +103 -0
  42. package/src/browser/composer-insert-messages.ts +31 -0
  43. package/src/browser/composer-insert.ts +150 -0
  44. package/src/browser/panel-messages.ts +24 -0
  45. package/src/browser/panel-slot.ts +354 -0
  46. package/src/browser/panel.ts +28 -0
  47. package/src/browser/resource-picker.tsx +12 -1
  48. package/src/contracts/app-approval.ts +49 -9
  49. package/src/contracts/app.ts +14 -0
  50. package/src/contracts/registry.ts +3 -1
  51. package/src/contracts/shared.ts +2 -0
  52. package/src/server/services/access.ts +5 -1
  53. package/src/services/accounts/posix.ts +2 -1
  54. package/src/services/app-approval.ts +158 -38
  55. package/src/services/approvals/device.ts +2 -0
  56. package/src/services/index.ts +21 -3
  57. package/src/services/notifications/browser.ts +16 -5
  58. package/src/services/pdf/image-protocol.ts +135 -0
  59. package/src/services/pdf/image-variants.ts +124 -0
  60. package/src/services/pdf/image-worker.ts +31 -0
  61. package/src/services/pdf/image.ts +109 -0
  62. package/src/services/pdf/index.ts +9 -0
  63. package/src/services/settings/api.ts +11 -6
  64. package/src/services/settings/app.ts +5 -4
  65. package/src/services/settings/core-settings.ts +4 -3
  66. package/src/services/settings/index.ts +19 -17
  67. package/src/services/settings/namespace.ts +2 -1
  68. package/src/services/settings/store.ts +83 -11
  69. package/src/shared/ai-platform-prompt.ts +1 -1
  70. package/src/shared/app-presentation.ts +1 -0
  71. package/src/shared/redirect.ts +12 -0
  72. package/src/ssr/CloudShell.island.tsx +86 -9
  73. package/src/ssr/Layout.tsx +42 -9
  74. package/src/ssr/LayoutHeader.tsx +15 -9
  75. package/src/ssr/LayoutHelp.tsx +1 -1
  76. package/src/ssr/LayoutRail.tsx +2 -2
  77. package/src/ssr/LayoutTabBar.tsx +29 -0
  78. package/src/ssr/PanelLayout.tsx +27 -0
  79. package/src/ssr/PanelRuntime.island.tsx +87 -0
  80. package/src/ssr/PanelSlot.tsx +108 -0
  81. package/src/ssr/PanelTrigger.tsx +49 -0
  82. package/src/ssr/ShellRuntime.island.tsx +60 -5
  83. package/src/ssr/app-appearance.ts +3 -2
  84. package/src/ssr/app-color.ts +100 -0
  85. package/src/ssr/app-launchpad.tsx +18 -1
  86. package/src/ssr/app-navigation.ts +15 -0
  87. package/src/ssr/compact-shell.baseline.json +5270 -0
  88. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  89. package/src/ssr/index.ts +3 -0
  90. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  91. package/src/ssr/platform-messages.ts +30 -0
  92. package/src/ssr/rail-navigation.ts +44 -0
  93. package/src/ssr/shell-data.tsx +4 -0
  94. package/src/ssr/shell-shortcuts.ts +13 -2
  95. package/src/styles/effects.css +3 -2
  96. package/src/styles/global.css +269 -55
  97. package/src/styles/panel.css +373 -0
  98. package/src/styles/tokens.css +23 -6
  99. package/src/styles/utilities-navigation.css +5 -21
@@ -0,0 +1,431 @@
1
+ import { afterAll, beforeAll, setDefaultTimeout, spyOn } from "bun:test";
2
+ import { mkdtempSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { resolve } from "node:path";
5
+ import { createConfig } from "@k2b/ssr";
6
+ import tailwind from "bun-plugin-tailwind";
7
+ import type { Browser, BrowserContext, Page } from "playwright";
8
+ import { createComponent } from "solid-js";
9
+ import type { launchBrowser } from "../../../ui/test/browser";
10
+
11
+ // The shell's geometry at every width — which chrome exists, where the app
12
+ // area ends, which edges draw lines — is decided by real layout, so a real
13
+ // engine loads the server-rendered Layout with the global stylesheet and the
14
+ // shared workspace controller, as Cloud serves them.
15
+ const root = mkdtempSync(resolve(tmpdir(), "cloud-compact-shell-browser-"));
16
+ const { plugin } = createConfig({ dev: true, rootDir: root });
17
+ Bun.plugin(plugin());
18
+ process.once("exit", () => rmSync(root, { recursive: true, force: true }));
19
+
20
+ const { Hono } = await import("hono");
21
+ const { AppWorkspace, Button, ScrollArea } = await import("@k2b/ui");
22
+ const { defineApp } = await import("../_internal/define-app");
23
+ const { person } = await import("../_internal/define-app.test-fixture");
24
+ const railSnapshot = await import("../services/rail-snapshot");
25
+ const settingsMiddleware = await import("../server/middleware/settings");
26
+ const { defaultRailPreferences } = await import("../contracts/rail-preferences");
27
+ const { default: WorkspaceNavigation } = await import("./WorkspaceNavigation.island");
28
+ const { default: Layout } = await import("./Layout");
29
+
30
+ const probe = defineApp({
31
+ id: "compact-shell-probe",
32
+ name: "Compact shell probe",
33
+ icon: "ti ti-layout",
34
+ description: "Shell geometry probe",
35
+ baseUrl: "http://compact-shell-probe:3000",
36
+ routes: ["/app/mail"],
37
+ });
38
+ const extraApps = ["Amber", "Birch", "Cedar", "Dune", "Elm"].map((name) => ({
39
+ id: name.toLowerCase(),
40
+ name,
41
+ icon: "ti ti-box",
42
+ description: `${name} demo`,
43
+ routes: [`/app/${name.toLowerCase()}`],
44
+ nav: { href: `/app/${name.toLowerCase()}`, section: "primary" },
45
+ }));
46
+ const apps = [
47
+ {
48
+ id: "mail",
49
+ name: "Mail",
50
+ icon: "ti ti-mail",
51
+ description: "Read and write mail",
52
+ routes: ["/app/mail"],
53
+ nav: { href: "/app/mail", section: "primary" },
54
+ },
55
+ {
56
+ id: "pwa",
57
+ name: "Phone app",
58
+ icon: "ti ti-device-mobile",
59
+ description: "Phone app",
60
+ routes: ["/pwa"],
61
+ nav: { href: "/pwa", section: "hidden" },
62
+ },
63
+ ];
64
+
65
+ type LayoutContextArg = Parameters<typeof Layout>[0]["c"];
66
+
67
+ /** A mail-like workspace: navigation, a list pane beside the reader, and a detail panel. Invented content. */
68
+ const workspace = (selected: string) =>
69
+ createComponent(AppWorkspace, {
70
+ mobileSurface: "flush",
71
+ class: "min-h-0 flex-1",
72
+ get children() {
73
+ return [
74
+ createComponent(AppWorkspace.Sidebar, {
75
+ id: "mail-navigation",
76
+ label: "Mailboxes",
77
+ get children() {
78
+ return createComponent(AppWorkspace.SidebarDesktop, {
79
+ get children() {
80
+ return createComponent(AppWorkspace.SidebarBody, {
81
+ get children() {
82
+ return ["inbox", "archive", "sent"].map((id) =>
83
+ createComponent(AppWorkspace.SidebarItem, {
84
+ href: `#${id}`,
85
+ icon: "ti ti-inbox",
86
+ active: id === selected,
87
+ children: id[0]!.toUpperCase() + id.slice(1),
88
+ }),
89
+ );
90
+ },
91
+ });
92
+ },
93
+ });
94
+ },
95
+ }),
96
+ createComponent(AppWorkspace.Content, {
97
+ get children() {
98
+ return [
99
+ createComponent(AppWorkspace.Main, {
100
+ get children() {
101
+ return [
102
+ createComponent(AppWorkspace.MainPane, { id: "messages", label: "Messages", children: "Quarterly numbers" }),
103
+ `Reader for ${selected}`,
104
+ ];
105
+ },
106
+ }),
107
+ createComponent(AppWorkspace.Detail, { id: "message-detail", open: true, width: "sm", children: "Sender details" }),
108
+ ];
109
+ },
110
+ }),
111
+ ];
112
+ },
113
+ });
114
+
115
+ /**
116
+ * A task-list page: a long list in Main and an open detail that the server renders with its own close
117
+ * control, as Contacts and Capabilities do. Invented content.
118
+ */
119
+ const listWorkspace = () =>
120
+ createComponent(AppWorkspace, {
121
+ class: "min-h-0 flex-1",
122
+ get children() {
123
+ return createComponent(AppWorkspace.Content, {
124
+ get children() {
125
+ return [
126
+ createComponent(AppWorkspace.Main, {
127
+ get children() {
128
+ return Array.from({ length: 80 }, (_, index) =>
129
+ createComponent(Button, { class: "list-row", align: "start", children: `Task ${index + 1}` }),
130
+ );
131
+ },
132
+ }),
133
+ createComponent(AppWorkspace.Detail, {
134
+ id: "task-detail",
135
+ open: true,
136
+ get children() {
137
+ return createComponent(Button, { "data-workspace-detail-close": "", children: "Close task" });
138
+ },
139
+ }),
140
+ ];
141
+ },
142
+ });
143
+ },
144
+ });
145
+
146
+ /** A sidebar-first overview that is the page itself, as Mail and Notebooks render. Invented content. */
147
+ const overviewWorkspace = () =>
148
+ createComponent(AppWorkspace, {
149
+ mobileSurface: "flush",
150
+ flat: true,
151
+ resizable: false,
152
+ get children() {
153
+ return [
154
+ createComponent(AppWorkspace.Sidebar, {
155
+ id: "overview-navigation",
156
+ label: "Mailboxes",
157
+ get children() {
158
+ return createComponent(AppWorkspace.SidebarDesktop, {
159
+ get children() {
160
+ return createComponent(AppWorkspace.SidebarBody, {
161
+ get children() {
162
+ return ["Personal", "Projects", "Support"].map((name) =>
163
+ createComponent(AppWorkspace.SidebarItem, { href: `#${name.toLowerCase()}`, children: name }),
164
+ );
165
+ },
166
+ });
167
+ },
168
+ });
169
+ },
170
+ }),
171
+ createComponent(AppWorkspace.Content, {
172
+ get children() {
173
+ return createComponent(AppWorkspace.Main, {
174
+ get children() {
175
+ return Array.from({ length: 80 }, (_, index) =>
176
+ createComponent(Button, { class: "overview-row", align: "start", children: `Message ${index + 1}` }),
177
+ );
178
+ },
179
+ });
180
+ },
181
+ }),
182
+ ];
183
+ },
184
+ });
185
+
186
+ const server = new Hono()
187
+ .get(
188
+ "/",
189
+ async (c, next) => {
190
+ c.set("runtime" as never, { apps } as never);
191
+ c.set("user" as never, person as never);
192
+ await next();
193
+ },
194
+ ...probe.ssr((c) => () => createComponent(Layout, { c: c as unknown as LayoutContextArg, title: "Start", children: "Start content" })),
195
+ )
196
+ .get(
197
+ "/app/mail/:folder",
198
+ async (c, next) => {
199
+ // `?name=` renames Mail, for a long app name in the phone header.
200
+ const name = c.req.query("name");
201
+ const catalog = c.req.query("catalog") ? [...extraApps, ...apps] : apps;
202
+ c.set("runtime" as never, { apps: name ? catalog.map((app) => (app.id === "mail" ? { ...app, name } : app)) : catalog } as never);
203
+ // A missing last name shows the profile notice above the app.
204
+ c.set("user" as never, (c.req.query("notice") ? { ...person, sn: "" } : person) as never);
205
+ await next();
206
+ },
207
+ ...probe.ssr((c) => {
208
+ const folder = c.req.param("folder") ?? "inbox";
209
+ const title = folder[0]!.toUpperCase() + folder.slice(1);
210
+ if (c.req.query("navigation"))
211
+ return () =>
212
+ createComponent(Layout, {
213
+ c: c as unknown as LayoutContextArg,
214
+ title: [{ title: "Home", href: "/" }, { title: "Mail" }],
215
+ get children() {
216
+ return [
217
+ createComponent(WorkspaceNavigation, { label: "Mail", items: [{ id: "inbox", label: "Inbox", href: "/app/mail/inbox" }] }),
218
+ overviewWorkspace(),
219
+ ];
220
+ },
221
+ });
222
+ // `?overview=` keeps regular Layout props, including its page scroller's gutter.
223
+ if (c.req.query("overview"))
224
+ return () =>
225
+ createComponent(Layout, {
226
+ c: c as unknown as LayoutContextArg,
227
+ title: [{ title: "Home", href: "/" }, { title: "Mail" }],
228
+ get children() {
229
+ return overviewWorkspace();
230
+ },
231
+ });
232
+ // `?plain=` renders a regular page without a workspace or scroll area: `main` is its page scroller.
233
+ if (c.req.query("plain"))
234
+ return () =>
235
+ createComponent(Layout, {
236
+ c: c as unknown as LayoutContextArg,
237
+ title: [{ title: "Home", href: "/" }, { title: "Settings" }],
238
+ get children() {
239
+ return Array.from({ length: 60 }, (_, index) =>
240
+ createComponent(Button, { class: "plain-row w-full", align: "start", children: `Setting ${index + 1}` }),
241
+ );
242
+ },
243
+ });
244
+ // `?cards=` renders a page without a workspace that scrolls its content in a ScrollArea, as
245
+ // Capabilities and Weather do.
246
+ if (c.req.query("cards"))
247
+ return () =>
248
+ createComponent(Layout, {
249
+ c: c as unknown as LayoutContextArg,
250
+ fullWidth: true,
251
+ title: [{ title: "Home", href: "/" }, { title: "Capabilities" }],
252
+ get children() {
253
+ return createComponent(ScrollArea, {
254
+ class: "min-h-0 min-w-0 flex-1",
255
+ get children() {
256
+ return Array.from({ length: 60 }, (_, index) =>
257
+ createComponent(Button, { class: "card-row w-full", align: "start", children: `App ${index + 1}` }),
258
+ );
259
+ },
260
+ });
261
+ },
262
+ });
263
+ // `?list=` renders the task list: `scroll` in a page that grows with its content, `page` in a full page.
264
+ const list = c.req.query("list");
265
+ if (list)
266
+ return () =>
267
+ createComponent(Layout, {
268
+ c: c as unknown as LayoutContextArg,
269
+ fullWidth: true,
270
+ fullPage: list === "page",
271
+ title: [{ title: "Home", href: "/" }, { title: "Tasks" }],
272
+ get children() {
273
+ return listWorkspace();
274
+ },
275
+ });
276
+ return () =>
277
+ createComponent(Layout, {
278
+ c: c as unknown as LayoutContextArg,
279
+ fullWidth: true,
280
+ fullPage: true,
281
+ title: [{ title: "Home", href: "/" }, { title: "Mail", href: "/app/mail" }, { title }],
282
+ get children() {
283
+ return workspace(folder);
284
+ },
285
+ });
286
+ }),
287
+ )
288
+ .get(
289
+ "/public",
290
+ async (c, next) => {
291
+ c.set("runtime" as never, { apps } as never);
292
+ await next();
293
+ },
294
+ ...probe.ssr(
295
+ (c) => () =>
296
+ createComponent(Layout, {
297
+ c: c as unknown as LayoutContextArg,
298
+ title: [{ title: "Home", href: "/" }, { title: c.req.query("title") ?? "Public tools" }],
299
+ get children() {
300
+ if (c.req.query("cards"))
301
+ return createComponent(ScrollArea, {
302
+ get children() {
303
+ return Array.from({ length: 60 }, (_, index) =>
304
+ createComponent(Button, { class: "card-row", children: `App ${index + 1}` }),
305
+ );
306
+ },
307
+ });
308
+ return c.req.query("overview") ? overviewWorkspace() : "Public content";
309
+ },
310
+ }),
311
+ ),
312
+ );
313
+
314
+ let browser: Browser;
315
+ let css: string;
316
+ let controller: string;
317
+ const spies: { mockRestore(): void }[] = [];
318
+ // Register hooks in each importing test file; Bun caches this module across files.
319
+ // Keeping hooks at module scope would leave the second file with a closed browser.
320
+ /**
321
+ * Each suite passes `launchBrowser` from packages/ui/test/browser itself, so the test runner and the
322
+ * browser-launch check find it as a browser suite and the gate also runs it in WebKit.
323
+ */
324
+ export function installCompactShellHarness(launch: typeof launchBrowser) {
325
+ // Several tests walk every device, each in a fresh browser context. Bun scopes the default timeout to
326
+ // the test file that sets it, and this module runs once per process, so each suite sets it here.
327
+ setDefaultTimeout(30_000);
328
+ beforeAll(async () => {
329
+ spies.push(
330
+ spyOn(railSnapshot, "readRailSnapshot").mockResolvedValue(defaultRailPreferences()),
331
+ spyOn(settingsMiddleware, "preloadLayoutAnnouncements").mockResolvedValue(undefined),
332
+ );
333
+ const build = await Bun.build({ entrypoints: [resolve(import.meta.dir, "../../../../styles.css")], plugins: [tailwind] });
334
+ if (!build.success) throw new AggregateError(build.logs, "Could not compile the global stylesheet.");
335
+ css = await build.outputs[0]!.text();
336
+ // Cloud installs the shared workspace controller once for the document; the
337
+ // test installs the same export from the shipped browser build.
338
+ const entry = resolve(import.meta.dir, "compact-shell.controller.ts");
339
+ const bundle = await Bun.build({
340
+ entrypoints: [entry],
341
+ files: {
342
+ [entry]: `import { installAppWorkspaceController } from ${JSON.stringify(resolve(import.meta.dir, "../../../ui/dist/browser/index.js"))};
343
+ window.installController = () => installAppWorkspaceController();`,
344
+ },
345
+ target: "browser",
346
+ format: "iife",
347
+ });
348
+ if (!bundle.success) throw new AggregateError(bundle.logs, "Could not bundle the workspace controller.");
349
+ controller = await bundle.outputs[0]!.text();
350
+ browser = await launch();
351
+ }, 60_000);
352
+ afterAll(async () => {
353
+ for (const spy of spies.splice(0)) spy.mockRestore();
354
+ await browser?.close();
355
+ });
356
+ }
357
+
358
+ export type Device = { name: string; width: number; height: number; touch: boolean };
359
+ export const phone: Device = { name: "phone", width: 390, height: 844, touch: true };
360
+ export const ipadSplitView: Device = { name: "iPad Split View", width: 694, height: 834, touch: true };
361
+ export const ipadPortrait: Device = { name: "iPad portrait", width: 834, height: 1194, touch: true };
362
+ export const smallLaptop: Device = { name: "small laptop", width: 1024, height: 768, touch: false };
363
+ export const ipadLandscape: Device = { name: "iPad landscape", width: 1194, height: 834, touch: true };
364
+ export const laptop: Device = { name: "laptop", width: 1440, height: 900, touch: false };
365
+ export const desktop: Device = { name: "desktop", width: 1920, height: 1080, touch: false };
366
+ export const devices = [phone, ipadSplitView, ipadPortrait, smallLaptop, ipadLandscape, laptop, desktop];
367
+ export const tabletBoundary: Device = { name: "tablet boundary", width: 1023, height: 768, touch: false };
368
+
369
+ // The signed-in card's spacing and radius in whole pixels: 4 and 10 px at 1024 px, 8 and 14 px from 1440 px.
370
+ export const cardInset = (width: number) => Math.round(Math.min(8, Math.max(4, 4 + (width - 1024) / 104)));
371
+ export const cardRadius = (width: number) => Math.round(Math.min(14, Math.max(10, 10 + (width - 1024) / 104)));
372
+
373
+ export type Options = {
374
+ language?: "en" | "de";
375
+ theme?: "light" | "dark";
376
+ notice?: boolean;
377
+ style?: string;
378
+ insets?: Partial<Record<"top" | "right" | "bottom" | "left", number>>;
379
+ };
380
+ // Match the existing app-canvas safe-area fixture: engines cannot override env().
381
+ export const withInsets = (stylesheet: string, insets: Options["insets"] = {}) => {
382
+ for (const [side, inset] of Object.entries(insets)) {
383
+ const pattern = new RegExp(`env\\(\\s*safe-area-inset-${side}\\s*(?:,\\s*0px\\s*)?\\)`, "g");
384
+ if (!pattern.test(stylesheet)) throw new Error(`Missing safe-area-inset-${side}`);
385
+ stylesheet = stylesheet.replace(pattern, `${inset}px`);
386
+ }
387
+ return stylesheet;
388
+ };
389
+ export const html = async (path: string, options: Options = {}) => {
390
+ const headers = {
391
+ "Accept-Language": options.language ?? "en",
392
+ Cookie: `theme=${options.theme ?? "light"}`,
393
+ };
394
+ const markup = await (await server.request(path, { headers })).text();
395
+ const clean = new HTMLRewriter()
396
+ .on('script[src], script[type="module"]', { element: (element) => void element.remove() })
397
+ .on('link[rel="stylesheet"], link[rel="preload"], link[rel="modulepreload"]', { element: (element) => void element.remove() })
398
+ .transform(markup);
399
+ return clean.replace(
400
+ "</head>",
401
+ `<style>${withInsets(css, options.insets)}</style><style>*,*::before,*::after{transition:none!important;animation:none!important}${options.style ?? ""}</style></head>`,
402
+ );
403
+ };
404
+
405
+ /** Attaches the controller as Cloud's island does after the first paint, and lets its first frame run. */
406
+ export const attach = async (page: Page) => {
407
+ await page.addScriptTag({ content: controller });
408
+ await page.evaluate(async () => {
409
+ (window as unknown as { installController: () => void }).installController();
410
+ await new Promise((settle) => requestAnimationFrame(() => requestAnimationFrame(settle)));
411
+ });
412
+ };
413
+
414
+ export const open = async (
415
+ device: Device,
416
+ path: string,
417
+ options: Options & { attached?: boolean } = {},
418
+ ): Promise<{ context: BrowserContext; page: Page }> => {
419
+ // iPads and phones report a coarse pointer without hover, as Safari does. A page set from markup has
420
+ // no cookies in WebKit, so the theme script follows the system scheme; it matches the requested theme.
421
+ const context = await browser.newContext({
422
+ viewport: { width: device.width, height: device.height },
423
+ hasTouch: device.touch,
424
+ isMobile: device.touch,
425
+ colorScheme: options.theme ?? "light",
426
+ });
427
+ const page = await context.newPage();
428
+ await page.setContent(await html(path, options));
429
+ if (options.attached !== false) await attach(page);
430
+ return { context, page };
431
+ };
package/src/ssr/index.ts CHANGED
@@ -4,9 +4,11 @@
4
4
  export { default as AdminLayout } from "./AdminLayout";
5
5
  export {
6
6
  hasDedicatedRuntimeRoute,
7
+ type PanelProvider,
7
8
  resolveRuntimeRoute,
8
9
  type VisiblePwaPart,
9
10
  visibleNavigationApps,
11
+ visiblePanelProviders,
10
12
  visiblePwaParts,
11
13
  } from "./app-navigation";
12
14
  export { default as Layout } from "./Layout";
@@ -15,6 +17,7 @@ export {
15
17
  type MinimalLayoutPreferencePosition,
16
18
  type MinimalLayoutProps,
17
19
  } from "./MinimalLayout";
20
+ export { default as PanelLayout, type PanelLayoutProps } from "./PanelLayout";
18
21
  export { default as PwaLayout, type PwaLayoutProps } from "./PwaLayout";
19
22
  export { requestPath } from "./request-path";
20
23
  export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
@@ -0,0 +1,110 @@
1
+ import { AppWorkspace } from "@k2b/ui";
2
+ import { Hono } from "hono";
3
+ import { defineApp } from "../_internal/define-app";
4
+ import { person } from "../_internal/define-app.test-fixture";
5
+ import type { RuntimeAppMeta } from "../contracts/app";
6
+ import type { User } from "../contracts/shared";
7
+ import Layout from "./Layout";
8
+ import PanelLayout from "./PanelLayout";
9
+ import type { RuntimeContext } from "./runtime";
10
+
11
+ const apps: RuntimeAppMeta[] = ["fixture", "second"].map((id) => ({
12
+ id,
13
+ name: id === "fixture" ? "Fixture" : "Second",
14
+ icon: "ti ti-box",
15
+ description: "Panel browser fixture",
16
+ routes: [`/app/${id}`],
17
+ nav: { href: `/app/${id}`, section: "primary" },
18
+ panel: { href: `/app/${id}/panel`, label: id === "fixture" ? "Fixture" : "Second", icon: "ti ti-box" },
19
+ }));
20
+ const probe = defineApp({
21
+ id: "fixture",
22
+ name: "Fixture",
23
+ icon: "ti ti-box",
24
+ description: "Panel browser fixture",
25
+ baseUrl: "http://fixture:3000",
26
+ routes: ["/app/fixture", "/app/second"],
27
+ });
28
+ type Environment = { Variables: { runtime: RuntimeContext; user: User } };
29
+ export const server = new Hono<Environment>()
30
+ .use(async (c, next) => {
31
+ c.set("runtime", { apps });
32
+ c.set("user", person);
33
+ await next();
34
+ })
35
+ .get(
36
+ "/app/:provider/panel",
37
+ ...probe.ssr<Environment>((c) => () => (
38
+ <PanelLayout c={c} title="Fixture panel">
39
+ <label>
40
+ Panel input
41
+ <input aria-label="Panel input" />
42
+ </label>
43
+ <button type="button" id="prevent-escape">
44
+ Keep Escape
45
+ </button>
46
+ <a href="/app/fixture/next" id="leave-panel">
47
+ Open in the app
48
+ </a>
49
+ </PanelLayout>
50
+ )),
51
+ )
52
+ .get(
53
+ "/app/fixture/long",
54
+ ...probe.ssr<Environment>((c) => () => (
55
+ <Layout c={c} title="Long fixture">
56
+ <button type="button" id="open-panel">
57
+ Open fixture
58
+ </button>
59
+ <button type="button" id="open-silent-panel">
60
+ Open silent fixture
61
+ </button>
62
+ <div style="height:150vh">Long content</div>
63
+ <p id="last-row">Last row</p>
64
+ </Layout>
65
+ )),
66
+ )
67
+ .get(
68
+ "/app/fixture/:page?",
69
+ ...probe.ssr<Environment>((c) => () => (
70
+ <Layout c={c} fullWidth fullPage title="Panel fixture">
71
+ <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
72
+ <AppWorkspace.Sidebar id="panel-navigation" label="Navigation">
73
+ <AppWorkspace.SidebarDesktop>
74
+ <AppWorkspace.SidebarItem href="/app/fixture" active>
75
+ Workspace
76
+ </AppWorkspace.SidebarItem>
77
+ </AppWorkspace.SidebarDesktop>
78
+ </AppWorkspace.Sidebar>
79
+ <AppWorkspace.Content>
80
+ <AppWorkspace.Main>
81
+ <button type="button" id="open-panel">
82
+ Open fixture
83
+ </button>
84
+ <button type="button" id="open-silent-panel">
85
+ Open silent fixture
86
+ </button>
87
+ <a href="/app/fixture/next" id="next-page">
88
+ Next page
89
+ </a>
90
+ <textarea aria-label="App editor" />
91
+ </AppWorkspace.Main>
92
+ <AppWorkspace.Detail id="panel-fixture-detail" width="lg" open>
93
+ <h2>Selected item</h2>
94
+ <button type="button" data-workspace-detail-close>
95
+ Close detail
96
+ </button>
97
+ </AppWorkspace.Detail>
98
+ </AppWorkspace.Content>
99
+ </AppWorkspace>
100
+ </Layout>
101
+ )),
102
+ );
103
+
104
+ /** Runs inline during parsing and again immediately after mounting the real runtime. */
105
+ export function panelGeometry() {
106
+ return [".layout-shell-content", "#cloud-panel", ".k2b-app-workspace"].map((selector) => {
107
+ const rect = document.querySelector(selector)?.getBoundingClientRect();
108
+ return rect && { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
109
+ });
110
+ }
@@ -4,6 +4,21 @@ export const platformMessages = i18n.define({
4
4
  baseLocale: "en",
5
5
  messages: {
6
6
  en: {
7
+ panel: "Panel",
8
+ panels: "Panels",
9
+ openPanel: "Open panel",
10
+ closePanel: "Close panel",
11
+ togglePanel: "Open or close the panel",
12
+ togglePanelDescription: "Shows a panel beside the app.",
13
+ panelShortcut: ({ mod }: { mod: string }) => `Panel (${mod}J)`,
14
+ resizePanel: "Resize panel",
15
+ releaseClosesPanel: "Release to close the panel",
16
+ panelLoadFailed: "The panel did not load",
17
+ panelNotResponding: ({ label }: { label: string }) => `${label} is not responding.`,
18
+ retry: "Try again",
19
+ panelLoading: ({ label }: { label: string }) => `${label} is loading`,
20
+ enlargePanel: "Enlarge panel",
21
+ shrinkPanel: "Shrink panel",
7
22
  printFallbackTitle: "This view does not print well.",
8
23
  printFallbackHint: "Open the item you want to print, or use the app's export.",
9
24
  admin: "Admin",
@@ -78,6 +93,21 @@ export const platformMessages = i18n.define({
78
93
  clearSearch: "Clear search",
79
94
  },
80
95
  de: {
96
+ panel: "Panel",
97
+ panels: "Panels",
98
+ openPanel: "Panel öffnen",
99
+ closePanel: "Panel schließen",
100
+ togglePanel: "Panel öffnen oder schließen",
101
+ togglePanelDescription: "Zeigt ein Panel neben der App.",
102
+ panelShortcut: ({ mod }: { mod: string }) => `Panel (${mod}J)`,
103
+ resizePanel: "Panelbreite ändern",
104
+ releaseClosesPanel: "Loslassen schließt das Panel",
105
+ panelLoadFailed: "Das Panel lädt nicht",
106
+ panelNotResponding: ({ label }: { label: string }) => `${label} antwortet nicht.`,
107
+ retry: "Erneut versuchen",
108
+ panelLoading: ({ label }: { label: string }) => `${label} lädt`,
109
+ enlargePanel: "Panel vergrößern",
110
+ shrinkPanel: "Panel verkleinern",
81
111
  printFallbackTitle: "Diese Ansicht lässt sich nicht gut drucken.",
82
112
  printFallbackHint: "Öffne den Eintrag, den du drucken möchtest, oder nutze den Export der App.",
83
113
  admin: "Administration",
@@ -1,3 +1,4 @@
1
+ import type { TabBarItem } from "@k2b/ui";
1
2
  import type { RailPreferences, RailSnapshot } from "../contracts/rail-preferences";
2
3
  import { helpPageAppId } from "./layout-help-url";
3
4
 
@@ -66,3 +67,46 @@ export const railLinkActive = (link: RailLink, currentPath: string): boolean =>
66
67
  (target.pathname !== "/" && current.pathname.startsWith(`${target.pathname.replace(/\/$/, "")}/`))
67
68
  );
68
69
  };
70
+
71
+ /**
72
+ * The phone bar's apps: the rail's links in the person's order, at most `limit`. The current page's link stays in the
73
+ * bar; when it comes later in the rail, it takes the last place, so the bar always marks where the person is.
74
+ */
75
+ export const phoneBarLinks = (navigation: { shortcuts: RailLink[]; apps: RailLink[] }, currentPath: string, limit: number): RailLink[] => {
76
+ const links = [...navigation.shortcuts, ...navigation.apps];
77
+ const current = links.findIndex((link) => railLinkActive(link, currentPath));
78
+ if (current < limit) return links.slice(0, limit);
79
+ return [...links.slice(0, limit - 1), links[current]!];
80
+ };
81
+
82
+ /** Start and four apps fill the phone bar's five places. */
83
+ const PHONE_BAR_APPS = 4;
84
+
85
+ const underPath = (pathname: string, path: string) => pathname === path || pathname.startsWith(`${path.replace(/\/$/, "")}/`);
86
+
87
+ /**
88
+ * The phone bar: Home, then the person's first apps in their rail order. Home leads to `/`, which opens the
89
+ * installation's start page (`home`), so it is the open item on `/` and on that start page, unless an app in the bar
90
+ * is open there.
91
+ */
92
+ export const phoneBarItems = (
93
+ navigation: { shortcuts: RailLink[]; apps: RailLink[] },
94
+ currentPath: string,
95
+ home: { label: string; path: string },
96
+ ): TabBarItem[] => {
97
+ const links = phoneBarLinks(navigation, currentPath, PHONE_BAR_APPS);
98
+ const items = links.map((link) => ({
99
+ id: link.id,
100
+ label: link.label,
101
+ icon: link.iconClass,
102
+ href: link.href,
103
+ current: railLinkActive(link, currentPath),
104
+ }));
105
+ const pathname = new URL(currentPath, "https://cloud.invalid").pathname;
106
+ const start = new URL(home.path, "https://cloud.invalid").pathname;
107
+ const atHome = pathname === "/" || (!items.some((item) => item.current) && underPath(pathname, start));
108
+ return [
109
+ { id: "home", label: home.label, icon: "ti ti-home", href: "/", current: atHome },
110
+ ...items.map((item) => ({ ...item, current: !atHome && item.current })),
111
+ ];
112
+ };
@@ -4,13 +4,17 @@ import type { AppSearchLink } from "../contracts/app";
4
4
  import type { RailSnapshot } from "../contracts/rail-preferences";
5
5
  import type { CloudThemePreference } from "../shared/theme";
6
6
  import type { AppLaunchpadLegalLink } from "./app-launchpad";
7
+ import type { PanelProvider } from "./app-navigation";
7
8
  import type { RailApp } from "./rail-navigation";
8
9
 
9
10
  export type ShellData = {
11
+ panel?: { providers: PanelProvider[]; provider?: string };
10
12
  /** The authorized navigation catalog, sorted for the request locale: the rail, the app grid and the phone menu show it. */
11
13
  apps: RailApp[];
12
14
  /** Signed-in pages: the person's rail preferences. */
13
15
  rail?: RailSnapshot;
16
+ /** Signed-in pages: the start page that `/` opens, for the phone bar's Home. */
17
+ home?: string;
14
18
  legalLinks: AppLaunchpadLegalLink[];
15
19
  /** Signed-in pages: the person, for the profile menu and the phone menu's profile actions. */
16
20
  profile?: { name: string; avatarSrc?: string; appHref?: string };