@company-semantics/contracts 52.0.0 → 53.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (48) hide show
  1. package/package.json +1 -1
  2. package/src/api/generated-spec-hash.ts +2 -2
  3. package/src/api/generated.ts +122 -69
  4. package/src/generated/openapi-routes.ts +3 -1
  5. package/src/identity/README.md +2 -2
  6. package/src/identity/__tests__/people-org-chart.test.ts +52 -17
  7. package/src/identity/__tests__/position-ref.test.ts +44 -0
  8. package/src/identity/index.ts +6 -2
  9. package/src/identity/people-org-chart.ts +26 -15
  10. package/src/identity/position-ref.ts +24 -0
  11. package/src/index.ts +21 -2
  12. package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
  13. package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
  14. package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
  15. package/src/notifications/renderers/README.md +8 -4
  16. package/src/notifications/renderers/ascii/README.md +75 -0
  17. package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
  18. package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
  19. package/src/notifications/renderers/ascii/chat.ts +179 -0
  20. package/src/notifications/renderers/ascii/cta.ts +57 -0
  21. package/src/notifications/renderers/ascii/geometry.ts +112 -0
  22. package/src/notifications/renderers/ascii/index.ts +40 -0
  23. package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
  24. package/src/notifications/renderers/ascii/rule.ts +53 -0
  25. package/src/notifications/renderers/ascii/runs.ts +84 -0
  26. package/src/notifications/renderers/ascii/signature.ts +41 -0
  27. package/src/notifications/renderers/ascii/wrap.ts +96 -0
  28. package/src/notifications/renderers/brand.ts +12 -0
  29. package/src/notifications/renderers/email/chat.ts +62 -146
  30. package/src/notifications/renderers/email/constants.ts +17 -2
  31. package/src/notifications/renderers/email/cta.ts +8 -13
  32. package/src/notifications/renderers/email/render.ts +29 -5
  33. package/src/notifications/renderers/layout.ts +31 -0
  34. package/src/notifications/renderers/slack/README.md +135 -79
  35. package/src/notifications/renderers/slack/__tests__/README.md +3 -2
  36. package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
  37. package/src/notifications/renderers/slack/blocks.ts +149 -0
  38. package/src/notifications/renderers/slack/chat.ts +167 -0
  39. package/src/notifications/renderers/slack/cta.ts +69 -0
  40. package/src/notifications/renderers/slack/index.ts +136 -229
  41. package/src/notifications/renderers/slack/message.ts +23 -0
  42. package/src/org/README.md +10 -2
  43. package/src/org/__tests__/org-units.test.ts +1 -1
  44. package/src/org/__tests__/set-seat-manager.test.ts +177 -0
  45. package/src/org/index.ts +20 -2
  46. package/src/org/reconciliation.ts +162 -0
  47. package/src/org/schemas.ts +43 -17
  48. package/src/identity/org-chart-actor.ts +0 -24
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Which lines of a REAL notification are allowed past the column budget.
3
+ *
4
+ * `../renderers/ascii/__tests__/layout.test.ts` proves the drawing functions
5
+ * keep their generated geometry inside the budget. That is the half that can be
6
+ * asserted outright. This is the other half, and it cannot be: once a
7
+ * notification is rendered, generated geometry and atomic content are one
8
+ * string, and no predicate can reliably tell a deliberately-unwrapped device
9
+ * string from a wrapper that failed.
10
+ *
11
+ * So this snapshots the exemption list instead. Every plain-text line that
12
+ * overhangs, across every kind and variant, in one reviewable place. The
13
+ * property being defended is not "nothing overhangs" — URLs and one-time codes
14
+ * must overhang rather than be broken (`../renderers/layout.ts`). It is that
15
+ * **nothing overhangs by accident**: a new entry here is a diff someone has to
16
+ * look at and agree with.
17
+ *
18
+ * If an entry appears that is ordinary prose, the wrapper has a bug. If one
19
+ * appears that is a URL, an identifier or a key/value row, that is the design
20
+ * working.
21
+ */
22
+
23
+ import { describe, expect, it } from "vitest";
24
+
25
+ import { renderToChannel } from "../render";
26
+ import { emailRenderer } from "../renderers/email";
27
+ import { MONOSPACE_COLUMNS } from "../renderers/layout";
28
+
29
+ import { NOTIFICATION_FIXTURES } from "./fixtures";
30
+
31
+ describe("monospace budget", () => {
32
+ it("overhangs only where content is atomic", () => {
33
+ const overhanging = new Set<string>();
34
+
35
+ for (const fixture of NOTIFICATION_FIXTURES) {
36
+ const rendered = renderToChannel(
37
+ fixture.kind,
38
+ fixture.payload as never,
39
+ emailRenderer,
40
+ );
41
+ for (const line of rendered.text.split("\n")) {
42
+ if (line.length > MONOSPACE_COLUMNS) overhanging.add(line.trim());
43
+ }
44
+ }
45
+
46
+ expect([...overhanging].sort()).toMatchSnapshot();
47
+ });
48
+
49
+ it("wraps ordinary prose rather than letting it overhang", () => {
50
+ // The regression this pins: `body` used to reach the plain-text surface
51
+ // unwrapped, so a long sentence became one 176-character line that a phone
52
+ // could only show by scrolling sideways. Prose has no claim to the atomic
53
+ // exemption — it is precisely the thing a wrapper is for, and this fixture
54
+ // carries the longest body in the corpus.
55
+ const fixture = NOTIFICATION_FIXTURES.find(
56
+ (f) => f.kind === "org.unit_owner_granted",
57
+ );
58
+ if (!fixture) throw new Error("fixture for org.unit_owner_granted is gone");
59
+
60
+ const rendered = renderToChannel(
61
+ fixture.kind,
62
+ fixture.payload as never,
63
+ emailRenderer,
64
+ );
65
+
66
+ // A line that overhangs AND contains a space AND holds no URL is prose the
67
+ // wrapper failed to break.
68
+ const prose = rendered.text
69
+ .split("\n")
70
+ .filter((line) => line.length > MONOSPACE_COLUMNS)
71
+ .filter((line) => line.includes(" ") && !line.includes("://"));
72
+
73
+ expect(prose).toEqual([]);
74
+ });
75
+ });
@@ -12,8 +12,12 @@ only place in this package that knows what a sentence looks like.
12
12
  whole layer" to one channel among several.
13
13
  - `sms/` — `Renderer<string>`, a non-functional placeholder. The poorest channel
14
14
  the vocabulary will meet: no markup, no images, no layout.
15
- - `slack/` — `Renderer<SlackMessage>`, a non-functional placeholder. A minimal
16
- Block Kit tree.
15
+ - `slack/` — `Renderer<SlackMessage>`, which renders but does not send. Its
16
+ narrative content is monospace, drawn by `ascii/` and set in
17
+ `rich_text_preformatted`; its controls are Slack's own native primitives.
18
+ - `ascii/` — NOT a channel. The monospace layout layer both `email/`'s
19
+ `text/plain` surface and `slack/` draw with, so a conversation cannot come out
20
+ a column apart on the two. It implements no `Renderer`.
17
21
 
18
22
  A channel is added by writing a directory here — not by editing `../content.ts`,
19
23
  `../definition.ts` or any kind. That is the property the seam exists for, and the
@@ -27,8 +31,8 @@ intention into a fact. They are placeholders on purpose: they do not send, and
27
31
  their copy is invented rather than relocated. Their SHAPE is not provisional.
28
32
 
29
33
  Three channels now answer `supports` differently over one content that carries no
30
- channel tags — email declines nothing, slack declines `chatUnit`, sms also
31
- declines `heroImage` and `divider` — and `Out` is a record, then a string, then a
34
+ channel tags — email and slack decline nothing, sms declines `heroImage`,
35
+ `chatUnit` and `divider` — and `Out` is a record, then a string, then a
32
36
  different record. Those are this layer's two design claims, stated by real modules
33
37
  rather than by test doubles.
34
38
 
@@ -0,0 +1,75 @@
1
+ # notifications/renderers/ascii/
2
+
3
+ ## Purpose
4
+
5
+ The monospace layout layer: box art, word wrapping, and the chat unit's
6
+ positioning decisions, shared by every channel that draws on a fixed-width
7
+ surface.
8
+
9
+ This is **not a renderer**. It implements no `Renderer`, answers no
10
+ `NotificationContent`, and produces no channel's bytes. It draws, and the
11
+ channels decide what the drawing becomes — `email/` flattens it into a
12
+ `text/plain` body, `slack/` maps it onto `rich_text_preformatted` elements.
13
+
14
+ It exists because the box art was trapped inside `../email/chat.ts` and
15
+ `../email/cta.ts`. The moment a second channel wanted to draw a conversation,
16
+ those columns were going to be copied — and two channels that wrap at different
17
+ points are two different messages.
18
+
19
+ | module | what it holds |
20
+ | ------------- | ---------------------------------------------------------------------- |
21
+ | `runs.ts` | `MonospaceRun` / `MonospaceLine` and the line helpers |
22
+ | `geometry.ts` | the column budget and every measurement derived from it |
23
+ | `wrap.ts` | `wrapText` / `wrapClamped` / `clampMessage` — the truncation authority |
24
+ | `cta.ts` | the `>> LABEL <<` box |
25
+ | `chat.ts` | `planChatUnit` (the layout plan) + the bubble, CTA and dots drawings |
26
+
27
+ ## Runs, not strings
28
+
29
+ A rendered line can contain a URL, and the channels disagree about what to do
30
+ with one: email's text body prints it, Slack wants a `RichTextLink` so it stays
31
+ clickable. So a line is runs — `text` or `link` — and flattening is the
32
+ CALLER's choice, made at the surface that knows its own capabilities.
33
+
34
+ Returning `string[]` would dissolve the href into a string and force Slack to go
35
+ looking for it again with a regex. That is the hack this shape exists to prevent.
36
+
37
+ ## Invariants
38
+
39
+ - **Pure.** Every column is real output, locked by
40
+ `../../__tests__/render-snapshot.test.ts`. A stray space is a visibly broken
41
+ email, not a whitespace nit.
42
+ - **The run vocabulary stays dumb.** `MonospaceRun` carries text, links and line
43
+ boundaries — nothing else, ever. CTA-ness, warning-ness, chat turn, author,
44
+ role and alignment are function INPUTS. A run that learns what a CTA is has
45
+ started building a second notification model beside `../../content`, which is
46
+ the fusion ADR-CONTRACTS-086 undid.
47
+ - **The budget binds generated geometry only.** Box borders, frames, rules,
48
+ padding and prose this layer wraps itself must fit `AsciiGeometry.columns`.
49
+ Atomic content — URLs, OTP codes, identifiers, tokens, cell values — MAY
50
+ exceed it and must never be broken to satisfy it. A line break inserted into a
51
+ one-time code is a correctness bug wearing a layout costume.
52
+ - **One truncation authority.** `clampMessage` clamps every surface of a chat
53
+ unit, so they all truncate at the same point.
54
+ - **A "column" is a JavaScript string character.** The decorative geometry is
55
+ ASCII, so characters and columns coincide there. Wrapped prose uses the same
56
+ string semantics and makes no claim to Unicode terminal-cell precision — a CJK
57
+ glyph or emoji occupying two cells is not modelled, deliberately, because
58
+ promising otherwise would be a false precision.
59
+
60
+ ## Public API
61
+
62
+ - `geometryFor(columns)` → `AsciiGeometry`; `AVATAR`, `KAOMOJI`.
63
+ - `MonospaceRun`, `MonospaceLine`, and `text` / `lineLength` / `blockWidth` /
64
+ `flattenLine` / `flattenLines` / `indentLine` / `padLineStart`.
65
+ - `wrapText`, `wrapClamped`, `clampMessage`.
66
+ - `renderCtaAscii`, `ctaBoxWidth`.
67
+ - `planChatUnit` → `ChatPart[]`, plus `renderBubbleAscii`,
68
+ `renderChatCtaAscii`, `renderChatDotsAscii`, `chatRuleAscii`.
69
+
70
+ ## Dependencies
71
+
72
+ - `../../content` — `CallToAction`, `ChatTurn`, `ChatUnitItem`. Types only; this
73
+ layer reads the vocabulary and never extends it.
74
+
75
+ Nothing else. It knows no channel, no colour, no markup and no `Renderer`.
@@ -0,0 +1,39 @@
1
+ # notifications/renderers/ascii/\_\_tests\_\_/
2
+
3
+ ## Purpose
4
+
5
+ Tests for the monospace layout layer.
6
+
7
+ `layout.test.ts` asserts the half of the column budget that CAN be asserted
8
+ outright: every line these functions **generate** — box borders, bubble frames,
9
+ rules, padding, wrapped prose — fits `AsciiGeometry.columns`. It asserts this
10
+ against the drawing functions directly, because that is the only place where
11
+ generated geometry and atomic content are still distinguishable.
12
+
13
+ It also pins the layout decisions `planChatUnit` makes (which side a CTA hangs
14
+ on, whether continuation dots fold into the CTA below them), since those are
15
+ shared by every channel that draws a conversation and a change to them is a
16
+ change to all of them at once.
17
+
18
+ The other half of the budget — which lines a REAL notification is allowed to
19
+ overhang with — is `../../../__tests__/monospace-budget.test.ts`. It has to be a
20
+ snapshot rather than an assertion, and that file explains why.
21
+
22
+ ## Invariants
23
+
24
+ - **Pathological input is the point.** A wrapper is easy to get right on "Hello".
25
+ The cases that matter are a 200-character unbroken word, a long URL, emoji,
26
+ combining characters, CJK, the empty string, and a CTA label sitting exactly on
27
+ the boundary. Adding a case is cheaper than the bug it would have caught.
28
+ - **Atomic content is expected to overhang, and is asserted to.** The tests that
29
+ look like they are permitting a failure — an over-long CTA label making a wide
30
+ box, a long display name past the box edge — are pinning a deliberate rule from
31
+ `../../layout.ts`. Breaking a one-time code or a URL across lines to satisfy a
32
+ width check would corrupt the thing the notification exists to deliver.
33
+ - **Geometry, not markup.** No test here asserts HTML or Block Kit. What a
34
+ channel makes of these lines belongs to that channel's tests.
35
+
36
+ ## Dependencies
37
+
38
+ - `../index` — the layer under test.
39
+ - `../../../content` — `ChatUnitItem` and friends, for building inputs.
@@ -0,0 +1,228 @@
1
+ /**
2
+ * The column budget, enforced where it is actually promised.
3
+ *
4
+ * The budget binds GENERATED geometry — box borders, bubble frames, rules,
5
+ * padding, and prose this layer wraps itself. So it is asserted here, against
6
+ * the drawing functions directly, rather than against a rendered notification
7
+ * where generated lines and atomic content are already mixed into one string and
8
+ * can no longer be told apart. `../../__tests__/monospace-budget.test.ts` covers
9
+ * the other half: which lines a real notification is allowed to overhang with.
10
+ *
11
+ * The pathological inputs are the point. A wrapper is easy to get right on
12
+ * "Hello" and easy to get wrong on a 200-character word, an empty string, or a
13
+ * label sitting exactly on the boundary.
14
+ */
15
+
16
+ import { describe, expect, it } from "vitest";
17
+
18
+ import type { ChatUnitItem } from "../../../content";
19
+ import {
20
+ ruleAscii,
21
+ geometryFor,
22
+ lineLength,
23
+ type MonospaceLine,
24
+ planChatUnit,
25
+ renderBubbleAscii,
26
+ renderChatCtaAscii,
27
+ renderChatDotsAscii,
28
+ renderCtaAscii,
29
+ wrapText,
30
+ } from "../index";
31
+
32
+ const COLUMNS = 40;
33
+ const G = geometryFor(COLUMNS);
34
+
35
+ /** The widest line, and the line itself, so a failure names the culprit. */
36
+ function widest(lines: MonospaceLine[]): {
37
+ width: number;
38
+ line: MonospaceLine;
39
+ } {
40
+ return lines.reduce(
41
+ (worst, line) =>
42
+ lineLength(line) > worst.width
43
+ ? { width: lineLength(line), line }
44
+ : worst,
45
+ { width: 0, line: [] as MonospaceLine },
46
+ );
47
+ }
48
+
49
+ const LONG_WORD = "A".repeat(200);
50
+ const LONG_URL =
51
+ "https://app.companysemantics.ai/doc/handbook?request=req_456&from=notification";
52
+ const EMOJI = "🎉 shipped 🚀 the 🧵 thing 🎊 today 🌟 and 🔥 again";
53
+ const COMBINING = "é".repeat(30) + " " + "é".repeat(30);
54
+ const CJK = "設計レビューのフィードバックをお願いします".repeat(3);
55
+
56
+ describe("geometryFor", () => {
57
+ it("reserves BOTH avatar gutters, so a user bubble fits the budget too", () => {
58
+ // The recipient's avatar hangs outside the box on the right. Reserving only
59
+ // the left gutter is the bug this asserts against: it let a user bubble run
60
+ // nine columns past the budget while the assistant's stayed inside.
61
+ const bubble = renderBubbleAscii(
62
+ { type: "message", role: "user", text: "hello there" },
63
+ G,
64
+ );
65
+ expect(widest(bubble).width).toBeLessThanOrEqual(COLUMNS);
66
+ });
67
+
68
+ it("puts the box edge short of the budget by the reserved right avatar", () => {
69
+ expect(G.boxRight).toBeLessThan(G.columns);
70
+ expect(G.indent + G.messageWidth + 4).toBe(G.boxRight);
71
+ });
72
+
73
+ it("refuses a budget too small to draw its own frame", () => {
74
+ // Silently clamping would produce a plausible-looking box with content
75
+ // missing from it, which is worse than a thrown error at build time.
76
+ expect(() => geometryFor(8)).toThrow(RangeError);
77
+ });
78
+ });
79
+
80
+ describe("wrapText", () => {
81
+ it("hard-breaks a word longer than the width rather than overflowing", () => {
82
+ for (const line of wrapText(LONG_WORD, COLUMNS)) {
83
+ expect(line.length).toBeLessThanOrEqual(COLUMNS);
84
+ }
85
+ });
86
+
87
+ it("returns one empty line for empty input, not zero lines", () => {
88
+ // A body line that is empty is a blank line the reader sees; dropping it
89
+ // would silently close up the paragraph.
90
+ expect(wrapText("", COLUMNS)).toEqual([""]);
91
+ });
92
+
93
+ it("keeps a word that exactly fills the width on its own line", () => {
94
+ const exact = "B".repeat(COLUMNS);
95
+ expect(wrapText(exact, COLUMNS)).toEqual([exact]);
96
+ });
97
+ });
98
+
99
+ describe("bubble geometry stays inside the budget", () => {
100
+ const cases: Array<[string, string]> = [
101
+ ["a long unbroken word", LONG_WORD],
102
+ ["a long URL", LONG_URL],
103
+ ["emoji", EMOJI],
104
+ ["combining characters", COMBINING],
105
+ ["CJK", CJK],
106
+ ["empty", ""],
107
+ ["a single space", " "],
108
+ ["newlines", "one\ntwo\nthree"],
109
+ ];
110
+
111
+ for (const role of ["user", "assistant"] as const) {
112
+ for (const [name, text] of cases) {
113
+ it(`${role} · ${name}`, () => {
114
+ const lines = renderBubbleAscii({ type: "message", role, text }, G);
115
+ expect(widest(lines).width).toBeLessThanOrEqual(COLUMNS);
116
+ });
117
+ }
118
+ }
119
+
120
+ it("a long display name does not push the attribution past the box", () => {
121
+ const lines = renderBubbleAscii(
122
+ {
123
+ type: "message",
124
+ role: "user",
125
+ text: "hi",
126
+ from: "Bartholomew Fitzgerald-Montgomery III",
127
+ },
128
+ G,
129
+ );
130
+ // The name is atomic — it is a person's name and must not be broken — so it
131
+ // may overhang. What must NOT happen is the BOX growing to accommodate it.
132
+ const boxLines = lines.filter((line) => lineLength(line) <= COLUMNS);
133
+ expect(widest(boxLines).width).toBeLessThanOrEqual(COLUMNS);
134
+ });
135
+ });
136
+
137
+ describe("CTA geometry", () => {
138
+ it("draws a label that sits exactly on the boundary without overflowing", () => {
139
+ // The box adds 14 columns to the label (3 pad + '>> ' + ' <<' + 3 pad + 2
140
+ // borders), so this is the widest label that still fits.
141
+ const label = "C".repeat(COLUMNS - 14);
142
+ const lines = renderCtaAscii({ type: "callToAction", label }, G);
143
+ expect(widest(lines).width).toBe(COLUMNS);
144
+ });
145
+
146
+ it("lets an over-long label make a wide box rather than breaking the label", () => {
147
+ // A CTA label is atomic. Wrapping it would corrupt the payload when the
148
+ // label IS the payload, which is exactly the hrefless case.
149
+ const label = "D".repeat(60);
150
+ const lines = renderCtaAscii({ type: "callToAction", label }, G);
151
+ expect(widest(lines).width).toBeGreaterThan(COLUMNS);
152
+ expect(lines).toHaveLength(3);
153
+ });
154
+
155
+ it("carries the href as a link run, not as text", () => {
156
+ const lines = renderCtaAscii(
157
+ { type: "callToAction", label: "Accept", href: LONG_URL },
158
+ G,
159
+ );
160
+ const link = lines.at(-1)?.[0];
161
+ expect(link).toEqual({ type: "link", text: LONG_URL, href: LONG_URL });
162
+ });
163
+
164
+ it("prints no destination line when there is nothing to go to", () => {
165
+ // An OTP code is a box with no link in it. A URL underneath would be a
166
+ // fabricated destination.
167
+ const lines = renderCtaAscii({ type: "callToAction", label: "123456" }, G);
168
+ expect(lines).toHaveLength(3);
169
+ });
170
+ });
171
+
172
+ describe("chat unit rules", () => {
173
+ const withCta: ChatUnitItem[] = [
174
+ { type: "message", role: "user", text: "share this" },
175
+ { type: "callToAction", label: "SEE MORE", href: LONG_URL },
176
+ ];
177
+
178
+ it("caps the rule at the budget when an atomic URL overhangs", () => {
179
+ const parts = planChatUnit(withCta);
180
+ const drawn = parts.flatMap((part) =>
181
+ part.kind === "bubble"
182
+ ? renderBubbleAscii(part.turn, G)
183
+ : part.kind === "cta"
184
+ ? renderChatCtaAscii(part.cta, part.align, part.withDots, G)
185
+ : renderChatDotsAscii(G),
186
+ );
187
+ // The URL is allowed past the edge; the rule is not allowed to follow it.
188
+ expect(widest(drawn).width).toBeGreaterThan(COLUMNS);
189
+ expect(lineLength(ruleAscii(drawn, G))).toBe(COLUMNS);
190
+ });
191
+
192
+ it("draws a short rule for a short unit rather than always spanning the budget", () => {
193
+ const short = renderBubbleAscii(
194
+ { type: "message", role: "assistant", text: "ok" },
195
+ G,
196
+ );
197
+ expect(lineLength(ruleAscii(short, G))).toBeLessThan(COLUMNS);
198
+ });
199
+ });
200
+
201
+ describe("planChatUnit", () => {
202
+ it("folds continuation dots into the CTA that follows them", () => {
203
+ const parts = planChatUnit([
204
+ { type: "message", role: "user", text: "hi" },
205
+ { type: "continuation" },
206
+ { type: "callToAction", label: "GO", href: "https://example.com" },
207
+ ]);
208
+ expect(parts.map((p) => p.kind)).toEqual(["bubble", "cta"]);
209
+ expect(parts[1]).toMatchObject({ kind: "cta", withDots: true });
210
+ });
211
+
212
+ it("leaves dots standing alone when no CTA follows", () => {
213
+ const parts = planChatUnit([
214
+ { type: "message", role: "user", text: "hi" },
215
+ { type: "continuation" },
216
+ ]);
217
+ expect(parts.map((p) => p.kind)).toEqual(["bubble", "dots"]);
218
+ });
219
+
220
+ it("hangs a CTA on the side of the message it belongs to, skipping dots", () => {
221
+ const parts = planChatUnit([
222
+ { type: "message", role: "user", text: "hi" },
223
+ { type: "continuation" },
224
+ { type: "callToAction", label: "GO" },
225
+ ]);
226
+ expect(parts[1]).toMatchObject({ align: "right" });
227
+ });
228
+ });
@@ -0,0 +1,179 @@
1
+ /**
2
+ * A conversation, drawn in monospace — and the layout PLAN both channels walk.
3
+ *
4
+ * Moved from `../email/chat.ts`, which keeps its HTML `<table>` skeleton and
5
+ * now asks here for the plan and the ascii form.
6
+ *
7
+ * **Two things live here, and the split matters.**
8
+ *
9
+ * `planChatUnit` answers the layout QUESTIONS — which side a CTA hangs on,
10
+ * whether continuation dots fold into the CTA below them or stand alone,
11
+ * whether a bubble hugs what follows it. Those answers are the same on every
12
+ * channel, and they are the part that was silently duplicated the moment a
13
+ * second channel tried to draw a conversation. Email walks the plan into
14
+ * `<table>`s; Slack walks the SAME plan into preformatted blocks and native
15
+ * buttons. Neither re-derives the alignment.
16
+ *
17
+ * The `render*Ascii` functions answer the DRAWING question, and only surfaces
18
+ * that want box art call them.
19
+ *
20
+ * `ChatPart` is a plan, not a content model: it holds `../../content` types and
21
+ * adds only positioning. It must not grow fields describing what a turn MEANS —
22
+ * that is `../../content`'s job and duplicating it here is how this directory
23
+ * would turn into a second vocabulary.
24
+ *
25
+ * INVARIANTS:
26
+ * - Pure. Every column is real output and is locked by
27
+ * `../../__tests__/render-snapshot.test.ts` — a stray space is a visibly
28
+ * broken email, not a whitespace nit.
29
+ * - `clampMessage` from `./wrap` is the one truncation authority, so every
30
+ * surface truncates at the same point.
31
+ */
32
+
33
+ import type { CallToAction, ChatTurn, ChatUnitItem } from "../../content";
34
+
35
+ import { ctaBoxWidth, renderCtaAscii } from "./cta";
36
+ import { AVATAR, type AsciiGeometry, KAOMOJI } from "./geometry";
37
+ import { indentLine, type MonospaceLine, padLineStart, text } from "./runs";
38
+ import { clampMessage, wrapText } from "./wrap";
39
+
40
+ // =============================================================================
41
+ // The plan
42
+ // =============================================================================
43
+
44
+ /** Which side of the channel a part hangs on. */
45
+ export type ChatAlign = "left" | "right";
46
+
47
+ /**
48
+ * One positioned item of a conversation.
49
+ *
50
+ * `tight` marks a bubble that is introducing whatever follows it (a CTA, the
51
+ * dots) and so should hug it. Named for the relationship rather than for a
52
+ * pixel gap, because the two channels spend it differently — email as a margin
53
+ * recipe, Slack as nothing at all.
54
+ */
55
+ export type ChatPart =
56
+ | { kind: "bubble"; turn: ChatTurn; tight: boolean }
57
+ | { kind: "cta"; cta: CallToAction; align: ChatAlign; withDots: boolean }
58
+ | { kind: "dots" };
59
+
60
+ /**
61
+ * Position a chat unit's items — the layout decisions, made once.
62
+ *
63
+ * Continuation dots immediately before a CTA are folded INTO that CTA (they
64
+ * render centered over its box); otherwise they stand alone, centered in the
65
+ * channel. A CTA mirrors the side of the nearest preceding message, skipping
66
+ * any dots between, so it sits under the bubble it belongs to.
67
+ */
68
+ export function planChatUnit(items: ChatUnitItem[]): ChatPart[] {
69
+ const parts: ChatPart[] = [];
70
+ items.forEach((item, i) => {
71
+ if (item.type === "continuation") {
72
+ if (items[i + 1]?.type !== "callToAction") parts.push({ kind: "dots" });
73
+ return;
74
+ }
75
+ if (item.type === "callToAction") {
76
+ let j = i - 1;
77
+ while (j >= 0 && items[j].type === "continuation") j--;
78
+ const prev = items[j];
79
+ parts.push({
80
+ kind: "cta",
81
+ cta: item,
82
+ align:
83
+ prev?.type === "message" && prev.role === "user" ? "right" : "left",
84
+ withDots: items[i - 1]?.type === "continuation",
85
+ });
86
+ return;
87
+ }
88
+ const next = items[i + 1]?.type;
89
+ parts.push({
90
+ kind: "bubble",
91
+ turn: item,
92
+ tight: next === "callToAction" || next === "continuation",
93
+ });
94
+ });
95
+ return parts;
96
+ }
97
+
98
+ // =============================================================================
99
+ // The drawing
100
+ // =============================================================================
101
+
102
+ /**
103
+ * One message as a box, with its speaker's avatar beside the last line.
104
+ *
105
+ * Both roles share the gutter so the two boxes align; the user's text is
106
+ * right-set inside it and the assistant's left-set, which is what makes the
107
+ * conversation readable without either box moving.
108
+ */
109
+ export function renderBubbleAscii(
110
+ turn: ChatTurn,
111
+ geometry: AsciiGeometry,
112
+ ): MonospaceLine[] {
113
+ const { indent, messageWidth, maxMessageLines } = geometry;
114
+ const isUser = turn.role === "user";
115
+ const clamped = clampMessage(turn.text, messageWidth, maxMessageLines);
116
+
117
+ const border = "─".repeat(messageWidth + 2);
118
+ const gutter = " ".repeat(indent);
119
+ const body = wrapText(clamped, messageWidth).map(
120
+ (line) =>
121
+ `${gutter}│ ${isUser ? line.padStart(messageWidth) : line.padEnd(messageWidth)} │`,
122
+ );
123
+
124
+ // The avatar sits beside the LAST line of the box, one row up from the bottom
125
+ // border — the recipient's outside the right edge, ours replacing the gutter.
126
+ const last = body.length - 1;
127
+ if (isUser) body[last] += ` ${KAOMOJI}`;
128
+ else body[last] = `${AVATAR} ${body[last].slice(indent)}`;
129
+
130
+ const box = [
131
+ text(`${gutter}┌${border}┐`),
132
+ ...body.map(text),
133
+ text(`${gutter}└${border}┘`),
134
+ ];
135
+
136
+ // A named recipient signs under their own bubble.
137
+ if (isUser && turn.from) {
138
+ box.push(padLineStart(text(turn.from), geometry.boxRight - 1));
139
+ }
140
+ return box;
141
+ }
142
+
143
+ /**
144
+ * A CTA inside the conversation, aligned to the side of the message it follows.
145
+ *
146
+ * When `withDots`, the continuation "⋮" render just above it, centered over the
147
+ * box's exact width regardless of how long the label is.
148
+ */
149
+ export function renderChatCtaAscii(
150
+ cta: CallToAction,
151
+ align: ChatAlign,
152
+ withDots: boolean,
153
+ geometry: AsciiGeometry,
154
+ ): MonospaceLine[] {
155
+ // `boxRight`, not `columns`: a CTA lines up with the BUBBLE's right border,
156
+ // not with the recipient's avatar hanging past it.
157
+ const { boxRight, indent } = geometry;
158
+ const lines = renderCtaAscii(cta, geometry).map((line) =>
159
+ align === "right" ? padLineStart(line, boxRight) : indentLine(line, indent),
160
+ );
161
+ if (!withDots) return lines;
162
+
163
+ const width = ctaBoxWidth(cta, geometry);
164
+ const column =
165
+ align === "right"
166
+ ? boxRight - Math.floor(width / 2)
167
+ : indent + Math.ceil(width / 2);
168
+ return [padLineStart(text("⋮"), column), [], ...lines];
169
+ }
170
+
171
+ /**
172
+ * Standalone continuation dots, centered in the message channel.
173
+ *
174
+ * Used only when a `continuation` is NOT immediately followed by a
175
+ * `callToAction` — the common case folds the dots into the CTA above it.
176
+ */
177
+ export function renderChatDotsAscii(geometry: AsciiGeometry): MonospaceLine[] {
178
+ return [padLineStart(text("⋮"), Math.round(geometry.boxRight / 2))];
179
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The `>> LABEL <<` box — the monospace drawing of a `callToAction`.
3
+ *
4
+ * Moved from `../email/cta.ts`, which keeps its HTML `<table>` and now asks
5
+ * here for the ascii form.
6
+ *
7
+ * **The href is a run, not a string.** A linked CTA draws its box and then puts
8
+ * the destination on its own line below — and that line comes back as a `link`
9
+ * run so a channel that can make it clickable still can. Email flattens it to
10
+ * the bare URL, which is what a plain-text body can do; Slack maps it to a
11
+ * `RichTextLink` inside its preformatted block, which is what Slack can do.
12
+ * Neither has to go looking for a URL inside a string.
13
+ *
14
+ * INVARIANTS:
15
+ * - Pure.
16
+ * - `href` absent means the label IS the payload (an OTP code): the box must
17
+ * not become a link and no URL line may appear beneath it. Fabricating a
18
+ * destination for a code is the one thing this element must never do.
19
+ * - The label is ATOMIC. It is never wrapped or truncated to fit the column
20
+ * budget — a code broken across lines is not a code. A long label makes a
21
+ * wide box, and that is the correct failure.
22
+ */
23
+
24
+ import type { CallToAction } from "../../content";
25
+
26
+ import type { AsciiGeometry } from "./geometry";
27
+ import { type MonospaceLine, text } from "./runs";
28
+
29
+ /**
30
+ * The `>> LABEL <<` box, and the destination line when there is one.
31
+ *
32
+ * Three lines of box, then — only when linked — a blank line and the href.
33
+ */
34
+ export function renderCtaAscii(
35
+ cta: CallToAction,
36
+ geometry: AsciiGeometry,
37
+ ): MonospaceLine[] {
38
+ const pad = " ".repeat(geometry.ctaPad);
39
+ const inner = `${pad}>> ${cta.label} <<${pad}`;
40
+ const border = `*${"-".repeat(inner.length)}*`;
41
+
42
+ const box = [text(border), text(`|${inner}|`), text(border)];
43
+ if (!cta.href) return box;
44
+
45
+ // A blank line is `[]`, not a run holding "" — an empty line has no runs, and
46
+ // the alignment helpers in `./runs` deliberately leave it alone rather than
47
+ // padding whitespace onto a line nobody can see.
48
+ return [...box, [], [{ type: "link", text: cta.href, href: cta.href }]];
49
+ }
50
+
51
+ /** The drawn width of a CTA box — its first line spans the whole thing. */
52
+ export function ctaBoxWidth(
53
+ cta: CallToAction,
54
+ geometry: AsciiGeometry,
55
+ ): number {
56
+ return cta.label.length + 6 + geometry.ctaPad * 2 + 2;
57
+ }