@atelic-action/artifact 1.3.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -21,13 +21,14 @@ Every export of the package, all from the one entry, `@atelic-action/artifact`.
21
21
 
22
22
  | Export | What it is | Route |
23
23
  |---|---|---|
24
+ | `Topic` | The writeup's section card on its own: the trade name linked to its definition, the one liner, the grade, the why line, the blocks, and the sources, with an `extra` slot after the blocks. The read renders its sections through it | `/writeup` |
24
25
  | `Writeup`, `WriteupProps` | The graded document an owner gets: the overview, the three tiers of section cards, the close | `/writeup` |
25
26
  | `ratingCell` | A star rating table cell for a writeup table, sorted by its figure | `/writeup` |
26
27
  | `WriteupBlock`, `WriteupTopic`, `WriteupPart`, `WriteupSheet`, `WriteupSummary`, `WriteupSummaryRow`, `WriteupMarkets`, `WriteupRented`, `WriteupMark`, `WriteupCell`, `WriteupTone`, and the rest of the `Writeup*` types | The shapes a writeup's content is written in | none |
27
28
  | `WriteupGrade`, `WriteupPerson`, `WriteupShot` | The grade letter, the sign off person, and the owner's screen, shared by every document, all three re exported from ui | none |
28
- | `Read`, `ReadProps` | The baseline's grades explained by cause: the gist table, with an optional `note` callout under it for what the read leaves out; each section under its trade name with the owner's question beneath it, read top to bottom as where we looked (the tools, linked, at the top), what it showed (a short paragraph or two, a compact table of facts, lists of examples under their own headings, and any proof pictures), why (one sentence on a tinted panel), today and after (a two column table), and what we recommend (a checklist); then the plan in order. A beat with no data is left out, heading and all | `/the-read` |
29
- | `readLabels`, `ReadLabels` | The fixed words the read sets (Prepared for, the gist table's columns, Where We Looked, What It Showed, Why, Today, After, What We Recommend, the plan's heading), each overridable through `labels` | `/the-read` |
30
- | `ReadTier`, `ReadSection`, `ReadTool`, `ReadFact`, `ReadExamples`, `ReadChange`, `ReadStep`, `ReadPlan`, `ReadClose`, `ReadLink`, `ReadParagraph` | The shapes a read's content is written in, all plain data so a routine can write a read as JSON. A section's `tools` were its `sources` in 1.0.0; `sources` is still read, and rendered at the top as tools, and `ReadSource` names the same shape as `ReadTool` | none |
29
+ | `Read`, `ReadProps` | The baseline's grades explained by cause: the gist table, with an optional `note` callout under it for what the read leaves out; each section on the writeup's card (`ReadSection` extends `WriteupTopic`: the trade name linked to its definition, the one liner, the grade, the why line as the summary, the writeup's blocks, and the sources at the foot) plus the read's own today and after (a two column table) and what we recommend (a checklist); then the plan in order. A beat with no data is left out | `/the-read` |
30
+ | `readLabels`, `ReadLabels` | The fixed words the read sets (Prepared for, the gist table's columns, Today, After, Recommendations, the plan's heading), each overridable through `labels` | `/the-read` |
31
+ | `ReadTier`, `ReadSection`, `ReadChange`, `ReadStep`, `ReadPlan`, `ReadClose`, `ReadLink`, `ReadParagraph` | The shapes a read's content is written in. `ReadSection` is a `WriteupTopic` plus `gist`, `changes`, and `recommend`; its `why` and a finding's `body` take a paragraph (a string, or runs of text and links) as well as a node, so a routine can still write a read as JSON | none |
31
32
  | `Report`, `ReportProps` | The monthly page: the four questions, the seven grades now and before, the sign off, on one letter sheet | `/report` |
32
33
  | `ReportLead`, `ReportLeads`, `ReportGradeRow` | The shapes a monthly page's content is written in | none |
33
34
  | `Note`, `NoteProps` | The first touch set as a page: the opener, two lanes of findings, where the work would start, the close | `/notes` |
@@ -44,7 +45,7 @@ What a document is built from stays in `@atelic-action/ui`, and this package imp
44
45
  bun add @atelic-action/artifact @atelic-action/ui
45
46
  ```
46
47
 
47
- Peer dependencies: `@atelic-action/ui` at `^2.0.0`, `react` at `^19.0.0`, and `lucide-react` at `^1.23.0`. The package ships TSX and CSS as source, compiled inside the site's own Vite like ui's; a site whose `vite.config.ts` is one `artifactConfig` call from `@atelic-action/ui/vite` needs nothing more, for the build, the prerender, and the dev server alike. The default labels a document sets live in the document: the read's `readLabels` and the sealed document's gate copy are overridden through props, and the monthly page's four questions are fixed on purpose, since they are its contract with the Statement of Work.
48
+ Peer dependencies: `@atelic-action/ui` at `^2.1.0`, `react` at `^19.0.0`, and `lucide-react` at `^1.23.0`. The package ships TSX and CSS as source, compiled inside the site's own Vite like ui's; a site whose `vite.config.ts` is one `artifactConfig` call from `@atelic-action/ui/vite` needs nothing more, for the build, the prerender, and the dev server alike. The default labels a document sets live in the document: the read's `readLabels` and the sealed document's gate copy are overridden through props, and the monthly page's four questions are fixed on purpose, since they are its contract with the Statement of Work.
48
49
 
49
50
  ```tsx
50
51
  import { Writeup } from "@atelic-action/artifact";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/artifact",
3
- "version": "1.3.0",
3
+ "version": "2.0.0",
4
4
  "description": "The composed documents the Atelic practice hands or sends a business (the writeup, the read, the monthly page, the note, and the sealed document), built from the pieces in @atelic-action/ui",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -37,12 +37,12 @@
37
37
  "format": "biome format . --write"
38
38
  },
39
39
  "peerDependencies": {
40
- "@atelic-action/ui": "^2.0.0",
40
+ "@atelic-action/ui": "^2.1.0",
41
41
  "lucide-react": "^1.23.0",
42
42
  "react": "^19.0.0"
43
43
  },
44
44
  "devDependencies": {
45
- "@atelic-action/ui": "^2.0.0",
45
+ "@atelic-action/ui": "^2.1.0",
46
46
  "@biomejs/biome": "^2.0.0",
47
47
  "@playwright/test": "^1.60.0",
48
48
  "@sentry/react": "^10.0.0",
@@ -4,44 +4,43 @@ import {
4
4
  GradeChip,
5
5
  gradeLabel,
6
6
  isEmpty,
7
- newTabProps,
8
7
  Paragraph,
9
8
  Runs,
10
- Shot,
11
9
  SignOff,
12
10
  type WriteupGrade,
13
11
  type WriteupPerson,
14
- type WriteupShot,
15
12
  } from "@atelic-action/ui/artifact";
16
- import { ArrowUpRight, Check } from "lucide-react";
13
+ import { IconList, type Tone } from "@atelic-action/ui/components";
14
+ import { Check } from "lucide-react";
15
+ import type { ReactNode } from "react";
16
+ import { Topic, type WriteupTopic } from "./Writeup";
17
17
 
18
18
  /**
19
19
  * The read: what a customer gets at the end of an engagement's first week,
20
20
  * before anything changes. Where the writeup says what letter each section
21
21
  * earned, the read says why, and what changes. Its shape is the practice's
22
22
  * 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.
23
+ * table (every section, its grade, and one line), with a note under it for
24
+ * what the read leaves out; then the tiers; then the plan, in order; then one
25
+ * closing line and the sign off.
25
26
  *
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.
27
+ * A section is the writeup's section card, the same `Topic`, so the two
28
+ * documents read alike (Forni, 2026-10-16): the trade name linked to its
29
+ * definition, the one liner, the grade, and the why line, which here is the
30
+ * summary of what we saw in one or two sentences; then the writeup's blocks
31
+ * (findings with the owner's screens beside them, the basics, a table, a
32
+ * fold); then what is the read's own, today and after as a two column table
33
+ * and what we recommend as a checklist; and the tools we looked through at
34
+ * the foot, as the sources. A beat with nothing in it is left out.
34
35
  *
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.
36
+ * It wears the writeup's column, head, grade chip and key, tier heading,
37
+ * section card, and close (the `.op` classes), and adds only what is its own
38
+ * (the `.rd` classes). The gist table is drawn from the tiers, so a tier
39
+ * added later (Conversion, once the analytics access lands) is one more entry
40
+ * in `tiers` and appears in the table and the body alike; until then `note`
41
+ * says so under the table. Every prop but a finding's body and the sign off
42
+ * person's `role` is plain data, and both are equally happy with a string, so
43
+ * a routine can write a read as JSON.
45
44
  */
46
45
 
47
46
  /** A link inside running text: the words the reader clicks and where they go. */
@@ -50,65 +49,36 @@ export type ReadLink = ArtifactLink;
50
49
  /** A paragraph: a plain string, or runs of text and links in order. */
51
50
  export type ReadParagraph = ArtifactParagraph;
52
51
 
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
52
  /** One row of today and after: what a thing is now, and what it becomes. */
77
53
  export interface ReadChange {
78
54
  now: ReadParagraph;
79
55
  after: ReadParagraph;
80
56
  }
81
57
 
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;
58
+ /**
59
+ * One section of a tier: the writeup's section card (its `title`, `href`,
60
+ * `oneLiner`, `grade`, `why`, `blocks`, and `sources`), plus the read's own:
61
+ * the gist table's line, today and after, and what we recommend.
62
+ */
63
+ export interface ReadSection extends Omit<WriteupTopic, "kind" | "why"> {
92
64
  /** The one line in the gist table. */
93
65
  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. */
66
+ /** 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. */
67
+ why?: ReactNode | ReadParagraph;
68
+ /** Today and after: what each thing is, and what it becomes. Set after the blocks. */
109
69
  changes?: ReadChange[];
110
- /** What we recommend, as a checklist. */
111
- recommend?: ReadParagraph[];
70
+ /** The recommendations, after today and after: a paragraph each, or one with its own icon and tone. */
71
+ recommend?: (ReadParagraph | ReadRecommendation)[];
72
+ }
73
+
74
+ /** 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. */
75
+ export interface ReadRecommendation {
76
+ /** The recommendation: a paragraph, or a node when it carries a table or a list. */
77
+ body: ReactNode | ReadParagraph;
78
+ /** The icon as the bullet, e.g. a lucide icon at size 16; the accent's check when left out. */
79
+ icon?: ReactNode;
80
+ /** How the item reads; the accent when left out. */
81
+ tone?: Tone;
112
82
  }
113
83
 
114
84
  /** A tier, e.g. Health: its heading, the question it answers, and its sections in order. */
@@ -117,7 +87,7 @@ export interface ReadTier {
117
87
  id?: string;
118
88
  /** The tier's name, e.g. "Health". */
119
89
  title: string;
120
- /** The question the tier answers, in the owner's words. */
90
+ /** The questions the tier answers, in the owner's words. */
121
91
  lead?: string;
122
92
  sections: ReadSection[];
123
93
  }
@@ -161,18 +131,16 @@ export interface ReadLabels {
161
131
  gist: string;
162
132
  /** What a screen reader hears for a section with no grade. */
163
133
  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
134
  /** The today and after table's first column. */
171
135
  now: string;
172
136
  /** The today and after table's second column. */
173
137
  after: string;
174
138
  /** The checklist beat at the foot of a section. */
175
139
  recommend: string;
140
+ /** The word before the grade on a section's why line. */
141
+ why: string;
142
+ /** The label over a section's tools when it names none. */
143
+ sources: string;
176
144
  /** The plan's heading when it names none. */
177
145
  plan: string;
178
146
  }
@@ -183,12 +151,11 @@ export const readLabels: ReadLabels = {
183
151
  grade: "Grade",
184
152
  gist: "The Gist",
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
 
@@ -218,51 +185,29 @@ function filled(paragraph: ReadParagraph | undefined | null): paragraph is ReadP
218
185
  return paragraph !== undefined && paragraph !== null && !isEmpty(paragraph);
219
186
  }
220
187
 
221
- function Tools({ tools, label }: { tools: ReadTool[]; label: string }) {
188
+ /** A string or runs of text and links, the shape JSON content arrives in; a node of elements is anything else. */
189
+ function isRuns(value: unknown): value is ReadParagraph {
190
+ if (typeof value === "string") return true;
222
191
  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>
192
+ Array.isArray(value) &&
193
+ value.every(
194
+ (run) =>
195
+ typeof run === "string" ||
196
+ (typeof run === "object" && run !== null && "text" in run && "href" in run),
197
+ )
242
198
  );
243
199
  }
244
200
 
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
- );
201
+ /** Content as a node: runs are rendered, a blank is dropped, and a node passes through. */
202
+ function asNode(value: ReactNode | ReadParagraph | undefined | null): ReactNode {
203
+ if (value === undefined || value === null) return undefined;
204
+ if (isRuns(value)) return filled(value) ? <Runs paragraph={value} /> : undefined;
205
+ return value;
261
206
  }
262
207
 
263
208
  function Changes({ changes, labels }: { changes: ReadChange[]; labels: ReadLabels }) {
264
209
  return (
265
- <div className="rd-beat rd-changes">
210
+ <div className="op-block rd-beat rd-changes">
266
211
  <table className="rd-changes-table">
267
212
  <thead>
268
213
  <tr>
@@ -292,21 +237,24 @@ function Changes({ changes, labels }: { changes: ReadChange[]; labels: ReadLabel
292
237
  );
293
238
  }
294
239
 
295
- function Recommend({ items, label }: { items: ReadParagraph[]; label: string }) {
240
+ /** A recommendation as its full shape, whichever way the content wrote it. */
241
+ function asRecommendation(item: ReadParagraph | ReadRecommendation): ReadRecommendation {
242
+ return isRuns(item) ? { body: item } : item;
243
+ }
244
+
245
+ function Recommend({ items, label }: { items: ReadRecommendation[]; label: string }) {
296
246
  return (
297
- <div className="rd-beat rd-recommend">
247
+ <div className="op-block rd-beat rd-recommend">
298
248
  <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>
249
+ <IconList
250
+ className="rd-checks"
251
+ icon={<Check size={16} strokeWidth={2.5} />}
252
+ items={items.map((item) => ({
253
+ icon: item.icon,
254
+ tone: item.tone,
255
+ body: asNode(item.body),
256
+ }))}
257
+ />
310
258
  </div>
311
259
  );
312
260
  }
@@ -365,7 +313,7 @@ function Gist({
365
313
  {tier.sections.map((section) => (
366
314
  <tr key={section.id}>
367
315
  <th scope="row" className="rd-gist-section">
368
- <a href={`#${section.id}`}>{section.name}</a>
316
+ <a href={`#${section.id}`}>{section.title}</a>
369
317
  </th>
370
318
  <td className="rd-gist-grade">
371
319
  {section.grade ? (
@@ -395,77 +343,45 @@ function Gist({
395
343
  );
396
344
  }
397
345
 
346
+ /** A section: the writeup's card, with today and after and the checklist handed in after its blocks. */
398
347
  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
348
  const changes = (section.changes ?? []).filter(
407
349
  (change) => filled(change?.now) || filled(change?.after),
408
350
  );
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>
351
+ const recommend = (section.recommend ?? [])
352
+ .filter((item) => item !== undefined && item !== null)
353
+ .map(asRecommendation)
354
+ .filter((item) => (isRuns(item.body) ? filled(item.body) : item.body != null));
355
+ const blocks = (section.blocks ?? []).map((block) =>
356
+ block.kind === "findings"
357
+ ? {
358
+ ...block,
359
+ items: block.items
360
+ .filter((item) => item?.lead?.trim())
361
+ .map((item) => ({ ...item, body: asNode(item.body) })),
362
+ }
363
+ : block,
468
364
  );
365
+ const sources = (section.sources ?? []).filter((source) => source?.label?.trim());
366
+ const topic: WriteupTopic = {
367
+ kind: "topic",
368
+ ...section,
369
+ why: asNode(section.why),
370
+ whyLabel: section.whyLabel ?? labels.why,
371
+ gradeWord: section.gradeWord ?? labels.grade,
372
+ blocks,
373
+ sources,
374
+ sourcesLabel: section.sourcesLabel ?? labels.sources,
375
+ noShot: section.noShot ?? true,
376
+ };
377
+ const extra =
378
+ changes.length + recommend.length > 0 ? (
379
+ <>
380
+ {changes.length > 0 && <Changes changes={changes} labels={labels} />}
381
+ {recommend.length > 0 && <Recommend items={recommend} label={labels.recommend} />}
382
+ </>
383
+ ) : undefined;
384
+ return <Topic topic={topic} extra={extra} />;
469
385
  }
470
386
 
471
387
  function Plan({ plan, labels }: { plan: ReadPlan; labels: ReadLabels }) {
@@ -1,7 +1,9 @@
1
1
  import {
2
+ type ArtifactParagraph,
2
3
  GradeChip,
3
4
  gradeLabel,
4
5
  newTabProps,
6
+ Runs,
5
7
  Shot,
6
8
  SignOff,
7
9
  type WriteupGrade,
@@ -52,8 +54,8 @@ export type WriteupTone = Tone;
52
54
  export interface WriteupBullet {
53
55
  /** The point, in a full sentence. The icon sits beside it. */
54
56
  lead: string;
55
- /** The one explainer that says why it matters. */
56
- body?: ReactNode;
57
+ /** The one explainer that says why it matters: a node, or a paragraph of runs when the content arrives as JSON. */
58
+ body?: ReactNode | ArtifactParagraph;
57
59
  /** A win the owner already has: marked good. Everything else reads as needing work. */
58
60
  win?: boolean;
59
61
  /** Overrides the tone, e.g. "info" for something not yet read. */
@@ -113,6 +115,8 @@ export interface WriteupTable extends BlockBase {
113
115
  sortable?: boolean;
114
116
  /** The direction a first click sorts in. */
115
117
  firstSort?: "asc" | "desc";
118
+ /** The column hugs its content, for a swatch or a mark. */
119
+ fit?: boolean;
116
120
  }>;
117
121
  rows: Array<{ cells: WriteupCell[]; tone?: WriteupTone }>;
118
122
  /** The sort the table opens in. */
@@ -199,8 +203,12 @@ export interface WriteupTopic {
199
203
  oneLiner: string;
200
204
  /** The section's grade, beside the heading. */
201
205
  grade?: WriteupGrade;
202
- /** Why it earned that letter: the rule it met, in a sentence. */
206
+ /** Why it earned that letter: the rule it met, in a sentence. The read sets its summary here. */
203
207
  why?: ReactNode;
208
+ /** The word before the grade on the why line; defaults to "Why". */
209
+ whyLabel?: string;
210
+ /** What a screen reader hears before the grade's letter; defaults to "Grade". */
211
+ gradeWord?: string;
204
212
  /** The section's blocks, in order. */
205
213
  blocks?: WriteupTopicBlock[];
206
214
  /** Findings without blocks: rendered as one findings block with the shots beside it. */
@@ -388,21 +396,43 @@ function Table({ block }: { block: WriteupTable }) {
388
396
  );
389
397
  }
390
398
 
399
+ /** A string or runs of text and links, the shape JSON content arrives in; a node of elements is anything else. */
400
+ function isRuns(value: unknown): value is ArtifactParagraph {
401
+ if (typeof value === "string") return true;
402
+ return (
403
+ Array.isArray(value) &&
404
+ value.every(
405
+ (run) =>
406
+ typeof run === "string" ||
407
+ (typeof run === "object" && run !== null && "text" in run && "href" in run),
408
+ )
409
+ );
410
+ }
411
+
412
+ /** A finding's body as a node, whichever way the content wrote it. */
413
+ function bodyNode(body: ReactNode | ArtifactParagraph | undefined): ReactNode {
414
+ if (body === undefined || body === null) return undefined;
415
+ return isRuns(body) ? <Runs paragraph={body} /> : body;
416
+ }
417
+
391
418
  function Findings({ block, brief }: { block: WriteupFindings; brief?: string }) {
392
419
  const hasShots = Boolean(block.shot || brief);
393
420
  return (
394
421
  <div className={`op-block op-findings${hasShots ? " has-shots" : ""}`}>
395
422
  <div className="op-findings-list">
396
- {block.items.map((item) => (
397
- <Callout
398
- key={item.lead}
399
- tone={item.tone ?? (item.win ? "good" : "warn")}
400
- title={item.lead}
401
- variant={item.box ? "box" : "plain"}
402
- >
403
- {item.body}
404
- </Callout>
405
- ))}
423
+ {block.items.map((item, i) => {
424
+ const shape = {
425
+ tone: item.tone ?? (item.win ? "good" : "warn"),
426
+ title: item.lead,
427
+ variant: item.box ? ("box" as const) : ("plain" as const),
428
+ };
429
+ return (
430
+ // biome-ignore lint/suspicious/noArrayIndexKey: findings are static content in a fixed order, and a lead may repeat
431
+ <Callout key={i} {...shape}>
432
+ {bodyNode(item.body)}
433
+ </Callout>
434
+ );
435
+ })}
406
436
  </div>
407
437
  {hasShots && (
408
438
  <div className="op-shots">
@@ -495,7 +525,14 @@ function TopicBlock({ block }: { block: WriteupTopicBlock }) {
495
525
  }
496
526
  }
497
527
 
498
- function Topic({ topic }: { topic: WriteupTopic }) {
528
+ /**
529
+ * The section card: the trade name linked to its definition, the one liner,
530
+ * the why line, the grade, the blocks, and the sources at the foot. The read
531
+ * renders its sections through it too, handing anything of its own (today and
532
+ * after, what we recommend) as `extra`, set after the blocks and before the
533
+ * sources, so the two documents share one layout.
534
+ */
535
+ export function Topic({ topic, extra }: { topic: WriteupTopic; extra?: ReactNode }) {
499
536
  const legacy: WriteupFindings | null = topic.bullets
500
537
  ? { kind: "findings", items: topic.bullets, shot: topic.shot, shot2: topic.shot2 }
501
538
  : null;
@@ -519,13 +556,25 @@ function Topic({ topic }: { topic: WriteupTopic }) {
519
556
  )}
520
557
  </h3>
521
558
  <p className="op-def">{topic.oneLiner}</p>
522
- {topic.grade && topic.why && (
559
+ {topic.why && (
523
560
  <p className="op-why">
524
- <b>{`Why ${gradeLabel[topic.grade]}:`}</b> {topic.why}
561
+ <b>
562
+ {topic.grade
563
+ ? `${topic.whyLabel ?? "Why"} ${gradeLabel[topic.grade]}:`
564
+ : `${topic.whyLabel ?? "Why"}:`}
565
+ </b>{" "}
566
+ {topic.why}
525
567
  </p>
526
568
  )}
527
569
  </div>
528
- {topic.grade && <GradeChip grade={topic.grade} />}
570
+ {topic.grade && (
571
+ <>
572
+ <span className="op-sr-only">{`${topic.gradeWord ?? "Grade"} ${gradeLabel[topic.grade]}`}</span>
573
+ <span aria-hidden="true">
574
+ <GradeChip grade={topic.grade} />
575
+ </span>
576
+ </>
577
+ )}
529
578
  </header>
530
579
  <div className="op-blocks">
531
580
  {topic.blocks?.map((block, i) => (
@@ -533,13 +582,15 @@ function Topic({ topic }: { topic: WriteupTopic }) {
533
582
  <TopicBlock block={block} key={i} />
534
583
  ))}
535
584
  {legacy && <Findings block={legacy} brief={brief} />}
585
+ {extra}
536
586
  </div>
537
587
  {topic.sources && topic.sources.length > 0 && (
538
588
  <footer className="op-sources">
539
589
  <span className="op-sources-label">{topic.sourcesLabel ?? "Sources"}</span>
540
590
  <ul>
541
- {topic.sources.map((source) => (
542
- <li key={source.label}>
591
+ {topic.sources.map((source, i) => (
592
+ // biome-ignore lint/suspicious/noArrayIndexKey: sources are static content in a fixed order, and a label may repeat
593
+ <li key={i}>
543
594
  {source.href ? (
544
595
  <a href={source.href} {...newTabProps(source.href, true)}>
545
596
  {source.label}
@@ -1126,7 +1126,7 @@
1126
1126
  font-weight: 650;
1127
1127
  color: var(--op-ink);
1128
1128
  }
1129
- .atelic-ui .op-topic-head > .op-grade {
1129
+ .atelic-ui .op-topic-head .op-grade {
1130
1130
  width: 42px;
1131
1131
  height: 42px;
1132
1132
  border-radius: 12px;
@@ -1582,7 +1582,7 @@
1582
1582
  .atelic-ui .op-topic h3 {
1583
1583
  font-size: 23px;
1584
1584
  }
1585
- .atelic-ui .op-topic-head > .op-grade {
1585
+ .atelic-ui .op-topic-head .op-grade {
1586
1586
  width: 46px;
1587
1587
  height: 46px;
1588
1588
  border-radius: 13px;
@@ -2160,18 +2160,22 @@
2160
2160
  column, the head, the grade chip and its key, the tier heading, the
2161
2161
  section card, the screen, the close, and every print rule of the
2162
2162
  writeup's are its own already. This block adds only what the read has
2163
- and the writeup does not: the gist table, the beats of a section (the
2164
- tools we looked through, what they showed with its facts and
2165
- examples, why, today and after, and what we recommend), the proof
2166
- pictures in a row, and the numbered plan. It sets no @page; the writeup's, earlier in this sheet and the
2163
+ and the writeup does not: the gist table and the note under it, the
2164
+ two beats a section adds to the writeup's card (today and after, and
2165
+ what we recommend), and the numbered plan. It sets no @page; the writeup's, earlier in this sheet and the
2167
2166
  later of the two there, sets letter paper for every artifact page.
2168
2167
 
2169
2168
  Tokens only (theme.css).
2170
2169
  ================================================================= */
2171
2170
 
2171
+ /* The lede runs the full width of the page; the writeup's intro is the one that is capped. */
2172
+ .atelic-ui .rd-lede {
2173
+ max-width: none;
2174
+ }
2172
2175
  /* Prose links read as links here, where the writeup's are quiet. */
2173
2176
  .atelic-ui .rd-lede a,
2174
2177
  .atelic-ui .rd-note a,
2178
+ .atelic-ui .rd-doc .op-why a,
2175
2179
  .atelic-ui .rd-beat a,
2176
2180
  .atelic-ui .rd-gist-read a,
2177
2181
  .atelic-ui .rd-plan a,
@@ -2250,22 +2254,6 @@
2250
2254
  text-wrap: pretty;
2251
2255
  min-width: 0;
2252
2256
  }
2253
-
2254
- /* ---- a section: the trade name over the question, then its beats ---- */
2255
- /* The owner's question sits under the section's name. */
2256
- .atelic-ui .rd-question {
2257
- margin: 4px 0 0;
2258
- font-size: 15.5px;
2259
- line-height: 1.4;
2260
- color: var(--op-ink-soft);
2261
- text-wrap: pretty;
2262
- }
2263
- .atelic-ui .rd-grade .op-grade {
2264
- width: 42px;
2265
- height: 42px;
2266
- border-radius: 12px;
2267
- font-size: 21px;
2268
- }
2269
2257
  .atelic-ui .rd-beat {
2270
2258
  margin-top: 20px;
2271
2259
  min-width: 0;
@@ -2287,104 +2275,7 @@
2287
2275
  .atelic-ui .rd-beat p:last-child {
2288
2276
  margin-bottom: 0;
2289
2277
  }
2290
- /* Where we looked: the tools in a row at the top of the section, each a
2291
- link with its line under it. */
2292
- .atelic-ui .rd-tools-list {
2293
- list-style: none;
2294
- margin: 0;
2295
- padding: 0;
2296
- display: flex;
2297
- flex-wrap: wrap;
2298
- gap: 10px 28px;
2299
- }
2300
- .atelic-ui .rd-tools-list li {
2301
- display: flex;
2302
- flex-direction: column;
2303
- gap: 1px;
2304
- min-width: 0;
2305
- max-width: 100%;
2306
- }
2307
- .atelic-ui .rd-tools-list a,
2308
- .atelic-ui .rd-tool-name {
2309
- display: inline-flex;
2310
- align-items: center;
2311
- gap: 3px;
2312
- font-size: 15px;
2313
- font-weight: 600;
2314
- color: var(--op-accent);
2315
- text-decoration: none;
2316
- }
2317
- .atelic-ui .rd-tool-name {
2318
- color: var(--op-ink);
2319
- }
2320
- .atelic-ui .rd-tools-list a:hover {
2321
- text-decoration: underline;
2322
- text-underline-offset: 2px;
2323
- }
2324
- .atelic-ui .rd-tools-list svg {
2325
- flex: none;
2326
- }
2327
- .atelic-ui .rd-tool-note {
2328
- font-size: 13px;
2329
- line-height: 1.35;
2330
- color: var(--op-ink-faint);
2331
- }
2332
-
2333
- /* What it showed: the facts as a compact table of label and value, in
2334
- the writeup's table dress (an ink rule over thin row rules). */
2335
- .atelic-ui .rd-facts {
2336
- width: 100%;
2337
- max-width: 620px;
2338
- margin: 12px 0 0;
2339
- border-collapse: collapse;
2340
- border-top: 1.5px solid var(--op-ink);
2341
- font-size: 15px;
2342
- line-height: 1.4;
2343
- }
2344
- .atelic-ui .rd-facts th,
2345
- .atelic-ui .rd-facts td {
2346
- padding: 7px 14px 7px 0;
2347
- border-bottom: 1px solid var(--neutral-100);
2348
- text-align: left;
2349
- vertical-align: top;
2350
- }
2351
- .atelic-ui .rd-facts th {
2352
- width: 55%;
2353
- font-weight: 400;
2354
- color: var(--op-ink-soft);
2355
- }
2356
- .atelic-ui .rd-facts td {
2357
- padding-right: 0;
2358
- font-weight: 600;
2359
- color: var(--op-ink);
2360
- font-variant-numeric: tabular-nums;
2361
- }
2362
- .atelic-ui .rd-facts tr:last-child > * {
2363
- border-bottom: 0;
2364
- }
2365
-
2366
- /* The examples: a list or more, each under its own short heading. */
2367
- .atelic-ui .rd-examples-block {
2368
- margin-top: 14px;
2369
- max-width: 68ch;
2370
- }
2371
- .atelic-ui .rd-examples-block h5 {
2372
- margin: 0 0 4px;
2373
- font-size: 15px;
2374
- font-weight: 650;
2375
- color: var(--op-ink);
2376
- }
2377
- .atelic-ui .rd-examples {
2378
- list-style: disc;
2379
- margin: 0;
2380
- padding-left: 1.2em;
2381
- color: var(--op-ink-soft);
2382
- text-wrap: pretty;
2383
- }
2384
- .atelic-ui .rd-examples li + li {
2385
- margin-top: 4px;
2386
- }
2387
- /* The note under the gist table: what the read leaves out, on the why panel's tint. */
2278
+ /* The note under the gist table: what the read leaves out, on a tinted panel with the accent's rule. */
2388
2279
  .atelic-ui .rd-note {
2389
2280
  margin-top: 18px;
2390
2281
  padding: 12px 14px;
@@ -2398,31 +2289,6 @@
2398
2289
  color: var(--op-ink);
2399
2290
  text-wrap: pretty;
2400
2291
  }
2401
- /* Why is one sentence, set apart: a tinted panel with the accent's rule. */
2402
- .atelic-ui .rd-why {
2403
- padding: 12px 14px;
2404
- border-left: 3px solid var(--op-accent);
2405
- border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
2406
- background: var(--surface-alt);
2407
- }
2408
- .atelic-ui .rd-why p {
2409
- font-size: 16.5px;
2410
- font-weight: 550;
2411
- line-height: 1.45;
2412
- color: var(--op-ink);
2413
- }
2414
- /* The proof pictures sit in a row at one size, a search capture per
2415
- market; a phone stacks them. */
2416
- .atelic-ui .rd-shots {
2417
- display: grid;
2418
- grid-template-columns: minmax(0, 1fr);
2419
- gap: 14px;
2420
- margin-top: 14px;
2421
- }
2422
- /* A lone picture keeps a screen's size rather than filling the card. */
2423
- .atelic-ui .rd-shots[data-count="1"] {
2424
- max-width: 380px;
2425
- }
2426
2292
 
2427
2293
  /* Today and after: what a thing is, and what it becomes. Two columns
2428
2294
  wide; on a phone each row stacks, each half wearing its label. */
@@ -2484,26 +2350,43 @@
2484
2350
  color: var(--op-accent);
2485
2351
  }
2486
2352
 
2487
- /* What we recommend: a checklist, each item led by the accent's check. */
2488
- .atelic-ui .rd-checks {
2489
- list-style: none;
2490
- margin: 0;
2491
- padding: 0;
2492
- display: grid;
2493
- gap: 8px;
2494
- max-width: 68ch;
2353
+ /* The recommendations: ui's icon list across the card's width, each bullet the accent's check unless the item brings its own icon or tone. */
2354
+ .atelic-ui .rd-recommend .rd-checks {
2355
+ --tone: var(--op-accent);
2495
2356
  }
2496
- .atelic-ui .rd-checks li {
2497
- display: grid;
2498
- grid-template-columns: auto minmax(0, 1fr);
2499
- column-gap: 10px;
2500
- align-items: start;
2501
- line-height: 1.45;
2502
- text-wrap: pretty;
2357
+ /* A list or a table inside a finding: a little air above it. */
2358
+ .atelic-ui .op-findings .callout-body .icon-list {
2359
+ margin-top: 6px;
2503
2360
  }
2504
- .atelic-ui .rd-checks svg {
2505
- margin-top: 0.2em;
2506
- color: var(--op-accent);
2361
+ .atelic-ui .op-findings .callout-body .data-table,
2362
+ .atelic-ui .rd-checks .data-table {
2363
+ margin-top: 12px;
2364
+ }
2365
+ /* In the read, three pictures fill the card's width. */
2366
+ .atelic-ui .rd-doc .op-shots-row[data-count="3"] {
2367
+ max-width: none;
2368
+ }
2369
+ /* Content patterns for a table inside a read, written by the content with ui's swatch: a rank with its badge in one cell, two equal halves, the swatch on the left and the figure on the right. These are the read's patterns, not a piece, until a consumer beyond the first wants them, when they move to ui. */
2370
+ .atelic-ui .rd-ranked {
2371
+ display: inline-grid;
2372
+ grid-template-columns: 1fr 1fr;
2373
+ column-gap: 8px;
2374
+ align-items: center;
2375
+ min-width: 3.4em;
2376
+ }
2377
+ .atelic-ui .rd-ranked .chart-swatch {
2378
+ justify-self: start;
2379
+ }
2380
+ .atelic-ui .rd-ranked > a,
2381
+ .atelic-ui .rd-ranked > span:last-child {
2382
+ justify-self: end;
2383
+ font-variant-numeric: tabular-nums;
2384
+ }
2385
+ /* A swatch as a table cell: the chart key's box, keying the row in its tone, in a column marked fit. */
2386
+ .atelic-ui .op-table .chart-swatch,
2387
+ .atelic-ui .op-findings .callout-body .chart-swatch {
2388
+ display: inline-block;
2389
+ vertical-align: middle;
2507
2390
  }
2508
2391
 
2509
2392
  /* ---- the plan: the steps in order, numbered ---- */
@@ -2598,9 +2481,6 @@
2598
2481
  padding-left: 20px;
2599
2482
  padding-right: 16px;
2600
2483
  }
2601
- .atelic-ui .rd-facts th {
2602
- width: 45%;
2603
- }
2604
2484
  .atelic-ui .rd-changes-table {
2605
2485
  display: table;
2606
2486
  table-layout: fixed;
@@ -2661,12 +2541,6 @@
2661
2541
  .atelic-ui .rd-changes-table tbody tr:last-child td {
2662
2542
  border-bottom: 0;
2663
2543
  }
2664
- .atelic-ui .rd-shots[data-count="2"] {
2665
- grid-template-columns: repeat(2, minmax(0, 1fr));
2666
- }
2667
- .atelic-ui .rd-shots[data-count="3"] {
2668
- grid-template-columns: repeat(3, minmax(0, 1fr));
2669
- }
2670
2544
  }
2671
2545
 
2672
2546
  /* ---- print: the writeup's rules carry the page; these hold the read's
@@ -2679,19 +2553,14 @@
2679
2553
  }
2680
2554
  .atelic-ui .rd-gist-table tr,
2681
2555
  .atelic-ui .rd-changes-table tr,
2682
- .atelic-ui .rd-facts,
2683
- .atelic-ui .rd-tools,
2684
2556
  .atelic-ui .rd-checks li,
2685
2557
  .atelic-ui .rd-closing,
2686
2558
  .atelic-ui .rd-note,
2687
- .atelic-ui .rd-why,
2688
2559
  .atelic-ui .rd-step,
2689
- .atelic-ui .rd-close,
2690
- .atelic-ui .rd-shots .op-shot {
2560
+ .atelic-ui .rd-close {
2691
2561
  break-inside: avoid;
2692
2562
  }
2693
2563
  .atelic-ui .rd-beat h4,
2694
- .atelic-ui .rd-examples-block h5,
2695
2564
  .atelic-ui .rd-plan h2 {
2696
2565
  break-after: avoid;
2697
2566
  }