@cosmicdrift/kumiko-document-designer 0.364.0 → 0.365.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/i18n.js CHANGED
@@ -469,6 +469,10 @@ export const DOCUMENT_DESIGNER_I18N = {
469
469
  en: "Preview text",
470
470
  de: "Vorschautext",
471
471
  },
472
+ "documentDesigner.mail.meta.previewTextPlaceholder": {
473
+ en: "Short line shown next to the subject in the inbox",
474
+ de: "Kurze Zeile, die im Posteingang neben dem Betreff erscheint",
475
+ },
472
476
  "documentDesigner.mail.props.title": {
473
477
  en: "Properties",
474
478
  de: "Eigenschaften",
@@ -477,6 +481,10 @@ export const DOCUMENT_DESIGNER_I18N = {
477
481
  en: "Layout",
478
482
  de: "Layout",
479
483
  },
484
+ "documentDesigner.mail.props.layoutBuiltIn": {
485
+ en: "Standard layout (built in)",
486
+ de: "Standard-Layout (eingebaut)",
487
+ },
480
488
  "documentDesigner.mail.props.layoutTenant": {
481
489
  en: "{title} (tenant customisation v{version})",
482
490
  de: "{title} (Mandanten-Anpassung v{version})",
@@ -521,6 +529,62 @@ export const DOCUMENT_DESIGNER_I18N = {
521
529
  en: 'Transactional mails need no unsubscribe link. With "Marketing" the layout must provide one.',
522
530
  de: "Transaktionale Mails brauchen keinen Abmeldelink. Bei „Marketing“ muss das Layout den Abmeldelink liefern.",
523
531
  },
532
+ "documentDesigner.mail.chrome.title": {
533
+ en: "Header and footer",
534
+ de: "Kopf und Fuß",
535
+ },
536
+ "documentDesigner.mail.chrome.headerChip": {
537
+ en: "Label in the header",
538
+ de: "Kennzeichen im Kopf",
539
+ },
540
+ "documentDesigner.mail.chrome.headerChipPlaceholder": {
541
+ en: "e.g. New incident",
542
+ de: "z. B. Neuer Incident",
543
+ },
544
+ "documentDesigner.mail.chrome.tone": {
545
+ en: "Tone",
546
+ de: "Farbton",
547
+ },
548
+ "documentDesigner.mail.chrome.toneAccent": {
549
+ en: "Accent colour (default)",
550
+ de: "Akzentfarbe (Standard)",
551
+ },
552
+ "documentDesigner.mail.chrome.toneInfo": {
553
+ en: "Info (blue grey)",
554
+ de: "Info (blaugrau)",
555
+ },
556
+ "documentDesigner.mail.chrome.toneSuccess": {
557
+ en: "Success (green)",
558
+ de: "Erfolg (grün)",
559
+ },
560
+ "documentDesigner.mail.chrome.toneWarning": {
561
+ en: "Warning (amber)",
562
+ de: "Warnung (gelb)",
563
+ },
564
+ "documentDesigner.mail.chrome.toneDanger": {
565
+ en: "Danger (red)",
566
+ de: "Gefahr (rot)",
567
+ },
568
+ "documentDesigner.mail.chrome.toneNeutral": {
569
+ en: "Neutral (grey)",
570
+ de: "Neutral (grau)",
571
+ },
572
+ "documentDesigner.mail.chrome.toneFromPlaceholder": {
573
+ en: "From placeholder: {value}",
574
+ de: "Aus Platzhalter: {value}",
575
+ },
576
+ "documentDesigner.mail.chrome.footerNote": {
577
+ en: "Note in the footer",
578
+ de: "Hinweis im Fuß",
579
+ },
580
+ "documentDesigner.mail.chrome.footerNotePlaceholder": {
581
+ en: "e.g. why the recipient gets this mail",
582
+ de: "z. B. warum der Empfänger diese Mail bekommt",
583
+ },
584
+ "documentDesigner.mail.chrome.hint": {
585
+ en: "The label sits right of the logo; the tone colours it and the line at the top. The note stands above the website and unsubscribe links. Layouts with their own MJML code show neither.",
586
+ de: "Das Kennzeichen steht rechts neben dem Logo, der Farbton färbt es und die Linie oben. Der Hinweis steht über den Links zur Website und zum Abmelden. Layouts mit eigenem MJML-Code zeigen beides nicht.",
587
+ },
524
588
  "documentDesigner.mail.attachments.title": {
525
589
  en: "Attachments",
526
590
  de: "Anhänge",
package/dist/web/cells.js CHANGED
@@ -6,14 +6,15 @@ import { formatWhen, useQuery, useTranslation, } from "@cosmicdrift/kumiko-rende
6
6
  import { StatusBadge } from "@cosmicdrift/kumiko-renderer-web";
7
7
  import { parseOverviewRow } from "./overview-row.js";
8
8
  import { statusLabel, statusTone } from "./status-label.js";
9
- import { usedByLabel } from "./used-by-label.js";
9
+ import { templateTitle, usedByLabel } from "./used-by-label.js";
10
10
  const EMPTY_MARK = "–";
11
11
  function withRow(row, render) {
12
12
  const parsed = parseOverviewRow(row);
13
13
  return parsed === null ? EMPTY_MARK : render(parsed);
14
14
  }
15
15
  export function TitleCell({ row }) {
16
- return withRow(row, (r) => (_jsxs("div", { children: [_jsx("div", { className: "font-medium", children: r.title }), _jsx("span", { className: "block max-w-40 truncate font-mono text-xs text-muted-foreground", title: r.slug, children: r.slug })] })));
16
+ const t = useTranslation();
17
+ return withRow(row, (r) => (_jsxs("div", { children: [_jsx("div", { className: "font-medium", children: templateTitle(t, r) }), _jsx("span", { className: "block max-w-40 truncate font-mono text-xs text-muted-foreground", title: r.slug, children: r.slug })] })));
17
18
  }
18
19
  const KIND_KEY = {
19
20
  mail: "documentDesigner.kind.mail",
@@ -0,0 +1 @@
1
+ export declare function defaultLocaleOf(uiLocale: string, appLocales: readonly string[], rowLocales: readonly string[]): string;
@@ -0,0 +1,15 @@
1
+ // @runtime client
2
+ import { DEFAULT_TEMPLATE_LOCALES } from "../constants.js";
3
+ // The editor opens in the language the user works in when the template has it
4
+ // (or, for a template without any version yet, when the app offers it).
5
+ export function defaultLocaleOf(uiLocale, appLocales, rowLocales) {
6
+ const available = rowLocales.length > 0 ? rowLocales : appLocales;
7
+ const uiLanguage = uiLocale.split("-")[0] ?? uiLocale;
8
+ const fromUi = [uiLocale, uiLanguage].find((candidate) => available.includes(candidate));
9
+ if (fromUi !== undefined)
10
+ return fromUi;
11
+ const preferred = appLocales[0] ?? DEFAULT_TEMPLATE_LOCALES[0] ?? "de";
12
+ if (rowLocales.length === 0 || rowLocales.includes(preferred))
13
+ return preferred;
14
+ return rowLocales[0] ?? preferred;
15
+ }
@@ -3,7 +3,7 @@ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-run
3
3
  import { DocumentContentEditor, MAIL_DOCUMENT_BLOCKS, PDF_DOCUMENT_BLOCKS, TemplateResolverHandlers, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
4
  import { dispatcherErrorText, useMutation, usePrimitives, useQuery, useTranslation, } from "@cosmicdrift/kumiko-renderer";
5
5
  import { cn, ModeSwitch, TextareaField } from "@cosmicdrift/kumiko-renderer-web";
6
- import { useDeferredValue, useState } from "react";
6
+ import { useState } from "react";
7
7
  import { BRANDING_QUERY_QN } from "../constants.js";
8
8
  import { composeLayoutHtml } from "./compose-layout.js";
9
9
  import { EditorHeader } from "./editor-header.js";
@@ -14,6 +14,9 @@ import { AppearanceRail, BrandingRail, MjmlSourceRail, PlaceholderRail, Renderer
14
14
  import { systemLabelMap } from "./mail-draft.js";
15
15
  import { OriginBar } from "./origin-bar.js";
16
16
  import { asRowStatus } from "./status-label.js";
17
+ import { useDebouncedValue } from "./use-debounced-value.js";
18
+ // Server-side validation compiles the whole layout (mjml), so wait for a pause in typing.
19
+ const VALIDATE_DEBOUNCE_MS = 400;
17
20
  const REGION_EDITOR_IDS = {
18
21
  header: "document-designer-region-header",
19
22
  signature: "document-designer-region-signature",
@@ -73,12 +76,12 @@ export function LayoutEditorBody({ kind, slug, isSystemAdmin, source, overviewRo
73
76
  const content = mjmlMode
74
77
  ? mjml
75
78
  : composeLayoutHtml({ header, signature: isMail ? signature : "", footer });
76
- const deferredContent = useDeferredValue(content);
79
+ const debouncedContent = useDebouncedValue(content, VALIDATE_DEBOUNCE_MS);
77
80
  const validation = useQuery(TemplateResolverQueries.validate, {
78
81
  slug,
79
82
  kind,
80
83
  contentFormat: mjmlMode ? "mjml" : "html",
81
- content: deferredContent,
84
+ content: debouncedContent,
82
85
  locale: source.locale,
83
86
  scope: isSystemAdmin ? "system" : "tenant",
84
87
  });
@@ -140,7 +143,8 @@ export function LayoutEditorBody({ kind, slug, isSystemAdmin, source, overviewRo
140
143
  };
141
144
  const accentColor = branding.data?.accentColor ?? "";
142
145
  const showAccentLine = isMail && appearanceEditable && appearance.accentLine === "branding" && isHexColor(accentColor);
143
- const blocks = isMail ? MAIL_DOCUMENT_BLOCKS : PDF_DOCUMENT_BLOCKS;
146
+ // A layout region has no per-mail variables, so a steps block there could never show a stage.
147
+ const blocks = (isMail ? MAIL_DOCUMENT_BLOCKS : PDF_DOCUMENT_BLOCKS).filter((block) => block !== "steps");
144
148
  // The layout's "logo" resource is the branding logo, so the editor shows it instead of a chip.
145
149
  const logoFile = branding.data?.logoFile ?? "";
146
150
  const resourcePreviews = logoFile === ""
@@ -28,7 +28,12 @@ function LayoutEditorLoader({ kind, slug, isSystemAdmin, onKindChange, }) {
28
28
  const data = source.data;
29
29
  const rows = (overview.data?.rows ?? []).flatMap((row) => parseOverviewRow(row) ?? []);
30
30
  const reload = async () => {
31
- await Promise.all([source.refetch(), overview.refetch(), overrides.refetch()]);
31
+ // refetch ignores `enabled`, and tenant-overrides is denied to everyone but the platform admin.
32
+ await Promise.all([
33
+ source.refetch(),
34
+ overview.refetch(),
35
+ ...(isSystemAdmin ? [overrides.refetch()] : []),
36
+ ]);
32
37
  };
33
38
  // A saved or reset layout is a new version: remount so the editors take the stored regions.
34
39
  const bodyKey = [kind, slug, data.tenant?.id, data.tenant?.version, data.system?.version].join("|");
@@ -5,6 +5,9 @@ export const EMPTY_DRAFT = {
5
5
  category: "",
6
6
  senderName: "",
7
7
  replyTo: "",
8
+ headerChip: "",
9
+ tone: "",
10
+ footerNote: "",
8
11
  body: "",
9
12
  };
10
13
  export const PLACEHOLDER_SYMBOL = {
@@ -66,6 +69,9 @@ export function loadedMailOf(row) {
66
69
  category: categoryOf(meta["category"]),
67
70
  senderName: textOf(meta["senderName"]),
68
71
  replyTo: textOf(meta["replyTo"]),
72
+ headerChip: textOf(meta["headerChip"]),
73
+ tone: textOf(meta["tone"]),
74
+ footerNote: textOf(meta["footerNote"]),
69
75
  body: row.content,
70
76
  };
71
77
  }
@@ -77,6 +83,9 @@ export function buildMeta(draft) {
77
83
  ...(draft.category !== "" && { category: draft.category }),
78
84
  ...(draft.senderName !== "" && { senderName: draft.senderName }),
79
85
  ...(draft.replyTo !== "" && { replyTo: draft.replyTo }),
86
+ ...(draft.headerChip !== "" && { headerChip: draft.headerChip }),
87
+ ...(draft.tone !== "" && { tone: draft.tone }),
88
+ ...(draft.footerNote !== "" && { footerNote: draft.footerNote }),
80
89
  };
81
90
  }
82
91
  export function isDraftDirty(current, saved) {
@@ -86,6 +95,9 @@ export function isDraftDirty(current, saved) {
86
95
  current.category !== saved.category ||
87
96
  current.senderName !== saved.senderName ||
88
97
  current.replyTo !== saved.replyTo ||
98
+ current.headerChip !== saved.headerChip ||
99
+ current.tone !== saved.tone ||
100
+ current.footerNote !== saved.footerNote ||
89
101
  current.body !== saved.body);
90
102
  }
91
103
  export function parseLinkedResources(value) {
@@ -8,5 +8,5 @@ function MetaLine({ label, testId, error, onFocusCapture, children, }) {
8
8
  }
9
9
  export function MailEditCenter({ draft, vocabulary, resourcePreviews, insert, readOnly, fieldErrors, onChange, }) {
10
10
  const t = useTranslation();
11
- return (_jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", "data-testid": "mail-editor-center", children: [_jsx(MetaLine, { label: t("documentDesigner.mail.meta.subject"), testId: "mail-meta-subject", error: fieldErrors.subject, onFocusCapture: insert.markFocused("subject"), children: _jsx(PlaceholderLineEditor, { id: "mail-subject", value: draft.subject, onChange: (subject) => onChange({ subject }), variableLabels: vocabulary.labels, systemVariables: vocabulary.system, readOnly: readOnly, flat: true, onEditorReady: insert.register("subject"), "aria-label": t("documentDesigner.mail.meta.subject") }) }), _jsx(MetaLine, { label: t("documentDesigner.mail.meta.previewText"), testId: "mail-meta-preview-text", error: fieldErrors.previewText, onFocusCapture: insert.markFocused("previewText"), children: _jsx(PlaceholderLineEditor, { id: "mail-preview-text", value: draft.previewText, onChange: (previewText) => onChange({ previewText }), variableLabels: vocabulary.labels, systemVariables: vocabulary.system, readOnly: readOnly, flat: true, onEditorReady: insert.register("previewText"), "aria-label": t("documentDesigner.mail.meta.previewText") }) }), _jsx("div", { className: "flex min-h-0 flex-1 flex-col", onFocusCapture: insert.markFocused("body"), "data-testid": "mail-content", children: _jsx(DocumentContentEditor, { id: "mail-body", value: draft.body, onChange: (body) => onChange({ body }), variables: vocabulary.names, readOnly: readOnly, blocks: MAIL_DOCUMENT_BLOCKS, variableLabels: vocabulary.labels, systemVariables: vocabulary.system, resourcePreviews: resourcePreviews, chrome: "canvas", renderPaper: renderMailPaper, onEditorReady: insert.register("body") }) })] }));
11
+ return (_jsxs("div", { className: "flex min-h-0 min-w-0 flex-1 flex-col", "data-testid": "mail-editor-center", children: [_jsx(MetaLine, { label: t("documentDesigner.mail.meta.subject"), testId: "mail-meta-subject", error: fieldErrors.subject, onFocusCapture: insert.markFocused("subject"), children: _jsx(PlaceholderLineEditor, { id: "mail-subject", value: draft.subject, onChange: (subject) => onChange({ subject }), variableLabels: vocabulary.labels, systemVariables: vocabulary.system, readOnly: readOnly, flat: true, onEditorReady: insert.register("subject"), "aria-label": t("documentDesigner.mail.meta.subject") }) }), _jsx(MetaLine, { label: t("documentDesigner.mail.meta.previewText"), testId: "mail-meta-preview-text", error: fieldErrors.previewText, onFocusCapture: insert.markFocused("previewText"), children: _jsx(PlaceholderLineEditor, { id: "mail-preview-text", value: draft.previewText, onChange: (previewText) => onChange({ previewText }), variableLabels: vocabulary.labels, systemVariables: vocabulary.system, readOnly: readOnly, flat: true, placeholder: t("documentDesigner.mail.meta.previewTextPlaceholder"), onEditorReady: insert.register("previewText"), "aria-label": t("documentDesigner.mail.meta.previewText") }) }), _jsx("div", { className: "flex min-h-0 flex-1 flex-col", onFocusCapture: insert.markFocused("body"), "data-testid": "mail-content", children: _jsx(DocumentContentEditor, { id: "mail-body", value: draft.body, onChange: (body) => onChange({ body }), variables: vocabulary.names, readOnly: readOnly, blocks: MAIL_DOCUMENT_BLOCKS, variableLabels: vocabulary.labels, systemVariables: vocabulary.system, resourcePreviews: resourcePreviews, chrome: "canvas", renderPaper: renderMailPaper, onEditorReady: insert.register("body") }) })] }));
12
12
  }
@@ -15,7 +15,7 @@ import { MissingLocaleCanvas } from "./mail-missing-locale.js";
15
15
  import { MailOriginBar } from "./mail-origin-bar.js";
16
16
  import { PdfLinkModal } from "./mail-pdf-link-modal.js";
17
17
  import { MailPreviewMode } from "./mail-preview.js";
18
- import { AttachmentSection, PropertiesSection, UsageSection } from "./mail-right-rail.js";
18
+ import { AttachmentSection, ChromeSection, PropertiesSection, UsageSection, } from "./mail-right-rail.js";
19
19
  import { UploadModal } from "./mail-upload-modal.js";
20
20
  import { VersionsSection } from "./mail-versions.js";
21
21
  import { useInsertTargets } from "./use-insert-targets.js";
@@ -98,9 +98,8 @@ export function MailEditorBody(props) {
98
98
  !busy &&
99
99
  validation?.canPublish === true &&
100
100
  (dirty || creating || row?.status !== "active");
101
- const persist = async (publish) => persistence.save(draft, publish);
102
101
  const saveAndReload = async (publish) => {
103
- if (await persist(publish))
102
+ if ((await persistence.save(draft, publish)) !== "failed")
104
103
  await onReload();
105
104
  };
106
105
  const saveDraft = () => void saveAndReload(false);
@@ -174,7 +173,7 @@ export function MailEditorBody(props) {
174
173
  return (_jsxs(EditorFrame, { testId: "document-designer-mail-editor", children: [_jsx(EditorHeader, { title: title, stored: row === null ? null : { status: row.status, version: row.version }, actions: { busy, canSave, canPublish, onSave: saveDraft, onPublish: publish }, children: _jsx(EditorModeSwitch, { testIdPrefix: "mail", mode: mode, onMode: setMode }) }), mode === "edit" && (_jsx(MailOriginBar, { row: row, systemVersion: props.systemVersion, locale: locale, isSystemAdmin: isSystemAdmin, creating: creating, controls: controls, onReload: () => {
175
174
  setResetError(null);
176
175
  void onReload();
177
- }, onResetError: setResetError })), testMail.state.kind === "sent" && (_jsx(Banner, { variant: "primary", testId: "mail-test-sent", children: t("documentDesigner.mail.testMail.sent", { subject: testMail.state.subject }) })), testMail.state.kind === "error" && (_jsx(Banner, { variant: "error", testId: "mail-test-error", children: testMail.state.text })), resetError !== null && (_jsx(Banner, { variant: "error", testId: "mail-reset-error", children: resetError })), resourceError !== null && (_jsx(Banner, { variant: "error", testId: "mail-resource-error", children: resourceError })), mode === "edit" ? (_jsx(EditorBody, { left: _jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: railValidation, onInsert: insert.insertPlaceholder }), _jsx(SystemPlaceholderSection, { validation: railValidation, onInsert: insert.insertPlaceholder }), _jsx(ResourceSection, { resources: resources, pdfTitles: pdfTitles, canUpload: uploadTemplateId !== null, onUpload: () => setUploadOpen(true), onInsert: insert.insertResource, onRemove: (key) => setDialog({ kind: "remove", key }) })] }), center: locked ? (_jsx(MissingLocaleCanvas, { slug: slug, locale: locale, fallbackLocale: props.fallbackLocale, copyScope: isSystemAdmin ? "system" : "tenant", onCreateBlank: () => setCreating(true), onCopied: () => void onReload() })) : (_jsx(MailEditCenter, { draft: draft, vocabulary: vocabulary, resourcePreviews: resourcePreviews, insert: insert, readOnly: false, fieldErrors: fieldErrors, onChange: update }, editorEpoch)), right: _jsxs(_Fragment, { children: [_jsx(PropertiesSection, { draft: draft, layouts: layouts.rows, onChange: update }), _jsx(AttachmentSection, { attachments: attachments, pdfTitles: pdfTitles, canEdit: uploadTemplateId !== null, onAddPdf: () => setPdfOpen(true), onRemove: (key) => setDialog({ kind: "remove", key }) }), _jsx(UsageSection, { slug: slug }), _jsx(VersionsSection, { rowId: row?.id ?? null, currentVersion: row?.version ?? 0, currentStatus: row?.status ?? "draft" })] }) })) : (_jsx(MailPreviewMode, { payload: {
176
+ }, onResetError: setResetError })), testMail.state.kind === "sent" && (_jsx(Banner, { variant: "primary", testId: "mail-test-sent", children: t("documentDesigner.mail.testMail.sent", { subject: testMail.state.subject }) })), testMail.state.kind === "error" && (_jsx(Banner, { variant: "error", testId: "mail-test-error", children: testMail.state.text })), resetError !== null && (_jsx(Banner, { variant: "error", testId: "mail-reset-error", children: resetError })), resourceError !== null && (_jsx(Banner, { variant: "error", testId: "mail-resource-error", children: resourceError })), mode === "edit" ? (_jsx(EditorBody, { left: _jsxs(_Fragment, { children: [_jsx(PlaceholderSection, { validation: railValidation, onInsert: insert.insertPlaceholder }), _jsx(SystemPlaceholderSection, { validation: railValidation, onInsert: insert.insertPlaceholder }), _jsx(ResourceSection, { resources: resources, pdfTitles: pdfTitles, canUpload: uploadTemplateId !== null, onUpload: () => setUploadOpen(true), onInsert: insert.insertResource, onRemove: (key) => setDialog({ kind: "remove", key }) })] }), center: locked ? (_jsx(MissingLocaleCanvas, { slug: slug, locale: locale, fallbackLocale: props.fallbackLocale, copyScope: isSystemAdmin ? "system" : "tenant", onCreateBlank: () => setCreating(true), onCopied: () => void onReload() })) : (_jsx(MailEditCenter, { draft: draft, vocabulary: vocabulary, resourcePreviews: resourcePreviews, insert: insert, readOnly: false, fieldErrors: fieldErrors, onChange: update }, editorEpoch)), right: _jsxs(_Fragment, { children: [_jsx(PropertiesSection, { draft: draft, layouts: layouts.rows, onChange: update }), _jsx(ChromeSection, { draft: draft, vocabulary: vocabulary, insert: insert, onChange: update }), _jsx(AttachmentSection, { attachments: attachments, pdfTitles: pdfTitles, canEdit: uploadTemplateId !== null, onAddPdf: () => setPdfOpen(true), onRemove: (key) => setDialog({ kind: "remove", key }) }), _jsx(UsageSection, { slug: slug }), _jsx(VersionsSection, { rowId: row?.id ?? null, currentVersion: row?.version ?? 0, currentStatus: row?.status ?? "draft" })] }) })) : (_jsx(MailPreviewMode, { payload: {
178
177
  slug,
179
178
  kind: "mail",
180
179
  locale,
@@ -200,7 +199,7 @@ export function MailEditorBody(props) {
200
199
  reset();
201
200
  setDialog({ kind: "none" });
202
201
  }, onRemove: removeResource, onLeaveSave: async (target) => {
203
- if (await persist(false))
202
+ if ((await persistence.save(draft, false)) === "saved")
204
203
  perform(target);
205
204
  }, onLeaveDiscard: (target) => {
206
205
  setDialog({ kind: "none" });
@@ -1,18 +1,14 @@
1
1
  import { jsx as _jsx } from "react/jsx-runtime";
2
2
  // @runtime client
3
3
  import { TemplateResolverQueries } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
- import { useNav, useQuery, useUserRoles } from "@cosmicdrift/kumiko-renderer";
4
+ import { useLocale, useNav, useQuery, useTranslation, useUserRoles, } from "@cosmicdrift/kumiko-renderer";
5
5
  import { LoadingState } from "@cosmicdrift/kumiko-renderer-web";
6
6
  import { useState } from "react";
7
7
  import { DEFAULT_TEMPLATE_LOCALES } from "../constants.js";
8
+ import { defaultLocaleOf } from "./editor-locale.js";
8
9
  import { MailEditorBody } from "./mail-editor-body.js";
9
10
  import { useOverviewRows } from "./use-overview-rows.js";
10
- function defaultLocaleOf(appLocales, rowLocales) {
11
- const preferred = appLocales[0] ?? DEFAULT_TEMPLATE_LOCALES[0] ?? "de";
12
- if (rowLocales.length === 0 || rowLocales.includes(preferred))
13
- return preferred;
14
- return rowLocales[0] ?? preferred;
15
- }
11
+ import { templateTitle } from "./used-by-label.js";
16
12
  function fallbackLocaleOf(locale, rowLocales) {
17
13
  const others = rowLocales.filter((candidate) => candidate !== locale);
18
14
  const preferred = DEFAULT_TEMPLATE_LOCALES[0];
@@ -25,6 +21,7 @@ function shownSummaryOf(summaries, slug, isSystemAdmin) {
25
21
  return (isSystemAdmin ? system : (tenant ?? system)) ?? null;
26
22
  }
27
23
  function MailEditorData({ slug, locale, locales, overviewRow, isSystemAdmin, onLocaleChange, refreshOverview, }) {
24
+ const t = useTranslation();
28
25
  const [generation, setGeneration] = useState(0);
29
26
  const list = useQuery(TemplateResolverQueries.list, {
30
27
  kind: "mail",
@@ -43,13 +40,14 @@ function MailEditorData({ slug, locale, locales, overviewRow, isSystemAdmin, onL
43
40
  setGeneration((current) => current + 1);
44
41
  };
45
42
  const rowLocales = overviewRow?.locales ?? [];
46
- return (_jsx(MailEditorBody, { slug: slug, locale: locale, locales: locales, title: overviewRow?.title ?? slug, isSystemAdmin: isSystemAdmin, row: row, systemVersion: overviewRow?.systemVersion ?? null, fallbackLocale: fallbackLocaleOf(locale, rowLocales), onLocaleChange: onLocaleChange, onReload: reload, onRefreshRow: async () => {
43
+ return (_jsx(MailEditorBody, { slug: slug, locale: locale, locales: locales, title: overviewRow === null ? slug : templateTitle(t, overviewRow), isSystemAdmin: isSystemAdmin, row: row, systemVersion: overviewRow?.systemVersion ?? null, fallbackLocale: fallbackLocaleOf(locale, rowLocales), onLocaleChange: onLocaleChange, onReload: reload, onRefreshRow: async () => {
47
44
  await stored.refetch();
48
45
  } }, `${locale}|${generation}|${row?.id ?? "none"}`));
49
46
  }
50
47
  function MailEditorEntry({ appLocales }) {
51
48
  const nav = useNav();
52
49
  const roles = useUserRoles();
50
+ const uiLocale = useLocale().locale();
53
51
  const slug = nav.searchParams["slug"] ?? "";
54
52
  const overview = useOverviewRows("mail", slug);
55
53
  const [picked, setPicked] = useState(nav.searchParams["locale"] ?? null);
@@ -57,7 +55,7 @@ function MailEditorEntry({ appLocales }) {
57
55
  return _jsx(LoadingState, {});
58
56
  const overviewRow = overview.rows.find((candidate) => candidate.slug === slug) ?? null;
59
57
  const rowLocales = overviewRow?.locales ?? [];
60
- const locale = picked ?? defaultLocaleOf(appLocales, rowLocales);
58
+ const locale = picked ?? defaultLocaleOf(uiLocale, appLocales, rowLocales);
61
59
  const locales = [...new Set([...appLocales, ...rowLocales])];
62
60
  return (_jsx(MailEditorData, { slug: slug, locale: locale, locales: locales, overviewRow: overviewRow, isSystemAdmin: roles?.includes("SystemAdmin") === true, onLocaleChange: setPicked, refreshOverview: overview.refetch }, locale));
63
61
  }
@@ -1,6 +1,8 @@
1
1
  import type { ReactNode } from "react";
2
+ import type { EditorVocabulary } from "./mail-edit-center.js";
2
3
  import type { LinkedResourceView, MailDraft } from "./mail-types.js";
3
4
  import type { OverviewRowView } from "./overview-row.js";
5
+ import type { InsertTargets } from "./use-insert-targets.js";
4
6
  export declare function layoutOptionLabel(t: (key: string, params?: Readonly<Record<string, unknown>>) => string, layout: OverviewRowView): string;
5
7
  export declare function AttachmentSection({ attachments, pdfTitles, canEdit, onAddPdf, onRemove, }: {
6
8
  readonly attachments: readonly LinkedResourceView[];
@@ -14,6 +16,12 @@ export declare function PropertiesSection({ draft, layouts, onChange, }: {
14
16
  readonly layouts: readonly OverviewRowView[];
15
17
  readonly onChange: (patch: Partial<MailDraft>) => void;
16
18
  }): ReactNode;
19
+ export declare function ChromeSection({ draft, vocabulary, insert, onChange, }: {
20
+ readonly draft: MailDraft;
21
+ readonly vocabulary: EditorVocabulary;
22
+ readonly insert: InsertTargets;
23
+ readonly onChange: (patch: Partial<MailDraft>) => void;
24
+ }): ReactNode;
17
25
  export declare function UsageSection({ slug }: {
18
26
  readonly slug: string;
19
27
  }): ReactNode;
@@ -1,7 +1,8 @@
1
1
  import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
- import { TemplateResolverQueries } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
3
+ import { MAIL_TONES, PlaceholderLineEditor, TemplateResolverQueries, } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
4
  import { usePrimitives, useQuery, useTranslation } from "@cosmicdrift/kumiko-renderer";
5
+ import { isOneOf } from "@cosmicdrift/kumiko-types/type-guards";
5
6
  import { DEFAULT_LAYOUT_SLUG } from "../constants.js";
6
7
  import { RailField, RailHint, RailRow, RailSection } from "./rail.js";
7
8
  import { usageParts } from "./used-by-label.js";
@@ -14,6 +15,20 @@ export function layoutOptionLabel(t, layout) {
14
15
  ? "documentDesigner.mail.props.layoutTenant"
15
16
  : "documentDesigner.mail.props.layoutSystem", { title, version: layout.version });
16
17
  }
18
+ // Without a stored default layout the renderer uses its built-in one, so the
19
+ // select still needs an option for the value it shows.
20
+ function layoutOptions(t, layouts) {
21
+ const stored = layouts.map((layout) => ({
22
+ value: layout.slug,
23
+ label: layoutOptionLabel(t, layout),
24
+ }));
25
+ return layouts.some((layout) => layout.slug === DEFAULT_LAYOUT_SLUG)
26
+ ? stored
27
+ : [
28
+ { value: DEFAULT_LAYOUT_SLUG, label: t("documentDesigner.mail.props.layoutBuiltIn") },
29
+ ...stored,
30
+ ];
31
+ }
17
32
  function UsageRows({ slug }) {
18
33
  const t = useTranslation();
19
34
  const { data } = useQuery(TemplateResolverQueries.usage, {
@@ -43,14 +58,40 @@ export function PropertiesSection({ draft, layouts, onChange, }) {
43
58
  const { Field, Input } = usePrimitives();
44
59
  const layoutValue = draft.layoutSlug === "" ? DEFAULT_LAYOUT_SLUG : draft.layoutSlug;
45
60
  const categoryValue = draft.category === "" ? "transactional" : draft.category;
46
- return (_jsxs(RailSection, { title: t("documentDesigner.mail.props.title"), testId: "mail-editor-properties", children: [_jsx(RailField, { children: _jsx(Field, { id: "mail-layout", label: t("documentDesigner.mail.props.layout"), children: _jsx(Input, { kind: "select", id: "mail-layout", name: "mail-layout", value: layoutValue, display: "dropdown", onChange: (next) => onChange({ layoutSlug: next }), options: layouts.map((layout) => ({
47
- value: layout.slug,
48
- label: layoutOptionLabel(t, layout),
49
- })) }) }) }), _jsx(RailField, { children: _jsx(Field, { id: "mail-sender", label: t("documentDesigner.mail.props.sender"), children: _jsx(Input, { kind: "text", id: "mail-sender", name: "mail-sender", testId: "mail-sender", value: draft.senderName, onChange: (next) => onChange({ senderName: next }), placeholder: t("documentDesigner.mail.props.senderPlaceholder") }) }) }), _jsx(RailHint, { children: t("documentDesigner.mail.props.senderHint") }), _jsx(RailField, { children: _jsx(Field, { id: "mail-reply-to", label: t("documentDesigner.mail.props.replyTo"), children: _jsx(Input, { kind: "email", id: "mail-reply-to", name: "mail-reply-to", testId: "mail-reply-to", value: draft.replyTo, onChange: (next) => onChange({ replyTo: next }) }) }) }), _jsx(RailField, { children: _jsx(Field, { id: "mail-category", label: t("documentDesigner.mail.props.category"), children: _jsx(Input, { kind: "select", id: "mail-category", name: "mail-category", value: categoryValue, display: "dropdown", onChange: (next) => onChange({ category: next === "marketing" ? "marketing" : "transactional" }), options: [
61
+ return (_jsxs(RailSection, { title: t("documentDesigner.mail.props.title"), testId: "mail-editor-properties", children: [_jsx(RailField, { children: _jsx(Field, { id: "mail-layout", label: t("documentDesigner.mail.props.layout"), children: _jsx(Input, { kind: "select", id: "mail-layout", name: "mail-layout", value: layoutValue, display: "dropdown", onChange: (next) => onChange({ layoutSlug: next }), options: layoutOptions(t, layouts) }) }) }), _jsx(RailField, { children: _jsx(Field, { id: "mail-sender", label: t("documentDesigner.mail.props.sender"), children: _jsx(Input, { kind: "text", id: "mail-sender", name: "mail-sender", testId: "mail-sender", value: draft.senderName, onChange: (next) => onChange({ senderName: next }), placeholder: t("documentDesigner.mail.props.senderPlaceholder") }) }) }), _jsx(RailHint, { children: t("documentDesigner.mail.props.senderHint") }), _jsx(RailField, { children: _jsx(Field, { id: "mail-reply-to", label: t("documentDesigner.mail.props.replyTo"), children: _jsx(Input, { kind: "email", id: "mail-reply-to", name: "mail-reply-to", testId: "mail-reply-to", value: draft.replyTo, onChange: (next) => onChange({ replyTo: next }) }) }) }), _jsx(RailField, { children: _jsx(Field, { id: "mail-category", label: t("documentDesigner.mail.props.category"), children: _jsx(Input, { kind: "select", id: "mail-category", name: "mail-category", value: categoryValue, display: "dropdown", onChange: (next) => onChange({ category: next === "marketing" ? "marketing" : "transactional" }), options: [
50
62
  { value: "transactional", label: t("documentDesigner.mail.props.transactional") },
51
63
  { value: "marketing", label: t("documentDesigner.mail.props.marketing") },
52
64
  ] }) }) }), _jsx(RailHint, { children: t("documentDesigner.mail.props.categoryHint") })] }));
53
65
  }
66
+ const TONE_LABEL_KEYS = {
67
+ accent: "documentDesigner.mail.chrome.toneAccent",
68
+ info: "documentDesigner.mail.chrome.toneInfo",
69
+ success: "documentDesigner.mail.chrome.toneSuccess",
70
+ warning: "documentDesigner.mail.chrome.toneWarning",
71
+ danger: "documentDesigner.mail.chrome.toneDanger",
72
+ neutral: "documentDesigner.mail.chrome.toneNeutral",
73
+ };
74
+ // The stored tone may be a placeholder that resolves per mail; the select keeps it as its own
75
+ // option so opening the template never silently drops it.
76
+ function toneOptions(t, current) {
77
+ const fixed = MAIL_TONES.map((tone) => ({ value: tone, label: t(TONE_LABEL_KEYS[tone]) }));
78
+ return current === "" || isOneOf(MAIL_TONES, current)
79
+ ? fixed
80
+ : [
81
+ ...fixed,
82
+ {
83
+ value: current,
84
+ label: t("documentDesigner.mail.chrome.toneFromPlaceholder", { value: current }),
85
+ },
86
+ ];
87
+ }
88
+ export function ChromeSection({ draft, vocabulary, insert, onChange, }) {
89
+ const t = useTranslation();
90
+ const { Field, Input } = usePrimitives();
91
+ // "accent" is the renderer default, so it is stored as an empty value.
92
+ const toneValue = draft.tone === "" ? "accent" : draft.tone;
93
+ return (_jsxs(RailSection, { title: t("documentDesigner.mail.chrome.title"), testId: "mail-editor-chrome", children: [_jsx(RailField, { children: _jsx(Field, { id: "mail-header-chip", label: t("documentDesigner.mail.chrome.headerChip"), children: _jsx("div", { onFocusCapture: insert.markFocused("headerChip"), "data-testid": "mail-header-chip", children: _jsx(PlaceholderLineEditor, { id: "mail-header-chip", value: draft.headerChip, onChange: (headerChip) => onChange({ headerChip }), variableLabels: vocabulary.labels, systemVariables: vocabulary.system, placeholder: t("documentDesigner.mail.chrome.headerChipPlaceholder"), onEditorReady: insert.register("headerChip"), "aria-label": t("documentDesigner.mail.chrome.headerChip") }) }) }) }), _jsx(RailField, { children: _jsx(Field, { id: "mail-tone", label: t("documentDesigner.mail.chrome.tone"), children: _jsx(Input, { kind: "select", id: "mail-tone", name: "mail-tone", value: toneValue, display: "dropdown", onChange: (next) => onChange({ tone: next === "accent" ? "" : next }), options: toneOptions(t, draft.tone) }) }) }), _jsx(RailField, { children: _jsx(Field, { id: "mail-footer-note", label: t("documentDesigner.mail.chrome.footerNote"), children: _jsx("div", { onFocusCapture: insert.markFocused("footerNote"), "data-testid": "mail-footer-note", children: _jsx(PlaceholderLineEditor, { id: "mail-footer-note", value: draft.footerNote, onChange: (footerNote) => onChange({ footerNote }), variableLabels: vocabulary.labels, systemVariables: vocabulary.system, placeholder: t("documentDesigner.mail.chrome.footerNotePlaceholder"), onEditorReady: insert.register("footerNote"), "aria-label": t("documentDesigner.mail.chrome.footerNote") }) }) }) }), _jsx(RailHint, { children: t("documentDesigner.mail.chrome.hint") })] }));
94
+ }
54
95
  export function UsageSection({ slug }) {
55
96
  const t = useTranslation();
56
97
  return (_jsxs(RailSection, { title: t("documentDesigner.mail.usage.title"), testId: "mail-editor-usage", children: [_jsx(UsageRows, { slug: slug }), _jsx(RailHint, { children: t("documentDesigner.mail.usage.hint") })] }));
@@ -92,6 +92,10 @@ export type MailDraft = {
92
92
  readonly category: MailCategory;
93
93
  readonly senderName: string;
94
94
  readonly replyTo: string;
95
+ /** Meta lines without rail fields yet; carried so a tenant save keeps them. */
96
+ readonly headerChip: string;
97
+ readonly tone: string;
98
+ readonly footerNote: string;
95
99
  readonly body: string;
96
100
  };
97
101
  export type LinkedResourceView = {
@@ -2,7 +2,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  // @runtime client
3
3
  import { TemplateResolverHandlers } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
4
4
  import { dispatcherErrorText, useMutation, usePrimitives, useTranslation, } from "@cosmicdrift/kumiko-renderer";
5
- import { useState } from "react";
5
+ import { useRef, useState } from "react";
6
6
  import { fetchStoredFileName } from "./file-name.js";
7
7
  import { uniqueResourceKey } from "./mail-draft.js";
8
8
  const IMAGE_ACCEPT = ["image/png", "image/jpeg", "image/gif", "image/webp"];
@@ -11,6 +11,12 @@ function useResourceUpload(target) {
11
11
  const resourceUpload = useMutation(TemplateResolverHandlers.resourceUpload);
12
12
  const [error, setError] = useState(null);
13
13
  const [busy, setBusy] = useState(false);
14
+ // takenKeys only refreshes after the fire-and-forget row refetch, so keys handed out
15
+ // here must be remembered or a quick second upload of the same file name collides.
16
+ const uploadedKeys = useRef({
17
+ templateId: target.templateId,
18
+ keys: new Set(),
19
+ });
14
20
  const upload = async (fileRefId, purpose) => {
15
21
  if (fileRefId === null)
16
22
  return;
@@ -22,7 +28,10 @@ function useResourceUpload(target) {
22
28
  setError(t("documentDesigner.mail.upload.noFileName"));
23
29
  return;
24
30
  }
25
- const key = uniqueResourceKey(fileName, target.takenKeys);
31
+ if (uploadedKeys.current.templateId !== target.templateId) {
32
+ uploadedKeys.current = { templateId: target.templateId, keys: new Set() };
33
+ }
34
+ const key = uniqueResourceKey(fileName, [...target.takenKeys, ...uploadedKeys.current.keys]);
26
35
  const result = await resourceUpload.mutate({
27
36
  sourceFileRefId: fileRefId,
28
37
  templateId: target.templateId,
@@ -34,6 +43,7 @@ function useResourceUpload(target) {
34
43
  setError(dispatcherErrorText(result.error, t));
35
44
  return;
36
45
  }
46
+ uploadedKeys.current.keys.add(key);
37
47
  target.onUploaded({ key, purpose });
38
48
  }
39
49
  finally {
@@ -13,7 +13,7 @@ export type PageCanvasProps = {
13
13
  readonly systemLabels: Readonly<Record<string, string>>;
14
14
  readonly variableNames: readonly string[];
15
15
  readonly resourcePreviews: Readonly<Record<string, DocumentResourcePreview>>;
16
- readonly onEditorReady: (handle: DocumentEditorHandle) => void;
16
+ readonly onEditorReady: (handle: DocumentEditorHandle | null) => void;
17
17
  readonly resourceSrc?: (key: string) => string | undefined;
18
18
  };
19
19
  export declare function PageCanvas(props: PageCanvasProps): ReactNode;
@@ -24,6 +24,7 @@ import { localesOf, parseJsonObject, pickRow, resolveLocale } from "./template-r
24
24
  import { DEFAULT_LAYOUT_SLUG, DOCUMENT_PDF_KIND, PDF_LAYOUT_KIND, } from "./types.js";
25
25
  import { useInsertTargets } from "./use-insert-targets.js";
26
26
  import { useOverviewRows } from "./use-overview-rows.js";
27
+ import { templateTitle } from "./used-by-label.js";
27
28
  const SETTLE_MS = 500;
28
29
  const NO_VARIABLES = {};
29
30
  // Opening the preview prints the page without a click; a failed print is not retried for the same draft.
@@ -262,7 +263,7 @@ export function PdfTemplateEditor({ slug, locale: requestedLocale, onLocaleChang
262
263
  const overrideCount = (overridesQuery.data?.activeOverrides ?? 0) + (overridesQuery.data?.draftOverrides ?? 0);
263
264
  const resourceKeys = Object.keys(parseJsonObject(detail.linkedResources));
264
265
  const overviewRow = overview.rows.find((candidate) => candidate.slug === slug);
265
- const title = overviewRow?.title ?? slug;
266
+ const title = overviewRow === undefined ? slug : templateTitle(t, overviewRow);
266
267
  const systemVersion = overviewRow?.systemVersion ?? null;
267
268
  const vocabulary = {
268
269
  names: variableNames,
@@ -0,0 +1 @@
1
+ export declare function useDebouncedValue<T>(value: T, delayMs: number): T;
@@ -0,0 +1,11 @@
1
+ import { useEffect, useState } from "react";
2
+ // Unlike useDeferredValue this waits for a quiet period, so a burst of
3
+ // keystrokes triggers one server round-trip instead of one per keystroke.
4
+ export function useDebouncedValue(value, delayMs) {
5
+ const [debounced, setDebounced] = useState(value);
6
+ useEffect(() => {
7
+ const timer = setTimeout(() => setDebounced(value), delayMs);
8
+ return () => clearTimeout(timer);
9
+ }, [value, delayMs]);
10
+ return debounced;
11
+ }
@@ -1,7 +1,7 @@
1
1
  import type { DocumentEditorHandle } from "@cosmicdrift/kumiko-bundled-features/template-resolver/web";
2
- export type InsertField = "subject" | "previewText" | "body" | "title" | "author";
2
+ export type InsertField = "subject" | "previewText" | "headerChip" | "footerNote" | "body" | "title" | "author";
3
3
  export type InsertTargets = {
4
- readonly register: (field: InsertField) => (handle: DocumentEditorHandle) => void;
4
+ readonly register: (field: InsertField) => (handle: DocumentEditorHandle | null) => void;
5
5
  readonly markFocused: (field: InsertField) => () => void;
6
6
  readonly insertPlaceholder: (name: string) => void;
7
7
  readonly insertResource: (key: string) => void;
@@ -6,7 +6,10 @@ export function useInsertTargets() {
6
6
  const lastFocused = useRef("body");
7
7
  return {
8
8
  register: (field) => (handle) => {
9
- handles.current[field] = handle;
9
+ if (handle === null)
10
+ delete handles.current[field];
11
+ else
12
+ handles.current[field] = handle;
10
13
  },
11
14
  markFocused: (field) => () => {
12
15
  lastFocused.current = field;
@@ -1,6 +1,7 @@
1
1
  import type { MailDraft, TemplateRowView } from "./mail-types.js";
2
+ export type SaveOutcome = "saved" | "saved-not-published" | "failed";
2
3
  export type MailPersistence = {
3
- readonly save: (draft: MailDraft, publish: boolean) => Promise<boolean>;
4
+ readonly save: (draft: MailDraft, publish: boolean) => Promise<SaveOutcome>;
4
5
  readonly busy: boolean;
5
6
  readonly error: string | null;
6
7
  readonly clearError: () => void;
@@ -29,16 +29,16 @@ export function useMailPersistence(args) {
29
29
  : await upsertTenant.mutate({ ...base, status: publish ? "active" : "draft" });
30
30
  if (!saved.isSuccess) {
31
31
  setError(errorTextWithFieldIssues(saved.error, t));
32
- return false;
32
+ return "failed";
33
33
  }
34
34
  if (args.isSystemAdmin && publish) {
35
35
  const published = await publishSystem.mutate({ id: saved.data.id });
36
36
  if (!published.isSuccess) {
37
37
  setError(errorTextWithFieldIssues(published.error, t));
38
- return false;
38
+ return "saved-not-published";
39
39
  }
40
40
  }
41
- return true;
41
+ return "saved";
42
42
  };
43
43
  return {
44
44
  save,
@@ -1,5 +1,6 @@
1
1
  import type { OverviewRowView, OverviewUsageView } from "./overview-row.js";
2
2
  type Translate = (key: string, params?: Readonly<Record<string, unknown>>) => string;
3
+ export declare function templateTitle(t: Translate, row: Pick<OverviewRowView, "title" | "slug" | "kind">): string;
3
4
  export declare function usageParts(t: Translate, usage: OverviewUsageView): {
4
5
  readonly feature: string;
5
6
  readonly notification: string;
@@ -5,6 +5,17 @@ function translateOrRaw(t, key, raw) {
5
5
  const translated = t(key);
6
6
  return translated === key ? raw : translated;
7
7
  }
8
+ const NOTIFICATION_SLUG = /^([^:]+):notify:([^:]+)$/;
9
+ // A seeded system mail has no stored title, so its notification label from the
10
+ // owning feature names it; the slug stays the last resort.
11
+ export function templateTitle(t, row) {
12
+ if (row.title !== row.slug)
13
+ return row.title;
14
+ const match = row.kind === "mail" ? NOTIFICATION_SLUG.exec(row.slug) : null;
15
+ if (match === null)
16
+ return row.slug;
17
+ return usageParts(t, { feature: match[1] ?? "", notification: match[2] ?? "" }).notification;
18
+ }
8
19
  export function usageParts(t, usage) {
9
20
  return {
10
21
  feature: translateOrRaw(t, `${usage.feature}:feature.label`, usage.feature),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-document-designer",
3
- "version": "0.364.0",
3
+ "version": "0.365.1",
4
4
  "description": "Document designer for Kumiko: template overview, layout editor (mail layout, PDF letterhead) and the PDF template editor.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -19,13 +19,13 @@
19
19
  }
20
20
  },
21
21
  "dependencies": {
22
- "@cosmicdrift/kumiko-bundled-features": "0.364.0",
23
- "@cosmicdrift/kumiko-framework": "0.364.0",
24
- "@cosmicdrift/kumiko-headless": "0.364.0",
25
- "@cosmicdrift/kumiko-renderer": "0.364.0",
26
- "@cosmicdrift/kumiko-renderer-puppeteer-client": "0.364.0",
27
- "@cosmicdrift/kumiko-renderer-web": "0.364.0",
28
- "@cosmicdrift/kumiko-types": "0.364.0",
22
+ "@cosmicdrift/kumiko-bundled-features": "0.365.1",
23
+ "@cosmicdrift/kumiko-framework": "0.365.1",
24
+ "@cosmicdrift/kumiko-headless": "0.365.1",
25
+ "@cosmicdrift/kumiko-renderer": "0.365.1",
26
+ "@cosmicdrift/kumiko-renderer-puppeteer-client": "0.365.1",
27
+ "@cosmicdrift/kumiko-renderer-web": "0.365.1",
28
+ "@cosmicdrift/kumiko-types": "0.365.1",
29
29
  "pdfjs-dist": "6.3.289",
30
30
  "react": "^19.2.6"
31
31
  },
package/src/changes.json CHANGED
@@ -1,4 +1,28 @@
1
1
  [
2
+ {
3
+ "version": "0.365.0",
4
+ "type": "improvement",
5
+ "title": "Seeded mails show readable titles and the editor opens in the UI language",
6
+ "detail": "A mail template without a stored title is named by its notification label (<feature>:notify.<name>.label) in the overview and the editor header instead of its slug. The mail editor opens on the UI language, or its base language, when the template has it, and falls back to the previous choice otherwise. The layout select offers the built-in standard layout when no default layout is stored, and the preview text field shows a hint while empty."
7
+ },
8
+ {
9
+ "version": "0.365.0",
10
+ "type": "fix",
11
+ "title": "Layout editor no longer asks for the platform override counts after a tenant save",
12
+ "detail": "Saving or resetting a layout as tenant admin refetched the tenant-overrides query, which only the platform admin may read, so every save logged a 403. The reload now refetches it only for the platform admin."
13
+ },
14
+ {
15
+ "version": "0.365.0",
16
+ "type": "improvement",
17
+ "title": "Mail editor rail edits header chip, tone and footer note",
18
+ "detail": "The properties rail of the mail editor has a section \"Kopf und Fuß\" with the header chip and the footer note as placeholder lines and the tone as a select (accent, info, success, warning, danger, neutral). The values are saved in the mail meta and show in the preview right away. A tone stored as placeholder stays as its own option; the accent tone is stored as no tone. template-resolver/web exports MAIL_TONES and MailTone."
19
+ },
20
+ {
21
+ "version": "0.365.0",
22
+ "type": "fix",
23
+ "title": "Debounced layout validation, collision-free resource keys and reload after a half-finished system publish",
24
+ "detail": "The layout editor sent a validate query (with an MJML compile) on every change; it now waits 400 ms after the last edit. A second upload with the same file name before the row refetch finished reused the first key; keys handed out earlier are now remembered. When a SystemAdmin saved a mail version but publishing it failed, the editor kept the old state with the error shown; it now reloads so the stored version is visible."
25
+ },
2
26
  {
3
27
  "version": "0.364.0",
4
28
  "type": "improvement",