@atelic-action/ui 0.4.0 → 0.6.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 CHANGED
@@ -71,6 +71,14 @@ import { Footer, SiteHeader, SkipLink, StickyCTABar } from "@atelic-action/ui/ch
71
71
  </div>;
72
72
  ```
73
73
 
74
+ A sent artifact's header names who the document is for as well as who it is
75
+ from: pass `recipient: { src, alt }` on the `brand` (added 0.6.0) and the
76
+ lockup reads "atelic × <their mark>", the sender's wordmark and run first, a
77
+ quiet times sign, then the recipient's mark at wordmark height. Cut the mark
78
+ for the surface the header wears (a light mark for the dark bar) and keep it
79
+ an image; the chrome draws no second wordmark. A marketing site's lockup
80
+ passes no recipient.
81
+
74
82
  The menu is a native `<dialog>` opened with `showModal()`, so Escape, focus containment, and focus return come from the browser.
75
83
 
76
84
  ## The Not Found Page
@@ -149,18 +157,30 @@ const html = renderEmail({
149
157
 
150
158
  Runner emails are mobile first. A layout that squashes on a phone is fixed here, in the components every runner shares, never in a report page. A fixed column table is for numbers alone and never carries a name; names go in a `RecordStack`.
151
159
 
152
- `RecordStack` takes `records`, each a `RecordStackItem` (`title`, `url`, `meta`, `note`), and renders one table row per record: the title on its own line at 15px, bold and wrapping freely, linked when `url` is set; a mono meta line beneath it with the items joined by middle dots (empty items drop); an optional note beneath that; a hairline between records and none after the last. No cell carries a width, so a name of any length wraps at 320 pixels instead of squashing. `recordStackText(records)` is its plain text twin: each title on its own line under a two space indent, the meta and the note wrapped under a four space indent, a blank line between records, and no line past `textWidth` (68 columns, indent included).
160
+ `RecordStack` takes `records`, each a `RecordStackItem` (`title`, `url`, `meta`, `note`, `badge`, `callout`), and renders one table row per record: the title on its own line at 15px, bold and wrapping freely, linked when `url` is set; a mono meta line beneath it with the items joined by middle dots (empty items drop); an optional note beneath that; a hairline between records and none after the last. No cell carries a width, so a name of any length wraps at 320 pixels instead of squashing. An optional `badge` (a stage word such as `"Contacted"`) sits on the title line in a small mono uppercase pill on a `line` hairline, in its own right aligned unbroken cell, so the title keeps wrapping beside it and the badge stays on the title's first line. An optional `callout` (`{ eyebrow, text }`) sets a short task under the meta and the note in the accented `Note` idiom: the eyebrow in the accent, the text at 13px, and a 2px accent rule down its left edge. A record with neither renders exactly as it did before. `recordStackText(records)` is its plain text twin: each title on its own line under a two space indent with any badge after it in square brackets (`Blue Heron Plumbing [Contacted]`), the meta and the note wrapped under a four space indent, any callout as its eyebrow in upper case over its text under that same indent, a blank line between records, and no line past `textWidth` (68 columns, indent included).
161
+
162
+ `StatStrip` lays its stats out as inline block cells with an 88 pixel floor inside one centered cell, so six or seven stats flow onto a second row on a phone rather than shrinking. Each stat takes an optional `delta` (`"+3 (12%)"`), set small under its label and colored by its sign: one that opens on `+` wears the palette's `up`, one that opens on `-` its `down`, and anything else stays muted.
153
163
 
154
- `StatStrip` lays its stats out as inline block cells with an 88 pixel floor inside one centered cell, so six or seven stats flow onto a second row on a phone rather than shrinking. Each stat takes an optional `delta` (`"+3 · +12%"`), set small and muted under its label.
164
+ `Masthead` takes an optional `meta` (`"Week 39 · 09/21 to 09/27"`) set in the eyebrow style at the right end of the row, so the title card no longer carries the week. It is a right aligned, unbroken block floated after the wordmark and title: on the same line while the row has room, and dropped under the title on its own line, still right aligned, when it does not (checked at 320 pixels). `Eyebrow` takes `strong`, which sets the label at 15px and weight 700 in ink rather than faint, with a slightly tighter tracking, so a section such as `MQL · 15` reads as a heading on a phone.
155
165
 
156
166
  ```tsx
157
167
  const records = [
158
- { title: "Pinewood Cabinetry", url: "https://example.test", meta: ["Lead", "Longmont", "fit 14"], note: "Answered the audit inside a day." },
168
+ {
169
+ title: "Pinewood Cabinetry",
170
+ url: "https://example.test",
171
+ meta: ["Lead", "Longmont", "fit 14"],
172
+ note: "Answered the audit inside a day.",
173
+ badge: "Contacted",
174
+ callout: { eyebrow: "Next", text: "Walk the Business Profile findings Thursday." },
175
+ },
159
176
  ];
160
177
 
178
+ <Masthead title="Pipeline" meta="Week 39 · 09/21 to 09/27" />;
179
+ <Eyebrow text="MQL · 15" strong />;
180
+
161
181
  <Card>
162
182
  <Row last={false}>
163
- <StatStrip stats={[{ n: 6, label: "Lead", delta: "+3 · +12%" }, { n: 2, label: "MQL" }]} />
183
+ <StatStrip stats={[{ n: 6, label: "Lead", delta: "+3 (12%)" }, { n: 2, label: "MQL", delta: "-1 (4%)" }]} />
164
184
  </Row>
165
185
  <Row last>
166
186
  <RecordStack records={records} />
@@ -178,7 +198,7 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
178
198
 
179
199
  | jq Function | Component | Props |
180
200
  |---|---|---|
181
- | `eyebrow` | `Eyebrow` | `text` |
201
+ | `eyebrow` | `Eyebrow` | `text`, `strong` (added 0.5.0, no jq counterpart) |
182
202
  | `card` | `Card` | `children` |
183
203
  | `fold` | `Fold` | `summary`, `children` |
184
204
  | `big_fold` | `BigFold` | `summary`, `count` (a `ReactNode`, absent for the jq's `""`), `children` |
@@ -203,10 +223,10 @@ Where the jq takes a pre rendered html string (`$rows`, `$body_html`, `$cells_ht
203
223
  | `sub_eyebrow` | `SubEyebrow` | `text` |
204
224
  | `badge` | `Badge` | `letter` |
205
225
  | `day_strip` | `DayStrip` | `days`, `last` |
206
- | `stat_strip` | `StatStrip` | `stats`, each with an optional `delta` (the component wraps where the jq does not) |
207
- | none | `RecordStack` | `records` (born here on 2026-09-24, with no jq counterpart) |
226
+ | `stat_strip` | `StatStrip` | `stats`, each with an optional `delta` colored by its sign (the component wraps where the jq does not) |
227
+ | none | `RecordStack` | `records`, each with an optional `badge` and `callout` (born here on 2026-09-24, with no jq counterpart) |
208
228
  | `records` | `Records` | `columns`, `rows`; a cell's `html` is a `ReactNode` |
209
- | `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`) |
229
+ | `masthead` | `Masthead` | `title`, `wordmark` (defaults to `atelic`), `meta` (added 0.5.0, no jq counterpart) |
210
230
  | `title_card` | `TitleCard` | `eyebrowText`, `headlineLines`, `lede`, `stats` (the rows under the lede, in place of the jq's `$stats_html`) |
211
231
  | `footer` | `Footer` | `meta` |
212
232
  | `page` | `renderEmail` | `title`, `preheader`, `children`, `palette`, `fonts` |
@@ -220,6 +240,8 @@ The plain text alternative part ports as plain functions with no React anywhere
220
240
 
221
241
  `@atelic-action/ui/tokens` carries the palette and the font stacks with no React import, so a build script or a plain text renderer can read them. `toThemeCSS(palette)` writes the palette out as the site token declarations (`--surface`, `--surface-dark`, `--card`, `--ink`, `--primary`), and `themeTokenMap` exposes which palette key each token takes.
222
242
 
243
+ The palette carries two optional keys beyond the jq's eight: `up` (`#2F7A4B`, a calm green) and `down` (`#B23A2E`, a calm red, kept well apart from the orange accent), the colors a `StatStrip` delta wears by its sign. Both read on the cream card. They are optional so a client palette written before 0.5.0 still compiles, and a palette without them borrows the Atelic values. Neither maps to a site token.
244
+
223
245
  ## Releasing
224
246
 
225
247
  1. Bump `version` in `package.json` and merge it to `main`: patch for a fix, minor for a new component or prop, major for a breaking prop or class rename.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "0.4.0",
3
+ "version": "0.6.0",
4
4
  "description": "Shared UI for the Atelic templates: site chrome, base styles, and the component library they install",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -9,12 +9,17 @@ export interface BrandLockupProps {
9
9
  /**
10
10
  * The header's identity as one link: the mark (a glyph tile or an image),
11
11
  * the wordmark, and the trailing run. The image carries empty alt text
12
- * because the link's own label already names it.
12
+ * because the link's own label already names it. On a sent artifact the
13
+ * recipient's mark follows, after a times sign: "atelic × Wynkoop Athletics",
14
+ * the sender's identity and the business the document is for in one lockup.
15
+ * The anchor's aria-label folds in the recipient's alt too, since it
16
+ * overrides accessible-name computation for every child, image alt included.
13
17
  */
14
18
  export function BrandLockup({ brand, ariaLabel }: BrandLockupProps) {
15
- const { name, href = "/", logo } = brand;
19
+ const { name, href = "/", logo, recipient } = brand;
20
+ const label = ariaLabel ?? (recipient ? `${name} × ${recipient.alt} home` : `${name} home`);
16
21
  return (
17
- <a className="nav-logo" href={href} aria-label={ariaLabel ?? `${name} home`}>
22
+ <a className="nav-logo" href={href} aria-label={label}>
18
23
  {logo?.tile ? (
19
24
  <span className="logo-tile" aria-hidden="true">
20
25
  {logo.tile}
@@ -24,6 +29,14 @@ export function BrandLockup({ brand, ariaLabel }: BrandLockupProps) {
24
29
  )}
25
30
  <span className="wordmark">{logo?.wordmark ?? name}</span>
26
31
  {logo?.run && <span className="wordmark-run" aria-hidden="true" />}
32
+ {recipient && (
33
+ <>
34
+ <span className="nav-x" aria-hidden="true">
35
+ ×
36
+ </span>
37
+ <img className="nav-recipient" src={recipient.src} alt={recipient.alt} />
38
+ </>
39
+ )}
27
40
  </a>
28
41
  );
29
42
  }
@@ -10,16 +10,27 @@ import { eyebrowStyle, tableReset, useEmailTheme } from "./theme";
10
10
  * is a plain string and React does the escaping.
11
11
  */
12
12
 
13
- export type EyebrowProps = { text: string };
13
+ export type EyebrowProps = {
14
+ text: string;
15
+ /** Reads as a heading on a phone: larger, bold, in ink rather than faint. */
16
+ strong?: boolean;
17
+ };
14
18
 
15
19
  /** A section label between cards. */
16
- export function Eyebrow({ text }: EyebrowProps) {
20
+ export function Eyebrow({ text, strong = false }: EyebrowProps) {
17
21
  const { palette, fonts } = useEmailTheme();
22
+ const look = strong
23
+ ? {
24
+ ...eyebrowStyle(fonts),
25
+ fontSize: "15px",
26
+ fontWeight: "700",
27
+ letterSpacing: "0.06em",
28
+ color: palette.ink,
29
+ }
30
+ : { ...eyebrowStyle(fonts), color: palette.faint };
18
31
  return (
19
32
  <tr>
20
- <td style={{ padding: "36px 8px 12px", ...eyebrowStyle(fonts), color: palette.faint }}>
21
- {text}
22
- </td>
33
+ <td style={{ padding: "36px 8px 12px", ...look }}>{text}</td>
23
34
  </tr>
24
35
  );
25
36
  }
@@ -14,62 +14,88 @@ export type MastheadProps = {
14
14
  title: string;
15
15
  /** The wordmark at the top left. */
16
16
  wordmark?: string;
17
+ /**
18
+ * A quiet fact at the right end of the row, such as the week. It sits on
19
+ * the title's line while it fits and drops under it, still right aligned,
20
+ * when the row runs out of room.
21
+ */
22
+ meta?: string;
17
23
  };
18
24
 
19
25
  /**
20
26
  * The wordmark and one orange rule at the top left, the email's title beside
21
- * them: the runner's name, not the week.
27
+ * them: the runner's name, not the week. A `meta` rides at the right end of
28
+ * the row in its own right aligned, unbroken block, floated after the brand so
29
+ * that on a narrow screen it drops under the title instead of squashing it.
22
30
  */
23
- export function Masthead({ title, wordmark = "atelic" }: MastheadProps) {
31
+ export function Masthead({ title, wordmark = "atelic", meta }: MastheadProps) {
24
32
  const { palette, fonts } = useEmailTheme();
33
+ const brand = (
34
+ <table {...tableReset}>
35
+ <tbody>
36
+ <tr>
37
+ <td
38
+ style={{
39
+ fontSize: "20px",
40
+ fontWeight: "600",
41
+ letterSpacing: "-0.045em",
42
+ color: palette.ink,
43
+ paddingRight: "8px",
44
+ lineHeight: "1",
45
+ }}
46
+ >
47
+ {wordmark}
48
+ </td>
49
+ <td width="34" style={{ width: "34px", verticalAlign: "middle", paddingRight: "12px" }}>
50
+ <div
51
+ style={{
52
+ width: "34px",
53
+ height: "2px",
54
+ fontSize: "0",
55
+ lineHeight: "0",
56
+ backgroundColor: palette.accent,
57
+ background: `linear-gradient(90deg,${palette.accent},${fadeStop(palette.accent)})`,
58
+ }}
59
+ >
60
+ {"\u00a0"}
61
+ </div>
62
+ </td>
63
+ <td
64
+ style={{
65
+ ...eyebrowStyle(fonts),
66
+ color: palette.faint,
67
+ verticalAlign: "middle",
68
+ lineHeight: "1",
69
+ }}
70
+ >
71
+ {title}
72
+ </td>
73
+ </tr>
74
+ </tbody>
75
+ </table>
76
+ );
25
77
  return (
26
78
  <tr>
27
79
  <td style={{ padding: "8px 8px 22px", fontFamily: fonts.sans }}>
28
- <table {...tableReset}>
29
- <tbody>
30
- <tr>
31
- <td
32
- style={{
33
- fontSize: "20px",
34
- fontWeight: "600",
35
- letterSpacing: "-0.045em",
36
- color: palette.ink,
37
- paddingRight: "8px",
38
- lineHeight: "1",
39
- }}
40
- >
41
- {wordmark}
42
- </td>
43
- <td
44
- width="34"
45
- style={{ width: "34px", verticalAlign: "middle", paddingRight: "12px" }}
46
- >
47
- <div
48
- style={{
49
- width: "34px",
50
- height: "2px",
51
- fontSize: "0",
52
- lineHeight: "0",
53
- backgroundColor: palette.accent,
54
- background: `linear-gradient(90deg,${palette.accent},${fadeStop(palette.accent)})`,
55
- }}
56
- >
57
- {"\u00a0"}
58
- </div>
59
- </td>
60
- <td
61
- style={{
62
- ...eyebrowStyle(fonts),
63
- color: palette.faint,
64
- verticalAlign: "middle",
65
- lineHeight: "1",
66
- }}
67
- >
68
- {title}
69
- </td>
70
- </tr>
71
- </tbody>
72
- </table>
80
+ {meta ? (
81
+ <>
82
+ <div style={{ display: "inline-block", verticalAlign: "middle" }}>{brand}</div>
83
+ <div
84
+ style={{
85
+ float: "right",
86
+ textAlign: "right",
87
+ whiteSpace: "nowrap",
88
+ ...eyebrowStyle(fonts),
89
+ lineHeight: "20px",
90
+ color: palette.faint,
91
+ }}
92
+ >
93
+ {meta}
94
+ </div>
95
+ </>
96
+ ) : (
97
+ brand
98
+ )}
73
99
  </td>
74
100
  </tr>
75
101
  );
@@ -1,4 +1,5 @@
1
- import { Fragment, type ReactNode } from "react";
1
+ import { type CSSProperties, Fragment, type ReactNode } from "react";
2
+ import { atelicPalette, type Palette } from "../tokens";
2
3
  import type { RecordStackItem } from "./text";
3
4
  import { eyebrowStyle, tableReset, useEmailTheme } from "./theme";
4
5
 
@@ -327,9 +328,22 @@ export function DayStrip({ days, last }: DayStripProps) {
327
328
  export type StatStripEntry = {
328
329
  n: string | number;
329
330
  label: string;
330
- /** A change beside the number, small and muted under the label: "+3 · +12%". */
331
+ /**
332
+ * A change beside the number, small under the label: "+3 (12%)". One that
333
+ * opens on "+" wears the palette's `up`, one that opens on "-" its `down`,
334
+ * anything else stays muted.
335
+ */
331
336
  delta?: string;
332
337
  };
338
+
339
+ /** The color a delta wears, read off its sign. */
340
+ export function deltaColor(delta: string, palette: Palette): string {
341
+ if (delta.startsWith("+")) return palette.up ?? atelicPalette.up ?? palette.faint;
342
+ if (delta.startsWith("-") || delta.startsWith("\u2212")) {
343
+ return palette.down ?? atelicPalette.down ?? palette.faint;
344
+ }
345
+ return palette.faint;
346
+ }
333
347
  export type StatStripProps = { stats: StatStripEntry[] };
334
348
 
335
349
  /**
@@ -377,7 +391,11 @@ export function StatStrip({ stats }: StatStripProps) {
377
391
  <>
378
392
  <br />
379
393
  <span
380
- style={{ fontFamily: fonts.mono, fontSize: "11px", color: palette.faint }}
394
+ style={{
395
+ fontFamily: fonts.mono,
396
+ fontSize: "11px",
397
+ color: deltaColor(entry.delta, palette),
398
+ }}
381
399
  >
382
400
  {entry.delta}
383
401
  </span>
@@ -392,6 +410,35 @@ export function StatStrip({ stats }: StatStripProps) {
392
410
  );
393
411
  }
394
412
 
413
+ type RecordBadgeProps = { text: string };
414
+
415
+ /**
416
+ * A word in a small mono pill on a hairline, the Badge idiom sized for a stage
417
+ * such as "Contacted". Only RecordStack wears it.
418
+ */
419
+ function RecordBadge({ text }: RecordBadgeProps) {
420
+ const { palette, fonts } = useEmailTheme();
421
+ return (
422
+ <span
423
+ style={{
424
+ display: "inline-block",
425
+ fontFamily: fonts.mono,
426
+ fontSize: "10px",
427
+ lineHeight: "14px",
428
+ letterSpacing: "0.08em",
429
+ textTransform: "uppercase",
430
+ padding: "0 6px",
431
+ border: `1px solid ${palette.line}`,
432
+ borderRadius: "3px",
433
+ color: palette.dim,
434
+ whiteSpace: "nowrap",
435
+ }}
436
+ >
437
+ {text}
438
+ </span>
439
+ );
440
+ }
441
+
395
442
  export type { RecordStackItem };
396
443
  export type RecordStackProps = { records: RecordStackItem[] };
397
444
 
@@ -403,12 +450,33 @@ export type RecordStackProps = { records: RecordStackItem[] };
403
450
  */
404
451
  export function RecordStack({ records }: RecordStackProps) {
405
452
  const { palette, fonts } = useEmailTheme();
453
+ const titleStyle: CSSProperties = {
454
+ fontSize: "15px",
455
+ fontWeight: "600",
456
+ lineHeight: "1.35",
457
+ color: palette.ink,
458
+ wordBreak: "break-word",
459
+ };
406
460
  return (
407
461
  <table {...tableReset} width="100%" style={{ fontFamily: fonts.sans, color: palette.ink }}>
408
462
  <tbody>
409
463
  {records.map((record, i) => {
410
464
  const last = i === records.length - 1;
411
465
  const meta = record.meta.filter((m) => m !== "");
466
+ const title = record.url ? (
467
+ <a
468
+ href={record.url}
469
+ style={{
470
+ color: palette.ink,
471
+ textDecoration: "none",
472
+ borderBottom: `1px solid ${palette.accent}`,
473
+ }}
474
+ >
475
+ {record.title}
476
+ </a>
477
+ ) : (
478
+ record.title
479
+ );
412
480
  return (
413
481
  // biome-ignore lint/suspicious/noArrayIndexKey: a record's position is its identity
414
482
  <tr key={i}>
@@ -419,30 +487,29 @@ export function RecordStack({ records }: RecordStackProps) {
419
487
  ...(last ? {} : { borderBottom: `1px solid ${palette.line}` }),
420
488
  }}
421
489
  >
422
- <div
423
- style={{
424
- fontSize: "15px",
425
- fontWeight: "600",
426
- lineHeight: "1.35",
427
- color: palette.ink,
428
- wordBreak: "break-word",
429
- }}
430
- >
431
- {record.url ? (
432
- <a
433
- href={record.url}
434
- style={{
435
- color: palette.ink,
436
- textDecoration: "none",
437
- borderBottom: `1px solid ${palette.accent}`,
438
- }}
439
- >
440
- {record.title}
441
- </a>
442
- ) : (
443
- record.title
444
- )}
445
- </div>
490
+ {record.badge ? (
491
+ <table {...tableReset} width="100%">
492
+ <tbody>
493
+ <tr>
494
+ <td style={{ ...titleStyle, verticalAlign: "top" }}>{title}</td>
495
+ <td
496
+ align="right"
497
+ style={{
498
+ textAlign: "right",
499
+ whiteSpace: "nowrap",
500
+ verticalAlign: "top",
501
+ paddingLeft: "12px",
502
+ paddingTop: "2px",
503
+ }}
504
+ >
505
+ <RecordBadge text={record.badge} />
506
+ </td>
507
+ </tr>
508
+ </tbody>
509
+ </table>
510
+ ) : (
511
+ <div style={titleStyle}>{title}</div>
512
+ )}
446
513
  {meta.length > 0 ? (
447
514
  <div
448
515
  style={{
@@ -468,6 +535,25 @@ export function RecordStack({ records }: RecordStackProps) {
468
535
  {record.note}
469
536
  </div>
470
537
  ) : null}
538
+ {record.callout ? (
539
+ <div
540
+ style={{
541
+ marginTop: "10px",
542
+ paddingLeft: "10px",
543
+ borderLeft: `2px solid ${palette.accent}`,
544
+ fontFamily: fonts.sans,
545
+ fontSize: "13px",
546
+ lineHeight: "1.5",
547
+ color: palette.ink,
548
+ }}
549
+ >
550
+ <span style={{ ...eyebrowStyle(fonts), color: palette.accent }}>
551
+ {record.callout.eyebrow}
552
+ </span>
553
+ <br />
554
+ {record.callout.text}
555
+ </div>
556
+ ) : null}
471
557
  </td>
472
558
  </tr>
473
559
  );
package/src/email/text.ts CHANGED
@@ -135,21 +135,32 @@ export type RecordStackItem = {
135
135
  /** Facts joined by a middle dot on the line under the title; empty items drop. */
136
136
  meta: string[];
137
137
  note?: string;
138
+ /** A stage word on the title line, right aligned in a pill; "[Contacted]" in the text twin. */
139
+ badge?: string;
140
+ /** A short task under the record, an orange eyebrow over a line of text. */
141
+ callout?: { eyebrow: string; text: string };
138
142
  };
139
143
 
140
144
  /**
141
145
  * The plain text twin of `RecordStack`: each title on its own line under a
142
- * two space indent, the meta joined by middle dots and the note beneath it,
143
- * both wrapped under a four space indent, a blank line between records. No
144
- * line runs past `textWidth`.
146
+ * two space indent with any badge after it in square brackets, the meta joined
147
+ * by middle dots and the note beneath it, both wrapped under a four space
148
+ * indent, then any callout as its eyebrow in upper case over its text under
149
+ * the same indent, a blank line between records. No line runs past
150
+ * `textWidth`.
145
151
  */
146
152
  export function recordStackText(records: RecordStackItem[]): string {
147
153
  return records
148
154
  .map((record) => {
149
- const lines = [wrapIndented(record.title, 2)];
155
+ const title = record.badge ? `${record.title} [${record.badge}]` : record.title;
156
+ const lines = [wrapIndented(title, 2)];
150
157
  const meta = record.meta.filter((m) => m !== "");
151
158
  if (meta.length > 0) lines.push(wrapIndented(meta.join(" · "), 4));
152
159
  if (record.note) lines.push(wrapIndented(record.note, 4));
160
+ if (record.callout) {
161
+ lines.push(wrapIndented(asciiUpcase(record.callout.eyebrow), 4));
162
+ lines.push(wrapIndented(record.callout.text, 4));
163
+ }
153
164
  return lines.join("\n");
154
165
  })
155
166
  .join("\n\n");
@@ -101,6 +101,21 @@
101
101
  height: 30px;
102
102
  width: 30px;
103
103
  }
104
+ /* The recipient half of a sent artifact's lockup (BrandLockup: recipient):
105
+ a quiet times sign in the mono face, then the business's mark at
106
+ wordmark height. */
107
+ .mkt .nav-logo .nav-x {
108
+ font-family: var(--font-mono);
109
+ font-weight: 400;
110
+ font-size: 0.9rem;
111
+ color: color-mix(in srgb, currentColor 55%, transparent);
112
+ margin: 0 2px;
113
+ }
114
+ .mkt .nav-logo .nav-recipient {
115
+ height: 22px;
116
+ width: auto;
117
+ display: block;
118
+ }
104
119
  .mkt .nav-links {
105
120
  display: none;
106
121
  align-items: center;
@@ -24,6 +24,14 @@ export type Palette = {
24
24
  line: string;
25
25
  /** The lighter rule inside a card. */
26
26
  hair: string;
27
+ /**
28
+ * A change for the better, such as a positive delta under a stat. Optional
29
+ * so a palette written before 0.5.0 still compiles; a palette without it
30
+ * borrows the Atelic value.
31
+ */
32
+ up?: string;
33
+ /** A change for the worse, the negative delta's color. Optional the same way. */
34
+ down?: string;
27
35
  };
28
36
 
29
37
  export const atelicPalette: Palette = {
@@ -35,6 +43,8 @@ export const atelicPalette: Palette = {
35
43
  accent: "#FC4A1A",
36
44
  line: "#E6DFD2",
37
45
  hair: "#F0EAE0",
46
+ up: "#2F7A4B",
47
+ down: "#B23A2E",
38
48
  };
39
49
 
40
50
  /**
package/src/types.ts CHANGED
@@ -20,12 +20,26 @@ export interface BrandLogo {
20
20
  run?: boolean;
21
21
  }
22
22
 
23
+ /**
24
+ * The recipient half of a sent artifact's lockup: the business a document
25
+ * was prepared for, whose mark follows the sender's wordmark after a times
26
+ * sign. An image cut for the surface the header wears (a light mark on the
27
+ * dark bar), never a wordmark string, since the sender's type is the one
28
+ * face the chrome draws in.
29
+ */
30
+ export interface BrandRecipient {
31
+ src: string;
32
+ alt: string;
33
+ }
34
+
23
35
  /** The identity a header or footer wears. */
24
36
  export interface Brand {
25
37
  name: string;
26
38
  /** Where the lockup points. Defaults to "/". */
27
39
  href?: string;
28
40
  logo?: BrandLogo;
41
+ /** Set on a sent artifact's header only; a marketing site's lockup has no recipient. */
42
+ recipient?: BrandRecipient;
29
43
  }
30
44
 
31
45
  /** An entry in a site's primary nav. */