@company-semantics/contracts 34.0.0 → 35.1.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 (76) hide show
  1. package/package.json +4 -4
  2. package/src/index.ts +17 -11
  3. package/src/notifications/README.md +142 -0
  4. package/src/notifications/__tests__/README.md +49 -0
  5. package/src/notifications/__tests__/__snapshots__/README.md +39 -0
  6. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
  7. package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
  8. package/src/notifications/__tests__/content.test.ts +186 -0
  9. package/src/notifications/__tests__/context.test.ts +72 -0
  10. package/src/notifications/__tests__/definition.test.ts +222 -0
  11. package/src/notifications/__tests__/fixtures.ts +226 -0
  12. package/src/notifications/__tests__/kinds.test.ts +80 -0
  13. package/src/notifications/__tests__/registry.test.ts +184 -0
  14. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +62 -29
  15. package/src/notifications/__tests__/renderer.test.ts +181 -0
  16. package/src/notifications/content.ts +249 -0
  17. package/src/notifications/context.ts +70 -0
  18. package/src/notifications/definition.ts +82 -0
  19. package/src/notifications/index.ts +104 -0
  20. package/src/notifications/kinds/README.md +57 -0
  21. package/src/notifications/kinds/auth-otp.ts +99 -0
  22. package/src/notifications/kinds/chat-shared.ts +56 -0
  23. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  24. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  25. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  26. package/src/notifications/kinds/index.ts +19 -0
  27. package/src/notifications/kinds/org-invite.ts +62 -0
  28. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  29. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  30. package/src/notifications/kinds/security-alert.ts +78 -0
  31. package/src/notifications/kinds/share-granted.ts +74 -0
  32. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  33. package/src/notifications/kinds.ts +68 -0
  34. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  35. package/src/notifications/registry.ts +107 -0
  36. package/src/notifications/render.ts +106 -0
  37. package/src/notifications/renderer.ts +50 -0
  38. package/src/notifications/renderers/README.md +50 -0
  39. package/src/notifications/renderers/email/README.md +78 -0
  40. package/src/notifications/renderers/email/__tests__/README.md +32 -0
  41. package/src/notifications/renderers/email/__tests__/render.test.ts +188 -0
  42. package/src/{email/render → notifications/renderers/email}/chat.ts +54 -107
  43. package/src/notifications/renderers/email/constants.ts +64 -0
  44. package/src/notifications/renderers/email/cta.ts +63 -0
  45. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  46. package/src/notifications/renderers/email/index.ts +73 -0
  47. package/src/notifications/renderers/email/render.ts +238 -0
  48. package/src/notifications/renderers/email/shells.ts +61 -0
  49. package/src/notifications/renderers/slack/README.md +73 -0
  50. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  51. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  52. package/src/notifications/renderers/slack/index.ts +261 -0
  53. package/src/notifications/renderers/sms/README.md +69 -0
  54. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  55. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  56. package/src/notifications/renderers/sms/index.ts +131 -0
  57. package/src/notifications/text.ts +52 -0
  58. package/src/email/README.md +0 -51
  59. package/src/email/__tests__/registry.test.ts +0 -161
  60. package/src/email/index.ts +0 -36
  61. package/src/email/registry.ts +0 -155
  62. package/src/email/render/auth-otp.ts +0 -71
  63. package/src/email/render/blocks.ts +0 -281
  64. package/src/email/render/chat-shared.ts +0 -35
  65. package/src/email/render/company-md-access-approved.ts +0 -42
  66. package/src/email/render/company-md-access-denied.ts +0 -43
  67. package/src/email/render/company-md-access-requested.ts +0 -46
  68. package/src/email/render/constants.ts +0 -18
  69. package/src/email/render/index.ts +0 -58
  70. package/src/email/render/org-invite.ts +0 -40
  71. package/src/email/render/ownership-transfer-completed.ts +0 -41
  72. package/src/email/render/ownership-transfer.ts +0 -42
  73. package/src/email/render/render-email.ts +0 -194
  74. package/src/email/render/security-alert.ts +0 -61
  75. package/src/email/render/share-granted.ts +0 -52
  76. package/src/email/render/unit-owner-granted.ts +0 -60
@@ -0,0 +1,63 @@
1
+ /**
2
+ * The `>> LABEL <<` button — email's answer to a `callToAction`.
3
+ *
4
+ * Moved verbatim from the deleted `src/email/render/blocks` (ADR-CONTRACTS-086). It
5
+ * lives in its own module because two callers need it: a standalone
6
+ * `callToAction` element (`./render`) and one inside a conversation
7
+ * (`./chat`), which place it at different margins.
8
+ *
9
+ * INVARIANTS:
10
+ * - Pure, and byte-identical to what the deleted `src/email/render/blocks` emits.
11
+ * - `href` absent means the label IS the payload (an OTP code): the button must
12
+ * not become a link, and plain text must not print a URL under it.
13
+ */
14
+
15
+ import type { CallToAction } from "../../content";
16
+
17
+ import { FONT_SIZE, MONO } from "./constants";
18
+ import { escapeHtml } from "./escape-html";
19
+
20
+ /** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
21
+ const CTA_BOX_PAD = 3;
22
+
23
+ /** `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label. */
24
+ function asciiCtaBox(label: string): string {
25
+ const pad = " ".repeat(CTA_BOX_PAD);
26
+ const inner = `${pad}>> ${label} <<${pad}`;
27
+ const border = `*${"-".repeat(inner.length)}*`;
28
+ return [border, `|${inner}|`, border].join("\n");
29
+ }
30
+
31
+ /**
32
+ * The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
33
+ * the table. A standalone element wraps it as its own line; a chat unit embeds
34
+ * it.
35
+ */
36
+ export function ctaButton(
37
+ cta: CallToAction,
38
+ margin: string,
39
+ ): { html: string; text: string } {
40
+ const { label, href } = cta;
41
+
42
+ const tableStyle = `display: inline-block; border: 1px solid #666; border-radius: 2px; margin: ${margin}; max-width: 220px;`;
43
+ const tdStyle = `padding: 16px 24px; text-align: center; ${MONO} font-size: ${FONT_SIZE};`;
44
+
45
+ // Underline only the label text (not the chevrons/spaces), and only when linked.
46
+ const labelHtml = href
47
+ ? `<span style="text-decoration: underline;">${escapeHtml(label)}</span>`
48
+ : escapeHtml(label);
49
+ const chevrons = `&gt;&gt; ${labelHtml} &lt;&lt;`;
50
+ const inner = href
51
+ ? `<a href="${href}" style="color: #0047FF; text-decoration: none;">${chevrons}</a>`
52
+ : chevrons;
53
+
54
+ const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
55
+ <tr><td style="${tdStyle}">
56
+ ${inner}
57
+ </td></tr>
58
+ </table>`;
59
+
60
+ const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
61
+
62
+ return { html, text };
63
+ }
@@ -1,5 +1,9 @@
1
1
  /**
2
- * HTML escaping for email templates.
2
+ * HTML escaping for the email channel.
3
+ *
4
+ * Moved here from the deleted `src/email/render/escape-html`
5
+ * (ADR-CONTRACTS-086): escaping is one channel's concern, and the channel now
6
+ * owns it.
3
7
  *
4
8
  * INVARIANT: every user-controlled field in an HTML variant MUST pass through
5
9
  * this before interpolation (prevents XSS in email clients).
@@ -0,0 +1,73 @@
1
+ /**
2
+ * The email channel (ADR-CONTRACTS-086).
3
+ *
4
+ * `emailRenderer` is the first `Renderer` — email demoted from "the shape of the
5
+ * whole layer" to one channel among several. Everything that used to BE the
6
+ * render layer (the shells, the styling, the escaping, the box art) is now this
7
+ * directory's private business; what it exposes is a renderer and the type it
8
+ * returns. `src/email/` is gone, and this is what replaced it.
9
+ *
10
+ * INVARIANTS:
11
+ * - Pure. `render` is a function of `(content, context)` — no clock, no
12
+ * environment, no I/O. The copyright year comes from
13
+ * `context.brand.copyrightYear`.
14
+ * - Output is locked by `../../__tests__/render-snapshot.test.ts`. Byte-identity
15
+ * with the old `src/email/render` was the whole constraint of the migration
16
+ * and it held; ADR-CONTRACTS-087 discharged that proof once it shipped, so
17
+ * this channel's markup is now free to change — reviewably, via the snapshot.
18
+ * - `supports` answers true for every element type: email is the rich channel,
19
+ * and there is nothing in the content model it cannot depict.
20
+ */
21
+
22
+ import type { NotificationContent, NotificationElement } from "../../content";
23
+ import type { RenderContext } from "../../context";
24
+ import type { Renderer } from "../../renderer";
25
+
26
+ import type { Spacing } from "./constants";
27
+ import { renderElement } from "./render";
28
+ import { type EmailLine, htmlShell, textShell } from "./shells";
29
+
30
+ /** One rendered email — the email channel's natural output type. */
31
+ export interface RenderedEmail {
32
+ /** Subject line — the notification's `metadata.title`. */
33
+ subject: string;
34
+ /** Plain-text body. */
35
+ text: string;
36
+ /** HTML body (every kind is dual-output). */
37
+ html: string;
38
+ }
39
+
40
+ /**
41
+ * The gap after an element's last line.
42
+ *
43
+ * `signature` owns the blank line above its own rule, so whatever precedes it
44
+ * carries no trailing gap — the one positional rule the old templates stated
45
+ * out loud (every `footer(..., "none")` call, and `security.alert`'s Time row).
46
+ */
47
+ function trailingSpacing(
48
+ elements: NotificationElement[],
49
+ index: number,
50
+ ): Spacing {
51
+ return elements[index + 1]?.type === "signature" ? "none" : "normal";
52
+ }
53
+
54
+ /** Email. Sections are one body — the seam email declines to split on. */
55
+ export const emailRenderer: Renderer<RenderedEmail> = {
56
+ id: "email",
57
+ supports: () => true,
58
+ render: (content: NotificationContent, context: RenderContext) => {
59
+ const elements = content.sections
60
+ .flatMap((section) => section.elements)
61
+ .filter((element) => emailRenderer.supports(element.type));
62
+
63
+ const lines: EmailLine[] = elements.flatMap((element, index) =>
64
+ renderElement(element, context, trailingSpacing(elements, index)),
65
+ );
66
+
67
+ return {
68
+ subject: content.metadata.title,
69
+ text: textShell(lines),
70
+ html: htmlShell(lines),
71
+ };
72
+ },
73
+ };
@@ -0,0 +1,238 @@
1
+ /**
2
+ * What each `NotificationElement` looks like as email (ADR-CONTRACTS-086).
3
+ *
4
+ * This is the deleted `src/email/render/blocks` read the other way round. That
5
+ * module offered templates a component per intent (`greeting()`, `keyValue()`,
6
+ * `footer()`) and each template chose which to call; here the CONTENT names the
7
+ * intent and this module is the only thing that knows the markup. The strings
8
+ * started as the same strings, byte-for-byte; they are free to diverge now
9
+ * (ADR-CONTRACTS-087) and `../../__tests__/render-snapshot.test.ts` is what
10
+ * makes each divergence a reviewed one.
11
+ *
12
+ * **Spacing is derived, because the content model does not carry it.** The old
13
+ * layer passed a `Spacing` per block, and `../../kinds` deliberately dropped it:
14
+ * a margin is a channel's answer. So this module has to re-derive one, and it
15
+ * does it with a single rule:
16
+ *
17
+ * > An element renders as one or more lines. Lines WITHIN an element hug
18
+ * > (`"tight"`); the element's LAST line carries the trailing gap, which is
19
+ * > `"normal"` — or `"none"` when a signature follows, because `signature`
20
+ * > owns the blank above its own rule.
21
+ *
22
+ * That rule reproduces every hand-authored spacing choice in the old templates,
23
+ * which is not a coincidence: "these lines are one thing, that gap separates two
24
+ * things" is what the authors were expressing, and the element boundaries are
25
+ * where they were expressing it. The one place it did NOT fall out was
26
+ * `security.alert`'s two-sentence paragraph, and the fix was to say so in the
27
+ * content (one `body` with a newline) rather than to special-case a kind here —
28
+ * see `../../kinds/security-alert`.
29
+ *
30
+ * INVARIANTS:
31
+ * - Pure. The copyright year and the brand come from `RenderContext`, never from
32
+ * a clock — retiring the `new Date().getFullYear()` inside the old
33
+ * `signature()`.
34
+ * - The `:` between a label and its value is PRESENTATION and lives in no
35
+ * element (see `../../kinds/README.md`). This module puts it back, on
36
+ * key/value rows and on the metadata heading.
37
+ * - Values arrive pre-formatted (dates, title-casing, truncation are `compose`'s
38
+ * work). Nothing here re-formats a value.
39
+ */
40
+
41
+ import type {
42
+ CallToAction,
43
+ KeyValueRow,
44
+ NotificationElement,
45
+ } from "../../content";
46
+ import type { RenderContext } from "../../context";
47
+
48
+ import {
49
+ COMPANY_URL,
50
+ DESTRUCTIVE,
51
+ FONT_SIZE,
52
+ MONO,
53
+ SPACING,
54
+ type Spacing,
55
+ } from "./constants";
56
+ import { renderChatUnit } from "./chat";
57
+ import { ctaButton } from "./cta";
58
+ import { escapeHtml } from "./escape-html";
59
+ import type { EmailLine } from "./shells";
60
+
61
+ /** One `<p>` — the primitive every prose-ish element is built from. */
62
+ function paragraph(html: string, text: string, spacing: Spacing): EmailLine {
63
+ return {
64
+ html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING[spacing]};">${html}</p>`,
65
+ text,
66
+ spacing,
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Apply the spacing rule to one element's lines: everything but the last hugs,
72
+ * the last carries the element's trailing gap.
73
+ */
74
+ function spaced(
75
+ lines: Array<{ html: string; text: string }>,
76
+ trailing: Spacing,
77
+ ): EmailLine[] {
78
+ return lines.map((line, i) =>
79
+ paragraph(
80
+ line.html,
81
+ line.text,
82
+ i === lines.length - 1 ? trailing : "tight",
83
+ ),
84
+ );
85
+ }
86
+
87
+ /** `Label: value` — the separator the content model does not carry. */
88
+ function keyValueLine(row: KeyValueRow): { html: string; text: string } {
89
+ const text = `${row.label}: ${row.value}`;
90
+ return { html: escapeHtml(text), text };
91
+ }
92
+
93
+ /** The standalone `>> LABEL <<` box. */
94
+ function ctaLine(cta: CallToAction, trailing: Spacing): EmailLine {
95
+ const { html, text } = ctaButton(cta, SPACING[trailing]);
96
+ return { html, text, spacing: trailing };
97
+ }
98
+
99
+ /** The trailing sign-off — a blank line, the `/* EOM *\/` rule, then the signer. */
100
+ function signatureLine(signer: string, year: number): EmailLine {
101
+ const line = `ⓒ ${year} • ${signer}`;
102
+ return {
103
+ html: `<p style="${MONO} font-size: ${FONT_SIZE}; margin: ${SPACING.none};"><br><br><span style="color: #bbb;">/* EOM */</span><br>${escapeHtml(line)}<br><a href="${COMPANY_URL}" target="_blank" rel="noopener noreferrer" style="color: #0047FF; text-decoration: none;">${escapeHtml(COMPANY_URL)}</a></p>`,
104
+ text: `\n\n/* EOM */\n${line}\n${COMPANY_URL}`,
105
+ spacing: "none",
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Render one element to its email lines.
111
+ *
112
+ * `trailing` is the gap after the element's last line, decided by what follows
113
+ * it — see the spacing rule at the top of this module.
114
+ */
115
+ export function renderElement(
116
+ element: NotificationElement,
117
+ context: RenderContext,
118
+ trailing: Spacing,
119
+ ): EmailLine[] {
120
+ switch (element.type) {
121
+ case "greeting": {
122
+ const text = element.recipientName
123
+ ? `Hi ${element.recipientName},`
124
+ : "Hi there,";
125
+ return [paragraph(escapeHtml(text), text, trailing)];
126
+ }
127
+
128
+ case "body":
129
+ // A newline inside a body separates lines of ONE paragraph, so they hug;
130
+ // two paragraphs are two body elements. See `../../content.ts`.
131
+ return spaced(
132
+ element.text.split("\n").map((line) => ({
133
+ html: escapeHtml(line),
134
+ text: line,
135
+ })),
136
+ trailing,
137
+ );
138
+
139
+ case "keyValueTable":
140
+ return spaced(element.rows.map(keyValueLine), trailing);
141
+
142
+ case "metadata": {
143
+ // The heading's `:` is presentation: `label` is "Request details", and the
144
+ // colon the recipient reads is put back here.
145
+ const heading = element.label
146
+ ? [{ html: escapeHtml(`${element.label}:`), text: `${element.label}:` }]
147
+ : [];
148
+ return spaced([...heading, ...element.rows.map(keyValueLine)], trailing);
149
+ }
150
+
151
+ case "callToAction":
152
+ return [ctaLine(element, trailing)];
153
+
154
+ case "warning": {
155
+ // Fieldless by design — the banner IS the content, and its wording is the
156
+ // renderer's (see `../../content.ts`).
157
+ //
158
+ // The colour is this channel's answer to "this is a security notice", and
159
+ // only the HTML surface can give it: plain text has no colour, so there
160
+ // the banner's own glyphs carry the whole signal.
161
+ const text = "🆆🅰🆁🅽🅸🅽🅶";
162
+ return [
163
+ paragraph(
164
+ `<span style="color: ${DESTRUCTIVE};">${escapeHtml(text)}</span>`,
165
+ text,
166
+ trailing,
167
+ ),
168
+ ];
169
+ }
170
+
171
+ case "notice":
172
+ // One paragraph, `<br>` between lines — the lines are one unit, so unlike
173
+ // a `body` they do not become separate `<p>`s.
174
+ return [
175
+ paragraph(
176
+ element.lines.map(escapeHtml).join("<br>"),
177
+ element.lines.join("\n"),
178
+ trailing,
179
+ ),
180
+ ];
181
+
182
+ case "chatUnit": {
183
+ const { html, text } = renderChatUnit(element.items);
184
+ return [{ html, text, spacing: trailing }];
185
+ }
186
+
187
+ case "signature":
188
+ return [
189
+ signatureLine(
190
+ element.signer ?? context.brand.name,
191
+ context.brand.copyrightYear,
192
+ ),
193
+ ];
194
+
195
+ // -------------------------------------------------------------------------
196
+ // Below: elements no notification says yet.
197
+ //
198
+ // The old email layer had no component for any of these and no kind composes
199
+ // one, so — unlike everything above — there are no bytes to reproduce and the
200
+ // markup here is invented. `supports` still answers true:
201
+ // email CAN depict a list, a rule and an image, and declining them would be
202
+ // a lie about the channel. The first kind to use one should expect to style
203
+ // it properly rather than trust these.
204
+ // -------------------------------------------------------------------------
205
+
206
+ case "list":
207
+ return spaced(
208
+ element.items.map((item, i) => {
209
+ const text = `${element.ordered ? `${i + 1}.` : "•"} ${item}`;
210
+ return { html: escapeHtml(text), text };
211
+ }),
212
+ trailing,
213
+ );
214
+
215
+ case "divider":
216
+ return [
217
+ {
218
+ html: `<hr style="border: none; border-top: 1px solid #bbb; margin: ${SPACING[trailing]};">`,
219
+ text: "---",
220
+ spacing: trailing,
221
+ },
222
+ ];
223
+
224
+ case "heroImage":
225
+ return [
226
+ {
227
+ html: `<img src="${element.src}" alt="${escapeHtml(element.alt)}" style="max-width: 100%; margin: ${SPACING[trailing]};">`,
228
+ text: element.alt,
229
+ spacing: trailing,
230
+ },
231
+ ];
232
+
233
+ default: {
234
+ const _exhaustive: never = element;
235
+ return _exhaustive;
236
+ }
237
+ }
238
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The email shells — the `<!DOCTYPE>` wrapper and the plain-text joiner.
3
+ *
4
+ * Moved verbatim from the deleted `src/email/render/blocks`
5
+ * (ADR-CONTRACTS-086). These are what make one email dual-output: `render`
6
+ * composes ONE list of `EmailLine`s and both surfaces derive from it, so the two
7
+ * can never disagree about what the email says.
8
+ *
9
+ * INVARIANTS:
10
+ * - Pure functions of their input lines. No clock, no environment, no I/O.
11
+ * - These strings are the email's actual markup, and
12
+ * `../../__tests__/render-snapshot.test.ts` asserts them
13
+ * character-for-character.
14
+ */
15
+
16
+ import { MONO, type Spacing } from "./constants";
17
+
18
+ /**
19
+ * One rendered line — both presentations of one thing the notification says,
20
+ * plus the gap that follows it.
21
+ *
22
+ * Named `EmailLine` rather than `Block`: `Block` was the old layer's canonical
23
+ * unit, and demoting it to a channel's internal detail is the point of
24
+ * ADR-CONTRACTS-086. One `NotificationElement` may produce several of these (a
25
+ * key/value table produces one per row).
26
+ */
27
+ export interface EmailLine {
28
+ html: string;
29
+ text: string;
30
+ spacing: Spacing;
31
+ }
32
+
33
+ /** Wrap rendered lines in the shared `<!DOCTYPE>` monospace shell. */
34
+ export function htmlShell(lines: EmailLine[]): string {
35
+ const inner = lines
36
+ .map((line) => line.html)
37
+ .filter(Boolean)
38
+ .join("\n");
39
+ return `<!DOCTYPE html>
40
+ <html lang="en">
41
+ <head><meta charset="UTF-8"></head>
42
+ <body style="${MONO} color: #1a1a1a; margin: 0; padding: 0;">
43
+ <div style="max-width: 520px; margin: 0 auto;">
44
+ ${inner}
45
+ </div>
46
+ </body>
47
+ </html>`;
48
+ }
49
+
50
+ /**
51
+ * Join rendered lines into the plain-text email — separated per each line's
52
+ * spacing, with a trailing newline.
53
+ */
54
+ export function textShell(lines: EmailLine[]): string {
55
+ let out = "";
56
+ lines.forEach((line, i) => {
57
+ out += line.text;
58
+ if (i < lines.length - 1) out += line.spacing === "normal" ? "\n\n" : "\n";
59
+ });
60
+ return out.trim() + "\n";
61
+ }
@@ -0,0 +1,73 @@
1
+ # notifications/renderers/slack/
2
+
3
+ ## Purpose
4
+
5
+ The Slack channel (ADR-CONTRACTS-086) — `slackRenderer: Renderer<SlackMessage>`. A
6
+ **non-functional placeholder**.
7
+
8
+ Where `../sms` proves the content model survives the poorest channel, this proves
9
+ the other edge: a channel whose output is a tree of typed blocks — neither a
10
+ string nor email's `{ subject, text, html }`. Between the three, `Out` has been a
11
+ record, a string and a different record, which is `../../renderer.ts`'s "each
12
+ renderer returns its natural type" held by real modules rather than asserted about
13
+ the type itself.
14
+
15
+ The whole channel is one `index.ts` — the Block Kit subset, the escaping, and the
16
+ renderer. `../email` splits across seven modules because it carries real markup; a
17
+ stub that sprawls is a stub pretending.
18
+
19
+ ## What "placeholder" means here
20
+
21
+ - **It does not send.** No token, no channel id, no `chat.postMessage`. A renderer
22
+ renders; delivery and the decision to deliver are the backend's.
23
+ - **It is low fidelity.** `SlackBlock` is a minimal, hand-written subset of Block
24
+ Kit — enough to carry every element the channel admits, and no more. It is not
25
+ the Slack API's types and is not generated from them. The wording is invented
26
+ rather than relocated: there is no golden here, unlike `../email`.
27
+
28
+ The field names are snake_case (`image_url`, `alt_text`) because Block Kit is a
29
+ wire format and those are its names. That is deliberate, not a lapse in the
30
+ repo's camelCase convention.
31
+
32
+ ## The one decline
33
+
34
+ `supports` answers `false` for `chatUnit` alone. Block Kit has no vocabulary for
35
+ turn-taking, and flattening a conversation into a run of sections drops the
36
+ attribution that IS the meaning — which the content model names as the reason to
37
+ decline a whole element rather than approximate it.
38
+
39
+ Everything else Slack genuinely depicts, **including the `heroImage` that `../sms`
40
+ has no surface for**. That difference is the point of shipping two stubs rather
41
+ than one: same `NotificationContent`, no channel tags on it, three channels of
42
+ differing capability each keeping what they can show.
43
+
44
+ `metadata` and `notice` become `context` blocks — Slack's own device for
45
+ de-emphasised small print, which is exactly what `MetadataElement` exists to be.
46
+
47
+ ## Invariants
48
+
49
+ - PURE. `render` is a function of `(content, context)` — no clock, no
50
+ environment, no I/O. The brand and the copyright year come from `context.brand`.
51
+ `signature` is the element that used to hide a `new Date()`; here there is
52
+ nowhere to hide.
53
+ - `supports` is total over `NotificationElementType` and AGREES with `render`:
54
+ `chatUnit` is omitted, never approximated and never thrown on.
55
+ - Every user-controlled string reaching an mrkdwn surface passes through
56
+ `escapeMrkdwn` first — the same rule `../email` applies with `escapeHtml`. This
57
+ is the one thing in a low-fidelity stub that would be a real defect rather than
58
+ a fidelity gap, so `__tests__/index.test.ts` pins it.
59
+ - `renderElement` switches over all TWELVE element types, not the eleven
60
+ `supports` admits. The declined arm is unreachable; writing it out is what makes
61
+ the compiler prove the two lists agree. Do not collapse it into a `default` —
62
+ that would silently swallow a thirteenth member of the union.
63
+ - A `callToAction` with no `href` becomes text, never a button. `href` absent
64
+ means the label IS the payload (an OTP code); a button with no destination is a
65
+ lie about what the notification is asking for.
66
+ - `SlackMessage.text` is `metadata.title` — the fallback Slack shows where blocks
67
+ cannot render. The same content-level fact email spends as its subject, which is
68
+ why it lives on the content rather than in a channel's registry.
69
+ - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
70
+ lost the model; the fix for a missing fact is upstream in `compose`.
71
+ - This channel is unreachable from `../../render.ts`'s `renderEmail` and touches
72
+ no parity fixture. It cannot change a sent email, and it must not acquire the
73
+ ability to.
@@ -0,0 +1,33 @@
1
+ # notifications/renderers/slack/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Tests for the Slack channel (ADR-CONTRACTS-086).
6
+
7
+ The channel is a placeholder, so — as in `../../sms/__tests__/` — these tests do
8
+ not pin the Block Kit wording. They pin the contract a placeholder still owes:
9
+ `supports` total and in agreement with `render`, `render` pure, the brand and year
10
+ read from `RenderContext`, and a content carrying all twelve element types
11
+ rendering without throwing.
12
+
13
+ One exception: the mrkdwn escaping IS pinned exactly. Everything else here is a
14
+ fidelity gap that a real Slack kind will close; unescaped user input would be a
15
+ real defect shipped in a published package.
16
+
17
+ ## Invariants
18
+
19
+ - The Block Kit shape is NOT the specification. When a real Slack kind lands and
20
+ reworks these blocks, updating this file is expected. When `supports` and
21
+ `render` stop agreeing, or when escaping regresses, that is a defect — keep
22
+ assertions about SHAPE separate from assertions about copy so the two failures
23
+ stay easy to tell apart.
24
+ - The "takes the hero image the SMS channel has no surface for" test is the
25
+ cross-channel claim, and it is why two stubs exist rather than one: the same
26
+ `NotificationContent`, carrying no channel tags, keeps different elements on
27
+ channels of different capability. It is not redundant with the totality loop.
28
+ - NO fake timers. The year is a field on `RenderContext` (see
29
+ `../../../__tests__/README.md`).
30
+ - `ALL_TYPES` is duplicated from the sibling suites rather than shared. The list
31
+ is the totality claim itself; importing it from one place would let a new
32
+ element type be added to the union and to the shared list in one move, with no
33
+ channel noticing.