@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.
- package/package.json +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +122 -69
- package/src/generated/openapi-routes.ts +3 -1
- package/src/identity/README.md +2 -2
- package/src/identity/__tests__/people-org-chart.test.ts +52 -17
- package/src/identity/__tests__/position-ref.test.ts +44 -0
- package/src/identity/index.ts +6 -2
- package/src/identity/people-org-chart.ts +26 -15
- package/src/identity/position-ref.ts +24 -0
- package/src/index.ts +21 -2
- package/src/notifications/__tests__/__snapshots__/monospace-budget.test.ts.snap +23 -0
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +309 -259
- package/src/notifications/__tests__/monospace-budget.test.ts +75 -0
- package/src/notifications/renderers/README.md +8 -4
- package/src/notifications/renderers/ascii/README.md +75 -0
- package/src/notifications/renderers/ascii/__tests__/README.md +39 -0
- package/src/notifications/renderers/ascii/__tests__/layout.test.ts +228 -0
- package/src/notifications/renderers/ascii/chat.ts +179 -0
- package/src/notifications/renderers/ascii/cta.ts +57 -0
- package/src/notifications/renderers/ascii/geometry.ts +112 -0
- package/src/notifications/renderers/ascii/index.ts +40 -0
- package/src/notifications/renderers/ascii/keyvalue.ts +34 -0
- package/src/notifications/renderers/ascii/rule.ts +53 -0
- package/src/notifications/renderers/ascii/runs.ts +84 -0
- package/src/notifications/renderers/ascii/signature.ts +41 -0
- package/src/notifications/renderers/ascii/wrap.ts +96 -0
- package/src/notifications/renderers/brand.ts +12 -0
- package/src/notifications/renderers/email/chat.ts +62 -146
- package/src/notifications/renderers/email/constants.ts +17 -2
- package/src/notifications/renderers/email/cta.ts +8 -13
- package/src/notifications/renderers/email/render.ts +29 -5
- package/src/notifications/renderers/layout.ts +31 -0
- package/src/notifications/renderers/slack/README.md +135 -79
- package/src/notifications/renderers/slack/__tests__/README.md +3 -2
- package/src/notifications/renderers/slack/__tests__/index.test.ts +233 -93
- package/src/notifications/renderers/slack/blocks.ts +149 -0
- package/src/notifications/renderers/slack/chat.ts +167 -0
- package/src/notifications/renderers/slack/cta.ts +69 -0
- package/src/notifications/renderers/slack/index.ts +136 -229
- package/src/notifications/renderers/slack/message.ts +23 -0
- package/src/org/README.md +10 -2
- package/src/org/__tests__/org-units.test.ts +1 -1
- package/src/org/__tests__/set-seat-manager.test.ts +177 -0
- package/src/org/index.ts +20 -2
- package/src/org/reconciliation.ts +162 -0
- package/src/org/schemas.ts +43 -17
- 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>`,
|
|
16
|
-
|
|
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
|
|
31
|
-
|
|
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
|
+
}
|