@k2b/cloud 0.33.0 → 0.34.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 +3 -3
- package/src/_internal/app-presentation.ts +40 -11
- package/src/_internal/define-app.ts +1 -0
- package/src/_internal/registry.ts +10 -1
- package/src/_internal/widget-declarations.ts +16 -3
- package/src/_internal/widget-settings.ts +93 -0
- package/src/ai/capabilities.ts +8 -5
- package/src/ai/resource-refs.ts +35 -3
- package/src/ai/system-prompt.ts +3 -2
- package/src/api/index.ts +2 -0
- package/src/api/mcp.ts +48 -17
- package/src/api/pwa.ts +7 -2
- package/src/api/resource-links.ts +43 -0
- package/src/api/widgets.ts +163 -82
- package/src/browser/CloudResourceSearch.tsx +6 -6
- package/src/browser/command-shortcuts.ts +2 -11
- package/src/browser/resource-picker.tsx +1 -0
- package/src/browser/resource-search.ts +7 -2
- package/src/browser/widgets.ts +23 -5
- package/src/capabilities/testing.ts +13 -1
- package/src/cli/capabilities.ts +61 -17
- package/src/cli/index.ts +2 -0
- package/src/cli/resource-links.ts +13 -0
- package/src/contracts/app.ts +7 -1
- package/src/contracts/capabilities.ts +25 -5
- package/src/contracts/registry.ts +3 -1
- package/src/contracts/resource-href.ts +7 -0
- package/src/contracts/widgets.ts +80 -6
- package/src/desktop/index.ts +3 -2
- package/src/desktop/solid.tsx +7 -5
- package/src/server/widget-request.ts +34 -5
- package/src/services/app-approval.ts +4 -2
- package/src/services/approvals/index.ts +40 -11
- package/src/services/approvals/list.ts +1 -0
- package/src/services/approvals/migrate.ts +10 -0
- package/src/services/pdf/markdown.ts +11 -7
- package/src/services/pwa-devices.ts +68 -15
- package/src/shared/app-presentation.ts +49 -1
- package/src/shared/index.ts +1 -0
- package/src/shared/markdown/callout-sanitizer.ts +37 -0
- package/src/shared/markdown/index.ts +8 -2
- package/src/ssr/Layout.tsx +3 -15
- package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
- package/src/ssr/LayoutHelp.tsx +319 -183
- package/src/ssr/LayoutHelpTrigger.tsx +1 -3
- package/src/ssr/LayoutPreferences.tsx +15 -5
- package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
- package/src/ssr/MobileProfileActions.tsx +3 -8
- package/src/ssr/ProfilePreferences.tsx +8 -10
- package/src/ssr/ShellRuntime.island.tsx +1 -5
- package/src/ssr/ThemeCycleButton.tsx +46 -0
- package/src/ssr/app-appearance.ts +6 -1
- package/src/ssr/layout-help-url.ts +39 -0
- package/src/ssr/platform-messages.ts +0 -8
- package/src/ssr/preference-controller.ts +53 -19
- package/src/ssr/profile-actions.ts +12 -16
- package/src/ssr/profile-preferences-messages.ts +4 -2
- package/src/ssr/rail-navigation.ts +3 -0
- package/src/ssr/shell-data.tsx +2 -2
- package/src/styles/effects.css +13 -3
- package/src/styles/global.css +27 -1
- package/src/styles/input.css +0 -8
- package/src/styles/tokens.css +0 -2
- package/src/styles/utilities-navigation.css +10 -13
- package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
package/src/contracts/app.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type { Sync } from "@k2b/sync";
|
|
|
2
2
|
import type { PlatformPermission } from "./outgoing-mail";
|
|
3
3
|
import type { AppRegistryHelpSummary } from "./registry";
|
|
4
4
|
import type { Role } from "./shared";
|
|
5
|
-
import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
|
|
5
|
+
import type { DashboardWidgetPresentation, DashboardWidgetSize, WidgetSettingsDeclaration } from "./widgets";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* One link entry contributed by an app to the global legal/info footer
|
|
@@ -73,9 +73,12 @@ export type AppPresentationTranslation = {
|
|
|
73
73
|
widgets?: Readonly<Record<string, AppWidgetTranslation>>;
|
|
74
74
|
};
|
|
75
75
|
|
|
76
|
+
/** Translated widget presentation, keyed by stable widget, setting, and option identifiers. */
|
|
76
77
|
export type AppWidgetTranslation = {
|
|
77
78
|
title?: string;
|
|
78
79
|
description?: string;
|
|
80
|
+
/** Setting presentation keyed by setting name, with option labels keyed by option value. */
|
|
81
|
+
settings?: Readonly<Record<string, { label?: string; placeholder?: string; options?: Readonly<Record<string, string>> }>>;
|
|
79
82
|
};
|
|
80
83
|
|
|
81
84
|
export type AppPresentationCatalog = {
|
|
@@ -163,6 +166,7 @@ export type AppMeta = {
|
|
|
163
166
|
*/
|
|
164
167
|
export type AppNavBadge = { count: number };
|
|
165
168
|
|
|
169
|
+
/** A widget endpoint and its board presentation; the handler owns resource authorization. */
|
|
166
170
|
export type WidgetEndpoint = {
|
|
167
171
|
/** Unique-within-the-app id, e.g. "open-requests". */
|
|
168
172
|
id: string;
|
|
@@ -179,6 +183,8 @@ export type WidgetEndpoint = {
|
|
|
179
183
|
sizes?: readonly DashboardWidgetSize[];
|
|
180
184
|
/** Size the widget starts in, one of `sizes`. Defaults to the largest offered size. */
|
|
181
185
|
defaultSize?: DashboardWidgetSize;
|
|
186
|
+
/** Optional per-entry settings; handlers must work without them and authorize selected resources. */
|
|
187
|
+
settings?: WidgetSettingsDeclaration;
|
|
182
188
|
/**
|
|
183
189
|
* Put the widget on the default board of everyone who may see it and list it under "Suggested for you" in the
|
|
184
190
|
* gallery. Suggest only widgets that are useful without any setup.
|
|
@@ -15,6 +15,8 @@ export const CAPABILITY_PROTOCOL_VERSION = 2 as const;
|
|
|
15
15
|
export const CAPABILITY_MAX_REQUEST_BYTES = 256 * 1024;
|
|
16
16
|
export const CAPABILITY_MAX_RESULT_BYTES = 256 * 1024;
|
|
17
17
|
export const CAPABILITY_MAX_CATALOG_BYTES = 2 * 1024 * 1024;
|
|
18
|
+
export const CAPABILITY_QUALIFIED_ID_MAX_LENGTH = 180;
|
|
19
|
+
export const CLOUD_RESOURCE_ID_MAX_LENGTH = 512;
|
|
18
20
|
|
|
19
21
|
export const CAPABILITY_FRAMEWORK_ERROR_CODES = {
|
|
20
22
|
appUnavailable: "APP_UNAVAILABLE",
|
|
@@ -75,18 +77,20 @@ export const CapabilityLocalIdSchema = z
|
|
|
75
77
|
export const CapabilityQualifiedIdSchema = z
|
|
76
78
|
.string()
|
|
77
79
|
.min(3)
|
|
78
|
-
.max(
|
|
80
|
+
.max(CAPABILITY_QUALIFIED_ID_MAX_LENGTH)
|
|
79
81
|
.regex(CAPABILITY_QUALIFIED_ID_PATTERN, "Expected a namespaced capability id such as contacts.contact");
|
|
80
82
|
|
|
81
83
|
export const CloudResourceRefSchema = z
|
|
82
84
|
.object({
|
|
83
85
|
type: CapabilityQualifiedIdSchema.describe("Namespaced resource type declared by the owning app."),
|
|
84
|
-
id: z.string().min(1).max(
|
|
86
|
+
id: z.string().min(1).max(CLOUD_RESOURCE_ID_MAX_LENGTH).describe("Stable app-owned resource identifier."),
|
|
85
87
|
})
|
|
86
88
|
.strict();
|
|
87
89
|
|
|
88
90
|
export type CloudResourceRef = z.infer<typeof CloudResourceRefSchema>;
|
|
89
91
|
|
|
92
|
+
export { cloudResourceHref } from "./resource-href";
|
|
93
|
+
|
|
90
94
|
export const CloudResourceReferenceSchema = CloudResourceRefSchema.extend({
|
|
91
95
|
title: z.string().min(1).max(500).optional().describe("Optional current user-facing resource label."),
|
|
92
96
|
preview: z.string().max(2000).optional().describe("Optional short plain-text context for the resource."),
|
|
@@ -95,6 +99,13 @@ export const CloudResourceReferenceSchema = CloudResourceRefSchema.extend({
|
|
|
95
99
|
|
|
96
100
|
export type CloudResourceReference = z.infer<typeof CloudResourceReferenceSchema>;
|
|
97
101
|
|
|
102
|
+
// Consumer permalinks may encode all 512 UTF-16 units of an id (up to 4,608 characters).
|
|
103
|
+
export const CloudResourceHrefSchema = z.string().min(1).max(8192).regex(CLOUD_PATH_PATTERN);
|
|
104
|
+
|
|
105
|
+
export const LinkedCloudResourceReferenceSchema = CloudResourceReferenceSchema.extend({
|
|
106
|
+
href: z.union([CloudResourceHrefSchema, z.url({ protocol: /^https?$/ }).max(8192)]).optional(),
|
|
107
|
+
}).strict();
|
|
108
|
+
|
|
98
109
|
export const CapabilitySemanticLinkSchema = z
|
|
99
110
|
.object({
|
|
100
111
|
rel: z.enum(["open", "edit", "status", "preview", "download"]).describe("Semantic relationship; clients decide how to present it."),
|
|
@@ -219,7 +230,10 @@ export const capabilityResultSchema = <T extends z.ZodType>(
|
|
|
219
230
|
data,
|
|
220
231
|
stream: CapabilityStreamSchema.optional(),
|
|
221
232
|
summary: z.string().trim().min(1).max(500).optional(),
|
|
222
|
-
refs: z
|
|
233
|
+
refs: z
|
|
234
|
+
.array(options?.consumer ? LinkedCloudResourceReferenceSchema : CloudResourceReferenceSchema)
|
|
235
|
+
.max(100)
|
|
236
|
+
.optional(),
|
|
223
237
|
page: CapabilityPageSchema.optional(),
|
|
224
238
|
links: z.array(CapabilitySemanticLinkSchema).max(20).optional(),
|
|
225
239
|
presentation: options?.consumer
|
|
@@ -241,8 +255,14 @@ export const capabilityResultSchema = <T extends z.ZodType>(
|
|
|
241
255
|
}
|
|
242
256
|
}) as unknown as z.ZodType<CapabilityResult<z.output<T>>>;
|
|
243
257
|
|
|
244
|
-
export const capabilityResultJsonSchema = (
|
|
245
|
-
|
|
258
|
+
export const capabilityResultJsonSchema = (
|
|
259
|
+
dataSchema: Record<string, unknown>,
|
|
260
|
+
options?: { consumer?: boolean },
|
|
261
|
+
): Record<string, unknown> =>
|
|
262
|
+
z.toJSONSchema(capabilityResultSchema(z.fromJSONSchema(structuredClone(dataSchema)), options), { io: "output" }) as Record<
|
|
263
|
+
string,
|
|
264
|
+
unknown
|
|
265
|
+
>;
|
|
246
266
|
|
|
247
267
|
export const CapabilityErrorSchema = z
|
|
248
268
|
.object({
|
|
@@ -2,7 +2,7 @@ import type { AppAdminNavigationGroup, AppAppearance, AppAppearanceColor, AppPre
|
|
|
2
2
|
import type { CapabilityManifest, CapabilityPresentationCatalog } from "./capabilities";
|
|
3
3
|
import type { PlatformPermission } from "./outgoing-mail";
|
|
4
4
|
import type { Role } from "./shared";
|
|
5
|
-
import type { DashboardWidgetPresentation, DashboardWidgetSize } from "./widgets";
|
|
5
|
+
import type { DashboardWidgetPresentation, DashboardWidgetSize, WidgetSettingsDeclaration } from "./widgets";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* App-registry entry type. Populated internally by `defineApp()` + the
|
|
@@ -54,6 +54,8 @@ export type AppRegistryLegalLink = {
|
|
|
54
54
|
|
|
55
55
|
/** A widget declaration as the registry carries it; see `WidgetEndpoint`. Older apps send only `id` and `path`. */
|
|
56
56
|
export type AppRegistryWidget = {
|
|
57
|
+
/** Per-entry setting declaration, validated again when listed. */
|
|
58
|
+
settings?: WidgetSettingsDeclaration;
|
|
57
59
|
id: string;
|
|
58
60
|
/** Absolute path on the app's HTTP service, e.g. "/api/quotes/widget/random". */
|
|
59
61
|
path: string;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { CloudResourceRef } from "./capabilities";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The permanent Cloud path of a resource. The fixed `~` keeps the ids `.` and `..` from being read as dot segments.
|
|
5
|
+
* This module stays free of runtime imports: the `cld` host loads it at startup.
|
|
6
|
+
*/
|
|
7
|
+
export const cloudResourceHref = (ref: CloudResourceRef): string => `/open/${ref.type}/~${encodeURIComponent(ref.id)}`;
|
package/src/contracts/widgets.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { CAPABILITY_QUALIFIED_ID_MAX_LENGTH, CLOUD_RESOURCE_ID_MAX_LENGTH, type CloudResourceRef } from "./capabilities";
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Widget JSON contract — what an app's widget endpoint must return when the
|
|
@@ -64,6 +65,59 @@ export type DashboardWidgetSize = (typeof DASHBOARD_WIDGET_SIZES)[number];
|
|
|
64
65
|
export const WIDGET_TITLE_MAX_LENGTH = 80;
|
|
65
66
|
/** Longest declared widget description, in characters: one sentence in the widget gallery. */
|
|
66
67
|
export const WIDGET_DESCRIPTION_MAX_LENGTH = 200;
|
|
68
|
+
/**
|
|
69
|
+
* Core signs `widget.read:<widgetId>`; the invocation token's OperationSchema limits operations to 200 characters
|
|
70
|
+
* matching /^[a-z][a-z0-9:._-]*$/ (services/identity/invocation-token.ts). Longer IDs or other characters cannot be invoked.
|
|
71
|
+
*/
|
|
72
|
+
export const WIDGET_ID_MAX_LENGTH = 188;
|
|
73
|
+
/** `<appId>/<widgetId>`; the invocation token's audience rule limits app IDs to 80 characters. */
|
|
74
|
+
export const WIDGET_KEY_MAX_LENGTH = 80 + 1 + WIDGET_ID_MAX_LENGTH;
|
|
75
|
+
|
|
76
|
+
/** Maximum settings a widget can declare. */
|
|
77
|
+
export const WIDGET_SETTINGS_MAX_FIELDS = 4;
|
|
78
|
+
/** Maximum setting name length, matching /^[a-z][a-zA-Z0-9]{0,31}$/. */
|
|
79
|
+
export const WIDGET_SETTING_NAME_MAX_LENGTH = 32;
|
|
80
|
+
/** Maximum options in a choice setting; at least one is required. */
|
|
81
|
+
export const WIDGET_SETTING_MAX_OPTIONS = 6;
|
|
82
|
+
/** Maximum text setting length, also used when no maxLength is declared. */
|
|
83
|
+
export const WIDGET_SETTING_TEXT_MAX_LENGTH = 200;
|
|
84
|
+
/** Maximum board entries in one widget stream request. */
|
|
85
|
+
export const WIDGET_REQUEST_MAX_ENTRIES = 100;
|
|
86
|
+
/** Maximum board entry identifier length, in characters. */
|
|
87
|
+
export const WIDGET_REQUEST_MAX_ID_LENGTH = 120;
|
|
88
|
+
const jsonCodeUnitMaxBytes = "\\u001f".length;
|
|
89
|
+
const resourceRefMaxBytes =
|
|
90
|
+
'{"type":"","id":""}'.length + CAPABILITY_QUALIFIED_ID_MAX_LENGTH + CLOUD_RESOURCE_ID_MAX_LENGTH * jsonCodeUnitMaxBytes;
|
|
91
|
+
const settingMemberMaxBytes = '"":'.length + WIDGET_SETTING_NAME_MAX_LENGTH + resourceRefMaxBytes;
|
|
92
|
+
const settingsMaxBytes = "{}".length + WIDGET_SETTINGS_MAX_FIELDS * settingMemberMaxBytes + (WIDGET_SETTINGS_MAX_FIELDS - 1) * ",".length;
|
|
93
|
+
const sizeNameMaxBytes = Math.max(...DASHBOARD_WIDGET_SIZES.map((size) => size.length));
|
|
94
|
+
const requestEntryMaxBytes =
|
|
95
|
+
'{"id":"","key":"","size":"","settings":}'.length +
|
|
96
|
+
WIDGET_REQUEST_MAX_ID_LENGTH * jsonCodeUnitMaxBytes +
|
|
97
|
+
WIDGET_KEY_MAX_LENGTH +
|
|
98
|
+
sizeNameMaxBytes +
|
|
99
|
+
settingsMaxBytes;
|
|
100
|
+
/**
|
|
101
|
+
* Largest compact JSON POST body in UTF-8: 100 entries with maximum IDs, keys, size names and four settings.
|
|
102
|
+
* Free-form entry and resource IDs need up to six bytes per UTF-16 code unit (e.g. `\u001f`); keys, size names,
|
|
103
|
+
* setting names and resource types are ASCII. A resource reference is larger than 200 text characters at six bytes
|
|
104
|
+
* each. Include every object's punctuation and the commas between entries and settings.
|
|
105
|
+
*/
|
|
106
|
+
export const WIDGET_REQUEST_MAX_BYTES =
|
|
107
|
+
'{"widgets":['.length + WIDGET_REQUEST_MAX_ENTRIES * requestEntryMaxBytes + (WIDGET_REQUEST_MAX_ENTRIES - 1) * ",".length + "]}".length;
|
|
108
|
+
|
|
109
|
+
/** A widget's editable setting; resource values carry identity, never authorization. */
|
|
110
|
+
export type WidgetSettingField =
|
|
111
|
+
| { kind: "choice"; label: string; options: readonly { label: string; value: string }[]; default?: string }
|
|
112
|
+
| { kind: "resource"; label: string; type: string }
|
|
113
|
+
| { kind: "toggle"; label: string; default?: boolean }
|
|
114
|
+
| { kind: "text"; label: string; placeholder?: string; maxLength?: number };
|
|
115
|
+
/** Settings keyed by short camelCase names, shared by discovery and request validation. */
|
|
116
|
+
export type WidgetSettingsDeclaration = Readonly<Record<string, WidgetSettingField>>;
|
|
117
|
+
/** A validated setting value; a handler must still authorize every resource reference. */
|
|
118
|
+
export type WidgetSettingValue = string | boolean | CloudResourceRef;
|
|
119
|
+
/** Validated widget settings; handlers must also work when values are missing. */
|
|
120
|
+
export type WidgetSettings = Readonly<Record<string, WidgetSettingValue>>;
|
|
67
121
|
|
|
68
122
|
export const isDashboardWidgetSize = (value: unknown): value is DashboardWidgetSize =>
|
|
69
123
|
DASHBOARD_WIDGET_SIZES.some((size) => size === value);
|
|
@@ -89,10 +143,15 @@ export const fitWidgetSize = (
|
|
|
89
143
|
widget: { sizes: readonly DashboardWidgetSize[]; defaultSize: DashboardWidgetSize },
|
|
90
144
|
): DashboardWidgetSize => (isDashboardWidgetSize(requested) && widget.sizes.includes(requested) ? requested : widget.defaultSize);
|
|
91
145
|
|
|
92
|
-
/**
|
|
146
|
+
/**
|
|
147
|
+
* What the dashboard asks a widget handler for, read with `getWidgetRequest(c)`. Settings are validated against the
|
|
148
|
+
* declaration; handlers must still authorize every chosen resource and work without settings.
|
|
149
|
+
*/
|
|
93
150
|
export type WidgetRequest = {
|
|
94
151
|
/** The size the widget is shown in, always one the widget declares. */
|
|
95
152
|
size: DashboardWidgetSize;
|
|
153
|
+
/** Declared, validated values with choice and toggle defaults filled. */
|
|
154
|
+
settings: WidgetSettings;
|
|
96
155
|
};
|
|
97
156
|
|
|
98
157
|
export const WidgetAccentSchema = z
|
|
@@ -237,12 +296,27 @@ export const widgetKey = (appId: string, widgetId: string): string => `${appId}/
|
|
|
237
296
|
export type WidgetStreamStatus = "ok" | "empty" | "forbidden" | "timeout" | "error";
|
|
238
297
|
|
|
239
298
|
/**
|
|
240
|
-
* One line of `GET /api/widgets/v1` (`application/x-ndjson`)
|
|
241
|
-
* order
|
|
242
|
-
* in `start` gets exactly one line.
|
|
299
|
+
* One line of `GET` or `POST /api/widgets/v1` (`application/x-ndjson`). GET names widget keys in registry order; POST
|
|
300
|
+
* names entry ids in request order. One `widget` line follows per entry in the order they finish; `done` closes the
|
|
301
|
+
* stream. Every name in `start` gets exactly one line. Only POST widget lines include a board entry id.
|
|
243
302
|
*/
|
|
244
303
|
export type WidgetStreamLine =
|
|
245
304
|
| { type: "start"; widgets: string[] }
|
|
246
|
-
| {
|
|
247
|
-
|
|
305
|
+
| {
|
|
306
|
+
type: "widget";
|
|
307
|
+
/** Board entry id, set only for POST entries. */
|
|
308
|
+
id?: string;
|
|
309
|
+
key: string;
|
|
310
|
+
status: "ok";
|
|
311
|
+
widget: WidgetResponse;
|
|
312
|
+
ms: number;
|
|
313
|
+
}
|
|
314
|
+
| {
|
|
315
|
+
type: "widget";
|
|
316
|
+
/** Board entry id, set only for POST entries. */
|
|
317
|
+
id?: string;
|
|
318
|
+
key: string;
|
|
319
|
+
status: Exclude<WidgetStreamStatus, "ok">;
|
|
320
|
+
ms: number;
|
|
321
|
+
}
|
|
248
322
|
| { type: "done"; status: "complete" | "partial" };
|
package/src/desktop/index.ts
CHANGED
|
@@ -304,8 +304,9 @@ let sqlInstance: DesktopSql | null = null;
|
|
|
304
304
|
|
|
305
305
|
const getSql = (): DesktopSql => {
|
|
306
306
|
if (sqlInstance) return sqlInstance;
|
|
307
|
-
|
|
308
|
-
|
|
307
|
+
// Checked before `runtimeRequire`: its `Function` probe would report a `script-src` violation under a CSP without 'unsafe-eval'.
|
|
308
|
+
const req = typeof window === "undefined" ? runtimeRequire() : null;
|
|
309
|
+
if (!req) throw new Error("desktop.sql is only available in the Bun desktop process.");
|
|
309
310
|
const requireFn = req as (id: string) => unknown;
|
|
310
311
|
|
|
311
312
|
const { Database } = requireFn("bun:sqlite") as { Database: new (path: string) => any };
|
package/src/desktop/solid.tsx
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { suppressTextSelection } from "@k2b/ui";
|
|
2
|
-
import {
|
|
2
|
+
import { fromJSON, toJSON } from "seroval";
|
|
3
3
|
import type { Component, JSX, ParentProps } from "solid-js";
|
|
4
4
|
import { children, createContext, createMemo, createSignal, For, onCleanup, onMount, Show, useContext } from "solid-js";
|
|
5
5
|
import { Dynamic, Portal } from "solid-js/web";
|
|
@@ -48,7 +48,7 @@ export const defineDesktopWindows = <Definitions extends Record<string, DesktopW
|
|
|
48
48
|
({
|
|
49
49
|
kind: desktopWindowDescriptorKind,
|
|
50
50
|
name,
|
|
51
|
-
props:
|
|
51
|
+
props: JSON.stringify(toJSON(props ?? {})),
|
|
52
52
|
}) satisfies DesktopWindowDescriptor as unknown as JSX.Element;
|
|
53
53
|
}
|
|
54
54
|
|
|
@@ -69,12 +69,14 @@ export function DesktopWindowHost<Definitions extends Record<string, DesktopWind
|
|
|
69
69
|
const descriptor = createMemo(() => nativeDescriptor());
|
|
70
70
|
const component = createMemo(() => {
|
|
71
71
|
const current = descriptor();
|
|
72
|
-
|
|
72
|
+
if (!current) return null;
|
|
73
|
+
// The name comes from the URL: only a registered window resolves, never an inherited member such as `constructor`.
|
|
74
|
+
const definitions = props.windows.__desktopWindowDefinitions;
|
|
75
|
+
return Object.hasOwn(definitions, current.name) ? definitions[current.name] : null;
|
|
73
76
|
});
|
|
74
77
|
const windowProps = createMemo(() => {
|
|
75
78
|
const current = descriptor();
|
|
76
|
-
|
|
77
|
-
return deserializeProps<Record<string, unknown>>(current.props);
|
|
79
|
+
return current && component() ? fromJSON<Record<string, unknown>>(JSON.parse(current.props)) : {};
|
|
78
80
|
});
|
|
79
81
|
|
|
80
82
|
return createMemo(() => {
|
|
@@ -1,8 +1,24 @@
|
|
|
1
1
|
import type { Context } from "hono";
|
|
2
|
-
import {
|
|
2
|
+
import { parseWidgetSettings } from "../_internal/widget-settings";
|
|
3
|
+
import {
|
|
4
|
+
type DashboardWidgetSize,
|
|
5
|
+
fitWidgetSize,
|
|
6
|
+
resolveWidgetSizes,
|
|
7
|
+
type WidgetRequest,
|
|
8
|
+
type WidgetSettingsDeclaration,
|
|
9
|
+
} from "../contracts/widgets";
|
|
3
10
|
|
|
4
11
|
/** The query parameter Core uses to ask a widget handler for one size. */
|
|
5
12
|
export const WIDGET_SIZE_PARAM = "size";
|
|
13
|
+
/** The query parameter Core uses to send one entry's validated settings. */
|
|
14
|
+
export const WIDGET_SETTINGS_PARAM = "settings";
|
|
15
|
+
/**
|
|
16
|
+
* Bun's default maximum request head is 16 KiB (a 17,000-character query was verified to return 431). The invocation
|
|
17
|
+
* JWT uses up to 4 KiB (IDENTITY_MAX_COMPACT_TOKEN_BYTES), and the path and other forwarded headers use about 1–2 KiB,
|
|
18
|
+
* leaving headroom for 8 KiB of URL-encoded settings. Core measures that encoding and never forwards more. A decoded
|
|
19
|
+
* value is never longer than its encoding, so the app never drops settings Core sends; longer input counts as missing.
|
|
20
|
+
*/
|
|
21
|
+
export const WIDGET_SETTINGS_PARAM_MAX_LENGTH = 8 * 1024;
|
|
6
22
|
|
|
7
23
|
const requests = new WeakMap<Request, WidgetRequest>();
|
|
8
24
|
|
|
@@ -12,9 +28,21 @@ const requests = new WeakMap<Request, WidgetRequest>();
|
|
|
12
28
|
*/
|
|
13
29
|
export const setWidgetRequest = (
|
|
14
30
|
c: Context,
|
|
15
|
-
declaration: { sizes?: readonly DashboardWidgetSize[]; defaultSize?: DashboardWidgetSize },
|
|
31
|
+
declaration: { sizes?: readonly DashboardWidgetSize[]; defaultSize?: DashboardWidgetSize; settings?: WidgetSettingsDeclaration },
|
|
16
32
|
): WidgetRequest => {
|
|
17
|
-
const
|
|
33
|
+
const param = c.req.query(WIDGET_SETTINGS_PARAM);
|
|
34
|
+
let raw: unknown;
|
|
35
|
+
if (param !== undefined && param.length <= WIDGET_SETTINGS_PARAM_MAX_LENGTH) {
|
|
36
|
+
try {
|
|
37
|
+
raw = JSON.parse(param);
|
|
38
|
+
} catch {
|
|
39
|
+
/* Malformed settings are treated as missing values. */
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const request = {
|
|
43
|
+
size: fitWidgetSize(c.req.query(WIDGET_SIZE_PARAM), resolveWidgetSizes(declaration)),
|
|
44
|
+
settings: parseWidgetSettings(declaration.settings, raw),
|
|
45
|
+
};
|
|
18
46
|
requests.set(c.req.raw, request);
|
|
19
47
|
return request;
|
|
20
48
|
};
|
|
@@ -22,6 +50,7 @@ export const setWidgetRequest = (
|
|
|
22
50
|
/**
|
|
23
51
|
* What the dashboard asks this widget handler for. `size` is always one of the sizes the widget declares; return the
|
|
24
52
|
* content that fits it, for example only the one number in `small`. Outside a dashboard invocation, such as a request
|
|
25
|
-
* to the handler's own public route, the request is `large
|
|
53
|
+
* to the handler's own public route, the request is `{ size: "large", settings: {} }`. Settings are validated against
|
|
54
|
+
* the declaration. The handler must still authorize every chosen resource on every call and work without settings.
|
|
26
55
|
*/
|
|
27
|
-
export const getWidgetRequest = (c: Context): WidgetRequest => requests.get(c.req.raw) ?? { size: "large" };
|
|
56
|
+
export const getWidgetRequest = (c: Context): WidgetRequest => requests.get(c.req.raw) ?? { size: "large", settings: {} };
|
|
@@ -296,7 +296,8 @@ export const createAppApprovalService = (
|
|
|
296
296
|
UPDATE auth.app_devices d SET action_wake_at=now(), action_wake_ref=${approvalId}::uuid
|
|
297
297
|
WHERE d.id IN (
|
|
298
298
|
SELECT device.id FROM auth.app_devices device JOIN approvals.requests r ON r.approver_user_id=device.user_id
|
|
299
|
-
|
|
299
|
+
JOIN auth.users u ON u.id=r.approver_user_id
|
|
300
|
+
WHERE r.id=${approvalId}::uuid AND r.status='pending' AND r.expires_at > now() AND r.approver_auth_epoch=u.auth_epoch
|
|
300
301
|
AND EXISTS (SELECT 1 FROM ai.pending_actions p JOIN ai.turns t ON t.id=p.turn_id
|
|
301
302
|
WHERE p.approval_id=r.id AND p.status='pending' AND t.run_config->>'signedInSession' IS NULL)
|
|
302
303
|
AND device.issuer=${cfg.issuer} AND device.revoked_at IS NULL AND device.push_token IS NOT NULL
|
|
@@ -661,7 +662,8 @@ export const createAppApprovalService = (
|
|
|
661
662
|
};
|
|
662
663
|
const result = (state: string) => ({ state, pollAfterSeconds: limits.pollSeconds });
|
|
663
664
|
if (!hold) return result((await read()).state);
|
|
664
|
-
// Listen before the first read so a decision committed in between
|
|
665
|
+
// Listen before the first read so a decision committed in between usually wakes this request.
|
|
666
|
+
// live() has no subscription-ready barrier, so the hint is best effort; the deadline re-read is the guarantee.
|
|
665
667
|
const waiting = new AbortController();
|
|
666
668
|
const ended = new Promise((resolve) => waiting.signal.addEventListener("abort", resolve, { once: true }));
|
|
667
669
|
const stop = () => waiting.abort();
|
|
@@ -72,6 +72,8 @@ export type ApprovalRequest = {
|
|
|
72
72
|
reviewDigest: string;
|
|
73
73
|
status: ApprovalStatus;
|
|
74
74
|
decidedVia: ApprovalDecisionChannel | null;
|
|
75
|
+
decidedDeviceId: string | null;
|
|
76
|
+
approverAuthEpoch: string | null;
|
|
75
77
|
decidedAt: Date | null;
|
|
76
78
|
consumedAt: Date | null;
|
|
77
79
|
createdAt: Date;
|
|
@@ -83,11 +85,23 @@ const columns = () => sql`id, app_id AS "appId", action, approval_scope AS "appr
|
|
|
83
85
|
requester_kind AS "requesterKind", requester_ref AS "requesterRef", requester_app_id AS "requesterAppId",
|
|
84
86
|
requester_title AS "requesterTitle", subject_id AS "subjectId", approver_user_id AS "approverUserId",
|
|
85
87
|
assurance, sentence, details, args_digest AS "argsDigest", review_digest AS "reviewDigest", status,
|
|
88
|
+
decided_device_id AS "decidedDeviceId", approver_auth_epoch::text AS "approverAuthEpoch",
|
|
86
89
|
decided_via AS "decidedVia", decided_at AS "decidedAt", consumed_at AS "consumedAt",
|
|
87
90
|
created_at AS "createdAt", expires_at AS "expiresAt"`;
|
|
88
91
|
|
|
89
92
|
const transaction = <T>(db: SQL | undefined, run: (tx: SQL) => Promise<T>): Promise<T> => (db && db !== sql ? run(db) : sql.begin(run));
|
|
90
93
|
|
|
94
|
+
const lockDecider = async (tx: SQL, userId: string, deviceId: string | null) => {
|
|
95
|
+
// User before device before request: sign-out everywhere updates the user first, then devices.
|
|
96
|
+
const [user] = await tx<{ epoch: string }[]>`SELECT auth_epoch::text AS epoch FROM auth.users WHERE id=${userId}::uuid FOR SHARE`;
|
|
97
|
+
const [device] =
|
|
98
|
+
deviceId === null
|
|
99
|
+
? []
|
|
100
|
+
: await tx`SELECT 1 FROM auth.app_devices
|
|
101
|
+
WHERE id=${deviceId}::uuid AND user_id=${userId}::uuid AND revoked_at IS NULL FOR SHARE`;
|
|
102
|
+
return { epoch: user?.epoch ?? null, deviceActive: !!device };
|
|
103
|
+
};
|
|
104
|
+
|
|
91
105
|
export type ApprovalRequestResult = { ok: true; id: string } | { ok: false; code: "CONFLICT" };
|
|
92
106
|
/**
|
|
93
107
|
* Records the question once per requester reference. Asking again, for example after a worker restart, returns the
|
|
@@ -103,10 +117,10 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
|
|
|
103
117
|
return transaction(db, async (tx): Promise<ApprovalRequestResult> => {
|
|
104
118
|
const [row] = await tx<{ id: string }[]>`
|
|
105
119
|
INSERT INTO approvals.requests (app_id, action, approval_scope, requester_kind, requester_ref, requester_app_id,
|
|
106
|
-
requester_title, subject_id, approver_user_id, assurance, sentence, details, args_digest, review_digest, expires_at)
|
|
120
|
+
requester_title, subject_id, approver_user_id, approver_auth_epoch, assurance, sentence, details, args_digest, review_digest, expires_at)
|
|
107
121
|
VALUES (${input.appId}, ${input.action}, ${input.approvalScope ?? null}, ${input.requester.kind}, ${input.requester.ref},
|
|
108
122
|
${input.requester.appId}, ${input.requester.title ?? null}, ${input.subjectId}::uuid, ${input.approverUserId}::uuid,
|
|
109
|
-
${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
|
|
123
|
+
(SELECT auth_epoch FROM auth.users WHERE id=${input.approverUserId}::uuid), ${input.assurance}, ${input.sentence}, ${JSON.stringify(input.review ?? null)}::text::jsonb,
|
|
110
124
|
${approvalArgsDigest(input.input)}, ${approvalReviewDigest(input.review)}, now() + (${expiresInMs} * interval '1 millisecond'))
|
|
111
125
|
ON CONFLICT (requester_kind, requester_ref) DO NOTHING
|
|
112
126
|
RETURNING id
|
|
@@ -115,6 +129,7 @@ export const request = async (input: ApprovalRequestInput, db?: SQL): Promise<Ap
|
|
|
115
129
|
const [open] = await tx<{ id: string }[]>`SELECT id FROM approvals.requests
|
|
116
130
|
WHERE requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}
|
|
117
131
|
AND status='pending' AND expires_at > now() AND subject_id=${input.subjectId}::uuid
|
|
132
|
+
AND approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${input.approverUserId}::uuid)
|
|
118
133
|
AND approver_user_id=${input.approverUserId}::uuid AND app_id=${input.appId} AND action=${input.action}`;
|
|
119
134
|
return open ? { ok: true, id: open.id } : { ok: false, code: "CONFLICT" };
|
|
120
135
|
});
|
|
@@ -137,12 +152,9 @@ export const decide = async (
|
|
|
137
152
|
db?: SQL,
|
|
138
153
|
): Promise<ApprovalDecisionResult> => {
|
|
139
154
|
const result = await transaction(db, async (tx): Promise<ApprovalDecisionResult> => {
|
|
140
|
-
// Lock the device before the request so revocation and
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
WHERE id=${actor.deviceId}::uuid AND user_id=${actor.userId}::uuid AND revoked_at IS NULL FOR SHARE`;
|
|
144
|
-
if (!device) return { ok: false, code: "NOT_FOUND" };
|
|
145
|
-
}
|
|
155
|
+
// Lock the approver and device before the request so revocation and decisions serialize.
|
|
156
|
+
const decider = await lockDecider(tx, actor.userId, actor.deviceId ?? null);
|
|
157
|
+
if (actor.deviceId !== undefined && !decider.deviceActive) return { ok: false, code: "NOT_FOUND" };
|
|
146
158
|
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
|
|
147
159
|
WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid FOR UPDATE`;
|
|
148
160
|
if (!row) return { ok: false, code: "NOT_FOUND" };
|
|
@@ -151,8 +163,9 @@ export const decide = async (
|
|
|
151
163
|
const [won] = rejectOnly
|
|
152
164
|
? []
|
|
153
165
|
: await tx<ApprovalRequest[]>`
|
|
154
|
-
UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_at=now()
|
|
166
|
+
UPDATE approvals.requests SET status=${status}, decided_via=${actor.via}, decided_device_id=${actor.deviceId ?? null}::uuid, decided_at=now()
|
|
155
167
|
WHERE id=${id}::uuid AND approver_user_id=${actor.userId}::uuid AND status='pending' AND expires_at > now()
|
|
168
|
+
AND approver_auth_epoch=${decider.epoch}::bigint
|
|
156
169
|
RETURNING ${columns()}
|
|
157
170
|
`;
|
|
158
171
|
if (won) {
|
|
@@ -178,7 +191,8 @@ export const decide = async (
|
|
|
178
191
|
}
|
|
179
192
|
if (row.status === "pending") {
|
|
180
193
|
const [expired] = await tx<{ id: string }[]>`UPDATE approvals.requests SET status='expired'
|
|
181
|
-
WHERE id=${id}::uuid AND status='pending'
|
|
194
|
+
WHERE id=${id}::uuid AND status='pending'
|
|
195
|
+
AND (expires_at <= now() OR approver_auth_epoch IS DISTINCT FROM ${decider.epoch}::bigint) RETURNING id`;
|
|
182
196
|
return { ok: false, code: expired || !rejectOnly ? "EXPIRED" : "REJECT_ONLY" };
|
|
183
197
|
}
|
|
184
198
|
if (
|
|
@@ -194,7 +208,7 @@ export const decide = async (
|
|
|
194
208
|
|
|
195
209
|
export type ApprovalConsumeResult =
|
|
196
210
|
| { ok: true }
|
|
197
|
-
| { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" };
|
|
211
|
+
| { ok: false; code: "NOT_FOUND" | "NOT_APPROVED" | "ALREADY_CONSUMED" | "INPUT_CHANGED" | "REVIEW_CHANGED" | "REVOKED" };
|
|
198
212
|
export const consume = async (
|
|
199
213
|
id: string,
|
|
200
214
|
input: { input: unknown; review: CapabilityActionReview | null; requester: { kind: ApprovalRequesterKind; ref: string } },
|
|
@@ -203,11 +217,26 @@ export const consume = async (
|
|
|
203
217
|
const argsDigest = approvalArgsDigest(input.input),
|
|
204
218
|
reviewDigest = approvalReviewDigest(input.review);
|
|
205
219
|
return transaction(db, async (tx): Promise<ApprovalConsumeResult> => {
|
|
220
|
+
const [initial] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
|
|
221
|
+
WHERE id=${id}::uuid AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref}`;
|
|
222
|
+
if (!initial) return { ok: false, code: "NOT_FOUND" };
|
|
223
|
+
if (initial.status === "consumed") return { ok: false, code: "ALREADY_CONSUMED" };
|
|
224
|
+
if (initial.status !== "approved") return { ok: false, code: "NOT_APPROVED" };
|
|
225
|
+
const decider = await lockDecider(tx, initial.approverUserId, initial.decidedDeviceId);
|
|
206
226
|
const [row] = await tx<ApprovalRequest[]>`SELECT ${columns()} FROM approvals.requests
|
|
207
227
|
WHERE id=${id}::uuid AND requester_kind=${input.requester.kind} AND requester_ref=${input.requester.ref} FOR UPDATE`;
|
|
208
228
|
if (!row) return { ok: false, code: "NOT_FOUND" };
|
|
209
229
|
if (row.status === "consumed") return { ok: false, code: "ALREADY_CONSUMED" };
|
|
210
230
|
if (row.status !== "approved") return { ok: false, code: "NOT_APPROVED" };
|
|
231
|
+
if (
|
|
232
|
+
(row.decidedVia === "cloud_login" && row.decidedDeviceId === null) ||
|
|
233
|
+
(row.decidedDeviceId !== null && !decider.deviceActive) ||
|
|
234
|
+
row.approverAuthEpoch === null ||
|
|
235
|
+
row.approverAuthEpoch !== decider.epoch
|
|
236
|
+
) {
|
|
237
|
+
await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=${id}::uuid AND status='approved'`;
|
|
238
|
+
return { ok: false, code: "REVOKED" };
|
|
239
|
+
}
|
|
211
240
|
if (row.argsDigest !== argsDigest || row.reviewDigest !== reviewDigest) {
|
|
212
241
|
await tx`UPDATE approvals.requests SET status='cancelled' WHERE id=${id}::uuid AND status='approved'`;
|
|
213
242
|
return { ok: false, code: row.argsDigest !== argsDigest ? "INPUT_CHANGED" : "REVIEW_CHANGED" };
|
|
@@ -33,6 +33,7 @@ export type PendingApprovalRow = {
|
|
|
33
33
|
// Start with this person's indexed pending rows; a Studio page alone cannot continue a chat.
|
|
34
34
|
const openRequests = (approverUserId: string, id?: string) => sql`SELECT r.* FROM approvals.requests r
|
|
35
35
|
WHERE r.approver_user_id=${approverUserId}::uuid AND r.status='pending' AND r.expires_at > now()
|
|
36
|
+
AND r.approver_auth_epoch=(SELECT auth_epoch FROM auth.users WHERE id=${approverUserId}::uuid)
|
|
36
37
|
AND (${id ?? null}::uuid IS NULL OR r.id=${id ?? null}::uuid)
|
|
37
38
|
AND EXISTS (SELECT 1 FROM ai.pending_actions p WHERE p.approval_id=r.id AND p.status='pending')`;
|
|
38
39
|
const matches = (appId?: string, link?: string) => sql`
|
|
@@ -38,4 +38,14 @@ export const migrateApprovals = async (): Promise<void> => {
|
|
|
38
38
|
END $$`.simple();
|
|
39
39
|
await sql`CREATE INDEX IF NOT EXISTS approvals_requests_pending ON approvals.requests(approver_user_id, created_at) WHERE status='pending'`.simple();
|
|
40
40
|
await sql`CREATE INDEX IF NOT EXISTS approvals_requests_expiry ON approvals.requests(expires_at)`.simple();
|
|
41
|
+
await sql`ALTER TABLE approvals.requests ADD COLUMN IF NOT EXISTS decided_device_id UUID`.simple();
|
|
42
|
+
// Backfill pending rows once at column creation; approved rows and older-release rows keep NULL and count as ended.
|
|
43
|
+
await sql`DO $$ BEGIN
|
|
44
|
+
IF NOT EXISTS (SELECT 1 FROM information_schema.columns
|
|
45
|
+
WHERE table_schema='approvals' AND table_name='requests' AND column_name='approver_auth_epoch') THEN
|
|
46
|
+
ALTER TABLE approvals.requests ADD COLUMN approver_auth_epoch BIGINT;
|
|
47
|
+
UPDATE approvals.requests r SET approver_auth_epoch=u.auth_epoch FROM auth.users u
|
|
48
|
+
WHERE u.id=r.approver_user_id AND r.status='pending';
|
|
49
|
+
END IF;
|
|
50
|
+
END $$`.simple();
|
|
41
51
|
};
|
|
@@ -127,19 +127,23 @@ hr { border: 0; border-top: 1px solid #d1d5db; margin: .9em 0; }
|
|
|
127
127
|
};
|
|
128
128
|
|
|
129
129
|
/**
|
|
130
|
-
* Markdown
|
|
131
|
-
*
|
|
130
|
+
* Markdown callouts print with a light tint and no border. The inline icon
|
|
131
|
+
* and title use print-safe tone ink; the body keeps the preset's text colour.
|
|
132
132
|
* The tints are a step stronger than on screen because paper and printers wash
|
|
133
133
|
* them out. Every preset and application document shares them.
|
|
134
134
|
*/
|
|
135
135
|
const INFO_BLOCK_PRINT_CSS = `
|
|
136
136
|
.k2b-sr-only { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
|
137
137
|
.k2b-notice-card { margin: 0 0 1em; padding: .85em 1.05em; border-radius: .8em; background: var(--notice-tint); break-inside: avoid; print-color-adjust: exact; -webkit-print-color-adjust: exact; }
|
|
138
|
-
.k2b-notice-card[data-tone="neutral"] { --notice-tint: #f1f2f4; }
|
|
139
|
-
.k2b-notice-card[data-tone="info"] { --notice-tint: #e9f1fe; }
|
|
140
|
-
.k2b-notice-card[data-tone="success"] { --notice-tint: #e6f6ee; }
|
|
141
|
-
.k2b-notice-card[data-tone="warning"] { --notice-tint: #fcf2da; }
|
|
142
|
-
.k2b-notice-card[data-tone="danger"] { --notice-tint: #fcebeb; }
|
|
138
|
+
.k2b-notice-card[data-tone="neutral"] { --notice-tint: #f1f2f4; --notice-ink: #4b5563; }
|
|
139
|
+
.k2b-notice-card[data-tone="info"] { --notice-tint: #e9f1fe; --notice-ink: #1d4ed8; }
|
|
140
|
+
.k2b-notice-card[data-tone="success"] { --notice-tint: #e6f6ee; --notice-ink: #15803d; }
|
|
141
|
+
.k2b-notice-card[data-tone="warning"] { --notice-tint: #fcf2da; --notice-ink: #92400e; }
|
|
142
|
+
.k2b-notice-card[data-tone="danger"] { --notice-tint: #fcebeb; --notice-ink: #b91c1c; }
|
|
143
|
+
.k2b-callout { display: grid; grid-template-columns: 1.25em minmax(0, 1fr); column-gap: .7em; }
|
|
144
|
+
.k2b-callout > * { grid-column: 2; }
|
|
145
|
+
.k2b-callout > .k2b-callout__icon { grid-column: 1; grid-row: 1 / span 2; width: 1.25em; height: 1lh; color: var(--notice-ink); }
|
|
146
|
+
.k2b-callout > .k2b-notice-card__title { color: var(--notice-ink); }
|
|
143
147
|
.k2b-notice-card__title { margin: 0 0 .25em; font-weight: 600; }
|
|
144
148
|
.k2b-notice-card__body > :first-child { margin-top: 0; }
|
|
145
149
|
.k2b-notice-card__body > :last-child { margin-bottom: 0; }
|