@cosmicdrift/kumiko-samples 0.374.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 (247) hide show
  1. package/package.json +1 -1
  2. package/packages/bundled-features/package.json +6 -4
  3. package/packages/bundled-features/src/account-security/changes.json +6 -0
  4. package/packages/bundled-features/src/account-security/constants.ts +1 -0
  5. package/packages/bundled-features/src/account-security/index.ts +1 -0
  6. package/packages/bundled-features/src/admin-shell/web/client-plugin.tsx +1 -1
  7. package/packages/bundled-features/src/audit/web/client-plugin.tsx +1 -1
  8. package/packages/bundled-features/src/auth-email-password/mail-notifications.ts +1 -1
  9. package/packages/bundled-features/src/auth-email-password/web/auth-client.ts +1 -1
  10. package/packages/bundled-features/src/auth-email-password/web/auth-form-primitives.tsx +1 -1
  11. package/packages/bundled-features/src/auth-email-password/web/client-plugin.ts +3 -3
  12. package/packages/bundled-features/src/auth-email-password/web/default-topbar-actions.tsx +1 -1
  13. package/packages/bundled-features/src/auth-email-password/web/index.ts +1 -1
  14. package/packages/bundled-features/src/auth-email-password/web/session.tsx +1 -1
  15. package/packages/bundled-features/src/auth-email-password/web/tenant-menu-items.tsx +1 -1
  16. package/packages/bundled-features/src/auth-email-password/web/tenant-switcher.tsx +1 -1
  17. package/packages/bundled-features/src/auth-email-password/web/user-menu.tsx +1 -1
  18. package/packages/bundled-features/src/auth-foundation/changes.json +6 -0
  19. package/packages/bundled-features/src/auth-foundation/env.ts +27 -0
  20. package/packages/bundled-features/src/auth-foundation/index.ts +1 -0
  21. package/packages/bundled-features/src/billing-foundation/changes.json +6 -0
  22. package/packages/bundled-features/src/billing-foundation/consumer-protection/confirmation-mail.ts +1 -1
  23. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-pages.ts +1 -1
  24. package/packages/bundled-features/src/billing-foundation/contact-card.ts +14 -0
  25. package/packages/bundled-features/src/billing-foundation/handlers/billing-plans.query.ts +1 -1
  26. package/packages/bundled-features/src/billing-foundation/index.ts +1 -0
  27. package/packages/bundled-features/src/billing-foundation/plan-catalog.ts +1 -0
  28. package/packages/bundled-features/src/billing-foundation/types.ts +12 -0
  29. package/packages/bundled-features/src/billing-foundation/validate-options.ts +16 -0
  30. package/packages/bundled-features/src/billing-foundation/web/billing-contact-plan.tsx +32 -0
  31. package/packages/bundled-features/src/billing-foundation/web/billing-plans-panel.tsx +5 -15
  32. package/packages/bundled-features/src/billing-foundation/web/cancel-contract-dialog.tsx +2 -2
  33. package/packages/bundled-features/src/billing-foundation/web/checkout-consent-dialog.tsx +1 -1
  34. package/packages/bundled-features/src/billing-foundation/web/client-plugin.tsx +1 -1
  35. package/packages/bundled-features/src/billing-foundation/web/localized-params.ts +14 -0
  36. package/packages/bundled-features/src/cap-counter/changes.json +12 -0
  37. package/packages/bundled-features/src/cap-counter/stock-cap-guard.ts +9 -0
  38. package/packages/bundled-features/src/cap-overview/web/cap-cards-panel.tsx +3 -3
  39. package/packages/bundled-features/src/cap-overview/web/client-plugin.tsx +1 -1
  40. package/packages/bundled-features/src/channel-email/changes.json +6 -0
  41. package/packages/bundled-features/src/channel-email/email-channel.ts +2 -1
  42. package/packages/bundled-features/src/compliance-profiles/web/client-plugin.tsx +1 -1
  43. package/packages/bundled-features/src/config/feature.ts +2 -2
  44. package/packages/bundled-features/src/config/web/i18n.ts +1 -0
  45. package/packages/bundled-features/src/crypto-shredding/changes.json +6 -0
  46. package/packages/bundled-features/src/custom-fields/web/client-plugin.tsx +1 -1
  47. package/packages/bundled-features/src/delivery/unsubscribe-page.ts +1 -1
  48. package/packages/bundled-features/src/delivery/web/client-plugin.tsx +1 -1
  49. package/packages/bundled-features/src/delivery/web/delivery-status-cell.tsx +1 -1
  50. package/packages/bundled-features/src/feature-toggles/web/client-plugin.tsx +1 -1
  51. package/packages/bundled-features/src/file-provider-s3/changes.json +6 -0
  52. package/packages/bundled-features/src/file-provider-s3/env-named-feature.ts +45 -0
  53. package/packages/bundled-features/src/file-provider-s3/env-schema.ts +20 -13
  54. package/packages/bundled-features/src/file-provider-s3/index.ts +4 -0
  55. package/packages/bundled-features/src/folders/web/client-plugin.tsx +1 -1
  56. package/packages/bundled-features/src/folders/web/folder-section.tsx +3 -4
  57. package/packages/bundled-features/src/host-routing/README.md +7 -0
  58. package/packages/bundled-features/src/host-routing/changes.json +6 -0
  59. package/packages/bundled-features/src/host-routing/constants.ts +4 -0
  60. package/packages/bundled-features/src/host-routing/dispatch.ts +34 -26
  61. package/packages/bundled-features/src/host-routing/feature.ts +122 -41
  62. package/packages/bundled-features/src/host-routing/host.ts +28 -1
  63. package/packages/bundled-features/src/host-routing/index.ts +3 -0
  64. package/packages/bundled-features/src/host-routing/options.ts +45 -2
  65. package/packages/bundled-features/src/host-routing/redirects.ts +33 -0
  66. package/packages/bundled-features/src/host-routing/tenant-lookup.ts +65 -0
  67. package/packages/bundled-features/src/host-routing/validate-options.ts +48 -1
  68. package/packages/bundled-features/src/inbound-mail-foundation/changes.json +13 -0
  69. package/packages/bundled-features/src/inbound-mail-foundation/feature.ts +8 -0
  70. package/packages/bundled-features/src/inbound-mail-foundation/handlers/list-accounts.query.ts +11 -4
  71. package/packages/bundled-features/src/inbound-mail-foundation/i18n.ts +42 -0
  72. package/packages/bundled-features/src/inbound-mail-foundation/index.ts +4 -0
  73. package/packages/bundled-features/src/inbound-mail-foundation/screens.ts +100 -0
  74. package/packages/bundled-features/src/inbound-mail-foundation/types.ts +2 -2
  75. package/packages/bundled-features/src/jobs/web/client-plugin.tsx +1 -1
  76. package/packages/bundled-features/src/legal-pages/changes.json +6 -0
  77. package/packages/bundled-features/src/legal-pages/feature.ts +14 -1
  78. package/packages/bundled-features/src/legal-pages/layout.ts +6 -6
  79. package/packages/bundled-features/src/legal-pages/web/client-plugin.ts +1 -1
  80. package/packages/bundled-features/src/mail-foundation/changes.json +6 -0
  81. package/packages/bundled-features/src/mail-foundation/feature.ts +21 -22
  82. package/packages/bundled-features/src/mail-foundation/i18n.ts +3 -2
  83. package/packages/bundled-features/src/mail-transport-smtp/i18n.ts +1 -1
  84. package/packages/bundled-features/src/managed-pages/web/client-plugin.tsx +1 -1
  85. package/packages/bundled-features/src/notes-history/web/client-plugin.tsx +1 -1
  86. package/packages/bundled-features/src/page-render/branding.ts +1 -1
  87. package/packages/bundled-features/src/page-render/layout.ts +2 -2
  88. package/packages/bundled-features/src/page-render/markdown.ts +1 -1
  89. package/packages/bundled-features/src/personal-access-tokens/i18n.ts +1 -1
  90. package/packages/bundled-features/src/personal-access-tokens/screens.ts +1 -1
  91. package/packages/bundled-features/src/secrets/feature.ts +4 -4
  92. package/packages/bundled-features/src/seo/sitemap.ts +1 -1
  93. package/packages/bundled-features/src/shared/mail-template-body.ts +1 -1
  94. package/packages/bundled-features/src/shared/purge-files-reaching-subject.ts +127 -2
  95. package/packages/bundled-features/src/tags/web/client-plugin.tsx +1 -1
  96. package/packages/bundled-features/src/template-resolver/changes.json +38 -0
  97. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +61 -3
  98. package/packages/bundled-features/src/template-resolver/constants.ts +3 -0
  99. package/packages/bundled-features/src/template-resolver/document-blocks.ts +36 -0
  100. package/packages/bundled-features/src/template-resolver/document-images.ts +29 -0
  101. package/packages/bundled-features/src/template-resolver/document-ops.ts +515 -0
  102. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +44 -0
  103. package/packages/bundled-features/src/template-resolver/document-schema.ts +5 -0
  104. package/packages/bundled-features/src/template-resolver/feature.ts +79 -34
  105. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +1 -0
  106. package/packages/bundled-features/src/template-resolver/handlers/collection-item.query.ts +1 -1
  107. package/packages/bundled-features/src/template-resolver/handlers/collection-list.query.ts +1 -1
  108. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +2 -6
  109. package/packages/bundled-features/src/template-resolver/handlers/collection-shared.ts +0 -6
  110. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +67 -58
  111. package/packages/bundled-features/src/template-resolver/handlers/discard-draft.write.ts +36 -13
  112. package/packages/bundled-features/src/template-resolver/handlers/document-base.ts +86 -0
  113. package/packages/bundled-features/src/template-resolver/handlers/document-blocks.query.ts +48 -0
  114. package/packages/bundled-features/src/template-resolver/handlers/export-bundle.query.ts +11 -3
  115. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +37 -28
  116. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +66 -62
  117. package/packages/bundled-features/src/template-resolver/handlers/patch-draft.write.ts +134 -0
  118. package/packages/bundled-features/src/template-resolver/handlers/preview.query.ts +2 -1
  119. package/packages/bundled-features/src/template-resolver/handlers/reset-to-system.write.ts +62 -49
  120. package/packages/bundled-features/src/template-resolver/handlers/resource-link-pdf.write.ts +44 -29
  121. package/packages/bundled-features/src/template-resolver/handlers/resource-remove.write.ts +52 -36
  122. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +126 -106
  123. package/packages/bundled-features/src/template-resolver/handlers/send-test-shared.ts +38 -0
  124. package/packages/bundled-features/src/template-resolver/handlers/send-test-status.query.ts +31 -0
  125. package/packages/bundled-features/src/template-resolver/handlers/send-test.write.ts +105 -110
  126. package/packages/bundled-features/src/template-resolver/handlers/set.write.ts +110 -103
  127. package/packages/bundled-features/src/template-resolver/handlers/template-write.ts +22 -1
  128. package/packages/bundled-features/src/template-resolver/handlers/toggle-status.write.ts +56 -28
  129. package/packages/bundled-features/src/template-resolver/handlers/upsert-tenant.write.ts +38 -29
  130. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +24 -9
  131. package/packages/bundled-features/src/template-resolver/index.ts +8 -1
  132. package/packages/bundled-features/src/template-resolver/{web/placeholder-text.ts → placeholder-text.ts} +1 -1
  133. package/packages/bundled-features/src/template-resolver/qualified-names.ts +4 -0
  134. package/packages/bundled-features/src/template-resolver/seeding.ts +3 -1
  135. package/packages/bundled-features/src/template-resolver/template-bundle.ts +6 -1
  136. package/packages/bundled-features/src/template-resolver/template-edit-policy.ts +91 -0
  137. package/packages/bundled-features/src/template-resolver/template-preview.ts +32 -10
  138. package/packages/bundled-features/src/template-resolver/template-validation.ts +32 -11
  139. package/packages/bundled-features/src/template-resolver/web/client-plugin.tsx +3 -3
  140. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +29 -0
  141. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +43 -5
  142. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +9 -33
  143. package/packages/bundled-features/src/template-resolver/web/i18n.ts +30 -0
  144. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +2 -2
  145. package/packages/bundled-features/src/tenant/web/client-plugin.tsx +1 -1
  146. package/packages/bundled-features/src/tenant/web/member-status-cell.tsx +1 -1
  147. package/packages/bundled-features/src/tier-engine/web/client-plugin.tsx +1 -1
  148. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  149. package/packages/bundled-features/src/user-data-rights/env.ts +15 -0
  150. package/packages/bundled-features/src/user-data-rights/feature.ts +4 -2
  151. package/packages/bundled-features/src/user-data-rights/index.ts +1 -0
  152. package/packages/bundled-features/src/user-data-rights/lib/mail-transport-resolver.ts +1 -1
  153. package/packages/bundled-features/src/user-data-rights/lib/storage-provider-resolver.ts +1 -1
  154. package/packages/bundled-features/src/user-data-rights/mail-notifications.ts +1 -1
  155. package/packages/bundled-features/src/user-data-rights/web/client-plugin.tsx +1 -1
  156. package/packages/bundled-features/src/user-data-rights/web/privacy-center-screen.tsx +2 -2
  157. package/packages/bundled-features/src/waitlist/changes.json +6 -0
  158. package/packages/bundled-features/src/waitlist/legacy-migrate.ts +51 -2
  159. package/samples/apps/admin-console/package.json +0 -3
  160. package/samples/apps/admin-console/src/app/client.tsx +1 -1
  161. package/samples/apps/admin-console/src/app/shell.tsx +1 -1
  162. package/samples/apps/config-demo/package.json +1 -3
  163. package/samples/apps/config-demo/src/app/client.tsx +1 -1
  164. package/samples/apps/config-demo/src/app/shell.tsx +2 -2
  165. package/samples/apps/config-demo/src/features/demo/web/index.ts +1 -1
  166. package/samples/apps/config-demo/tsconfig.json +0 -12
  167. package/samples/apps/marketing-demo/package.json +1 -3
  168. package/samples/apps/marketing-demo/src/app/client.tsx +1 -1
  169. package/samples/apps/marketing-demo/src/app/shell.tsx +1 -1
  170. package/samples/apps/marketing-demo/src/features/assets/web/index.ts +1 -1
  171. package/samples/apps/marketing-demo/src/features/helpdesk/web/index.ts +1 -1
  172. package/samples/apps/showcase/package.json +1 -3
  173. package/samples/apps/showcase/src/app/client.tsx +1 -1
  174. package/samples/apps/showcase/src/app/shell.tsx +1 -1
  175. package/samples/apps/showcase/src/features/demos/web/index.ts +1 -1
  176. package/samples/apps/showcase/src/features/demos/web/pages/sidebar-panel.tsx +1 -1
  177. package/samples/apps/showcase/src/features/demos/web/pages/toast.tsx +1 -1
  178. package/samples/apps/showcase/src/features/items/web/index.ts +1 -1
  179. package/samples/apps/styleguide/package.json +1 -3
  180. package/samples/apps/styleguide/src/app/client.tsx +1 -1
  181. package/samples/apps/styleguide/src/app/shell.tsx +2 -2
  182. package/samples/apps/styleguide/src/features/ai-settings/web.ts +1 -1
  183. package/samples/apps/styleguide/src/features/content/web.tsx +2 -2
  184. package/samples/apps/styleguide/src/features/demo/web.ts +1 -1
  185. package/samples/apps/styleguide/src/features/examples/web.ts +1 -1
  186. package/samples/apps/styleguide/src/features/gallery/web/Gallery.tsx +1 -1
  187. package/samples/apps/styleguide/src/features/gallery/web/index.ts +1 -1
  188. package/samples/apps/styleguide/src/features/widgets/demo-locale.ts +1 -1
  189. package/samples/apps/styleguide/src/features/widgets/lib/format.ts +1 -1
  190. package/samples/apps/styleguide/src/features/widgets/web/DashboardFilterEcho.tsx +1 -1
  191. package/samples/apps/styleguide/src/features/widgets/web/FormExamples.tsx +1 -1
  192. package/samples/apps/styleguide/src/features/widgets/web/Widgets.tsx +1 -1
  193. package/samples/apps/styleguide/src/features/widgets/web/index.ts +1 -1
  194. package/samples/apps/styleguide/tsconfig.json +0 -12
  195. package/samples/apps/ui-walkthrough/package.json +1 -3
  196. package/samples/apps/ui-walkthrough/src/app/client.tsx +1 -1
  197. package/samples/apps/ui-walkthrough/src/app/help-action.tsx +37 -0
  198. package/samples/apps/ui-walkthrough/src/app/seed-templates.ts +2 -0
  199. package/samples/apps/ui-walkthrough/src/app/shell.tsx +7 -1
  200. package/samples/apps/ui-walkthrough/src/app/template-seed-data.ts +20 -0
  201. package/samples/apps/ui-walkthrough/src/features/billing/web/index.ts +1 -1
  202. package/samples/apps/ui-walkthrough/src/features/rental/feature.ts +65 -1
  203. package/samples/apps/ui-walkthrough/src/features/rental/i18n.ts +70 -0
  204. package/samples/apps/ui-walkthrough/src/features/rental/invoice-receive.ts +20 -0
  205. package/samples/apps/ui-walkthrough/src/features/rental/lease-support.ts +15 -0
  206. package/samples/apps/ui-walkthrough/src/features/rental/qualified-names.ts +2 -0
  207. package/samples/apps/ui-walkthrough/src/features/rental/screens.ts +79 -0
  208. package/samples/apps/ui-walkthrough/src/features/rental/web/index.ts +3 -1
  209. package/samples/apps/ui-walkthrough/src/features/rental/web/upload-standard-layout.tsx +61 -0
  210. package/samples/apps/ui-walkthrough/src/features/tasks/i18n.ts +8 -0
  211. package/samples/apps/ui-walkthrough/src/features/tasks/web/index.ts +1 -1
  212. package/samples/apps/ui-walkthrough/src/features/vehicles/web/index.ts +1 -1
  213. package/samples/apps/ui-walkthrough/src/run-config.ts +32 -3
  214. package/samples/apps/use-all-bundled/bin/main.ts +3 -2
  215. package/samples/apps/use-all-bundled/e2e-billing-gallery/gallery-features.ts +12 -0
  216. package/samples/apps/use-all-bundled/e2e-billing-gallery/server.ts +1 -1
  217. package/samples/apps/use-all-bundled/feature-manifest.json +3 -2
  218. package/samples/apps/use-all-bundled/package.json +1 -3
  219. package/samples/apps/use-all-bundled/src/app/client.tsx +1 -1
  220. package/samples/apps/use-all-bundled/src/app/dev-app-options.ts +5 -4
  221. package/samples/apps/use-all-bundled/src/app/shell.tsx +1 -1
  222. package/samples/apps/use-all-bundled/src/run-config.ts +10 -1
  223. package/samples/apps/workspaces/package.json +1 -3
  224. package/samples/apps/workspaces/src/app/client.tsx +1 -1
  225. package/samples/apps/workspaces/src/app/shell.tsx +1 -1
  226. package/samples/apps/workspaces/src/features/demo/web/index.ts +1 -1
  227. package/samples/apps/workspaces/src/features/demo-driver/web/index.ts +1 -1
  228. package/samples/recipes/apex-landing/package.json +1 -1
  229. package/samples/recipes/apex-landing/src/feature.ts +1 -1
  230. package/samples/recipes/apex-surface-auth/README.md +1 -1
  231. package/samples/recipes/apex-surface-auth/src/feature.ts +2 -2
  232. package/samples/recipes/compose-stacks/README.md +7 -4
  233. package/samples/recipes/delivery-notifications/README.md +1 -1
  234. package/samples/recipes/embedded/README.md +8 -0
  235. package/samples/recipes/embedded/src/feature.ts +8 -0
  236. package/samples/recipes/embedded/src/handlers/invoice-receive.write.ts +38 -0
  237. package/samples/recipes/embedded/src/screens/invoice-receive.screen.ts +59 -0
  238. package/samples/recipes/embedded-entity-form/package.json +0 -1
  239. package/samples/recipes/embedded-entity-form/src/web/accept-suggestion-drawer.tsx +1 -1
  240. package/samples/recipes/notes-history-basic/README.md +1 -1
  241. package/samples/recipes/record-detail-layout/package.json +0 -1
  242. package/samples/recipes/tags-basic/README.md +1 -1
  243. package/samples/recipes/user-profile/README.md +1 -1
  244. package/samples/recipes/wizard-form/package.json +0 -1
  245. package/samples/recipes/wizard-form/src/web/index.ts +2 -2
  246. package/samples/recipes/wizard-form/src/web/listing-review-section.tsx +2 -2
  247. package/samples/recipes/writeform-section/package.json +0 -1
@@ -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
+ }
@@ -47,17 +47,19 @@ type TemplateDraftInput = {
47
47
  readonly scope: TemplateWriteScope;
48
48
  };
49
49
 
50
- // The dry run of the write pipeline; preview feeds on the evaluation, validate returns the response.
51
- export async function validateTemplateDraft(
50
+ // Image keys the editor may reference: the system rows plus, for a tenant write, the caller's own.
51
+ export async function knownResourceKeysFor(
52
52
  ctx: HandlerContext,
53
53
  user: SessionUser,
54
- payload: TemplateDraftInput,
55
- ) {
56
- const variables = resolveTemplateVariables(ctx.registry, payload.slug, payload.variableSchema);
57
- const { slug, kind, locale } = payload;
58
- // The dry run must not promise what the caller's write handlers would refuse.
59
- const scope: TemplateWriteScope = isSystemAdmin(user) ? payload.scope : "tenant";
60
- const known = [
54
+ where: {
55
+ readonly slug: string;
56
+ readonly kind: string;
57
+ readonly scope: TemplateWriteScope;
58
+ readonly locale: string | undefined;
59
+ },
60
+ ): Promise<string[]> {
61
+ const { slug, kind, scope, locale } = where;
62
+ return [
61
63
  ...(await knownResourceKeys(ctx.db, {
62
64
  tenantId: SYSTEM_TENANT_ID,
63
65
  slug,
@@ -69,6 +71,19 @@ export async function validateTemplateDraft(
69
71
  ? await knownResourceKeys(ctx.db, { tenantId: user.tenantId, slug, kind, scope, locale })
70
72
  : []),
71
73
  ];
74
+ }
75
+
76
+ // The dry run of the write pipeline; preview feeds on the evaluation, validate returns the response.
77
+ export async function validateTemplateDraft(
78
+ ctx: HandlerContext,
79
+ user: SessionUser,
80
+ payload: TemplateDraftInput,
81
+ ) {
82
+ const variables = resolveTemplateVariables(ctx.registry, payload.slug, payload.variableSchema);
83
+ const { slug, kind, locale } = payload;
84
+ // The dry run must not promise what the caller's write handlers would refuse.
85
+ const scope: TemplateWriteScope = isSystemAdmin(user) ? payload.scope : "tenant";
86
+ const known = await knownResourceKeysFor(ctx, user, { slug, kind, scope, locale });
72
87
  const evaluation = evaluateTemplateWrite({
73
88
  kind,
74
89
  scope,
@@ -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,
@@ -1,6 +1,6 @@
1
1
  // @runtime client
2
2
  import type { JSONContent } from "@tiptap/core";
3
- import { isDocumentReferenceName } from "../document-schema.ts";
3
+ import { isDocumentReferenceName } from "./document-schema.ts";
4
4
 
5
5
  const PLACEHOLDER_TOKEN = /\{\{([^{}]*)\}\}/g;
6
6
 
@@ -20,6 +20,7 @@ export const TemplateResolverHandlers = {
20
20
  resourceRemove: "template-resolver:write:resource-remove",
21
21
  resourceLinkPdf: "template-resolver:write:resource-link-pdf",
22
22
  sendTest: "template-resolver:write:send-test",
23
+ patchDraft: "template-resolver:write:patch-draft",
23
24
  } as const;
24
25
 
25
26
  export const TemplateResolverQueries = {
@@ -32,7 +33,9 @@ export const TemplateResolverQueries = {
32
33
  diffToSystem: "template-resolver:query:diff-to-system",
33
34
  validate: "template-resolver:query:validate",
34
35
  preview: "template-resolver:query:preview",
36
+ documentBlocks: "template-resolver:query:document-blocks",
35
37
  exportBundle: "template-resolver:query:export-bundle",
38
+ sendTestStatus: "template-resolver:query:send-test-status",
36
39
  // Public pair — anonymous-reachable, kind pinned to text-block.
37
40
  bySlug: "template-resolver:query:by-slug",
38
41
  byTenant: "template-resolver:query:by-tenant",
@@ -61,6 +64,7 @@ export const TemplateResolverErrors = {
61
64
  targetExists: "target_exists",
62
65
  systemResourcesNeedSystemTenant: "system_resources_need_system_tenant",
63
66
  systemOnlyTemplate: "system_only_template",
67
+ templateNotTenantEditable: "template_not_tenant_editable",
64
68
  bundleLocalesDiffer: "bundle_locales_differ",
65
69
  resourceTooLarge: "template_resource_too_large",
66
70
  resourceUnsupportedImage: "template_resource_unsupported_image",
@@ -78,6 +78,7 @@ export type SeedSystemTemplateOptions = {
78
78
  * system admin uploaded through resource-upload. */
79
79
  readonly linkedResources?: Record<string, string>;
80
80
  readonly meta?: unknown;
81
+ readonly title?: string;
81
82
  readonly parentTemplateId?: string | null;
82
83
  readonly by?: SessionUser;
83
84
  readonly ifExists?: SeedIfExists;
@@ -138,7 +139,7 @@ export async function seedSystemTemplate(
138
139
  variableSchema,
139
140
  ...(linkedResources !== undefined && { linkedResources }),
140
141
  meta,
141
- title: null,
142
+ title: opts.title ?? null,
142
143
  folder: null,
143
144
  scope: "system" as const,
144
145
  parentTemplateId,
@@ -261,6 +262,7 @@ export async function seedSystemTemplateBundle(
261
262
  contentFormat: bundle.contentFormat,
262
263
  variableSchema: bundle.variableSchema ?? {},
263
264
  meta: entry.meta,
265
+ ...(entry.title !== undefined ? { title: entry.title } : {}),
264
266
  ...(opts.by !== undefined ? { by: opts.by } : {}),
265
267
  ...(opts.ifExists !== undefined ? { ifExists: opts.ifExists } : {}),
266
268
  });
@@ -40,7 +40,12 @@ const bundleResourceSchema = z
40
40
  const pdfAttachmentLinkBodySchema = templatePdfAttachmentLinkSchema.omit({ kind: true });
41
41
 
42
42
  const bundleLocaleEntrySchema = z
43
- .object({ body: z.string().max(200_000), meta: z.unknown().optional() })
43
+ .object({
44
+ body: z.string().max(200_000),
45
+ meta: z.unknown().optional(),
46
+ // Shown in the template overview instead of the slug; one per locale, so it is translated.
47
+ title: z.string().min(1).max(200).optional(),
48
+ })
44
49
  .strict();
45
50
 
46
51
  export const templateBundleSchema = z
@@ -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
+ }
@@ -1,20 +1,24 @@
1
1
  import { selectMany } from "@cosmicdrift/kumiko-framework/db";
2
- import type {
3
- HandlerContext,
4
- SessionUser,
5
- TemplateListVariableDef,
6
- TemplateScalarVariableDef,
7
- TemplateVariables,
2
+ import {
3
+ type HandlerContext,
4
+ type SessionUser,
5
+ type TemplateListVariableDef,
6
+ type TemplateScalarVariableDef,
7
+ type TemplateVariableDef,
8
+ type TemplateVariables,
9
+ templateVariablesToZod,
8
10
  } from "@cosmicdrift/kumiko-framework/engine";
9
11
  import {
10
12
  type KumikoError,
11
13
  NotFoundError,
12
14
  UnprocessableError,
13
15
  ValidationError,
16
+ validationErrorFromZod,
14
17
  } from "@cosmicdrift/kumiko-framework/errors";
15
18
  import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
16
19
  import { createI18n } from "@cosmicdrift/kumiko-framework/i18n";
17
- import { escapeHtml } from "@cosmicdrift/kumiko-headless";
20
+ import { escapeHtml } from "@cosmicdrift/kumiko-renderer/headless";
21
+ import * as z from "zod";
18
22
  import { createEmailAttachmentReader, createTemplateMailRenderer } from "../delivery/index.ts";
19
23
  import { fileContextForTenant } from "../file-foundation/index.ts";
20
24
  import {
@@ -117,6 +121,12 @@ function sampleList(def: TemplateListVariableDef, labelText: LabelText): Record<
117
121
  return rows;
118
122
  }
119
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
+
120
130
  // Declared examples win; the fallbacks are deterministic so a preview never differs between calls.
121
131
  export function sampleValuesFor(
122
132
  variables: TemplateVariables,
@@ -124,8 +134,7 @@ export function sampleValuesFor(
124
134
  ): Record<string, unknown> {
125
135
  const values: Record<string, unknown> = {};
126
136
  for (const [name, def] of Object.entries(variables)) {
127
- values[name] =
128
- def.type === "list" ? sampleList(def, labelText) : sampleScalar(name, def, labelText);
137
+ values[name] = sampleValueFor(name, def, labelText);
129
138
  }
130
139
  return values;
131
140
  }
@@ -177,6 +186,8 @@ export type TemplatePreviewInput = {
177
186
  readonly content?: string | undefined;
178
187
  readonly meta?: unknown;
179
188
  readonly variableSchema: Readonly<Record<string, unknown>>;
189
+ /** Replaces the declared example values for the named variables. */
190
+ readonly variables?: Readonly<Record<string, unknown>> | undefined;
180
191
  };
181
192
 
182
193
  type ValidationResponse = Awaited<ReturnType<typeof validateTemplateDraft>>["response"];
@@ -197,6 +208,7 @@ export type TemplatePreview = {
197
208
  readonly scope: TemplateScope;
198
209
  readonly version: number;
199
210
  } | null;
211
+ /** The values the preview was actually rendered with: examples plus any overrides. */
200
212
  readonly sampleValues: Readonly<Record<string, unknown>>;
201
213
  readonly checks: readonly PreviewCheck[];
202
214
  readonly validation: ValidationResponse;
@@ -689,7 +701,17 @@ export async function renderTemplatePreview(
689
701
 
690
702
  const content = evaluation.content ?? "";
691
703
  const meta = parseTemplateMeta(input.kind, parseStoredJson(evaluation.metaJson));
692
- const sampleValues = sampleValuesFor(validation.variables, labelTextFor(ctx, input.locale));
704
+ const exampleValues = sampleValuesFor(validation.variables, labelTextFor(ctx, input.locale));
705
+ let sampleValues = exampleValues;
706
+ if (input.variables !== undefined) {
707
+ const overrides = z.object({
708
+ variables: templateVariablesToZod(validation.variables).partial().strict(),
709
+ });
710
+ const checked = overrides.safeParse({ variables: input.variables });
711
+ if (!checked.success) return failed(validationErrorFromZod(checked.error));
712
+ // Original values, not the parse result: parsing would strip keys inside list rows.
713
+ sampleValues = { ...exampleValues, ...input.variables };
714
+ }
693
715
 
694
716
  if (input.kind === "document-pdf") {
695
717
  return renderDocumentPreview(ctx, user, {
@@ -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"),
@@ -76,7 +77,7 @@ const variableDefSchema = z.union([
76
77
  // A stored variableSchema value is either a bare string (legacy "this is a text
77
78
  // variable") or a TemplateVariableDef object; anything else is not declared and
78
79
  // therefore shows up as an unknown placeholder instead of being guessed at.
79
- function toVariableDef(raw: unknown): TemplateVariableDef | null {
80
+ export function toVariableDef(raw: unknown): TemplateVariableDef | null {
80
81
  if (typeof raw === "string") return { type: "text" };
81
82
  const parsed = variableDefSchema.safeParse(raw);
82
83
  return parsed.success ? parsed.data : null;
@@ -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") ?? "";
@@ -1,4 +1,4 @@
1
- import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-headless";
1
+ import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-renderer/headless";
2
2
  // @runtime client
3
3
  // Client feature factory for the text-block content tree. Apps mount it via
4
4
  // createKumikoApp({ clientFeatures: [textBlocksClient()] }); it loads the
@@ -12,7 +12,6 @@ import { EMPTY_VALUE_PLACEHOLDER } from "@cosmicdrift/kumiko-headless";
12
12
  // (a hint that the slug exists but is empty).
13
13
 
14
14
  import { useShellUser } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/web";
15
- import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-dispatcher-live";
16
15
  import type {
17
16
  TargetRef,
18
17
  TreeChildrenSubscribe,
@@ -28,11 +27,12 @@ import {
28
27
  useQuery,
29
28
  useTranslation,
30
29
  } from "@cosmicdrift/kumiko-renderer";
30
+ import { CSRF_HEADER_NAME, readCsrfToken } from "@cosmicdrift/kumiko-renderer/live";
31
31
  import {
32
32
  type ClientFeatureDefinition,
33
33
  PlainContentEditor,
34
34
  RichContentEditor,
35
- } from "@cosmicdrift/kumiko-renderer-web";
35
+ } from "@cosmicdrift/kumiko-renderer/web";
36
36
  import type { ContentEditorFormat } from "@cosmicdrift/kumiko-types/nav";
37
37
  import { type FormEvent, type ReactNode, useEffect, useId, useState } from "react";
38
38
  import {
@@ -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),