@company-semantics/contracts 33.0.0 → 35.0.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 (78) hide show
  1. package/package.json +4 -4
  2. package/src/api/generated-spec-hash.ts +2 -2
  3. package/src/api/generated.ts +2 -2
  4. package/src/index.ts +17 -11
  5. package/src/notifications/README.md +140 -0
  6. package/src/notifications/__tests__/README.md +54 -0
  7. package/src/notifications/__tests__/__snapshots__/README.md +32 -0
  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__/kinds.test.ts +81 -0
  12. package/src/notifications/__tests__/output-parity.golden.ts +363 -0
  13. package/src/notifications/__tests__/output-parity.test.ts +122 -0
  14. package/src/notifications/__tests__/registry.test.ts +184 -0
  15. package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -29
  16. package/src/notifications/__tests__/renderer.test.ts +181 -0
  17. package/src/notifications/content.ts +249 -0
  18. package/src/notifications/context.ts +70 -0
  19. package/src/notifications/definition.ts +82 -0
  20. package/src/notifications/index.ts +104 -0
  21. package/src/notifications/kinds/README.md +57 -0
  22. package/src/notifications/kinds/auth-otp.ts +99 -0
  23. package/src/notifications/kinds/chat-shared.ts +56 -0
  24. package/src/notifications/kinds/company-md-access-approved.ts +57 -0
  25. package/src/notifications/kinds/company-md-access-denied.ts +61 -0
  26. package/src/notifications/kinds/company-md-access-requested.ts +65 -0
  27. package/src/notifications/kinds/index.ts +19 -0
  28. package/src/notifications/kinds/org-invite.ts +62 -0
  29. package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
  30. package/src/notifications/kinds/ownership-transfer.ts +68 -0
  31. package/src/notifications/kinds/security-alert.ts +78 -0
  32. package/src/notifications/kinds/share-granted.ts +74 -0
  33. package/src/notifications/kinds/unit-owner-granted.ts +95 -0
  34. package/src/notifications/kinds.ts +68 -0
  35. package/src/{email/types.ts → notifications/payloads.ts} +33 -68
  36. package/src/notifications/registry.ts +107 -0
  37. package/src/notifications/render.ts +106 -0
  38. package/src/notifications/renderer.ts +50 -0
  39. package/src/notifications/renderers/README.md +50 -0
  40. package/src/notifications/renderers/email/README.md +74 -0
  41. package/src/notifications/renderers/email/__tests__/README.md +29 -0
  42. package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
  43. package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
  44. package/src/notifications/renderers/email/constants.ts +47 -0
  45. package/src/notifications/renderers/email/cta.ts +63 -0
  46. package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
  47. package/src/notifications/renderers/email/index.ts +73 -0
  48. package/src/notifications/renderers/email/render.ts +225 -0
  49. package/src/notifications/renderers/email/shells.ts +60 -0
  50. package/src/notifications/renderers/slack/README.md +73 -0
  51. package/src/notifications/renderers/slack/__tests__/README.md +33 -0
  52. package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
  53. package/src/notifications/renderers/slack/index.ts +261 -0
  54. package/src/notifications/renderers/sms/README.md +69 -0
  55. package/src/notifications/renderers/sms/__tests__/README.md +31 -0
  56. package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
  57. package/src/notifications/renderers/sms/index.ts +131 -0
  58. package/src/notifications/text.ts +52 -0
  59. package/src/email/README.md +0 -51
  60. package/src/email/__tests__/registry.test.ts +0 -161
  61. package/src/email/index.ts +0 -36
  62. package/src/email/registry.ts +0 -155
  63. package/src/email/render/auth-otp.ts +0 -71
  64. package/src/email/render/blocks.ts +0 -281
  65. package/src/email/render/chat-shared.ts +0 -35
  66. package/src/email/render/company-md-access-approved.ts +0 -42
  67. package/src/email/render/company-md-access-denied.ts +0 -43
  68. package/src/email/render/company-md-access-requested.ts +0 -46
  69. package/src/email/render/constants.ts +0 -18
  70. package/src/email/render/index.ts +0 -58
  71. package/src/email/render/org-invite.ts +0 -40
  72. package/src/email/render/ownership-transfer-completed.ts +0 -41
  73. package/src/email/render/ownership-transfer.ts +0 -42
  74. package/src/email/render/render-email.ts +0 -194
  75. package/src/email/render/security-alert.ts +0 -61
  76. package/src/email/render/share-granted.ts +0 -52
  77. package/src/email/render/unit-owner-granted.ts +0 -60
  78. /package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -0
@@ -0,0 +1,261 @@
1
+ /**
2
+ * The Slack channel (ADR-CONTRACTS-086) — a NON-FUNCTIONAL placeholder.
3
+ *
4
+ * Where `../sms` proves the content model survives the poorest channel, this one
5
+ * proves the other edge: a channel whose output is neither a string nor email's
6
+ * `{ subject, text, html }`, but a tree of typed blocks. Between the three, `Out`
7
+ * has been a record, a string and a different record — which is the seam property
8
+ * `../../renderer.ts` states in prose ("each renderer returns its natural type",
9
+ * no `ChannelOutput` supertype) held by real modules rather than asserted.
10
+ *
11
+ * It is a placeholder in two specific senses:
12
+ *
13
+ * - **It does not send.** No token, no channel id, no `chat.postMessage`. A
14
+ * renderer renders; delivery and the decision to deliver are the backend's.
15
+ * - **It is low fidelity.** This is a minimal, hand-written subset of Block Kit —
16
+ * enough to carry every element the channel admits, and no more. It is not the
17
+ * Slack API's types, it is not generated from them, and it is invented rather
18
+ * than relocated: there is no golden here, unlike `../email`.
19
+ *
20
+ * INVARIANTS:
21
+ * - Pure — a function of `(content, context)`. No clock, no environment, no I/O.
22
+ * The brand and the copyright year come from `context.brand`.
23
+ * - `supports` is total, and agrees with `render`: `chatUnit` is omitted, never
24
+ * approximated and never thrown on.
25
+ * - Every user-controlled string reaching an mrkdwn surface passes through
26
+ * `escapeMrkdwn` first — the same rule `../email` applies with `escapeHtml`.
27
+ * - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
28
+ * lost the model — the fix for a missing fact is upstream in `compose`.
29
+ */
30
+
31
+ import type { NotificationContent, NotificationElement } from "../../content";
32
+ import type { RenderContext } from "../../context";
33
+ import type { Renderer } from "../../renderer";
34
+
35
+ // =============================================================================
36
+ // The Block Kit subset
37
+ // =============================================================================
38
+
39
+ /** Formatted text. `mrkdwn` is Slack's dialect, not Markdown. */
40
+ export interface SlackMrkdwnText {
41
+ type: "mrkdwn";
42
+ text: string;
43
+ }
44
+
45
+ /** Literal text — Slack forbids `mrkdwn` inside a button label. */
46
+ export interface SlackPlainText {
47
+ type: "plain_text";
48
+ text: string;
49
+ }
50
+
51
+ /** A paragraph. The workhorse block: most elements land here. */
52
+ export interface SlackSectionBlock {
53
+ type: "section";
54
+ text: SlackMrkdwnText;
55
+ }
56
+
57
+ /** A rule. The one element with a native counterpart. */
58
+ export interface SlackDividerBlock {
59
+ type: "divider";
60
+ }
61
+
62
+ /** An image. Field names are Slack's wire format, hence the snake_case. */
63
+ export interface SlackImageBlock {
64
+ type: "image";
65
+ image_url: string;
66
+ alt_text: string;
67
+ }
68
+
69
+ /** A button. `url` absent makes it inert — see `renderElement`'s `callToAction`. */
70
+ export interface SlackButtonElement {
71
+ type: "button";
72
+ text: SlackPlainText;
73
+ url?: string;
74
+ }
75
+
76
+ /** A row of buttons. */
77
+ export interface SlackActionsBlock {
78
+ type: "actions";
79
+ elements: SlackButtonElement[];
80
+ }
81
+
82
+ /** De-emphasised small print. Slack's own device for secondary detail. */
83
+ export interface SlackContextBlock {
84
+ type: "context";
85
+ elements: SlackMrkdwnText[];
86
+ }
87
+
88
+ /** Every block this channel can emit. */
89
+ export type SlackBlock =
90
+ | SlackSectionBlock
91
+ | SlackDividerBlock
92
+ | SlackImageBlock
93
+ | SlackActionsBlock
94
+ | SlackContextBlock;
95
+
96
+ /**
97
+ * One Slack message — this channel's natural output type.
98
+ *
99
+ * `text` is the notification-and-fallback string Slack shows where blocks cannot
100
+ * render (a push notification, a screen reader). It is `metadata.title`, which is
101
+ * the same field email spends as its subject — one content-level fact, two
102
+ * channel-level uses, which is why it lives on the content rather than in a
103
+ * channel's registry.
104
+ */
105
+ export interface SlackMessage {
106
+ text: string;
107
+ blocks: SlackBlock[];
108
+ }
109
+
110
+ // =============================================================================
111
+ // Rendering
112
+ // =============================================================================
113
+
114
+ /**
115
+ * Slack's three reserved characters. Everything else is literal, so — unlike
116
+ * HTML — this is the whole of the escaping rule.
117
+ */
118
+ function escapeMrkdwn(value: string): string {
119
+ return value
120
+ .replace(/&/g, "&")
121
+ .replace(/</g, "&lt;")
122
+ .replace(/>/g, "&gt;");
123
+ }
124
+
125
+ /** A section carrying pre-escaped mrkdwn. */
126
+ function section(text: string): SlackSectionBlock {
127
+ return { type: "section", text: { type: "mrkdwn", text } };
128
+ }
129
+
130
+ /** A context block carrying pre-escaped mrkdwn. */
131
+ function context(text: string): SlackContextBlock {
132
+ return { type: "context", elements: [{ type: "mrkdwn", text }] };
133
+ }
134
+
135
+ /**
136
+ * One element as zero or more blocks.
137
+ *
138
+ * Zero and many are both real here — `chatUnit` yields nothing, and an element
139
+ * is free to become several blocks — which is why this returns an array where
140
+ * `../sms`'s equivalent returns a string. The switch is total over all twelve
141
+ * types rather than the eleven `supports` admits: the declined arm is unreachable
142
+ * (`render` filters on `supports` first), but writing it out is what makes the
143
+ * compiler prove the two lists agree. A `default` arm would silently swallow a
144
+ * thirteenth member of the union.
145
+ */
146
+ function renderElement(
147
+ element: NotificationElement,
148
+ renderContext: RenderContext,
149
+ ): SlackBlock[] {
150
+ switch (element.type) {
151
+ case "greeting":
152
+ return [section(`Hi ${escapeMrkdwn(element.recipientName ?? "there")},`)];
153
+ case "body":
154
+ return [section(escapeMrkdwn(element.text))];
155
+ case "keyValueTable":
156
+ // The `:` is presentation and lives in no element (see ../../kinds/README.md).
157
+ return [
158
+ section(
159
+ element.rows
160
+ .map(
161
+ (row) =>
162
+ `*${escapeMrkdwn(row.label)}:* ${escapeMrkdwn(row.value)}`,
163
+ )
164
+ .join("\n"),
165
+ ),
166
+ ];
167
+ case "callToAction":
168
+ // `href` absent means the label IS the payload (an OTP code), so it becomes
169
+ // text rather than a button with a fabricated destination.
170
+ return element.href
171
+ ? [
172
+ {
173
+ type: "actions",
174
+ elements: [
175
+ {
176
+ type: "button",
177
+ text: { type: "plain_text", text: element.label },
178
+ url: element.href,
179
+ },
180
+ ],
181
+ },
182
+ ]
183
+ : [section(`*${escapeMrkdwn(element.label)}*`)];
184
+ case "list":
185
+ return [
186
+ section(
187
+ element.items
188
+ .map((item, index) =>
189
+ element.ordered
190
+ ? `${index + 1}. ${escapeMrkdwn(item)}`
191
+ : `• ${escapeMrkdwn(item)}`,
192
+ )
193
+ .join("\n"),
194
+ ),
195
+ ];
196
+ case "divider":
197
+ return [{ type: "divider" }];
198
+ case "warning":
199
+ // Fieldless by design: the banner IS the content and its prominence is the
200
+ // channel's to choose. Slack's answer is an emoji and bold text.
201
+ return [section(":warning: *Security notice*")];
202
+ case "metadata":
203
+ // A context block, because this element exists to BE de-emphasisable —
204
+ // diagnostic detail about the request, distinct from the facts themselves.
205
+ return [
206
+ context(
207
+ [
208
+ element.label ? `*${escapeMrkdwn(element.label)}*` : "",
209
+ ...element.rows.map(
210
+ (row) => `${escapeMrkdwn(row.label)}: ${escapeMrkdwn(row.value)}`,
211
+ ),
212
+ ]
213
+ .filter(Boolean)
214
+ .join(" · "),
215
+ ),
216
+ ];
217
+ case "notice":
218
+ return [context(element.lines.map(escapeMrkdwn).join("\n"))];
219
+ case "signature":
220
+ // Both the signer fallback and the year come from the context. This is the
221
+ // element that used to hide a `new Date()`; here there is nowhere to hide.
222
+ return [
223
+ context(
224
+ `© ${renderContext.brand.copyrightYear} ${escapeMrkdwn(
225
+ element.signer ?? renderContext.brand.name,
226
+ )}`,
227
+ ),
228
+ ];
229
+ case "heroImage":
230
+ return [{ type: "image", image_url: element.src, alt_text: element.alt }];
231
+ case "chatUnit":
232
+ return [];
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Slack. Sections are one message — a channel MAY split on them (three messages
238
+ * in a thread is a real Slack shape) but choosing to is a delivery decision, and
239
+ * delivery is not this package's.
240
+ */
241
+ export const slackRenderer: Renderer<SlackMessage> = {
242
+ id: "slack",
243
+ /**
244
+ * One decline: `chatUnit`.
245
+ *
246
+ * Block Kit has no vocabulary for turn-taking. Flattening a conversation into a
247
+ * run of sections would drop the attribution that IS the meaning, which the
248
+ * content model names as the reason to decline the whole element rather than
249
+ * approximate it. Everything else Slack can genuinely depict — including the
250
+ * hero image `../sms` has no surface for, which is the point of having two
251
+ * stubs rather than one.
252
+ */
253
+ supports: (elementType) => elementType !== "chatUnit",
254
+ render: (content: NotificationContent, renderContext: RenderContext) => ({
255
+ text: content.metadata.title,
256
+ blocks: content.sections
257
+ .flatMap((section_) => section_.elements)
258
+ .filter((element) => slackRenderer.supports(element.type))
259
+ .flatMap((element) => renderElement(element, renderContext)),
260
+ }),
261
+ };
@@ -0,0 +1,69 @@
1
+ # notifications/renderers/sms/
2
+
3
+ ## Purpose
4
+
5
+ The SMS channel (ADR-CONTRACTS-086) — `smsRenderer: Renderer<string>`. A
6
+ **non-functional placeholder**.
7
+
8
+ `../email` could satisfy `Renderer` by construction: the seam was drawn around it,
9
+ so it proves nothing about whether the seam generalises. SMS is the test. It is
10
+ the poorest channel this vocabulary will ever meet — no markup, no images, no
11
+ layout, one string — and if `NotificationContent` survives being projected onto it
12
+ without a new element, a new field or a channel tag, then the content model is
13
+ channel-agnostic rather than email-shaped with a `Renderer` label on it.
14
+
15
+ The whole channel is one `index.ts`. `../email` splits across seven modules
16
+ because it carries real markup; a stub that sprawls is a stub pretending.
17
+
18
+ ## What "placeholder" means here
19
+
20
+ Two senses, neither of them a TODO to be closed by tightening this file:
21
+
22
+ - **It does not send.** No delivery, no segmentation into 160-character parts, no
23
+ phone number. A renderer renders; the rest is the backend's, and the moment it
24
+ appears here the seam has leaked.
25
+ - **It is low fidelity.** The wording is invented, not relocated. Unlike
26
+ `../email` there are no bytes to reproduce and no golden to answer to, so the
27
+ first kind actually sent over SMS should expect to author its copy properly
28
+ rather than trust these strings.
29
+
30
+ What is NOT provisional is the shape: `Out = string`, `supports` total, `render`
31
+ pure. Those are the seam's claims, and this module is one of the two places they
32
+ are stated by real code instead of a test double.
33
+
34
+ ## The three declines
35
+
36
+ `supports` answers `false` for `heroImage`, `chatUnit` and `divider`. Each is a
37
+ fact about the channel, never an edit of the notification:
38
+
39
+ | element | why |
40
+ | ----------- | ----------------------------------------------------------------------------------------------------------------------- |
41
+ | `heroImage` | There is no image surface. Substituting `alt` would approximate the element, which answering `false` forbids. |
42
+ | `chatUnit` | The turn-taking IS the meaning; a flat run of text cannot attribute a turn. The model says decline rather than flatten. |
43
+ | `divider` | A thematic break is a visual device and carries no content by design. SMS has no visual vocabulary to draw one with. |
44
+
45
+ Everything else is the universal subset — what survives on the poorest channel.
46
+ Note that `metadata` and `notice` are NOT declined: they are depictable, and
47
+ dropping them to save characters would be an editorial choice smuggled in as a
48
+ capability claim. Capability is "can this channel depict it", not "should it".
49
+
50
+ ## Invariants
51
+
52
+ - PURE. `render` is a function of `(content, context)` — no clock, no
53
+ environment, no I/O. The brand comes from `context.brand`.
54
+ - `supports` is total over `NotificationElementType` and AGREES with `render`: a
55
+ declined element is omitted, never approximated and never thrown on.
56
+ `__tests__/index.test.ts` asserts the whole rendered string for exactly this
57
+ reason — a decline is only visible in what is absent.
58
+ - `renderElement` switches over all TWELVE element types, not the nine `supports`
59
+ admits. The declined arms are unreachable; writing them out is what makes the
60
+ compiler prove the two lists agree. Do not collapse them into a `default` —
61
+ that would silently accept a thirteenth member of the union.
62
+ - The signature carries no copyright year. A copyright line is a footer device and
63
+ an SMS has no footer — a fidelity choice, not a purity hole. The brand still
64
+ comes from the context.
65
+ - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
66
+ lost the model; the fix for a missing fact is upstream in `compose`.
67
+ - This channel is unreachable from `../../render.ts`'s `renderEmail` and touches
68
+ no parity fixture. It cannot change a sent email, and it must not acquire the
69
+ ability to.
@@ -0,0 +1,31 @@
1
+ # notifications/renderers/sms/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Tests for the SMS channel (ADR-CONTRACTS-086).
6
+
7
+ The channel is a placeholder, which changes what these tests are for. There is no
8
+ golden and no bytes to reproduce, so `index.test.ts` does not pin the wording —
9
+ that is invented and expected to change the first time a kind is really sent over
10
+ SMS. It pins the contract a placeholder still owes: `supports` total and in
11
+ agreement with `render`, `render` pure, the brand read from `RenderContext`, and a
12
+ content carrying all twelve element types going through without throwing.
13
+
14
+ ## Invariants
15
+
16
+ - The wording is NOT the specification. When a real SMS kind lands and rewrites
17
+ these strings, updating this file is expected and cheap. When `supports` and
18
+ `render` stop agreeing, that is a defect — the two kinds of failure must stay
19
+ easy to tell apart, so keep assertions about SHAPE separate from assertions
20
+ about copy.
21
+ - The full-string assertion in "omits declined elements" is deliberate and must
22
+ stay full-string. A decline is only observable in what is ABSENT — a partial
23
+ match cannot see an approximated hero image or a flattened chat turn.
24
+ - NO fake timers. The year is a field on `RenderContext`, and keeping every test
25
+ in this domain clock-free is the improvement ADR-CONTRACTS-086 was built to
26
+ deliver — do not reintroduce them (see `../../../__tests__/README.md`).
27
+ - `ALL_TYPES` is duplicated from `../../../__tests__/renderer.test.ts` and
28
+ `../../email/__tests__/render.test.ts` rather than shared. The list is the
29
+ totality claim itself; importing it from one place would let a new element type
30
+ be added to the union and to the shared list in one move, with no channel
31
+ noticing.
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The SMS channel is a placeholder, so what is worth pinning is NOT its wording —
3
+ * that is invented and expected to change when a kind is actually sent over SMS.
4
+ * What is pinned here is the contract a placeholder still owes: that `supports` is
5
+ * total, that it AGREES with `render`, that `render` is pure and reads the brand
6
+ * from the context, and that a content carrying all twelve element types goes
7
+ * through without throwing.
8
+ *
9
+ * The one assertion on exact bytes exists to prove the declines are silent — the
10
+ * only way to see "omitted, never approximated" is to look at the whole string.
11
+ */
12
+
13
+ import { describe, expect, it } from "vitest";
14
+
15
+ import type {
16
+ NotificationContent,
17
+ NotificationElementType,
18
+ } from "../../../content";
19
+ import type { RenderContext } from "../../../context";
20
+ import { smsRenderer } from "../index";
21
+
22
+ const CONTEXT: RenderContext = {
23
+ brand: { name: "Company Semantics", copyrightYear: 2026 },
24
+ };
25
+
26
+ /** Every element type, so `render` is exercised over the whole union. */
27
+ const CONTENT: NotificationContent = {
28
+ metadata: { kind: "org.invite", title: "Join Acme" },
29
+ sections: [
30
+ {
31
+ elements: [
32
+ { type: "heroImage", src: "https://example.test/h.png", alt: "Acme" },
33
+ { type: "greeting", recipientName: "Ada" },
34
+ { type: "body", text: "Acme uses Company Semantics." },
35
+ {
36
+ type: "keyValueTable",
37
+ rows: [{ label: "Org", value: "Acme" }],
38
+ },
39
+ { type: "list", items: ["Read", "Write"] },
40
+ { type: "divider" },
41
+ { type: "warning" },
42
+ {
43
+ type: "chatUnit",
44
+ items: [{ type: "message", role: "user", text: "Hello?" }],
45
+ },
46
+ {
47
+ type: "metadata",
48
+ label: "Request details",
49
+ rows: [{ label: "IP", value: "203.0.113.7" }],
50
+ },
51
+ { type: "notice", lines: ["Not expecting this?", "No action needed."] },
52
+ { type: "callToAction", label: "JOIN", href: "https://example.test/j" },
53
+ { type: "signature" },
54
+ ],
55
+ },
56
+ ],
57
+ };
58
+
59
+ const ALL_TYPES: NotificationElementType[] = [
60
+ "greeting",
61
+ "body",
62
+ "keyValueTable",
63
+ "callToAction",
64
+ "list",
65
+ "divider",
66
+ "warning",
67
+ "metadata",
68
+ "notice",
69
+ "chatUnit",
70
+ "signature",
71
+ "heroImage",
72
+ ];
73
+
74
+ const DECLINED: NotificationElementType[] = [
75
+ "heroImage",
76
+ "chatUnit",
77
+ "divider",
78
+ ];
79
+
80
+ describe("smsRenderer", () => {
81
+ it("declares a stable channel id", () => {
82
+ expect(smsRenderer.id).toBe("sms");
83
+ });
84
+
85
+ it("answers supports() for every element type — the predicate is total", () => {
86
+ for (const type of ALL_TYPES) {
87
+ expect(typeof smsRenderer.supports(type), type).toBe("boolean");
88
+ }
89
+ });
90
+
91
+ it("declines exactly the elements it has no surface for", () => {
92
+ for (const type of ALL_TYPES) {
93
+ expect(smsRenderer.supports(type), type).toBe(!DECLINED.includes(type));
94
+ }
95
+ });
96
+
97
+ it("renders a content carrying every element type without throwing", () => {
98
+ expect(() => smsRenderer.render(CONTENT, CONTEXT)).not.toThrow();
99
+ });
100
+
101
+ it("omits declined elements rather than approximating them", () => {
102
+ const rendered = smsRenderer.render(CONTENT, CONTEXT);
103
+
104
+ // The whole string, because the claim is about what is ABSENT: no alt text
105
+ // standing in for the hero image, no flattened chat turn, no "---" rule.
106
+ expect(rendered).toBe(
107
+ [
108
+ "Hi Ada,",
109
+ "Acme uses Company Semantics.",
110
+ "Org: Acme",
111
+ "- Read",
112
+ "- Write",
113
+ "Security notice.",
114
+ "Request details:",
115
+ "IP: 203.0.113.7",
116
+ "Not expecting this? No action needed.",
117
+ "JOIN: https://example.test/j",
118
+ "- Company Semantics",
119
+ ].join("\n"),
120
+ );
121
+ expect(rendered).not.toContain("Acme uses Company Semantics.\n---");
122
+ expect(rendered).not.toContain("Hello?");
123
+ expect(rendered).not.toContain("example.test/h.png");
124
+ });
125
+
126
+ it("returns its natural type — a string, not a record", () => {
127
+ expect(typeof smsRenderer.render(CONTENT, CONTEXT)).toBe("string");
128
+ });
129
+
130
+ it("reads the brand from context, never from a clock or a constant", () => {
131
+ // No fake timer in this file, and no COMPANY_NAME import: swapping the
132
+ // context is enough to move the sign-off.
133
+ const pinned: RenderContext = {
134
+ brand: { name: "Acme", copyrightYear: 1999 },
135
+ };
136
+ expect(smsRenderer.render(CONTENT, pinned)).toContain("- Acme");
137
+ });
138
+
139
+ it("lets an element's signer override the context brand", () => {
140
+ const onBehalf: NotificationContent = {
141
+ metadata: { kind: "org.invite", title: "Join Acme" },
142
+ sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
143
+ };
144
+ expect(smsRenderer.render(onBehalf, CONTEXT)).toBe("- Grace");
145
+ });
146
+
147
+ it("renders a call to action without an href as a bare label", () => {
148
+ // `href` absent means the label IS the payload (an OTP code). A channel must
149
+ // not fabricate a destination for it.
150
+ const otp: NotificationContent = {
151
+ metadata: { kind: "auth.otp", title: "Your code" },
152
+ sections: [{ elements: [{ type: "callToAction", label: "123456" }] }],
153
+ };
154
+ expect(smsRenderer.render(otp, CONTEXT)).toBe("123456");
155
+ });
156
+
157
+ it("is pure — same inputs, same bytes", () => {
158
+ expect(smsRenderer.render(CONTENT, CONTEXT)).toBe(
159
+ smsRenderer.render(CONTENT, CONTEXT),
160
+ );
161
+ });
162
+ });
@@ -0,0 +1,131 @@
1
+ /**
2
+ * The SMS channel (ADR-CONTRACTS-086) — a NON-FUNCTIONAL placeholder.
3
+ *
4
+ * This renderer exists to prove a claim `renderers/README.md` makes: a channel is
5
+ * added by writing a directory here, touching no element, no kind and no
6
+ * definition. Email could satisfy `Renderer` by construction — the seam was drawn
7
+ * around it. SMS could not: it is the poorest channel this vocabulary will ever
8
+ * meet, so if the content model survives being projected onto a plain string with
9
+ * no markup, no images and no layout, the model is channel-agnostic rather than
10
+ * email-shaped with a `Renderer` label on it.
11
+ *
12
+ * It is a placeholder in two specific senses, and neither is a TODO to be closed
13
+ * by tightening this file:
14
+ *
15
+ * - **It does not send.** A renderer renders. No delivery, no segmentation into
16
+ * 160-character parts, no phone number — those are the backend's, and the
17
+ * moment they appear here the seam has leaked.
18
+ * - **It is low fidelity.** The wording below is invented, not relocated: unlike
19
+ * `../email`, there are no bytes to reproduce and no golden to answer to. The
20
+ * first kind actually sent over SMS should expect to author its copy properly
21
+ * rather than trust these strings.
22
+ *
23
+ * `Out` is `string`, not a record. That is the second seam property, stated by a
24
+ * real module rather than a test double: email returns `{ subject, text, html }`
25
+ * and an SMS is one string, and neither is coerced toward the other.
26
+ *
27
+ * INVARIANTS:
28
+ * - Pure — a function of `(content, context)`. No clock, no environment, no I/O.
29
+ * The brand comes from `context.brand`.
30
+ * - `supports` is total, and agrees with `render`: the three element types it
31
+ * declines are omitted, never approximated and never thrown on.
32
+ * - Nothing here reads `metadata.kind`. A renderer that special-cases a kind has
33
+ * lost the model — the fix for a missing fact is upstream in `compose`.
34
+ */
35
+
36
+ import type { NotificationContent, NotificationElement } from "../../content";
37
+ import type { RenderContext } from "../../context";
38
+ import type { Renderer } from "../../renderer";
39
+
40
+ /**
41
+ * One element as one run of plain text, or `""` for nothing to say.
42
+ *
43
+ * The switch is total over all twelve element types rather than over the nine
44
+ * `supports` admits: the declined arms are unreachable — `render` filters on
45
+ * `supports` before calling this — but writing them out is what makes the
46
+ * compiler prove the two lists agree. A `default` arm would silently accept a
47
+ * thirteenth member of the union, which is precisely the drift this shape
48
+ * prevents.
49
+ */
50
+ function renderElement(
51
+ element: NotificationElement,
52
+ context: RenderContext,
53
+ ): string {
54
+ switch (element.type) {
55
+ case "greeting":
56
+ return `Hi ${element.recipientName ?? "there"},`;
57
+ case "body":
58
+ return element.text;
59
+ case "keyValueTable":
60
+ // The `:` is presentation and lives in no element (see ../../kinds/README.md).
61
+ return element.rows.map((row) => `${row.label}: ${row.value}`).join("\n");
62
+ case "callToAction":
63
+ // `href` absent means the label IS the payload (an OTP code). Rendering it
64
+ // as a bare label is the only honest option — a channel must not fabricate
65
+ // a destination for it.
66
+ return element.href ? `${element.label}: ${element.href}` : element.label;
67
+ case "list":
68
+ return element.items
69
+ .map((item, index) =>
70
+ element.ordered ? `${index + 1}. ${item}` : `- ${item}`,
71
+ )
72
+ .join("\n");
73
+ case "warning":
74
+ // Fieldless by design: the banner IS the content, and its wording belongs
75
+ // to the channel. A text message has no banner, so it has a sentence.
76
+ return "Security notice.";
77
+ case "metadata":
78
+ return [
79
+ element.label ? `${element.label}:` : "",
80
+ ...element.rows.map((row) => `${row.label}: ${row.value}`),
81
+ ]
82
+ .filter(Boolean)
83
+ .join("\n");
84
+ case "notice":
85
+ // Joined with a space, not a newline: the lines are one closing remark, and
86
+ // an SMS has no vertical budget to spend separating them.
87
+ return element.lines.join(" ");
88
+ case "signature":
89
+ // The brand comes from the context — never a constant here — but the year
90
+ // does not appear: a copyright line is a footer device and an SMS has no
91
+ // footer. Dropping it is a fidelity choice, not a purity hole.
92
+ return `- ${element.signer ?? context.brand.name}`;
93
+ case "divider":
94
+ case "heroImage":
95
+ case "chatUnit":
96
+ return "";
97
+ }
98
+ }
99
+
100
+ /**
101
+ * SMS. Sections are one message — the seam SMS declines to split on, for now:
102
+ * splitting one notification into several texts is a delivery decision, and
103
+ * delivery is not this package's.
104
+ */
105
+ export const smsRenderer: Renderer<string> = {
106
+ id: "sms",
107
+ /**
108
+ * The universal subset — what survives on the poorest channel.
109
+ *
110
+ * Three declines, each a fact about the channel rather than an edit of the
111
+ * notification:
112
+ * - `heroImage` — there is no image surface. Substituting `alt` would be
113
+ * approximating an element, which `supports` answering `false` forbids.
114
+ * - `chatUnit` — the turn-taking IS the meaning, and a flat run of text cannot
115
+ * attribute a turn. The content model says decline the whole element rather
116
+ * than flatten it.
117
+ * - `divider` — a thematic break is a visual device and carries no content by
118
+ * design. SMS has no visual vocabulary to draw it with.
119
+ */
120
+ supports: (elementType) =>
121
+ elementType !== "heroImage" &&
122
+ elementType !== "chatUnit" &&
123
+ elementType !== "divider",
124
+ render: (content: NotificationContent, context: RenderContext) =>
125
+ content.sections
126
+ .flatMap((section) => section.elements)
127
+ .filter((element) => smsRenderer.supports(element.type))
128
+ .map((element) => renderElement(element, context))
129
+ .filter(Boolean)
130
+ .join("\n"),
131
+ };