@atelic-action/artifact 1.3.0 → 3.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.
@@ -1,47 +1,45 @@
1
1
  import {
2
2
  type ArtifactLink,
3
3
  type ArtifactParagraph,
4
- GradeChip,
5
- gradeLabel,
6
4
  isEmpty,
7
5
  newTabProps,
8
6
  Paragraph,
9
7
  Runs,
10
- Shot,
11
8
  SignOff,
12
9
  type WriteupGrade,
13
10
  type WriteupPerson,
14
- type WriteupShot,
15
11
  } from "@atelic-action/ui/artifact";
12
+ import { IconList, ReportCard, type Tone } from "@atelic-action/ui/components";
16
13
  import { ArrowUpRight, Check } from "lucide-react";
14
+ import type { ReactNode } from "react";
15
+ import { Topic, type WriteupTopic } from "./Writeup";
17
16
 
18
17
  /**
19
18
  * The read: what a customer gets at the end of an engagement's first week,
20
19
  * before anything changes. Where the writeup says what letter each section
21
20
  * earned, the read says why, and what changes. Its shape is the practice's
22
21
  * FOOTPRINT.md, under What the Owner Gets: a title and a dated lede; the gist
23
- * table (every section, its grade, and one line); then the tiers; then the
24
- * plan, in order; then one closing line and the sign off.
22
+ * table (every section, its grade, and one line), with a note under it for
23
+ * what the read leaves out; then the tiers; then the plan, in order; then one
24
+ * closing line and the sign off.
25
25
  *
26
- * Each section reads as an aggregator of the tools we looked through, top to
27
- * bottom, under its trade name with the owner's question beneath it: where
28
- * we looked (the tools, linked, each with an optional line);
29
- * what it showed (a short paragraph or two, a compact table of facts, lists
30
- * of examples, and any proof pictures); why, in one sentence on a tinted
31
- * panel; today and after, a two column table of what a thing is and what it
32
- * becomes; and what we recommend, as a checklist. A beat with nothing in it
33
- * is left out, heading and all.
26
+ * A section is the writeup's section card, the same `Topic`, so the two
27
+ * documents read alike (Forni, 2026-10-16): the trade name linked to its
28
+ * definition, the one liner, the grade, and the why line, which here is the
29
+ * summary of what we saw in one or two sentences; then the writeup's blocks
30
+ * (findings with the owner's screens beside them, the basics, a table, a
31
+ * fold); then what is the read's own, today and after as a two column table
32
+ * and what we recommend as a checklist; and the tools we looked through at
33
+ * the foot, as the sources. A beat with nothing in it is left out.
34
34
  *
35
- * It is the writeup's sibling, not a variant: it wears the writeup's column,
36
- * head, grade chip and key, tier heading, section card, screen, and close
37
- * (the `.op` classes), and adds only what is its own (the `.rd` classes).
38
- * The gist table is drawn from the tiers, so a tier added later (Conversion,
39
- * once the analytics access lands) is one more entry in `tiers` and appears
40
- * in the table and the body alike, with no change to the props; until then
41
- * `note` says so in a callout under the table. Every prop
42
- * is plain data, so a routine can write a read as JSON; the one exception is
43
- * the sign off person's `role`, which takes a node and is equally happy with
44
- * a string.
35
+ * It wears the writeup's column, head, grade chip and key, tier heading,
36
+ * section card, and close (the `.op` classes), and adds only what is its own
37
+ * (the `.rd` classes). The gist table is drawn from the tiers, so a tier
38
+ * added later (Conversion, once the analytics access lands) is one more entry
39
+ * in `tiers` and appears in the table and the body alike; until then `note`
40
+ * says so under the table. Every prop but a finding's body and the sign off
41
+ * person's `role` is plain data, and both are equally happy with a string, so
42
+ * a routine can write a read as JSON.
45
43
  */
46
44
 
47
45
  /** A link inside running text: the words the reader clicks and where they go. */
@@ -50,65 +48,36 @@ export type ReadLink = ArtifactLink;
50
48
  /** A paragraph: a plain string, or runs of text and links in order. */
51
49
  export type ReadParagraph = ArtifactParagraph;
52
50
 
53
- /** A tool we looked through: its name, where it opens (in a new tab), and a line on what we read there. */
54
- export interface ReadTool {
55
- label: string;
56
- href?: string;
57
- /** One short line, e.g. "Page indexing, the last 90 days". */
58
- note?: string;
59
- }
60
-
61
- /** The 1.0.0 name for a tool; the same shape. */
62
- export type ReadSource = ReadTool;
63
-
64
- /** One row of a section's facts: a short label and its value, e.g. "Pages on Google" and "9 of 9". */
65
- export interface ReadFact {
66
- label: string;
67
- value: ReadParagraph;
68
- }
69
-
70
- /** A list of examples under its own short heading; each item a paragraph that can carry links. */
71
- export interface ReadExamples {
72
- title?: string;
73
- items: ReadParagraph[];
74
- }
75
-
76
51
  /** One row of today and after: what a thing is now, and what it becomes. */
77
52
  export interface ReadChange {
78
53
  now: ReadParagraph;
79
54
  after: ReadParagraph;
80
55
  }
81
56
 
82
- /** One section of a tier: a question, read top to bottom in five beats. */
83
- export interface ReadSection {
84
- /** The anchor the gist table links to, e.g. "technical-seo". */
85
- id: string;
86
- /** The section's trade name, e.g. "Technical SEO": the section's heading, and the gist table's Section column. */
87
- name: string;
88
- /** The question the section answers, in the owner's words, e.g. "Can Google read the site?", set under the heading. */
89
- question: string;
90
- /** The baseline's grade. Omit for a section not yet graded; the table shows a quiet dash. */
91
- grade?: WriteupGrade;
57
+ /**
58
+ * One section of a tier: the writeup's section card (its `title`, `href`,
59
+ * `oneLiner`, `grade`, `why`, `blocks`, and `sources`), plus the read's own:
60
+ * the gist table's line, today and after, and what we recommend.
61
+ */
62
+ export interface ReadSection extends Omit<WriteupTopic, "kind" | "why"> {
92
63
  /** The one line in the gist table. */
93
64
  gist: ReadParagraph;
94
- /** Where we looked: the tools the readings came from, linked, at the top of the section. */
95
- tools?: ReadTool[];
96
- /** The 1.0.0 name for `tools`, still read and rendered in the same place; `tools` wins when both are set. */
97
- sources?: ReadTool[];
98
- /** What it showed: one or two short paragraphs. */
99
- found: ReadParagraph[];
100
- /** What it showed, as a compact two column table of facts under the paragraphs. */
101
- facts?: ReadFact[];
102
- /** What it showed, as lists of examples, each under its own short heading. */
103
- examples?: ReadExamples[];
104
- /** Proof pictures, each over its caption, e.g. a search result capture per market. */
105
- shots?: WriteupShot[];
106
- /** Why, in one sentence, set apart. */
107
- why: ReadParagraph;
108
- /** Today and after: what each thing is, and what it becomes. */
65
+ /** The summary of what we saw, in one or two sentences: the why line. A paragraph, so a read written as JSON can carry links in it. */
66
+ why?: ReactNode | ReadParagraph;
67
+ /** Today and after: what each thing is, and what it becomes. Set after the blocks. */
109
68
  changes?: ReadChange[];
110
- /** What we recommend, as a checklist. */
111
- recommend?: ReadParagraph[];
69
+ /** The recommendations, after today and after: a paragraph each, or one with its own icon and tone. */
70
+ recommend?: (ReadParagraph | ReadRecommendation)[];
71
+ }
72
+
73
+ /** One recommendation with a bullet of its own: the icon it leads with, and the tone it reads in. Both fall back to the list's. */
74
+ export interface ReadRecommendation {
75
+ /** The recommendation: a paragraph, or a node when it carries a table or a list. */
76
+ body: ReactNode | ReadParagraph;
77
+ /** The icon as the bullet, e.g. a lucide icon at size 16; the accent's check when left out. */
78
+ icon?: ReactNode;
79
+ /** How the item reads; the accent when left out. */
80
+ tone?: Tone;
112
81
  }
113
82
 
114
83
  /** A tier, e.g. Health: its heading, the question it answers, and its sections in order. */
@@ -117,7 +86,9 @@ export interface ReadTier {
117
86
  id?: string;
118
87
  /** The tier's name, e.g. "Health". */
119
88
  title: string;
120
- /** The question the tier answers, in the owner's words. */
89
+ /** Where the name is defined, e.g. the practice's glossary; the heading links there when set. */
90
+ href?: string;
91
+ /** The questions the tier answers, in the owner's words. */
121
92
  lead?: string;
122
93
  sections: ReadSection[];
123
94
  }
@@ -128,9 +99,11 @@ export interface ReadStep {
128
99
  body?: ReadParagraph;
129
100
  /** Whose step it is when it is not the practice's, e.g. "Yours" or a name; set beside the title. */
130
101
  who?: string;
102
+ /** The cards the step draws on, linked in a line beneath it, e.g. { text: "Local SEO", href: "#local-seo" }. */
103
+ cards?: ReadLink[];
131
104
  }
132
105
 
133
- /** The plan: the steps in order, numbered. */
106
+ /** The plan: the steps in order, numbered, between the gist and the cards, so the whole program reads before any one card's detail. */
134
107
  export interface ReadPlan {
135
108
  /** The anchor a header stop points at, e.g. "plan". */
136
109
  id?: string;
@@ -153,42 +126,36 @@ export interface ReadClose {
153
126
  export interface ReadLabels {
154
127
  /** The dateline's words before the business, e.g. "Prepared for". */
155
128
  preparedFor: string;
156
- /** The gist table's first column. */
157
- section: string;
158
- /** The gist table's second column. */
159
- grade: string;
160
- /** The gist table's third column. */
129
+ /** What a screen reader calls the gist card when it has no title. */
161
130
  gist: string;
131
+ /** What a screen reader hears before a letter, on the gist card and each section's card. */
132
+ grade: string;
162
133
  /** What a screen reader hears for a section with no grade. */
163
134
  notGraded: string;
164
- /** The beat at the top of a section: the tools we looked through. */
165
- tools: string;
166
- /** The beat that says what the tools showed. */
167
- found: string;
168
- /** The one sentence beat. */
169
- why: string;
170
135
  /** The today and after table's first column. */
171
136
  now: string;
172
137
  /** The today and after table's second column. */
173
138
  after: string;
174
139
  /** The checklist beat at the foot of a section. */
175
140
  recommend: string;
141
+ /** The word before the grade on a section's why line. */
142
+ why: string;
143
+ /** The label over a section's tools when it names none. */
144
+ sources: string;
176
145
  /** The plan's heading when it names none. */
177
146
  plan: string;
178
147
  }
179
148
 
180
149
  export const readLabels: ReadLabels = {
181
150
  preparedFor: "Prepared for",
182
- section: "Section",
183
- grade: "Grade",
184
151
  gist: "The Gist",
152
+ grade: "Grade",
185
153
  notGraded: "Not graded yet",
186
- tools: "Where We Looked",
187
- found: "What It Showed",
188
- why: "Why",
189
154
  now: "Today",
190
155
  after: "After",
191
- recommend: "What We Recommend",
156
+ recommend: "Recommendations",
157
+ why: "Why",
158
+ sources: "See it yourself",
192
159
  plan: "The Plan",
193
160
  };
194
161
 
@@ -200,9 +167,13 @@ export interface ReadProps {
200
167
  date: string;
201
168
  /** Two or three sentences under the title. */
202
169
  lede: ReadParagraph;
203
- /** The heading over the gist table, e.g. "At a Glance". */
170
+ /** The heading over the scorecard, e.g. "The Scorecard". */
204
171
  gistTitle?: string;
205
- /** A callout under the gist table, e.g. what the read leaves out and when it follows. */
172
+ /** The line under the scorecard's heading, e.g. what it is. */
173
+ gistLead?: string;
174
+ /** The anchor a header stop points at for the scorecard, e.g. "scorecard". */
175
+ gistId?: string;
176
+ /** A callout over the scorecard, under its heading, e.g. what the read leaves out and when it follows. */
206
177
  note?: ReadParagraph;
207
178
  /** The key: what each letter means, in a few words each. */
208
179
  gradeKey?: Array<{ grade: WriteupGrade; label: string }>;
@@ -218,51 +189,29 @@ function filled(paragraph: ReadParagraph | undefined | null): paragraph is ReadP
218
189
  return paragraph !== undefined && paragraph !== null && !isEmpty(paragraph);
219
190
  }
220
191
 
221
- function Tools({ tools, label }: { tools: ReadTool[]; label: string }) {
192
+ /** A string or runs of text and links, the shape JSON content arrives in; a node of elements is anything else. */
193
+ function isRuns(value: unknown): value is ReadParagraph {
194
+ if (typeof value === "string") return true;
222
195
  return (
223
- <div className="rd-beat rd-tools">
224
- <h4>{label}</h4>
225
- <ul className="rd-tools-list">
226
- {tools.map((tool, i) => (
227
- // biome-ignore lint/suspicious/noArrayIndexKey: tools are static content in a fixed order, and a label may repeat
228
- <li key={i}>
229
- {tool.href ? (
230
- <a href={tool.href} {...newTabProps(tool.href, true)}>
231
- {tool.label}
232
- <ArrowUpRight size={13} aria-hidden="true" />
233
- </a>
234
- ) : (
235
- <span className="rd-tool-name">{tool.label}</span>
236
- )}
237
- {tool.note && <span className="rd-tool-note">{tool.note}</span>}
238
- </li>
239
- ))}
240
- </ul>
241
- </div>
196
+ Array.isArray(value) &&
197
+ value.every(
198
+ (run) =>
199
+ typeof run === "string" ||
200
+ (typeof run === "object" && run !== null && "text" in run && "href" in run),
201
+ )
242
202
  );
243
203
  }
244
204
 
245
- function Facts({ facts }: { facts: ReadFact[] }) {
246
- return (
247
- <table className="rd-facts">
248
- <tbody>
249
- {facts.map((fact, i) => (
250
- // biome-ignore lint/suspicious/noArrayIndexKey: facts are static content in a fixed order
251
- <tr key={i}>
252
- <th scope="row">{fact.label}</th>
253
- <td>
254
- <Runs paragraph={fact.value} />
255
- </td>
256
- </tr>
257
- ))}
258
- </tbody>
259
- </table>
260
- );
205
+ /** Content as a node: runs are rendered, a blank is dropped, and a node passes through. */
206
+ function asNode(value: ReactNode | ReadParagraph | undefined | null): ReactNode {
207
+ if (value === undefined || value === null) return undefined;
208
+ if (isRuns(value)) return filled(value) ? <Runs paragraph={value} /> : undefined;
209
+ return value;
261
210
  }
262
211
 
263
212
  function Changes({ changes, labels }: { changes: ReadChange[]; labels: ReadLabels }) {
264
213
  return (
265
- <div className="rd-beat rd-changes">
214
+ <div className="op-block rd-beat rd-changes">
266
215
  <table className="rd-changes-table">
267
216
  <thead>
268
217
  <tr>
@@ -292,180 +241,117 @@ function Changes({ changes, labels }: { changes: ReadChange[]; labels: ReadLabel
292
241
  );
293
242
  }
294
243
 
295
- function Recommend({ items, label }: { items: ReadParagraph[]; label: string }) {
244
+ /** A recommendation as its full shape, whichever way the content wrote it. */
245
+ function asRecommendation(item: ReadParagraph | ReadRecommendation): ReadRecommendation {
246
+ return isRuns(item) ? { body: item } : item;
247
+ }
248
+
249
+ function Recommend({ items, label }: { items: ReadRecommendation[]; label: string }) {
296
250
  return (
297
- <div className="rd-beat rd-recommend">
251
+ <div className="op-block rd-beat rd-recommend">
298
252
  <h4>{label}</h4>
299
- <ul className="rd-checks">
300
- {items.map((item, i) => (
301
- // biome-ignore lint/suspicious/noArrayIndexKey: recommendations are static content in a fixed order
302
- <li key={i}>
303
- <Check size={16} strokeWidth={2.5} aria-hidden="true" />
304
- <span>
305
- <Runs paragraph={item} />
306
- </span>
307
- </li>
308
- ))}
309
- </ul>
253
+ <IconList
254
+ className="rd-checks"
255
+ icon={<Check size={16} strokeWidth={2.5} />}
256
+ items={items.map((item) => ({
257
+ icon: item.icon,
258
+ tone: item.tone,
259
+ body: asNode(item.body),
260
+ }))}
261
+ />
310
262
  </div>
311
263
  );
312
264
  }
313
265
 
314
266
  function Gist({
315
267
  tiers,
268
+ id,
316
269
  title,
270
+ lead,
271
+ note,
317
272
  gradeKey,
318
273
  labels,
319
274
  }: {
320
275
  tiers: ReadTier[];
276
+ id?: string;
321
277
  title?: string;
278
+ lead?: string;
279
+ note?: ReadProps["note"];
322
280
  gradeKey?: ReadProps["gradeKey"];
323
281
  labels: ReadLabels;
324
282
  }) {
325
- const key = gradeKey ?? [];
326
283
  return (
327
- <section className="rd-gist" aria-label={title ? undefined : labels.gist}>
328
- {(title || key.length > 0) && (
329
- <div className="op-ov-head">
330
- {title && <h2 className="op-ov-title">{title}</h2>}
331
- {key.length > 0 && (
332
- <ul className="op-legend">
333
- {key.map((entry) => (
334
- <li key={entry.grade}>
335
- <GradeChip grade={entry.grade} />
336
- <span>{entry.label}</span>
337
- </li>
338
- ))}
339
- </ul>
340
- )}
284
+ <>
285
+ {title && (
286
+ <div className="op-part" id={id}>
287
+ <h2>{title}</h2>
288
+ {lead && <p>{lead}</p>}
341
289
  </div>
342
290
  )}
343
- <div className="rd-gist-card">
344
- <table className="rd-gist-table">
345
- <thead>
346
- <tr>
347
- <th scope="col" className="rd-gist-section">
348
- {labels.section}
349
- </th>
350
- <th scope="col" className="rd-gist-grade">
351
- {labels.grade}
352
- </th>
353
- <th scope="col" className="rd-gist-read">
354
- {labels.gist}
355
- </th>
356
- </tr>
357
- </thead>
358
- {tiers.map((tier) => (
359
- <tbody key={tier.title}>
360
- <tr className="rd-gist-tier">
361
- <th scope="rowgroup" colSpan={3}>
362
- {tier.title}
363
- </th>
364
- </tr>
365
- {tier.sections.map((section) => (
366
- <tr key={section.id}>
367
- <th scope="row" className="rd-gist-section">
368
- <a href={`#${section.id}`}>{section.name}</a>
369
- </th>
370
- <td className="rd-gist-grade">
371
- {section.grade ? (
372
- <>
373
- <span className="op-sr-only">{gradeLabel[section.grade]}</span>
374
- <span aria-hidden="true">
375
- <GradeChip grade={section.grade} />
376
- </span>
377
- </>
378
- ) : (
379
- <>
380
- <span className="op-sr-only">{labels.notGraded}</span>
381
- <span className="op-dash" aria-hidden="true" />
382
- </>
383
- )}
384
- </td>
385
- <td className="rd-gist-read">
386
- <Runs paragraph={section.gist} />
387
- </td>
388
- </tr>
389
- ))}
390
- </tbody>
391
- ))}
392
- </table>
393
- </div>
394
- </section>
291
+ {filled(note) && (
292
+ <div className="rd-note" role="note">
293
+ <Paragraph paragraph={note} />
294
+ </div>
295
+ )}
296
+ <ReportCard
297
+ className="rd-gist"
298
+ label={title ?? labels.gist}
299
+ legend={gradeKey}
300
+ legendAt="foot"
301
+ labels={{ grade: labels.grade, notGraded: labels.notGraded }}
302
+ groups={tiers.map((tier) => ({
303
+ title: tier.title,
304
+ rows: tier.sections.map((section) => ({
305
+ name: section.title,
306
+ href: `#${section.id}`,
307
+ grade: section.grade,
308
+ line: <Runs paragraph={section.gist} />,
309
+ })),
310
+ }))}
311
+ />
312
+ </>
395
313
  );
396
314
  }
397
315
 
316
+ /** A section: the writeup's card, with today and after and the checklist handed in after its blocks. */
398
317
  function Section({ section, labels }: { section: ReadSection; labels: ReadLabels }) {
399
- const tools = (section.tools ?? section.sources ?? []).filter((tool) => tool?.label?.trim());
400
- const found = (section.found ?? []).filter(filled);
401
- const facts = (section.facts ?? []).filter((fact) => filled(fact?.value));
402
- const examples = (section.examples ?? [])
403
- .map((list) => ({ title: list?.title, items: (list?.items ?? []).filter(filled) }))
404
- .filter((list) => list.items.length > 0);
405
- const shots = section.shots ?? [];
406
318
  const changes = (section.changes ?? []).filter(
407
319
  (change) => filled(change?.now) || filled(change?.after),
408
320
  );
409
- const recommend = (section.recommend ?? []).filter(filled);
410
- const showed = found.length + facts.length + examples.length + shots.length > 0;
411
- return (
412
- <section className="op-topic rd-section" id={section.id}>
413
- <header className="op-topic-head">
414
- <div>
415
- <h3>{section.name}</h3>
416
- <p className="rd-question">{section.question}</p>
417
- </div>
418
- {section.grade && (
419
- <>
420
- <span className="op-sr-only">{`${labels.grade} ${gradeLabel[section.grade]}`}</span>
421
- <span className="rd-grade" aria-hidden="true">
422
- <GradeChip grade={section.grade} />
423
- </span>
424
- </>
425
- )}
426
- </header>
427
- {tools.length > 0 && <Tools tools={tools} label={labels.tools} />}
428
- {showed && (
429
- <div className="rd-beat rd-found">
430
- <h4>{labels.found}</h4>
431
- {found.map((paragraph, i) => (
432
- // biome-ignore lint/suspicious/noArrayIndexKey: paragraphs are static content in a fixed order
433
- <Paragraph paragraph={paragraph} key={i} />
434
- ))}
435
- {facts.length > 0 && <Facts facts={facts} />}
436
- {examples.map((list, i) => (
437
- // biome-ignore lint/suspicious/noArrayIndexKey: lists are static content in a fixed order, and a title may repeat
438
- <div className="rd-examples-block" key={i}>
439
- {list.title && <h5>{list.title}</h5>}
440
- <ul className="rd-examples">
441
- {list.items.map((example, j) => (
442
- // biome-ignore lint/suspicious/noArrayIndexKey: examples are static content in a fixed order
443
- <li key={j}>
444
- <Runs paragraph={example} />
445
- </li>
446
- ))}
447
- </ul>
448
- </div>
449
- ))}
450
- {shots.length > 0 && (
451
- <div className="rd-shots" data-count={Math.min(shots.length, 3)}>
452
- {shots.map((shot) => (
453
- <Shot shot={shot} key={shot.src} />
454
- ))}
455
- </div>
456
- )}
457
- </div>
458
- )}
459
- {filled(section.why) && (
460
- <div className="rd-beat rd-why">
461
- <h4>{labels.why}</h4>
462
- <Paragraph paragraph={section.why} />
463
- </div>
464
- )}
465
- {changes.length > 0 && <Changes changes={changes} labels={labels} />}
466
- {recommend.length > 0 && <Recommend items={recommend} label={labels.recommend} />}
467
- </section>
321
+ const recommend = (section.recommend ?? [])
322
+ .filter((item) => item !== undefined && item !== null)
323
+ .map(asRecommendation)
324
+ .filter((item) => (isRuns(item.body) ? filled(item.body) : item.body != null));
325
+ const blocks = (section.blocks ?? []).map((block) =>
326
+ block.kind === "findings"
327
+ ? {
328
+ ...block,
329
+ items: block.items
330
+ .filter((item) => item?.lead?.trim())
331
+ .map((item) => ({ ...item, body: asNode(item.body) })),
332
+ }
333
+ : block,
468
334
  );
335
+ const sources = (section.sources ?? []).filter((source) => source?.label?.trim());
336
+ const topic: WriteupTopic = {
337
+ kind: "topic",
338
+ ...section,
339
+ why: asNode(section.why),
340
+ whyLabel: section.whyLabel ?? labels.why,
341
+ gradeWord: section.gradeWord ?? labels.grade,
342
+ blocks,
343
+ sources,
344
+ sourcesLabel: section.sourcesLabel ?? labels.sources,
345
+ noShot: section.noShot ?? true,
346
+ };
347
+ const extra =
348
+ changes.length + recommend.length > 0 ? (
349
+ <>
350
+ {changes.length > 0 && <Changes changes={changes} labels={labels} />}
351
+ {recommend.length > 0 && <Recommend items={recommend} label={labels.recommend} />}
352
+ </>
353
+ ) : undefined;
354
+ return <Topic topic={topic} extra={extra} />;
469
355
  }
470
356
 
471
357
  function Plan({ plan, labels }: { plan: ReadPlan; labels: ReadLabels }) {
@@ -476,21 +362,24 @@ function Plan({ plan, labels }: { plan: ReadPlan; labels: ReadLabels }) {
476
362
  <ol className="rd-steps">
477
363
  {plan.steps.map((step) => (
478
364
  <li className="rd-step" key={step.title}>
479
- <p>
480
- <b className="rd-step-title">{step.title}</b>
481
- {step.who && (
482
- <>
483
- {" "}
484
- <span className="rd-step-who">{step.who}</span>
485
- </>
486
- )}
487
- {filled(step.body) && (
488
- <>
489
- {" "}
490
- <Runs paragraph={step.body} />
491
- </>
365
+ <div className="rd-step-body">
366
+ <p className="rd-step-head">
367
+ <b className="rd-step-title">{step.title}</b>
368
+ {step.who && <span className="rd-step-who">{step.who}</span>}
369
+ </p>
370
+ {filled(step.body) && <Paragraph paragraph={step.body} />}
371
+ {(step.cards ?? []).some((card) => card?.href) && (
372
+ <p className="rd-step-cards">
373
+ {(step.cards ?? [])
374
+ .filter((card) => card?.href)
375
+ .map((card, i) => (
376
+ <a href={card.href} key={`${i}-${card.href}`}>
377
+ {card.text}
378
+ </a>
379
+ ))}
380
+ </p>
492
381
  )}
493
- </p>
382
+ </div>
494
383
  </li>
495
384
  ))}
496
385
  </ol>
@@ -504,6 +393,8 @@ export function Read({
504
393
  date,
505
394
  lede,
506
395
  gistTitle,
396
+ gistLead,
397
+ gistId,
507
398
  note,
508
399
  gradeKey,
509
400
  tiers,
@@ -528,19 +419,30 @@ export function Read({
528
419
  {filled(lede) && <Paragraph paragraph={lede} className="op-intro rd-lede" />}
529
420
  </header>
530
421
  {shownTiers.length > 0 && (
531
- <>
532
- <Gist tiers={shownTiers} title={gistTitle} gradeKey={gradeKey} labels={labels} />
533
- {filled(note) && (
534
- <div className="rd-note" role="note">
535
- <Paragraph paragraph={note} />
536
- </div>
537
- )}
538
- </>
422
+ <Gist
423
+ tiers={shownTiers}
424
+ id={gistId}
425
+ title={gistTitle}
426
+ lead={gistLead}
427
+ note={note}
428
+ gradeKey={gradeKey}
429
+ labels={labels}
430
+ />
539
431
  )}
432
+ {plan.steps.length > 0 && <Plan plan={plan} labels={labels} />}
540
433
  {shownTiers.map((tier) => (
541
434
  <div className="rd-tier" key={tier.title}>
542
435
  <div className="op-part" id={tier.id}>
543
- <h2>{tier.title}</h2>
436
+ <h2>
437
+ {tier.href ? (
438
+ <a href={tier.href} {...newTabProps(tier.href, true)}>
439
+ {tier.title}
440
+ <ArrowUpRight size={14} aria-hidden="true" />
441
+ </a>
442
+ ) : (
443
+ tier.title
444
+ )}
445
+ </h2>
544
446
  {tier.lead && <p>{tier.lead}</p>}
545
447
  </div>
546
448
  {tier.sections.map((section) => (
@@ -548,7 +450,6 @@ export function Read({
548
450
  ))}
549
451
  </div>
550
452
  ))}
551
- {plan.steps.length > 0 && <Plan plan={plan} labels={labels} />}
552
453
  {filled(close.line) && <Paragraph paragraph={close.line} className="rd-closing" />}
553
454
  {close.signOff && close.person && (
554
455
  <footer className="op-close rd-close">