@k2b/cloud 0.29.1 → 0.31.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/_internal/app-presentation.ts +22 -1
- package/src/_internal/app-roles.ts +14 -0
- package/src/_internal/capabilities.ts +113 -5
- package/src/_internal/capability-sentences.ts +413 -0
- package/src/_internal/define-app.ts +24 -5
- package/src/_internal/registry-validation.ts +5 -0
- package/src/_internal/registry.ts +50 -6
- package/src/_internal/widget-declarations.ts +53 -0
- package/src/access/PermissionEditor.tsx +43 -24
- package/src/ai/app-skill-store.ts +298 -0
- package/src/ai/app-skills.ts +105 -0
- package/src/ai/approval-routes.ts +40 -4
- package/src/ai/approvals.ts +68 -10
- package/src/ai/browser.ts +10 -0
- package/src/ai/capabilities.ts +68 -9
- package/src/ai/chart-block.ts +380 -0
- package/src/ai/chat/action-sentences.ts +49 -0
- package/src/ai/chat/blocks.tsx +97 -40
- package/src/ai/chat/chart-block.tsx +79 -0
- package/src/ai/chat/live-turn.browser-harness.tsx +4 -1
- package/src/ai/chat/message-actions.tsx +3 -1
- package/src/ai/chat/message-utils.ts +3 -0
- package/src/ai/chat/messages.ts +26 -2
- package/src/ai/chat/tool-groups.ts +66 -12
- package/src/ai/chat/tool-names.ts +6 -2
- package/src/ai/chat/turn-layout.ts +10 -2
- package/src/ai/chat/turn-view.tsx +123 -36
- package/src/ai/chat/web-tools.tsx +95 -1
- package/src/ai/client/controller.ts +6 -3
- package/src/ai/code-runtime-tools.ts +109 -61
- package/src/ai/code-source-contracts.ts +25 -21
- package/src/ai/code-source-tools.ts +3 -1
- package/src/ai/default-tool-contracts.ts +198 -0
- package/src/ai/default-tools.ts +25 -1
- package/src/ai/executor.ts +189 -35
- package/src/ai/fetch-file-tool.ts +41 -4
- package/src/ai/firecrawl-tools.ts +87 -4
- package/src/ai/index.ts +17 -1
- package/src/ai/migrate.ts +172 -14
- package/src/ai/protocol.ts +22 -1
- package/src/ai/routes.ts +11 -0
- package/src/ai/runtime-tools.ts +1 -0
- package/src/ai/runtime.ts +46 -7
- package/src/ai/skill-content.ts +54 -0
- package/src/ai/skills-routes.ts +22 -13
- package/src/ai/skills.ts +177 -231
- package/src/ai/store.ts +66 -1
- package/src/ai/tool-audit.ts +11 -1
- package/src/ai/tool-sentences.ts +38 -0
- package/src/ai/tools.ts +91 -3
- package/src/ai/types.ts +47 -1
- package/src/ai/web-provenance.ts +103 -0
- package/src/api/admin-ai-skills.ts +35 -16
- package/src/api/capabilities.ts +3 -2
- package/src/api/fanout.ts +128 -0
- package/src/api/help.ts +8 -3
- package/src/api/mcp.ts +4 -1
- package/src/api/search.ts +31 -106
- package/src/api/widgets.ts +291 -101
- package/src/browser/CloudResourceSearch.browser-harness.tsx +1 -1
- package/src/browser/CloudResourceSearch.tsx +1 -1
- package/src/browser/command-shortcuts.ts +21 -6
- package/src/browser/ndjson.ts +34 -0
- package/src/browser/reload.ts +46 -0
- package/src/browser/search-stream.ts +4 -26
- package/src/browser/spotlight-position.ts +4 -1
- package/src/browser/widgets.ts +30 -0
- package/src/capabilities/executions.ts +3 -0
- package/src/capabilities/server.ts +20 -2
- package/src/cli/access.ts +48 -14
- package/src/cli/admin/ai-skills.ts +59 -39
- package/src/cli/commands.ts +20 -0
- package/src/cli/index.ts +16 -0
- package/src/cli/plugin.ts +30 -0
- package/src/contracts/announcement-cookie.ts +62 -0
- package/src/contracts/announcements.ts +14 -52
- package/src/contracts/app.ts +34 -2
- package/src/contracts/capabilities.ts +24 -0
- package/src/contracts/pwa-paths.ts +7 -2
- package/src/contracts/pwa.ts +2 -7
- package/src/contracts/registry.ts +10 -1
- package/src/contracts/shared.ts +5 -0
- package/src/contracts/widgets.ts +67 -2
- package/src/desktop/solid.tsx +6 -7
- package/src/index.ts +1 -0
- package/src/server/help.ts +8 -1
- package/src/server/index.ts +1 -0
- package/src/server/widget-request.ts +27 -0
- package/src/services/accounts/users.ts +15 -0
- package/src/services/approvals/index.ts +225 -0
- package/src/services/approvals/migrate.ts +33 -0
- package/src/services/help/index.ts +18 -5
- package/src/services/help/types.ts +3 -2
- package/src/services/identity/constants.ts +2 -0
- package/src/services/identity/key-ring.ts +123 -42
- package/src/services/identity/runtime-config.ts +13 -2
- package/src/shared/app-presentation.ts +11 -0
- package/src/shared/email-html.ts +43 -8
- package/src/ssr/AdminLayout.tsx +4 -7
- package/src/ssr/AppLaunchpad.island.tsx +1 -1
- package/src/ssr/AppLaunchpadPanel.tsx +12 -3
- package/src/ssr/GlobalAnnouncements.island.tsx +2 -2
- package/src/ssr/GlobalSearchDialog.tsx +1 -43
- package/src/ssr/GlobalSearchTrigger.island.tsx +12 -3
- package/src/ssr/Layout.tsx +38 -14
- package/src/ssr/LayoutFooter.tsx +1 -1
- package/src/ssr/LayoutHeader.tsx +17 -20
- package/src/ssr/LayoutHelp.tsx +1 -1
- package/src/ssr/LayoutHelpTrigger.island.tsx +1 -1
- package/src/ssr/LayoutRail.tsx +1 -2
- package/src/ssr/PageTrail.tsx +23 -0
- package/src/ssr/PageTrailRow.island.tsx +20 -0
- package/src/ssr/PageTrailRuntime.island.tsx +26 -0
- package/src/ssr/ProfilePreferences.island.tsx +7 -3
- package/src/ssr/PwaRuntime.island.tsx +1 -1
- package/src/ssr/app-navigation.ts +5 -7
- package/src/ssr/global-search-host.tsx +55 -0
- package/src/ssr/help.ts +3 -3
- package/src/ssr/islands/index.ts +1 -0
- package/src/ssr/layout-runtime.ts +60 -0
- package/src/ssr/mobile-menu-history.ts +1 -1
- package/src/ssr/page-trail-runtime.ts +313 -0
- package/src/ssr/page-trail.browser-harness.tsx +216 -0
- package/src/ssr/page-trail.ts +59 -0
- package/src/ssr/platform-messages.ts +4 -0
- package/src/ssr/rail-context.ts +4 -3
- package/src/ssr/runtime.ts +14 -1
- package/src/styles/effects.css +21 -4
- package/src/styles/global.css +35 -2
- package/src/styles/utilities-layout.css +24 -1
- package/src/styles/utilities-navigation.css +6 -18
- package/src/ai/code-mode-skill.ts +0 -114
- package/src/ai/data-analysis-skill.ts +0 -10
- package/src/ai/grids-skill.ts +0 -158
- package/src/ai/skill-seeds.ts +0 -560
|
@@ -0,0 +1,313 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The way up, in the browser: links in a page trail lead to the page above,
|
|
3
|
+
* as this tab left it. A plain up link would reset the list a person
|
|
4
|
+
* filtered, searched, and scrolled; this runtime remembers each page's last
|
|
5
|
+
* URL and scroll position per tab and returns there. The trail's links keep
|
|
6
|
+
* their canonical `href`, so a new tab, a copied link, or a reload still lead
|
|
7
|
+
* to the page itself. After returning, the row that led down is marked.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { PAGE_TRAIL_EVENT, readPageTrail } from "@k2b/ui";
|
|
11
|
+
import { registerContextAwareCommand } from "../browser/command-bridge";
|
|
12
|
+
import { PAGE_UP_EVENT, type PageUpDetail } from "./layout-runtime";
|
|
13
|
+
|
|
14
|
+
const STORAGE_KEY = "cloud:page-trail";
|
|
15
|
+
/**
|
|
16
|
+
* Recent pages per tab. A trail goes back through a few levels, often across
|
|
17
|
+
* a detour into another app; thirty distinct pages cover that with room to
|
|
18
|
+
* spare while the memory stays a few kilobytes of `sessionStorage`.
|
|
19
|
+
*/
|
|
20
|
+
const VISIT_LIMIT = 30;
|
|
21
|
+
/** How long a returning page may take to render the regions and rows it restores, at one try per frame. */
|
|
22
|
+
const RESTORE_FRAMES = 60;
|
|
23
|
+
const TRAIL_LINK = "a[data-k2b-page-trail-link]";
|
|
24
|
+
const MAIN = ".layout-content-main";
|
|
25
|
+
|
|
26
|
+
type Region = { key: string; top: number; left: number };
|
|
27
|
+
/** A page as this tab left it; `up` holds the links of the levels above it then. */
|
|
28
|
+
type Visit = { url: string; regions: Region[]; up?: string[] };
|
|
29
|
+
type Memory = { visits: Visit[]; returning?: { url: string; from: string } };
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `path` as a page of this origin, or nothing: read against the origin,
|
|
33
|
+
* `//host`, `@host`, or `javascript:` would lead elsewhere, and a path that
|
|
34
|
+
* parses to `//host`, such as `/..//host`, would once an island reads its
|
|
35
|
+
* pathname.
|
|
36
|
+
*/
|
|
37
|
+
const localPage = (path: string): URL | undefined => {
|
|
38
|
+
if (!path.startsWith("/")) return undefined;
|
|
39
|
+
try {
|
|
40
|
+
const url = new URL(path, location.origin);
|
|
41
|
+
return url.origin === location.origin && !url.pathname.startsWith("//") ? url : undefined;
|
|
42
|
+
} catch {
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
const isRegion = (value: unknown): value is Region =>
|
|
48
|
+
typeof value === "object" &&
|
|
49
|
+
value !== null &&
|
|
50
|
+
"key" in value &&
|
|
51
|
+
typeof value.key === "string" &&
|
|
52
|
+
"top" in value &&
|
|
53
|
+
typeof value.top === "number" &&
|
|
54
|
+
"left" in value &&
|
|
55
|
+
typeof value.left === "number";
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The tab's storage is not trusted: a visit or a return counts only when its
|
|
59
|
+
* addresses are pages of this origin and the rest has the shape this runtime writes.
|
|
60
|
+
*/
|
|
61
|
+
const isVisit = (value: unknown): value is Visit =>
|
|
62
|
+
typeof value === "object" &&
|
|
63
|
+
value !== null &&
|
|
64
|
+
"url" in value &&
|
|
65
|
+
typeof value.url === "string" &&
|
|
66
|
+
localPage(value.url) !== undefined &&
|
|
67
|
+
"regions" in value &&
|
|
68
|
+
Array.isArray(value.regions) &&
|
|
69
|
+
value.regions.every(isRegion) &&
|
|
70
|
+
(!("up" in value) ||
|
|
71
|
+
(Array.isArray(value.up) && value.up.every((item: unknown) => typeof item === "string" && URL.canParse(item, location.href))));
|
|
72
|
+
|
|
73
|
+
const isReturning = (value: unknown): value is NonNullable<Memory["returning"]> =>
|
|
74
|
+
typeof value === "object" &&
|
|
75
|
+
value !== null &&
|
|
76
|
+
"url" in value &&
|
|
77
|
+
typeof value.url === "string" &&
|
|
78
|
+
localPage(value.url) !== undefined &&
|
|
79
|
+
"from" in value &&
|
|
80
|
+
typeof value.from === "string" &&
|
|
81
|
+
localPage(value.from) !== undefined;
|
|
82
|
+
|
|
83
|
+
const read = (): Memory => {
|
|
84
|
+
try {
|
|
85
|
+
const value: unknown = JSON.parse(sessionStorage.getItem(STORAGE_KEY) ?? "null");
|
|
86
|
+
if (value !== null && typeof value === "object" && "visits" in value && Array.isArray(value.visits)) {
|
|
87
|
+
const visits = value.visits.filter(isVisit);
|
|
88
|
+
return "returning" in value && isReturning(value.returning) ? { visits, returning: value.returning } : { visits };
|
|
89
|
+
}
|
|
90
|
+
} catch {
|
|
91
|
+
// A full or blocked storage only costs the memory, never the navigation.
|
|
92
|
+
}
|
|
93
|
+
return { visits: [] };
|
|
94
|
+
};
|
|
95
|
+
const write = (memory: Memory) => {
|
|
96
|
+
try {
|
|
97
|
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify(memory));
|
|
98
|
+
} catch {
|
|
99
|
+
// See `read`.
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const here = () => `${location.pathname}${location.search}${location.hash}`;
|
|
104
|
+
const pathOf = (url: URL) => url.pathname.replace(/\/+$/, "") || "/";
|
|
105
|
+
const resolve = (href: string) => new URL(href, location.href);
|
|
106
|
+
|
|
107
|
+
/** A remembered page is the page `target` names when it has the same path and every query value `target` sets. */
|
|
108
|
+
const isPage = (visited: URL, target: URL) =>
|
|
109
|
+
visited.origin === target.origin &&
|
|
110
|
+
pathOf(visited) === pathOf(target) &&
|
|
111
|
+
[...target.searchParams].every(([key, value]) => visited.searchParams.getAll(key).includes(value));
|
|
112
|
+
/** How many of `from`'s query values `url` carries, less the ones the two do not share. */
|
|
113
|
+
const queryMatch = (url: URL, from: URL) => {
|
|
114
|
+
const shared = [...url.searchParams].filter(([key, value]) => from.searchParams.getAll(key).includes(value)).length;
|
|
115
|
+
return 2 * shared - ([...url.searchParams].length - shared) - ([...from.searchParams].length - shared);
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** The page's scroll positions: the window, the shell's page scroller, and every keyed `data-scroll-preserve` region. */
|
|
119
|
+
const captureRegions = (): Region[] => {
|
|
120
|
+
const regions: Region[] = [{ key: "@window", top: window.scrollY, left: window.scrollX }];
|
|
121
|
+
const main = document.querySelector<HTMLElement>(MAIN);
|
|
122
|
+
if (main) regions.push({ key: "@main", top: main.scrollTop, left: main.scrollLeft });
|
|
123
|
+
for (const element of document.querySelectorAll<HTMLElement>("[data-scroll-preserve]")) {
|
|
124
|
+
const key = element.dataset.scrollPreserve;
|
|
125
|
+
if (key) regions.push({ key, top: element.scrollTop, left: element.scrollLeft });
|
|
126
|
+
}
|
|
127
|
+
return regions.filter((region) => region.top || region.left);
|
|
128
|
+
};
|
|
129
|
+
|
|
130
|
+
/** Remembers the page as it is now, newest first, with the levels above it. */
|
|
131
|
+
export const rememberPage = () => {
|
|
132
|
+
const memory = read();
|
|
133
|
+
const url = here();
|
|
134
|
+
const up = readPageTrail()?.ancestors.map((level) => level.href) ?? [];
|
|
135
|
+
memory.visits = [{ url, regions: captureRegions(), up }, ...memory.visits.filter((visit) => visit.url !== url)].slice(0, VISIT_LIMIT);
|
|
136
|
+
write(memory);
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Where an up link leads in this tab: the page's last URL here, or its own
|
|
141
|
+
* `href`. A page below the target is never that page, even when its address
|
|
142
|
+
* carries the target's query, as a Files document carries its folder's: it
|
|
143
|
+
* names the target as a level above it.
|
|
144
|
+
*/
|
|
145
|
+
export const upDestination = (href: string): string => {
|
|
146
|
+
const target = resolve(href);
|
|
147
|
+
const below = (visit: Visit) => (visit.up ?? []).some((level) => isPage(resolve(level), target) && isPage(target, resolve(level)));
|
|
148
|
+
const visit = read().visits.find((candidate) => isPage(resolve(candidate.url), target) && !below(candidate));
|
|
149
|
+
return visit?.url ?? `${target.pathname}${target.search}${target.hash}`;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Goes one or more levels up to `href`, back to how this tab left that page.
|
|
154
|
+
* An island that navigates in place takes the way up itself (see
|
|
155
|
+
* `layout.handleUp`); otherwise the page loads. Returns whether it took the
|
|
156
|
+
* way up; a link it does not take stays a plain link.
|
|
157
|
+
*/
|
|
158
|
+
export const goUp = (href: string): boolean => {
|
|
159
|
+
// The way up stays on this origin; a link elsewhere, such as `javascript:`, names no page above.
|
|
160
|
+
if (resolve(href).origin !== location.origin) return false;
|
|
161
|
+
rememberPage();
|
|
162
|
+
const destination = upDestination(href);
|
|
163
|
+
// The destination is read as a path of this origin and must stay there; one that starts with `//` names another host.
|
|
164
|
+
const url = localPage(destination);
|
|
165
|
+
if (!url) return false;
|
|
166
|
+
const detail: PageUpDetail = { href: destination };
|
|
167
|
+
if (!window.dispatchEvent(new CustomEvent(PAGE_UP_EVENT, { cancelable: true, detail }))) return true;
|
|
168
|
+
const memory = read();
|
|
169
|
+
memory.returning = { url: destination, from: here() };
|
|
170
|
+
write(memory);
|
|
171
|
+
location.assign(url.href);
|
|
172
|
+
return true;
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
const regionElement = (key: string): HTMLElement | null =>
|
|
176
|
+
key === "@main"
|
|
177
|
+
? document.querySelector<HTMLElement>(MAIN)
|
|
178
|
+
: document.querySelector<HTMLElement>(`[data-scroll-preserve="${CSS.escape(key)}"]`);
|
|
179
|
+
|
|
180
|
+
/** Scores of links to the page itself rank above every link to a page above it, which score by path length. */
|
|
181
|
+
const SAME_PAGE = 1_000_000;
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The link on this page that led towards `from`: the same page, or, for
|
|
185
|
+
* nested paths, the nearest page above it. Navigation, the rail, and trails
|
|
186
|
+
* themselves lead elsewhere and do not count.
|
|
187
|
+
*/
|
|
188
|
+
const rowTowards = (from: URL): HTMLElement | null => {
|
|
189
|
+
const scope = document.querySelector(MAIN) ?? document.body;
|
|
190
|
+
let best: { element: HTMLAnchorElement; score: number } | null = null;
|
|
191
|
+
for (const link of scope.querySelectorAll<HTMLAnchorElement>("a[href]")) {
|
|
192
|
+
if (link.closest(".k2b-page-title, .k2b-app-workspace__sidebar, .layout-rail, nav")) continue;
|
|
193
|
+
const url = resolve(link.getAttribute("href") ?? "");
|
|
194
|
+
if (url.origin !== from.origin) continue;
|
|
195
|
+
// The row may carry the list's query or leave it out; either way it names the same page. The closer its query
|
|
196
|
+
// is to `from`'s, the surer it is the row: a link without one, such as "All things", only names the list.
|
|
197
|
+
const same = isPage(from, url) || isPage(url, from);
|
|
198
|
+
const above = !same && url.search === "" && pathOf(from).startsWith(`${pathOf(url)}/`);
|
|
199
|
+
if (!same && !above) continue;
|
|
200
|
+
const score = same ? SAME_PAGE + queryMatch(url, from) : pathOf(url).length;
|
|
201
|
+
if (!best || score > best.score) best = { element: link, score };
|
|
202
|
+
}
|
|
203
|
+
return best ? (best.element.closest<HTMLElement>("tr, [role='row'], li") ?? best.element) : null;
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
/** Restores a returning page's scroll positions and marks the row that led down. Islands render late, so it retries per frame. */
|
|
207
|
+
const restoreReturn = (visit: Visit | undefined, from: URL) => {
|
|
208
|
+
const pending = new Map((visit?.regions ?? []).map((region) => [region.key, region]));
|
|
209
|
+
let marked: HTMLElement | null = null;
|
|
210
|
+
let frames = 0;
|
|
211
|
+
const step = () => {
|
|
212
|
+
for (const [key, region] of pending) {
|
|
213
|
+
// A region is restored once both axes hold; content that renders late can still widen or lengthen it.
|
|
214
|
+
if (key === "@window") {
|
|
215
|
+
window.scrollTo(region.left, region.top);
|
|
216
|
+
if (Math.abs(window.scrollY - region.top) < 1 && Math.abs(window.scrollX - region.left) < 1) pending.delete(key);
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const element = regionElement(key);
|
|
220
|
+
if (!element) continue;
|
|
221
|
+
element.scrollTop = region.top;
|
|
222
|
+
element.scrollLeft = region.left;
|
|
223
|
+
if (Math.abs(element.scrollTop - region.top) < 1 && Math.abs(element.scrollLeft - region.left) < 1) pending.delete(key);
|
|
224
|
+
}
|
|
225
|
+
if (!marked?.isConnected) {
|
|
226
|
+
marked = rowTowards(from);
|
|
227
|
+
if (marked) {
|
|
228
|
+
marked.setAttribute("data-page-trail-recent", "");
|
|
229
|
+
if (!visit?.regions.length) marked.scrollIntoView({ block: "nearest" });
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
frames += 1;
|
|
233
|
+
if (frames < RESTORE_FRAMES && (pending.size || !marked)) requestAnimationFrame(step);
|
|
234
|
+
};
|
|
235
|
+
requestAnimationFrame(step);
|
|
236
|
+
};
|
|
237
|
+
|
|
238
|
+
/** The localized name and description of the "One level up" command for a parent page. */
|
|
239
|
+
export type PageUpLabels = (parent: string) => { title: string; description: string };
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Offers "One level up" (Alt+↑, ⌥↑ on a Mac) while the page has a page above
|
|
243
|
+
* it, through the shared command system: the shortcut, the palette, and the
|
|
244
|
+
* shortcut help list it only then, and the shortcut stays quiet in text
|
|
245
|
+
* fields. It follows trail changes from `layout.update()`.
|
|
246
|
+
*/
|
|
247
|
+
const offerPageUp = (labels: PageUpLabels): (() => void) => {
|
|
248
|
+
let unregister = () => {};
|
|
249
|
+
const update = () => {
|
|
250
|
+
unregister();
|
|
251
|
+
unregister = () => {};
|
|
252
|
+
const parent = readPageTrail()?.ancestors.at(-1);
|
|
253
|
+
if (!parent) return;
|
|
254
|
+
unregister = registerContextAwareCommand({
|
|
255
|
+
id: "cloud.page-up",
|
|
256
|
+
...labels(parent.title),
|
|
257
|
+
icon: "ti ti-arrow-up",
|
|
258
|
+
shortcut: "alt+arrowup",
|
|
259
|
+
action: () => {
|
|
260
|
+
goUp(parent.href);
|
|
261
|
+
},
|
|
262
|
+
});
|
|
263
|
+
};
|
|
264
|
+
update();
|
|
265
|
+
window.addEventListener(PAGE_TRAIL_EVENT, update);
|
|
266
|
+
return () => {
|
|
267
|
+
window.removeEventListener(PAGE_TRAIL_EVENT, update);
|
|
268
|
+
unregister();
|
|
269
|
+
};
|
|
270
|
+
};
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Installs the runtime for the document. Every click and Enter remembers the
|
|
274
|
+
* page as it is before anything navigates, also in place, as a list row that
|
|
275
|
+
* opens on Enter does; a plain click on a trail link goes up through the
|
|
276
|
+
* memory. Returns the disposer.
|
|
277
|
+
*/
|
|
278
|
+
export const installPageTrailRuntime = (labels: PageUpLabels): (() => void) => {
|
|
279
|
+
const stopPageUp = offerPageUp(labels);
|
|
280
|
+
const memory = read();
|
|
281
|
+
const returning = memory.returning;
|
|
282
|
+
if (returning) {
|
|
283
|
+
delete memory.returning;
|
|
284
|
+
write(memory);
|
|
285
|
+
const current = resolve(here());
|
|
286
|
+
if (pathOf(resolve(returning.url)) === pathOf(current) && resolve(returning.url).search === current.search)
|
|
287
|
+
restoreReturn(
|
|
288
|
+
memory.visits.find((visit) => visit.url === returning.url),
|
|
289
|
+
resolve(returning.from),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
const onCapture = () => rememberPage();
|
|
293
|
+
const onKey = (event: KeyboardEvent) => {
|
|
294
|
+
if (event.key === "Enter") rememberPage();
|
|
295
|
+
};
|
|
296
|
+
const onClick = (event: MouseEvent) => {
|
|
297
|
+
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
|
|
298
|
+
const link = (event.target as Element | null)?.closest?.<HTMLAnchorElement>(TRAIL_LINK);
|
|
299
|
+
if (!link || (link.target && link.target !== "_self")) return;
|
|
300
|
+
if (goUp(link.href)) event.preventDefault();
|
|
301
|
+
};
|
|
302
|
+
document.addEventListener("click", onCapture, true);
|
|
303
|
+
document.addEventListener("keydown", onKey, true);
|
|
304
|
+
document.addEventListener("click", onClick);
|
|
305
|
+
window.addEventListener("pagehide", onCapture);
|
|
306
|
+
return () => {
|
|
307
|
+
stopPageUp();
|
|
308
|
+
document.removeEventListener("click", onCapture, true);
|
|
309
|
+
document.removeEventListener("keydown", onKey, true);
|
|
310
|
+
document.removeEventListener("click", onClick);
|
|
311
|
+
window.removeEventListener("pagehide", onCapture);
|
|
312
|
+
};
|
|
313
|
+
};
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
import { AppWorkspace } from "@k2b/ui";
|
|
2
|
+
import { Hono } from "hono";
|
|
3
|
+
import type { JSX } from "solid-js";
|
|
4
|
+
import { defineApp } from "../_internal/define-app";
|
|
5
|
+
import { person } from "../_internal/define-app.test-fixture";
|
|
6
|
+
import Layout from "./Layout";
|
|
7
|
+
import PageTrail from "./PageTrail";
|
|
8
|
+
|
|
9
|
+
// Pages for `page-trail.browser.test.ts`: Accounts-like list and detail pages
|
|
10
|
+
// in a workspace, a Files-like folder at any depth, a page without a
|
|
11
|
+
// workspace, and a third-party app that draws its own header. All names are
|
|
12
|
+
// invented.
|
|
13
|
+
|
|
14
|
+
const probe = defineApp({
|
|
15
|
+
id: "page-trail-probe",
|
|
16
|
+
name: "Page trail probe",
|
|
17
|
+
icon: "ti ti-route",
|
|
18
|
+
description: "Page trail probe",
|
|
19
|
+
baseUrl: "http://page-trail-probe:3000",
|
|
20
|
+
routes: ["/app/accounts"],
|
|
21
|
+
});
|
|
22
|
+
const apps = [
|
|
23
|
+
{
|
|
24
|
+
id: "accounts",
|
|
25
|
+
name: "Accounts",
|
|
26
|
+
icon: "ti ti-users",
|
|
27
|
+
description: "People",
|
|
28
|
+
routes: ["/app/accounts"],
|
|
29
|
+
nav: { href: "/app/accounts", section: "primary" },
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
id: "files",
|
|
33
|
+
name: "Files",
|
|
34
|
+
icon: "ti ti-files",
|
|
35
|
+
description: "Files",
|
|
36
|
+
routes: ["/app/files"],
|
|
37
|
+
nav: { href: "/app/files", section: "primary" },
|
|
38
|
+
},
|
|
39
|
+
// A third-party app: it draws its own header and places the trail there.
|
|
40
|
+
{
|
|
41
|
+
id: "timetrack",
|
|
42
|
+
name: "Time",
|
|
43
|
+
icon: "ti ti-clock",
|
|
44
|
+
description: "Time tracking",
|
|
45
|
+
routes: ["/app/timetrack"],
|
|
46
|
+
nav: { href: "/app/timetrack", section: "primary" },
|
|
47
|
+
},
|
|
48
|
+
{ id: "core", name: "Account", icon: "ti ti-user", description: "Account", routes: ["/me"], nav: { href: "/me", section: "hidden" } },
|
|
49
|
+
];
|
|
50
|
+
type LayoutContext = Parameters<typeof Layout>[0]["c"];
|
|
51
|
+
type Title = { title: string; href?: string }[];
|
|
52
|
+
|
|
53
|
+
const start = { title: "Start", href: "/" };
|
|
54
|
+
const accountsRoot = { title: "Accounts", href: "/app/accounts" };
|
|
55
|
+
export const users = Array.from({ length: 60 }, (_, index) => `user${String(index + 1).padStart(2, "0")}`);
|
|
56
|
+
export const folders = [
|
|
57
|
+
"Projects for the new community centre",
|
|
58
|
+
"2026 budget year with the second construction phase",
|
|
59
|
+
"Quotes from all invited suppliers",
|
|
60
|
+
"Supplier A, Hartmann and Sons, Bremen branch",
|
|
61
|
+
"Questions about the foundation",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
const Page = (props: { c: unknown; title: Title; children: JSX.Element }) => (
|
|
65
|
+
<Layout c={props.c as LayoutContext} fullWidth fullPage title={props.title}>
|
|
66
|
+
{props.children}
|
|
67
|
+
</Layout>
|
|
68
|
+
);
|
|
69
|
+
const AccountsWorkspace = (props: { children: JSX.Element }) => (
|
|
70
|
+
<AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
|
|
71
|
+
<AppWorkspace.Sidebar id="accounts-navigation" label="Accounts">
|
|
72
|
+
<AppWorkspace.SidebarDesktop>
|
|
73
|
+
<AppWorkspace.SidebarItem href="/app/accounts/users" icon="ti ti-user" active>
|
|
74
|
+
Users
|
|
75
|
+
</AppWorkspace.SidebarItem>
|
|
76
|
+
</AppWorkspace.SidebarDesktop>
|
|
77
|
+
</AppWorkspace.Sidebar>
|
|
78
|
+
<AppWorkspace.Content>
|
|
79
|
+
<AppWorkspace.Main scrollPreserveKey="accounts-users">{props.children}</AppWorkspace.Main>
|
|
80
|
+
</AppWorkspace.Content>
|
|
81
|
+
</AppWorkspace>
|
|
82
|
+
);
|
|
83
|
+
const folderHref = (depth: number) => `/app/files?path=${encodeURIComponent(folders.slice(0, depth).join("/"))}`;
|
|
84
|
+
const filesTitle = (depth: number): Title => [
|
|
85
|
+
{ title: "Files", href: "/app/files" },
|
|
86
|
+
...folders.slice(0, depth - 1).map((name, index) => ({ title: name, href: folderHref(index + 1) })),
|
|
87
|
+
{ title: folders[depth - 1]! },
|
|
88
|
+
];
|
|
89
|
+
/** A document in the editor, as Files names it: its way up is its folder with the document's row selected. */
|
|
90
|
+
const editorTitle = (depth: number, file: string): Title => [
|
|
91
|
+
...filesTitle(depth).slice(0, -1),
|
|
92
|
+
{ title: folders[depth - 1]!, href: `${folderHref(depth)}&file=${encodeURIComponent(file)}` },
|
|
93
|
+
{ title: file.split("/").at(-1)! },
|
|
94
|
+
];
|
|
95
|
+
|
|
96
|
+
export const server = new Hono()
|
|
97
|
+
.use(async (c, next) => {
|
|
98
|
+
c.set("runtime" as never, { apps } as never);
|
|
99
|
+
c.set("user" as never, person as never);
|
|
100
|
+
await next();
|
|
101
|
+
})
|
|
102
|
+
.get(
|
|
103
|
+
"/app/accounts/users",
|
|
104
|
+
...probe.ssr((c) => {
|
|
105
|
+
const group = c.req.query("group");
|
|
106
|
+
const shown = group ? users.filter((_, index) => index % 2 === 0) : users;
|
|
107
|
+
return () => (
|
|
108
|
+
<Page c={c} title={[start, accountsRoot, { title: "Users" }]}>
|
|
109
|
+
<AccountsWorkspace>
|
|
110
|
+
<h1 class="p-3 font-semibold">{group ? `Users in ${group}` : "Users"}</h1>
|
|
111
|
+
{/* A region that scrolls sideways once its content arrives, as a wide table that renders late does. */}
|
|
112
|
+
<div data-scroll-preserve="accounts-wide" class="accounts-wide" style="width:300px;overflow:auto">
|
|
113
|
+
<div class="accounts-wide-content" style="width:300px;height:8px" />
|
|
114
|
+
</div>
|
|
115
|
+
<table class="w-full">
|
|
116
|
+
<tbody>
|
|
117
|
+
{shown.map((uid) => (
|
|
118
|
+
<tr style="height:48px">
|
|
119
|
+
<td>
|
|
120
|
+
<a href={`/app/accounts/users/${uid}${group ? `?group=${group}` : ""}`}>{uid}</a>
|
|
121
|
+
</td>
|
|
122
|
+
</tr>
|
|
123
|
+
))}
|
|
124
|
+
</tbody>
|
|
125
|
+
</table>
|
|
126
|
+
</AccountsWorkspace>
|
|
127
|
+
</Page>
|
|
128
|
+
);
|
|
129
|
+
}),
|
|
130
|
+
)
|
|
131
|
+
.get(
|
|
132
|
+
"/app/accounts/users/:uid",
|
|
133
|
+
...probe.ssr((c) => {
|
|
134
|
+
const uid = c.req.param("uid") ?? "";
|
|
135
|
+
return () => (
|
|
136
|
+
<Page c={c} title={[start, accountsRoot, { title: "Users", href: "/app/accounts/users" }, { title: uid }]}>
|
|
137
|
+
<AccountsWorkspace>
|
|
138
|
+
<p class="p-3">Profile of {uid}</p>
|
|
139
|
+
<input aria-label="Note" class="m-3" />
|
|
140
|
+
</AccountsWorkspace>
|
|
141
|
+
</Page>
|
|
142
|
+
);
|
|
143
|
+
}),
|
|
144
|
+
)
|
|
145
|
+
.get(
|
|
146
|
+
"/app/files",
|
|
147
|
+
...probe.ssr((c) => {
|
|
148
|
+
const depth = (c.req.query("path") ?? "").split("/").filter(Boolean).length || 1;
|
|
149
|
+
const file = c.req.query("view") === "edit" ? c.req.query("file") : undefined;
|
|
150
|
+
return () => (
|
|
151
|
+
<Page c={c} title={file ? editorTitle(depth, file) : filesTitle(depth)}>
|
|
152
|
+
<AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
|
|
153
|
+
<AppWorkspace.Sidebar id="files-navigation" label="Storage">
|
|
154
|
+
<AppWorkspace.SidebarDesktop>
|
|
155
|
+
<AppWorkspace.SidebarItem href="/app/files" icon="ti ti-folder">
|
|
156
|
+
My files
|
|
157
|
+
</AppWorkspace.SidebarItem>
|
|
158
|
+
</AppWorkspace.SidebarDesktop>
|
|
159
|
+
</AppWorkspace.Sidebar>
|
|
160
|
+
<AppWorkspace.Content>
|
|
161
|
+
<AppWorkspace.Main>Folder contents</AppWorkspace.Main>
|
|
162
|
+
<AppWorkspace.Detail id="file-detail" open={c.req.query("detail") === "1"} width="lg">
|
|
163
|
+
Details
|
|
164
|
+
</AppWorkspace.Detail>
|
|
165
|
+
</AppWorkspace.Content>
|
|
166
|
+
</AppWorkspace>
|
|
167
|
+
</Page>
|
|
168
|
+
);
|
|
169
|
+
}),
|
|
170
|
+
)
|
|
171
|
+
.get(
|
|
172
|
+
"/me/notifications/history",
|
|
173
|
+
...probe.ssr((c) => () => (
|
|
174
|
+
<Page
|
|
175
|
+
c={c}
|
|
176
|
+
title={[
|
|
177
|
+
start,
|
|
178
|
+
{ title: "Account", href: "/me" },
|
|
179
|
+
{ title: "Notifications", href: "/me/notifications" },
|
|
180
|
+
{ title: "Delivery history" },
|
|
181
|
+
]}
|
|
182
|
+
>
|
|
183
|
+
<div class="p-3">
|
|
184
|
+
<h1 class="text-xl font-semibold">Delivery history</h1>
|
|
185
|
+
<p>Every message sent to you.</p>
|
|
186
|
+
</div>
|
|
187
|
+
</Page>
|
|
188
|
+
)),
|
|
189
|
+
)
|
|
190
|
+
.get(
|
|
191
|
+
"/app/timetrack/entries/:id",
|
|
192
|
+
...probe.ssr((c) => () => (
|
|
193
|
+
<Page
|
|
194
|
+
c={c}
|
|
195
|
+
title={[
|
|
196
|
+
start,
|
|
197
|
+
{ title: "Time", href: "/app/timetrack" },
|
|
198
|
+
{ title: "Website relaunch", href: "/app/timetrack/projects/web" },
|
|
199
|
+
{ title: "Homepage: agree on drafts" },
|
|
200
|
+
]}
|
|
201
|
+
>
|
|
202
|
+
<div class="flex min-h-0 flex-1 flex-col">
|
|
203
|
+
<header class="timetrack-head flex min-h-14 items-center gap-2 px-3">
|
|
204
|
+
<PageTrail subtitle="2:15 h" />
|
|
205
|
+
<button type="button" class="ml-auto">
|
|
206
|
+
Start timer
|
|
207
|
+
</button>
|
|
208
|
+
</header>
|
|
209
|
+
<dl class="p-3">
|
|
210
|
+
<dt>Project</dt>
|
|
211
|
+
<dd>Website relaunch</dd>
|
|
212
|
+
</dl>
|
|
213
|
+
</div>
|
|
214
|
+
</Page>
|
|
215
|
+
)),
|
|
216
|
+
);
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { PageTrailLink, PageTrailState } from "@k2b/ui";
|
|
2
|
+
import type { LayoutBreadcrumb } from "./layout-runtime";
|
|
3
|
+
|
|
4
|
+
/** `path` without trailing slashes, in one pass: a regular expression would backtrack on long runs of `/`. */
|
|
5
|
+
const withoutTrailingSlashes = (path: string): string => {
|
|
6
|
+
let end = path.length;
|
|
7
|
+
while (end > 0 && path[end - 1] === "/") end--;
|
|
8
|
+
return path.slice(0, end);
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
/** Path and query of a same-origin link, without a trailing slash; the hash names no other page. */
|
|
12
|
+
const pageOf = (href: string): string => {
|
|
13
|
+
const hash = href.indexOf("#");
|
|
14
|
+
const withoutHash = hash >= 0 ? href.slice(0, hash) : href;
|
|
15
|
+
const queryStart = withoutHash.indexOf("?");
|
|
16
|
+
const path = queryStart >= 0 ? withoutHash.slice(0, queryStart) : withoutHash;
|
|
17
|
+
const query = queryStart >= 0 ? withoutHash.slice(queryStart) : "";
|
|
18
|
+
return (path.length > 1 ? withoutTrailingSlashes(path) : path) + (query.length > 1 ? query : "");
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
/** The trail without levels that link to `page` itself, by path and query. */
|
|
22
|
+
export const dropSelfLinks = (trail: PageTrailState, page: string): PageTrailState => {
|
|
23
|
+
const self = pageOf(page);
|
|
24
|
+
const ancestors = trail.ancestors.filter((level) => pageOf(level.href) !== self);
|
|
25
|
+
return ancestors.length === trail.ancestors.length ? trail : { ...trail, ancestors };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The page trail from a page's title array, by one rule for every app.
|
|
30
|
+
*
|
|
31
|
+
* The last entry is the page itself. Above it, Start (`href: "/"`) and the
|
|
32
|
+
* app's own root (`href` equal to its base path) are dropped: the rail already
|
|
33
|
+
* leads there. So is an entry without an `href`, because nobody can go back to
|
|
34
|
+
* it, and one that links to the page itself (`currentPage`, path and query).
|
|
35
|
+
* What remains are the levels below the app root, nearest last; their number
|
|
36
|
+
* alone decides how the shell shows the way up, so an app never chooses a form
|
|
37
|
+
* and every app with the same depth looks the same.
|
|
38
|
+
*/
|
|
39
|
+
export const derivePageTrail = (
|
|
40
|
+
title: string | readonly LayoutBreadcrumb[] | undefined,
|
|
41
|
+
appBasePath: string | undefined,
|
|
42
|
+
fallback: string,
|
|
43
|
+
currentPage?: string,
|
|
44
|
+
): PageTrailState => {
|
|
45
|
+
if (typeof title === "string") return { title, ancestors: [] };
|
|
46
|
+
const current = title?.at(-1);
|
|
47
|
+
if (!title || !current) return { title: fallback, ancestors: [] };
|
|
48
|
+
const base = appBasePath ? pageOf(appBasePath) : undefined;
|
|
49
|
+
const ancestors = title
|
|
50
|
+
.slice(0, -1)
|
|
51
|
+
.filter((entry): entry is PageTrailLink => typeof entry.href === "string" && entry.href.length > 0)
|
|
52
|
+
.filter((entry) => {
|
|
53
|
+
const page = pageOf(entry.href);
|
|
54
|
+
return page !== "/" && page !== "" && page !== base;
|
|
55
|
+
})
|
|
56
|
+
.map((entry) => ({ title: entry.title, href: entry.href }));
|
|
57
|
+
const trail = { title: current.title, ancestors };
|
|
58
|
+
return currentPage ? dropSelfLinks(trail, currentPage) : trail;
|
|
59
|
+
};
|
|
@@ -65,6 +65,8 @@ export const platformMessages = i18n.define({
|
|
|
65
65
|
searchShortcut: "Search (⌘/Ctrl+K)",
|
|
66
66
|
openShortcutHelp: "Open shortcut help",
|
|
67
67
|
shortcutHelpDescription: "Open help, guides, and keyboard shortcuts.",
|
|
68
|
+
pageUp: ({ page }: { page: string }) => `One level up: ${page}`,
|
|
69
|
+
pageUpDescription: "Go to the page above this one, as you left it in this tab.",
|
|
68
70
|
openHelp: "Open help",
|
|
69
71
|
helpShortcut: "Help (Shift+/)",
|
|
70
72
|
signIn: "Sign in",
|
|
@@ -134,6 +136,8 @@ export const platformMessages = i18n.define({
|
|
|
134
136
|
searchShortcut: "Suchen (⌘/Strg+K)",
|
|
135
137
|
openShortcutHelp: "Tastaturhilfe öffnen",
|
|
136
138
|
shortcutHelpDescription: "Hilfe, Anleitungen und Tastaturkürzel öffnen.",
|
|
139
|
+
pageUp: ({ page }: { page: string }) => `Eine Ebene höher: ${page}`,
|
|
140
|
+
pageUpDescription: "Zur Seite darüber, so wie du sie in diesem Tab verlassen hast.",
|
|
137
141
|
openHelp: "Hilfe öffnen",
|
|
138
142
|
helpShortcut: "Hilfe (Umschalt+/)",
|
|
139
143
|
signIn: "Anmelden",
|
package/src/ssr/rail-context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { RailPreferences, RailSnapshot } from "../contracts/rail-preferences";
|
|
2
2
|
import type { RailApp } from "./rail-navigation";
|
|
3
3
|
|
|
4
4
|
export type RailContext = { apps: RailApp[]; settings: RailSnapshot };
|
|
@@ -8,9 +8,10 @@ export const readRailContext = (): RailContext | undefined => {
|
|
|
8
8
|
if (typeof document === "undefined") return undefined;
|
|
9
9
|
const text = document.getElementById("cloud-rail-data")?.textContent;
|
|
10
10
|
if (!text) return undefined;
|
|
11
|
-
// Framework-generated, escaped SSR data; never loaded from localStorage.
|
|
11
|
+
// Framework-generated, escaped SSR data; never loaded from localStorage. The server validates the snapshot when
|
|
12
|
+
// it reads it, so the shell does not load the schemas on every page.
|
|
12
13
|
const value: RailContext = JSON.parse(text);
|
|
13
|
-
return { apps: value.apps, settings:
|
|
14
|
+
return { apps: value.apps, settings: value.settings };
|
|
14
15
|
};
|
|
15
16
|
|
|
16
17
|
export const publishRailPreferences = (settings: RailPreferences) => {
|
package/src/ssr/runtime.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
import type { CloudRuntime } from "../contracts/app";
|
|
6
6
|
import { getLocale } from "../server/locale";
|
|
7
|
-
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
7
|
+
import { resolveAppPresentation, resolveAppPresentations } from "../shared/app-presentation";
|
|
8
8
|
|
|
9
9
|
export type RuntimeContext = CloudRuntime;
|
|
10
10
|
|
|
@@ -29,3 +29,16 @@ export const getLocalizedRuntimeContext = (carrier: RuntimeCarrier & { req: { ra
|
|
|
29
29
|
const locale = getLocale(carrier as Parameters<typeof getLocale>[0]);
|
|
30
30
|
return { ...runtime, apps: resolveAppPresentations(runtime.apps, locale) };
|
|
31
31
|
};
|
|
32
|
+
|
|
33
|
+
/** An app's name in the request locale while the app is registered; `fallback` (its stored base name) otherwise. */
|
|
34
|
+
export const localizedAppName = (
|
|
35
|
+
carrier: RuntimeCarrier & { req: { raw: { headers: Headers } } },
|
|
36
|
+
appId: string,
|
|
37
|
+
fallback: string,
|
|
38
|
+
): string => {
|
|
39
|
+
const runtime = carrier.get("runtime");
|
|
40
|
+
const apps =
|
|
41
|
+
runtime && typeof runtime === "object" && Array.isArray((runtime as RuntimeContext).apps) ? (runtime as RuntimeContext).apps : [];
|
|
42
|
+
const app = apps.find((entry) => entry.id === appId);
|
|
43
|
+
return app ? resolveAppPresentation(app, getLocale(carrier as Parameters<typeof getLocale>[0])).name : fallback;
|
|
44
|
+
};
|