@company-semantics/contracts 37.0.0 → 38.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +2 -7
- package/src/notifications/__tests__/__snapshots__/render-snapshot.test.ts.snap +1965 -0
- package/src/notifications/content.ts +4 -5
- package/src/notifications/renderers/email/README.md +81 -52
- package/src/notifications/renderers/email/__tests__/amp.test.ts +225 -0
- package/src/notifications/renderers/email/__tests__/styles.test.ts +5 -5
- package/src/notifications/renderers/email/chat.ts +4 -5
- package/src/notifications/renderers/email/colors.ts +32 -20
- package/src/notifications/renderers/email/cta.ts +8 -6
- package/src/notifications/renderers/email/index.ts +22 -9
- package/src/notifications/renderers/email/render.ts +4 -2
- package/src/notifications/renderers/email/shells.ts +101 -15
- package/src/notifications/renderers/email/styles.ts +52 -10
- package/src/permissions/share-api.ts +40 -1
|
@@ -216,8 +216,10 @@ export function renderElement(
|
|
|
216
216
|
];
|
|
217
217
|
|
|
218
218
|
case "heroImage": {
|
|
219
|
-
// Stated as attributes, not just style: they are the image's intrinsic
|
|
220
|
-
//
|
|
219
|
+
// Stated as attributes, not just style: they are the image's intrinsic
|
|
220
|
+
// size, every client uses them to reserve space before it loads, and
|
|
221
|
+
// `./styles.ts`'s `ampify` reads them to decide whether the AMP surface can
|
|
222
|
+
// draw an `<amp-img>` at all.
|
|
221
223
|
const dimensions =
|
|
222
224
|
element.width !== undefined && element.height !== undefined
|
|
223
225
|
? ` width="${element.width}" height="${element.height}"`
|
|
@@ -6,9 +6,6 @@
|
|
|
6
6
|
* composes ONE list of `EmailLine`s and both surfaces derive from it, so the two
|
|
7
7
|
* can never disagree about what the email says.
|
|
8
8
|
*
|
|
9
|
-
* There was a third shell — `ampShell` — retired in ADR-CONTRACTS-091. See
|
|
10
|
-
* `./index.ts`: the AMP part cost Gmail readers their dark mode.
|
|
11
|
-
*
|
|
12
9
|
* INVARIANTS:
|
|
13
10
|
* - Pure functions of their input lines. No clock, no environment, no I/O.
|
|
14
11
|
* - These strings are the email's actual markup, and
|
|
@@ -16,10 +13,16 @@
|
|
|
16
13
|
* character-for-character.
|
|
17
14
|
*/
|
|
18
15
|
|
|
19
|
-
import { BASE_STYLE, DARK_STYLE } from "./colors";
|
|
16
|
+
import { BASE_STYLE, DARK_STYLE, hoverStyle } from "./colors";
|
|
20
17
|
import type { Spacing } from "./constants";
|
|
21
18
|
import { MSO_CTA_STYLE } from "./cta";
|
|
22
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
AMP_RECIPE_RULES,
|
|
21
|
+
ampify,
|
|
22
|
+
inlineOf,
|
|
23
|
+
inlineStyles,
|
|
24
|
+
styleClass,
|
|
25
|
+
} from "./styles";
|
|
23
26
|
|
|
24
27
|
/**
|
|
25
28
|
* One rendered line — both presentations of one thing the notification says,
|
|
@@ -45,16 +48,18 @@ export interface EmailLine {
|
|
|
45
48
|
* protecting our colours, apply its dark background, and leave the inline LIGHT
|
|
46
49
|
* text colour sitting on top of it. The two ship together or not at all.
|
|
47
50
|
*
|
|
48
|
-
* Only Apple Mail, iOS Mail and Outlook for Mac read any of this.
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
* the body still states an explicit
|
|
52
|
-
*
|
|
53
|
-
*
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
51
|
+
* Only Apple Mail, iOS Mail and Outlook for Mac read any of this. Clients that
|
|
52
|
+
* recolour instead (Outlook.com, Windows Outlook, and — measured —
|
|
53
|
+
* **Gmail iOS**) ignore both metas and rewrite the inline styles post-delivery.
|
|
54
|
+
* Nothing here can stop that, which is why the body still states an explicit
|
|
55
|
+
* background rather than trusting a default.
|
|
56
|
+
*
|
|
57
|
+
* Do not read that as a degradation to fix: Gmail iOS's inversion of THIS part is
|
|
58
|
+
* the only dark mode a Gmail iOS reader gets, and preserving it is why `ampShell`'s
|
|
59
|
+
* output is never sent (ADR-CONTRACTS-092). Gmail web, by contrast, recolours
|
|
60
|
+
* nothing at all — a light email stays light in a dark client, and no surface fixes
|
|
61
|
+
* that. Gmail Android is untested: unknown, not negative.
|
|
62
|
+
* `company-semantics-backend/docs/dark-mode-probe.md`.
|
|
58
63
|
*
|
|
59
64
|
* The `<!--[if mso]>` block is the mirror image: it is read ONLY by the Outlook
|
|
60
65
|
* Word engine, and undoes the one layout that engine cannot render — a linked
|
|
@@ -87,6 +92,87 @@ ${inner}
|
|
|
87
92
|
</html>`;
|
|
88
93
|
}
|
|
89
94
|
|
|
95
|
+
/**
|
|
96
|
+
* Wrap rendered lines in the AMP4EMAIL shell (ADR-CONTRACTS-089, -091, -092).
|
|
97
|
+
*
|
|
98
|
+
* ⚠️ **THIS BODY IS NEVER SENT, AND MUST NOT BE.** It is rendered, previewed in the
|
|
99
|
+
* Ladle gallery, and dropped. Delivering it is a REGRESSION, not a feature — see
|
|
100
|
+
* below. `company-semantics-backend`'s `EmailDelivery.test.ts` fails if it ever
|
|
101
|
+
* reaches the transport, and that test is the invariant; this comment is only its
|
|
102
|
+
* explanation.
|
|
103
|
+
*
|
|
104
|
+
* **Why it is not sent.** Gmail iOS inverts the `text/html` part post-delivery and
|
|
105
|
+
* does **NOT** touch a `text/x-amp-html` part. That inversion is the ONLY dark mode
|
|
106
|
+
* a Gmail iOS reader gets — no Gmail client honours `prefers-color-scheme` on any
|
|
107
|
+
* surface — so shipping an AMP part would switch it off and hand them a white email
|
|
108
|
+
* in a dark client. This surface buys `:hover` in Gmail and would cost dark mode in
|
|
109
|
+
* Gmail. That trade was measured and refused (ADR-CONTRACTS-091), and the measurement
|
|
110
|
+
* is `company-semantics-backend/docs/dark-mode-probe.md`. ADR-CONTRACTS-089 asserted
|
|
111
|
+
* the opposite — "AMP clients force-invert, so they never got our dark colours
|
|
112
|
+
* anyway" — and that is FALSE: Gmail web recolours nothing, Yahoo does not invert,
|
|
113
|
+
* and Gmail iOS exempts AMP.
|
|
114
|
+
*
|
|
115
|
+
* **Why it exists at all, then.** AMP is the only email format with a real animation
|
|
116
|
+
* model — `amp-animation`, `amp-bind`, `amp-position-observer` — and no arbitrary JS
|
|
117
|
+
* needed. Nothing else can make the kaomoji companions blink once, three seconds
|
|
118
|
+
* after open. That is a future worth keeping the surface alive for, and rebuilding it
|
|
119
|
+
* later costs more than carrying it inert now (ADR-CONTRACTS-092). If Google ever
|
|
120
|
+
* allows `prefers-color-scheme` here, or Gmail starts inverting AMP too, the trade
|
|
121
|
+
* flips and this is ready.
|
|
122
|
+
*
|
|
123
|
+
* **Light-only, permanently.** AMP4EMAIL disallows the `prefers-color-scheme` media
|
|
124
|
+
* feature — a hard validator error, checked with and without `data-css-strict` — so
|
|
125
|
+
* `DARK_STYLE` cannot appear here in any form. The `media` ATTRIBUTE on an AMP
|
|
126
|
+
* element takes the same feature and PASSES the validator; Gmail's runtime ignores
|
|
127
|
+
* it. Both doors are closed. That is not a gap to close; it is the format.
|
|
128
|
+
*
|
|
129
|
+
* Everything AMP forbids is absent BY CONSTRUCTION rather than by stripping:
|
|
130
|
+
*
|
|
131
|
+
* - **No inline `style`.** The lines are class-only; only `htmlShell` inlines them.
|
|
132
|
+
* - **No `!important`.** `hoverStyle(false)` — there is no inline style to beat
|
|
133
|
+
* here, so nothing needs to win a specificity fight, and AMP forbids the keyword
|
|
134
|
+
* anyway.
|
|
135
|
+
* - **No `<!--[if mso]>`.** `MSO_CTA_STYLE` lives in `htmlShell`'s `<head>`, never
|
|
136
|
+
* in a line, so nothing has to remove it. Outlook is not an AMP client, and
|
|
137
|
+
* `display: block` needs no correction in the three that are.
|
|
138
|
+
*
|
|
139
|
+
* The boilerplate hides the body until the runtime unhides it — which is why an
|
|
140
|
+
* AMP document that cannot run scripts shows nothing at all, rather than showing
|
|
141
|
+
* an unstyled version of itself.
|
|
142
|
+
*/
|
|
143
|
+
export function ampShell(lines: EmailLine[]): string {
|
|
144
|
+
const inner = ampify(
|
|
145
|
+
lines
|
|
146
|
+
.map((line) => line.html)
|
|
147
|
+
.filter(Boolean)
|
|
148
|
+
.join("\n"),
|
|
149
|
+
);
|
|
150
|
+
// `amp4email` rather than the `⚡4email` the spec also allows: both are valid,
|
|
151
|
+
// and one of them survives every editor, terminal and diff it will ever cross.
|
|
152
|
+
//
|
|
153
|
+
// `data-css-strict` opts into AMP's strict CSS validation. The validator warns
|
|
154
|
+
// when it is absent and says it "may become an error in the future"; our CSS
|
|
155
|
+
// passes with and without it, so taking it now costs nothing and means that
|
|
156
|
+
// future arrives already handled.
|
|
157
|
+
//
|
|
158
|
+
// The shell wears its own recipes as CLASSES, where `htmlShell` inlines them —
|
|
159
|
+
// that asymmetry is the whole surface split, and it means `body` and `frame`
|
|
160
|
+
// need no special case: `AMP_RECIPE_RULES` already states every recipe's rule.
|
|
161
|
+
return `<!DOCTYPE html>
|
|
162
|
+
<html amp4email data-css-strict lang="en">
|
|
163
|
+
<head><meta charset="utf-8">
|
|
164
|
+
<script async src="https://cdn.ampproject.org/v0.js"></script>
|
|
165
|
+
<style amp4email-boilerplate>body{visibility:hidden}</style>
|
|
166
|
+
<style amp-custom>${AMP_RECIPE_RULES}
|
|
167
|
+
${hoverStyle(false)}</style></head>
|
|
168
|
+
<body ${styleClass("body")}>
|
|
169
|
+
<div ${styleClass("frame")}>
|
|
170
|
+
${inner}
|
|
171
|
+
</div>
|
|
172
|
+
</body>
|
|
173
|
+
</html>`;
|
|
174
|
+
}
|
|
175
|
+
|
|
90
176
|
/**
|
|
91
177
|
* Join rendered lines into the plain-text email — separated per each line's
|
|
92
178
|
* spacing, with a trailing newline.
|
|
@@ -250,16 +250,10 @@ export const STYLE_NAMES = Object.keys(RECIPES) as StyleName[];
|
|
|
250
250
|
*
|
|
251
251
|
* This is the whole mechanism. A `cs-` role class is a HOOK: `darkStyle` needs it
|
|
252
252
|
* in the shipped markup, so it survives. A `csr-` recipe class is a BUNDLE OF
|
|
253
|
-
* DECLARATIONS: the html surface spends it and drops it
|
|
254
|
-
* them — for `inlineStyles` below,
|
|
255
|
-
* every SHIPPED class has a dark
|
|
256
|
-
* class in this file.
|
|
257
|
-
*
|
|
258
|
-
* The registry once had a second consumer — the AMP surface kept these classes and
|
|
259
|
-
* stated their rules, and that opposition (html inlines, AMP does not) is why it
|
|
260
|
-
* exists. AMP is gone (ADR-CONTRACTS-091) and the registry stays: it is now the
|
|
261
|
-
* single source for a recipe, spent in one place, and the markup it produces is
|
|
262
|
-
* byte-identical either way.
|
|
253
|
+
* DECLARATIONS: the html surface spends it and drops it, the AMP surface keeps it
|
|
254
|
+
* and states its rule. One prefix test separates them — for `inlineStyles` below,
|
|
255
|
+
* and for `__tests__/colors.test.ts`, which asserts every SHIPPED class has a dark
|
|
256
|
+
* rule and would otherwise trip over every recipe class in this file.
|
|
263
257
|
*/
|
|
264
258
|
const RECIPE_PREFIX = "csr-";
|
|
265
259
|
|
|
@@ -285,6 +279,11 @@ export function inlineOf(name: StyleName): string {
|
|
|
285
279
|
return `${RECIPES[name].join("; ")};`;
|
|
286
280
|
}
|
|
287
281
|
|
|
282
|
+
/** `.csr-bubble-user { border-radius: 8px; … }` — one recipe, as a rule. */
|
|
283
|
+
export function ruleOf(name: StyleName): string {
|
|
284
|
+
return `.${recipeClass(name)} { ${inlineOf(name)} }`;
|
|
285
|
+
}
|
|
286
|
+
|
|
288
287
|
/**
|
|
289
288
|
* Class-only markup → the html surface's markup.
|
|
290
289
|
*
|
|
@@ -317,3 +316,46 @@ export function inlineStyles(html: string): string {
|
|
|
317
316
|
.join(" ");
|
|
318
317
|
});
|
|
319
318
|
}
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Class-only markup → the AMP surface's markup.
|
|
322
|
+
*
|
|
323
|
+
* The mirror of `inlineStyles`, and the reason both exist: that one resolves the
|
|
324
|
+
* surfaces' disagreement about STYLE, this one their disagreement about TAGS. AMP
|
|
325
|
+
* keeps the classes (its stylesheet is what needs them), so all this has to do is
|
|
326
|
+
* the one structural swap the surfaces cannot share.
|
|
327
|
+
*
|
|
328
|
+
* `<img>` → `<amp-img>`, which AMP requires and which needs explicit dimensions to
|
|
329
|
+
* reserve layout before the image loads. `../../content.ts` makes them optional,
|
|
330
|
+
* because a channel that can measure an image should not be told — so when they
|
|
331
|
+
* are absent this surface CANNOT draw the image, and says the `alt` text instead.
|
|
332
|
+
* That is a degradation, not a lie: `alt` is required exactly so a channel with
|
|
333
|
+
* nothing else has something to say, and the html surface still draws the picture.
|
|
334
|
+
*
|
|
335
|
+
* This does not contradict `supports("heroImage") === true` — `../../renderer.ts`
|
|
336
|
+
* puts `supports` on the RENDERER, and AMP is one of the email renderer's surfaces,
|
|
337
|
+
* not a renderer. Email can depict a hero image. One of its three MIME parts is
|
|
338
|
+
* plain text, which cannot, and that has never made the claim false.
|
|
339
|
+
*/
|
|
340
|
+
export function ampify(html: string): string {
|
|
341
|
+
return html.replace(/<img ([^>]*)>/g, (_match, attrs: string) => {
|
|
342
|
+
const sized = /\bwidth="\d+"/.test(attrs) && /\bheight="\d+"/.test(attrs);
|
|
343
|
+
if (sized) return `<amp-img ${attrs} layout="intrinsic"></amp-img>`;
|
|
344
|
+
const alt = /\balt="([^"]*)"/.exec(attrs)?.[1] ?? "";
|
|
345
|
+
// Carry the image's own spacing over to the paragraph replacing it, so the
|
|
346
|
+
// gap below it does not change with the fallback.
|
|
347
|
+
const spacing = /\bcsr-hero-(\w+)\b/.exec(attrs)?.[1] ?? "normal";
|
|
348
|
+
return `<p class="${RECIPE_PREFIX}p-${spacing}">${alt}</p>`;
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Every recipe as a rule, for `<style amp-custom>`.
|
|
354
|
+
*
|
|
355
|
+
* ALL of them, always — not just the ones this email happens to use. Emitting the
|
|
356
|
+
* used subset would make the stylesheet a function of the content, so two emails
|
|
357
|
+
* would carry two different stylesheets and the snapshot would stop being a
|
|
358
|
+
* statement about the channel. ~40 rules is about 4KB against AMP's 75,000-byte
|
|
359
|
+
* budget, which `__tests__/amp.test.ts` holds us to.
|
|
360
|
+
*/
|
|
361
|
+
export const AMP_RECIPE_RULES = STYLE_NAMES.map(ruleOf).join("\n");
|
|
@@ -8,6 +8,17 @@
|
|
|
8
8
|
*
|
|
9
9
|
* Authority: ADR-CTRL-085 (Rights Table), ADR-CTRL-086 (most-permissive
|
|
10
10
|
* aggregation), ADR-BE-181 (AUTH-006 compose model).
|
|
11
|
+
*
|
|
12
|
+
* DECODER RING — the label/identifier map, stated once.
|
|
13
|
+
*
|
|
14
|
+
* UI "General access" = wire `visibility` / EntityVisibility
|
|
15
|
+
* (access scope: WHO may read)
|
|
16
|
+
* UI "Discoverability" = wire `discoverability` / EntityDiscoverability
|
|
17
|
+
* (WHO knows it exists)
|
|
18
|
+
*
|
|
19
|
+
* They are SIBLINGS, not modifiers. A doc can be readable by two named people
|
|
20
|
+
* and still be globally hidden: access changes without discoverability
|
|
21
|
+
* changing (ADR-CONTRACTS-093).
|
|
11
22
|
*/
|
|
12
23
|
import { z } from "zod";
|
|
13
24
|
import { AccessLevelSchema } from "./access-levels";
|
|
@@ -41,6 +52,9 @@ export type GrantableAccessLevel = z.infer<typeof GrantableAccessLevelSchema>;
|
|
|
41
52
|
export const EntityVisibilitySchema = z.enum(["private", "unit", "org"]);
|
|
42
53
|
export type EntityVisibility = z.infer<typeof EntityVisibilitySchema>;
|
|
43
54
|
|
|
55
|
+
export const EntityDiscoverabilitySchema = z.enum(["org", "owners"]);
|
|
56
|
+
export type EntityDiscoverability = z.infer<typeof EntityDiscoverabilitySchema>;
|
|
57
|
+
|
|
44
58
|
export const AclGrantRequestSchema = z.object({
|
|
45
59
|
access_level: GrantableAccessLevelSchema,
|
|
46
60
|
/**
|
|
@@ -83,7 +97,10 @@ export const AclListResponseSchema = z.object({
|
|
|
83
97
|
owner_user_ids: z
|
|
84
98
|
.array(z.string().uuid())
|
|
85
99
|
.refine(uniqueValues, { message: "duplicate id in owner_user_ids" }),
|
|
86
|
-
/**
|
|
100
|
+
/**
|
|
101
|
+
* General-access scope: WHO may read. NOT discoverability — see
|
|
102
|
+
* `discoverability`.
|
|
103
|
+
*/
|
|
87
104
|
visibility: EntityVisibilitySchema,
|
|
88
105
|
/**
|
|
89
106
|
* The ROLE the general-access scope grants its audience: WHAT they may do (the
|
|
@@ -92,6 +109,20 @@ export const AclListResponseSchema = z.object({
|
|
|
92
109
|
* ignored when `visibility` is `private` (no general grant).
|
|
93
110
|
*/
|
|
94
111
|
general_access_role: GrantableAccessLevelSchema.default("viewer"),
|
|
112
|
+
/**
|
|
113
|
+
* Who knows this entity EXISTS — a SIBLING of `visibility`, not a modifier
|
|
114
|
+
* on it (ADR-CONTRACTS-093).
|
|
115
|
+
*
|
|
116
|
+
* ABSENT => this entity type has no discoverability axis (work_item,
|
|
117
|
+
* meeting_recording). Deliberately not defaulted: `org|owners` has no honest
|
|
118
|
+
* cross-entity value, unlike `general_access_role`'s `viewer`.
|
|
119
|
+
*
|
|
120
|
+
* The policy's domain is NON-READERS only. When `visibility` is `org` every
|
|
121
|
+
* member is a reader, so the stored value affects no one — it is not erased,
|
|
122
|
+
* it simply has no subjects (ADR-CTRL-194 invariant 4). A UI therefore
|
|
123
|
+
* disables the control rather than rewriting the value.
|
|
124
|
+
*/
|
|
125
|
+
discoverability: EntityDiscoverabilitySchema.optional(),
|
|
95
126
|
editors_can_share: z.boolean(),
|
|
96
127
|
grants: z.array(AclGrantResponseSchema),
|
|
97
128
|
});
|
|
@@ -106,6 +137,14 @@ export const VisibilityPatchRequestSchema = z.object({
|
|
|
106
137
|
* set); ignored when `tier` is `private`.
|
|
107
138
|
*/
|
|
108
139
|
role: GrantableAccessLevelSchema.optional(),
|
|
140
|
+
/**
|
|
141
|
+
* New discoverability. OMITTED => preserve the stored value.
|
|
142
|
+
*
|
|
143
|
+
* NOTE the asymmetry with the response field: there `undefined` means "not
|
|
144
|
+
* applicable to this entity type"; here it means "leave unchanged"
|
|
145
|
+
* (ADR-CONTRACTS-093).
|
|
146
|
+
*/
|
|
147
|
+
discoverability: EntityDiscoverabilitySchema.optional(),
|
|
109
148
|
});
|
|
110
149
|
export type VisibilityPatchRequest = z.infer<
|
|
111
150
|
typeof VisibilityPatchRequestSchema
|