@devdogsuga/newsletter 0.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/README.md ADDED
@@ -0,0 +1,193 @@
1
+ # @devdogsuga/newsletter
2
+
3
+ The DevDogs Changelog — the weekly newsletter — as one tree of email-safe React
4
+ components with two callers:
5
+
6
+ - **`apps/platform`** renders issues as pages at `/changelog` and
7
+ `/changelog/<version>`, straight from `ChangelogEmail`.
8
+ - **`@devdogsuga/devtools`** exports issues as files — `pnpm devtools
9
+ newsletter` — an `.html` preview and an Outlook-importable `.eml` per issue.
10
+
11
+ It deliberately does not go through `@devdogsuga/email`. That package's
12
+ compiled-slot pipeline exists to prove a template cannot branch on its inputs,
13
+ which is exactly what a newsletter must do: issues differ in how many events
14
+ they list. So issues here are authored data (`src/issues.ts`), rendered at
15
+ author time with `react-dom/server`, and the send is a finished HTML document
16
+ rather than a template plus fill.
17
+
18
+ ## One tree, two targets
19
+
20
+ Every component takes a `RenderContext` — font stacks plus an asset resolver —
21
+ because the two destinations disagree about both:
22
+
23
+ - **Pages** pass `webRenderContext()`: `var(--font-display)` and friends
24
+ (`next/font` renames every family it self-hosts, so a literal `'Alan Sans'`
25
+ would never match on the site) and SVG data-URI image sources.
26
+ - **Exports** pass `emailRenderContext()`: literal font stacks with email-safe
27
+ fallbacks, and `cid:` references to PNG parts embedded in the `.eml` —
28
+ Gmail and both Outlooks strip SVG in any form.
29
+
30
+ Everything else is the same markup, and that markup is written for the worst
31
+ renderer it will meet (classic Outlook's Word engine): tables for layout,
32
+ longhand font properties via the `font()` helper (never the `font:`
33
+ shorthand), `bgColor` attributes under every painted background, no alpha
34
+ anywhere (`mix()` pre-blends), and button geometry on table cells rather than
35
+ anchors. The tests in `src/export/render.test.tsx` hold the line on the
36
+ mechanical parts.
37
+
38
+ ## Dark mode
39
+
40
+ Dark-mode mail clients do not leave an already-dark email alone: the web
41
+ Outlooks (outlook.com, new Outlook, Outlook mobile) run every color through a
42
+ contrast-repair pass. Rendered-DOM forensics (two dumps plus a sixteen-way
43
+ carrier probe, 2026-09-11) showed exactly how far the 2026 transform goes: it
44
+ computes every element's effective background from the full cascade and
45
+ **injects** the repaired value inline with `!important` — which, by the
46
+ cascade, no stylesheet rule can ever outrank — rewrites `bgcolor` attributes
47
+ and border colors, deletes `background-image`, `hsl()`, `var()` and
48
+ `color-mix()` from the embedded stylesheet, and recolors `rgba()` in place.
49
+ Exactly one paint mechanism passed the probe untouched, inline and in the
50
+ stylesheet alike: `box-shadow`. The architecture follows from those findings:
51
+
52
+ - **No background is ever inline.** Every painted element carries a `bc-`
53
+ class naming its hex; `paintCss()` in `src/darkmode.ts` supplies the base
54
+ rule (the color plus `solidBg`'s same-color `linear-gradient` underlay, as
55
+ Gmail armor), and a `bgcolor` attribute covers clients without `<style>`.
56
+ The dot-grid and slant textures ride the same stylesheet as `.bg-dots` /
57
+ `.bg-slants`. The platform's `/changelog` pages embed `paintCss()` too.
58
+ - **Pins re-assert every color** (`darkModeCss()`): each value repeated with
59
+ `!important` under `@media (prefers-color-scheme: dark)` for clients that
60
+ honor it, and under the `[data-ogsc]`/`[data-ogsb]` attributes the web
61
+ Outlooks stamp on elements they recolor — rules in that scope escape their
62
+ color conversion (ancestor-scoped single-selector rules only; that is the
63
+ one shape Outlook.com's CSS support keeps).
64
+ - **The scoped pins carry the real weapon:** a same-color
65
+ `box-shadow: inset 0 0 0 3000px` on every `bc-` pin, painting the authored
66
+ color over the gray Outlook injected. It lives only under the `data-og*`
67
+ scopes because an inset shadow paints over `background-image` — in any
68
+ other client it would erase the bar textures, but those scopes apply
69
+ exactly when Outlook has already stripped the textures anyway.
70
+ - **One deliberate sacrifice:** `<body>` keeps its inline background so the
71
+ web Outlooks repaint _it_, stamp it with `data-ogsb`, and thereby switch on
72
+ every scoped pin below. The repainted body is fully covered by the pinned
73
+ full-width table, so the sacrifice never shows.
74
+
75
+ Text colors stay inline (with `tc-` classes alongside); Outlook's text
76
+ repairs — lightening the dim grays — read fine on the armored surfaces.
77
+ Borders split by role: Outlook repaints border colors inline with
78
+ `!important` too, so every structural divider is a painted 1px cell
79
+ (`DividerCell`, full background armor) and only the card outlines remain
80
+ true borders, where the repaint reads as an intentional outline. In its dark
81
+ mode the design trades its textures and the featured card's block shadow for
82
+ flat, correct surfaces. A render test walks every issue and fails on any
83
+ inline background outside `<body>`, any `bgcolor` without its class, and any
84
+ class without its paint rule and pin.
85
+
86
+ None of this survives being sent from an Outlook composer — see
87
+ [Sending](#sending). The defenses assume the authored document reaches
88
+ recipients intact, which only `--send` guarantees.
89
+
90
+ The one holdout is classic Outlook for Windows in dark mode: the Word engine
91
+ does a full color invert with no override hook. The result stays readable
92
+ (its whole purpose is contrast), just re-themed, and readers get a
93
+ per-message toggle back to the sent colors.
94
+
95
+ ## Issues
96
+
97
+ `src/issues.ts` is the content: one `ChangelogIssue` per send, versioned with
98
+ semver because a send is a release. The version doubles as the archive URL
99
+ segment and the export filename. A copy pass before a send touches that one
100
+ file; the platform archive and the exported email can never disagree.
101
+
102
+ ## Exporting
103
+
104
+ ```bash
105
+ pnpm --filter @devdogsuga/newsletter build # the CLI consumes dist/
106
+ pnpm devtools newsletter # pick issues interactively
107
+ pnpm devtools newsletter '*' --out ~/changelog
108
+ ```
109
+
110
+ The `.eml` carries `X-Unsent: 1` and no `Message-ID`, so **classic Outlook
111
+ for Windows** opens it as an editable compose draft. New Outlook and Outlook
112
+ on the web do not honor `X-Unsent` and open `.eml` files read-only; for those
113
+ clients the draft has to already be in the mailbox — which is what `--push`
114
+ does. Either way the draft is a review copy: the send happens with `--send`
115
+ (below), because every Outlook composer rewrites what it sends.
116
+
117
+ ## Pushing a draft
118
+
119
+ ```bash
120
+ pnpm devtools newsletter 3.0.1 --push
121
+ ```
122
+
123
+ appends the issue — same MIME as the `.eml`, minus `X-Unsent` — straight into
124
+ the Drafts folder of `devdogs@uga.edu` over IMAP, where an officer can review
125
+ it in any Outlook signed into the club account.
126
+
127
+ Review is all a draft is for. **Never send the newsletter from Outlook
128
+ itself.** Its composers are rich-text editors with a style whitelist, and
129
+ sending re-serializes whatever the editor kept: forensics on a received copy
130
+ showed the head `<style>` replaced with Outlook's own, every
131
+ `background-image` and `bgcolor` stripped, and most class attributes dropped —
132
+ the entire dark-mode defense, gone in transit, leaving recipients' dark-mode
133
+ Outlooks free to repaint every background. (Classic Outlook mangles
134
+ differently, into Word HTML, with the same result.)
135
+
136
+ ## Sending
137
+
138
+ ```bash
139
+ pnpm devtools newsletter 3.0.1 --send --to listserv@listserv.uga.edu
140
+ ```
141
+
142
+ submits the issue over SMTP as the club mailbox, byte-for-byte as authored —
143
+ no composer touches it, so recipients get the document with its stylesheet,
144
+ pins and gradient underlays intact. `--to` is required and comma-separated;
145
+ there is deliberately no interactive path to a send.
146
+
147
+ The first `--push` or `--send` opens a browser; sign in as the mailbox and
148
+ the CLI catches the redirect itself on a loopback port. (If no local port
149
+ would bind, it falls back to printing the URL and asking for the
150
+ `https://localhost` address the browser lands on.) The refresh token is
151
+ stored at `~/.config/devdogsuga/newsletter-mailbox.json` (mode 600 — it opens
152
+ the club mailbox, treat it like a password) and every later run is silent.
153
+ `--mailbox` overrides the account.
154
+
155
+ Why IMAP/SMTP and not Microsoft Graph: UGA's tenant blocks user consent for
156
+ every Graph mail scope, but Microsoft's default consent policy allowlists a
157
+ handful of mail clients by application ID for the legacy IMAP and SMTP
158
+ scopes. The sign-in therefore presents Thunderbird's public client ID — the
159
+ same grant Thunderbird itself would hold, and a documented convention in
160
+ open-source mail tooling (mbsync, OfflineIMAP, DavMail) — but it is
161
+ Microsoft's allowlist, and this stops working the day they prune it. The
162
+ durable fix is an EITS-approved app registration with delegated
163
+ `Mail.ReadWrite` and `Mail.Send`; if that ever lands, swap the IMAP APPEND
164
+ and SMTP submission for their Graph calls and delete the borrowed ID.
165
+
166
+ ## Regenerating the lockup
167
+
168
+ `src/generated/lockup.ts` embeds `apps/platform/public/brand/devdogs-logo-dark.svg`
169
+ (the composed mascot + wordmark, which `@devdogsuga/og` does not export). If
170
+ that artwork changes, re-run from the repo root:
171
+
172
+ ```bash
173
+ node --input-type=module -e '
174
+ import { readFileSync, writeFileSync } from "node:fs";
175
+ const svg = readFileSync("apps/platform/public/brand/devdogs-logo-dark.svg", "utf8").trim();
176
+ const [, w, h] = svg.match(/viewBox="0 0 ([\d.]+) ([\d.]+)"/);
177
+ writeFileSync("packages/newsletter/src/generated/lockup.ts", [
178
+ "// @generated from apps/platform/public/brand/devdogs-logo-dark.svg — the",
179
+ "// mascot + wordmark lockup drawn for dark grounds. Regenerate by re-running",
180
+ "// the base64 line in packages/newsletter/README.md if the artwork changes.",
181
+ "",
182
+ "/** The artwork's own viewBox units, carried so display sizes derive an aspect ratio instead of hard-coding one. */",
183
+ "export const DEVDOGS_LOCKUP_ON_DARK = {",
184
+ ` src: "data:image/svg+xml;base64,${Buffer.from(svg).toString("base64")}",`,
185
+ ` width: ${w},`,
186
+ ` height: ${h},`,
187
+ "} as const;",
188
+ "",
189
+ ].join("\n"));
190
+ '
191
+ ```
192
+
193
+ [API reference](https://devdogsuga.org/docs/toolkit/reference/api/newsletter)
@@ -0,0 +1,54 @@
1
+ /**
2
+ * The two brand marks the newsletter draws: the DevDogs mascot+wordmark
3
+ * lockup (embedded here — brand carries the wordmark and mascot separately,
4
+ * and the masthead wants the composed artwork) and the GDG-on-Campus · UGA
5
+ * cobrand, taken from brand so the two stay one file.
6
+ *
7
+ * Display sizes are derived from each artwork's own units against a shared
8
+ * 33px masthead height, so a re-exported SVG with a new viewBox cannot
9
+ * silently stretch.
10
+ */
11
+ import { GDGC_UGA, type Asset } from "@devdogsuga/brand";
12
+ import { type SocialIconName } from "./icons.js";
13
+ import type { FontStacks } from "./theme.js";
14
+ export { GDGC_UGA };
15
+ export declare const DEVDOGS_LOCKUP: Asset;
16
+ /** On-screen `<img>` dimensions, shared by the page, the email and the PNG rasteriser (which renders at 2x these). */
17
+ export declare const MARK_SIZES: {
18
+ readonly devdogsLockup: {
19
+ readonly width: number;
20
+ readonly height: 33;
21
+ };
22
+ readonly gdgcLockup: {
23
+ readonly width: number;
24
+ readonly height: 33;
25
+ };
26
+ readonly socialIcon: {
27
+ readonly width: 17;
28
+ readonly height: 17;
29
+ };
30
+ };
31
+ /** Where the components find their three `<img>` sources. */
32
+ export interface NewsletterAssets {
33
+ /** `<img>` src for the DevDogs mascot+wordmark lockup (dark ground). */
34
+ devdogsLockup: string;
35
+ /** `<img>` src for the GDG On Campus · UGA cobrand lockup (dark ground). */
36
+ gdgcLockup: string;
37
+ /** `<img>` src for a footer social icon. */
38
+ socialIcon: (name: SocialIconName) => string;
39
+ }
40
+ /**
41
+ * Everything a render target decides: the page passes `next/font` CSS
42
+ * variables and SVG sources, the exporter passes literal font stacks and
43
+ * `cid:` references to embedded PNGs.
44
+ */
45
+ export interface RenderContext {
46
+ fonts: FontStacks;
47
+ assets: NewsletterAssets;
48
+ }
49
+ /**
50
+ * The marks as self-contained data URIs — right for anything with a real
51
+ * browser behind it (the platform pages, an .html preview on disk), and
52
+ * exactly wrong for a send: Gmail and Outlook strip SVG in any form.
53
+ */
54
+ export declare const DATA_URI_ASSETS: NewsletterAssets;
package/dist/assets.js ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * The two brand marks the newsletter draws: the DevDogs mascot+wordmark
3
+ * lockup (embedded here — brand carries the wordmark and mascot separately,
4
+ * and the masthead wants the composed artwork) and the GDG-on-Campus · UGA
5
+ * cobrand, taken from brand so the two stay one file.
6
+ *
7
+ * Display sizes are derived from each artwork's own units against a shared
8
+ * 33px masthead height, so a re-exported SVG with a new viewBox cannot
9
+ * silently stretch.
10
+ */
11
+ import { GDGC_UGA } from "@devdogsuga/brand";
12
+ import { DEVDOGS_LOCKUP_ON_DARK } from "./generated/lockup.js";
13
+ import { socialIconDataUri } from "./icons.js";
14
+ export { GDGC_UGA };
15
+ export const DEVDOGS_LOCKUP = DEVDOGS_LOCKUP_ON_DARK;
16
+ /** The masthead/footer line height every mark is scaled to. */
17
+ const MARK_HEIGHT = 33;
18
+ function displayWidth(asset) {
19
+ return Math.round((MARK_HEIGHT * asset.width) / asset.height);
20
+ }
21
+ /** On-screen `<img>` dimensions, shared by the page, the email and the PNG rasteriser (which renders at 2x these). */
22
+ export const MARK_SIZES = {
23
+ devdogsLockup: {
24
+ width: displayWidth(DEVDOGS_LOCKUP),
25
+ height: MARK_HEIGHT,
26
+ },
27
+ gdgcLockup: {
28
+ width: displayWidth(GDGC_UGA),
29
+ height: MARK_HEIGHT,
30
+ },
31
+ socialIcon: { width: 17, height: 17 },
32
+ };
33
+ /**
34
+ * The marks as self-contained data URIs — right for anything with a real
35
+ * browser behind it (the platform pages, an .html preview on disk), and
36
+ * exactly wrong for a send: Gmail and Outlook strip SVG in any form.
37
+ */
38
+ export const DATA_URI_ASSETS = {
39
+ devdogsLockup: DEVDOGS_LOCKUP.src,
40
+ gdgcLockup: GDGC_UGA.src,
41
+ socialIcon: socialIconDataUri,
42
+ };
@@ -0,0 +1,21 @@
1
+ import { type RenderContext } from "./assets.js";
2
+ import type { ChangelogIssue } from "./issues.js";
3
+ /**
4
+ * The complete issue, from the terminal title bar to the footer lockups —
5
+ * everything inside (and including) the 600px shell.
6
+ */
7
+ export declare function ChangelogEmail({ issue, ctx, }: {
8
+ issue: ChangelogIssue;
9
+ ctx: RenderContext;
10
+ }): import("react").JSX.Element;
11
+ /**
12
+ * The full mail document around `ChangelogEmail`: head metas, the Google Fonts
13
+ * link (honored by Apple/iOS Mail, ignored by Gmail and Outlook, which fall
14
+ * down the literal stacks), the hidden inbox-preview line, and the centered
15
+ * canvas. Only the exporter renders this; the platform pages already have a
16
+ * document of their own.
17
+ */
18
+ export declare function ChangelogDocument({ issue, ctx, }: {
19
+ issue: ChangelogIssue;
20
+ ctx: RenderContext;
21
+ }): import("react").JSX.Element;
@@ -0,0 +1,269 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { MARK_SIZES } from "./assets.js";
3
+ import { bc, brc, darkModeCss, DOT_GRID_CLASS, paintCss, SLANTS_CLASS, tc, } from "./darkmode.js";
4
+ import { blockShadow, chipColors, font, HEADING_TINTS, KIND, PALETTE, SITE, SOCIAL_LINKS, solidBg, FONTS_HREF, UGA, } from "./theme.js";
5
+ const WIDTH = 600;
6
+ /** Every layout table shares these; email clients honor the attributes where they ignore the CSS. */
7
+ const TABLE_RESET = {
8
+ role: "presentation",
9
+ cellPadding: 0,
10
+ cellSpacing: 0,
11
+ border: 0,
12
+ };
13
+ /**
14
+ * React 19's types dropped the legacy `bgcolor` from `<td>` (it survives on
15
+ * `<table>`). Classic Outlook still reads the attribute where it ignores the
16
+ * CSS, so it arrives through a spread the checker cannot veto; React renders
17
+ * lowercase unknown attributes verbatim.
18
+ */
19
+ function bgAttr(color) {
20
+ return { bgcolor: color };
21
+ }
22
+ /**
23
+ * A 1px divider as a painted cell, never a border: the web Outlooks' dark
24
+ * mode repaints border colors inline with `!important` (turning dividers
25
+ * near-white on the pinned dark surfaces), while a painted cell gets the
26
+ * full background armor. Card outlines stay true borders — their repaint
27
+ * reads as an intentional outline.
28
+ */
29
+ function DividerCell({ vertical = false }) {
30
+ const size = vertical ? { width: "1px" } : { height: "1px" };
31
+ return (_jsx("td", { ...bgAttr(PALETTE.border), ...(vertical ? { width: 1 } : { height: 1 }), className: bc(PALETTE.border), style: { ...size, fontSize: "0px", lineHeight: 0 }, children: " " }));
32
+ }
33
+ /** Three title-bar dots in event-type colors, echoing terminal window controls. */
34
+ function KindDots() {
35
+ return (_jsx("span", { style: { fontSize: "0px", lineHeight: 0 }, children: [KIND.interest, KIND.build, KIND.study].map((color) => (_jsx("span", { className: bc(color), style: {
36
+ display: "inline-block",
37
+ width: "11px",
38
+ height: "11px",
39
+ borderRadius: "50%",
40
+ marginLeft: "6px",
41
+ } }, color))) }));
42
+ }
43
+ /** The site's event chip: a tinted pill with a faint accent border and lightened accent text, flattened to hex. */
44
+ function Chip({ label, color, ground = PALETTE.card, fonts, }) {
45
+ const colors = chipColors(color, ground);
46
+ return (_jsx("span", { className: `${tc(colors.text)} ${bc(colors.fill)} ${brc(colors.border)}`, style: {
47
+ display: "inline-block",
48
+ border: `1px solid ${colors.border}`,
49
+ color: colors.text,
50
+ ...font(700, 11, 1, fonts.sans),
51
+ letterSpacing: "0.4px",
52
+ textTransform: "uppercase",
53
+ padding: "5px 10px",
54
+ borderRadius: "20px",
55
+ }, children: label }));
56
+ }
57
+ /**
58
+ * Outlook's Word engine does not reliably apply padding, borders, or
59
+ * backgrounds to anchors. All button geometry lives on a table cell; the
60
+ * anchor is responsible only for the label.
61
+ */
62
+ function CtaButton({ label, url, color, ground, fonts, compact = false, }) {
63
+ const fill = ground ?? color;
64
+ return (_jsx("table", { ...TABLE_RESET, children: _jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { align: "center", valign: "middle", ...bgAttr(fill), className: `${bc(fill)} ${brc(color)}`, style: {
65
+ border: `1px solid ${color}`,
66
+ borderRadius: "8px",
67
+ padding: compact ? "9px 13px" : "14px 26px",
68
+ msoPaddingAlt: "0",
69
+ whiteSpace: "nowrap",
70
+ }, children: _jsxs("a", { href: url, className: tc(PALETTE.ink), style: {
71
+ display: "block",
72
+ ...font(compact ? 700 : 800, compact ? 12 : 15, 1, fonts.sans),
73
+ color: PALETTE.ink,
74
+ textDecoration: "none",
75
+ msoLineHeightRule: "exactly",
76
+ }, children: [label, " ", "\u25B8"] }) }) }) }) }));
77
+ }
78
+ /**
79
+ * An upcoming-event row, drawn the way the site's schedule list draws one: a
80
+ * stacked weekday / day-number date column, a chip pill, a display-face
81
+ * title, and the time + room in quiet sans.
82
+ */
83
+ function EventRow({ event, ctx, }) {
84
+ const { fonts } = ctx;
85
+ const [month, day] = event.date.split(" ");
86
+ return (_jsx("table", { ...TABLE_RESET, width: "100%", bgcolor: PALETTE.card, className: `${bc(PALETTE.card)} ${brc(PALETTE.border)}`, style: {
87
+ border: `1px solid ${PALETTE.border}`,
88
+ borderRadius: "10px",
89
+ margin: "0 0 12px",
90
+ }, children: _jsx("tbody", { children: _jsxs("tr", { children: [_jsxs("td", { width: 66, align: "center", valign: "middle", style: {
91
+ padding: "16px 2px 16px 8px",
92
+ }, children: [_jsx("div", { className: tc(PALETTE.mute), style: {
93
+ ...font(800, 10, 1, fonts.display),
94
+ letterSpacing: "2px",
95
+ color: PALETTE.mute,
96
+ textTransform: "uppercase",
97
+ }, children: event.dow }), _jsx("div", { className: tc(PALETTE.ink), style: {
98
+ ...font(800, 28, 1.1, fonts.display),
99
+ color: PALETTE.ink,
100
+ fontVariantNumeric: "tabular-nums",
101
+ padding: "3px 0 2px",
102
+ }, children: day }), _jsx("div", { className: tc(PALETTE.dim), style: {
103
+ ...font(700, 10, 1, fonts.display),
104
+ letterSpacing: "2px",
105
+ color: PALETTE.dim,
106
+ textTransform: "uppercase",
107
+ }, children: month })] }), _jsx(DividerCell, { vertical: true }), _jsx("td", { valign: "middle", style: { padding: "15px 17px" }, children: _jsx("table", { ...TABLE_RESET, width: "100%", children: _jsx("tbody", { children: _jsxs("tr", { children: [_jsxs("td", { valign: "middle", children: [_jsx(Chip, { label: event.chip, color: event.color, fonts: fonts }), _jsx("div", { className: tc(PALETTE.ink), style: {
108
+ ...font(800, 18, 1.25, fonts.display),
109
+ color: PALETTE.ink,
110
+ letterSpacing: "-0.2px",
111
+ padding: "9px 0 5px",
112
+ }, children: event.title }), _jsxs("div", { className: tc(PALETTE.mute), style: {
113
+ ...font(400, 13, 1.5, fonts.sans),
114
+ color: PALETTE.mute,
115
+ }, children: [event.time, " ", _jsx("span", { className: tc(PALETTE.dim), style: { color: PALETTE.dim }, children: "\u00B7" }), " ", event.loc] }), event.blurb ? (_jsx("div", { className: tc(PALETTE.dim), style: {
116
+ ...font(400, 13, 1.55, fonts.sans),
117
+ color: PALETTE.dim,
118
+ paddingTop: "7px",
119
+ }, children: event.blurb })) : null] }), event.rsvp ? (_jsx("td", { valign: "middle", align: "right", width: 74, style: { paddingLeft: "10px" }, children: _jsx(CtaButton, { label: "RSVP", url: event.rsvp, color: event.color, ground: PALETTE.card, fonts: fonts, compact: true }) })) : null] }) }) }) })] }) }) }));
120
+ }
121
+ /** A `## heading` in the terminal voice. */
122
+ function SectionHeading({ children, color, fonts, paddingBottom = 14, }) {
123
+ return (_jsxs("div", { className: tc(color), style: {
124
+ ...font(700, 13, 1, fonts.mono),
125
+ color,
126
+ padding: `0 0 ${paddingBottom}px`,
127
+ }, children: ["## ", children] }));
128
+ }
129
+ /** One quiet bottom card — the shared frame under "new here?" and "sign-off". */
130
+ function QuietCard({ children }) {
131
+ return (_jsx("table", { ...TABLE_RESET, width: "100%", bgcolor: PALETTE.card, className: `${bc(PALETTE.card)} ${brc(PALETTE.border)}`, style: {
132
+ border: `1px solid ${PALETTE.border}`,
133
+ borderRadius: "10px",
134
+ }, children: _jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { style: { padding: "18px 20px" }, children: children }) }) }) }));
135
+ }
136
+ /**
137
+ * The complete issue, from the terminal title bar to the footer lockups —
138
+ * everything inside (and including) the 600px shell.
139
+ */
140
+ export function ChangelogEmail({ issue, ctx, }) {
141
+ const { fonts, assets } = ctx;
142
+ const featured = issue.featured;
143
+ const arrowLinkStyle = {
144
+ color: PALETTE.ink,
145
+ textDecoration: "none",
146
+ };
147
+ return (_jsx("table", { ...TABLE_RESET, width: WIDTH, bgcolor: PALETTE.bg, className: `${bc(PALETTE.bg)} ${brc(PALETTE.border)}`, style: {
148
+ maxWidth: `${WIDTH}px`,
149
+ width: "100%",
150
+ border: `1px solid ${PALETTE.border}`,
151
+ borderRadius: "14px",
152
+ overflow: "hidden",
153
+ }, children: _jsxs("tbody", { children: [_jsx("tr", { children: _jsx("td", { ...bgAttr(PALETTE.bar), className: `${bc(PALETTE.bar)} ${DOT_GRID_CLASS}`, style: {
154
+ padding: "12px 18px",
155
+ }, children: _jsx("table", { ...TABLE_RESET, width: "100%", children: _jsx("tbody", { children: _jsxs("tr", { children: [_jsx("td", { width: 86, children: _jsx(KindDots, {}) }), _jsxs("td", { align: "center", className: tc(PALETTE.dim), style: {
156
+ ...font(600, 12, 1, fonts.mono),
157
+ color: PALETTE.dim,
158
+ }, children: ["devdogs -", " ", _jsx("span", { className: tc(KIND.workshop), style: { color: KIND.workshop }, children: "changelog" }), " ", "- 80\u00D724"] }), _jsx("td", { width: 86, align: "right", className: tc(PALETTE.dim), style: {
159
+ ...font(600, 12, 1, fonts.mono),
160
+ color: PALETTE.dim,
161
+ }, children: issue.sendLabel })] }) }) }) }) }), _jsx("tr", { children: _jsx(DividerCell, {}) }), _jsx("tr", { children: _jsxs("td", { style: { padding: "24px 22px 4px" }, children: [_jsx("table", { ...TABLE_RESET, width: "100%", children: _jsx("tbody", { children: _jsxs("tr", { children: [_jsx("td", { valign: "middle", children: _jsx("table", { ...TABLE_RESET, children: _jsx("tbody", { children: _jsxs("tr", { children: [_jsx("td", { valign: "middle", children: _jsx("img", { src: assets.devdogsLockup, width: MARK_SIZES.devdogsLockup.width, height: MARK_SIZES.devdogsLockup.height, alt: "DevDogs", style: { display: "block" } }) }), _jsx("td", { valign: "bottom", style: { padding: "0 0 5px 8px" }, children: _jsx("span", { className: tc(KIND.workshop), style: {
162
+ ...font(700, 22, 1, fonts.mono),
163
+ letterSpacing: "-0.4px",
164
+ color: KIND.workshop,
165
+ }, children: "Changelog_" }) })] }) }) }) }), _jsxs("td", { valign: "middle", align: "right", children: [_jsxs("div", { className: tc(UGA), style: { ...font(800, 12, 1, fonts.mono), color: UGA }, children: ["v", issue.version] }), _jsx("div", { className: tc(PALETTE.mute), style: {
166
+ ...font(600, 12, 1, fonts.mono),
167
+ color: PALETTE.mute,
168
+ paddingTop: "5px",
169
+ }, children: issue.term })] })] }) }) }), _jsxs("div", { className: tc(PALETTE.mute), style: {
170
+ ...font(400, 13, 1.6, fonts.mono),
171
+ color: PALETTE.mute,
172
+ paddingTop: "16px",
173
+ }, children: [_jsx("span", { className: tc(KIND.workshop), style: { color: KIND.workshop, fontWeight: 700 }, children: "$" }), " ", _jsx("span", { className: tc(KIND.workshop), style: { color: KIND.workshop }, children: "changelog" }), " ", _jsx("span", { className: tc(PALETTE.ink), style: { color: PALETTE.ink }, children: issue.command.replace(/^changelog\s*/, "") })] }), _jsxs("div", { className: tc(PALETTE.dim), style: {
174
+ ...font(400, 13, 1.6, fonts.mono),
175
+ color: PALETTE.dim,
176
+ paddingTop: "3px",
177
+ }, children: ["\u2192 mirror:", " ", _jsxs("a", { href: `${SITE}/changelog/${issue.version}`, className: tc(KIND.build), style: { color: KIND.build, textDecoration: "none" }, children: ["curl devdogsuga.org/changelog/", issue.version] })] })] }) }), _jsx("tr", { children: _jsx("td", { style: { padding: "14px 22px 0" }, children: _jsx("table", { ...TABLE_RESET, width: "100%", children: _jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { ...bgAttr(UGA), className: `${bc(UGA)} ${SLANTS_CLASS}`, height: 9, style: {
178
+ height: "9px",
179
+ borderRadius: "4px",
180
+ fontSize: "0px",
181
+ lineHeight: 0,
182
+ }, children: " " }) }) }) }) }) }), _jsx("tr", { children: _jsxs("td", { style: { padding: "20px 22px 6px" }, children: [_jsx("div", { className: tc(PALETTE.ink), style: {
183
+ ...font(800, 24, 1.25, fonts.display),
184
+ color: PALETTE.ink,
185
+ letterSpacing: "-0.3px",
186
+ }, children: issue.tagline }), _jsx("div", { className: tc(PALETTE.mute), style: {
187
+ ...font(400, 15, 1.6, fonts.sans),
188
+ color: PALETTE.mute,
189
+ paddingTop: "11px",
190
+ }, children: issue.intro })] }) }), _jsx("tr", { children: _jsxs("td", { style: { padding: "20px 22px 6px" }, children: [_jsx(SectionHeading, { color: featured.color, fonts: fonts, children: issue.featuredLabel }), _jsx("table", { ...TABLE_RESET, width: "100%", bgcolor: PALETTE.card2, className: `${bc(PALETTE.card2)} ${brc(featured.color)}`, style: {
191
+ border: `1px solid ${featured.color}`,
192
+ borderRadius: "10px",
193
+ ...blockShadow(featured.color),
194
+ }, children: _jsx("tbody", { children: _jsx("tr", { children: _jsxs("td", { style: { padding: "22px 22px 24px" }, children: [_jsx(Chip, { label: featured.chip, color: featured.color, ground: PALETTE.card2, fonts: fonts }), _jsx("div", { className: tc(PALETTE.ink), style: {
195
+ ...font(700, 30, 1.1, fonts.display),
196
+ color: PALETTE.ink,
197
+ letterSpacing: "-0.4px",
198
+ padding: "14px 0 8px",
199
+ }, children: featured.title }), _jsx("table", { ...TABLE_RESET, style: { margin: "2px 0 4px" }, children: _jsx("tbody", { children: _jsxs("tr", { children: [_jsxs("td", { className: tc(PALETTE.ink), style: {
200
+ ...font(700, 14, 1.5, fonts.sans),
201
+ color: PALETTE.ink,
202
+ paddingRight: "8px",
203
+ }, children: [featured.dow, ", ", featured.date] }), _jsx(DividerCell, { vertical: true }), _jsx("td", { className: tc(PALETTE.mute), style: {
204
+ ...font(400, 14, 1.5, fonts.sans),
205
+ color: PALETTE.mute,
206
+ padding: "0 8px",
207
+ }, children: featured.time }), _jsx(DividerCell, { vertical: true }), _jsx("td", { className: tc(PALETTE.mute), style: {
208
+ ...font(400, 14, 1.5, fonts.sans),
209
+ color: PALETTE.mute,
210
+ padding: "0 8px",
211
+ }, children: featured.loc })] }) }) }), _jsx("div", { className: tc(PALETTE.mute), style: {
212
+ ...font(400, 15, 1.6, fonts.sans),
213
+ color: PALETTE.mute,
214
+ paddingTop: "10px",
215
+ }, children: featured.blurb }), _jsx("div", { style: {
216
+ height: "18px",
217
+ lineHeight: "18px",
218
+ fontSize: "0px",
219
+ }, children: " " }), _jsx(CtaButton, { label: issue.cta, url: featured.rsvp ?? `${SITE}/events`, color: UGA, fonts: fonts })] }) }) }) })] }) }), _jsx("tr", { children: _jsxs("td", { style: { padding: "22px 22px 0" }, children: [_jsx(SectionHeading, { color: PALETTE.ink, fonts: fonts, children: "upcoming" }), issue.upcoming.map((event, index) => (_jsx(EventRow, { event: event, ctx: ctx }, `${event.date}-${index}`))), _jsx("div", { style: { padding: "2px 0 4px", textAlign: "center" }, children: _jsx("a", { href: `${SITE}/events`, className: tc(KIND.build), style: {
220
+ ...font(700, 13, 1, fonts.mono),
221
+ color: KIND.build,
222
+ textDecoration: "none",
223
+ }, children: "\u2192 full calendar & subscribe (.ics)" }) })] }) }), _jsx("tr", { children: _jsx("td", { style: { padding: "18px 22px 0" }, children: _jsxs(QuietCard, { children: [_jsx(SectionHeading, { color: HEADING_TINTS.newHere, fonts: fonts, paddingBottom: 11, children: "new here?" }), _jsx("div", { className: tc(PALETTE.mute), style: {
224
+ ...font(400, 14, 1.6, fonts.sans),
225
+ color: PALETTE.mute,
226
+ paddingBottom: "12px",
227
+ }, children: "No experience required, ever. Bring a laptop if you've got one, curiosity if you don't. Start here:" }), _jsxs("div", { className: tc(PALETTE.ink), style: { ...font(600, 14, 2, fonts.mono), color: PALETTE.ink }, children: [_jsxs("a", { href: "https://discord.gg/devdogs", className: tc(PALETTE.ink), style: arrowLinkStyle, children: [_jsx("span", { className: tc(KIND.build), style: { color: KIND.build }, children: "\u2192" }), " ", "join the Discord"] }), _jsx("br", {}), _jsxs("a", { href: `${SITE}/events`, className: tc(PALETTE.ink), style: arrowLinkStyle, children: [_jsx("span", { className: tc(KIND.workshop), style: { color: KIND.workshop }, children: "\u2192" }), " ", "add our events to your calendar"] }), _jsx("br", {}), _jsxs("a", { href: "https://github.com/devdogsuga", className: tc(PALETTE.ink), style: arrowLinkStyle, children: [_jsx("span", { className: tc(KIND.social), style: { color: KIND.social }, children: "\u2192" }), " ", "browse what members are building"] })] })] }) }) }), _jsx("tr", { children: _jsx("td", { style: { padding: "18px 22px 24px" }, children: _jsxs(QuietCard, { children: [_jsx(SectionHeading, { color: HEADING_TINTS.signoff, fonts: fonts, paddingBottom: 11, children: "sign-off" }), _jsxs("div", { className: tc(PALETTE.mute), style: {
228
+ ...font(400, 14, 1.6, fonts.sans),
229
+ color: PALETTE.mute,
230
+ }, children: [issue.signoff, _jsx("div", { className: tc(PALETTE.dim), style: {
231
+ color: PALETTE.dim,
232
+ fontSize: "13px",
233
+ textAlign: "right",
234
+ paddingTop: "4px",
235
+ }, children: "— the DevDogs officers" })] })] }) }) }), _jsx("tr", { children: _jsx(DividerCell, {}) }), _jsx("tr", { children: _jsxs("td", { align: "center", ...bgAttr(PALETTE.bar), className: `${bc(PALETTE.bar)} ${DOT_GRID_CLASS}`, style: {
236
+ padding: "28px 22px 30px",
237
+ }, children: [_jsx("table", { ...TABLE_RESET, style: { margin: "0 auto" }, children: _jsx("tbody", { children: _jsxs("tr", { children: [_jsx("td", { align: "center", valign: "top", style: { padding: "0 20px" }, children: _jsx("img", { src: assets.devdogsLockup, width: MARK_SIZES.devdogsLockup.width, height: MARK_SIZES.devdogsLockup.height, alt: "DevDogs", style: { display: "block" } }) }), _jsx("td", { align: "center", valign: "top", style: { padding: "7px 20px 0" }, children: _jsx("img", { src: assets.gdgcLockup, width: MARK_SIZES.gdgcLockup.width, height: MARK_SIZES.gdgcLockup.height, alt: "Google Developer Groups On Campus \u00B7 University of Georgia", style: { display: "block" } }) })] }) }) }), _jsx("table", { ...TABLE_RESET, style: { margin: "20px auto 0" }, children: _jsx("tbody", { children: _jsx("tr", { children: SOCIAL_LINKS.map((social) => (_jsx("td", { align: "center", valign: "middle", style: { padding: "0 10px", whiteSpace: "nowrap" }, children: _jsxs("a", { href: social.url, className: tc(PALETTE.ink), style: {
238
+ ...font(600, 13, "17px", fonts.mono),
239
+ color: PALETTE.ink,
240
+ textDecoration: "none",
241
+ whiteSpace: "nowrap",
242
+ }, children: [_jsx("img", { src: assets.socialIcon(social.icon), width: MARK_SIZES.socialIcon.width, height: MARK_SIZES.socialIcon.height, alt: "", style: {
243
+ display: "inline-block",
244
+ verticalAlign: "middle",
245
+ border: 0,
246
+ outline: "none",
247
+ } }), " ", _jsx("span", { className: tc(PALETTE.ink), style: { color: PALETTE.ink }, children: social.label })] }) }, social.label))) }) }) }), _jsxs("div", { className: tc(PALETTE.dim), style: {
248
+ ...font(400, 12, 1.7, fonts.mono),
249
+ color: PALETTE.dim,
250
+ paddingTop: "16px",
251
+ }, children: ["DevDogs at the University of Georgia", _jsx("br", {}), "Google Developer Group on Campus"] })] }) })] }) }));
252
+ }
253
+ /**
254
+ * The full mail document around `ChangelogEmail`: head metas, the Google Fonts
255
+ * link (honored by Apple/iOS Mail, ignored by Gmail and Outlook, which fall
256
+ * down the literal stacks), the hidden inbox-preview line, and the centered
257
+ * canvas. Only the exporter renders this; the platform pages already have a
258
+ * document of their own.
259
+ */
260
+ export function ChangelogDocument({ issue, ctx, }) {
261
+ return (_jsxs("html", { lang: "en", children: [_jsxs("head", { children: [_jsx("meta", { charSet: "utf-8" }), _jsx("meta", { name: "viewport", content: "width=device-width, initial-scale=1" }), _jsx("meta", { name: "x-apple-disable-message-reformatting" }), _jsx("meta", { name: "color-scheme", content: "light dark" }), _jsx("meta", { name: "supported-color-schemes", content: "light dark" }), _jsx("title", { children: issue.title }), _jsx("link", { rel: "preconnect", href: "https://fonts.googleapis.com" }), _jsx("link", { rel: "preconnect", href: "https://fonts.gstatic.com", crossOrigin: "anonymous" }), _jsx("link", { href: FONTS_HREF, rel: "stylesheet" }), _jsx("style", { dangerouslySetInnerHTML: { __html: `@import url('${FONTS_HREF}');` } }), _jsx("style", { dangerouslySetInnerHTML: {
262
+ __html: `${paintCss()}\n${darkModeCss()}`,
263
+ } })] }), _jsxs("body", { className: bc(PALETTE.bg), ...bgAttr(PALETTE.bg), style: { margin: 0, padding: 0, ...solidBg(PALETTE.bg) }, children: [_jsx("div", { style: {
264
+ display: "none",
265
+ maxHeight: 0,
266
+ overflow: "hidden",
267
+ opacity: 0,
268
+ }, children: issue.preview }), _jsx("table", { ...TABLE_RESET, width: "100%", bgcolor: PALETTE.bg, className: bc(PALETTE.bg), children: _jsx("tbody", { children: _jsx("tr", { children: _jsx("td", { align: "center", style: { padding: "24px 12px" }, children: _jsx(ChangelogEmail, { issue: issue, ctx: ctx }) }) }) }) })] })] }));
269
+ }
@@ -0,0 +1,45 @@
1
+ /** Class for an element whose `color` is `color`. */
2
+ export declare function tc(color: string): string;
3
+ /** Class for an element whose background is `color`. */
4
+ export declare function bc(color: string): string;
5
+ /** Class for an element with a `color`-colored border. */
6
+ export declare function brc(color: string): string;
7
+ /** The slant stripe's texture class — always the UGA red. */
8
+ export declare const SLANTS_CLASS = "bg-slants";
9
+ /** The terminal bars' faint dot-grid texture class. */
10
+ export declare const DOT_GRID_CLASS = "bg-dots";
11
+ /**
12
+ * The base paint layer: what actually colors every `bc-` background, since no
13
+ * background is ever inline (see the module comment). Deliberately without
14
+ * `!important` so the pins in `darkModeCss()` outrank it, and with the
15
+ * texture rules last so their `background-image` wins the tie against the
16
+ * flat `bc-` rule on the same element. The platform's /changelog pages embed
17
+ * this too — the classes paint there exactly as they do in an inbox.
18
+ */
19
+ export declare function paintCss(): string;
20
+ /**
21
+ * The pinning stylesheet `ChangelogDocument` embeds. Three layers, weakest
22
+ * first: a `color-scheme` declaration telling well-behaved clients the email
23
+ * handles both schemes itself, the same pins under `prefers-color-scheme` for
24
+ * clients that honor the media query (Apple Mail, Outlook for Mac), and the
25
+ * `data-ogsc`/`data-ogsb` scoped pins for the web Outlooks, which apply their
26
+ * recoloring in the DOM rather than through any media query.
27
+ *
28
+ * The scoped layers are where the real fight happens. The 2026 web Outlooks
29
+ * compute every element's effective background from the full cascade and
30
+ * inject the repaired value inline with `!important` — unbeatable by any
31
+ * stylesheet color — so the scoped pins carry the box-shadow armor (see
32
+ * `rules`) that repaints the authored color on top. The color pins still
33
+ * matter for Outlook mobile's older transform, and the `<body>` donor in
34
+ * `ChangelogDocument` guarantees a stamped ancestor so the scope always
35
+ * matches in dark mode.
36
+ *
37
+ * The scoped rules are deliberately spartan: Outlook.com's CSS support only
38
+ * stretches to an attribute selector on an ancestor (`[data-ogsc] .x`), not
39
+ * compounded onto the element itself (`.x[data-ogsc]`), and a selector its
40
+ * sanitizer rejects can take the whole rule down with it — so every pin is
41
+ * its own single-selector rule, in the one shape the client is documented
42
+ * to keep. Both attributes scope every rule because which one Outlook stamps
43
+ * depends on whether it rewrote a color or a background up the tree.
44
+ */
45
+ export declare function darkModeCss(): string;