@cosmicdrift/kumiko-samples 0.364.0 → 0.365.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 (143) hide show
  1. package/LICENSE +1 -1
  2. package/package.json +1 -1
  3. package/packages/bundled-features/package.json +1 -1
  4. package/packages/bundled-features/src/audit/changes.json +12 -0
  5. package/packages/bundled-features/src/audit/feature.ts +1 -1
  6. package/packages/bundled-features/src/audit/run-escape-hatch-retention.ts +63 -11
  7. package/packages/bundled-features/src/auth-email-password/bootstrap.ts +1 -3
  8. package/packages/bundled-features/src/auth-email-password/changes.json +18 -0
  9. package/packages/bundled-features/src/auth-email-password/feature.ts +4 -7
  10. package/packages/bundled-features/src/auth-email-password/handlers/invite-accept-with-login.write.ts +2 -3
  11. package/packages/bundled-features/src/auth-email-password/handlers/invite-create.write.ts +6 -4
  12. package/packages/bundled-features/src/auth-email-password/handlers/switch-tenant-mfa-gate.write.ts +2 -2
  13. package/packages/bundled-features/src/auth-email-password/magic-link-mail.ts +7 -3
  14. package/packages/bundled-features/src/auth-email-password/mail-i18n.ts +10 -0
  15. package/packages/bundled-features/src/billing-foundation/changes.json +12 -0
  16. package/packages/bundled-features/src/billing-foundation/consumer-protection/termination-record.ts +8 -1
  17. package/packages/bundled-features/src/billing-foundation/i18n.ts +6 -0
  18. package/packages/bundled-features/src/billing-foundation/subscription-tier-sync.ts +10 -2
  19. package/packages/bundled-features/src/cap-counter/book-cap-usage.ts +20 -8
  20. package/packages/bundled-features/src/cap-counter/cap-reservation.ts +7 -2
  21. package/packages/bundled-features/src/cap-counter/changes.json +13 -0
  22. package/packages/bundled-features/src/cap-counter/handlers/increment-rolling.write.ts +2 -0
  23. package/packages/bundled-features/src/cap-overview/changes.json +6 -0
  24. package/packages/bundled-features/src/cap-overview/handlers/tenant-caps-list.query.ts +2 -1
  25. package/packages/bundled-features/src/cap-overview/handlers/tenant-options.query.ts +2 -1
  26. package/packages/bundled-features/src/channel-discord/discord-channel.ts +4 -2
  27. package/packages/bundled-features/src/channel-email/changes.json +18 -0
  28. package/packages/bundled-features/src/channel-email/email-channel.ts +30 -20
  29. package/packages/bundled-features/src/channel-email/smtp-transport.ts +7 -1
  30. package/packages/bundled-features/src/channel-slack/slack-channel.ts +4 -2
  31. package/packages/bundled-features/src/channel-teams/teams-channel.ts +4 -2
  32. package/packages/bundled-features/src/crypto-shredding/handlers/forget-subject.write.ts +7 -1
  33. package/packages/bundled-features/src/custom-fields/db/queries/retention.ts +2 -2
  34. package/packages/bundled-features/src/data-retention/run-retention-cleanup.ts +4 -5
  35. package/packages/bundled-features/src/delivery/attachment-ref.ts +18 -1
  36. package/packages/bundled-features/src/delivery/changes.json +12 -0
  37. package/packages/bundled-features/src/delivery/delivery-service.ts +93 -92
  38. package/packages/bundled-features/src/document-ingest-foundation/changes.json +6 -0
  39. package/packages/bundled-features/src/feature-toggles/changes.json +7 -0
  40. package/packages/bundled-features/src/feature-toggles/toggle-runtime.ts +26 -12
  41. package/packages/bundled-features/src/files-tenant-data/index.ts +3 -1
  42. package/packages/bundled-features/src/form-draft/db/queries/owned-file-refs.ts +12 -3
  43. package/packages/bundled-features/src/form-draft/handlers/discard.write.ts +1 -1
  44. package/packages/bundled-features/src/jobs/changes.json +12 -0
  45. package/packages/bundled-features/src/jobs/job-run-logger.ts +5 -2
  46. package/packages/bundled-features/src/metrics/changes.json +6 -0
  47. package/packages/bundled-features/src/metrics/filters.ts +28 -12
  48. package/packages/bundled-features/src/personal-access-tokens/changes.json +6 -0
  49. package/packages/bundled-features/src/personal-access-tokens/revoke-for-user.ts +7 -8
  50. package/packages/bundled-features/src/secrets/changes.json +6 -0
  51. package/packages/bundled-features/src/secrets/handlers/delete.write.ts +3 -1
  52. package/packages/bundled-features/src/secrets/write-gate.ts +8 -2
  53. package/packages/bundled-features/src/shared/index.ts +8 -0
  54. package/packages/bundled-features/src/shared/resource-image-formats.ts +31 -0
  55. package/packages/bundled-features/src/shared/sharp-input-limits.ts +7 -8
  56. package/packages/bundled-features/src/step-dispatcher/webhook-runner.ts +2 -2
  57. package/packages/bundled-features/src/template-resolver/changes.json +81 -0
  58. package/packages/bundled-features/src/template-resolver/compile-document-body.ts +78 -12
  59. package/packages/bundled-features/src/template-resolver/compile-steps-block.ts +68 -0
  60. package/packages/bundled-features/src/template-resolver/constants.ts +11 -1
  61. package/packages/bundled-features/src/template-resolver/document-sanitizer.ts +3 -0
  62. package/packages/bundled-features/src/template-resolver/document-schema.ts +7 -1
  63. package/packages/bundled-features/src/template-resolver/document-steps.ts +114 -0
  64. package/packages/bundled-features/src/template-resolver/feature.ts +12 -0
  65. package/packages/bundled-features/src/template-resolver/handlers/bundle-import.ts +81 -39
  66. package/packages/bundled-features/src/template-resolver/handlers/collection-set.write.ts +4 -3
  67. package/packages/bundled-features/src/template-resolver/handlers/copy-locale.write.ts +111 -61
  68. package/packages/bundled-features/src/template-resolver/handlers/diff-to-system.query.ts +1 -0
  69. package/packages/bundled-features/src/template-resolver/handlers/import-bundle-system.write.ts +25 -15
  70. package/packages/bundled-features/src/template-resolver/handlers/import-bundle.write.ts +12 -1
  71. package/packages/bundled-features/src/template-resolver/handlers/overview.query.ts +12 -2
  72. package/packages/bundled-features/src/template-resolver/handlers/resource-upload.write.ts +48 -9
  73. package/packages/bundled-features/src/template-resolver/handlers/validate.query.ts +4 -1
  74. package/packages/bundled-features/src/template-resolver/index.ts +6 -1
  75. package/packages/bundled-features/src/template-resolver/layout-appearance.ts +25 -9
  76. package/packages/bundled-features/src/template-resolver/mail-constants.ts +4 -0
  77. package/packages/bundled-features/src/template-resolver/resource-image.ts +9 -16
  78. package/packages/bundled-features/src/template-resolver/seeding.ts +2 -0
  79. package/packages/bundled-features/src/template-resolver/store-template-resource.ts +390 -124
  80. package/packages/bundled-features/src/template-resolver/template-bundle.ts +4 -0
  81. package/packages/bundled-features/src/template-resolver/template-draft.ts +1 -1
  82. package/packages/bundled-features/src/template-resolver/template-meta.ts +8 -0
  83. package/packages/bundled-features/src/template-resolver/template-preview.ts +1 -1
  84. package/packages/bundled-features/src/template-resolver/template-resources.ts +2 -43
  85. package/packages/bundled-features/src/template-resolver/template-validation.ts +15 -9
  86. package/packages/bundled-features/src/template-resolver/web/document-editor-extensions.ts +34 -0
  87. package/packages/bundled-features/src/template-resolver/web/document-editor.tsx +3 -2
  88. package/packages/bundled-features/src/template-resolver/web/document-tiptap-editor.tsx +56 -11
  89. package/packages/bundled-features/src/template-resolver/web/document-toolbar-blocks.ts +6 -3
  90. package/packages/bundled-features/src/template-resolver/web/i18n.ts +7 -0
  91. package/packages/bundled-features/src/template-resolver/web/index.ts +1 -0
  92. package/packages/bundled-features/src/template-resolver/web/placeholder-line-editor.tsx +14 -5
  93. package/packages/bundled-features/src/tenant/changes.json +6 -0
  94. package/packages/bundled-features/src/tenant/handlers/update-member-roles.write.ts +3 -8
  95. package/packages/bundled-features/src/tenant/seeding.ts +33 -27
  96. package/packages/bundled-features/src/tenant-handover/move-entity-graph.ts +14 -3
  97. package/packages/bundled-features/src/tenant-lifecycle/changes.json +6 -0
  98. package/packages/bundled-features/src/tenant-lifecycle/run-tenant-destroy.ts +9 -1
  99. package/packages/bundled-features/src/tenant-lifecycle/stages.ts +3 -2
  100. package/packages/bundled-features/src/tier-engine/changes.json +12 -0
  101. package/packages/bundled-features/src/tier-engine/constants.ts +9 -0
  102. package/packages/bundled-features/src/tier-engine/entity.ts +9 -4
  103. package/packages/bundled-features/src/tier-engine/handlers/set-tenant-tier.write.ts +8 -5
  104. package/packages/bundled-features/src/tier-engine/index.ts +2 -0
  105. package/packages/bundled-features/src/user/constants.ts +1 -0
  106. package/packages/bundled-features/src/user/invited-global-roles.ts +2 -4
  107. package/packages/bundled-features/src/user-data-rights/changes.json +6 -0
  108. package/packages/bundled-features/src/user-data-rights/email-templates.ts +0 -1
  109. package/packages/bundled-features/src/user-data-rights/i18n.ts +5 -0
  110. package/packages/bundled-features/src/user-data-rights/lib/update-user-lifecycle.ts +13 -2
  111. package/packages/bundled-features/src/user-data-rights/run-forget-cleanup.ts +7 -1
  112. package/packages/bundled-features/src/waitlist/changes.json +18 -0
  113. package/packages/bundled-features/src/waitlist/feature.ts +1 -1
  114. package/packages/bundled-features/src/waitlist/handlers/invite.write.ts +35 -3
  115. package/packages/bundled-features/src/waitlist/handlers/reject.write.ts +2 -17
  116. package/packages/bundled-features/src/waitlist/handlers/submit.write.ts +33 -9
  117. package/packages/bundled-features/src/waitlist/i18n.ts +9 -0
  118. package/packages/bundled-features/src/waitlist/lib.ts +20 -0
  119. package/packages/bundled-features/src/workflow-runner/feature.ts +1 -1
  120. package/samples/apps/ui-walkthrough/src/run-config.ts +14 -1
  121. package/samples/apps/use-all-bundled/README.md +3 -13
  122. package/samples/apps/use-all-bundled/feature-manifest.json +3 -3
  123. package/samples/apps/use-all-bundled/scripts/check-coverage.ts +34 -118
  124. package/samples/apps/use-all-bundled/src/app/notes-feature.ts +1 -1
  125. package/samples/apps/use-all-bundled/src/run-config.ts +0 -3
  126. package/samples/recipes/auth-mfa/README.md +2 -1
  127. package/samples/recipes/auth-mfa/src/feature.ts +1 -0
  128. package/samples/recipes/custom-handlers/README.md +4 -3
  129. package/samples/recipes/custom-handlers/src/feature.ts +21 -12
  130. package/samples/recipes/dashboard-stats/README.md +38 -0
  131. package/samples/recipes/dashboard-stats/package.json +8 -0
  132. package/samples/recipes/dashboard-stats/src/feature.ts +104 -0
  133. package/samples/recipes/legal-pages/README.md +2 -2
  134. package/samples/recipes/managed-pages/README.md +2 -2
  135. package/samples/recipes/pdf-audit-export/README.md +2 -1
  136. package/samples/recipes/pdf-audit-export/package.json +1 -1
  137. package/samples/recipes/pdf-audit-export/src/feature.ts +68 -48
  138. package/samples/recipes/workflow-engine/src/db/queries/suspended-runs.ts +2 -2
  139. package/samples/apps/use-all-bundled/schema/generate.ts +0 -617
  140. package/samples/apps/use-all-bundled/schema/migrations/meta/0000_snapshot.json +0 -3055
  141. package/samples/apps/use-all-bundled/schema/migrations/meta/_journal.json +0 -13
  142. package/samples/apps/use-all-bundled/schema/schema.generated.ts +0 -94
  143. package/samples/apps/use-all-bundled/schema/schema.ts +0 -9
@@ -27,6 +27,7 @@ import { createTemplateResolverApi, TemplateNotFoundError, type TemplateResource
27
27
  import { collectDocumentResourceKeys } from "./compile-document-body.js";
28
28
  import {
29
29
  DEFAULT_MAIL_LAYOUT_SLUG,
30
+ DEFAULT_PDF_LAYOUT_SLUG,
30
31
  MAIL_PREVIEW_MAX_BYTES,
31
32
  SYSTEM_TENANT_ID,
32
33
  type TemplateScope,
@@ -54,7 +55,6 @@ import {
54
55
  const SAMPLE_LINK = "https://example.com";
55
56
  const SAMPLE_DATE = "2026-01-15";
56
57
  const SAMPLE_LIST_ROWS = 3;
57
- const DEFAULT_PDF_LAYOUT_SLUG = "default";
58
58
 
59
59
  function sampleScalar(name: string, def: TemplateScalarVariableDef): unknown {
60
60
  if (def.example !== undefined) return def.example;
@@ -6,8 +6,7 @@ import type {
6
6
  WriteResult,
7
7
  } from "@cosmicdrift/kumiko-framework/engine";
8
8
  import { NotFoundError, writeFailure } from "@cosmicdrift/kumiko-framework/errors";
9
- import { createFileRefFromBytes, fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
10
- import { fileContextForTenant } from "../file-foundation/index.js";
9
+ import { fileRefsTable } from "@cosmicdrift/kumiko-framework/files";
11
10
  import { requireForTenant } from "../shared/index.js";
12
11
  import { executor } from "./handlers/shared.js";
13
12
  import type { LinkedResourceEntry } from "./linked-resources.js";
@@ -42,48 +41,8 @@ export async function readTemplateResource(
42
41
  return { fileName: row.fileName, contentType: row.mimeType, bytes: await handle.read() };
43
42
  }
44
43
 
45
- // Gives the target tenant its own copy of every file behind the entries, so a template that was
46
- // copied from the system default keeps working once the tenant exports it or measures its
47
- // attachments. Rendered-PDF links carry no file and pass through. An entry whose source file is
48
- // gone is left out: it could not be resolved at render time either.
49
- export async function duplicateLinkedResourceFiles(
50
- ctx: HandlerContext,
51
- args: {
52
- readonly user: SessionUser;
53
- readonly tenantId: TenantId;
54
- readonly resources: Readonly<Record<string, LinkedResourceEntry>>;
55
- },
56
- ): Promise<WriteResult<Record<string, LinkedResourceEntry>>> {
57
- const { user, tenantId } = args;
58
- const { db } = requireForTenant(ctx, tenantId);
59
- const copies: Record<string, LinkedResourceEntry> = {};
60
- for (const [key, entry] of Object.entries(args.resources)) {
61
- if (typeof entry !== "string" && entry.kind === "rendered-pdf") {
62
- copies[key] = entry;
63
- continue;
64
- }
65
- const sourceFileRefId = typeof entry === "string" ? entry : entry.fileRefId;
66
- const file = await readTemplateResource(ctx, sourceFileRefId);
67
- // skip: the source file is gone, there is nothing to copy
68
- if (file === null) continue;
69
- const created = await createFileRefFromBytes(
70
- {
71
- db,
72
- files: fileContextForTenant(ctx, ctx.user.tenantId, tenantId),
73
- registry: ctx.registry,
74
- user: { ...user, tenantId },
75
- },
76
- { fileName: file.fileName, mimeType: file.contentType, data: file.bytes },
77
- );
78
- if (!created.isSuccess) return created;
79
- copies[key] =
80
- typeof entry === "string" ? created.data.id : { ...entry, fileRefId: created.data.id };
81
- }
82
- return { isSuccess: true, data: copies };
83
- }
84
-
85
44
  // Replaces the whole key map of one row with files that are already stored (bundle import,
86
- // copy-locale). Single keys go through storeTemplateResource and resource-remove.
45
+ // copy-locale). Single keys go through storePreparedTemplateResource and resource-remove.
87
46
  export async function attachTemplateResources(
88
47
  ctx: Pick<HandlerContext, "forTenant">,
89
48
  args: {
@@ -128,12 +128,10 @@ function isKnownName(
128
128
  ): Resolution {
129
129
  const { variables } = scope;
130
130
  const root = rootName(name);
131
- if (Object.hasOwn(itemFields, name) || Object.hasOwn(itemFields, root)) {
132
- return { known: true, used: "" };
133
- }
131
+ // Declared variables and item fields are scalars: a dotted path under one never renders a value.
132
+ if (Object.hasOwn(itemFields, name)) return { known: true, used: "" };
134
133
  if (scope.systemPlaceholders.has(root)) return { known: true, used: "" };
135
134
  if (Object.hasOwn(variables, name)) return { known: true, used: name };
136
- if (Object.hasOwn(variables, root)) return { known: true, used: root };
137
135
  return { known: false, used: "" };
138
136
  }
139
137
 
@@ -151,6 +149,11 @@ function enclosingEachName(element: ScanElement): string | null {
151
149
  return element.closest("tr[data-each]")?.getAttribute("data-each") ?? null;
152
150
  }
153
151
 
152
+ const NAME_ATTRIBUTE_BY_TAG: Readonly<Record<string, string>> = {
153
+ span: "data-variable",
154
+ div: "data-steps",
155
+ };
156
+
154
157
  function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
155
158
  withParsedDocument(body, (root) => {
156
159
  for (const element of Array.from(root.querySelectorAll("tr[data-each]"))) {
@@ -160,12 +163,13 @@ function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
160
163
  else scope.unknown.add(listName);
161
164
  }
162
165
  for (const element of Array.from(
163
- root.querySelectorAll("span[data-variable], a[href], img[data-resource]"),
166
+ root.querySelectorAll("span[data-variable], div[data-steps], a[href], img[data-resource]"),
164
167
  )) {
165
168
  const itemFields = listItemFields(scope.variables, enclosingEachName(element));
166
169
  const tag = element.tagName.toLowerCase();
167
- if (tag === "span") {
168
- recordName(element.getAttribute("data-variable") ?? "", itemFields, scope);
170
+ const nameAttribute = NAME_ATTRIBUTE_BY_TAG[tag];
171
+ if (nameAttribute !== undefined) {
172
+ recordName(element.getAttribute(nameAttribute) ?? "", itemFields, scope);
169
173
  } else if (tag === "a") {
170
174
  const href = element.getAttribute("href") ?? "";
171
175
  if (isDocumentPlaceholderHref(href)) recordName(href.slice(2, -2), itemFields, scope);
@@ -179,7 +183,7 @@ function scanBodyPlaceholders(body: string, scope: PlaceholderScope): void {
179
183
 
180
184
  const HANDLEBARS_EXPRESSION = /\{\{([^{}]*)\}\}/g;
181
185
 
182
- // Meta text supports only plain `{{name}}` / `{{name.field}}`; every other
186
+ // Meta text supports only plain `{{name}}` references; every other
183
187
  // Handlebars construct is reported as unknown so it can never reach the compiler.
184
188
  function scanMetaPlaceholders(text: string, scope: PlaceholderScope): void {
185
189
  const leftover = text.replace(HANDLEBARS_EXPRESSION, (_match, inner: string) => {
@@ -193,7 +197,9 @@ function scanMetaPlaceholders(text: string, scope: PlaceholderScope): void {
193
197
 
194
198
  function metaTexts(meta: TemplateMeta | null): readonly string[] {
195
199
  if (meta === null || !("subject" in meta)) return [];
196
- return [meta.subject, ...(meta.previewText === undefined ? [] : [meta.previewText])];
200
+ return [meta.subject, meta.previewText, meta.headerChip, meta.tone, meta.footerNote].filter(
201
+ (text): text is string => text !== undefined,
202
+ );
197
203
  }
198
204
 
199
205
  export function validateTemplatePlaceholders(input: {
@@ -7,9 +7,11 @@ import {
7
7
  DocumentButton,
8
8
  DocumentPageBreak,
9
9
  DocumentResource,
10
+ DocumentSteps,
10
11
  DocumentVariable,
11
12
  documentExtensions,
12
13
  } from "../document-schema.js";
14
+ import { readDocumentStepsAttribute } from "../document-steps.js";
13
15
  import type { DocumentResourcePreview } from "./document-toolbar-blocks.js";
14
16
 
15
17
  const DOCUMENT_RESOURCE_SIZE_TOKEN = "{size}";
@@ -20,6 +22,7 @@ export type DocumentEditorLabels = {
20
22
  readonly image: string;
21
23
  readonly pageBreak: string;
22
24
  readonly button?: string;
25
+ readonly steps?: string;
23
26
  /** Canvas block label of an image, e.g. "Image · inline". */
24
27
  readonly imageBlock?: string;
25
28
  readonly variableLabels?: Readonly<Record<string, string>>;
@@ -175,6 +178,35 @@ function createPageBreakView(pageBreakLabel: string): NodeViewRenderer {
175
178
  };
176
179
  }
177
180
 
181
+ // A static sketch of the row: which stage is reached is only known at render time.
182
+ function createStepsView(labels: DocumentEditorLabels): NodeViewRenderer {
183
+ return ({ node }) => {
184
+ const dom = createElementWithClass(
185
+ "div",
186
+ "relative my-3 rounded border border-dashed border-input px-3 pb-2 pt-3",
187
+ );
188
+ dom.setAttribute(DOCUMENT_BLOCK_ATTRIBUTE, "");
189
+ dom.contentEditable = "false";
190
+ const variable = String(node.attrs["variable"]);
191
+ const variableLabels = labels.variableLabels ?? {};
192
+ const variableLabel = Object.hasOwn(variableLabels, variable)
193
+ ? variableLabels[variable]
194
+ : undefined;
195
+ dom.append(createBlockLabel(`${labels.steps ?? "Steps"} · ${variableLabel ?? variable}`));
196
+ const row = createElementWithClass("div", "flex gap-1.5");
197
+ for (const step of readDocumentStepsAttribute(node.attrs["steps"])) {
198
+ const cell = createElementWithClass("div", "min-w-0 flex-1");
199
+ cell.append(
200
+ createElementWithClass("div", "h-1 rounded-full bg-primary/40"),
201
+ createElementWithClass("div", "truncate pt-1.5 text-xs text-muted-foreground", step.label),
202
+ );
203
+ row.append(cell);
204
+ }
205
+ dom.append(row);
206
+ return { dom };
207
+ };
208
+ }
209
+
178
210
  // Replaced by name, not appended: the schema must keep exactly one node per
179
211
  // name, and only the editor view differs from the shared document schema.
180
212
  export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Extensions {
@@ -195,6 +227,8 @@ export function createDocumentEditorExtensions(labels: DocumentEditorLabels): Ex
195
227
  return labels.blockLabels
196
228
  ? DocumentButton.extend({ addNodeView: () => createButtonView(labels.button ?? "") })
197
229
  : extension;
230
+ case DocumentSteps.name:
231
+ return DocumentSteps.extend({ addNodeView: () => createStepsView(labels) });
198
232
  case DocumentPageBreak.name:
199
233
  return DocumentPageBreak.extend({
200
234
  addNodeView: () => createPageBreakView(labels.pageBreak),
@@ -21,13 +21,14 @@ export function DocumentContentEditor(props: DocumentEditorProps): ReactNode {
21
21
  const editorProps = props.blocks === undefined ? { ...props, blocks: contextBlocks } : props;
22
22
  return (
23
23
  <Suspense
24
+ // Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
24
25
  fallback={
25
26
  <TextareaContentEditor
26
27
  id={props.id}
27
28
  value={props.value}
28
29
  onChange={props.onChange}
29
- variables={props.variables}
30
- readOnly={props.readOnly}
30
+ variables={[]}
31
+ readOnly
31
32
  />
32
33
  }
33
34
  >
@@ -29,6 +29,7 @@ import {
29
29
  Italic as ItalicIcon,
30
30
  Link as LinkIcon,
31
31
  List,
32
+ ListChecks,
32
33
  ListOrdered,
33
34
  Minus,
34
35
  MousePointerClick,
@@ -47,6 +48,7 @@ import {
47
48
  isDocumentReferenceName,
48
49
  isDocumentResourceKey,
49
50
  } from "../document-schema.js";
51
+ import { parseDocumentStepsInput } from "../document-steps.js";
50
52
  import {
51
53
  createDocumentEditorExtensions,
52
54
  DOCUMENT_BLOCK_ATTRIBUTE,
@@ -70,7 +72,7 @@ function previewsKey(previews: Readonly<Record<string, DocumentResourcePreview>>
70
72
  .join("\n");
71
73
  }
72
74
 
73
- type PanelKind = "link" | "placeholder" | "image" | "button" | "repeat" | "align";
75
+ type PanelKind = "link" | "placeholder" | "image" | "button" | "repeat" | "align" | "steps";
74
76
 
75
77
  // Blocks that stay in the canvas toolbar; the rest moves into the overflow menu.
76
78
  const CANVAS_INLINE_BLOCKS: readonly DocumentToolbarBlock[] = ["image", "table"];
@@ -80,6 +82,7 @@ const BLOCK_PANEL: Partial<Record<DocumentToolbarBlock, PanelKind>> = {
80
82
  button: "button",
81
83
  repeat: "repeat",
82
84
  align: "align",
85
+ steps: "steps",
83
86
  };
84
87
 
85
88
  const ALIGNMENT_ICONS: Record<DocumentTextAlignment, ReactNode> = {
@@ -315,6 +318,9 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
315
318
  const [buttonText, setButtonText] = useState(() =>
316
319
  t("template-resolver.documentEditor.buttonDefaultText"),
317
320
  );
321
+ const [stepsText, setStepsText] = useState(() =>
322
+ t("template-resolver.documentEditor.stepsDefault"),
323
+ );
318
324
  const [chosenName, setChosenName] = useState("");
319
325
  const [error, setError] = useState<string | null>(null);
320
326
 
@@ -364,7 +370,17 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
364
370
  insertAndClose({ type: "resource", attrs: { resource, alt: alt || null } });
365
371
  };
366
372
 
367
- const needsVariables = kind === "button" || kind === "repeat" || kind === "placeholder";
373
+ const submitSteps = (): void => {
374
+ const steps = parseDocumentStepsInput(stepsText);
375
+ if (steps === null) {
376
+ setError(t("template-resolver.documentEditor.invalidSteps"));
377
+ return;
378
+ }
379
+ insertAndClose({ type: "steps", attrs: { variable: selectedName, steps } });
380
+ };
381
+
382
+ const needsVariables =
383
+ kind === "button" || kind === "repeat" || kind === "placeholder" || kind === "steps";
368
384
  const noVariables = needsVariables && selectable.length === 0;
369
385
  const idPrefix = `${editorId}-panel`;
370
386
 
@@ -467,6 +483,29 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
467
483
  onChange={setChosenName}
468
484
  />
469
485
  )}
486
+ {kind === "steps" && !noVariables && (
487
+ <>
488
+ <NameSelect
489
+ id={`${idPrefix}-steps-variable`}
490
+ label={t("template-resolver.documentEditor.stepsVariable")}
491
+ names={selectable}
492
+ value={selectedName}
493
+ onChange={setChosenName}
494
+ />
495
+ <Field
496
+ id={`${idPrefix}-steps-list`}
497
+ label={t("template-resolver.documentEditor.stepsList")}
498
+ >
499
+ <Input
500
+ kind="text"
501
+ id={`${idPrefix}-steps-list`}
502
+ name="steps"
503
+ value={stepsText}
504
+ onChange={setStepsText}
505
+ />
506
+ </Field>
507
+ </>
508
+ )}
470
509
  {error !== null && (
471
510
  <p role="alert" className="text-sm text-destructive">
472
511
  {error}
@@ -495,6 +534,7 @@ function InsertPanel({ editor, editorId, kind, variables, onClose }: PanelProps)
495
534
  })}
496
535
  {...(kind === "repeat" &&
497
536
  !noVariables && { onInsert: () => insertAndClose(repeatTableContent(selectedName)) })}
537
+ {...(kind === "steps" && !noVariables && { onInsert: submitSteps })}
498
538
  />
499
539
  </div>
500
540
  </div>
@@ -531,6 +571,7 @@ export default function DocumentTiptapEditor({
531
571
  const imageBlockLabel = t("template-resolver.documentEditor.imageBlock");
532
572
  const pageBreakLabel = t("template-resolver.documentEditor.pageBreak");
533
573
  const buttonLabel = t("template-resolver.documentEditor.button");
574
+ const stepsLabel = t("template-resolver.documentEditor.steps");
534
575
  const stableLabels = useStableByKey(variableLabels, JSON.stringify(variableLabels));
535
576
  const stableSystem = useStableByKey(systemVariables, systemVariables.join("\n"));
536
577
  const stablePreviews = useStableByKey(resourcePreviews, previewsKey(resourcePreviews));
@@ -541,6 +582,7 @@ export default function DocumentTiptapEditor({
541
582
  image: imageLabel,
542
583
  pageBreak: pageBreakLabel,
543
584
  button: buttonLabel,
585
+ steps: stepsLabel,
544
586
  imageBlock: imageBlockLabel,
545
587
  variableLabels: stableLabels,
546
588
  systemVariables: stableSystem,
@@ -553,6 +595,7 @@ export default function DocumentTiptapEditor({
553
595
  imageBlockLabel,
554
596
  pageBreakLabel,
555
597
  buttonLabel,
598
+ stepsLabel,
556
599
  stableLabels,
557
600
  stableSystem,
558
601
  stablePreviews,
@@ -573,6 +616,7 @@ export default function DocumentTiptapEditor({
573
616
  immediatelyRender: false,
574
617
  editorProps: { attributes: { id } },
575
618
  onCreate: ({ editor: created }) => onEditorReady?.(createEditorHandle(created)),
619
+ onDestroy: () => onEditorReady?.(null),
576
620
  onUpdate: ({ editor: e }) => onChange(e.getHTML()),
577
621
  },
578
622
  [extensions],
@@ -614,27 +658,27 @@ export default function DocumentTiptapEditor({
614
658
 
615
659
  // The entry loads asynchronously, so `value` arrives after useEditor read
616
660
  // its initial content; the guard keeps in-flight typing from being clobbered.
661
+ // When `extensions` change, useEditor's own effect destroys this render's
662
+ // editor earlier in the same commit (a late validate result in the layout
663
+ // editor does that); reading it then throws and unmounts the whole app. The
664
+ // replacement editor re-renders and syncs `value` itself.
617
665
  // kumiko-lint-ignore no-raw-hooks DOM integration: syncs the controlled value into the imperative tiptap editor
618
666
  useEffect(() => {
619
- if (editor && value !== editor.getHTML()) {
667
+ if (editor && !editor.isDestroyed && value !== editor.getHTML()) {
620
668
  editor.commands.setContent(value, { emitUpdate: false });
621
669
  }
622
670
  }, [value, editor]);
623
671
 
624
672
  // kumiko-lint-ignore no-raw-hooks DOM integration: tiptap exposes editability only imperatively via setEditable
625
673
  useEffect(() => {
626
- editor?.setEditable(!readOnly);
674
+ // setEditable emits "update" by default, which would report the mount as an edit.
675
+ if (editor && !editor.isDestroyed) editor.setEditable(!readOnly, false);
627
676
  }, [readOnly, editor]);
628
677
 
678
+ // Read-only without chips: a textarea would insert {{name}} text and plaintext into a document body.
629
679
  if (!editor)
630
680
  return (
631
- <TextareaContentEditor
632
- id={id}
633
- value={value}
634
- onChange={onChange}
635
- variables={variables}
636
- readOnly={readOnly}
637
- />
681
+ <TextareaContentEditor id={id} value={value} onChange={onChange} variables={[]} readOnly />
638
682
  );
639
683
 
640
684
  const togglePanel = (kind: PanelKind): void =>
@@ -670,6 +714,7 @@ export default function DocumentTiptapEditor({
670
714
  repeat: { label: t("template-resolver.documentEditor.repeat"), icon: <Repeat size={16} /> },
671
715
  pageBreak: { label: pageBreakLabel, icon: <Scissors size={16} /> },
672
716
  align: { label: t("template-resolver.documentEditor.align"), icon: <AlignLeft size={16} /> },
717
+ steps: { label: stepsLabel, icon: <ListChecks size={16} /> },
673
718
  };
674
719
 
675
720
  const repeatTable = activeState?.repeatTable ?? null;
@@ -9,7 +9,8 @@ export type DocumentToolbarBlock =
9
9
  | "divider"
10
10
  | "repeat"
11
11
  | "pageBreak"
12
- | "align";
12
+ | "align"
13
+ | "steps";
13
14
 
14
15
  /** Imperative access for an outside panel (placeholder list, resource list). */
15
16
  export type DocumentEditorHandle = {
@@ -45,8 +46,8 @@ export type DocumentEditorProps = ContentEditorProps & {
45
46
  /** Canvas only: renders the toolbar (and its insert panel) into this element instead of above the editor;
46
47
  * null renders none. Lets several region editors share one toolbar that follows the focused one. */
47
48
  readonly toolbarHost?: HTMLElement | null;
48
- /** Called with the handle once the editor exists and with null when it is destroyed. */
49
- readonly onEditorReady?: (handle: DocumentEditorHandle) => void;
49
+ /** Called with the handle once the editor exists and with null when it is destroyed (unmount or rebuild), so a held handle never outlives its editor. */
50
+ readonly onEditorReady?: (handle: DocumentEditorHandle | null) => void;
50
51
  };
51
52
 
52
53
  export const MAIL_DOCUMENT_BLOCKS = [
@@ -55,6 +56,7 @@ export const MAIL_DOCUMENT_BLOCKS = [
55
56
  "button",
56
57
  "divider",
57
58
  "repeat",
59
+ "steps",
58
60
  ] as const satisfies readonly DocumentToolbarBlock[];
59
61
 
60
62
  export const PDF_DOCUMENT_BLOCKS = [
@@ -63,6 +65,7 @@ export const PDF_DOCUMENT_BLOCKS = [
63
65
  "table",
64
66
  "divider",
65
67
  "repeat",
68
+ "steps",
66
69
  "pageBreak",
67
70
  ] as const satisfies readonly DocumentToolbarBlock[];
68
71
 
@@ -43,6 +43,13 @@ 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.steps": "Steps",
47
+ "template-resolver.documentEditor.stepsVariable": "Current step from",
48
+ "template-resolver.documentEditor.stepsList": "Steps, separated by commas (value=label)",
49
+ "template-resolver.documentEditor.stepsDefault":
50
+ "reported=Reported, investigating=Investigating, resolved=Resolved",
51
+ "template-resolver.documentEditor.invalidSteps":
52
+ "Enter 1 to 8 different steps; values use letters, digits, - and _.",
46
53
  "template-resolver.documentEditor.resourceKey": "Resource key",
47
54
  "template-resolver.documentEditor.table": "Table",
48
55
  "template-resolver.editor.titleLabel": "Title",
@@ -5,6 +5,7 @@
5
5
  // `@cosmicdrift/kumiko-bundled-features/template-resolver` and has no React or
6
6
  // DOM deps.
7
7
 
8
+ export { MAIL_TONES, type MailTone } from "../mail-constants.js";
8
9
  export {
9
10
  PDF_DEFAULT_MARGIN_MM,
10
11
  PDF_FOOTER_MARGIN_MM,
@@ -6,7 +6,7 @@
6
6
 
7
7
  import { cn } from "@cosmicdrift/kumiko-renderer-web";
8
8
  import { Extension, Node } from "@tiptap/core";
9
- import { UndoRedo } from "@tiptap/extensions";
9
+ import { Placeholder, UndoRedo } from "@tiptap/extensions";
10
10
  import { EditorContent, useEditor } from "@tiptap/react";
11
11
  import StarterKit from "@tiptap/starter-kit";
12
12
  import { type ReactNode, useMemo, useState } from "react";
@@ -28,7 +28,9 @@ export type PlaceholderLineEditorProps = {
28
28
  readonly readOnly?: boolean;
29
29
  /** Drops the input frame so the line sits flat inside a label/value row. */
30
30
  readonly flat?: boolean;
31
- readonly onEditorReady?: (handle: DocumentEditorHandle) => void;
31
+ /** Hint shown while the line is empty. */
32
+ readonly placeholder?: string;
33
+ readonly onEditorReady?: (handle: DocumentEditorHandle | null) => void;
32
34
  readonly "aria-label": string;
33
35
  };
34
36
 
@@ -53,6 +55,7 @@ function LineEditorBody({
53
55
  systemVariables,
54
56
  readOnly,
55
57
  flat,
58
+ placeholder,
56
59
  onEditorReady,
57
60
  ariaLabel,
58
61
  }: {
@@ -63,7 +66,8 @@ function LineEditorBody({
63
66
  readonly systemVariables: readonly string[];
64
67
  readonly readOnly: boolean;
65
68
  readonly flat: boolean;
66
- readonly onEditorReady: ((handle: DocumentEditorHandle) => void) | undefined;
69
+ readonly placeholder: string | undefined;
70
+ readonly onEditorReady: ((handle: DocumentEditorHandle | null) => void) | undefined;
67
71
  readonly ariaLabel: string;
68
72
  }): ReactNode {
69
73
  const extensions = useMemo(
@@ -96,8 +100,9 @@ function LineEditorBody({
96
100
  }),
97
101
  UndoRedo,
98
102
  BlockLineBreaks,
103
+ ...(placeholder === undefined ? [] : [Placeholder.configure({ placeholder })]),
99
104
  ],
100
- [variableLabels, systemVariables, flat],
105
+ [variableLabels, systemVariables, flat, placeholder],
101
106
  );
102
107
 
103
108
  const editor = useEditor({
@@ -115,6 +120,7 @@ function LineEditorBody({
115
120
  transformPastedHTML: (html) => singleLine(html.replace(/<[^>]*>/g, " ")),
116
121
  },
117
122
  onCreate: ({ editor: created }) => onEditorReady?.(createEditorHandle(created)),
123
+ onDestroy: () => onEditorReady?.(null),
118
124
  onUpdate: ({ editor: e }) => onChange(contentToLine(e.getJSON().content?.[0]?.content)),
119
125
  });
120
126
 
@@ -126,6 +132,7 @@ function LineEditorBody({
126
132
  flat ? "min-h-10" : "min-h-9 rounded-md border border-input px-3",
127
133
  "[&_.ProseMirror]:w-full [&_.ProseMirror]:whitespace-nowrap [&_.ProseMirror]:outline-none",
128
134
  "[&_p]:m-0",
135
+ "[&_.is-editor-empty]:before:pointer-events-none [&_.is-editor-empty]:before:float-left [&_.is-editor-empty]:before:h-0 [&_.is-editor-empty]:before:text-muted-foreground [&_.is-editor-empty]:before:content-[attr(data-placeholder)]",
129
136
  readOnly && "opacity-60",
130
137
  )}
131
138
  />
@@ -140,6 +147,7 @@ export default function PlaceholderLineEditor({
140
147
  systemVariables = NO_NAMES,
141
148
  readOnly = false,
142
149
  flat = false,
150
+ placeholder,
143
151
  onEditorReady,
144
152
  "aria-label": ariaLabel,
145
153
  }: PlaceholderLineEditorProps): ReactNode {
@@ -158,7 +166,7 @@ export default function PlaceholderLineEditor({
158
166
 
159
167
  return (
160
168
  <LineEditorBody
161
- key={`${epoch}-${readOnly}-${JSON.stringify(stableLabels)}`}
169
+ key={`${epoch}-${readOnly}-${JSON.stringify(stableLabels)}-${stableSystem.join("\n")}`}
162
170
  id={id}
163
171
  initialValue={value}
164
172
  onChange={(next) => {
@@ -169,6 +177,7 @@ export default function PlaceholderLineEditor({
169
177
  systemVariables={stableSystem}
170
178
  readOnly={readOnly}
171
179
  flat={flat}
180
+ placeholder={placeholder}
172
181
  onEditorReady={onEditorReady}
173
182
  ariaLabel={ariaLabel}
174
183
  />
@@ -1,4 +1,10 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "fix",
5
+ "title": "Post-save seed hooks use the after-commit notify factory",
6
+ "detail": "afterCommit seed hooks now get a notify function bound to the signup dispatcher's job runner when the dispatcher provides one, falling back to the regular factory. The afterCommit hook context is built only when the hook actually runs."
7
+ },
2
8
  {
3
9
  "version": "0.357.0",
4
10
  "type": "improvement",
@@ -9,7 +9,6 @@ import {
9
9
  mayOverrideTenant,
10
10
  mergeAssignedRoles,
11
11
  parseTenantId,
12
- type SessionUser,
13
12
  SYSTEM_ROLE,
14
13
  type TenantId,
15
14
  withResponseData,
@@ -50,10 +49,6 @@ function resolveTargetTenantId(
50
49
  return parseTenantId(payloadTenantId) ?? undefined;
51
50
  }
52
51
 
53
- function foreignStreamTenant(user: SessionUser, targetTenantId: TenantId): TenantId | undefined {
54
- return targetTenantId === user.tenantId ? undefined : targetTenantId;
55
- }
56
-
57
52
  // Editing your OWN roles keeps the current session: roles are re-derived per
58
53
  // request, so the new roles apply at once without a re-login. Other sessions
59
54
  // of that user and any foreign edit still revoke all.
@@ -195,8 +190,8 @@ export const updateMemberRolesWrite = defineWriteHandler({
195
190
  }
196
191
 
197
192
  // The membership stream lives on the membership's tenant (where seedTenantMembership/
198
- // invite-accept wrote it); a cross-tenant operator addresses it without becoming a member.
199
- // Same-tenant writes run on a tenant-mode db, which refuses a stream override.
193
+ // invite-accept wrote it); streamTenantId addresses it with the operator as actor, also
194
+ // without membership there.
200
195
  const result = await executor.update(
201
196
  {
202
197
  id: row["id"] as string, // @cast-boundary db-row
@@ -205,7 +200,7 @@ export const updateMemberRolesWrite = defineWriteHandler({
205
200
  },
206
201
  event.user,
207
202
  db,
208
- { streamTenantId: foreignStreamTenant(event.user, targetTenantId) },
203
+ { streamTenantId: targetTenantId },
209
204
  );
210
205
 
211
206
  return withResponseData(result, {