@k2b/cloud 0.19.0 → 0.21.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 +14 -14
- package/scripts/build.ts +3 -1
- package/scripts/sync-recovery-smoke.ts +1 -4
- package/src/_internal/define-app.ts +4 -0
- package/src/_internal/font-preloads.ts +25 -0
- package/src/_internal/nats-connection.ts +3 -10
- package/src/_internal/process-sync.ts +100 -21
- package/src/_internal/status-preserving-ssr.ts +9 -1
- package/src/_internal/sync-budget.ts +306 -0
- package/src/access/PermissionEditor.tsx +31 -12
- package/src/access/PrincipalPicker.tsx +23 -19
- package/src/access/messages.ts +2 -0
- package/src/access/service-account-kind.ts +28 -0
- package/src/account/EntitySearch.tsx +9 -8
- package/src/ai/chat-quotas.ts +10 -6
- package/src/ai/code-mode-skill.ts +2 -2
- package/src/ai/inference-calls.ts +2 -2
- package/src/ai/model-access.ts +5 -2
- package/src/ai/projects.ts +7 -2
- package/src/ai/quota-report.ts +6 -1
- package/src/ai/quotas.ts +1 -1
- package/src/ai/skills.ts +7 -2
- package/src/cli/access.ts +1 -0
- package/src/config/env.ts +5 -0
- package/src/server/locale.ts +17 -4
- package/src/server/middleware/validator.ts +23 -1
- package/src/services/gateway.ts +5 -0
- package/src/services/index.ts +2 -0
- package/src/services/pdf/index.ts +2 -0
- package/src/services/pdf/markdown.ts +50 -12
- package/src/services/settings/core-settings.ts +2 -0
- package/src/shared/ai-quotas.ts +16 -3
- package/src/ssr/LayoutPreferences.island.tsx +2 -17
- package/src/ssr/MinimalLayout.tsx +44 -20
- package/src/ssr/MinimalLayoutPreferences.island.tsx +52 -0
- package/src/ssr/PageError.tsx +1 -1
- package/src/ssr/profile-preferences-messages.ts +4 -0
- package/src/styles/resource-search.css +2 -2
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-markdown-editor.css +2 -2
- package/src/styles/utilities-navigation.css +94 -20
- package/src/types/k2b-ui-fonts.d.ts +7 -0
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Context, ValidationTargets } from "hono";
|
|
2
|
-
import { validator as honoValidator } from "hono-openapi";
|
|
2
|
+
import { type GenerateSpecOptions, validator as honoValidator } from "hono-openapi";
|
|
3
3
|
import type { ZodType } from "zod";
|
|
4
4
|
|
|
5
5
|
export type ValidatorError = Readonly<{
|
|
@@ -9,6 +9,28 @@ export type ValidatorError = Readonly<{
|
|
|
9
9
|
|
|
10
10
|
export type ValidatorErrorResolver = (context: Context) => ValidatorError;
|
|
11
11
|
|
|
12
|
+
/**
|
|
13
|
+
* OpenAPI description of the 400 body `validator()` sends: `{ message }`, or
|
|
14
|
+
* `{ code, message }` from a route's error resolver. The generated spec of
|
|
15
|
+
* every application uses it for validated routes that do not describe their
|
|
16
|
+
* own 400, in place of hono-openapi's default body, which Cloud never sends.
|
|
17
|
+
*/
|
|
18
|
+
export const validationErrorResponse: Exclude<GenerateSpecOptions["defaultValidationErrorResponse"], boolean> = {
|
|
19
|
+
description: "Validation failed",
|
|
20
|
+
content: {
|
|
21
|
+
"application/json": {
|
|
22
|
+
schema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
message: { type: "string" },
|
|
26
|
+
code: { type: "string" },
|
|
27
|
+
},
|
|
28
|
+
required: ["message"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
|
|
12
34
|
/**
|
|
13
35
|
* Zod validator middleware with pretty error messages and OpenAPI support.
|
|
14
36
|
* Uses hono-openapi validator for automatic OpenAPI schema generation.
|
package/src/services/gateway.ts
CHANGED
|
@@ -132,6 +132,11 @@ export const latestGatewayRouteSnapshot = async (): Promise<GatewayRouteSnapshot
|
|
|
132
132
|
return all.sort((a, b) => b.updatedAt - a.updatedAt)[0] ?? null;
|
|
133
133
|
};
|
|
134
134
|
|
|
135
|
+
/**
|
|
136
|
+
* Dead letters keep the event stream's limit. Gateway Ops leaves dead letters
|
|
137
|
+
* when rollup writes fail; if they exceeded a lowered limit, this topic, and
|
|
138
|
+
* with it Gateway Ops' start, would fail until they expired.
|
|
139
|
+
*/
|
|
135
140
|
export const gatewayTelemetryTopic = lazySync((sync) =>
|
|
136
141
|
sync.topic<GatewayTelemetryEvent>({
|
|
137
142
|
id: TOPIC_ID,
|
package/src/services/index.ts
CHANGED
|
@@ -148,6 +148,7 @@ export {
|
|
|
148
148
|
MARKDOWN_PDF_TEMPLATE_IDS,
|
|
149
149
|
MarkdownPdfError,
|
|
150
150
|
buildMarkdownPdfHtml,
|
|
151
|
+
buildPresetPdfHtml,
|
|
151
152
|
getGotenbergConfig,
|
|
152
153
|
attachPdfFiles,
|
|
153
154
|
attachPdfFilesWithConfig,
|
|
@@ -163,6 +164,7 @@ export {
|
|
|
163
164
|
testGotenberg,
|
|
164
165
|
} from "./pdf";
|
|
165
166
|
export type {
|
|
167
|
+
BuildPresetPdfHtmlInput,
|
|
166
168
|
GotenbergConfig,
|
|
167
169
|
AttachPdfFilesInput,
|
|
168
170
|
RenderFacturXHtmlToPdfInput,
|
|
@@ -22,6 +22,7 @@ export {
|
|
|
22
22
|
testGotenberg,
|
|
23
23
|
} from "./gotenberg";
|
|
24
24
|
export type {
|
|
25
|
+
BuildPresetPdfHtmlInput,
|
|
25
26
|
MarkdownPdfErrorCode,
|
|
26
27
|
MarkdownPdfTemplateId,
|
|
27
28
|
RenderMarkdownToPdfInput,
|
|
@@ -29,6 +30,7 @@ export type {
|
|
|
29
30
|
} from "./markdown";
|
|
30
31
|
export {
|
|
31
32
|
buildMarkdownPdfHtml,
|
|
33
|
+
buildPresetPdfHtml,
|
|
32
34
|
MARKDOWN_PDF_MAX_CUSTOM_CSS_BYTES,
|
|
33
35
|
MARKDOWN_PDF_MAX_MARKDOWN_BYTES,
|
|
34
36
|
MARKDOWN_PDF_TEMPLATE_IDS,
|
|
@@ -38,6 +38,27 @@ export type RenderMarkdownToPdfInput = {
|
|
|
38
38
|
|
|
39
39
|
export type RenderMarkdownToPdfOptions = RenderHtmlToPdfOptions;
|
|
40
40
|
|
|
41
|
+
export type BuildPresetPdfHtmlInput = {
|
|
42
|
+
/** Body HTML the application rendered from its own document format. */
|
|
43
|
+
html: string;
|
|
44
|
+
templateId?: MarkdownPdfTemplateId;
|
|
45
|
+
customCss?: string;
|
|
46
|
+
/** Application CSS for its own elements, applied after the preset and before `customCss`. */
|
|
47
|
+
css?: string;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Noto Serif, which Gotenberg ships, forms f-ligatures for the report body. It
|
|
51
|
+
// has no "≠" but has "=" and the combining overlay U+0338, so Chromium would
|
|
52
|
+
// build "≠" from both and draw "=/". Without U+0338 in the range, such
|
|
53
|
+
// characters fall back whole to the next font. local() takes the PostScript
|
|
54
|
+
// name and fetches nothing.
|
|
55
|
+
const REPORT_SERIF_RANGE = "U+0-337, U+339-10FFFF";
|
|
56
|
+
const REPORT_SERIF_FACES = `
|
|
57
|
+
@font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-Regular"); unicode-range: ${REPORT_SERIF_RANGE}; }
|
|
58
|
+
@font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-Bold"); font-weight: bold; unicode-range: ${REPORT_SERIF_RANGE}; }
|
|
59
|
+
@font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-Italic"); font-style: italic; unicode-range: ${REPORT_SERIF_RANGE}; }
|
|
60
|
+
@font-face { font-family: "Report Noto Serif"; src: local("NotoSerif-BoldItalic"); font-weight: bold; font-style: italic; unicode-range: ${REPORT_SERIF_RANGE}; }`;
|
|
61
|
+
|
|
41
62
|
const TEMPLATE_CSS: Record<MarkdownPdfTemplateId, string> = {
|
|
42
63
|
document: `
|
|
43
64
|
@page { size: A4; margin: 22mm 20mm 24mm; }
|
|
@@ -58,9 +79,9 @@ th { background: #f1f5f9; font-weight: 650; }
|
|
|
58
79
|
thead { display: table-header-group; } tr { break-inside: avoid; }
|
|
59
80
|
hr { border: 0; border-top: 1px solid #cbd5e1; margin: 1.5em 0; }
|
|
60
81
|
`,
|
|
61
|
-
report:
|
|
82
|
+
report: `${REPORT_SERIF_FACES}
|
|
62
83
|
@page { size: A4; margin: 24mm 22mm 26mm; }
|
|
63
|
-
:root { color: #263244; font: 10.75pt/1.58 Georgia, "Times New Roman", serif; }
|
|
84
|
+
:root { color: #263244; font: 10.75pt/1.58 Georgia, "Report Noto Serif", "Times New Roman", serif; }
|
|
64
85
|
body { margin: 0; background: #fff; }
|
|
65
86
|
.markdown-document { max-width: 100%; overflow-wrap: anywhere; }
|
|
66
87
|
h1, h2, h3, h4, h5, h6 { color: #13233a; font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; line-height: 1.18; break-after: avoid-page; }
|
|
@@ -114,6 +135,12 @@ const safeLink = (value: string): string | null => {
|
|
|
114
135
|
const renderMarkdown = (source: string): string => {
|
|
115
136
|
const renderer = new Renderer();
|
|
116
137
|
renderer.html = ({ text }: Tokens.HTML | Tokens.Tag) => escapeHtml(text);
|
|
138
|
+
// marked flags text that follows an inline <pre>, <code>, <kbd> or <script>
|
|
139
|
+
// tag as already escaped, because it expects that tag to reach the output as
|
|
140
|
+
// HTML. The tag is escaped here, so the text after it is escaped as well.
|
|
141
|
+
renderer.text = function (token: Tokens.Text | Tokens.Escape) {
|
|
142
|
+
return Renderer.prototype.text.call(this, token.type === "text" && token.escaped ? { ...token, escaped: false } : token);
|
|
143
|
+
};
|
|
117
144
|
renderer.image = ({ href, title, text }: Tokens.Image) => {
|
|
118
145
|
const label = escapeHtml(text.trim() ? `Image: ${text}` : "Image");
|
|
119
146
|
const safeHref = safeLink(href);
|
|
@@ -144,6 +171,10 @@ const renderMarkdown = (source: string): string => {
|
|
|
144
171
|
}
|
|
145
172
|
};
|
|
146
173
|
|
|
174
|
+
// Keep CSS inside its style element even when it contains an HTML end tag.
|
|
175
|
+
// The backslash is valid CSS escaping but no longer an HTML token.
|
|
176
|
+
const styleText = (css: string): string => css.replace(/<\/style/giu, "<\\/style");
|
|
177
|
+
|
|
147
178
|
const validateCustomCss = (customCss: string): string => {
|
|
148
179
|
if (byteLength(customCss) > MARKDOWN_PDF_MAX_CUSTOM_CSS_BYTES) {
|
|
149
180
|
throw new MarkdownPdfError("invalid_css", "Custom CSS exceeds the 32 KiB limit.", "css_too_large");
|
|
@@ -168,15 +199,15 @@ const validateCustomCss = (customCss: string): string => {
|
|
|
168
199
|
}
|
|
169
200
|
});
|
|
170
201
|
|
|
171
|
-
|
|
172
|
-
// end tag. The backslash is valid CSS escaping but no longer an HTML token.
|
|
173
|
-
return root.toString().replace(/<\/style/giu, "<\\/style");
|
|
202
|
+
return styleText(root.toString());
|
|
174
203
|
};
|
|
175
204
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
205
|
+
/**
|
|
206
|
+
* Wrap application-rendered HTML in the same print presets and validated
|
|
207
|
+
* custom CSS as Markdown PDFs, for documents whose Markdown dialect the
|
|
208
|
+
* application renders itself.
|
|
209
|
+
*/
|
|
210
|
+
export const buildPresetPdfHtml = (input: BuildPresetPdfHtmlInput): string => {
|
|
180
211
|
const templateId = input.templateId;
|
|
181
212
|
if (templateId !== undefined && !MARKDOWN_PDF_TEMPLATE_IDS.includes(templateId)) {
|
|
182
213
|
throw new MarkdownPdfError("bad_input", "Unknown Markdown PDF template.", "unknown_template");
|
|
@@ -185,8 +216,8 @@ export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string =>
|
|
|
185
216
|
const suppliedCustomCss = input.customCss?.trim() ?? "";
|
|
186
217
|
const customCss = suppliedCustomCss ? validateCustomCss(input.customCss ?? "") : "";
|
|
187
218
|
const presetCss = templateId ? TEMPLATE_CSS[templateId] : customCss ? "" : TEMPLATE_CSS.document;
|
|
188
|
-
const
|
|
189
|
-
const
|
|
219
|
+
const baseCss = [presetCss, input.css ? styleText(input.css) : ""].filter(Boolean).join("\n");
|
|
220
|
+
const stylesheet = `${baseCss}${baseCss && customCss ? `\n/* Custom CSS overrides */\n` : ""}${customCss}`;
|
|
190
221
|
return `<!doctype html>
|
|
191
222
|
<html lang="en">
|
|
192
223
|
<head>
|
|
@@ -195,10 +226,17 @@ export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string =>
|
|
|
195
226
|
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; base-uri 'none'; connect-src 'none'; font-src 'none'; form-action 'none'; frame-src 'none'; img-src 'none'; media-src 'none'; object-src 'none'; script-src 'none'; style-src 'unsafe-inline'">
|
|
196
227
|
<style>${stylesheet}</style>
|
|
197
228
|
</head>
|
|
198
|
-
<body><main class="markdown-document">${
|
|
229
|
+
<body><main class="markdown-document">${input.html}</main></body>
|
|
199
230
|
</html>`;
|
|
200
231
|
};
|
|
201
232
|
|
|
233
|
+
export const buildMarkdownPdfHtml = (input: RenderMarkdownToPdfInput): string => {
|
|
234
|
+
if (typeof input.markdown !== "string" || !input.markdown.trim()) {
|
|
235
|
+
throw new MarkdownPdfError("bad_input", "Markdown must not be empty.", "markdown_empty");
|
|
236
|
+
}
|
|
237
|
+
return buildPresetPdfHtml({ html: renderMarkdown(input.markdown), templateId: input.templateId, customCss: input.customCss });
|
|
238
|
+
};
|
|
239
|
+
|
|
202
240
|
export const renderMarkdownToPdfWithConfig = (
|
|
203
241
|
input: RenderMarkdownToPdfInput,
|
|
204
242
|
config: GotenbergConfig,
|
|
@@ -317,6 +317,8 @@ export const CORE_SETTINGS = {
|
|
|
317
317
|
default: "",
|
|
318
318
|
description: "Internal base URL of the Gotenberg service used for HTML-to-PDF rendering.",
|
|
319
319
|
placeholder: "e.g. http://gotenberg:3000",
|
|
320
|
+
envFallback: () => env.GOTENBERG_URL,
|
|
321
|
+
envBootstrap: () => env.GOTENBERG_URL,
|
|
320
322
|
},
|
|
321
323
|
"gotenberg.username": {
|
|
322
324
|
kind: "string",
|
package/src/shared/ai-quotas.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
-
import { PrincipalSchema } from "../contracts/shared";
|
|
2
|
+
import { PrincipalSchema, ServiceAccountKindSchema } from "../contracts/shared";
|
|
3
3
|
import { AiPriceSchema } from "./ai-costs";
|
|
4
4
|
|
|
5
5
|
export const AiBackgroundBudgetSchema = z
|
|
@@ -22,6 +22,8 @@ export const AiQuotaRuleSchema = z
|
|
|
22
22
|
.object({
|
|
23
23
|
principal: PrincipalSchema.refine((p) => p.type !== "public", "Chat quotas require an identity."),
|
|
24
24
|
displayName: z.string().max(300).optional(),
|
|
25
|
+
/** Presentation only, like `displayName`: service-account grants show the same kind as the principal picker. */
|
|
26
|
+
serviceAccountKind: ServiceAccountKindSchema.optional(),
|
|
25
27
|
limit: AiPriceSchema.nullable(),
|
|
26
28
|
})
|
|
27
29
|
.strict(),
|
|
@@ -71,11 +73,22 @@ export const AiQuotaUsersQuerySchema = z
|
|
|
71
73
|
})
|
|
72
74
|
.strict();
|
|
73
75
|
|
|
76
|
+
/**
|
|
77
|
+
* One own chat allowance as the browser sees it. It carries no amounts, limits, or unit:
|
|
78
|
+
* `usedPercent` is a whole number from 0 to 100 and reaches 100 only when the allowance is used up.
|
|
79
|
+
* Below 100 the server can still reject a call that the rest of the allowance cannot cover.
|
|
80
|
+
* It is `null` when the allowance is unlimited or its usage cannot be measured right now.
|
|
81
|
+
*/
|
|
82
|
+
export type AiChatQuotaBalance = {
|
|
83
|
+
scope: string;
|
|
84
|
+
unlimited: boolean;
|
|
85
|
+
usedPercent: number | null;
|
|
86
|
+
resetsAt: string | null;
|
|
87
|
+
};
|
|
74
88
|
export type AiChatQuotaSnapshot = {
|
|
75
|
-
unit?: string;
|
|
76
89
|
unlimitedModels?: string[];
|
|
77
90
|
enabled: boolean;
|
|
78
|
-
balances:
|
|
91
|
+
balances: AiChatQuotaBalance[];
|
|
79
92
|
};
|
|
80
93
|
|
|
81
94
|
export const AiQuotaReportQuerySchema = z.object({
|
|
@@ -3,10 +3,8 @@ import type { CloudTheme } from "../shared/theme";
|
|
|
3
3
|
import { createPreferenceController } from "./preference-controller";
|
|
4
4
|
|
|
5
5
|
type LayoutPreferencesProps = {
|
|
6
|
-
class?: string;
|
|
7
6
|
initialTheme: CloudTheme;
|
|
8
7
|
position: DropdownPosition;
|
|
9
|
-
triggerClass?: string;
|
|
10
8
|
};
|
|
11
9
|
|
|
12
10
|
export default function LayoutPreferences(props: LayoutPreferencesProps) {
|
|
@@ -14,21 +12,8 @@ export default function LayoutPreferences(props: LayoutPreferencesProps) {
|
|
|
14
12
|
const preferences = createPreferenceController(props.initialTheme, locale);
|
|
15
13
|
|
|
16
14
|
return (
|
|
17
|
-
<Dropdown.Root
|
|
18
|
-
|
|
19
|
-
items={preferences.items()}
|
|
20
|
-
label={preferences.messages().preferencesMenuLabel}
|
|
21
|
-
position={props.position}
|
|
22
|
-
width="14rem"
|
|
23
|
-
>
|
|
24
|
-
<Dropdown.Trigger
|
|
25
|
-
class={props.triggerClass}
|
|
26
|
-
iconOnly
|
|
27
|
-
label={preferences.messages().preferencesMenuLabel}
|
|
28
|
-
size="sm"
|
|
29
|
-
tooltip={false}
|
|
30
|
-
variant="secondary"
|
|
31
|
-
>
|
|
15
|
+
<Dropdown.Root items={preferences.items()} label={preferences.messages().preferencesMenuLabel} position={props.position} width="14rem">
|
|
16
|
+
<Dropdown.Trigger iconOnly label={preferences.messages().preferencesMenuLabel} size="sm" variant="secondary">
|
|
32
17
|
<i class="ti ti-adjustments-horizontal" aria-hidden="true" />
|
|
33
18
|
</Dropdown.Trigger>
|
|
34
19
|
</Dropdown.Root>
|
|
@@ -1,46 +1,70 @@
|
|
|
1
1
|
import { LocaleProvider } from "@k2b/ui";
|
|
2
2
|
import type { JSX } from "solid-js/jsx-runtime";
|
|
3
3
|
import { getLocale } from "../server/locale";
|
|
4
|
+
import { resolveAppPresentations } from "../shared/app-presentation";
|
|
4
5
|
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
5
|
-
import LayoutPreferences from "./LayoutPreferences.island";
|
|
6
6
|
import type { MinimalLayoutContext } from "./layout-context";
|
|
7
|
+
import MinimalLayoutPreferences from "./MinimalLayoutPreferences.island";
|
|
8
|
+
import { profilePreferencesMessages } from "./profile-preferences-messages";
|
|
7
9
|
import TimezoneCookie from "./TimezoneCookie.island";
|
|
8
10
|
|
|
11
|
+
/**
|
|
12
|
+
* @deprecated The language and theme settings always sit in the footer; a
|
|
13
|
+
* position is treated like `true`.
|
|
14
|
+
*/
|
|
9
15
|
export type MinimalLayoutPreferencePosition = "top-left" | "top-right" | "bottom-left" | "bottom-right";
|
|
10
16
|
|
|
11
17
|
export type MinimalLayoutProps = {
|
|
12
18
|
children: JSX.Element;
|
|
13
19
|
c: MinimalLayoutContext;
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
if (position === "top-left") return "bottom-right" as const;
|
|
20
|
-
if (position === "top-right") return "bottom-left" as const;
|
|
21
|
-
if (position === "bottom-left") return "top-right" as const;
|
|
22
|
-
return "top-left" as const;
|
|
20
|
+
/**
|
|
21
|
+
* Render the footer with legal links and the language and theme settings
|
|
22
|
+
* (default), or `false` for embeds and fixed presentation surfaces.
|
|
23
|
+
*/
|
|
24
|
+
preferences?: boolean | MinimalLayoutPreferencePosition;
|
|
23
25
|
};
|
|
24
26
|
|
|
25
27
|
export default function MinimalLayout(props: MinimalLayoutProps) {
|
|
26
28
|
const cookie = props.c.req.raw.headers.get("Cookie") ?? "";
|
|
27
29
|
const theme = readThemeFromCookieHeader(cookie);
|
|
28
30
|
const locale = getLocale(props.c);
|
|
29
|
-
const preferences = props.preferences === undefined ? "bottom-right" : props.preferences;
|
|
30
31
|
props.c.get("page").theme = theme;
|
|
31
32
|
|
|
33
|
+
if (props.preferences === false) {
|
|
34
|
+
return (
|
|
35
|
+
<LocaleProvider locale={locale}>
|
|
36
|
+
<TimezoneCookie />
|
|
37
|
+
{props.children}
|
|
38
|
+
</LocaleProvider>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const t = profilePreferencesMessages.resolve([locale]).t;
|
|
43
|
+
// Aggregate every running app's legal links like Layout (last wins on
|
|
44
|
+
// duplicate href). Unlike Layout, MinimalLayout does not require
|
|
45
|
+
// middleware.runtime(); without it the footer has no legal links.
|
|
46
|
+
const apps = resolveAppPresentations(props.c.get("runtime")?.apps ?? [], locale);
|
|
47
|
+
const legalLinks = [...new Map(apps.flatMap((app) => (app.legalLinks ?? []).map((link) => [link.href, link] as const))).values()];
|
|
48
|
+
|
|
32
49
|
return (
|
|
33
50
|
<LocaleProvider locale={locale}>
|
|
34
51
|
<TimezoneCookie />
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
<
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
52
|
+
<div class="minimal-layout">
|
|
53
|
+
{props.children}
|
|
54
|
+
<footer class="minimal-layout-footer">
|
|
55
|
+
{legalLinks.length > 0 && (
|
|
56
|
+
<nav class="minimal-layout-footer__links" aria-label={t.legalLinks}>
|
|
57
|
+
{legalLinks.map((link) => (
|
|
58
|
+
// A new tab keeps a started upload or a filled-in form on this page.
|
|
59
|
+
<a href={link.href} target="_blank" rel="noopener">
|
|
60
|
+
{link.label}
|
|
61
|
+
</a>
|
|
62
|
+
))}
|
|
63
|
+
</nav>
|
|
64
|
+
)}
|
|
65
|
+
<MinimalLayoutPreferences initialTheme={theme} />
|
|
66
|
+
</footer>
|
|
67
|
+
</div>
|
|
44
68
|
</LocaleProvider>
|
|
45
69
|
);
|
|
46
70
|
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { Button, Dropdown, useLocale } from "@k2b/ui";
|
|
2
|
+
import { type ProfilePreferenceLocale, profilePreferenceLocale, setLocalePreference } from "../browser/locale-preference";
|
|
3
|
+
import { canonicalLocale } from "../shared/locale";
|
|
4
|
+
import type { CloudTheme } from "../shared/theme";
|
|
5
|
+
import { createPreferenceController } from "./preference-controller";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Labeled language and theme controls for the MinimalLayout footer. The
|
|
9
|
+
* language trigger names the current language; the theme button names the
|
|
10
|
+
* mode it switches to, like every other Cloud preference menu. Below `md` both
|
|
11
|
+
* are compact, the language code and the theme icon with the full names as
|
|
12
|
+
* accessible labels, so they fit next to the legal links on a phone. CSS picks
|
|
13
|
+
* the form, so the server response is already final.
|
|
14
|
+
*/
|
|
15
|
+
export default function MinimalLayoutPreferences(props: { initialTheme: CloudTheme }) {
|
|
16
|
+
const locale = useLocale();
|
|
17
|
+
const preferences = createPreferenceController(props.initialTheme, locale);
|
|
18
|
+
const t = preferences.messages;
|
|
19
|
+
const language = () => profilePreferenceLocale(locale());
|
|
20
|
+
const languageName = () => (language() === "de" ? t().switchToGerman : t().switchToEnglish);
|
|
21
|
+
// Keep a regional locale such as en-GB when its language is chosen again,
|
|
22
|
+
// but let a visitor on an unsupported locale such as fr-FR, who reads the
|
|
23
|
+
// English fallback, still choose English explicitly.
|
|
24
|
+
const choose = (next: ProfilePreferenceLocale) => {
|
|
25
|
+
if (canonicalLocale(locale())?.split("-")[0] !== next) setLocalePreference(next);
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
return (
|
|
29
|
+
<div class="minimal-layout-footer__preferences">
|
|
30
|
+
<Dropdown.Root
|
|
31
|
+
label={t().language}
|
|
32
|
+
position="top-right"
|
|
33
|
+
width="10rem"
|
|
34
|
+
items={[
|
|
35
|
+
{ label: t().switchToGerman, choice: "radio", checked: () => language() === "de", action: () => choose("de") },
|
|
36
|
+
{ label: t().switchToEnglish, choice: "radio", checked: () => language() === "en", action: () => choose("en") },
|
|
37
|
+
]}
|
|
38
|
+
>
|
|
39
|
+
<Dropdown.Trigger label={`${t().language}: ${languageName()}`} size="sm" tooltip={false} variant="ghost">
|
|
40
|
+
<i class="ti ti-language" aria-hidden="true" />
|
|
41
|
+
<span class="hidden md:inline">{languageName()}</span>
|
|
42
|
+
<span class="md:hidden">{language().toUpperCase()}</span>
|
|
43
|
+
<i class="ti ti-chevron-down hidden md:inline" aria-hidden="true" />
|
|
44
|
+
</Dropdown.Trigger>
|
|
45
|
+
</Dropdown.Root>
|
|
46
|
+
<Button aria-label={preferences.themeLabel()} onClick={preferences.toggleTheme} size="sm" variant="ghost">
|
|
47
|
+
<i class={preferences.theme() === "light" ? "ti ti-moon" : "ti ti-sun-high"} aria-hidden="true" />
|
|
48
|
+
<span class="hidden md:inline">{preferences.themeLabel()}</span>
|
|
49
|
+
</Button>
|
|
50
|
+
</div>
|
|
51
|
+
);
|
|
52
|
+
}
|
package/src/ssr/PageError.tsx
CHANGED
|
@@ -17,7 +17,7 @@ export const renderPageError = (c: Context, status: PageErrorStatus, options: Pa
|
|
|
17
17
|
return () =>
|
|
18
18
|
options.layout === "minimal" ? (
|
|
19
19
|
<MinimalLayout c={c}>
|
|
20
|
-
<main class="
|
|
20
|
+
<main class="flex-1 bg-[var(--ui-canvas)] p-[var(--ui-space-shell)]">
|
|
21
21
|
<NotFoundState code={String(status)} title={title} description={description} action={action} />
|
|
22
22
|
</main>
|
|
23
23
|
</MinimalLayout>
|
|
@@ -6,6 +6,8 @@ export const profilePreferencesMessages = i18n.define({
|
|
|
6
6
|
en: {
|
|
7
7
|
menuLabel: "Profile and preferences",
|
|
8
8
|
preferencesMenuLabel: "Appearance and language",
|
|
9
|
+
language: "Language",
|
|
10
|
+
legalLinks: "Legal",
|
|
9
11
|
profileSettings: "Profile settings",
|
|
10
12
|
signOut: "Sign out",
|
|
11
13
|
signOutFailed: "Sign out failed. Please try again.",
|
|
@@ -17,6 +19,8 @@ export const profilePreferencesMessages = i18n.define({
|
|
|
17
19
|
de: {
|
|
18
20
|
menuLabel: "Profil und Einstellungen",
|
|
19
21
|
preferencesMenuLabel: "Darstellung und Sprache",
|
|
22
|
+
language: "Sprache",
|
|
23
|
+
legalLinks: "Rechtliches",
|
|
20
24
|
profileSettings: "Profileinstellungen",
|
|
21
25
|
signOut: "Abmelden",
|
|
22
26
|
signOutFailed: "Abmelden fehlgeschlagen. Bitte versuche es erneut.",
|
|
@@ -345,8 +345,8 @@
|
|
|
345
345
|
display: none;
|
|
346
346
|
}
|
|
347
347
|
.cloud-resource-search :is(.cloud-resource-search__result, .cloud-resource-search__tag-option, .cloud-resource-search__tag):focus-visible {
|
|
348
|
-
outline:
|
|
349
|
-
outline-offset: -
|
|
348
|
+
outline: var(--k2b-focus-width) solid var(--color-blue-500);
|
|
349
|
+
outline-offset: calc(-1 * var(--k2b-focus-width));
|
|
350
350
|
}
|
|
351
351
|
@media (min-width: 48rem) {
|
|
352
352
|
.cloud-resource-search {
|
|
@@ -219,8 +219,8 @@
|
|
|
219
219
|
|
|
220
220
|
@media (forced-colors: active) {
|
|
221
221
|
.prompt-search-surface:focus-within {
|
|
222
|
-
outline:
|
|
223
|
-
outline-offset: -
|
|
222
|
+
outline: var(--k2b-focus-width) solid CanvasText;
|
|
223
|
+
outline-offset: calc(-1 * var(--k2b-focus-width));
|
|
224
224
|
}
|
|
225
225
|
}
|
|
226
226
|
|
|
@@ -433,8 +433,8 @@
|
|
|
433
433
|
|
|
434
434
|
.md-link-widget:focus-visible {
|
|
435
435
|
border-radius: 0.125rem;
|
|
436
|
-
outline:
|
|
437
|
-
outline-offset:
|
|
436
|
+
outline: var(--k2b-focus-width) solid var(--ui-app-accent-border);
|
|
437
|
+
outline-offset: var(--k2b-focus-offset);
|
|
438
438
|
}
|
|
439
439
|
|
|
440
440
|
.md-link-icon {
|
|
@@ -903,36 +903,110 @@
|
|
|
903
903
|
}
|
|
904
904
|
}
|
|
905
905
|
|
|
906
|
-
/* MinimalLayout
|
|
907
|
-
|
|
908
|
-
.
|
|
909
|
-
|
|
910
|
-
|
|
906
|
+
/* MinimalLayout stacks the page content and its footer in a column at least
|
|
907
|
+
one viewport tall, so the footer ends a short page and follows a long one.
|
|
908
|
+
Content that should fill the remaining height grows with flex-1. */
|
|
909
|
+
.minimal-layout {
|
|
910
|
+
display: flex;
|
|
911
|
+
min-height: 100vh;
|
|
912
|
+
min-height: 100dvh;
|
|
913
|
+
flex-direction: column;
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
.minimal-layout-footer {
|
|
917
|
+
display: flex;
|
|
918
|
+
flex-wrap: wrap;
|
|
919
|
+
align-items: center;
|
|
920
|
+
justify-content: center;
|
|
921
|
+
gap: 0.25rem 1rem;
|
|
922
|
+
margin-top: auto;
|
|
923
|
+
padding: 0.75rem max(1rem, env(safe-area-inset-right)) max(0.75rem, env(safe-area-inset-bottom))
|
|
924
|
+
max(1rem, env(safe-area-inset-left));
|
|
925
|
+
color: var(--k2b-text-muted);
|
|
926
|
+
font-size: 0.75rem;
|
|
927
|
+
line-height: 1rem;
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
.minimal-layout-footer__links,
|
|
931
|
+
.minimal-layout-footer__preferences {
|
|
932
|
+
display: flex;
|
|
933
|
+
flex-wrap: wrap;
|
|
934
|
+
align-items: center;
|
|
935
|
+
justify-content: center;
|
|
911
936
|
}
|
|
912
937
|
|
|
913
|
-
.minimal-layout-
|
|
914
|
-
|
|
915
|
-
left: max(0.75rem, env(safe-area-inset-left));
|
|
938
|
+
.minimal-layout-footer__links {
|
|
939
|
+
column-gap: 1rem;
|
|
916
940
|
}
|
|
917
941
|
|
|
918
|
-
.minimal-layout-
|
|
919
|
-
|
|
920
|
-
right: max(0.75rem, env(safe-area-inset-right));
|
|
942
|
+
.minimal-layout-footer__preferences {
|
|
943
|
+
gap: 0.25rem;
|
|
921
944
|
}
|
|
922
945
|
|
|
923
|
-
.minimal-layout-
|
|
924
|
-
|
|
925
|
-
|
|
946
|
+
.minimal-layout-footer__links a {
|
|
947
|
+
display: inline-flex;
|
|
948
|
+
min-height: 1.75rem;
|
|
949
|
+
align-items: center;
|
|
950
|
+
border-radius: var(--k2b-radius-control);
|
|
951
|
+
color: inherit;
|
|
952
|
+
transition: color 150ms ease-out;
|
|
926
953
|
}
|
|
927
954
|
|
|
928
|
-
.minimal-layout-
|
|
929
|
-
|
|
930
|
-
bottom: max(0.75rem, env(safe-area-inset-bottom));
|
|
955
|
+
.minimal-layout-footer__links a:hover {
|
|
956
|
+
color: var(--k2b-text);
|
|
931
957
|
}
|
|
932
958
|
|
|
933
|
-
.minimal-layout-
|
|
934
|
-
|
|
935
|
-
|
|
959
|
+
.minimal-layout-footer__links a:focus-visible {
|
|
960
|
+
outline: var(--k2b-focus-width) solid var(--k2b-focus-ring);
|
|
961
|
+
outline-offset: var(--k2b-focus-offset);
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
.k2b-ui .minimal-layout-footer .k2b-button[data-size] {
|
|
965
|
+
color: inherit;
|
|
966
|
+
font-size: inherit;
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
.k2b-ui .minimal-layout-footer .k2b-button[data-size]:hover {
|
|
970
|
+
color: var(--k2b-text);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/* Finger-sized footer targets on touch screens. */
|
|
974
|
+
@media (any-pointer: coarse) {
|
|
975
|
+
.minimal-layout-footer__links a,
|
|
976
|
+
.k2b-ui .minimal-layout-footer .k2b-button[data-size] {
|
|
977
|
+
min-height: 2.75rem;
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
@media (prefers-reduced-motion: reduce) {
|
|
982
|
+
.minimal-layout-footer__links a {
|
|
983
|
+
transition: none;
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/* The one centered card of a standalone page (sign-in, a public share, an
|
|
988
|
+
unlock form). Phones have no room for a frame inside the page padding, so
|
|
989
|
+
below Tailwind's `md` breakpoint the card loses border, radius, shadow,
|
|
990
|
+
surface and its own padding, and its content sits on the page background.
|
|
991
|
+
Pure CSS on the same markup: the server response is already in its final
|
|
992
|
+
form. The second selector outranks an elevated @k2b/ui Paper.
|
|
993
|
+
The query is the exact opposite of `md:`, so a page that moves its own
|
|
994
|
+
padding or columns with `md:` switches at the same width as the card, also
|
|
995
|
+
with a larger default font size. `(width < 48rem)` is not: the build lowers
|
|
996
|
+
it to `max-width: calc(48rem - .001px)`, which Chromium still matches at
|
|
997
|
+
48rem. Without a frame there is nothing left to clip, so the card also
|
|
998
|
+
stops clipping: its content then reaches the card edge, and a clipping card
|
|
999
|
+
would cut the outer focus ring of a full-width button or field. */
|
|
1000
|
+
@media not all and (min-width: 48rem) {
|
|
1001
|
+
.standalone-card,
|
|
1002
|
+
.k2b-ui .k2b-paper.standalone-card {
|
|
1003
|
+
padding: 0;
|
|
1004
|
+
border: 0;
|
|
1005
|
+
border-radius: 0;
|
|
1006
|
+
background: transparent;
|
|
1007
|
+
box-shadow: none;
|
|
1008
|
+
overflow: visible;
|
|
1009
|
+
}
|
|
936
1010
|
}
|
|
937
1011
|
|
|
938
1012
|
/* Workspace navigation chrome and resource identity. */
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// The @k2b/ui Plex preset, imported as text (`with { type: "text" }`). The
|
|
2
|
+
// importing module references this file, so npm consumers that type-check
|
|
3
|
+
// Cloud's sources get the declaration without adding it to their tsconfig.
|
|
4
|
+
declare module "@k2b/ui/fonts/plex.css" {
|
|
5
|
+
const content: string;
|
|
6
|
+
export default content;
|
|
7
|
+
}
|