@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@company-semantics/contracts",
3
- "version": "34.0.0",
3
+ "version": "35.1.0",
4
4
  "private": false,
5
5
  "repository": {
6
6
  "type": "git",
@@ -32,9 +32,9 @@
32
32
  "types": "./src/execution/index.ts",
33
33
  "default": "./src/execution/index.ts"
34
34
  },
35
- "./email": {
36
- "types": "./src/email/index.ts",
37
- "default": "./src/email/index.ts"
35
+ "./notifications": {
36
+ "types": "./src/notifications/index.ts",
37
+ "default": "./src/notifications/index.ts"
38
38
  },
39
39
  "./ralph": {
40
40
  "types": "./src/ralph/index.ts",
package/src/index.ts CHANGED
@@ -219,20 +219,26 @@ export type {
219
219
  WriteTierDisabledMeta,
220
220
  } from "./dispatch/index";
221
221
 
222
- // Email domain types
223
- // @see ADR-CONT-034 for design rationale
222
+ // Notification domain types
223
+ // Replaces the Email* vocabulary (ADR-CONT-034), which was deleted with
224
+ // `@company-semantics/contracts/email` — email is now one channel, not the shape
225
+ // of the layer. The render layer itself (content model, renderers,
226
+ // renderToChannel) stays on the `/notifications` subpath; the root carries the
227
+ // vocabulary, as it did for email.
228
+ // @see ADR-CONTRACTS-086 for the multi-channel render layer
229
+ // @see ADR-CONTRACTS-085 for the definition + registry
224
230
  export type {
225
- EmailKind,
226
- EmailPayloads,
227
- SendEmailInput,
228
- EmailKindDefinition,
229
- } from "./email/index";
231
+ NotificationKind,
232
+ NotificationPayloads,
233
+ NotificationDefinition,
234
+ NotificationDefinitions,
235
+ } from "./notifications/index";
230
236
 
231
237
  export {
232
- EMAIL_KINDS,
233
- getEmailKindDefinition,
234
- isValidEmailKind,
235
- } from "./email/index";
238
+ NOTIFICATION_DEFINITIONS,
239
+ getNotificationKindDefinition,
240
+ isValidNotificationKind,
241
+ } from "./notifications/index";
236
242
 
237
243
  // Chat domain types
238
244
  // @see PRD-00142 for share chat design rationale
@@ -0,0 +1,142 @@
1
+ # notifications/
2
+
3
+ ## Purpose
4
+
5
+ Home of the multi-channel notification render layer (ADR-CONTRACTS-086) — the
6
+ generalisation of the old email-only render layer (`src/email/`, now deleted)
7
+ into `Definition → Payload → compose(payload, context) → Content → Renderer`.
8
+
9
+ This is the package's SOLE notification surface. `@company-semantics/contracts/email`
10
+ and every `Email*` symbol are gone, with no shim and no alias.
11
+
12
+ The domain holds the **semantic model**: the channel-agnostic vocabulary an
13
+ element names, and the renderers that decide what it looks like per channel.
14
+
15
+ It used to hold an **output-parity baseline** too — a byte-exact capture of what
16
+ the email layer rendered at 32.0.0, landed deliberately _before_ the abstraction
17
+ it protected, so every step of the migration had a mechanical answer to "did any
18
+ real email change?". That baseline did its job: the answer was "none", and it
19
+ shipped in 35.0.0. ADR-CONTRACTS-087 retired it, because a proof that the
20
+ migration changed nothing cannot also serve as permission to change something,
21
+ and email design is not frozen. `__tests__/render-snapshot.test.ts` is the fence
22
+ now; the retired bytes are readable in git at `v35.0.0`.
23
+
24
+ - `content.ts` — `NotificationContent { metadata, sections }`, `Section`, and the
25
+ flat `NotificationElement` union (greeting, body, keyValueTable, callToAction,
26
+ list, divider, warning, metadata, notice, chatUnit, signature, heroImage). What
27
+ a notification SAYS, with no markup and no channel names.
28
+ - `context.ts` — `RenderContext { brand: { name, copyrightYear } }`,
29
+ `COMPANY_NAME`, and `createRenderContext()`, whose defaults reproduce the brand
30
+ name + current year the email layer rendered.
31
+ - `renderer.ts` — `Renderer<Out>`: a channel's `id`, its `supports(elementType)`
32
+ capability, and `render(content, context)` returning that channel's own type.
33
+
34
+ And on top of THAT sits the **definition layer** (ADR-CONTRACTS-085) — the kinds
35
+ themselves, moved out of the old email layer:
36
+
37
+ - `kinds.ts` — the `NotificationKind` union. Eleven members: the kinds with
38
+ something to say.
39
+ - `payloads.ts` — `NotificationPayloads`, the facts each kind carries.
40
+ - `definition.ts` — `NotificationDefinition<K> { kind, defaults?, compose }`.
41
+ - `kinds/` — one `compose` per kind. See its README for the block → element map.
42
+ - `text.ts` — the stock wording (`NOTICE`, `ACCESS_PHRASE`) and value formats
43
+ (`titleCase`, `formatExpiry`) `compose` builds copy from. Channel-agnostic:
44
+ what a notification says is the same on every channel.
45
+ - `registry.ts` — `NOTIFICATION_DEFINITIONS` plus
46
+ `getNotificationKindDefinition` / `isValidNotificationKind`.
47
+
48
+ And on top of THAT sit the **channels** (ADR-CONTRACTS-086):
49
+
50
+ - `renderers/email/` — `emailRenderer: Renderer<RenderedEmail>`, the old
51
+ `src/email/render` layer relocated behind the seam. Its own tests prove it
52
+ reproduces the golden from composed content.
53
+ - `renderers/sms/` — `smsRenderer: Renderer<string>`, a non-functional
54
+ placeholder.
55
+ - `renderers/slack/` — `slackRenderer: Renderer<SlackMessage>`, a non-functional
56
+ placeholder.
57
+
58
+ The two placeholders are what make the seam's claims checkable. Email could
59
+ satisfy `Renderer` by construction — the seam was drawn around it — so a layer
60
+ with one channel in it proves only that the layer compiles. SMS (no markup, no
61
+ images, one string) and Slack (a tree of typed blocks) were added without editing
62
+ a single element, kind or definition, which is the property `renderers/README.md`
63
+ says the seam exists for. They do not send and their copy is invented; their shape
64
+ is not provisional.
65
+
66
+ And on top of THAT sits the **entry point** (ADR-CONTRACTS-086):
67
+
68
+ - `render.ts` — `renderToChannel(kind, payload, renderer, context?)`, the whole
69
+ pipeline in one call: look up the definition, `compose` the payload, hand the
70
+ content to a renderer. Plus `renderEmail(kind, payload, overrides?)`, the
71
+ convenience for the channel every caller has today.
72
+
73
+ The pipeline is **closed**: every parity fixture renders through
74
+ `renderToChannel` and lands on the golden byte-for-byte, so the whole
75
+ generalisation is proven to have cost no email a single character. The snapshot
76
+ suite moved here from the old `src/email/render` with the markup it documents —
77
+ its `.snap` came across untouched, keys and all.
78
+
79
+ The old `src/email/` is **deleted** — directory, `./email` subpath, and every
80
+ `Email*` symbol, with no shim. `renderEmail` here is the replacement, same name
81
+ and a different third argument (`RenderContextOverrides`, not
82
+ `RenderEmailOptions`). Consumers migrate against the published major rather than
83
+ against an alias: backend in PRD-00869, app in PRD-00870.
84
+
85
+ Two things left the package with it and have no replacement here, both
86
+ deliberately (ADR-CONTRACTS-086):
87
+
88
+ - `auth.magic_link` — a subject reserved for a kind that never had a template.
89
+ It cannot be composed, so it is not a `NotificationKind`.
90
+ - `SendEmailInput` — `to` / `idempotencyKey` are DELIVERY, and delivery is the
91
+ backend's (PRD-00869). Unlike `EmailKind` / `EmailPayloads`, it was never an
92
+ alias of anything here, so there was nothing to rename.
93
+
94
+ ## Invariants
95
+
96
+ - `__tests__/render-snapshot.test.ts` locks what this layer renders. A diff there
97
+ means a real sent email changed: review it, then regenerate. It is a regression
98
+ fence, not a specification — but since ADR-CONTRACTS-087 retired the parity
99
+ golden that used to outrank it, reading the diff is the ONLY control left. A
100
+ snapshot refreshed on sight proves nothing.
101
+ - Every kind in `NOTIFICATION_DEFINITIONS` needs at least one fixture in
102
+ `__tests__/fixtures.ts`, enforced by the tests. The registry is the source of
103
+ that list — it is total, so "every kind the entry point accepts" and "every
104
+ kind with a fixture" are the same claim.
105
+ - Rendering MUST pin the year to `FIXTURE_CLOCK`'s — by passing
106
+ `copyrightYear` in the `RenderContext`, NOT by freezing the clock. `signature`
107
+ used to read `new Date().getFullYear()` mid-render, which is why the fixtures
108
+ once needed fake timers; the year is a value now, and no test in this domain
109
+ has an opinion about what day it is.
110
+ - The captured year is **not** redacted or normalised. It is part of the output
111
+ surface the parity proof exists to protect.
112
+ - Types and **pure** functions only — no classes, no side effects, no external
113
+ imports beyond `zod` (vocabulary-guard enforced), as in the old email layer.
114
+ - `NotificationKind` has ELEVEN members where the old `EmailKind` had twelve. The
115
+ difference is `auth.magic_link`, a registered subject with no template that the
116
+ old `renderEmail` threw on. Excluding it is what makes
117
+ `NOTIFICATION_DEFINITIONS` total; adding it back "for symmetry" restores a
118
+ runtime hole. Give it a `compose` and it graduates.
119
+ - This domain depends on NOTHING outside itself. The `notifications → email`
120
+ imports that existed while both layers did are gone with the directory, and
121
+ nothing may reintroduce one.
122
+ - A kind IS its definition. Every `NotificationKind` has facts in `payloads.ts`
123
+ and a `compose` in `kinds/`, both compiler-enforced via `NotificationPayloads[K]`.
124
+ - Capability belongs to the **renderer** (`Renderer.supports`), never to an
125
+ element or a kind. Elements carry no channel tags — the moment they do, every
126
+ new channel becomes a rewrite of the content.
127
+ - `supports` and `render` MUST agree: an element `supports` declines is omitted,
128
+ not approximated and not thrown on.
129
+ - `renderers/sms` and `renderers/slack` are PLACEHOLDERS: they do not send, and
130
+ their copy is invented rather than relocated from anything. Neither is reachable
131
+ from `renderEmail` and neither touches a parity fixture — a stub cannot change a
132
+ sent email, and must not acquire the ability to. Their SHAPE, however, is not
133
+ provisional: purity, total `supports`, and a natural `Out` are the seam's claims
134
+ and these modules are where two of them stop being test doubles.
135
+ - `createRenderContext()` is the ONLY clock read in this domain, and only to
136
+ default `copyrightYear`. Renderers take the year from `RenderContext` — that is
137
+ what retires the hidden `new Date()` the old `signature()` did mid-render.
138
+ - Channel-agnostic modules MUST NOT import from `renderers/`. `COMPANY_NAME`
139
+ lives in `context.ts` and `NOTICE` / `ACCESS_PHRASE` / `titleCase` /
140
+ `formatExpiry` in `text.ts` for exactly this reason: they are the same answer
141
+ on every channel, and sourcing a default from one channel is how the seam leaks
142
+ back. The arrow runs `kinds/` → `text.ts` / `context.ts`, never into a renderer.
@@ -0,0 +1,49 @@
1
+ # notifications/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ The shared fixture corpus for the multi-channel notification render layer
6
+ (ADR-CONTRACTS-086), and the snapshot suite that documents the markup it renders
7
+ to.
8
+
9
+ - `fixtures.ts` — every implemented kind × representative payload, plus
10
+ `FIXTURE_CLOCK` and `fixtureKey`. Shared by every suite here and by
11
+ `../renderers/email/__tests__/render.test.ts`, so a kind added to the registry
12
+ is covered everywhere by being added once.
13
+ - `render-snapshot.test.ts` + `__snapshots__/` — those fixtures rendered through
14
+ the email channel, as vitest snapshots. This is the fence: **a diff here means
15
+ a real sent email changed.**
16
+
17
+ ## The retired output-parity golden (ADR-CONTRACTS-087)
18
+
19
+ `fixtures.ts` was `output-parity.golden.ts`, and it used to carry
20
+ `OUTPUT_PARITY_GOLDEN` — the exact `{ subject, text, html }` the email layer
21
+ emitted at 32.0.0, before any of this existed — asserted byte-for-byte by
22
+ `output-parity.test.ts` at the entry point and by the email renderer's own suite
23
+ at the renderer. Those two tests were the "no output change" contract for the
24
+ generalisation: `src/email/` was deleted on that proof.
25
+
26
+ **The proof passed and shipped in 35.0.0, which discharged it.** It was a claim
27
+ about a migration, not a freeze on email design — and the moment email design is
28
+ deliberately exercised, byte-equality to a deleted layer fails by construction
29
+ and keeps failing.
30
+
31
+ It was **retired rather than regenerated**, and the distinction is the point:
32
+ rewriting those bytes to match a redesign would have made the file assert
33
+ something false about a layer that no longer exists to contradict it. The bytes
34
+ are readable in git at `v35.0.0`; nothing in the tree claims to be them.
35
+
36
+ ## Invariants
37
+
38
+ - A snapshot diff means **a real sent email changed**. Review it, then
39
+ `vitest -u` — never the other way round. Regenerating on sight is the whole
40
+ hazard; the review is the control, and it is now the only one.
41
+ - The year MUST be pinned via `FIXTURE_CLOCK` on the `RenderContext`, never by
42
+ freezing the clock. `signature` bakes it into every case, so an unpinned render
43
+ fails on the next New Year's Day for calendar reasons alone. Fake timers are
44
+ not needed anywhere here — that is the impurity ADR-CONTRACTS-086 retired, so
45
+ do not reintroduce them.
46
+ - The pinned year is **not** redacted or normalised. It is part of the output
47
+ surface these suites exist to protect.
48
+ - Every kind in `NOTIFICATION_DEFINITIONS` MUST have a fixture, enforced by
49
+ `./definition.test.ts` and `./registry.test.ts`.
@@ -0,0 +1,39 @@
1
+ # notifications/\_\_tests\_\_/\_\_snapshots\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Vitest snapshots for `../render-snapshot.test.ts` — the rendered subject / text /
6
+ html of every notification kind × variant, as the email channel draws them.
7
+
8
+ `render-snapshot.test.ts.snap` moved here verbatim from the old
9
+ `src/email/render/__tests__/__snapshots__/` when the render layer was
10
+ generalised behind the channel seam (ADR-CONTRACTS-086). Not one recorded byte
11
+ changed: the suite's describe label stayed `email render` and its case labels
12
+ stayed `{kind} · {name}`, so every snapshot key still resolves against the same
13
+ file. That is the claim the move is making — the keys matched, so the bytes
14
+ matched.
15
+
16
+ The `security.alert` cases have since moved: the `warning` banner is drawn in the
17
+ product's destructive colour (ADR-CONTRACTS-087). That is the first deliberate
18
+ change to this markup since the move, and the reason the parity golden that used
19
+ to guard it is gone.
20
+
21
+ This directory exists because vitest resolves snapshots adjacent to the test
22
+ file, so the `.snap` had to travel with it.
23
+
24
+ ## Invariants
25
+
26
+ - This is **production-parity email markup**. A diff here means a real sent email
27
+ changed. Review it, then `vitest -u` — never the other way round.
28
+ - These snapshots are now the **only** automated record of that markup. Until
29
+ ADR-CONTRACTS-087 the migration's proof sat beside them in
30
+ `../output-parity.golden.ts`, a committed module `vitest -u` could not touch,
31
+ and it was the tie-breaker when the two disagreed. That proof was discharged in
32
+ 35.0.0 and retired, because it asserted byte-equality with a deleted layer and
33
+ email design is allowed to change. Nothing now catches a snapshot regenerated
34
+ under refactor pressure except reading the diff — so read it.
35
+ - The copyright year is pinned to `FIXTURE_CLOCK`'s, never the wall clock. It is
36
+ baked into every case via `signature`, so an unpinned capture would fail on the
37
+ next New Year's Day for calendar reasons alone.
38
+ - The year is **not** redacted or normalised. It is part of the output surface
39
+ these snapshots exist to protect.
@@ -0,0 +1,31 @@
1
+ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
2
+
3
+ exports[`NOTIFICATION_DEFINITIONS > titles every kind 1`] = `
4
+ {
5
+ "auth.otp · Default": "Your login code for Company Semantics",
6
+ "auth.otp · Short expiry": "Your login code for Company Semantics",
7
+ "auth.otp · With request metadata": "Your login code for Company Semantics",
8
+ "chat.shared · Default": "A chat has been shared with you",
9
+ "chat.shared · Long preview": "A chat has been shared with you",
10
+ "chat.shared · Long title": "A chat has been shared with you",
11
+ "chat.shared · No preview": "A chat has been shared with you",
12
+ "chat.shared · Short": "A chat has been shared with you",
13
+ "companyMd.access_request_approved · Editor": "Your access request was approved",
14
+ "companyMd.access_request_approved · Viewer": "Your access request was approved",
15
+ "companyMd.access_request_denied · No reason": "Your access request was reviewed",
16
+ "companyMd.access_request_denied · With reason": "Your access request was reviewed",
17
+ "companyMd.access_requested · No message": "Someone requested access to a document",
18
+ "companyMd.access_requested · With message": "Someone requested access to a document",
19
+ "org.invite · Admin": "You've been invited to join Acme Corp on Company Semantics",
20
+ "org.invite · Member": "You've been invited to join Acme Corp on Company Semantics",
21
+ "org.ownership_transfer · No note (admin)": "You've been invited to become a workspace owner",
22
+ "org.ownership_transfer · With note + from": "You've been invited to become a workspace owner",
23
+ "org.ownership_transfer_completed · Default": "Workspace ownership has been transferred",
24
+ "org.unit_owner_granted · Delegate with expiry": "You've been added as delegate owner to Platform Engineering in Acme Corp",
25
+ "org.unit_owner_granted · Unit owner + message": "You've been added as owner to Platform Engineering in Acme Corp",
26
+ "security.alert · Excessive OTP requests": "Security alert for your account",
27
+ "security.alert · Unusual login location": "Security alert for your account",
28
+ "share.granted · Document · editor · message": "Something has been shared with you",
29
+ "share.granted · Meeting · viewer · no title": "Something has been shared with you",
30
+ }
31
+ `;
@@ -1547,7 +1547,7 @@ exports[`email render > security.alert · Excessive OTP requests 1`] = `
1547
1547
  <head><meta charset="UTF-8"></head>
1548
1548
  <body style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; color: #1a1a1a; margin: 0; padding: 0;">
1549
1549
  <div style="max-width: 520px; margin: 0 auto;">
1550
- <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">🆆🅰🆁🅽🅸🅽🅶</p>
1550
+ <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;"><span style="color: #e7000b;">🆆🅰🆁🅽🅸🅽🅶</span></p>
1551
1551
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Unusual login-code activity detected.</p>
1552
1552
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Details: 12 login codes requested in 5 minutes</p>
1553
1553
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 4px 0;">If this was you, no action is needed.</p>
@@ -1587,7 +1587,7 @@ exports[`email render > security.alert · Unusual login location 1`] = `
1587
1587
  <head><meta charset="UTF-8"></head>
1588
1588
  <body style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; color: #1a1a1a; margin: 0; padding: 0;">
1589
1589
  <div style="max-width: 520px; margin: 0 auto;">
1590
- <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">🆆🅰🆁🅽🅸🅽🅶</p>
1590
+ <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;"><span style="color: #e7000b;">🆆🅰🆁🅽🅸🅽🅶</span></p>
1591
1591
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">A login from an unusual location was detected.</p>
1592
1592
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 20px 0;">Details: Login from Berlin, DE</p>
1593
1593
  <p style="font-family: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace; font-size: 13px; margin: 0 0 4px 0;">If this was you, no action is needed.</p>
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The semantic model is types-only, so these tests do two jobs a type alone
3
+ * cannot (ADR-CONTRACTS-086).
4
+ *
5
+ * They pin the MEMBERSHIP of the element union: `ALL_ELEMENT_TYPES` is typed
6
+ * `Record<NotificationElementType, true>`, so adding an element without listing
7
+ * it here fails to compile, and the runtime assertion below fails if one is
8
+ * renamed or dropped. The union is the contract every renderer's `supports` is
9
+ * total over — it should not move by accident.
10
+ *
11
+ * And they pin that the union is REACHABLE as content: the fixture composes one
12
+ * element of every kind through `NotificationContent`, which is the check that
13
+ * the model can actually express a notification rather than merely type-check.
14
+ */
15
+
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import type {
19
+ NotificationContent,
20
+ NotificationElement,
21
+ NotificationElementType,
22
+ } from "../content";
23
+
24
+ /**
25
+ * Every element type, exhaustively. The `Record` type is the lock: a new union
26
+ * member is a compile error until it is named here.
27
+ */
28
+ const ALL_ELEMENT_TYPES: Record<NotificationElementType, true> = {
29
+ greeting: true,
30
+ body: true,
31
+ keyValueTable: true,
32
+ callToAction: true,
33
+ list: true,
34
+ divider: true,
35
+ warning: true,
36
+ metadata: true,
37
+ notice: true,
38
+ chatUnit: true,
39
+ signature: true,
40
+ heroImage: true,
41
+ };
42
+
43
+ /** One element of every kind, in a plausible notification order. */
44
+ const EVERY_ELEMENT: NotificationElement[] = [
45
+ { type: "heroImage", src: "https://example.test/hero.png", alt: "Acme" },
46
+ { type: "greeting", recipientName: "Ada" },
47
+ { type: "warning" },
48
+ { type: "body", text: "Acme uses Company Semantics." },
49
+ {
50
+ type: "chatUnit",
51
+ items: [
52
+ { type: "message", role: "user", text: "Can I join?", from: "Ada" },
53
+ { type: "continuation" },
54
+ { type: "message", role: "assistant", text: "Join to accept." },
55
+ { type: "callToAction", label: "JOIN", href: "https://example.test/j" },
56
+ ],
57
+ },
58
+ { type: "callToAction", label: "123456" },
59
+ { type: "divider" },
60
+ {
61
+ type: "keyValueTable",
62
+ rows: [
63
+ { label: "From", value: "Grace" },
64
+ { label: "Role", value: "Admin" },
65
+ ],
66
+ },
67
+ { type: "list", items: ["Read", "Comment"], ordered: false },
68
+ {
69
+ type: "metadata",
70
+ label: "Request details",
71
+ rows: [{ label: "IP address", value: "203.0.113.4" }],
72
+ },
73
+ {
74
+ type: "notice",
75
+ lines: ["Sent via Company Semantics.", "No action needed."],
76
+ },
77
+ { type: "signature" },
78
+ ];
79
+
80
+ describe("NotificationElement", () => {
81
+ it("has exactly the twelve element types the renderers switch over", () => {
82
+ expect(Object.keys(ALL_ELEMENT_TYPES).sort()).toEqual([
83
+ "body",
84
+ "callToAction",
85
+ "chatUnit",
86
+ "divider",
87
+ "greeting",
88
+ "heroImage",
89
+ "keyValueTable",
90
+ "list",
91
+ "metadata",
92
+ "notice",
93
+ "signature",
94
+ "warning",
95
+ ]);
96
+ });
97
+
98
+ it("covers every element type in the fixture", () => {
99
+ const covered = new Set(EVERY_ELEMENT.map((e) => e.type));
100
+ expect([...covered].sort()).toEqual(Object.keys(ALL_ELEMENT_TYPES).sort());
101
+ });
102
+
103
+ it("discriminates exhaustively on `type`", () => {
104
+ // The `never` guard is the idiom every renderer will rely on: if a member is
105
+ // added to the union, this stops compiling rather than silently falling
106
+ // through to a default.
107
+ const describeElement = (element: NotificationElement): string => {
108
+ switch (element.type) {
109
+ case "greeting":
110
+ return element.recipientName ?? "anonymous";
111
+ case "body":
112
+ return element.text;
113
+ case "keyValueTable":
114
+ return element.rows.map((r) => r.label).join(",");
115
+ case "callToAction":
116
+ return element.href ?? element.label;
117
+ case "list":
118
+ return element.items.join(",");
119
+ case "divider":
120
+ return "divider";
121
+ case "warning":
122
+ return "warning";
123
+ case "metadata":
124
+ return element.label ?? "metadata";
125
+ case "notice":
126
+ return element.lines.join(" ");
127
+ case "chatUnit":
128
+ return element.items.map((i) => i.type).join(",");
129
+ case "signature":
130
+ return element.signer ?? "brand";
131
+ case "heroImage":
132
+ return element.alt;
133
+ default: {
134
+ const _exhaustive: never = element;
135
+ throw new Error(`Unhandled element: ${JSON.stringify(_exhaustive)}`);
136
+ }
137
+ }
138
+ };
139
+
140
+ expect(EVERY_ELEMENT.map(describeElement)).toHaveLength(
141
+ EVERY_ELEMENT.length,
142
+ );
143
+ });
144
+
145
+ it("reuses CallToAction inside a chat unit rather than mirroring it", () => {
146
+ // A CTA in a conversation is the same intent as a standalone one. If these
147
+ // ever diverge, a renderer would need two code paths for one concept.
148
+ const cta: NotificationElement = { type: "callToAction", label: "OPEN" };
149
+ const unit: NotificationElement = { type: "chatUnit", items: [cta] };
150
+ expect(unit).toEqual({
151
+ type: "chatUnit",
152
+ items: [{ type: "callToAction", label: "OPEN" }],
153
+ });
154
+ });
155
+ });
156
+
157
+ describe("NotificationContent", () => {
158
+ it("carries the headline in metadata, not in a channel registry", () => {
159
+ // The subject moving out of EMAIL_KINDS and into Content is the fusion
160
+ // ADR-CONTRACTS-086 splits — it is what lets a second renderer attach
161
+ // without restating the copy.
162
+ const content: NotificationContent = {
163
+ metadata: { kind: "org.invite", title: "Join Acme on Company Semantics" },
164
+ sections: [{ elements: EVERY_ELEMENT }],
165
+ };
166
+
167
+ expect(content.metadata.title).toBe("Join Acme on Company Semantics");
168
+ expect(content.metadata.kind).toBe("org.invite");
169
+ expect(content.sections[0].elements).toHaveLength(EVERY_ELEMENT.length);
170
+ });
171
+
172
+ it("groups elements into sections a channel may split on", () => {
173
+ const content: NotificationContent = {
174
+ metadata: { kind: "auth.otp", title: "Your login code" },
175
+ sections: [
176
+ { elements: [{ type: "greeting" }] },
177
+ { elements: [{ type: "callToAction", label: "123456" }] },
178
+ ],
179
+ };
180
+
181
+ expect(content.sections).toHaveLength(2);
182
+ expect(
183
+ content.sections.flatMap((s) => s.elements).map((e) => e.type),
184
+ ).toEqual(["greeting", "callToAction"]);
185
+ });
186
+ });
@@ -0,0 +1,72 @@
1
+ /**
2
+ * `createRenderContext` is the one place in the notifications domain allowed to
3
+ * read the clock (ADR-CONTRACTS-086). These tests pin both halves of that deal:
4
+ * the defaults reproduce today's values exactly (so hoisting the year out of
5
+ * `signature()` changes no bytes), and an override makes the context fully
6
+ * deterministic (so nothing downstream needs a fake timer).
7
+ *
8
+ * The clock is frozen here for the same reason the parity suite freezes it: the
9
+ * default year assertion would otherwise be a New Year's Day time bomb.
10
+ */
11
+
12
+ import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
13
+
14
+ import { COMPANY_NAME, createRenderContext } from "../context";
15
+ import { FIXTURE_CLOCK } from "./fixtures";
16
+
17
+ const FROZEN_YEAR = new Date(FIXTURE_CLOCK).getUTCFullYear();
18
+
19
+ beforeAll(() => {
20
+ vi.useFakeTimers();
21
+ vi.setSystemTime(new Date(FIXTURE_CLOCK));
22
+ });
23
+
24
+ afterAll(() => {
25
+ vi.useRealTimers();
26
+ });
27
+
28
+ describe("createRenderContext", () => {
29
+ it("defaults to the values the email layer rendered", () => {
30
+ // The no-op case must reproduce `signature()`'s inputs exactly: COMPANY_NAME
31
+ // and the current year. If this drifts, hoisting the clock read out of the
32
+ // render path silently changes a real sent email.
33
+ expect(createRenderContext()).toEqual({
34
+ brand: { name: COMPANY_NAME, copyrightYear: FROZEN_YEAR },
35
+ });
36
+ });
37
+
38
+ it("defaults the year from the clock", () => {
39
+ vi.setSystemTime(new Date("2030-03-04T05:06:07.000Z"));
40
+ expect(createRenderContext().brand.copyrightYear).toBe(2030);
41
+ vi.setSystemTime(new Date(FIXTURE_CLOCK));
42
+ });
43
+
44
+ it("overrides each brand field independently", () => {
45
+ expect(createRenderContext({ brand: { name: "Acme" } })).toEqual({
46
+ brand: { name: "Acme", copyrightYear: FROZEN_YEAR },
47
+ });
48
+ expect(createRenderContext({ brand: { copyrightYear: 1999 } })).toEqual({
49
+ brand: { name: COMPANY_NAME, copyrightYear: 1999 },
50
+ });
51
+ });
52
+
53
+ it("is deterministic — a pinned year ignores the clock entirely", () => {
54
+ const pinned = { brand: { copyrightYear: 2020 } };
55
+ const before = createRenderContext(pinned);
56
+ vi.setSystemTime(new Date("2031-01-01T00:00:00.000Z"));
57
+ expect(createRenderContext(pinned)).toEqual(before);
58
+ vi.setSystemTime(new Date(FIXTURE_CLOCK));
59
+ });
60
+
61
+ it("returns a fresh context per call — callers cannot alias each other", () => {
62
+ const a = createRenderContext();
63
+ const b = createRenderContext();
64
+ expect(a).not.toBe(b);
65
+ expect(a.brand).not.toBe(b.brand);
66
+ });
67
+
68
+ it("treats an empty override object as no override", () => {
69
+ expect(createRenderContext({})).toEqual(createRenderContext());
70
+ expect(createRenderContext({ brand: {} })).toEqual(createRenderContext());
71
+ });
72
+ });