@company-semantics/contracts 51.3.0 → 52.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 (33) hide show
  1. package/package.json +1 -1
  2. package/src/identity/__tests__/people-org-chart.test.ts +75 -0
  3. package/src/identity/people-org-chart.ts +25 -6
  4. package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
  5. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
  6. package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
  7. package/src/notifications/renderers/README.md +8 -4
  8. package/src/notifications/renderers/ascii/README.md +75 -0
  9. package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
  10. package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
  11. package/src/notifications/renderers/ascii/chat.ts +179 -0
  12. package/src/notifications/renderers/ascii/cta.ts +57 -0
  13. package/src/notifications/renderers/ascii/geometry.ts +112 -0
  14. package/src/notifications/renderers/ascii/index.ts +40 -0
  15. package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
  16. package/src/notifications/renderers/ascii/rule.ts +53 -0
  17. package/src/notifications/renderers/ascii/runs.ts +84 -0
  18. package/src/notifications/renderers/ascii/signature.ts +41 -0
  19. package/src/notifications/renderers/ascii/wrap.ts +96 -0
  20. package/src/notifications/renderers/brand.ts +12 -0
  21. package/src/notifications/renderers/email/chat.ts +62 -146
  22. package/src/notifications/renderers/email/constants.ts +17 -2
  23. package/src/notifications/renderers/email/cta.ts +8 -13
  24. package/src/notifications/renderers/email/render.ts +29 -5
  25. package/src/notifications/renderers/layout.ts +31 -0
  26. package/src/notifications/renderers/slack/README.md +135 -79
  27. package/src/notifications/renderers/slack/__tests__/README.md +3 -2
  28. package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
  29. package/src/notifications/renderers/slack/blocks.ts +149 -0
  30. package/src/notifications/renderers/slack/chat.ts +167 -0
  31. package/src/notifications/renderers/slack/cta.ts +69 -0
  32. package/src/notifications/renderers/slack/index.ts +136 -229
  33. package/src/notifications/renderers/slack/message.ts +23 -0
@@ -0,0 +1,167 @@
1
+ /**
2
+ * A `chatUnit` on Slack — the element this channel used to drop on the floor.
3
+ *
4
+ * It was declined whole on the grounds that Block Kit has no vocabulary for
5
+ * turn-taking. That reasoning was sound about BLOCKS and wrong about the
6
+ * message: `auth.otp` and `org.invite` both nest their payload inside a
7
+ * `chatUnit`, so declining the element meant the one-time code and the accept
8
+ * button never reached Slack at all. That is semantic loss, not a presentation
9
+ * limitation, and no amount of correctness about turn-taking justifies posting a
10
+ * login email with no login code in it.
11
+ *
12
+ * Slack does have a monospace surface, so the conversation is drawn the way
13
+ * email draws it — `../ascii`'s bubbles, from the same functions, so the two
14
+ * channels cannot drift — inside `rich_text_preformatted`.
15
+ *
16
+ * **Linked CTAs break OUT of the drawing.** An `actions` block cannot nest
17
+ * inside `rich_text`, and it should not want to: a native button is the better
18
+ * control (see `./cta.ts`). So the stream is cut at each linked CTA, and the
19
+ * button is emitted between the halves. A hrefless CTA — the OTP code — stays
20
+ * inside the drawing, where the box IS the payload.
21
+ *
22
+ * INVARIANTS:
23
+ * - **No empty preformatted block, ever.** A break-out at the head or tail of
24
+ * the stream must not leave a zero-line code box beside the button. Slack
25
+ * renders one as a visible empty rectangle, so this is a drawing bug rather
26
+ * than a tidiness question. Segments are merged and then filtered, so the
27
+ * arithmetic cannot produce one.
28
+ * - **Adjacent buttons coalesce** into one `actions` block rather than one each
29
+ * — see `ctaActions`.
30
+ * - Continuation dots that folded into a LINKED CTA survive the break-out as
31
+ * their own drawn line. Folding them into a button would silently delete them.
32
+ */
33
+
34
+ import type { KnownBlock } from "@slack/types";
35
+
36
+ import type { CallToAction, ChatUnitItem } from "../../content";
37
+ import {
38
+ type AsciiGeometry,
39
+ ruleAscii,
40
+ type MonospaceLine,
41
+ planChatUnit,
42
+ renderBubbleAscii,
43
+ renderChatCtaAscii,
44
+ renderChatDotsAscii,
45
+ } from "../ascii";
46
+
47
+ import { preformatted } from "./blocks";
48
+ import { ctaActions } from "./cta";
49
+
50
+ /**
51
+ * A stretch of the unit that becomes one block.
52
+ *
53
+ * `draw` is monospace lines; `buttons` is a run of linked CTAs. The whole
54
+ * algorithm below is: classify each part into one of these, merge neighbours of
55
+ * the same kind, drop anything empty, emit.
56
+ */
57
+ type Segment =
58
+ | { kind: "draw"; lines: MonospaceLine[] }
59
+ | { kind: "buttons"; ctas: CallToAction[] };
60
+
61
+ /** Classify the planned parts, keeping drawn lines and buttons apart. */
62
+ function segmentsFor(
63
+ items: ChatUnitItem[],
64
+ geometry: AsciiGeometry,
65
+ ): Segment[] {
66
+ const segments: Segment[] = [];
67
+
68
+ for (const part of planChatUnit(items)) {
69
+ if (part.kind === "bubble") {
70
+ segments.push({
71
+ kind: "draw",
72
+ lines: renderBubbleAscii(part.turn, geometry),
73
+ });
74
+ continue;
75
+ }
76
+ if (part.kind === "dots") {
77
+ segments.push({ kind: "draw", lines: renderChatDotsAscii(geometry) });
78
+ continue;
79
+ }
80
+ if (!part.cta.href) {
81
+ // The label is the payload. It belongs in the drawing.
82
+ segments.push({
83
+ kind: "draw",
84
+ lines: renderChatCtaAscii(
85
+ part.cta,
86
+ part.align,
87
+ part.withDots,
88
+ geometry,
89
+ ),
90
+ });
91
+ continue;
92
+ }
93
+ // A linked CTA leaves the drawing. Its folded dots do not go with it — they
94
+ // are part of the conversation's flow, so they stay drawn.
95
+ if (part.withDots) {
96
+ segments.push({ kind: "draw", lines: renderChatDotsAscii(geometry) });
97
+ }
98
+ segments.push({ kind: "buttons", ctas: [part.cta] });
99
+ }
100
+
101
+ return segments;
102
+ }
103
+
104
+ /**
105
+ * Merge neighbouring segments of the same kind.
106
+ *
107
+ * Drawn stretches rejoin with a blank line between them, the way email joins its
108
+ * parts; button runs concatenate so `ctaActions` can lay them out side by side.
109
+ */
110
+ function merge(segments: Segment[]): Segment[] {
111
+ const merged: Segment[] = [];
112
+
113
+ for (const segment of segments) {
114
+ const last = merged.at(-1);
115
+ if (last?.kind === "draw" && segment.kind === "draw") {
116
+ last.lines = [...last.lines, [], ...segment.lines];
117
+ } else if (last?.kind === "buttons" && segment.kind === "buttons") {
118
+ last.ctas = [...last.ctas, ...segment.ctas];
119
+ } else {
120
+ merged.push(
121
+ segment.kind === "draw"
122
+ ? { kind: "draw", lines: [...segment.lines] }
123
+ : { kind: "buttons", ctas: [...segment.ctas] },
124
+ );
125
+ }
126
+ }
127
+
128
+ return merged;
129
+ }
130
+
131
+ /**
132
+ * A conversation as Slack blocks, bracketed by the same `___` rules email draws.
133
+ *
134
+ * The rules join the FIRST and LAST drawn stretches rather than becoming blocks
135
+ * of their own: a unit that opens or closes on a button would otherwise need a
136
+ * one-line code box holding nothing but a rule, which is the empty-block problem
137
+ * wearing a disguise. A unit made entirely of buttons draws no rule at all,
138
+ * because there is no conversation to bracket.
139
+ */
140
+ export function renderChatUnitBlocks(
141
+ items: ChatUnitItem[],
142
+ geometry: AsciiGeometry,
143
+ ): KnownBlock[] {
144
+ const segments = merge(segmentsFor(items, geometry)).filter((segment) =>
145
+ segment.kind === "buttons"
146
+ ? segment.ctas.length > 0
147
+ : segment.lines.length > 0,
148
+ );
149
+
150
+ const drawn = segments.filter((segment) => segment.kind === "draw");
151
+ if (drawn.length > 0) {
152
+ const rule = ruleAscii(
153
+ drawn.flatMap((segment) => segment.lines),
154
+ geometry,
155
+ );
156
+ const first = drawn[0];
157
+ const last = drawn.at(-1);
158
+ first.lines = [rule, [], ...first.lines];
159
+ if (last) last.lines = [...last.lines, rule];
160
+ }
161
+
162
+ return segments.flatMap((segment): KnownBlock[] =>
163
+ segment.kind === "buttons"
164
+ ? ctaActions(segment.ctas)
165
+ : preformatted(segment.lines),
166
+ );
167
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * A `callToAction` on Slack — and the one place this channel deliberately
3
+ * refuses to copy email's drawing.
4
+ *
5
+ * **With an `href`: a native `actions` button.** Slack has a real control here
6
+ * and it is better than anything we could draw — a large touch target, an
7
+ * unambiguous affordance, keyboard and screen-reader semantics supplied by the
8
+ * client, and forward compatibility with whatever Slack's button becomes next.
9
+ * Drawing an ASCII box with a link inside would be sharing the CONTROL
10
+ * REPRESENTATION, which is not what cross-channel consistency means. The
11
+ * vocabulary is shared; the control is the channel's own.
12
+ *
13
+ * **Without an `href`: the ASCII box.** The label IS the payload — a one-time
14
+ * code — so there is nothing to click and a button would need a fabricated
15
+ * destination. Here email's `>> LABEL <<` box is exactly right, drawn by
16
+ * `../ascii` so the two channels cannot drift a column apart, and set in
17
+ * `rich_text_preformatted` so Slack renders it monospaced and cleanly
18
+ * selectable.
19
+ *
20
+ * That split is the whole principle in one element: same semantics, same brand
21
+ * drawing where the channel has nothing better, native primitive where it does.
22
+ */
23
+
24
+ import type { ActionsBlock, Button, KnownBlock } from "@slack/types";
25
+
26
+ import type { CallToAction } from "../../content";
27
+ import { type AsciiGeometry, renderCtaAscii } from "../ascii";
28
+
29
+ import { ACTIONS_MAX_ELEMENTS, chunk, plainText, preformatted } from "./blocks";
30
+
31
+ /** A linked CTA as one button. `plain_text` is literal — never escaped. */
32
+ export function ctaButton(cta: CallToAction): Button {
33
+ return {
34
+ type: "button",
35
+ text: plainText(cta.label),
36
+ // Only ever called for a linked CTA; the caller owns that branch.
37
+ url: cta.href,
38
+ };
39
+ }
40
+
41
+ /**
42
+ * One `actions` block per run of linked CTAs, split at Slack's element cap.
43
+ *
44
+ * Adjacent CTAs coalesce rather than each claiming their own block: two buttons
45
+ * in one `actions` sit side by side the way a reader expects a choice to be
46
+ * offered, while two `actions` blocks stack them and read as two unrelated
47
+ * prompts. Slack caps a block at 25 elements, so a longer run splits rather
48
+ * than being rejected.
49
+ */
50
+ export function ctaActions(ctas: CallToAction[]): ActionsBlock[] {
51
+ return chunk(ctas, ACTIONS_MAX_ELEMENTS).map((group) => ({
52
+ type: "actions",
53
+ elements: group.map(ctaButton),
54
+ }));
55
+ }
56
+
57
+ /**
58
+ * A standalone `callToAction` element.
59
+ *
60
+ * The hrefless form draws the box; the linked form is a button.
61
+ */
62
+ export function renderCtaBlocks(
63
+ cta: CallToAction,
64
+ geometry: AsciiGeometry,
65
+ ): KnownBlock[] {
66
+ return cta.href
67
+ ? ctaActions([cta])
68
+ : preformatted(renderCtaAscii(cta, geometry));
69
+ }