@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.
- package/package.json +4 -4
- package/src/index.ts +17 -11
- package/src/notifications/README.md +142 -0
- package/src/notifications/__tests__/README.md +49 -0
- package/src/notifications/__tests__/__snapshots__/README.md +39 -0
- package/src/notifications/__tests__/__snapshots__/registry.test.ts.snap +31 -0
- package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +2 -2
- package/src/notifications/__tests__/content.test.ts +186 -0
- package/src/notifications/__tests__/context.test.ts +72 -0
- package/src/notifications/__tests__/definition.test.ts +222 -0
- package/src/notifications/__tests__/fixtures.ts +226 -0
- package/src/notifications/__tests__/kinds.test.ts +80 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +62 -29
- package/src/notifications/__tests__/renderer.test.ts +181 -0
- package/src/notifications/content.ts +249 -0
- package/src/notifications/context.ts +70 -0
- package/src/notifications/definition.ts +82 -0
- package/src/notifications/index.ts +104 -0
- package/src/notifications/kinds/README.md +57 -0
- package/src/notifications/kinds/auth-otp.ts +99 -0
- package/src/notifications/kinds/chat-shared.ts +56 -0
- package/src/notifications/kinds/company-md-access-approved.ts +57 -0
- package/src/notifications/kinds/company-md-access-denied.ts +61 -0
- package/src/notifications/kinds/company-md-access-requested.ts +65 -0
- package/src/notifications/kinds/index.ts +19 -0
- package/src/notifications/kinds/org-invite.ts +62 -0
- package/src/notifications/kinds/ownership-transfer-completed.ts +57 -0
- package/src/notifications/kinds/ownership-transfer.ts +68 -0
- package/src/notifications/kinds/security-alert.ts +78 -0
- package/src/notifications/kinds/share-granted.ts +74 -0
- package/src/notifications/kinds/unit-owner-granted.ts +95 -0
- package/src/notifications/kinds.ts +68 -0
- package/src/{email/types.ts → notifications/payloads.ts} +33 -68
- package/src/notifications/registry.ts +107 -0
- package/src/notifications/render.ts +106 -0
- package/src/notifications/renderer.ts +50 -0
- package/src/notifications/renderers/README.md +50 -0
- package/src/notifications/renderers/email/README.md +78 -0
- package/src/notifications/renderers/email/__tests__/README.md +32 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +188 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +54 -107
- package/src/notifications/renderers/email/constants.ts +64 -0
- package/src/notifications/renderers/email/cta.ts +63 -0
- package/src/{email/render → notifications/renderers/email}/escape-html.ts +5 -1
- package/src/notifications/renderers/email/index.ts +73 -0
- package/src/notifications/renderers/email/render.ts +238 -0
- package/src/notifications/renderers/email/shells.ts +61 -0
- package/src/notifications/renderers/slack/README.md +73 -0
- package/src/notifications/renderers/slack/__tests__/README.md +33 -0
- package/src/notifications/renderers/slack/__tests__/index.test.ts +201 -0
- package/src/notifications/renderers/slack/index.ts +261 -0
- package/src/notifications/renderers/sms/README.md +69 -0
- package/src/notifications/renderers/sms/__tests__/README.md +31 -0
- package/src/notifications/renderers/sms/__tests__/index.test.ts +162 -0
- package/src/notifications/renderers/sms/index.ts +131 -0
- package/src/notifications/text.ts +52 -0
- package/src/email/README.md +0 -51
- package/src/email/__tests__/registry.test.ts +0 -161
- package/src/email/index.ts +0 -36
- package/src/email/registry.ts +0 -155
- package/src/email/render/auth-otp.ts +0 -71
- package/src/email/render/blocks.ts +0 -281
- package/src/email/render/chat-shared.ts +0 -35
- package/src/email/render/company-md-access-approved.ts +0 -42
- package/src/email/render/company-md-access-denied.ts +0 -43
- package/src/email/render/company-md-access-requested.ts +0 -46
- package/src/email/render/constants.ts +0 -18
- package/src/email/render/index.ts +0 -58
- package/src/email/render/org-invite.ts +0 -40
- package/src/email/render/ownership-transfer-completed.ts +0 -41
- package/src/email/render/ownership-transfer.ts +0 -42
- package/src/email/render/render-email.ts +0 -194
- package/src/email/render/security-alert.ts +0 -61
- package/src/email/render/share-granted.ts +0 -52
- package/src/email/render/unit-owner-granted.ts +0 -60
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# notifications/renderers/email/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
The email channel (ADR-CONTRACTS-086) — `emailRenderer: Renderer<RenderedEmail>`.
|
|
6
|
+
|
|
7
|
+
This is the old `src/email/render` turned inside out — and, now that it is
|
|
8
|
+
deleted, what replaced it. That layer offered templates a component per intent
|
|
9
|
+
(`greeting()`, `keyValue()`, `footer()`) and each template chose which to call;
|
|
10
|
+
here the CONTENT names the intent and this directory is the only thing that knows
|
|
11
|
+
the markup. The strings are the same strings.
|
|
12
|
+
|
|
13
|
+
| module | what it holds |
|
|
14
|
+
| ---------------- | ----------------------------------------------------------------------- |
|
|
15
|
+
| `index.ts` | `emailRenderer` + `RenderedEmail`, the channel's natural type |
|
|
16
|
+
| `render.ts` | each `NotificationElement` → its `<p>`/table markup, and spacing |
|
|
17
|
+
| `chat.ts` | `chatUnit` → HTML bubbles and plain-text box art |
|
|
18
|
+
| `cta.ts` | the `>> LABEL <<` button, shared by a standalone CTA and a chat one |
|
|
19
|
+
| `shells.ts` | `htmlShell` / `textShell` — what makes one email dual-output |
|
|
20
|
+
| `constants.ts` | the styling vocabulary (`MONO`, `FONT_SIZE`, `SPACING`) + `COMPANY_URL` |
|
|
21
|
+
| `escape-html.ts` | `escapeHtml` |
|
|
22
|
+
|
|
23
|
+
`constants.ts` and `escape-html.ts` moved here from the old `src/email/render`
|
|
24
|
+
before it was deleted. The brand NAME is deliberately not among them: it is
|
|
25
|
+
`../../context`'s `COMPANY_NAME`, because it is the same answer on every channel
|
|
26
|
+
and renderers read it from `context.brand`.
|
|
27
|
+
|
|
28
|
+
## Spacing — the thing the content model does not carry
|
|
29
|
+
|
|
30
|
+
The old layer passed a `Spacing` per block; `../../kinds` deliberately dropped
|
|
31
|
+
it, because a margin is a channel's answer. So this channel re-derives one, with
|
|
32
|
+
a single rule:
|
|
33
|
+
|
|
34
|
+
> An element renders as one or more lines. Lines WITHIN an element hug
|
|
35
|
+
> (`"tight"`); the element's LAST line carries the trailing gap, which is
|
|
36
|
+
> `"normal"` — or `"none"` when a `signature` follows, because `signature` owns
|
|
37
|
+
> the blank above its own rule.
|
|
38
|
+
|
|
39
|
+
That reproduces every hand-authored spacing choice in all eleven old templates.
|
|
40
|
+
The one place it did not fall out was `security.alert`'s reassurance/denial
|
|
41
|
+
couplet, and the fix was to say so in the CONTENT — one `body` with a newline —
|
|
42
|
+
rather than to special-case a kind here. See `../../kinds/security-alert.ts`.
|
|
43
|
+
|
|
44
|
+
`list`, `divider` and `heroImage` have no counterpart in the old layer and no
|
|
45
|
+
kind composes one, so their markup is invented rather than relocated. `supports`
|
|
46
|
+
still answers true — email can depict them — but the first kind to use one should
|
|
47
|
+
expect to style it properly rather than trust the placeholder.
|
|
48
|
+
|
|
49
|
+
## Invariants
|
|
50
|
+
|
|
51
|
+
- Output is locked by `../../__tests__/render-snapshot.test.ts`, across every
|
|
52
|
+
fixture in `../../__tests__/fixtures.ts` — a diff there means a real sent email
|
|
53
|
+
changed, so review it before regenerating. Byte-identity with the old
|
|
54
|
+
`src/email/render` was the whole constraint of the migration; it held, shipped
|
|
55
|
+
in 35.0.0, and ADR-CONTRACTS-087 retired the golden that proved it, because
|
|
56
|
+
that proof could not double as permission to change the design.
|
|
57
|
+
- `__tests__/render.test.ts` asserts the rules that outlive any redesign — every
|
|
58
|
+
element type supported, subject from the content's title, year and signer from
|
|
59
|
+
context, the `:` restored, user content escaped. Markup belongs in the
|
|
60
|
+
snapshot, not there.
|
|
61
|
+
- PURE. `render` is a function of `(content, context)`: no clock, no environment,
|
|
62
|
+
no I/O. The copyright year comes from `context.brand.copyrightYear`, which is
|
|
63
|
+
what retires the `new Date().getFullYear()` hidden inside the old
|
|
64
|
+
`signature()`.
|
|
65
|
+
- `supports` answers true for EVERY element type. Email is the rich channel;
|
|
66
|
+
declining an element would be a lie about the channel rather than a fact about
|
|
67
|
+
the notification.
|
|
68
|
+
- The `:` between a label and its value is presentation and lives in no element
|
|
69
|
+
(see `../../kinds/README.md`). `render.ts` puts it back — on key/value rows and
|
|
70
|
+
on the metadata heading.
|
|
71
|
+
- Values arrive pre-formatted: dates, title-casing and truncation are `compose`'s
|
|
72
|
+
work. Nothing here re-formats a value.
|
|
73
|
+
- Every user-controlled field in the HTML surface passes through `escapeHtml`
|
|
74
|
+
before interpolation.
|
|
75
|
+
- `MONO` / `FONT_SIZE` / `SPACING` are this channel's own and are NOT exported
|
|
76
|
+
from the domain barrel: a margin is not vocabulary. They were duplicated from
|
|
77
|
+
the old `email/render/blocks`'s private copies while both layers existed; that
|
|
78
|
+
duplication ended with the directory, and these are now the only copies.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# notifications/renderers/email/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Tests for the email channel (ADR-CONTRACTS-086).
|
|
6
|
+
|
|
7
|
+
`render.test.ts` pins the renderer's behavioural rules — the claims that hold no
|
|
8
|
+
matter how the email is styled: that `supports` is total, that the subject comes
|
|
9
|
+
from the content's title, that the year and signer come from `RenderContext`
|
|
10
|
+
rather than a clock, that the `:` separator the content model drops is put back,
|
|
11
|
+
and that user-controlled fields are escaped. It also renders every fixture in
|
|
12
|
+
`../../../__tests__/fixtures.ts` through `NOTIFICATION_DEFINITIONS`, which proves
|
|
13
|
+
each kind composes and renders on both surfaces at all.
|
|
14
|
+
|
|
15
|
+
The MARKUP is not here. `../../../__tests__/render-snapshot.test.ts` locks that.
|
|
16
|
+
|
|
17
|
+
## Invariants
|
|
18
|
+
|
|
19
|
+
- Rules here, markup there. A test in this file that asserts what the HTML looks
|
|
20
|
+
like is in the wrong file: it belongs in the snapshot, where a redesign shows
|
|
21
|
+
up as a reviewable diff instead of a wall of broken assertions.
|
|
22
|
+
- NO fake timers. The old layer's `signature()` read the wall clock, which is why
|
|
23
|
+
every render test had to freeze it; here the year is a field on
|
|
24
|
+
`RenderContext`, and keeping this file clock-free is the improvement being
|
|
25
|
+
locked in.
|
|
26
|
+
- This file used to assert every fixture byte-for-byte against a frozen golden of
|
|
27
|
+
what `src/email/render` emitted at 32.0.0 — the migration's "no rendered email
|
|
28
|
+
changed" promise, checked here at the RENDERER and in a companion suite at the
|
|
29
|
+
ENTRY POINT (`renderToChannel`). The promise held and shipped in 35.0.0;
|
|
30
|
+
ADR-CONTRACTS-087 retired both, because byte-equality with a deleted layer
|
|
31
|
+
cannot survive email design being deliberately changed. The bytes are in git at
|
|
32
|
+
`v35.0.0`.
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email renderer's behavioural rules (ADR-CONTRACTS-086) — the claims that
|
|
3
|
+
* hold no matter how the email is styled: it supports every element type, takes
|
|
4
|
+
* the subject from the content's title, takes the year and signer from context
|
|
5
|
+
* rather than a clock, restores the `:` the content model does not carry, and
|
|
6
|
+
* escapes user-controlled text.
|
|
7
|
+
*
|
|
8
|
+
* The MARKUP is not asserted here. `../../../__tests__/render-snapshot.test.ts`
|
|
9
|
+
* locks that, as a reviewable regenerable diff. Until ADR-CONTRACTS-087 this
|
|
10
|
+
* file also asserted every fixture byte-for-byte against the 32.0.0 golden, to
|
|
11
|
+
* prove the multi-channel generalisation moved no bytes; that proof shipped in
|
|
12
|
+
* 35.0.0 and was retired with the golden, because email design is now allowed to
|
|
13
|
+
* change and byte-equality to a deleted layer cannot survive it being exercised.
|
|
14
|
+
*
|
|
15
|
+
* There is no fake timer here, and that is the improvement being locked in: the
|
|
16
|
+
* year comes from `RenderContext`, so the fixtures' clock is just a number.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { describe, expect, it } from "vitest";
|
|
20
|
+
|
|
21
|
+
import type {
|
|
22
|
+
NotificationContent,
|
|
23
|
+
NotificationElementType,
|
|
24
|
+
} from "../../../content";
|
|
25
|
+
import type { RenderContext } from "../../../context";
|
|
26
|
+
import { NOTIFICATION_DEFINITIONS } from "../../../registry";
|
|
27
|
+
import {
|
|
28
|
+
FIXTURE_CLOCK,
|
|
29
|
+
NOTIFICATION_FIXTURES,
|
|
30
|
+
fixtureKey,
|
|
31
|
+
} from "../../../__tests__/fixtures";
|
|
32
|
+
import { emailRenderer } from "../index";
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The fixtures' context, stated rather than ticked. The old layer reached for
|
|
36
|
+
* `new Date().getFullYear()` mid-render, which is why every render test had to
|
|
37
|
+
* freeze the clock; here the year is simply a value.
|
|
38
|
+
*/
|
|
39
|
+
const FIXTURE_CONTEXT: RenderContext = {
|
|
40
|
+
brand: {
|
|
41
|
+
name: "Company Semantics",
|
|
42
|
+
copyrightYear: new Date(FIXTURE_CLOCK).getUTCFullYear(),
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const ALL_TYPES: NotificationElementType[] = [
|
|
47
|
+
"greeting",
|
|
48
|
+
"body",
|
|
49
|
+
"keyValueTable",
|
|
50
|
+
"callToAction",
|
|
51
|
+
"list",
|
|
52
|
+
"divider",
|
|
53
|
+
"warning",
|
|
54
|
+
"metadata",
|
|
55
|
+
"notice",
|
|
56
|
+
"chatUnit",
|
|
57
|
+
"signature",
|
|
58
|
+
"heroImage",
|
|
59
|
+
];
|
|
60
|
+
|
|
61
|
+
describe("emailRenderer", () => {
|
|
62
|
+
it("declares the email channel", () => {
|
|
63
|
+
expect(emailRenderer.id).toBe("email");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("supports every element type — email is the rich channel", () => {
|
|
67
|
+
for (const type of ALL_TYPES) {
|
|
68
|
+
expect(emailRenderer.supports(type), type).toBe(true);
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("renders the subject from the content's title, not a registry", () => {
|
|
73
|
+
const content: NotificationContent = {
|
|
74
|
+
metadata: { kind: "org.invite", title: "Join Acme" },
|
|
75
|
+
sections: [{ elements: [{ type: "body", text: "Hello." }] }],
|
|
76
|
+
};
|
|
77
|
+
expect(emailRenderer.render(content, FIXTURE_CONTEXT).subject).toBe(
|
|
78
|
+
"Join Acme",
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("takes the copyright year and signer from context, never from a clock", () => {
|
|
83
|
+
const content: NotificationContent = {
|
|
84
|
+
metadata: { kind: "org.invite", title: "t" },
|
|
85
|
+
sections: [{ elements: [{ type: "signature" }] }],
|
|
86
|
+
};
|
|
87
|
+
const pinned: RenderContext = {
|
|
88
|
+
brand: { name: "Acme", copyrightYear: 1999 },
|
|
89
|
+
};
|
|
90
|
+
expect(emailRenderer.render(content, pinned).text).toContain(
|
|
91
|
+
"ⓒ 1999 • Acme",
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it("lets an element's signer override the context brand", () => {
|
|
96
|
+
const content: NotificationContent = {
|
|
97
|
+
metadata: { kind: "org.invite", title: "t" },
|
|
98
|
+
sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
|
|
99
|
+
};
|
|
100
|
+
expect(emailRenderer.render(content, FIXTURE_CONTEXT).text).toContain(
|
|
101
|
+
"• Grace",
|
|
102
|
+
);
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("puts back the ':' the content model does not carry", () => {
|
|
106
|
+
// The separator is presentation and lives in no element — a renderer that
|
|
107
|
+
// forgets it silently drops the colon from every fact in every email.
|
|
108
|
+
const content: NotificationContent = {
|
|
109
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
110
|
+
sections: [
|
|
111
|
+
{
|
|
112
|
+
elements: [
|
|
113
|
+
{
|
|
114
|
+
type: "keyValueTable",
|
|
115
|
+
rows: [{ label: "Status", value: "Valid" }],
|
|
116
|
+
},
|
|
117
|
+
{
|
|
118
|
+
type: "metadata",
|
|
119
|
+
label: "Request details",
|
|
120
|
+
rows: [{ label: "IP address", value: "203.0.113.1" }],
|
|
121
|
+
},
|
|
122
|
+
],
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
const { text } = emailRenderer.render(content, FIXTURE_CONTEXT);
|
|
127
|
+
expect(text).toContain("Status: Valid");
|
|
128
|
+
expect(text).toContain("Request details:");
|
|
129
|
+
expect(text).toContain("IP address: 203.0.113.1");
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("escapes user-controlled content in the HTML surface", () => {
|
|
133
|
+
const content: NotificationContent = {
|
|
134
|
+
metadata: { kind: "org.invite", title: "t" },
|
|
135
|
+
sections: [
|
|
136
|
+
{
|
|
137
|
+
elements: [{ type: "greeting", recipientName: "<script>x</script>" }],
|
|
138
|
+
},
|
|
139
|
+
],
|
|
140
|
+
};
|
|
141
|
+
const { html } = emailRenderer.render(content, FIXTURE_CONTEXT);
|
|
142
|
+
expect(html).toContain("<script>");
|
|
143
|
+
expect(html).not.toContain("<script>");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("is pure — same inputs, same bytes", () => {
|
|
147
|
+
const content = NOTIFICATION_DEFINITIONS["security.alert"].compose(
|
|
148
|
+
{
|
|
149
|
+
alertType: "excessive_otp_requests",
|
|
150
|
+
details: "5 codes in 2 minutes",
|
|
151
|
+
timestamp: "2026-07-15T00:00:00.000Z",
|
|
152
|
+
},
|
|
153
|
+
FIXTURE_CONTEXT,
|
|
154
|
+
);
|
|
155
|
+
expect(emailRenderer.render(content, FIXTURE_CONTEXT)).toEqual(
|
|
156
|
+
emailRenderer.render(content, FIXTURE_CONTEXT),
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("renders every fixture", () => {
|
|
161
|
+
// Was a byte-for-byte assertion against the 32.0.0 golden until
|
|
162
|
+
// ADR-CONTRACTS-087 retired it. The markup itself is locked by
|
|
163
|
+
// `../../../__tests__/render-snapshot.test.ts`; what is worth proving HERE
|
|
164
|
+
// is narrower and survives a redesign — that every kind in the registry
|
|
165
|
+
// composes and renders through this channel at all, on both surfaces. A kind
|
|
166
|
+
// whose `compose` reaches for a field its payload lacks fails here, at the
|
|
167
|
+
// renderer, rather than in whichever consumer sends it first.
|
|
168
|
+
for (const fixture of NOTIFICATION_FIXTURES) {
|
|
169
|
+
const key = fixtureKey(fixture.kind, fixture.name);
|
|
170
|
+
it(`${key}`, () => {
|
|
171
|
+
const definition = NOTIFICATION_DEFINITIONS[fixture.kind];
|
|
172
|
+
// `payload as never`: the fixture call sites are type-checked in the
|
|
173
|
+
// fixtures module; this only bridges compose's per-kind payload.
|
|
174
|
+
const content = definition.compose(
|
|
175
|
+
fixture.payload as never,
|
|
176
|
+
FIXTURE_CONTEXT,
|
|
177
|
+
);
|
|
178
|
+
const { subject, text, html } = emailRenderer.render(
|
|
179
|
+
content,
|
|
180
|
+
FIXTURE_CONTEXT,
|
|
181
|
+
);
|
|
182
|
+
expect(subject).toBeTruthy();
|
|
183
|
+
expect(text).toBeTruthy();
|
|
184
|
+
expect(html).toContain("<!DOCTYPE html>");
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
});
|
|
@@ -1,32 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Email's answer to a `chatUnit` — bubbles in HTML, box art in plain text.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* concern that the paragraph/CTA primitives next door do not share.
|
|
4
|
+
* Moved verbatim from the deleted `src/email/render/blocks` (ADR-CONTRACTS-086), with
|
|
5
|
+
* one change: it lays out `ChatUnitItem`s from `../../content` rather than the
|
|
6
|
+
* old layer's parallel `ChatItem` vocabulary. The mapping is exact —
|
|
7
|
+
* `message` ↔ a bubble, `callToAction` ↔ an embedded button, `continuation` ↔
|
|
8
|
+
* the "⋮" dots.
|
|
10
9
|
*
|
|
11
10
|
* INVARIANTS:
|
|
12
|
-
* - Pure
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
11
|
+
* - Pure. The box art is real output, and every column of it is locked by
|
|
12
|
+
* `../../__tests__/render-snapshot.test.ts` — a stray space is a visibly
|
|
13
|
+
* broken email, not a whitespace nit.
|
|
14
|
+
* - Both surfaces truncate at the same point — `clampMessage` is the one
|
|
15
|
+
* truncation authority, and HTML and plain text both run content through it.
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
-
import {
|
|
19
|
-
type Block,
|
|
20
|
-
type CtaBoxOptions,
|
|
21
|
-
ctaButton,
|
|
22
|
-
FONT_SIZE,
|
|
23
|
-
MONO,
|
|
24
|
-
} from "./blocks";
|
|
25
|
-
import { escapeHtml } from "./escape-html";
|
|
18
|
+
import type { CallToAction, ChatTurn, ChatUnitItem } from "../../content";
|
|
26
19
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
20
|
+
import { FONT_SIZE, MONO } from "./constants";
|
|
21
|
+
import { ctaButton } from "./cta";
|
|
22
|
+
import { escapeHtml } from "./escape-html";
|
|
30
23
|
|
|
31
24
|
/** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
|
|
32
25
|
function wrapText(text: string, width: number): string[] {
|
|
@@ -74,65 +67,13 @@ function wrapClamped(text: string, width: number, maxLines: number): string[] {
|
|
|
74
67
|
|
|
75
68
|
/**
|
|
76
69
|
* The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
|
|
77
|
-
* MESSAGE_WIDTH, ellipsized. Both surfaces of a chat
|
|
70
|
+
* MESSAGE_WIDTH, ellipsized. Both surfaces of a chat unit run content through
|
|
78
71
|
* this, so HTML and plain text truncate at exactly the same point.
|
|
79
72
|
*/
|
|
80
73
|
function clampMessage(text: string): string {
|
|
81
74
|
return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
|
|
82
75
|
}
|
|
83
76
|
|
|
84
|
-
// =============================================================================
|
|
85
|
-
// Chat items
|
|
86
|
-
// =============================================================================
|
|
87
|
-
|
|
88
|
-
/** One message in a chat unit. `chatUser`/`chatAssistant` build these; `chatUnit`
|
|
89
|
-
* lays them out together. `from` is the user attribution (sender name). */
|
|
90
|
-
export interface ChatMessage {
|
|
91
|
-
role: "user" | "assistant";
|
|
92
|
-
text: string;
|
|
93
|
-
from?: string;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
/** A user (right-aligned) chat message with an optional `from` attribution. */
|
|
97
|
-
export function chatUser(text: string, from?: string): ChatMessage {
|
|
98
|
-
return { role: "user", text, from };
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
/** An assistant (left-aligned) chat message. */
|
|
102
|
-
export function chatAssistant(text: string): ChatMessage {
|
|
103
|
-
return { role: "assistant", text };
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** A CTA button placed inside a chat unit (below a message). */
|
|
107
|
-
export interface ChatCta {
|
|
108
|
-
role: "cta";
|
|
109
|
-
cta: CtaBoxOptions;
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Centered continuation dots inside a chat unit — a "conversation continues"
|
|
113
|
-
* separator placed between a message bubble and a following CTA. */
|
|
114
|
-
export interface ChatDots {
|
|
115
|
-
role: "dots";
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** An item in a chat unit: a message bubble, a CTA button, or continuation dots. */
|
|
119
|
-
export type ChatItem = ChatMessage | ChatCta | ChatDots;
|
|
120
|
-
|
|
121
|
-
/** A CTA button for a chat unit — pass it to `chatUnit` alongside messages. */
|
|
122
|
-
export function chatCta(cta: CtaBoxOptions): ChatCta {
|
|
123
|
-
return { role: "cta", cta };
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/** Continuation dots for a chat unit — pass it to `chatUnit` where the
|
|
127
|
-
* conversation should read as continuing (e.g. between the preview and CTA). */
|
|
128
|
-
export function chatDots(): ChatDots {
|
|
129
|
-
return { role: "dots" };
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
// =============================================================================
|
|
133
|
-
// Item rendering
|
|
134
|
-
// =============================================================================
|
|
135
|
-
|
|
136
77
|
/** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
|
|
137
78
|
* outer side. */
|
|
138
79
|
function chatRuleHtml(position: "top" | "bottom"): string {
|
|
@@ -141,18 +82,18 @@ function chatRuleHtml(position: "top" | "bottom"): string {
|
|
|
141
82
|
}
|
|
142
83
|
|
|
143
84
|
/**
|
|
144
|
-
* Render one
|
|
85
|
+
* Render one turn to its HTML `<table>` and plain-text box lines. Both roles
|
|
145
86
|
* share the 3-column skeleton: a fixed avatar column on each side (the visible
|
|
146
87
|
* avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
|
|
147
88
|
* width, so bubbles stay bounded and aligned), a middle cell that right/left-
|
|
148
89
|
* aligns the bubble, and — for a user `from` — an attribution row below.
|
|
149
90
|
*/
|
|
150
91
|
function renderBubble(
|
|
151
|
-
|
|
92
|
+
turn: ChatTurn,
|
|
152
93
|
margin: string,
|
|
153
94
|
): { html: string; text: string[] } {
|
|
154
|
-
const clamped = clampMessage(
|
|
155
|
-
const isUser =
|
|
95
|
+
const clamped = clampMessage(turn.text);
|
|
96
|
+
const isUser = turn.role === "user";
|
|
156
97
|
|
|
157
98
|
const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
|
|
158
99
|
const bubbleAlign = isUser ? " text-align: right;" : "";
|
|
@@ -161,11 +102,11 @@ function renderBubble(
|
|
|
161
102
|
const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
|
|
162
103
|
|
|
163
104
|
const attributionRow =
|
|
164
|
-
isUser &&
|
|
105
|
+
isUser && turn.from
|
|
165
106
|
? `
|
|
166
107
|
<tr>
|
|
167
108
|
<td></td>
|
|
168
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(
|
|
109
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(turn.from)}</td>
|
|
169
110
|
<td></td>
|
|
170
111
|
</tr>`
|
|
171
112
|
: "";
|
|
@@ -196,8 +137,8 @@ function renderBubble(
|
|
|
196
137
|
...body,
|
|
197
138
|
`${CHAT_INDENT}└${border}┘`,
|
|
198
139
|
];
|
|
199
|
-
if (isUser &&
|
|
200
|
-
box.push(
|
|
140
|
+
if (isUser && turn.from) {
|
|
141
|
+
box.push(turn.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
|
|
201
142
|
}
|
|
202
143
|
return { html, text: box };
|
|
203
144
|
}
|
|
@@ -219,7 +160,7 @@ function dotsOverCtaHtml(): string {
|
|
|
219
160
|
* When `withDots`, continuation "⋮" render just above the button, centered over it.
|
|
220
161
|
*/
|
|
221
162
|
function renderChatCta(
|
|
222
|
-
cta:
|
|
163
|
+
cta: CallToAction,
|
|
223
164
|
align: "left" | "right",
|
|
224
165
|
withDots: boolean,
|
|
225
166
|
): { html: string; text: string[] } {
|
|
@@ -259,8 +200,8 @@ function renderChatCta(
|
|
|
259
200
|
|
|
260
201
|
/**
|
|
261
202
|
* Standalone continuation dots — centered in the message channel. Used only when
|
|
262
|
-
* `
|
|
263
|
-
* dots into the CTA via `renderChatCta`, centered over the box).
|
|
203
|
+
* a `continuation` is NOT immediately followed by a `callToAction` (the common
|
|
204
|
+
* case folds the dots into the CTA via `renderChatCta`, centered over the box).
|
|
264
205
|
*/
|
|
265
206
|
function renderChatDots(): { html: string; text: string[] } {
|
|
266
207
|
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
|
|
@@ -275,57 +216,63 @@ function renderChatDots(): { html: string; text: string[] } {
|
|
|
275
216
|
return { html, text: ["⋮".padStart(center)] };
|
|
276
217
|
}
|
|
277
218
|
|
|
278
|
-
// =============================================================================
|
|
279
|
-
// Chat unit
|
|
280
|
-
// =============================================================================
|
|
281
|
-
|
|
282
219
|
/**
|
|
283
|
-
* Lay out
|
|
284
|
-
*
|
|
220
|
+
* Lay out a chat unit's items — bubbles and/or CTA buttons — as a single
|
|
221
|
+
* rendered thing: a rule above and below (the real-chat "unit"), the items
|
|
285
222
|
* between. The plain-text rule spans the widest line; a blank line follows the
|
|
286
|
-
* top rule and the last item hugs the bottom rule. Continuation dots
|
|
287
|
-
*
|
|
223
|
+
* top rule and the last item hugs the bottom rule. Continuation dots sit above
|
|
224
|
+
* the CTA they precede, centered over the box.
|
|
288
225
|
*/
|
|
289
|
-
export function
|
|
226
|
+
export function renderChatUnit(items: ChatUnitItem[]): {
|
|
227
|
+
html: string;
|
|
228
|
+
text: string;
|
|
229
|
+
} {
|
|
290
230
|
const parts: { html: string; text: string[] }[] = [];
|
|
291
231
|
items.forEach((item, i) => {
|
|
292
|
-
if (item.
|
|
232
|
+
if (item.type === "continuation") {
|
|
293
233
|
// Dots immediately before a CTA render with it (centered over the box);
|
|
294
234
|
// otherwise fall back to channel-centered standalone dots.
|
|
295
|
-
if (items[i + 1]?.
|
|
235
|
+
if (items[i + 1]?.type !== "callToAction") parts.push(renderChatDots());
|
|
296
236
|
return;
|
|
297
237
|
}
|
|
298
|
-
if (item.
|
|
238
|
+
if (item.type === "callToAction") {
|
|
299
239
|
// Mirror the side of the nearest preceding message (skip any dots between),
|
|
300
240
|
// so the CTA sits under the bubble it belongs to — right under a user.
|
|
301
241
|
let j = i - 1;
|
|
302
|
-
while (j >= 0 && items[j].
|
|
303
|
-
const
|
|
304
|
-
|
|
242
|
+
while (j >= 0 && items[j].type === "continuation") j--;
|
|
243
|
+
const prev = items[j];
|
|
244
|
+
const align =
|
|
245
|
+
prev?.type === "message" && prev.role === "user" ? "right" : "left";
|
|
246
|
+
parts.push(
|
|
247
|
+
renderChatCta(item, align, items[i - 1]?.type === "continuation"),
|
|
248
|
+
);
|
|
305
249
|
return;
|
|
306
250
|
}
|
|
307
251
|
// A bubble directly above a CTA or continuation dots gets a tighter 16px
|
|
308
252
|
// gap; else 24px.
|
|
309
|
-
const next = items[i + 1]?.
|
|
253
|
+
const next = items[i + 1]?.type;
|
|
310
254
|
const margin =
|
|
311
|
-
next === "
|
|
255
|
+
next === "callToAction" || next === "continuation"
|
|
256
|
+
? "0 0 16px 0"
|
|
257
|
+
: "0 0 24px 0";
|
|
312
258
|
parts.push(renderBubble(item, margin));
|
|
313
259
|
});
|
|
260
|
+
|
|
314
261
|
const width = parts
|
|
315
|
-
.flatMap((
|
|
262
|
+
.flatMap((part) => part.text)
|
|
316
263
|
.reduce((w, l) => Math.max(w, l.length), 0);
|
|
317
264
|
const rule = "_".repeat(width);
|
|
318
265
|
|
|
319
266
|
const html = [
|
|
320
267
|
chatRuleHtml("top"),
|
|
321
|
-
...parts.map((
|
|
268
|
+
...parts.map((part) => part.html),
|
|
322
269
|
chatRuleHtml("bottom"),
|
|
323
270
|
].join("\n");
|
|
324
271
|
|
|
325
272
|
const text =
|
|
326
273
|
`${rule}\n\n` +
|
|
327
|
-
parts.map((
|
|
274
|
+
parts.map((part) => part.text.join("\n")).join("\n\n") +
|
|
328
275
|
`\n${rule}`;
|
|
329
276
|
|
|
330
|
-
return { html, text
|
|
277
|
+
return { html, text };
|
|
331
278
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Email channel constants — the styling vocabulary and the one link in it
|
|
3
|
+
* (ADR-CONTRACTS-086). `EMAIL_FROM` (SES envelope) is infra and stays in the
|
|
4
|
+
* backend.
|
|
5
|
+
*
|
|
6
|
+
* These are the email channel's own answer and are deliberately NOT exported
|
|
7
|
+
* from the domain barrel: a margin is not vocabulary. The brand NAME is not here
|
|
8
|
+
* either — it is `../../context`'s `COMPANY_NAME`, because it is the same answer
|
|
9
|
+
* on every channel and renderers read it from `context.brand`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// =============================================================================
|
|
13
|
+
// Branding
|
|
14
|
+
// =============================================================================
|
|
15
|
+
|
|
16
|
+
/** Monospace font stack for HTML emails. */
|
|
17
|
+
const MONO_FONT_STACK =
|
|
18
|
+
"'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace";
|
|
19
|
+
|
|
20
|
+
/** The `companysemantics.ai` link under every signature. */
|
|
21
|
+
export const COMPANY_URL = "https://companysemantics.ai";
|
|
22
|
+
|
|
23
|
+
// =============================================================================
|
|
24
|
+
// Styling
|
|
25
|
+
// =============================================================================
|
|
26
|
+
|
|
27
|
+
/** The `font-family` declaration every element carries. */
|
|
28
|
+
export const MONO = `font-family: ${MONO_FONT_STACK};`;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The single font size for every email element (HTML). Plain text is monospace
|
|
32
|
+
* so it carries no size — this keeps one visual size across both surfaces.
|
|
33
|
+
*/
|
|
34
|
+
export const FONT_SIZE = "13px";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The colour for a `warning` — the product's destructive token, resolved.
|
|
38
|
+
*
|
|
39
|
+
* This is `--destructive` from the app's `globals.css` `:root`
|
|
40
|
+
* (`oklch(0.577 0.245 27.325)`) converted to sRGB hex. It is COPIED, not
|
|
41
|
+
* imported, and it has to be: contracts sits ABOVE the app in the dependency
|
|
42
|
+
* flow, so this package cannot read the app's stylesheet — and email cannot read
|
|
43
|
+
* one either. There is no CSS to cascade from and no custom properties to
|
|
44
|
+
* resolve, so every colour in this channel is a literal in an inline `style`.
|
|
45
|
+
* If the token moves, this does not follow it; that divergence is the cost of
|
|
46
|
+
* the arrow pointing one way.
|
|
47
|
+
*
|
|
48
|
+
* The `:root` value rather than `.dark`'s, because this channel is light-only by
|
|
49
|
+
* construction — `./shells.ts` states `color: #1a1a1a` on the body.
|
|
50
|
+
*/
|
|
51
|
+
export const DESTRUCTIVE = "#e7000b";
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The gap AFTER a rendered line: `"normal"` is a blank line in plain text,
|
|
55
|
+
* anything else is none.
|
|
56
|
+
*/
|
|
57
|
+
export type Spacing = "normal" | "tight" | "none";
|
|
58
|
+
|
|
59
|
+
/** The HTML margin each `Spacing` maps to. */
|
|
60
|
+
export const SPACING: Record<Spacing, string> = {
|
|
61
|
+
normal: "0 0 20px 0",
|
|
62
|
+
tight: "0 0 4px 0",
|
|
63
|
+
none: "0",
|
|
64
|
+
};
|