@company-semantics/contracts 33.0.0 → 35.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +2 -2
- package/src/index.ts +17 -11
- package/src/notifications/README.md +140 -0
- package/src/notifications/__tests__/README.md +54 -0
- package/src/notifications/__tests__/__snapshots__/README.md +32 -0
- 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__/kinds.test.ts +81 -0
- package/src/notifications/__tests__/output-parity.golden.ts +363 -0
- package/src/notifications/__tests__/output-parity.test.ts +122 -0
- package/src/notifications/__tests__/registry.test.ts +184 -0
- package/src/{email/render → notifications}/__tests__/render-snapshot.test.ts +58 -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 +74 -0
- package/src/notifications/renderers/email/__tests__/README.md +29 -0
- package/src/notifications/renderers/email/__tests__/render.test.ts +180 -0
- package/src/{email/render → notifications/renderers/email}/chat.ts +53 -107
- package/src/notifications/renderers/email/constants.ts +47 -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 +225 -0
- package/src/notifications/renderers/email/shells.ts +60 -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
- /package/src/{email/render → notifications}/__tests__/__snapshots__/render-snapshot.test.ts.snap +0 -0
|
@@ -0,0 +1,74 @@
|
|
|
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 byte-identical to what the old `src/email/render` emitted. This is
|
|
52
|
+
the whole constraint of the migration. `__tests__/render.test.ts` proves it by
|
|
53
|
+
composing every fixture in the frozen output-parity golden and rendering it
|
|
54
|
+
through this channel — a diff there means a real sent email changed. The golden
|
|
55
|
+
is the only record of those bytes now, so it is the authority: fix the
|
|
56
|
+
renderer, never the golden.
|
|
57
|
+
- PURE. `render` is a function of `(content, context)`: no clock, no environment,
|
|
58
|
+
no I/O. The copyright year comes from `context.brand.copyrightYear`, which is
|
|
59
|
+
what retires the `new Date().getFullYear()` hidden inside the old
|
|
60
|
+
`signature()`.
|
|
61
|
+
- `supports` answers true for EVERY element type. Email is the rich channel;
|
|
62
|
+
declining an element would be a lie about the channel rather than a fact about
|
|
63
|
+
the notification.
|
|
64
|
+
- The `:` between a label and its value is presentation and lives in no element
|
|
65
|
+
(see `../../kinds/README.md`). `render.ts` puts it back — on key/value rows and
|
|
66
|
+
on the metadata heading.
|
|
67
|
+
- Values arrive pre-formatted: dates, title-casing and truncation are `compose`'s
|
|
68
|
+
work. Nothing here re-formats a value.
|
|
69
|
+
- Every user-controlled field in the HTML surface passes through `escapeHtml`
|
|
70
|
+
before interpolation.
|
|
71
|
+
- `MONO` / `FONT_SIZE` / `SPACING` are this channel's own and are NOT exported
|
|
72
|
+
from the domain barrel: a margin is not vocabulary. They were duplicated from
|
|
73
|
+
the old `email/render/blocks`'s private copies while both layers existed; that
|
|
74
|
+
duplication ended with the directory, and these are now the only copies.
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# notifications/renderers/email/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Tests for the email channel (ADR-CONTRACTS-086).
|
|
6
|
+
|
|
7
|
+
`render.test.ts` holds the renderer's one load-bearing proof: every fixture in
|
|
8
|
+
`../../../__tests__/output-parity.golden.ts`, composed through
|
|
9
|
+
`NOTIFICATION_DEFINITIONS` and rendered through `emailRenderer`, byte-for-byte.
|
|
10
|
+
The rest of the file pins the properties the golden cannot see — that `supports`
|
|
11
|
+
is total, that the year comes from `RenderContext`, that the `:` separator the
|
|
12
|
+
content model drops is put back, and that user-controlled fields are escaped.
|
|
13
|
+
|
|
14
|
+
## Invariants
|
|
15
|
+
|
|
16
|
+
- The golden is the ORACLE, not this renderer's output. When the two disagree,
|
|
17
|
+
the renderer is wrong: the golden records what the old `src/email/render`
|
|
18
|
+
emitted at 32.0.0, and "no rendered email changed" is the migration's whole
|
|
19
|
+
promise. It is never regenerated to make this file pass — and since that layer
|
|
20
|
+
is now deleted, the golden is the only record of those bytes, so regenerating
|
|
21
|
+
it destroys the evidence rather than updating it.
|
|
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
|
+
- Parity is asserted at two levels against one golden: here through the RENDERER
|
|
27
|
+
(composed content → bytes), and in `../../../__tests__/output-parity.test.ts`
|
|
28
|
+
through the ENTRY POINT (`renderToChannel`, the whole pipeline). Either one
|
|
29
|
+
drifting from the golden is a failure.
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The email renderer's correctness proof (ADR-CONTRACTS-086).
|
|
3
|
+
*
|
|
4
|
+
* The renderer has exactly one hard requirement: compose a kind, render it
|
|
5
|
+
* through this channel, and get the bytes the old `src/email/render` produced.
|
|
6
|
+
* So that is what this asserts — every fixture in the frozen output-parity
|
|
7
|
+
* golden, composed via `NOTIFICATION_DEFINITIONS` and rendered through
|
|
8
|
+
* `emailRenderer`, character-for-character.
|
|
9
|
+
*
|
|
10
|
+
* This does NOT replace `../../../__tests__/output-parity.test.ts`. That one
|
|
11
|
+
* pins the ENTRY POINT (`renderToChannel`, the whole pipeline) against the same
|
|
12
|
+
* golden; this pins the RENDERER alone, so a failure here says the markup moved
|
|
13
|
+
* rather than that the wiring did. Two levels, one golden: either drifting is a
|
|
14
|
+
* failure.
|
|
15
|
+
*
|
|
16
|
+
* There is no fake timer here, and that is the improvement being locked in: the
|
|
17
|
+
* year comes from `RenderContext`, so the golden's clock is just a number.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { describe, expect, it } from "vitest";
|
|
21
|
+
|
|
22
|
+
import type {
|
|
23
|
+
NotificationContent,
|
|
24
|
+
NotificationElementType,
|
|
25
|
+
} from "../../../content";
|
|
26
|
+
import type { RenderContext } from "../../../context";
|
|
27
|
+
import { NOTIFICATION_DEFINITIONS } from "../../../registry";
|
|
28
|
+
import {
|
|
29
|
+
OUTPUT_PARITY_CLOCK,
|
|
30
|
+
OUTPUT_PARITY_FIXTURES,
|
|
31
|
+
OUTPUT_PARITY_GOLDEN,
|
|
32
|
+
fixtureKey,
|
|
33
|
+
} from "../../../__tests__/output-parity.golden";
|
|
34
|
+
import { emailRenderer } from "../index";
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* The golden's context, stated rather than ticked. The old layer reached for
|
|
38
|
+
* `new Date().getFullYear()` mid-render, which is why every render test had to
|
|
39
|
+
* freeze the clock; here the year the golden was captured under is simply a
|
|
40
|
+
* value.
|
|
41
|
+
*/
|
|
42
|
+
const GOLDEN_CONTEXT: RenderContext = {
|
|
43
|
+
brand: {
|
|
44
|
+
name: "Company Semantics",
|
|
45
|
+
copyrightYear: new Date(OUTPUT_PARITY_CLOCK).getUTCFullYear(),
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const ALL_TYPES: NotificationElementType[] = [
|
|
50
|
+
"greeting",
|
|
51
|
+
"body",
|
|
52
|
+
"keyValueTable",
|
|
53
|
+
"callToAction",
|
|
54
|
+
"list",
|
|
55
|
+
"divider",
|
|
56
|
+
"warning",
|
|
57
|
+
"metadata",
|
|
58
|
+
"notice",
|
|
59
|
+
"chatUnit",
|
|
60
|
+
"signature",
|
|
61
|
+
"heroImage",
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
describe("emailRenderer", () => {
|
|
65
|
+
it("declares the email channel", () => {
|
|
66
|
+
expect(emailRenderer.id).toBe("email");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("supports every element type — email is the rich channel", () => {
|
|
70
|
+
for (const type of ALL_TYPES) {
|
|
71
|
+
expect(emailRenderer.supports(type), type).toBe(true);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it("renders the subject from the content's title, not a registry", () => {
|
|
76
|
+
const content: NotificationContent = {
|
|
77
|
+
metadata: { kind: "org.invite", title: "Join Acme" },
|
|
78
|
+
sections: [{ elements: [{ type: "body", text: "Hello." }] }],
|
|
79
|
+
};
|
|
80
|
+
expect(emailRenderer.render(content, GOLDEN_CONTEXT).subject).toBe(
|
|
81
|
+
"Join Acme",
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("takes the copyright year and signer from context, never from a clock", () => {
|
|
86
|
+
const content: NotificationContent = {
|
|
87
|
+
metadata: { kind: "org.invite", title: "t" },
|
|
88
|
+
sections: [{ elements: [{ type: "signature" }] }],
|
|
89
|
+
};
|
|
90
|
+
const pinned: RenderContext = {
|
|
91
|
+
brand: { name: "Acme", copyrightYear: 1999 },
|
|
92
|
+
};
|
|
93
|
+
expect(emailRenderer.render(content, pinned).text).toContain(
|
|
94
|
+
"ⓒ 1999 • Acme",
|
|
95
|
+
);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("lets an element's signer override the context brand", () => {
|
|
99
|
+
const content: NotificationContent = {
|
|
100
|
+
metadata: { kind: "org.invite", title: "t" },
|
|
101
|
+
sections: [{ elements: [{ type: "signature", signer: "Grace" }] }],
|
|
102
|
+
};
|
|
103
|
+
expect(emailRenderer.render(content, GOLDEN_CONTEXT).text).toContain(
|
|
104
|
+
"• Grace",
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("puts back the ':' the content model does not carry", () => {
|
|
109
|
+
// The separator is presentation and lives in no element — a renderer that
|
|
110
|
+
// forgets it silently drops the colon from every fact in every email.
|
|
111
|
+
const content: NotificationContent = {
|
|
112
|
+
metadata: { kind: "auth.otp", title: "t" },
|
|
113
|
+
sections: [
|
|
114
|
+
{
|
|
115
|
+
elements: [
|
|
116
|
+
{
|
|
117
|
+
type: "keyValueTable",
|
|
118
|
+
rows: [{ label: "Status", value: "Valid" }],
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
type: "metadata",
|
|
122
|
+
label: "Request details",
|
|
123
|
+
rows: [{ label: "IP address", value: "203.0.113.1" }],
|
|
124
|
+
},
|
|
125
|
+
],
|
|
126
|
+
},
|
|
127
|
+
],
|
|
128
|
+
};
|
|
129
|
+
const { text } = emailRenderer.render(content, GOLDEN_CONTEXT);
|
|
130
|
+
expect(text).toContain("Status: Valid");
|
|
131
|
+
expect(text).toContain("Request details:");
|
|
132
|
+
expect(text).toContain("IP address: 203.0.113.1");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("escapes user-controlled content in the HTML surface", () => {
|
|
136
|
+
const content: NotificationContent = {
|
|
137
|
+
metadata: { kind: "org.invite", title: "t" },
|
|
138
|
+
sections: [
|
|
139
|
+
{
|
|
140
|
+
elements: [{ type: "greeting", recipientName: "<script>x</script>" }],
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
};
|
|
144
|
+
const { html } = emailRenderer.render(content, GOLDEN_CONTEXT);
|
|
145
|
+
expect(html).toContain("<script>");
|
|
146
|
+
expect(html).not.toContain("<script>");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("is pure — same inputs, same bytes", () => {
|
|
150
|
+
const content = NOTIFICATION_DEFINITIONS["security.alert"].compose(
|
|
151
|
+
{
|
|
152
|
+
alertType: "excessive_otp_requests",
|
|
153
|
+
details: "5 codes in 2 minutes",
|
|
154
|
+
timestamp: "2026-07-15T00:00:00.000Z",
|
|
155
|
+
},
|
|
156
|
+
GOLDEN_CONTEXT,
|
|
157
|
+
);
|
|
158
|
+
expect(emailRenderer.render(content, GOLDEN_CONTEXT)).toEqual(
|
|
159
|
+
emailRenderer.render(content, GOLDEN_CONTEXT),
|
|
160
|
+
);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("output parity with the email layer at 32.0.0", () => {
|
|
164
|
+
for (const fixture of OUTPUT_PARITY_FIXTURES) {
|
|
165
|
+
const key = fixtureKey(fixture.kind, fixture.name);
|
|
166
|
+
it(`${key} renders byte-for-byte`, () => {
|
|
167
|
+
const definition = NOTIFICATION_DEFINITIONS[fixture.kind];
|
|
168
|
+
// `payload as never`: the fixture call sites are type-checked in the
|
|
169
|
+
// golden module; this only bridges compose's per-kind payload.
|
|
170
|
+
const content = definition.compose(
|
|
171
|
+
fixture.payload as never,
|
|
172
|
+
GOLDEN_CONTEXT,
|
|
173
|
+
);
|
|
174
|
+
expect(emailRenderer.render(content, GOLDEN_CONTEXT)).toEqual(
|
|
175
|
+
OUTPUT_PARITY_GOLDEN[key],
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
});
|
|
180
|
+
});
|
|
@@ -1,32 +1,24 @@
|
|
|
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, and byte-identical to what the deleted `src/email/render/blocks` emits. The
|
|
12
|
+
* box art is real output: the output-parity golden asserts every column of it.
|
|
13
|
+
* - Both surfaces truncate at the same point — `clampMessage` is the one
|
|
14
|
+
* truncation authority, and HTML and plain text both run content through it.
|
|
16
15
|
*/
|
|
17
16
|
|
|
18
|
-
import {
|
|
19
|
-
type Block,
|
|
20
|
-
type CtaBoxOptions,
|
|
21
|
-
ctaButton,
|
|
22
|
-
FONT_SIZE,
|
|
23
|
-
MONO,
|
|
24
|
-
} from "./blocks";
|
|
25
|
-
import { escapeHtml } from "./escape-html";
|
|
17
|
+
import type { CallToAction, ChatTurn, ChatUnitItem } from "../../content";
|
|
26
18
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
19
|
+
import { FONT_SIZE, MONO } from "./constants";
|
|
20
|
+
import { ctaButton } from "./cta";
|
|
21
|
+
import { escapeHtml } from "./escape-html";
|
|
30
22
|
|
|
31
23
|
/** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
|
|
32
24
|
function wrapText(text: string, width: number): string[] {
|
|
@@ -74,65 +66,13 @@ function wrapClamped(text: string, width: number, maxLines: number): string[] {
|
|
|
74
66
|
|
|
75
67
|
/**
|
|
76
68
|
* The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
|
|
77
|
-
* MESSAGE_WIDTH, ellipsized. Both surfaces of a chat
|
|
69
|
+
* MESSAGE_WIDTH, ellipsized. Both surfaces of a chat unit run content through
|
|
78
70
|
* this, so HTML and plain text truncate at exactly the same point.
|
|
79
71
|
*/
|
|
80
72
|
function clampMessage(text: string): string {
|
|
81
73
|
return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
|
|
82
74
|
}
|
|
83
75
|
|
|
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
76
|
/** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
|
|
137
77
|
* outer side. */
|
|
138
78
|
function chatRuleHtml(position: "top" | "bottom"): string {
|
|
@@ -141,18 +81,18 @@ function chatRuleHtml(position: "top" | "bottom"): string {
|
|
|
141
81
|
}
|
|
142
82
|
|
|
143
83
|
/**
|
|
144
|
-
* Render one
|
|
84
|
+
* Render one turn to its HTML `<table>` and plain-text box lines. Both roles
|
|
145
85
|
* share the 3-column skeleton: a fixed avatar column on each side (the visible
|
|
146
86
|
* avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
|
|
147
87
|
* width, so bubbles stay bounded and aligned), a middle cell that right/left-
|
|
148
88
|
* aligns the bubble, and — for a user `from` — an attribution row below.
|
|
149
89
|
*/
|
|
150
90
|
function renderBubble(
|
|
151
|
-
|
|
91
|
+
turn: ChatTurn,
|
|
152
92
|
margin: string,
|
|
153
93
|
): { html: string; text: string[] } {
|
|
154
|
-
const clamped = clampMessage(
|
|
155
|
-
const isUser =
|
|
94
|
+
const clamped = clampMessage(turn.text);
|
|
95
|
+
const isUser = turn.role === "user";
|
|
156
96
|
|
|
157
97
|
const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
|
|
158
98
|
const bubbleAlign = isUser ? " text-align: right;" : "";
|
|
@@ -161,11 +101,11 @@ function renderBubble(
|
|
|
161
101
|
const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
|
|
162
102
|
|
|
163
103
|
const attributionRow =
|
|
164
|
-
isUser &&
|
|
104
|
+
isUser && turn.from
|
|
165
105
|
? `
|
|
166
106
|
<tr>
|
|
167
107
|
<td></td>
|
|
168
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(
|
|
108
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(turn.from)}</td>
|
|
169
109
|
<td></td>
|
|
170
110
|
</tr>`
|
|
171
111
|
: "";
|
|
@@ -196,8 +136,8 @@ function renderBubble(
|
|
|
196
136
|
...body,
|
|
197
137
|
`${CHAT_INDENT}└${border}┘`,
|
|
198
138
|
];
|
|
199
|
-
if (isUser &&
|
|
200
|
-
box.push(
|
|
139
|
+
if (isUser && turn.from) {
|
|
140
|
+
box.push(turn.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
|
|
201
141
|
}
|
|
202
142
|
return { html, text: box };
|
|
203
143
|
}
|
|
@@ -219,7 +159,7 @@ function dotsOverCtaHtml(): string {
|
|
|
219
159
|
* When `withDots`, continuation "⋮" render just above the button, centered over it.
|
|
220
160
|
*/
|
|
221
161
|
function renderChatCta(
|
|
222
|
-
cta:
|
|
162
|
+
cta: CallToAction,
|
|
223
163
|
align: "left" | "right",
|
|
224
164
|
withDots: boolean,
|
|
225
165
|
): { html: string; text: string[] } {
|
|
@@ -259,8 +199,8 @@ function renderChatCta(
|
|
|
259
199
|
|
|
260
200
|
/**
|
|
261
201
|
* Standalone continuation dots — centered in the message channel. Used only when
|
|
262
|
-
* `
|
|
263
|
-
* dots into the CTA via `renderChatCta`, centered over the box).
|
|
202
|
+
* a `continuation` is NOT immediately followed by a `callToAction` (the common
|
|
203
|
+
* case folds the dots into the CTA via `renderChatCta`, centered over the box).
|
|
264
204
|
*/
|
|
265
205
|
function renderChatDots(): { html: string; text: string[] } {
|
|
266
206
|
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
|
|
@@ -275,57 +215,63 @@ function renderChatDots(): { html: string; text: string[] } {
|
|
|
275
215
|
return { html, text: ["⋮".padStart(center)] };
|
|
276
216
|
}
|
|
277
217
|
|
|
278
|
-
// =============================================================================
|
|
279
|
-
// Chat unit
|
|
280
|
-
// =============================================================================
|
|
281
|
-
|
|
282
218
|
/**
|
|
283
|
-
* Lay out
|
|
284
|
-
*
|
|
219
|
+
* Lay out a chat unit's items — bubbles and/or CTA buttons — as a single
|
|
220
|
+
* rendered thing: a rule above and below (the real-chat "unit"), the items
|
|
285
221
|
* 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
|
-
*
|
|
222
|
+
* top rule and the last item hugs the bottom rule. Continuation dots sit above
|
|
223
|
+
* the CTA they precede, centered over the box.
|
|
288
224
|
*/
|
|
289
|
-
export function
|
|
225
|
+
export function renderChatUnit(items: ChatUnitItem[]): {
|
|
226
|
+
html: string;
|
|
227
|
+
text: string;
|
|
228
|
+
} {
|
|
290
229
|
const parts: { html: string; text: string[] }[] = [];
|
|
291
230
|
items.forEach((item, i) => {
|
|
292
|
-
if (item.
|
|
231
|
+
if (item.type === "continuation") {
|
|
293
232
|
// Dots immediately before a CTA render with it (centered over the box);
|
|
294
233
|
// otherwise fall back to channel-centered standalone dots.
|
|
295
|
-
if (items[i + 1]?.
|
|
234
|
+
if (items[i + 1]?.type !== "callToAction") parts.push(renderChatDots());
|
|
296
235
|
return;
|
|
297
236
|
}
|
|
298
|
-
if (item.
|
|
237
|
+
if (item.type === "callToAction") {
|
|
299
238
|
// Mirror the side of the nearest preceding message (skip any dots between),
|
|
300
239
|
// so the CTA sits under the bubble it belongs to — right under a user.
|
|
301
240
|
let j = i - 1;
|
|
302
|
-
while (j >= 0 && items[j].
|
|
303
|
-
const
|
|
304
|
-
|
|
241
|
+
while (j >= 0 && items[j].type === "continuation") j--;
|
|
242
|
+
const prev = items[j];
|
|
243
|
+
const align =
|
|
244
|
+
prev?.type === "message" && prev.role === "user" ? "right" : "left";
|
|
245
|
+
parts.push(
|
|
246
|
+
renderChatCta(item, align, items[i - 1]?.type === "continuation"),
|
|
247
|
+
);
|
|
305
248
|
return;
|
|
306
249
|
}
|
|
307
250
|
// A bubble directly above a CTA or continuation dots gets a tighter 16px
|
|
308
251
|
// gap; else 24px.
|
|
309
|
-
const next = items[i + 1]?.
|
|
252
|
+
const next = items[i + 1]?.type;
|
|
310
253
|
const margin =
|
|
311
|
-
next === "
|
|
254
|
+
next === "callToAction" || next === "continuation"
|
|
255
|
+
? "0 0 16px 0"
|
|
256
|
+
: "0 0 24px 0";
|
|
312
257
|
parts.push(renderBubble(item, margin));
|
|
313
258
|
});
|
|
259
|
+
|
|
314
260
|
const width = parts
|
|
315
|
-
.flatMap((
|
|
261
|
+
.flatMap((part) => part.text)
|
|
316
262
|
.reduce((w, l) => Math.max(w, l.length), 0);
|
|
317
263
|
const rule = "_".repeat(width);
|
|
318
264
|
|
|
319
265
|
const html = [
|
|
320
266
|
chatRuleHtml("top"),
|
|
321
|
-
...parts.map((
|
|
267
|
+
...parts.map((part) => part.html),
|
|
322
268
|
chatRuleHtml("bottom"),
|
|
323
269
|
].join("\n");
|
|
324
270
|
|
|
325
271
|
const text =
|
|
326
272
|
`${rule}\n\n` +
|
|
327
|
-
parts.map((
|
|
273
|
+
parts.map((part) => part.text.join("\n")).join("\n\n") +
|
|
328
274
|
`\n${rule}`;
|
|
329
275
|
|
|
330
|
-
return { html, text
|
|
276
|
+
return { html, text };
|
|
331
277
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
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 gap AFTER a rendered line: `"normal"` is a blank line in plain text,
|
|
38
|
+
* anything else is none.
|
|
39
|
+
*/
|
|
40
|
+
export type Spacing = "normal" | "tight" | "none";
|
|
41
|
+
|
|
42
|
+
/** The HTML margin each `Spacing` maps to. */
|
|
43
|
+
export const SPACING: Record<Spacing, string> = {
|
|
44
|
+
normal: "0 0 20px 0",
|
|
45
|
+
tight: "0 0 4px 0",
|
|
46
|
+
none: "0",
|
|
47
|
+
};
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `>> LABEL <<` button — email's answer to a `callToAction`.
|
|
3
|
+
*
|
|
4
|
+
* Moved verbatim from the deleted `src/email/render/blocks` (ADR-CONTRACTS-086). It
|
|
5
|
+
* lives in its own module because two callers need it: a standalone
|
|
6
|
+
* `callToAction` element (`./render`) and one inside a conversation
|
|
7
|
+
* (`./chat`), which place it at different margins.
|
|
8
|
+
*
|
|
9
|
+
* INVARIANTS:
|
|
10
|
+
* - Pure, and byte-identical to what the deleted `src/email/render/blocks` emits.
|
|
11
|
+
* - `href` absent means the label IS the payload (an OTP code): the button must
|
|
12
|
+
* not become a link, and plain text must not print a URL under it.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import type { CallToAction } from "../../content";
|
|
16
|
+
|
|
17
|
+
import { FONT_SIZE, MONO } from "./constants";
|
|
18
|
+
import { escapeHtml } from "./escape-html";
|
|
19
|
+
|
|
20
|
+
/** Padding columns on each side of the `>> LABEL <<` line in the ASCII box. */
|
|
21
|
+
const CTA_BOX_PAD = 3;
|
|
22
|
+
|
|
23
|
+
/** `>> LABEL <<` ASCII box (`*` corners): 3 lines, sized to the label. */
|
|
24
|
+
function asciiCtaBox(label: string): string {
|
|
25
|
+
const pad = " ".repeat(CTA_BOX_PAD);
|
|
26
|
+
const inner = `${pad}>> ${label} <<${pad}`;
|
|
27
|
+
const border = `*${"-".repeat(inner.length)}*`;
|
|
28
|
+
return [border, `|${inner}|`, border].join("\n");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
|
|
33
|
+
* the table. A standalone element wraps it as its own line; a chat unit embeds
|
|
34
|
+
* it.
|
|
35
|
+
*/
|
|
36
|
+
export function ctaButton(
|
|
37
|
+
cta: CallToAction,
|
|
38
|
+
margin: string,
|
|
39
|
+
): { html: string; text: string } {
|
|
40
|
+
const { label, href } = cta;
|
|
41
|
+
|
|
42
|
+
const tableStyle = `display: inline-block; border: 1px solid #666; border-radius: 2px; margin: ${margin}; max-width: 220px;`;
|
|
43
|
+
const tdStyle = `padding: 16px 24px; text-align: center; ${MONO} font-size: ${FONT_SIZE};`;
|
|
44
|
+
|
|
45
|
+
// Underline only the label text (not the chevrons/spaces), and only when linked.
|
|
46
|
+
const labelHtml = href
|
|
47
|
+
? `<span style="text-decoration: underline;">${escapeHtml(label)}</span>`
|
|
48
|
+
: escapeHtml(label);
|
|
49
|
+
const chevrons = `>> ${labelHtml} <<`;
|
|
50
|
+
const inner = href
|
|
51
|
+
? `<a href="${href}" style="color: #0047FF; text-decoration: none;">${chevrons}</a>`
|
|
52
|
+
: chevrons;
|
|
53
|
+
|
|
54
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" style="${tableStyle}">
|
|
55
|
+
<tr><td style="${tdStyle}">
|
|
56
|
+
${inner}
|
|
57
|
+
</td></tr>
|
|
58
|
+
</table>`;
|
|
59
|
+
|
|
60
|
+
const text = href ? `${asciiCtaBox(label)}\n\n${href}` : asciiCtaBox(label);
|
|
61
|
+
|
|
62
|
+
return { html, text };
|
|
63
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* HTML escaping for email
|
|
2
|
+
* HTML escaping for the email channel.
|
|
3
|
+
*
|
|
4
|
+
* Moved here from the deleted `src/email/render/escape-html`
|
|
5
|
+
* (ADR-CONTRACTS-086): escaping is one channel's concern, and the channel now
|
|
6
|
+
* owns it.
|
|
3
7
|
*
|
|
4
8
|
* INVARIANT: every user-controlled field in an HTML variant MUST pass through
|
|
5
9
|
* this before interpolation (prevents XSS in email clients).
|