@company-semantics/contracts 32.0.0 → 33.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 +2 -2
- package/src/content/schemas.ts +18 -1
- package/src/email/render/auth-otp.ts +1 -3
- package/src/email/render/blocks.ts +12 -298
- package/src/email/render/chat-shared.ts +1 -5
- package/src/email/render/chat.ts +331 -0
- package/src/email/render/company-md-access-approved.ts +1 -3
- package/src/email/render/company-md-access-denied.ts +1 -2
- package/src/email/render/company-md-access-requested.ts +1 -4
- package/src/email/render/index.ts +12 -8
- package/src/email/render/org-invite.ts +1 -3
- package/src/email/render/ownership-transfer-completed.ts +1 -2
- package/src/email/render/ownership-transfer.ts +1 -4
- package/src/email/render/share-granted.ts +1 -4
- package/src/email/render/unit-owner-granted.ts +1 -4
- package/src/org/company-md.ts +15 -1
- package/src/permissions/share-api.ts +22 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@company-semantics/contracts",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "33.0.0",
|
|
4
4
|
"private": false,
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -125,7 +125,7 @@
|
|
|
125
125
|
"zod": "^4.4.3"
|
|
126
126
|
},
|
|
127
127
|
"devDependencies": {
|
|
128
|
-
"@types/node": "^
|
|
128
|
+
"@types/node": "^22.20.1",
|
|
129
129
|
"husky": "^9.1.7",
|
|
130
130
|
"lint-staged": "^17.0.8",
|
|
131
131
|
"markdownlint-cli2": "^0.23.0",
|
package/src/content/schemas.ts
CHANGED
|
@@ -125,7 +125,24 @@ export const CompanyMdDocResponseSchema = z.object({
|
|
|
125
125
|
]),
|
|
126
126
|
parentId: z.string().nullable(),
|
|
127
127
|
owningUnitId: z.string().nullable(),
|
|
128
|
-
|
|
128
|
+
/**
|
|
129
|
+
* The doc's EFFECTIVE owners (ADR-CONTRACTS-084), resolved by the same
|
|
130
|
+
* authority as the share dialog's `owner_user_ids`: a stored personal owner,
|
|
131
|
+
* else the owning unit's live authority holders, else the org account owner.
|
|
132
|
+
*
|
|
133
|
+
* A list, not a scalar — co-ownership is first-class (ADR-CTRL-161) and every
|
|
134
|
+
* entry is owner-equivalent (ADR-BE-189), so a surface naming one owner reads
|
|
135
|
+
* `owners[0]` and derives any co-owner count from `owners.length` (e.g. "Ada
|
|
136
|
+
* +2 are owners"). Ordered deterministically so the named owner is stable
|
|
137
|
+
* across reads. Empty only when the org has no account owner
|
|
138
|
+
* (runtime-impossible) — replaces the nullable `owner` scalar and the
|
|
139
|
+
* `ownerCount` shadow field.
|
|
140
|
+
*/
|
|
141
|
+
owners: z
|
|
142
|
+
.array(CompanyMdPersonSchema)
|
|
143
|
+
.refine((a) => new Set(a.map((p) => p.id)).size === a.length, {
|
|
144
|
+
message: "duplicate id in owners",
|
|
145
|
+
}),
|
|
129
146
|
canEdit: z.boolean(),
|
|
130
147
|
/**
|
|
131
148
|
* UI capability signal (ADR-BE-382): whether the requesting actor holds
|
|
@@ -10,15 +10,13 @@ import type { EmailPayloads } from "../types";
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
type Block,
|
|
13
|
-
chatAssistant,
|
|
14
|
-
chatCta,
|
|
15
|
-
chatUnit,
|
|
16
13
|
footer,
|
|
17
14
|
greeting,
|
|
18
15
|
keyValue,
|
|
19
16
|
paragraph,
|
|
20
17
|
signature,
|
|
21
18
|
} from "./blocks";
|
|
19
|
+
import { chatAssistant, chatCta, chatUnit } from "./chat";
|
|
22
20
|
import { COMPANY_NAME } from "./constants";
|
|
23
21
|
|
|
24
22
|
export type AuthOtpPayload = EmailPayloads["auth.otp"];
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
* UI is the components.** Editing a block restyles every email, HTML and plain
|
|
8
8
|
* text, across the backend (real sends) and the app (Ladle preview).
|
|
9
9
|
*
|
|
10
|
+
* This module holds the primitives — shells, paragraphs, and the CTA box. The
|
|
11
|
+
* chat unit builds on them from `./chat`; both are re-exported together from
|
|
12
|
+
* `./index`, which is the surface templates and consumers import.
|
|
13
|
+
*
|
|
10
14
|
* INVARIANTS:
|
|
11
15
|
* - Pure functions, no side effects — except `signature()`, which reads the
|
|
12
16
|
* current year (`new Date().getFullYear()`) for the copyright line.
|
|
@@ -17,11 +21,14 @@
|
|
|
17
21
|
import { COMPANY_NAME, MONO_FONT_STACK } from "./constants";
|
|
18
22
|
import { escapeHtml } from "./escape-html";
|
|
19
23
|
|
|
20
|
-
|
|
24
|
+
/** Shared type styling. Exported for `./chat` only — not part of the package
|
|
25
|
+
* surface (`./index` does not re-export these), so components stay the one
|
|
26
|
+
* place styling is decided. */
|
|
27
|
+
export const MONO = `font-family: ${MONO_FONT_STACK};`;
|
|
21
28
|
|
|
22
29
|
/** The single font size for every email element (HTML). Plain text is monospace
|
|
23
30
|
* so it carries no size — this keeps one visual size across both surfaces. */
|
|
24
|
-
const FONT_SIZE = "13px";
|
|
31
|
+
export const FONT_SIZE = "13px";
|
|
25
32
|
|
|
26
33
|
// =============================================================================
|
|
27
34
|
// Core types
|
|
@@ -235,8 +242,9 @@ function asciiCtaBox(label: string): string {
|
|
|
235
242
|
}
|
|
236
243
|
|
|
237
244
|
/** The bordered `>> LABEL <<` button (HTML table + ascii text), with `margin` on
|
|
238
|
-
* the table. `ctaBox` wraps it as a standalone block; a chat unit embeds it
|
|
239
|
-
|
|
245
|
+
* the table. `ctaBox` wraps it as a standalone block; a chat unit embeds it
|
|
246
|
+
* (hence the export — internal to the render layer, not re-exported by `./index`). */
|
|
247
|
+
export function ctaButton(
|
|
240
248
|
opts: CtaBoxOptions,
|
|
241
249
|
margin: string,
|
|
242
250
|
): { html: string; text: string } {
|
|
@@ -271,297 +279,3 @@ export function ctaBox(opts: CtaBoxOptions): Block {
|
|
|
271
279
|
const { html, text } = ctaButton(opts, "0 0 20px 0");
|
|
272
280
|
return { html, text, spacing: "normal" };
|
|
273
281
|
}
|
|
274
|
-
|
|
275
|
-
// =============================================================================
|
|
276
|
-
// Chat bubbles — one dual-output block per role
|
|
277
|
-
// =============================================================================
|
|
278
|
-
|
|
279
|
-
/** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
|
|
280
|
-
function wrapText(text: string, width: number): string[] {
|
|
281
|
-
const lines: string[] = [];
|
|
282
|
-
let cur = "";
|
|
283
|
-
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
284
|
-
let w = word;
|
|
285
|
-
while (w.length > width) {
|
|
286
|
-
if (cur) {
|
|
287
|
-
lines.push(cur);
|
|
288
|
-
cur = "";
|
|
289
|
-
}
|
|
290
|
-
lines.push(w.slice(0, width));
|
|
291
|
-
w = w.slice(width);
|
|
292
|
-
}
|
|
293
|
-
if (!cur) cur = w;
|
|
294
|
-
else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
|
|
295
|
-
else {
|
|
296
|
-
lines.push(cur);
|
|
297
|
-
cur = w;
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
if (cur) lines.push(cur);
|
|
301
|
-
return lines.length ? lines : [""];
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
/** Chat message truncation budget: at most MAX_MESSAGE_LINES lines of MESSAGE_WIDTH chars. */
|
|
305
|
-
const MAX_MESSAGE_LINES = 3;
|
|
306
|
-
const MESSAGE_WIDTH = 36;
|
|
307
|
-
/** Plain-text left gutter (7 cols) reserved for the avatar on both sides, so the
|
|
308
|
-
* user box aligns with the assistant box. */
|
|
309
|
-
const CHAT_INDENT = " ";
|
|
310
|
-
|
|
311
|
-
/** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
|
|
312
|
-
function wrapClamped(text: string, width: number, maxLines: number): string[] {
|
|
313
|
-
const lines = wrapText(text, width);
|
|
314
|
-
if (lines.length <= maxLines) return lines;
|
|
315
|
-
const kept = lines.slice(0, maxLines);
|
|
316
|
-
const last = kept[maxLines - 1];
|
|
317
|
-
kept[maxLines - 1] =
|
|
318
|
-
(last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
|
|
319
|
-
"...";
|
|
320
|
-
return kept;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/**
|
|
324
|
-
* The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
|
|
325
|
-
* MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
|
|
326
|
-
* this, so HTML and plain text truncate at exactly the same point.
|
|
327
|
-
*/
|
|
328
|
-
function clampMessage(text: string): string {
|
|
329
|
-
return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
/** One message in a chat unit. `chatUser`/`chatAssistant` build these; `chatUnit`
|
|
333
|
-
* lays them out together. `from` is the user attribution (sender name). */
|
|
334
|
-
export interface ChatMessage {
|
|
335
|
-
role: "user" | "assistant";
|
|
336
|
-
text: string;
|
|
337
|
-
from?: string;
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
/** A user (right-aligned) chat message with an optional `from` attribution. */
|
|
341
|
-
export function chatUser(text: string, from?: string): ChatMessage {
|
|
342
|
-
return { role: "user", text, from };
|
|
343
|
-
}
|
|
344
|
-
|
|
345
|
-
/** An assistant (left-aligned) chat message. */
|
|
346
|
-
export function chatAssistant(text: string): ChatMessage {
|
|
347
|
-
return { role: "assistant", text };
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/** A CTA button placed inside a chat unit (below a message). */
|
|
351
|
-
export interface ChatCta {
|
|
352
|
-
role: "cta";
|
|
353
|
-
cta: CtaBoxOptions;
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
/** Centered continuation dots inside a chat unit — a "conversation continues"
|
|
357
|
-
* separator placed between a message bubble and a following CTA. */
|
|
358
|
-
export interface ChatDots {
|
|
359
|
-
role: "dots";
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/** An item in a chat unit: a message bubble, a CTA button, or continuation dots. */
|
|
363
|
-
export type ChatItem = ChatMessage | ChatCta | ChatDots;
|
|
364
|
-
|
|
365
|
-
/** A CTA button for a chat unit — pass it to `chatUnit` alongside messages. */
|
|
366
|
-
export function chatCta(cta: CtaBoxOptions): ChatCta {
|
|
367
|
-
return { role: "cta", cta };
|
|
368
|
-
}
|
|
369
|
-
|
|
370
|
-
/** Continuation dots for a chat unit — pass it to `chatUnit` where the
|
|
371
|
-
* conversation should read as continuing (e.g. between the preview and CTA). */
|
|
372
|
-
export function chatDots(): ChatDots {
|
|
373
|
-
return { role: "dots" };
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
/** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
|
|
377
|
-
* outer side. */
|
|
378
|
-
function chatRuleHtml(position: "top" | "bottom"): string {
|
|
379
|
-
const margin = position === "top" ? "12px 0 24px 0" : "24px 0 12px 0";
|
|
380
|
-
return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
|
|
381
|
-
}
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Render one message to its HTML `<table>` and plain-text box lines. Both roles
|
|
385
|
-
* share the 3-column skeleton: a fixed avatar column on each side (the visible
|
|
386
|
-
* avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
|
|
387
|
-
* width, so bubbles stay bounded and aligned), a middle cell that right/left-
|
|
388
|
-
* aligns the bubble, and — for a user `from` — an attribution row below.
|
|
389
|
-
*/
|
|
390
|
-
function renderBubble(
|
|
391
|
-
msg: ChatMessage,
|
|
392
|
-
margin: string,
|
|
393
|
-
): { html: string; text: string[] } {
|
|
394
|
-
const clamped = clampMessage(msg.text);
|
|
395
|
-
const isUser = msg.role === "user";
|
|
396
|
-
|
|
397
|
-
const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
|
|
398
|
-
const bubbleAlign = isUser ? " text-align: right;" : "";
|
|
399
|
-
const cellAlign = isUser ? "right" : "left";
|
|
400
|
-
const csHidden = isUser ? "visibility: hidden; " : "";
|
|
401
|
-
const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
|
|
402
|
-
|
|
403
|
-
const attributionRow =
|
|
404
|
-
isUser && msg.from
|
|
405
|
-
? `
|
|
406
|
-
<tr>
|
|
407
|
-
<td></td>
|
|
408
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(msg.from)}</td>
|
|
409
|
-
<td></td>
|
|
410
|
-
</tr>`
|
|
411
|
-
: "";
|
|
412
|
-
|
|
413
|
-
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
|
|
414
|
-
<tr>
|
|
415
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; ${csHidden}white-space: nowrap;">[c_S]</td>
|
|
416
|
-
<td style="width: 100%; text-align: ${cellAlign};"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
|
|
417
|
-
<tr><td style="border-radius: ${radius}; padding: 10px 14px;${bubbleAlign} ${MONO} font-size: ${FONT_SIZE}; color: #ffffff; background: #666;">${escapeHtml(clamped)}</td></tr>
|
|
418
|
-
</table></td>
|
|
419
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; ${kaomojiHidden}white-space: nowrap;">(•̀_ರ╮)</td>
|
|
420
|
-
</tr>${attributionRow}
|
|
421
|
-
</table>`;
|
|
422
|
-
|
|
423
|
-
const border = "─".repeat(MESSAGE_WIDTH + 2);
|
|
424
|
-
const lines = wrapText(clamped, MESSAGE_WIDTH);
|
|
425
|
-
const body = lines.map(
|
|
426
|
-
(line) =>
|
|
427
|
-
`${CHAT_INDENT}│ ${isUser ? line.padStart(MESSAGE_WIDTH) : line.padEnd(MESSAGE_WIDTH)} │`,
|
|
428
|
-
);
|
|
429
|
-
// Avatar beside the last message line (one row up from the bottom border).
|
|
430
|
-
const last = body.length - 1;
|
|
431
|
-
if (isUser) body[last] += " (•̀_ರ╮)";
|
|
432
|
-
else body[last] = `[c_S] ${body[last].slice(CHAT_INDENT.length)}`;
|
|
433
|
-
|
|
434
|
-
const box = [
|
|
435
|
-
`${CHAT_INDENT}┌${border}┐`,
|
|
436
|
-
...body,
|
|
437
|
-
`${CHAT_INDENT}└${border}┘`,
|
|
438
|
-
];
|
|
439
|
-
if (isUser && msg.from) {
|
|
440
|
-
box.push(msg.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
|
|
441
|
-
}
|
|
442
|
-
return { html, text: box };
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
/** The right-edge column the plain-text CTA/dots align to under a user bubble. */
|
|
446
|
-
const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
|
|
447
|
-
|
|
448
|
-
/** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
|
|
449
|
-
* the same inline-block, so the dots span exactly the button's width). */
|
|
450
|
-
function dotsOverCtaHtml(): string {
|
|
451
|
-
return `<div style="${MONO} font-size: 20px; font-weight: bold; color: #666; text-align: center; margin: 0 0 16px 0;">⋮</div>`;
|
|
452
|
-
}
|
|
453
|
-
|
|
454
|
-
/**
|
|
455
|
-
* A CTA button inside the chat stream — aligned to the side of the message it
|
|
456
|
-
* follows (`right` under a user bubble, `left` otherwise). Hidden avatar mirrors
|
|
457
|
-
* reserve both columns, so it sits in the message channel and never enters the
|
|
458
|
-
* kaomoji column. The button carries no margin; the row's 24px matches the bubbles.
|
|
459
|
-
* When `withDots`, continuation "⋮" render just above the button, centered over it.
|
|
460
|
-
*/
|
|
461
|
-
function renderChatCta(
|
|
462
|
-
cta: CtaBoxOptions,
|
|
463
|
-
align: "left" | "right",
|
|
464
|
-
withDots: boolean,
|
|
465
|
-
): { html: string; text: string[] } {
|
|
466
|
-
const { html: btnHtml, text: btnText } = ctaButton(cta, "0");
|
|
467
|
-
// Dots + button share one inline-block so the dots center over the button's
|
|
468
|
-
// exact width regardless of label length.
|
|
469
|
-
const stack = `<div style="display: inline-block; text-align: left;">${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
|
|
470
|
-
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
|
|
471
|
-
<tr>
|
|
472
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
|
|
473
|
-
<td style="width: 100%; text-align: ${align};">${stack}</td>
|
|
474
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
|
|
475
|
-
</tr>
|
|
476
|
-
</table>`;
|
|
477
|
-
|
|
478
|
-
// Right-align to the message box's right edge (under a user bubble); else the
|
|
479
|
-
// left avatar gutter.
|
|
480
|
-
const btnLines = btnText
|
|
481
|
-
.split("\n")
|
|
482
|
-
.map((l) =>
|
|
483
|
-
!l
|
|
484
|
-
? l
|
|
485
|
-
: align === "right"
|
|
486
|
-
? l.padStart(CHAT_RIGHT_EDGE)
|
|
487
|
-
: `${CHAT_INDENT}${l}`,
|
|
488
|
-
);
|
|
489
|
-
if (!withDots) return { html, text: btnLines };
|
|
490
|
-
|
|
491
|
-
// Center "⋮" over the ascii box (its first line spans the full box width).
|
|
492
|
-
const boxWidth = btnText.split("\n")[0].length;
|
|
493
|
-
const dotsCol =
|
|
494
|
-
align === "right"
|
|
495
|
-
? CHAT_RIGHT_EDGE - Math.floor(boxWidth / 2)
|
|
496
|
-
: CHAT_INDENT.length + Math.ceil(boxWidth / 2);
|
|
497
|
-
return { html, text: ["⋮".padStart(dotsCol), "", ...btnLines] };
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
/**
|
|
501
|
-
* Standalone continuation dots — centered in the message channel. Used only when
|
|
502
|
-
* `chatDots()` is NOT immediately followed by a CTA (the common case folds the
|
|
503
|
-
* dots into the CTA via `renderChatCta`, centered over the box).
|
|
504
|
-
*/
|
|
505
|
-
function renderChatDots(): { html: string; text: string[] } {
|
|
506
|
-
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
|
|
507
|
-
<tr>
|
|
508
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
|
|
509
|
-
<td style="width: 100%; text-align: center; ${MONO} font-size: 20px; font-weight: bold; color: #666;">⋮</td>
|
|
510
|
-
<td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
|
|
511
|
-
</tr>
|
|
512
|
-
</table>`;
|
|
513
|
-
// Center the "⋮" over the message box (avatar gutter + box width + borders).
|
|
514
|
-
const center = Math.round(CHAT_RIGHT_EDGE / 2);
|
|
515
|
-
return { html, text: ["⋮".padStart(center)] };
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
/**
|
|
519
|
-
* Lay out one or more chat items — message bubbles and/or CTA buttons — as a
|
|
520
|
-
* single block: a rule above and below (the real-chat "unit"), the items
|
|
521
|
-
* between. The plain-text rule spans the widest line; a blank line follows the
|
|
522
|
-
* top rule and the last item hugs the bottom rule. Continuation dots
|
|
523
|
-
* (`chatDots`) sit above the CTA they precede, centered over the box.
|
|
524
|
-
*/
|
|
525
|
-
export function chatUnit(...items: ChatItem[]): Block {
|
|
526
|
-
const parts: { html: string; text: string[] }[] = [];
|
|
527
|
-
items.forEach((item, i) => {
|
|
528
|
-
if (item.role === "dots") {
|
|
529
|
-
// Dots immediately before a CTA render with it (centered over the box);
|
|
530
|
-
// otherwise fall back to channel-centered standalone dots.
|
|
531
|
-
if (items[i + 1]?.role !== "cta") parts.push(renderChatDots());
|
|
532
|
-
return;
|
|
533
|
-
}
|
|
534
|
-
if (item.role === "cta") {
|
|
535
|
-
// Mirror the side of the nearest preceding message (skip any dots between),
|
|
536
|
-
// so the CTA sits under the bubble it belongs to — right under a user.
|
|
537
|
-
let j = i - 1;
|
|
538
|
-
while (j >= 0 && items[j].role === "dots") j--;
|
|
539
|
-
const align = items[j]?.role === "user" ? "right" : "left";
|
|
540
|
-
parts.push(renderChatCta(item.cta, align, items[i - 1]?.role === "dots"));
|
|
541
|
-
return;
|
|
542
|
-
}
|
|
543
|
-
// A bubble directly above a CTA or continuation dots gets a tighter 16px
|
|
544
|
-
// gap; else 24px.
|
|
545
|
-
const next = items[i + 1]?.role;
|
|
546
|
-
const margin =
|
|
547
|
-
next === "cta" || next === "dots" ? "0 0 16px 0" : "0 0 24px 0";
|
|
548
|
-
parts.push(renderBubble(item, margin));
|
|
549
|
-
});
|
|
550
|
-
const width = parts
|
|
551
|
-
.flatMap((b) => b.text)
|
|
552
|
-
.reduce((w, l) => Math.max(w, l.length), 0);
|
|
553
|
-
const rule = "_".repeat(width);
|
|
554
|
-
|
|
555
|
-
const html = [
|
|
556
|
-
chatRuleHtml("top"),
|
|
557
|
-
...parts.map((b) => b.html),
|
|
558
|
-
chatRuleHtml("bottom"),
|
|
559
|
-
].join("\n");
|
|
560
|
-
|
|
561
|
-
const text =
|
|
562
|
-
`${rule}\n\n` +
|
|
563
|
-
parts.map((b) => b.text.join("\n")).join("\n\n") +
|
|
564
|
-
`\n${rule}`;
|
|
565
|
-
|
|
566
|
-
return { html, text, spacing: "normal" };
|
|
567
|
-
}
|
|
@@ -6,17 +6,13 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
chatAssistant,
|
|
10
|
-
chatCta,
|
|
11
|
-
chatDots,
|
|
12
|
-
chatUnit,
|
|
13
|
-
chatUser,
|
|
14
9
|
footer,
|
|
15
10
|
greeting,
|
|
16
11
|
NOTICE,
|
|
17
12
|
paragraph,
|
|
18
13
|
signature,
|
|
19
14
|
} from "./blocks";
|
|
15
|
+
import { chatAssistant, chatCta, chatDots, chatUnit, chatUser } from "./chat";
|
|
20
16
|
import { COMPANY_NAME } from "./constants";
|
|
21
17
|
|
|
22
18
|
export type ChatSharedPayload = EmailPayloads["chat.shared"];
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat unit — the dual-output chat transcript block.
|
|
3
|
+
*
|
|
4
|
+
* The one component with real layout of its own: message bubbles, an in-stream
|
|
5
|
+
* CTA, and continuation dots, laid out as a bracketed "unit". Same contract as
|
|
6
|
+
* every other component in `blocks.ts` (returns a `Block` = `{ html, text }`, so
|
|
7
|
+
* both surfaces derive from one source); it lives apart because bubble geometry
|
|
8
|
+
* — wrapping, truncation, avatar gutters, alignment — is a self-contained
|
|
9
|
+
* concern that the paragraph/CTA primitives next door do not share.
|
|
10
|
+
*
|
|
11
|
+
* INVARIANTS:
|
|
12
|
+
* - Pure functions, no side effects.
|
|
13
|
+
* - Components escape their own content; templates pass raw text.
|
|
14
|
+
* - HTML and plain text truncate at exactly the same point (`clampMessage` is
|
|
15
|
+
* the single truncation authority).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
type Block,
|
|
20
|
+
type CtaBoxOptions,
|
|
21
|
+
ctaButton,
|
|
22
|
+
FONT_SIZE,
|
|
23
|
+
MONO,
|
|
24
|
+
} from "./blocks";
|
|
25
|
+
import { escapeHtml } from "./escape-html";
|
|
26
|
+
|
|
27
|
+
// =============================================================================
|
|
28
|
+
// Text geometry
|
|
29
|
+
// =============================================================================
|
|
30
|
+
|
|
31
|
+
/** Greedy word-wrap into lines of at most `width` chars (hard-breaks long words). */
|
|
32
|
+
function wrapText(text: string, width: number): string[] {
|
|
33
|
+
const lines: string[] = [];
|
|
34
|
+
let cur = "";
|
|
35
|
+
for (const word of text.split(/\s+/).filter(Boolean)) {
|
|
36
|
+
let w = word;
|
|
37
|
+
while (w.length > width) {
|
|
38
|
+
if (cur) {
|
|
39
|
+
lines.push(cur);
|
|
40
|
+
cur = "";
|
|
41
|
+
}
|
|
42
|
+
lines.push(w.slice(0, width));
|
|
43
|
+
w = w.slice(width);
|
|
44
|
+
}
|
|
45
|
+
if (!cur) cur = w;
|
|
46
|
+
else if (cur.length + 1 + w.length <= width) cur += ` ${w}`;
|
|
47
|
+
else {
|
|
48
|
+
lines.push(cur);
|
|
49
|
+
cur = w;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (cur) lines.push(cur);
|
|
53
|
+
return lines.length ? lines : [""];
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Chat message truncation budget: at most MAX_MESSAGE_LINES lines of MESSAGE_WIDTH chars. */
|
|
57
|
+
const MAX_MESSAGE_LINES = 3;
|
|
58
|
+
const MESSAGE_WIDTH = 36;
|
|
59
|
+
/** Plain-text left gutter (7 cols) reserved for the avatar on both sides, so the
|
|
60
|
+
* user box aligns with the assistant box. */
|
|
61
|
+
const CHAT_INDENT = " ";
|
|
62
|
+
|
|
63
|
+
/** Word-wrap `text`, then clamp to `maxLines`, ellipsizing the last line on overflow. */
|
|
64
|
+
function wrapClamped(text: string, width: number, maxLines: number): string[] {
|
|
65
|
+
const lines = wrapText(text, width);
|
|
66
|
+
if (lines.length <= maxLines) return lines;
|
|
67
|
+
const kept = lines.slice(0, maxLines);
|
|
68
|
+
const last = kept[maxLines - 1];
|
|
69
|
+
kept[maxLines - 1] =
|
|
70
|
+
(last.length > width - 3 ? last.slice(0, width - 3).trimEnd() : last) +
|
|
71
|
+
"...";
|
|
72
|
+
return kept;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The one truncation authority: clamp a raw message to MAX_MESSAGE_LINES ×
|
|
77
|
+
* MESSAGE_WIDTH, ellipsized. Both surfaces of a chat block run content through
|
|
78
|
+
* this, so HTML and plain text truncate at exactly the same point.
|
|
79
|
+
*/
|
|
80
|
+
function clampMessage(text: string): string {
|
|
81
|
+
return wrapClamped(text, MESSAGE_WIDTH, MAX_MESSAGE_LINES).join(" ");
|
|
82
|
+
}
|
|
83
|
+
|
|
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
|
+
/** The `<hr>` bracketing a chat unit — 24px toward the bubbles, 12px on the
|
|
137
|
+
* outer side. */
|
|
138
|
+
function chatRuleHtml(position: "top" | "bottom"): string {
|
|
139
|
+
const margin = position === "top" ? "12px 0 24px 0" : "24px 0 12px 0";
|
|
140
|
+
return `<hr style="border: none; border-top: 1px solid #bbb; margin: ${margin};">`;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Render one message to its HTML `<table>` and plain-text box lines. Both roles
|
|
145
|
+
* share the 3-column skeleton: a fixed avatar column on each side (the visible
|
|
146
|
+
* avatar plus the opposite avatar rendered `visibility: hidden` to reserve its
|
|
147
|
+
* width, so bubbles stay bounded and aligned), a middle cell that right/left-
|
|
148
|
+
* aligns the bubble, and — for a user `from` — an attribution row below.
|
|
149
|
+
*/
|
|
150
|
+
function renderBubble(
|
|
151
|
+
msg: ChatMessage,
|
|
152
|
+
margin: string,
|
|
153
|
+
): { html: string; text: string[] } {
|
|
154
|
+
const clamped = clampMessage(msg.text);
|
|
155
|
+
const isUser = msg.role === "user";
|
|
156
|
+
|
|
157
|
+
const radius = isUser ? "8px 8px 0 8px" : "8px 8px 8px 0";
|
|
158
|
+
const bubbleAlign = isUser ? " text-align: right;" : "";
|
|
159
|
+
const cellAlign = isUser ? "right" : "left";
|
|
160
|
+
const csHidden = isUser ? "visibility: hidden; " : "";
|
|
161
|
+
const kaomojiHidden = isUser ? "" : "visibility: hidden; ";
|
|
162
|
+
|
|
163
|
+
const attributionRow =
|
|
164
|
+
isUser && msg.from
|
|
165
|
+
? `
|
|
166
|
+
<tr>
|
|
167
|
+
<td></td>
|
|
168
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; color: #666; text-align: right; padding-top: 6px; padding-right: 1ch;">${escapeHtml(msg.from)}</td>
|
|
169
|
+
<td></td>
|
|
170
|
+
</tr>`
|
|
171
|
+
: "";
|
|
172
|
+
|
|
173
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: ${margin};">
|
|
174
|
+
<tr>
|
|
175
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; ${csHidden}white-space: nowrap;">[c_S]</td>
|
|
176
|
+
<td style="width: 100%; text-align: ${cellAlign};"><table cellpadding="0" cellspacing="0" border="0" style="display: inline-block; max-width: 100%; vertical-align: bottom;">
|
|
177
|
+
<tr><td style="border-radius: ${radius}; padding: 10px 14px;${bubbleAlign} ${MONO} font-size: ${FONT_SIZE}; color: #ffffff; background: #666;">${escapeHtml(clamped)}</td></tr>
|
|
178
|
+
</table></td>
|
|
179
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; ${kaomojiHidden}white-space: nowrap;">(•̀_ರ╮)</td>
|
|
180
|
+
</tr>${attributionRow}
|
|
181
|
+
</table>`;
|
|
182
|
+
|
|
183
|
+
const border = "─".repeat(MESSAGE_WIDTH + 2);
|
|
184
|
+
const lines = wrapText(clamped, MESSAGE_WIDTH);
|
|
185
|
+
const body = lines.map(
|
|
186
|
+
(line) =>
|
|
187
|
+
`${CHAT_INDENT}│ ${isUser ? line.padStart(MESSAGE_WIDTH) : line.padEnd(MESSAGE_WIDTH)} │`,
|
|
188
|
+
);
|
|
189
|
+
// Avatar beside the last message line (one row up from the bottom border).
|
|
190
|
+
const last = body.length - 1;
|
|
191
|
+
if (isUser) body[last] += " (•̀_ರ╮)";
|
|
192
|
+
else body[last] = `[c_S] ${body[last].slice(CHAT_INDENT.length)}`;
|
|
193
|
+
|
|
194
|
+
const box = [
|
|
195
|
+
`${CHAT_INDENT}┌${border}┐`,
|
|
196
|
+
...body,
|
|
197
|
+
`${CHAT_INDENT}└${border}┘`,
|
|
198
|
+
];
|
|
199
|
+
if (isUser && msg.from) {
|
|
200
|
+
box.push(msg.from.padStart(CHAT_INDENT.length + MESSAGE_WIDTH + 3));
|
|
201
|
+
}
|
|
202
|
+
return { html, text: box };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** The right-edge column the plain-text CTA/dots align to under a user bubble. */
|
|
206
|
+
const CHAT_RIGHT_EDGE = CHAT_INDENT.length + MESSAGE_WIDTH + 4;
|
|
207
|
+
|
|
208
|
+
/** Centered "⋮" HTML, sized to sit above and centered over a CTA box (they share
|
|
209
|
+
* the same inline-block, so the dots span exactly the button's width). */
|
|
210
|
+
function dotsOverCtaHtml(): string {
|
|
211
|
+
return `<div style="${MONO} font-size: 20px; font-weight: bold; color: #666; text-align: center; margin: 0 0 16px 0;">⋮</div>`;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A CTA button inside the chat stream — aligned to the side of the message it
|
|
216
|
+
* follows (`right` under a user bubble, `left` otherwise). Hidden avatar mirrors
|
|
217
|
+
* reserve both columns, so it sits in the message channel and never enters the
|
|
218
|
+
* kaomoji column. The button carries no margin; the row's 24px matches the bubbles.
|
|
219
|
+
* When `withDots`, continuation "⋮" render just above the button, centered over it.
|
|
220
|
+
*/
|
|
221
|
+
function renderChatCta(
|
|
222
|
+
cta: CtaBoxOptions,
|
|
223
|
+
align: "left" | "right",
|
|
224
|
+
withDots: boolean,
|
|
225
|
+
): { html: string; text: string[] } {
|
|
226
|
+
const { html: btnHtml, text: btnText } = ctaButton(cta, "0");
|
|
227
|
+
// Dots + button share one inline-block so the dots center over the button's
|
|
228
|
+
// exact width regardless of label length.
|
|
229
|
+
const stack = `<div style="display: inline-block; text-align: left;">${withDots ? dotsOverCtaHtml() : ""}${btnHtml}</div>`;
|
|
230
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 24px 0;">
|
|
231
|
+
<tr>
|
|
232
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
|
|
233
|
+
<td style="width: 100%; text-align: ${align};">${stack}</td>
|
|
234
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
|
|
235
|
+
</tr>
|
|
236
|
+
</table>`;
|
|
237
|
+
|
|
238
|
+
// Right-align to the message box's right edge (under a user bubble); else the
|
|
239
|
+
// left avatar gutter.
|
|
240
|
+
const btnLines = btnText
|
|
241
|
+
.split("\n")
|
|
242
|
+
.map((l) =>
|
|
243
|
+
!l
|
|
244
|
+
? l
|
|
245
|
+
: align === "right"
|
|
246
|
+
? l.padStart(CHAT_RIGHT_EDGE)
|
|
247
|
+
: `${CHAT_INDENT}${l}`,
|
|
248
|
+
);
|
|
249
|
+
if (!withDots) return { html, text: btnLines };
|
|
250
|
+
|
|
251
|
+
// Center "⋮" over the ascii box (its first line spans the full box width).
|
|
252
|
+
const boxWidth = btnText.split("\n")[0].length;
|
|
253
|
+
const dotsCol =
|
|
254
|
+
align === "right"
|
|
255
|
+
? CHAT_RIGHT_EDGE - Math.floor(boxWidth / 2)
|
|
256
|
+
: CHAT_INDENT.length + Math.ceil(boxWidth / 2);
|
|
257
|
+
return { html, text: ["⋮".padStart(dotsCol), "", ...btnLines] };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Standalone continuation dots — centered in the message channel. Used only when
|
|
262
|
+
* `chatDots()` is NOT immediately followed by a CTA (the common case folds the
|
|
263
|
+
* dots into the CTA via `renderChatCta`, centered over the box).
|
|
264
|
+
*/
|
|
265
|
+
function renderChatDots(): { html: string; text: string[] } {
|
|
266
|
+
const html = `<table cellpadding="0" cellspacing="0" border="0" width="100%" style="margin: 0 0 16px 0;">
|
|
267
|
+
<tr>
|
|
268
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-right: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">[c_S]</td>
|
|
269
|
+
<td style="width: 100%; text-align: center; ${MONO} font-size: 20px; font-weight: bold; color: #666;">⋮</td>
|
|
270
|
+
<td style="${MONO} font-size: ${FONT_SIZE}; padding-left: 8px; vertical-align: bottom; visibility: hidden; white-space: nowrap;">(•̀_ರ╮)</td>
|
|
271
|
+
</tr>
|
|
272
|
+
</table>`;
|
|
273
|
+
// Center the "⋮" over the message box (avatar gutter + box width + borders).
|
|
274
|
+
const center = Math.round(CHAT_RIGHT_EDGE / 2);
|
|
275
|
+
return { html, text: ["⋮".padStart(center)] };
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// =============================================================================
|
|
279
|
+
// Chat unit
|
|
280
|
+
// =============================================================================
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Lay out one or more chat items — message bubbles and/or CTA buttons — as a
|
|
284
|
+
* single block: a rule above and below (the real-chat "unit"), the items
|
|
285
|
+
* 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
|
+
* (`chatDots`) sit above the CTA they precede, centered over the box.
|
|
288
|
+
*/
|
|
289
|
+
export function chatUnit(...items: ChatItem[]): Block {
|
|
290
|
+
const parts: { html: string; text: string[] }[] = [];
|
|
291
|
+
items.forEach((item, i) => {
|
|
292
|
+
if (item.role === "dots") {
|
|
293
|
+
// Dots immediately before a CTA render with it (centered over the box);
|
|
294
|
+
// otherwise fall back to channel-centered standalone dots.
|
|
295
|
+
if (items[i + 1]?.role !== "cta") parts.push(renderChatDots());
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
if (item.role === "cta") {
|
|
299
|
+
// Mirror the side of the nearest preceding message (skip any dots between),
|
|
300
|
+
// so the CTA sits under the bubble it belongs to — right under a user.
|
|
301
|
+
let j = i - 1;
|
|
302
|
+
while (j >= 0 && items[j].role === "dots") j--;
|
|
303
|
+
const align = items[j]?.role === "user" ? "right" : "left";
|
|
304
|
+
parts.push(renderChatCta(item.cta, align, items[i - 1]?.role === "dots"));
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
// A bubble directly above a CTA or continuation dots gets a tighter 16px
|
|
308
|
+
// gap; else 24px.
|
|
309
|
+
const next = items[i + 1]?.role;
|
|
310
|
+
const margin =
|
|
311
|
+
next === "cta" || next === "dots" ? "0 0 16px 0" : "0 0 24px 0";
|
|
312
|
+
parts.push(renderBubble(item, margin));
|
|
313
|
+
});
|
|
314
|
+
const width = parts
|
|
315
|
+
.flatMap((b) => b.text)
|
|
316
|
+
.reduce((w, l) => Math.max(w, l.length), 0);
|
|
317
|
+
const rule = "_".repeat(width);
|
|
318
|
+
|
|
319
|
+
const html = [
|
|
320
|
+
chatRuleHtml("top"),
|
|
321
|
+
...parts.map((b) => b.html),
|
|
322
|
+
chatRuleHtml("bottom"),
|
|
323
|
+
].join("\n");
|
|
324
|
+
|
|
325
|
+
const text =
|
|
326
|
+
`${rule}\n\n` +
|
|
327
|
+
parts.map((b) => b.text.join("\n")).join("\n\n") +
|
|
328
|
+
`\n${rule}`;
|
|
329
|
+
|
|
330
|
+
return { html, text, spacing: "normal" };
|
|
331
|
+
}
|
|
@@ -7,15 +7,13 @@ import type { EmailPayloads } from "../types";
|
|
|
7
7
|
import {
|
|
8
8
|
ACCESS_PHRASE,
|
|
9
9
|
type Block,
|
|
10
|
-
chatAssistant,
|
|
11
|
-
chatCta,
|
|
12
|
-
chatUnit,
|
|
13
10
|
footer,
|
|
14
11
|
greeting,
|
|
15
12
|
keyValue,
|
|
16
13
|
paragraph,
|
|
17
14
|
signature,
|
|
18
15
|
} from "./blocks";
|
|
16
|
+
import { chatAssistant, chatCta, chatUnit } from "./chat";
|
|
19
17
|
import { COMPANY_NAME } from "./constants";
|
|
20
18
|
|
|
21
19
|
export type AccessApprovedPayload =
|
|
@@ -6,14 +6,13 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
chatUnit,
|
|
10
|
-
chatUser,
|
|
11
9
|
footer,
|
|
12
10
|
greeting,
|
|
13
11
|
keyValue,
|
|
14
12
|
paragraph,
|
|
15
13
|
signature,
|
|
16
14
|
} from "./blocks";
|
|
15
|
+
import { chatUnit, chatUser } from "./chat";
|
|
17
16
|
import { COMPANY_NAME } from "./constants";
|
|
18
17
|
|
|
19
18
|
export type AccessDeniedPayload =
|
|
@@ -6,16 +6,13 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
chatAssistant,
|
|
10
|
-
chatCta,
|
|
11
|
-
chatUnit,
|
|
12
|
-
chatUser,
|
|
13
9
|
footer,
|
|
14
10
|
greeting,
|
|
15
11
|
keyValue,
|
|
16
12
|
paragraph,
|
|
17
13
|
signature,
|
|
18
14
|
} from "./blocks";
|
|
15
|
+
import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
|
|
19
16
|
import { COMPANY_NAME } from "./constants";
|
|
20
17
|
|
|
21
18
|
export type AccessRequestedPayload =
|
|
@@ -23,23 +23,27 @@ export {
|
|
|
23
23
|
signature,
|
|
24
24
|
NOTICE,
|
|
25
25
|
ACCESS_PHRASE,
|
|
26
|
-
chatDots,
|
|
27
26
|
ctaBox,
|
|
28
|
-
chatUser,
|
|
29
|
-
chatAssistant,
|
|
30
|
-
chatCta,
|
|
31
|
-
chatUnit,
|
|
32
27
|
bold,
|
|
33
28
|
type Block,
|
|
34
|
-
type ChatMessage,
|
|
35
|
-
type ChatCta,
|
|
36
|
-
type ChatItem,
|
|
37
29
|
type Inline,
|
|
38
30
|
type InlineContent,
|
|
39
31
|
type Spacing,
|
|
40
32
|
type CtaBoxOptions,
|
|
41
33
|
} from "./blocks";
|
|
42
34
|
|
|
35
|
+
// The chat unit — same `Block` contract, its own module (bubble geometry)
|
|
36
|
+
export {
|
|
37
|
+
chatDots,
|
|
38
|
+
chatUser,
|
|
39
|
+
chatAssistant,
|
|
40
|
+
chatCta,
|
|
41
|
+
chatUnit,
|
|
42
|
+
type ChatMessage,
|
|
43
|
+
type ChatCta,
|
|
44
|
+
type ChatItem,
|
|
45
|
+
} from "./chat";
|
|
46
|
+
|
|
43
47
|
// Dispatcher
|
|
44
48
|
export {
|
|
45
49
|
renderEmail,
|
|
@@ -6,9 +6,6 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
chatAssistant,
|
|
10
|
-
chatCta,
|
|
11
|
-
chatUnit,
|
|
12
9
|
footer,
|
|
13
10
|
formatExpiry,
|
|
14
11
|
greeting,
|
|
@@ -18,6 +15,7 @@ import {
|
|
|
18
15
|
signature,
|
|
19
16
|
titleCase,
|
|
20
17
|
} from "./blocks";
|
|
18
|
+
import { chatAssistant, chatCta, chatUnit } from "./chat";
|
|
21
19
|
import { COMPANY_NAME } from "./constants";
|
|
22
20
|
|
|
23
21
|
export type OrgInvitePayload = EmailPayloads["org.invite"];
|
|
@@ -6,14 +6,13 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
chatAssistant,
|
|
10
|
-
chatUnit,
|
|
11
9
|
footer,
|
|
12
10
|
greeting,
|
|
13
11
|
keyValue,
|
|
14
12
|
paragraph,
|
|
15
13
|
signature,
|
|
16
14
|
} from "./blocks";
|
|
15
|
+
import { chatAssistant, chatUnit } from "./chat";
|
|
17
16
|
import { COMPANY_NAME } from "./constants";
|
|
18
17
|
|
|
19
18
|
export type OwnershipTransferCompletedPayload =
|
|
@@ -6,10 +6,6 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
chatAssistant,
|
|
10
|
-
chatCta,
|
|
11
|
-
chatUnit,
|
|
12
|
-
chatUser,
|
|
13
9
|
footer,
|
|
14
10
|
formatExpiry,
|
|
15
11
|
greeting,
|
|
@@ -18,6 +14,7 @@ import {
|
|
|
18
14
|
paragraph,
|
|
19
15
|
signature,
|
|
20
16
|
} from "./blocks";
|
|
17
|
+
import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
|
|
21
18
|
import { COMPANY_NAME } from "./constants";
|
|
22
19
|
|
|
23
20
|
export type OwnershipTransferPayload = EmailPayloads["org.ownership_transfer"];
|
|
@@ -7,10 +7,6 @@ import type { EmailPayloads } from "../types";
|
|
|
7
7
|
import {
|
|
8
8
|
ACCESS_PHRASE,
|
|
9
9
|
type Block,
|
|
10
|
-
chatAssistant,
|
|
11
|
-
chatCta,
|
|
12
|
-
chatUnit,
|
|
13
|
-
chatUser,
|
|
14
10
|
footer,
|
|
15
11
|
greeting,
|
|
16
12
|
keyValue,
|
|
@@ -18,6 +14,7 @@ import {
|
|
|
18
14
|
paragraph,
|
|
19
15
|
signature,
|
|
20
16
|
} from "./blocks";
|
|
17
|
+
import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
|
|
21
18
|
import { COMPANY_NAME } from "./constants";
|
|
22
19
|
|
|
23
20
|
export type ShareGrantedPayload = EmailPayloads["share.granted"];
|
|
@@ -6,10 +6,6 @@ import type { EmailPayloads } from "../types";
|
|
|
6
6
|
|
|
7
7
|
import {
|
|
8
8
|
type Block,
|
|
9
|
-
chatAssistant,
|
|
10
|
-
chatCta,
|
|
11
|
-
chatUnit,
|
|
12
|
-
chatUser,
|
|
13
9
|
footer,
|
|
14
10
|
formatExpiry,
|
|
15
11
|
greeting,
|
|
@@ -19,6 +15,7 @@ import {
|
|
|
19
15
|
signature,
|
|
20
16
|
titleCase,
|
|
21
17
|
} from "./blocks";
|
|
18
|
+
import { chatAssistant, chatCta, chatUnit, chatUser } from "./chat";
|
|
22
19
|
import { COMPANY_NAME } from "./constants";
|
|
23
20
|
|
|
24
21
|
export type UnitOwnerGrantedPayload = EmailPayloads["org.unit_owner_granted"];
|
package/src/org/company-md.ts
CHANGED
|
@@ -221,7 +221,21 @@ export interface CompanyMdDocCore extends CompanyMdNodeIdentity {
|
|
|
221
221
|
}
|
|
222
222
|
|
|
223
223
|
export interface CompanyMdDocCollaborators {
|
|
224
|
-
|
|
224
|
+
/**
|
|
225
|
+
* The doc's EFFECTIVE owners (ADR-CONTRACTS-084) — hand-mirror of
|
|
226
|
+
* `CompanyMdDocResponseSchema.owners`; keep the two in lockstep.
|
|
227
|
+
*
|
|
228
|
+
* A list, not a scalar: ownership is resolved rather than stored (a personal
|
|
229
|
+
* owner, else the owning unit's live authority holders, else the org account
|
|
230
|
+
* owner), and co-ownership is first-class (ADR-CTRL-161) — every entry is
|
|
231
|
+
* owner-equivalent (ADR-BE-189). Name one owner with `owners[0]` and derive
|
|
232
|
+
* the co-owner count from `owners.length`. Empty only when the org has no
|
|
233
|
+
* account owner (runtime-impossible).
|
|
234
|
+
*/
|
|
235
|
+
readonly owners: ReadonlyArray<{
|
|
236
|
+
readonly id: string;
|
|
237
|
+
readonly name: string;
|
|
238
|
+
}>;
|
|
225
239
|
readonly canEdit: boolean;
|
|
226
240
|
readonly members: ReadonlyArray<{
|
|
227
241
|
readonly id: string;
|
|
@@ -13,6 +13,15 @@ import { z } from "zod";
|
|
|
13
13
|
import { AccessLevelSchema } from "./access-levels";
|
|
14
14
|
import { AccessSourceSchema } from "./access-source";
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Contract-level invariant: an id set contains each id at most once. A duplicate
|
|
18
|
+
* is a serializer bug, not a presentation concern, so we fail Zod parse at the
|
|
19
|
+
* API boundary on both server and client (mirrors `uniqueByUserId` on
|
|
20
|
+
* OrgUnitOwnersResponseSchema).
|
|
21
|
+
*/
|
|
22
|
+
const uniqueValues = (arr: ReadonlyArray<string>): boolean =>
|
|
23
|
+
new Set(arr).size === arr.length;
|
|
24
|
+
|
|
16
25
|
export const PrincipalTypeSchema = z.enum(["user", "unit", "org"]);
|
|
17
26
|
export type PrincipalType = z.infer<typeof PrincipalTypeSchema>;
|
|
18
27
|
|
|
@@ -61,7 +70,19 @@ export type AclGrantResponse = z.infer<typeof AclGrantResponseSchema>;
|
|
|
61
70
|
export const AclListResponseSchema = z.object({
|
|
62
71
|
entity_type: z.string(),
|
|
63
72
|
entity_id: z.string().uuid(),
|
|
64
|
-
|
|
73
|
+
/**
|
|
74
|
+
* The entity's EFFECTIVE owners (ADR-CONTRACTS-084). Ownership is resolved,
|
|
75
|
+
* not stored: a personal doc yields its stored owner; a structural company.md
|
|
76
|
+
* doc yields its owning unit's live authority holders; the org account owner
|
|
77
|
+
* is the guaranteed fallback.
|
|
78
|
+
*
|
|
79
|
+
* A set, not a scalar — co-ownership is first-class (ADR-CTRL-161), so a unit
|
|
80
|
+
* with co-owners yields > 1 and EVERY entry is owner-equivalent (ADR-BE-189).
|
|
81
|
+
* Empty only when the org has no account owner (runtime-impossible).
|
|
82
|
+
*/
|
|
83
|
+
owner_user_ids: z
|
|
84
|
+
.array(z.string().uuid())
|
|
85
|
+
.refine(uniqueValues, { message: "duplicate id in owner_user_ids" }),
|
|
65
86
|
/** General-access scope: WHO is eligible (the discoverability axis). */
|
|
66
87
|
visibility: EntityVisibilitySchema,
|
|
67
88
|
/**
|