@company-semantics/contracts 35.0.0 → 36.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 (36) hide show
  1. package/package.json +7 -2
  2. package/src/notifications/README.md +21 -19
  3. package/src/notifications/__tests__/README.md +42 -47
  4. package/src/notifications/__tests__/__snapshots__/README.md +15 -8
  5. package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
  6. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +2580 -215
  7. package/src/notifications/__tests__/context.test.ts +5 -5
  8. package/src/notifications/__tests__/definition.test.ts +2 -2
  9. package/src/notifications/__tests__/fixtures.ts +226 -0
  10. package/src/notifications/__tests__/kinds.test.ts +2 -3
  11. package/src/notifications/__tests__/registry.test.ts +27 -27
  12. package/src/notifications/__tests__/render-snapshot.test.ts +10 -6
  13. package/src/notifications/content.ts +13 -1
  14. package/src/notifications/index.ts +15 -9
  15. package/src/notifications/kinds/README.md +2 -2
  16. package/src/notifications/render.ts +6 -6
  17. package/src/notifications/renderers/email/README.md +132 -17
  18. package/src/notifications/renderers/email/__tests__/README.md +19 -16
  19. package/src/notifications/renderers/email/__tests__/amp.test.ts +217 -0
  20. package/src/notifications/renderers/email/__tests__/colors.test.ts +227 -0
  21. package/src/notifications/renderers/email/__tests__/render.test.ts +130 -36
  22. package/src/notifications/renderers/email/__tests__/styles.test.ts +111 -0
  23. package/src/notifications/renderers/email/chat.ts +44 -32
  24. package/src/notifications/renderers/email/colors.ts +342 -0
  25. package/src/notifications/renderers/email/constants.ts +27 -3
  26. package/src/notifications/renderers/email/cta.ts +60 -10
  27. package/src/notifications/renderers/email/index.ts +23 -8
  28. package/src/notifications/renderers/email/render.ts +31 -15
  29. package/src/notifications/renderers/email/shells.ts +115 -11
  30. package/src/notifications/renderers/email/styles.ts +361 -0
  31. package/src/notifications/renderers/slack/README.md +66 -25
  32. package/src/notifications/renderers/slack/__tests__/index.test.ts +249 -41
  33. package/src/notifications/renderers/slack/index.ts +192 -107
  34. package/src/notifications/text.ts +2 -2
  35. package/src/notifications/__tests__/output-parity.golden.ts +0 -363
  36. package/src/notifications/__tests__/output-parity.test.ts +0 -122
@@ -12,13 +12,13 @@
12
12
  import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
13
13
 
14
14
  import { COMPANY_NAME, createRenderContext } from "../context";
15
- import { OUTPUT_PARITY_CLOCK } from "./output-parity.golden";
15
+ import { FIXTURE_CLOCK } from "./fixtures";
16
16
 
17
- const FROZEN_YEAR = new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear();
17
+ const FROZEN_YEAR = new Date(FIXTURE_CLOCK).getUTCFullYear();
18
18
 
19
19
  beforeAll(() => {
20
20
  vi.useFakeTimers();
21
- vi.setSystemTime(new Date(OUTPUT_PARITY_CLOCK));
21
+ vi.setSystemTime(new Date(FIXTURE_CLOCK));
22
22
  });
23
23
 
24
24
  afterAll(() => {
@@ -38,7 +38,7 @@ describe("createRenderContext", () => {
38
38
  it("defaults the year from the clock", () => {
39
39
  vi.setSystemTime(new Date("2030-03-04T05:06:07.000Z"));
40
40
  expect(createRenderContext().brand.copyrightYear).toBe(2030);
41
- vi.setSystemTime(new Date(OUTPUT_PARITY_CLOCK));
41
+ vi.setSystemTime(new Date(FIXTURE_CLOCK));
42
42
  });
43
43
 
44
44
  it("overrides each brand field independently", () => {
@@ -55,7 +55,7 @@ describe("createRenderContext", () => {
55
55
  const before = createRenderContext(pinned);
56
56
  vi.setSystemTime(new Date("2031-01-01T00:00:00.000Z"));
57
57
  expect(createRenderContext(pinned)).toEqual(before);
58
- vi.setSystemTime(new Date(OUTPUT_PARITY_CLOCK));
58
+ vi.setSystemTime(new Date(FIXTURE_CLOCK));
59
59
  });
60
60
 
61
61
  it("returns a fresh context per call — callers cannot alias each other", () => {
@@ -31,7 +31,7 @@ import type { NotificationContent, NotificationElement } from "../content";
31
31
  import { createRenderContext } from "../context";
32
32
  import type { NotificationKind } from "../kinds";
33
33
  import { NOTIFICATION_DEFINITIONS } from "../registry";
34
- import { OUTPUT_PARITY_FIXTURES } from "./output-parity.golden";
34
+ import { NOTIFICATION_FIXTURES } from "./fixtures";
35
35
 
36
36
  const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
37
37
 
@@ -40,7 +40,7 @@ function compose(
40
40
  kind: NotificationKind,
41
41
  context = CONTEXT,
42
42
  ): NotificationContent {
43
- const fixture = OUTPUT_PARITY_FIXTURES.find((f) => f.kind === kind);
43
+ const fixture = NOTIFICATION_FIXTURES.find((f) => f.kind === kind);
44
44
  if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
45
45
  const fn = NOTIFICATION_DEFINITIONS[kind].compose as (
46
46
  p: unknown,
@@ -0,0 +1,226 @@
1
+ /**
2
+ * The notification fixture corpus: every implemented kind × representative
3
+ * payload. Shared by `./render-snapshot.test.ts`, `./definition.test.ts`,
4
+ * `./registry.test.ts` and `../renderers/email/__tests__/render.test.ts`, so a
5
+ * kind added to the registry is covered everywhere by being added once here.
6
+ *
7
+ * WAS `output-parity.golden.ts` (ADR-CONTRACTS-087). These same inputs used to
8
+ * carry a frozen `OUTPUT_PARITY_GOLDEN` of the `{ subject, text, html }` the
9
+ * email layer emitted at 32.0.0, asserted byte-for-byte to prove the
10
+ * multi-channel generalisation (ADR-CONTRACTS-086) changed no output. That proof
11
+ * passed and shipped in 35.0.0, which discharged it: it was a claim about a
12
+ * migration, not a freeze on email design. The bytes were retired rather than
13
+ * rewritten — editing them to match a deliberate redesign would have made the
14
+ * file assert something false about a layer that no longer exists. They remain
15
+ * readable in git at `v35.0.0`.
16
+ *
17
+ * `./render-snapshot.test.ts` is now the fence: it locks the same fixtures'
18
+ * rendered output, and unlike the golden it is regenerable on purpose, because
19
+ * changing how email looks is now an ordinary reviewed diff.
20
+ *
21
+ * Capture-time options are not recorded. The old `renderEmail` took a
22
+ * `RenderEmailOptions`, and one fixture set `includeRequestMetadata: true`; the
23
+ * entry point has no equivalent because `auth.otp`'s `compose` gates request
24
+ * details on whether the payload carries them — and that fixture's payload
25
+ * carries both, so the gate opens anyway.
26
+ */
27
+
28
+ import type { NotificationKind } from "../kinds";
29
+ import type { NotificationPayloads } from "../payloads";
30
+
31
+ /**
32
+ * The instant every fixture renders under.
33
+ *
34
+ * A pinned year, not a frozen clock. The copyright year is a value on
35
+ * `RenderContext`, so a test states this instant instead of stubbing time —
36
+ * which is the impurity ADR-CONTRACTS-086 retired. Pinned rather than left to
37
+ * the wall clock so no snapshot is due to fail on New Year's Day.
38
+ */
39
+ export const FIXTURE_CLOCK = "2026-07-15T00:00:00.000Z";
40
+
41
+ /** One case: a kind rendered with a representative payload. */
42
+ export interface NotificationFixture {
43
+ readonly kind: NotificationKind;
44
+ /** Variant label, unique within a kind. */
45
+ readonly name: string;
46
+ readonly payload: NotificationPayloads[NotificationKind];
47
+ }
48
+
49
+ /** Stable label for a fixture — also its snapshot key. */
50
+ export function fixtureKey(kind: NotificationKind, name: string): string {
51
+ return `${kind} · ${name}`;
52
+ }
53
+
54
+ const fixtures: NotificationFixture[] = [];
55
+ function add<K extends NotificationKind>(
56
+ kind: K,
57
+ name: string,
58
+ payload: NotificationPayloads[K],
59
+ ): void {
60
+ fixtures.push({ kind, name, payload });
61
+ }
62
+
63
+ const APP = "https://app.companysemantics.ai";
64
+
65
+ add("auth.otp", "Default", { otp: "123456", expiresInMinutes: 10 });
66
+ add("auth.otp", "Short expiry", { otp: "902413", expiresInMinutes: 1 });
67
+ add("auth.otp", "With request metadata", {
68
+ otp: "246810",
69
+ expiresInMinutes: 5,
70
+ requestIp: "203.0.113.4",
71
+ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7)",
72
+ });
73
+
74
+ add("org.invite", "Admin", {
75
+ inviterName: "Alex Rivera",
76
+ orgName: "Acme Corp",
77
+ role: "admin",
78
+ acceptUrl: `${APP}/invite/abc123`,
79
+ expiresAt: "2026-06-13T00:00:00.000Z",
80
+ });
81
+ add("org.invite", "Member", {
82
+ inviterName: "Alex Rivera",
83
+ orgName: "Acme Corp",
84
+ role: "member",
85
+ acceptUrl: `${APP}/invite/def456`,
86
+ expiresAt: "2026-06-13T00:00:00.000Z",
87
+ });
88
+
89
+ add("org.unit_owner_granted", "Unit owner + message", {
90
+ granterName: "Jordan Lee",
91
+ recipientName: "Sam Chen",
92
+ orgName: "Acme Corp",
93
+ unitName: "Platform Engineering",
94
+ roleLabel: "Unit owner",
95
+ ctaUrl: `${APP}/org/unit/42`,
96
+ message: "Welcome aboard — glad to have you owning this team.",
97
+ });
98
+ add("org.unit_owner_granted", "Delegate with expiry", {
99
+ granterName: "Jordan Lee",
100
+ orgName: "Acme Corp",
101
+ unitName: "Platform Engineering",
102
+ roleLabel: "Delegate",
103
+ ctaUrl: `${APP}/org/unit/42`,
104
+ expiresAt: "2026-06-13T00:00:00.000Z",
105
+ });
106
+
107
+ add("org.ownership_transfer", "With note + from", {
108
+ orgName: "Acme Corp",
109
+ acceptUrl: `${APP}/owner-transfer/accept/tok123`,
110
+ expiresAt: "2026-06-13T00:00:00.000Z",
111
+ note: "Handing this over as I move to an advisory role.",
112
+ fromName: "Jordan Lee",
113
+ });
114
+ add("org.ownership_transfer", "No note (admin)", {
115
+ orgName: "Acme Corp",
116
+ acceptUrl: `${APP}/owner-transfer/accept/tok456`,
117
+ expiresAt: "2026-06-13T00:00:00.000Z",
118
+ });
119
+
120
+ add("org.ownership_transfer_completed", "Default", {
121
+ orgName: "Acme Corp",
122
+ newOwnerEmail: "new.owner@acme.com",
123
+ });
124
+
125
+ add("security.alert", "Excessive OTP requests", {
126
+ alertType: "excessive_otp_requests",
127
+ details: "12 login codes requested in 5 minutes",
128
+ timestamp: "2026-07-12T14:30:00Z",
129
+ });
130
+ add("security.alert", "Unusual login location", {
131
+ alertType: "unusual_login_location",
132
+ details: "Login from Berlin, DE",
133
+ timestamp: "2026-07-12T14:30:00Z",
134
+ });
135
+
136
+ const CHAT_BASE = {
137
+ sharedByName: "Alex Rivera",
138
+ visibility: "private",
139
+ } as const;
140
+ add("chat.shared", "Default", {
141
+ ...CHAT_BASE,
142
+ chatTitle: "API design discussion",
143
+ shareUrl: `${APP}/share/abc123`,
144
+ previewText:
145
+ "Here are my thoughts on the REST API design for the new authentication endpoints.",
146
+ });
147
+ add("chat.shared", "No preview", {
148
+ ...CHAT_BASE,
149
+ chatTitle: "Quick sync notes",
150
+ shareUrl: `${APP}/share/xyz789`,
151
+ });
152
+ add("chat.shared", "Long title", {
153
+ ...CHAT_BASE,
154
+ visibility: "public",
155
+ chatTitle:
156
+ "This is a very long chat title that should be truncated to fit the layout properly",
157
+ shareUrl: `${APP}/share/long456`,
158
+ previewText: "The assistant response preview text.",
159
+ });
160
+ add("chat.shared", "Long preview", {
161
+ ...CHAT_BASE,
162
+ chatTitle: "Code review feedback",
163
+ shareUrl: `${APP}/share/preview789`,
164
+ previewText:
165
+ "I reviewed the pull request and found several areas that need improvement. The authentication logic needs better error handling, and the database queries could be optimized.",
166
+ });
167
+ add("chat.shared", "Short", {
168
+ ...CHAT_BASE,
169
+ chatTitle: "Hi",
170
+ shareUrl: `${APP}/share/short123`,
171
+ previewText: "Hello!",
172
+ });
173
+
174
+ add("share.granted", "Document · editor · message", {
175
+ granterName: "Jordan Lee",
176
+ recipientName: "Sam Chen",
177
+ entityLabel: "document",
178
+ entityTitle: "Q3 Roadmap",
179
+ accessLevel: "editor",
180
+ ctaUrl: `${APP}/doc/roadmap`,
181
+ message: "Take a look before Thursday's planning.",
182
+ });
183
+ add("share.granted", "Meeting · viewer · no title", {
184
+ granterName: "Jordan Lee",
185
+ entityLabel: "meeting",
186
+ accessLevel: "viewer",
187
+ ctaUrl: `${APP}/meeting/8842`,
188
+ });
189
+
190
+ add("companyMd.access_requested", "With message", {
191
+ requesterName: "Sam Chen",
192
+ docTitle: "Engineering Handbook",
193
+ message: "I'd like to contribute the onboarding section.",
194
+ reviewUrl: `${APP}/doc/handbook?request=req_123`,
195
+ });
196
+ add("companyMd.access_requested", "No message", {
197
+ requesterName: "Sam Chen",
198
+ docTitle: "Engineering Handbook",
199
+ reviewUrl: `${APP}/doc/handbook?request=req_456`,
200
+ });
201
+
202
+ add("companyMd.access_request_approved", "Editor", {
203
+ approverName: "Jordan Lee",
204
+ docTitle: "Engineering Handbook",
205
+ accessLevel: "editor",
206
+ docUrl: `${APP}/doc/handbook`,
207
+ });
208
+ add("companyMd.access_request_approved", "Viewer", {
209
+ approverName: "Jordan Lee",
210
+ docTitle: "Engineering Handbook",
211
+ accessLevel: "viewer",
212
+ docUrl: `${APP}/doc/handbook`,
213
+ });
214
+
215
+ add("companyMd.access_request_denied", "With reason", {
216
+ approverName: "Jordan Lee",
217
+ docTitle: "Engineering Handbook",
218
+ reason: "This doc is limited to the platform team for now.",
219
+ });
220
+ add("companyMd.access_request_denied", "No reason", {
221
+ approverName: "Jordan Lee",
222
+ docTitle: "Engineering Handbook",
223
+ });
224
+
225
+ /** The corpus — every implemented kind × representative variants. */
226
+ export const NOTIFICATION_FIXTURES: readonly NotificationFixture[] = fixtures;
@@ -17,9 +17,8 @@
17
17
  * union member is added without being named here;
18
18
  * - the frozen list in the first test, which fails at runtime if one is removed;
19
19
  * - `./registry.test.ts`, which pins a definition for every member;
20
- * - `./output-parity.test.ts`, which pins golden bytes captured from the old
21
- * layer — for every member. That is what now carries "these are exactly the
22
- * kinds email could render": every kind here has bytes that layer produced.
20
+ * - `./render-snapshot.test.ts`, which pins rendered output for every member.
21
+ * That is what carries "these are exactly the kinds email can render".
23
22
  *
24
23
  * The DIVERGENCE is the easiest thing in this change to mistake for an omission,
25
24
  * so it keeps a test of its own. `auth.magic_link` was a registered subject with
@@ -28,11 +28,7 @@ import {
28
28
  isValidNotificationKind,
29
29
  NOTIFICATION_DEFINITIONS,
30
30
  } from "../registry";
31
- import {
32
- fixtureKey,
33
- OUTPUT_PARITY_FIXTURES,
34
- OUTPUT_PARITY_GOLDEN,
35
- } from "./output-parity.golden";
31
+ import { fixtureKey, NOTIFICATION_FIXTURES } from "./fixtures";
36
32
 
37
33
  const KINDS = Object.keys(NOTIFICATION_DEFINITIONS) as NotificationKind[];
38
34
 
@@ -45,7 +41,7 @@ const CONTEXT = createRenderContext({ brand: { copyrightYear: 2026 } });
45
41
  * so a second set of inputs would be a second thing to keep true.
46
42
  */
47
43
  function payloadFor(kind: NotificationKind): unknown {
48
- const fixture = OUTPUT_PARITY_FIXTURES.find((f) => f.kind === kind);
44
+ const fixture = NOTIFICATION_FIXTURES.find((f) => f.kind === kind);
49
45
  if (!fixture) throw new Error(`No parity fixture for kind: ${kind}`);
50
46
  return fixture.payload;
51
47
  }
@@ -112,28 +108,32 @@ describe("NOTIFICATION_DEFINITIONS", () => {
112
108
  }
113
109
  });
114
110
 
115
- it("titles every kind exactly as email subjected it at 32.0.0", () => {
116
- // The subject MOVED from EMAIL_KINDS into compose it was not rewritten.
117
- // Asserted against the frozen golden's subjects, across every parity
118
- // fixture, so the interpolation the old `resolveSubject`/`subjectData` did
119
- // (the `{orgName}` fill, and unit_owner_granted's derived `roleWord`) is
120
- // proven to survive as a template literal rather than assumed to.
111
+ it("titles every kind", () => {
112
+ // The title is what the email channel turns into a subject, and the
113
+ // interpolation `compose` does (the `{orgName}` fill, and
114
+ // unit_owner_granted's derived `roleWord`) is easy to break silently — a
115
+ // template literal that stops filling yields a plausible-looking title with
116
+ // a hole in it. Snapshotting every fixture's title makes that a reviewable
117
+ // diff, and isolates the claim at COMPOSE: `./render-snapshot.test.ts`
118
+ // covers the same titles once the renderer has had them.
121
119
  //
122
- // The oracle used to be the old `renderEmail`'s own output. The golden is
123
- // what replaced it when that layer was deleted, and it is the stronger of
124
- // the two: it is the bytes 32.0.0 actually shipped, not live code that could
125
- // have drifted alongside this registry. This isolates the claim at COMPOSE —
126
- // `./output-parity.test.ts` proves the same subjects survive the renderer.
127
- for (const fixture of OUTPUT_PARITY_FIXTURES) {
128
- const compose = NOTIFICATION_DEFINITIONS[fixture.kind].compose as (
129
- p: unknown,
130
- c: typeof CONTEXT,
131
- ) => { metadata: { title: string } };
132
-
133
- expect(compose(fixture.payload, CONTEXT).metadata.title).toBe(
134
- OUTPUT_PARITY_GOLDEN[fixtureKey(fixture.kind, fixture.name)].subject,
135
- );
136
- }
120
+ // These titles were asserted against the frozen 32.0.0 golden's subjects
121
+ // until ADR-CONTRACTS-087 retired it the values below are those subjects,
122
+ // recaptured here as an ordinary snapshot. The migration proof they served
123
+ // was discharged in 35.0.0.
124
+ const titles = Object.fromEntries(
125
+ NOTIFICATION_FIXTURES.map((fixture) => {
126
+ const compose = NOTIFICATION_DEFINITIONS[fixture.kind].compose as (
127
+ p: unknown,
128
+ c: typeof CONTEXT,
129
+ ) => { metadata: { title: string } };
130
+ return [
131
+ fixtureKey(fixture.kind, fixture.name),
132
+ compose(fixture.payload, CONTEXT).metadata.title,
133
+ ];
134
+ }),
135
+ );
136
+ expect(titles).toMatchSnapshot();
137
137
  });
138
138
  });
139
139
 
@@ -13,11 +13,15 @@
13
13
  * every snapshot key still resolves. That is the whole claim of the move: the
14
14
  * keys still match, so the bytes are still the bytes.
15
15
  *
16
- * Read this alongside `./output-parity.test.ts`, which is the load-bearing one.
17
- * A snapshot can be regenerated with a keystroke; the golden cannot. This file
18
- * exists to make the markup reviewable, not to be the proof.
16
+ * This file used to be the reviewable companion to `./output-parity.test.ts`,
17
+ * which was the load-bearing one: a snapshot can be regenerated with a
18
+ * keystroke, a committed golden cannot, so the golden was the proof and this was
19
+ * the documentation. ADR-CONTRACTS-087 retired that golden once its migration
20
+ * proof shipped in 35.0.0 — so this file is now BOTH. There is no longer a
21
+ * second oracle to catch a snapshot refreshed on autopilot; the diff you read
22
+ * before typing `vitest -u` is the control.
19
23
  *
20
- * The clock is pinned to `OUTPUT_PARITY_CLOCK`'s year rather than left to run.
24
+ * The clock is pinned to `FIXTURE_CLOCK`'s year rather than left to run.
21
25
  * These snapshots used to be captured under the wall clock — with the copyright
22
26
  * year baked into every one of them — so they were due to fail on the next New
23
27
  * Year's Day for calendar reasons alone. The year is a value in `RenderContext`
@@ -32,11 +36,11 @@ import type { NotificationPayloads } from "../payloads";
32
36
  import { renderEmail } from "../render";
33
37
  import { NOTIFICATION_DEFINITIONS } from "../registry";
34
38
  import type { RenderedEmail } from "../renderers/email";
35
- import { OUTPUT_PARITY_CLOCK } from "./output-parity.golden";
39
+ import { FIXTURE_CLOCK } from "./fixtures";
36
40
 
37
41
  /** Pinned so no wall-clock year can reach a snapshot. */
38
42
  const CONTEXT: RenderContextOverrides = {
39
- brand: { copyrightYear: new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear() },
43
+ brand: { copyrightYear: new Date(FIXTURE_CLOCK).getUTCFullYear() },
40
44
  };
41
45
 
42
46
  interface Fixture {
@@ -138,11 +138,23 @@ export interface Signature {
138
138
  signer?: string;
139
139
  }
140
140
 
141
- /** A leading image. `alt` is required — a text-only channel has nothing else. */
141
+ /**
142
+ * A leading image. `alt` is required — a text-only channel has nothing else.
143
+ *
144
+ * `width`/`height` are the image's intrinsic pixel dimensions. They are optional
145
+ * because a channel that can measure an image itself does not need to be told, and
146
+ * requiring them would put a rendering constraint into channel-agnostic content.
147
+ * But a channel MAY be unable to draw one without them: AMP's `<amp-img>` requires
148
+ * explicit dimensions to reserve layout before the image loads, so the email
149
+ * channel's AMP surface degrades to the `alt` text when they are absent
150
+ * (ADR-CONTRACTS-089). Supplying them is what makes the image drawable everywhere.
151
+ */
142
152
  export interface HeroImage {
143
153
  type: "heroImage";
144
154
  src: string;
145
155
  alt: string;
156
+ width?: number;
157
+ height?: number;
146
158
  }
147
159
 
148
160
  // =============================================================================
@@ -58,17 +58,23 @@ export type { RenderedEmail } from "./renderers/email";
58
58
 
59
59
  export { emailRenderer } from "./renderers/email";
60
60
 
61
+ // The `Slack*` block types this barrel used to export were a hand-written subset
62
+ // of Block Kit and are GONE (ADR-CONTRACTS-090). Slack's own `@slack/types` is
63
+ // the vocabulary now, re-exported here so a consumer need not depend on it
64
+ // directly: `SlackBlock` -> `KnownBlock`, `SlackSectionBlock` -> `SectionBlock`,
65
+ // `SlackMrkdwnText` -> `MrkdwnElement`, `SlackPlainText` -> `PlainTextElement`.
61
66
  export type {
62
- SlackActionsBlock,
63
- SlackBlock,
64
- SlackButtonElement,
65
- SlackContextBlock,
66
- SlackDividerBlock,
67
- SlackImageBlock,
67
+ ActionsBlock,
68
+ ContextBlock,
69
+ DividerBlock,
70
+ HeaderBlock,
71
+ ImageBlock,
72
+ KnownBlock,
73
+ MrkdwnElement,
74
+ PlainTextElement,
75
+ RichTextBlock,
76
+ SectionBlock,
68
77
  SlackMessage,
69
- SlackMrkdwnText,
70
- SlackPlainText,
71
- SlackSectionBlock,
72
78
  } from "./renderers/slack";
73
79
 
74
80
  export { slackRenderer } from "./renderers/slack";
@@ -50,8 +50,8 @@ template literal the compiler checks.
50
50
  - Definitions take `formatExpiry`, `titleCase`, `NOTICE` and `ACCESS_PHRASE` from
51
51
  `../text` — never from a renderer. They are content (phrasing and display
52
52
  format), not markup, so they sit beside the kinds rather than inside the
53
- channel that used to own them. The output-parity golden asserts these strings
54
- byte-for-byte; a second copy of one is the exact failure this migration exists
53
+ channel that used to own them. `../__tests__/render-snapshot.test.ts` locks
54
+ these strings; a second copy of one is the exact failure this migration exists
55
55
  to prevent.
56
56
  - A file per kind, named after its old `email/render` counterpart. Kinds do not
57
57
  share a module — the point is that a kind is self-contained.
@@ -4,8 +4,7 @@
4
4
  * `renderToChannel` is the whole pipeline in one call: look the kind up in
5
5
  * `NOTIFICATION_DEFINITIONS`, `compose` its payload into channel-agnostic
6
6
  * `NotificationContent`, hand that to a `Renderer`. Every piece already existed
7
- * and is already proven byte-for-byte against the output-parity golden this
8
- * module is the wiring, not a rendering change.
7
+ * when this was written the module is the wiring, not a rendering change.
9
8
  *
10
9
  * What it deliberately does NOT do is dispatch. The old `renderEmail` was a
11
10
  * twelve-arm `switch` that threw on the one arm it did not have; here the
@@ -30,10 +29,11 @@
30
29
  * only to build one when the caller supplies none, and only via
31
30
  * `createRenderContext` — the single sanctioned clock read in this domain.
32
31
  * Pass a context to get reproducible bytes.
33
- * - Output MUST equal what the old email layer emitted, byte for byte, for every
34
- * fixture in `__tests__/output-parity.golden.ts`. That is what
35
- * `__tests__/output-parity.test.ts` asserts, and it is the only reason this
36
- * rewiring is safe. The golden is now the sole record of those bytes.
32
+ * - Output is locked by `__tests__/render-snapshot.test.ts`, across every fixture
33
+ * in `__tests__/fixtures.ts`. A diff there means a real sent email changed:
34
+ * review it, then regenerate. This rewiring was originally proven safe by
35
+ * byte-equality with the old email layer; that proof shipped in 35.0.0 and was
36
+ * retired with the golden (ADR-CONTRACTS-087).
37
37
  * - A kind's `defaults` layer UNDER the caller's wishes, never over them. A full
38
38
  * `RenderContext` has no gaps left to fill, so it wins outright; partial
39
39
  * overrides are filled from `defaults` first and the package default last.