@cosmicdrift/kumiko-samples 0.377.0 → 0.378.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +1 -1
  3. package/packages/bundled-features/src/cap-counter/changes.json +12 -0
  4. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
  5. package/packages/bundled-features/src/config/web/i18n.ts +1 -0
  6. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +6 -0
  7. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
  8. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  9. package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
  10. package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
  11. package/packages/bundled-features/src/template-resolver/changes.json +20 -0
  12. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
  13. package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
  14. package/packages/bundled-features/src/template-resolver/document-blocks.ts +3 -1
  15. package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
  16. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +35 -0
  17. package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
  18. package/packages/bundled-features/src/template-resolver/feature.ts +77 -36
  19. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
  20. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
  21. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
  22. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
  23. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
  24. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
  25. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
  26. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
  27. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +105 -96
  28. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
  29. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
  30. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
  31. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
  32. package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
  33. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
  34. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -111
  35. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
  36. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
  37. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
  38. package/packages/bundled-features/src/template-resolver/index.ts +8 -1
  39. package/packages/bundled-features/src/template-resolver/qualified-names.ts +2 -0
  40. package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
  41. package/packages/bundled-features/src/template-resolver/template-preview.ts +8 -2
  42. package/packages/bundled-features/src/template-resolver/template-validation.ts +31 -10
  43. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
  44. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +42 -4
  45. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +2 -0
  46. package/packages/bundled-features/src/template-resolver/web/i18n.ts +6 -0
  47. package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
  48. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
  49. package/samples/apps/ui-walkthrough/src/app/shell.tsx +6 -0
  50. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
  51. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
  52. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
  53. package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
  54. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
  55. package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
  56. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
  57. package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +2 -0
  58. package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
  59. package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
  60. package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
  61. package/samples/apps/use-all-bundled/feature-manifest.json +2 -2
  62. package/samples/recipes/delivery-notifications/README.md +1 -1
  63. package/samples/recipes/embedded/README.md +8 -0
  64. package/samples/recipes/embedded/src/feature.ts +8 -0
  65. package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
  66. package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
@@ -1,5 +1,10 @@
1
1
  import { defineWriteHandler, type TenantId } from "@cosmicdrift/kumiko-framework/engine";
2
2
  import * as z from "zod";
3
+ import {
4
+ refuseTemplateEdit,
5
+ type TemplateEditPolicy,
6
+ templateEditAccess,
7
+ } from "../template-edit-policy.ts";
3
8
  import { resolveTemplateVariables } from "../template-validation.ts";
4
9
  import { upsertPayloadSchema } from "./shared.ts";
5
10
  import { writeTemplateRow } from "./template-write.ts";
@@ -9,32 +14,36 @@ import { writeTemplateRow } from "./template-write.ts";
9
14
  // via publish-Handler. SystemAdmin kann via tenantIdOverride für
10
15
  // einen anderen Tenant schreiben (typisch: Plattform-Admin-UI das
11
16
  // Tenant-Templates kuratiert).
12
- export const upsertTenantWrite = defineWriteHandler({
13
- name: "upsert-tenant",
14
- schema: upsertPayloadSchema.extend({
15
- tenantIdOverride: z.string().min(1).optional(),
16
- status: z.enum(["draft", "active"]).default("draft"),
17
- }),
18
- access: { roles: ["TenantAdmin", "SystemAdmin"] },
19
- description:
20
- "Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live (over an active override the draft is stored beside it, the active version keeps being served until publish); it refuses a system-tenant target, which is what upsert-system is for.",
21
- handler: async (event, ctx) => {
22
- // @cast-boundary engine-payload — override aus Zod-parsed string,
23
- // event.user.tenantId schon TenantId-branded; union als TenantId casten
24
- // ist legit (override ist UUID-Format-validiert in schema).
25
- const tenantId = (event.payload.tenantIdOverride ?? event.user.tenantId) as TenantId;
26
- return writeTemplateRow({
27
- ctx,
28
- user: event.user,
29
- tenantId,
30
- scope: "tenant",
31
- status: event.payload.status,
32
- variables: resolveTemplateVariables(
33
- ctx.registry,
34
- event.payload.slug,
35
- event.payload.variableSchema,
36
- ),
37
- payload: event.payload,
38
- });
39
- },
40
- });
17
+ export function makeUpsertTenantWrite(policy: TemplateEditPolicy) {
18
+ return defineWriteHandler({
19
+ name: "upsert-tenant",
20
+ schema: upsertPayloadSchema.extend({
21
+ tenantIdOverride: z.string().min(1).optional(),
22
+ status: z.enum(["draft", "active"]).default("draft"),
23
+ }),
24
+ access: templateEditAccess(policy.tenantEditable),
25
+ description:
26
+ "Creates or overwrites a tenant-scoped template override with its variable schema, landing as a draft that only publish makes live (over an active override the draft is stored beside it, the active version keeps being served until publish); it refuses a system-tenant target, which is what upsert-system is for; a TenantAdmin may write it only for templates the app lists in tenantEditable, otherwise SystemAdmin only.",
27
+ handler: async (event, ctx) => {
28
+ const refused = refuseTemplateEdit(policy, event.user, event.payload);
29
+ if (refused !== null) return refused;
30
+ // @cast-boundary engine-payload — override is a zod-validated string and
31
+ // event.user.tenantId is already TenantId-branded; forTenant rejects the
32
+ // override for non-SystemAdmins.
33
+ const tenantId = (event.payload.tenantIdOverride ?? event.user.tenantId) as TenantId;
34
+ return writeTemplateRow({
35
+ ctx,
36
+ user: event.user,
37
+ tenantId,
38
+ scope: "tenant",
39
+ status: event.payload.status,
40
+ variables: resolveTemplateVariables(
41
+ ctx.registry,
42
+ event.payload.slug,
43
+ event.payload.variableSchema,
44
+ ),
45
+ payload: event.payload,
46
+ });
47
+ },
48
+ });
49
+ }
@@ -1,3 +1,4 @@
1
+ export { isResourceImageContentType } from "../shared/index.ts";
1
2
  export {
2
3
  createTemplateResolverApi,
3
4
  type ResolveRequest,
@@ -8,6 +9,7 @@ export {
8
9
  } from "./api.ts";
9
10
  export {
10
11
  type CompileDocumentBodyOptions,
12
+ collectDocumentImageVariables,
11
13
  collectDocumentResourceKeys,
12
14
  compileDocumentBodyToHandlebars,
13
15
  toResourceDataUri,
@@ -75,7 +77,7 @@ export {
75
77
  isDocumentReferenceName,
76
78
  isDocumentResourceKey,
77
79
  } from "./document-schema.ts";
78
- export { createTemplateResolverFeature } from "./feature.ts";
80
+ export { createTemplateResolverFeature, type TemplateResolverOptions } from "./feature.ts";
79
81
  export type { LayoutSourceResponse, LayoutSourceRow } from "./handlers/layout-source.query.ts";
80
82
  export type {
81
83
  OverviewKindGroup,
@@ -126,6 +128,11 @@ export {
126
128
  templateResourcesTable,
127
129
  } from "./table.ts";
128
130
  export { type TemplateBundle, templateBundleSchema } from "./template-bundle.ts";
131
+ export {
132
+ type TemplateEditPolicy,
133
+ type TenantEditableTemplate,
134
+ templateEditAccess,
135
+ } from "./template-edit-policy.ts";
129
136
  export {
130
137
  isMailTemplateMeta,
131
138
  type LayoutTemplateMeta,
@@ -35,6 +35,7 @@ export const TemplateResolverQueries = {
35
35
  preview: "template-resolver:query:preview",
36
36
  documentBlocks: "template-resolver:query:document-blocks",
37
37
  exportBundle: "template-resolver:query:export-bundle",
38
+ sendTestStatus: "template-resolver:query:send-test-status",
38
39
  // Public pair — anonymous-reachable, kind pinned to text-block.
39
40
  bySlug: "template-resolver:query:by-slug",
40
41
  byTenant: "template-resolver:query:by-tenant",
@@ -63,6 +64,7 @@ export const TemplateResolverErrors = {
63
64
  targetExists: "target_exists",
64
65
  systemResourcesNeedSystemTenant: "system_resources_need_system_tenant",
65
66
  systemOnlyTemplate: "system_only_template",
67
+ templateNotTenantEditable: "template_not_tenant_editable",
66
68
  bundleLocalesDiffer: "bundle_locales_differ",
67
69
  resourceTooLarge: "template_resource_too_large",
68
70
  resourceUnsupportedImage: "template_resource_unsupported_image",
@@ -0,0 +1,91 @@
1
+ import { fetchOne } from "@cosmicdrift/kumiko-framework/db";
2
+ import {
3
+ type HandlerContext,
4
+ hasAccess,
5
+ type RoleAccessRule,
6
+ type SessionUser,
7
+ } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ AccessDeniedError,
10
+ type WriteFailure,
11
+ writeFailure,
12
+ } from "@cosmicdrift/kumiko-framework/errors";
13
+ import type { TemplateKind } from "./constants.ts";
14
+ import { TemplateResolverErrors } from "./qualified-names.ts";
15
+ import { type TemplateResourceRow, templateResourcesTable } from "./table.ts";
16
+ import { isSystemAdmin } from "./template-draft.ts";
17
+
18
+ export type TenantEditableTemplate = {
19
+ readonly kind: TemplateKind;
20
+ readonly slug: string;
21
+ readonly access: RoleAccessRule;
22
+ };
23
+
24
+ export type TemplateEditPolicy = {
25
+ readonly tenantEditable: readonly TenantEditableTemplate[];
26
+ };
27
+
28
+ type EditTarget = { readonly slug: string; readonly kind: string };
29
+ type EditUser = Pick<SessionUser, "roles">;
30
+
31
+ const SYSTEM_ADMIN_ROLE = "SystemAdmin";
32
+
33
+ export function templateEditAccess(entries: readonly TenantEditableTemplate[]): RoleAccessRule {
34
+ const roles = new Set<string>([SYSTEM_ADMIN_ROLE]);
35
+ for (const entry of entries) {
36
+ for (const role of entry.access.roles) roles.add(role);
37
+ }
38
+ return { roles: [...roles] };
39
+ }
40
+
41
+ export function mayEditTemplate(
42
+ policy: TemplateEditPolicy,
43
+ user: EditUser,
44
+ target: EditTarget,
45
+ ): boolean {
46
+ if (isSystemAdmin(user)) return true;
47
+ const entry = policy.tenantEditable.find(
48
+ (candidate) => candidate.slug === target.slug && candidate.kind === target.kind,
49
+ );
50
+ return entry !== undefined && hasAccess(user, entry.access);
51
+ }
52
+
53
+ export function refuseTemplateEdit(
54
+ policy: TemplateEditPolicy,
55
+ user: EditUser,
56
+ target: EditTarget,
57
+ ): WriteFailure | null {
58
+ if (mayEditTemplate(policy, user, target)) return null;
59
+ return writeFailure(
60
+ new AccessDeniedError({
61
+ i18nKey: "templateResolver.errors.templateNotTenantEditable",
62
+ details: { reason: TemplateResolverErrors.templateNotTenantEditable },
63
+ }),
64
+ );
65
+ }
66
+
67
+ // Fail closed: a missing row gives a non-SystemAdmin the same refusal as a locked one,
68
+ // so the response never reveals whether an id exists. SystemAdmin keeps the 404.
69
+ export function refuseMissingTemplate(
70
+ policy: TemplateEditPolicy,
71
+ user: EditUser,
72
+ ): WriteFailure | null {
73
+ return isSystemAdmin(user) ? null : refuseTemplateEdit(policy, user, { slug: "", kind: "" });
74
+ }
75
+
76
+ export async function refuseTemplateEditById(
77
+ db: HandlerContext["db"],
78
+ policy: TemplateEditPolicy,
79
+ user: EditUser,
80
+ tenantId: string,
81
+ templateId: string,
82
+ ): Promise<WriteFailure | null> {
83
+ if (isSystemAdmin(user)) return null;
84
+ const row = await fetchOne<TemplateResourceRow>(db, templateResourcesTable, {
85
+ tenantId,
86
+ id: templateId,
87
+ });
88
+ return row === undefined
89
+ ? refuseMissingTemplate(policy, user)
90
+ : refuseTemplateEdit(policy, user, row);
91
+ }
@@ -4,6 +4,7 @@ import {
4
4
  type SessionUser,
5
5
  type TemplateListVariableDef,
6
6
  type TemplateScalarVariableDef,
7
+ type TemplateVariableDef,
7
8
  type TemplateVariables,
8
9
  templateVariablesToZod,
9
10
  } from "@cosmicdrift/kumiko-framework/engine";
@@ -120,6 +121,12 @@ function sampleList(def: TemplateListVariableDef, labelText: LabelText): Record<
120
121
  return rows;
121
122
  }
122
123
 
124
+ function sampleValueFor(name: string, def: TemplateVariableDef, labelText: LabelText): unknown {
125
+ // An images variable has no sample: file ids are record data, never preview content.
126
+ if (def.type === "images") return [];
127
+ return def.type === "list" ? sampleList(def, labelText) : sampleScalar(name, def, labelText);
128
+ }
129
+
123
130
  // Declared examples win; the fallbacks are deterministic so a preview never differs between calls.
124
131
  export function sampleValuesFor(
125
132
  variables: TemplateVariables,
@@ -127,8 +134,7 @@ export function sampleValuesFor(
127
134
  ): Record<string, unknown> {
128
135
  const values: Record<string, unknown> = {};
129
136
  for (const [name, def] of Object.entries(variables)) {
130
- values[name] =
131
- def.type === "list" ? sampleList(def, labelText) : sampleScalar(name, def, labelText);
137
+ values[name] = sampleValueFor(name, def, labelText);
132
138
  }
133
139
  return values;
134
140
  }
@@ -65,6 +65,7 @@ const scalarVariableSchema = z.discriminatedUnion("type", [
65
65
 
66
66
  const variableDefSchema = z.union([
67
67
  scalarVariableSchema,
68
+ z.object({ ...variableCommon, type: z.literal("images") }),
68
69
  z.object({
69
70
  ...variableCommon,
70
71
  type: z.literal("list"),
@@ -126,17 +127,24 @@ function listItemFields(
126
127
  return def?.type === "list" ? def.item : {};
127
128
  }
128
129
 
130
+ // An images variable holds file ids: as text it would leak them, so only an images block
131
+ // (and a condition on its presence) may name it.
129
132
  function isKnownName(
130
133
  name: string,
131
134
  scope: PlaceholderScope,
132
135
  itemFields: Readonly<Record<string, unknown>>,
136
+ allowImages = false,
133
137
  ): Resolution {
134
138
  const { variables } = scope;
135
139
  const root = rootName(name);
136
140
  // Declared variables and item fields are scalars: a dotted path under one never renders a value.
137
141
  if (Object.hasOwn(itemFields, name)) return { known: true, used: "" };
138
142
  if (scope.systemPlaceholders.has(root)) return { known: true, used: "" };
139
- if (Object.hasOwn(variables, name)) return { known: true, used: name };
143
+ if (Object.hasOwn(variables, name)) {
144
+ return variables[name]?.type === "images" && !allowImages
145
+ ? { known: false, used: "" }
146
+ : { known: true, used: name };
147
+ }
140
148
  return { known: false, used: "" };
141
149
  }
142
150
 
@@ -144,8 +152,9 @@ function recordName(
144
152
  name: string,
145
153
  itemFields: Readonly<Record<string, unknown>>,
146
154
  scope: PlaceholderScope,
155
+ allowImages = false,
147
156
  ) {
148
- const resolution = isKnownName(name, scope, itemFields);
157
+ const resolution = isKnownName(name, scope, itemFields, allowImages);
149
158
  if (!resolution.known) scope.unknown.add(name);
150
159
  else if (resolution.used !== "") scope.used.add(resolution.used);
151
160
  }
@@ -154,10 +163,18 @@ function enclosingEachName(element: ScanElement): string | null {
154
163
  return element.closest("tr[data-each]")?.getAttribute("data-each") ?? null;
155
164
  }
156
165
 
157
- const NAME_ATTRIBUTE_BY_TAG: Readonly<Record<string, string>> = {
158
- span: "data-variable",
159
- div: "data-steps",
160
- };
166
+ function nameAttributeOf(element: ScanElement): string | undefined {
167
+ const tag = element.tagName.toLowerCase();
168
+ if (tag === "span") return "data-variable";
169
+ if (tag !== "div") return undefined;
170
+ return element.hasAttribute("data-images") ? "data-images" : "data-steps";
171
+ }
172
+
173
+ function recordImagesName(name: string, scope: PlaceholderScope): void {
174
+ const def = Object.hasOwn(scope.variables, name) ? scope.variables[name] : undefined;
175
+ if (def?.type === "images") scope.used.add(name);
176
+ else scope.unknown.add(name);
177
+ }
161
178
 
162
179
  function scanRepeatRows(root: ScanElement, scope: PlaceholderScope): void {
163
180
  for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
@@ -174,18 +191,22 @@ function scanConditions(root: ScanElement, scope: PlaceholderScope): void {
174
191
  scope.variables,
175
192
  enclosingEachName(conditionScopeElement(element)),
176
193
  );
177
- recordName(element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE) ?? "", itemFields, scope);
194
+ recordName(element.getAttribute(DOCUMENT_CONDITION_ATTRIBUTE) ?? "", itemFields, scope, true);
178
195
  }
179
196
  }
180
197
 
181
198
  function scanReferences(root: ScanElement, scope: PlaceholderScope): void {
182
199
  for (const element of Array.from(
183
- root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"),
200
+ root.querySelectorAll(
201
+ "span[data-variable], div[data-steps], div[data-images], a[href], img[data-resource]",
202
+ ),
184
203
  )) {
185
204
  const itemFields = listItemFields(scope.variables, enclosingEachName(element));
186
205
  const tag = element.tagName.toLowerCase();
187
- const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
188
- if (nameAttribute !== undefined) {
206
+ const nameAttribute = nameAttributeOf(element);
207
+ if (nameAttribute === "data-images") {
208
+ recordImagesName(element.getAttribute(nameAttribute) ?? "", scope);
209
+ } else if (nameAttribute !== undefined) {
189
210
  recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
190
211
  } else if (tag === "a") {
191
212
  const href = element.getAttribute("href") ?? "";
@@ -5,6 +5,7 @@ import { Dropcursor, Gapcursor, Placeholder, UndoRedo } from "@tiptap/extensions
5
5
  import type { Node as ProseMirrorNode } from "@tiptap/pm/model";
6
6
  import {
7
7
  DocumentButton,
8
+ DocumentImages,
8
9
  DocumentPageBreak,
9
10
  DocumentResource,
10
11
  DocumentSteps,
@@ -23,6 +24,7 @@ export type DocumentEditorLabels = {
23
24
  readonly pageBreak: string;
24
25
  readonly button?: string;
25
26
  readonly steps?: string;
27
+ readonly images?: string;
26
28
  /** Canvas block label of an image, e.g. "Image · inline". */
27
29
  readonly imageBlock?: string;
28
30
  readonly variableLabels?: Readonly<Record<string, string>>;
@@ -206,6 +208,31 @@ function createStepsView(labels: DocumentEditorLabels): NodeViewRenderer {
206
208
  };
207
209
  }
208
210
 
211
+ // A sketch only: the photos are record data, known at render time.
212
+ function createImagesView(labels: DocumentEditorLabels): NodeViewRenderer {
213
+ return ({ node }) => {
214
+ const dom = createElementWithClass(
215
+ "div",
216
+ "relative my-3 rounded border border-dashed border-input px-3 pb-3 pt-4",
217
+ );
218
+ dom.setAttribute(DOCUMENT_BLOCK_ATTRIBUTE, "");
219
+ dom.contentEditable = "false";
220
+ const variable = String(node.attrs["variable"]);
221
+ const variableLabels = labels.variableLabels ?? {};
222
+ const variableLabel = Object.hasOwn(variableLabels, variable)
223
+ ? variableLabels[variable]
224
+ : undefined;
225
+ dom.append(createBlockLabel(`${labels.images ?? "Photos"} · ${variableLabel ?? variable}`));
226
+ const grid = createElementWithClass("div", "grid grid-cols-2 gap-1.5");
227
+ grid.append(
228
+ createElementWithClass("div", "h-12 rounded bg-muted"),
229
+ createElementWithClass("div", "h-12 rounded bg-muted"),
230
+ );
231
+ dom.append(grid);
232
+ return { dom };
233
+ };
234
+ }
235
+
209
236
  // Replaced by name, not appended: the schema must keep exactly one node per
210
237
  // name, and only the editor view differs from the shared document schema.
211
238
  export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Extensions {
@@ -224,6 +251,8 @@ export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Ex
224
251
  : extension;
225
252
  case DocumentSteps.name:
226
253
  return DocumentSteps.extend({ addNodeView: () => createStepsView(labels) });
254
+ case DocumentImages.name:
255
+ return DocumentImages.extend({ addNodeView: () => createImagesView(labels) });
227
256
  case DocumentPageBreak.name:
228
257
  return DocumentPageBreak.extend({
229
258
  addNodeView: () => createPageBreakView(labels.pageBreak),
@@ -25,6 +25,7 @@ import {
25
25
  Bold as BoldIcon,
26
26
  Braces,
27
27
  ChevronDown,
28
+ GalleryThumbnails,
28
29
  Image as ImageIcon,
29
30
  Italic as ItalicIcon,
30
31
  Link as LinkIcon,
@@ -72,7 +73,15 @@ function previewsKey(previews: Readonly<Record<string, DocumentResourcePreview>>
72
73
  .join("\n");
73
74
  }
74
75
 
75
- type PanelKind = "link" | "placeholder" | "image" | "button" | "repeat" | "align" | "steps";
76
+ type PanelKind =
77
+ | "link"
78
+ | "placeholder"
79
+ | "image"
80
+ | "button"
81
+ | "repeat"
82
+ | "align"
83
+ | "steps"
84
+ | "images";
76
85
 
77
86
  // Blocks that stay in the canvas toolbar; the rest moves into the overflow menu.
78
87
  const CANVAS_INLINE_BLOCKS: readonly DocumentToolbarBlock[] = ["image", "table"];
@@ -83,6 +92,7 @@ const BLOCK_PANEL: Partial<Record<DocumentToolbarBlock, PanelKind>> = {
83
92
  repeat: "repeat",
84
93
  align: "align",
85
94
  steps: "steps",
95
+ images: "images",
86
96
  };
87
97
 
88
98
  const ALIGNMENT_ICONS: Record<DocumentTextAlignment, ReactNode> = {
@@ -392,7 +402,11 @@ function InsertPanel({
392
402
  };
393
403
 
394
404
  const needsVariables =
395
- kind === "button" || kind === "repeat" || kind === "placeholder" || kind === "steps";
405
+ kind === "button" ||
406
+ kind === "repeat" ||
407
+ kind === "placeholder" ||
408
+ kind === "steps" ||
409
+ kind === "images";
396
410
  const noVariables = needsVariables && selectable.length === 0;
397
411
  const idPrefix = `${editorId}-panel`;
398
412
 
@@ -498,6 +512,16 @@ function InsertPanel({
498
512
  onChange={setChosenName}
499
513
  />
500
514
  )}
515
+ {kind === "images" && !noVariables && (
516
+ <NameSelect
517
+ id={`${idPrefix}-images-variable`}
518
+ label={t("template-resolver.documentEditor.imagesVariable")}
519
+ names={selectable}
520
+ labels={variableLabels}
521
+ value={selectedName}
522
+ onChange={setChosenName}
523
+ />
524
+ )}
501
525
  {kind === "steps" && !noVariables && (
502
526
  <>
503
527
  <NameSelect
@@ -551,6 +575,10 @@ function InsertPanel({
551
575
  {...(kind === "repeat" &&
552
576
  !noVariables && { onInsert: () => insertAndClose(repeatTableContent(selectedName)) })}
553
577
  {...(kind === "steps" && !noVariables && { onInsert: submitSteps })}
578
+ {...(kind === "images" &&
579
+ !noVariables && {
580
+ onInsert: () => insertAndClose({ type: "images", attrs: { variable: selectedName } }),
581
+ })}
554
582
  />
555
583
  </div>
556
584
  </div>
@@ -563,7 +591,8 @@ export default function DocumentTiptapEditor({
563
591
  onChange,
564
592
  variables,
565
593
  readOnly,
566
- blocks = MAIL_DOCUMENT_BLOCKS,
594
+ blocks: configuredBlocks = MAIL_DOCUMENT_BLOCKS,
595
+ imageVariables = NO_NAMES,
567
596
  variableLabels = NO_LABELS,
568
597
  systemVariables = NO_NAMES,
569
598
  resourcePreviews = NO_PREVIEWS,
@@ -581,6 +610,11 @@ export default function DocumentTiptapEditor({
581
610
  const canvas = chrome === "canvas";
582
611
  const compact = toolbar === "compact" && !canvas;
583
612
  const hosted = toolbarHost !== undefined;
613
+ // A photo block needs an images variable to bind to; without one the button would open an empty panel.
614
+ const blocks =
615
+ imageVariables.length === 0
616
+ ? configuredBlocks.filter((block) => block !== "images")
617
+ : configuredBlocks;
584
618
 
585
619
  const placeholderLabel = t("template-resolver.documentEditor.placeholder");
586
620
  const imageLabel = t("template-resolver.documentEditor.image");
@@ -588,6 +622,7 @@ export default function DocumentTiptapEditor({
588
622
  const pageBreakLabel = t("template-resolver.documentEditor.pageBreak");
589
623
  const buttonLabel = t("template-resolver.documentEditor.button");
590
624
  const stepsLabel = t("template-resolver.documentEditor.steps");
625
+ const imagesLabel = t("template-resolver.documentEditor.images");
591
626
  const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
592
627
  const stableSystem = useStableByKey(systemVariables, systemVariables.join("\n"));
593
628
  const stablePreviews = useStableByKey(resourcePreviews, previewsKey(resourcePreviews));
@@ -599,6 +634,7 @@ export default function DocumentTiptapEditor({
599
634
  pageBreak: pageBreakLabel,
600
635
  button: buttonLabel,
601
636
  steps: stepsLabel,
637
+ images: imagesLabel,
602
638
  imageBlock: imageBlockLabel,
603
639
  variableLabels: stableLabels,
604
640
  systemVariables: stableSystem,
@@ -612,6 +648,7 @@ export default function DocumentTiptapEditor({
612
648
  pageBreakLabel,
613
649
  buttonLabel,
614
650
  stepsLabel,
651
+ imagesLabel,
615
652
  stableLabels,
616
653
  stableSystem,
617
654
  stablePreviews,
@@ -731,6 +768,7 @@ export default function DocumentTiptapEditor({
731
768
  pageBreak: { label: pageBreakLabel, icon: <Scissors size={16} /> },
732
769
  align: { label: t("template-resolver.documentEditor.align"), icon: <AlignLeft size={16} /> },
733
770
  steps: { label: stepsLabel, icon: <ListChecks size={16} /> },
771
+ images: { label: imagesLabel, icon: <GalleryThumbnails size={16} /> },
734
772
  };
735
773
 
736
774
  const repeatTable = activeState?.repeatTable ?? null;
@@ -1058,7 +1096,7 @@ export default function DocumentTiptapEditor({
1058
1096
  editor={editor}
1059
1097
  editorId={id}
1060
1098
  kind={openPanel}
1061
- variables={variables}
1099
+ variables={openPanel === "images" ? imageVariables : variables}
1062
1100
  variableLabels={stableLabels}
1063
1101
  onClose={() => setOpenPanel(null)}
1064
1102
  />
@@ -22,6 +22,8 @@ export type DocumentEditorChrome = "default" | "canvas";
22
22
 
23
23
  export type DocumentEditorProps = ContentEditorProps & {
24
24
  readonly blocks?: readonly DocumentToolbarBlock[];
25
+ /** Names of the template's images variables; the photo block offers exactly these and is hidden without any. */
26
+ readonly imageVariables?: readonly string[];
25
27
  /** Display label per variable name, as inserted (list item fields use the
26
28
  * dotted form, e.g. "lines.description"). A chip shows label and name. */
27
29
  readonly variableLabels?: Readonly<Record<string, string>>;
@@ -43,6 +43,8 @@ export const defaultTranslations: TranslationsByLocale = {
43
43
  "template-resolver.documentEditor.removeLink": "Remove link",
44
44
  "template-resolver.documentEditor.repeat": "Repeat",
45
45
  "template-resolver.documentEditor.repeatList": "List to repeat",
46
+ "template-resolver.documentEditor.images": "Photos",
47
+ "template-resolver.documentEditor.imagesVariable": "Photos from",
46
48
  "template-resolver.documentEditor.steps": "Steps",
47
49
  "template-resolver.documentEditor.stepsVariable": "Current step from",
48
50
  "template-resolver.documentEditor.stepsList": "Steps, separated by commas (value=label)",
@@ -58,6 +60,8 @@ export const defaultTranslations: TranslationsByLocale = {
58
60
  "template-resolver.editor.saving": "Saving…",
59
61
  "template-resolver.editor.created": "Created.",
60
62
  "template-resolver.editor.saved": "Saved.",
63
+ "templateResolver.errors.document.imagesBlockPdfOnly":
64
+ "Photo blocks are only allowed in PDF documents.",
61
65
  "templateResolver.errors.document.buttonHrefNotPlaceholder":
62
66
  "The button target “{detail}” must be a placeholder.",
63
67
  "templateResolver.errors.document.disallowedLinkHref":
@@ -122,6 +126,8 @@ export const defaultTranslations: TranslationsByLocale = {
122
126
  "templateResolver.errors.noDraft": "There is no draft to discard.",
123
127
  "templateResolver.errors.systemOnlyTemplate":
124
128
  "Only the platform maintains this template; it cannot be overridden per tenant.",
129
+ "templateResolver.errors.templateNotTenantEditable":
130
+ "Only the platform may edit this template.",
125
131
  "templateResolver.errors.template.invalidMeta": "The template settings are invalid.",
126
132
  "templateResolver.errors.template.meta.subject.required": "The subject is missing.",
127
133
  "templateResolver.errors.template.meta.subject.invalid":
@@ -0,0 +1,37 @@
1
+ import { useTranslation } from "@cosmicdrift/kumiko-renderer";
2
+ import { Drawer } from "@cosmicdrift/kumiko-renderer/web";
3
+ import { CircleHelp } from "lucide-react";
4
+ import { type ReactNode, useState } from "react";
5
+
6
+ // A non-modal side panel: the screen behind stays usable and narrows instead of being covered.
7
+ export function HelpAction(): ReactNode {
8
+ const t = useTranslation();
9
+ const [open, setOpen] = useState(false);
10
+ return (
11
+ <>
12
+ <button
13
+ type="button"
14
+ data-testid="help-action"
15
+ aria-label={t("tasks.help.label")}
16
+ aria-pressed={open}
17
+ onClick={() => setOpen((current) => !current)}
18
+ className="inline-flex size-9 items-center justify-center rounded-md border border-border bg-card text-foreground hover:bg-accent"
19
+ >
20
+ <CircleHelp className="h-4 w-4" aria-hidden="true" />
21
+ </button>
22
+ <Drawer
23
+ open={open}
24
+ onOpenChange={setOpen}
25
+ side="right"
26
+ modal={false}
27
+ belowHeader
28
+ width={360}
29
+ title={t("tasks.help.label")}
30
+ description={t("tasks.help.description")}
31
+ testId="help-drawer"
32
+ >
33
+ <p className="py-2 text-sm text-muted-foreground">{t("tasks.help.body")}</p>
34
+ </Drawer>
35
+ </>
36
+ );
37
+ }
@@ -31,6 +31,7 @@ import {
31
31
  SYSTEM_INVOICE_DOCUMENTS,
32
32
  SYSTEM_INVOICE_ISSUED,
33
33
  SYSTEM_PDF_LAYOUT,
34
+ SYSTEM_PROPERTY_EXPOSE,
34
35
  SYSTEM_TENANCY_CERTIFICATE,
35
36
  SYSTEM_TERMS_CHANGED,
36
37
  TENANT_INVOICE_OVERRIDES,
@@ -168,6 +169,7 @@ export const seedTemplates: SeedFn = async (stack) => {
168
169
  await upsertSystem(SYSTEM_INVOICE_ISSUED);
169
170
  await upsertSystem(SYSTEM_TERMS_CHANGED);
170
171
  for (const document of SYSTEM_INVOICE_DOCUMENTS) await upsertSystem(document);
172
+ await upsertSystem(SYSTEM_PROPERTY_EXPOSE);
171
173
  const certificate = await upsertSystem(SYSTEM_TENANCY_CERTIFICATE);
172
174
  await stack.http.writeOk(
173
175
  TemplateResolverHandlers.archiveSystem,
@@ -14,6 +14,7 @@ import {
14
14
  SidebarBrand,
15
15
  ThemeMenuItem,
16
16
  ThemeToggle,
17
+ useNav,
17
18
  useTranslation,
18
19
  } from "@cosmicdrift/kumiko-renderer/web";
19
20
  import { MoonStar, Sun } from "lucide-react";
@@ -25,8 +26,10 @@ import {
25
26
  NORDLICHT_TENANT_ID,
26
27
  NORDLICHT_TENANT_NAME,
27
28
  } from "./auth-constants";
29
+ import { HelpAction } from "./help-action";
28
30
 
29
31
  const APP_NAME = "Kumiko Walkthrough";
32
+ const HELP_SCREEN_ID = "vehicle-edit";
30
33
 
31
34
  const tenantName = (tenantId: string): string => {
32
35
  if (tenantId === DEV_TENANT_ID) return "Dev Tenant";
@@ -49,6 +52,7 @@ export function AppShell({
49
52
  }): ReactNode {
50
53
  const t = useTranslation();
51
54
  const session = useSession();
55
+ const nav = useNav();
52
56
  // Without the user the shell hides role-gated navs and KumikoScreen denies role-gated screens.
53
57
  const user = session.user !== null ? { id: session.user.id, roles: session.roles } : undefined;
54
58
  return (
@@ -58,6 +62,8 @@ export function AppShell({
58
62
  {...(user !== undefined && { user })}
59
63
  headerActions={
60
64
  <>
65
+ {/* Only here: the editor screens need the header width for their own actions. */}
66
+ {nav.route?.screenId === HELP_SCREEN_ID && <HelpAction />}
61
67
  <AssistantAction />
62
68
  <ThemeToggle />
63
69
  </>