@atelic-action/artifact 1.2.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
@@ -7,7 +7,7 @@ The documents the practice hands or sends a business, as a published package and
7
7
 
8
8
  Three layers, each a version the next depends on (ATE-671): `@atelic-action/ui` holds the pieces, this package holds the documents built from them, and each business's folder holds only its content, routes, and config. A document fix reaches a folder by bumping this package; a piece fix by bumping ui here, then this package in the folder.
9
9
 
10
- Five artifacts live in the template site: the **writeup** at `/writeup`, the graded document an owner gets, a web page first with its printed copy derived from it; the **read** at `/read`, what a customer gets at the end of an engagement's first week, the baseline's grades explained by cause with the plan in order; the **proposal** at `/proposal`, sealed into the page and opened by the reader's own email address; the **monthly page** at `/report`, what an Operate client gets each month on one letter sheet; and the **note** at `/notes`, the first touch for a business whose site publishes no email address, set as a short page. The bare host sends a reader to the writeup. Every artifact prerenders to static HTML, stays out of every index, and carries the practice's brand by default.
10
+ Five artifacts live in the template site: the **writeup** at `/writeup`, the graded document an owner gets, a web page first with its printed copy derived from it; the **read** at `/the-read`, what a customer gets at the end of an engagement's first week, the baseline's grades explained by cause with the plan in order; the **proposal** at `/proposal`, sealed into the page and opened by the reader's own email address; the **monthly page** at `/report`, what an Operate client gets each month on one letter sheet; and the **note** at `/notes`, the first touch for a business whose site publishes no email address, set as a short page. The bare host sends a reader to the writeup. Every artifact prerenders to static HTML, stays out of every index, and carries the practice's brand by default.
11
11
 
12
12
  The method behind them (the seven sections, the four grades from A to D, the rented line, what moved, and when an artifact is private) is the practice repo's `FOOTPRINT.md`. This repo builds to it and never restates it.
13
13
 
@@ -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; each section 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 | `/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` | `/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.2.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,42 +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: where we looked (the tools, linked, each with an optional line);
28
- * what it showed (a short paragraph or two, a compact table of facts, lists
29
- * of examples, and any proof pictures); why, in one sentence on a tinted
30
- * panel; today and after, a two column table of what a thing is and what it
31
- * becomes; and what we recommend, as a checklist. A beat with nothing in it
32
- * 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.
33
35
  *
34
- * It is the writeup's sibling, not a variant: it wears the writeup's column,
35
- * head, grade chip and key, tier heading, section card, screen, and close
36
- * (the `.op` classes), and adds only what is its own (the `.rd` classes).
37
- * The gist table is drawn from the tiers, so a tier added later (Conversion,
38
- * once the analytics access lands) is one more entry in `tiers` and appears
39
- * in the table and the body alike, with no change to the props. Every prop
40
- * is plain data, so a routine can write a read as JSON; the one exception is
41
- * the sign off person's `role`, which takes a node and is equally happy with
42
- * 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.
43
44
  */
44
45
 
45
46
  /** A link inside running text: the words the reader clicks and where they go. */
@@ -48,65 +49,36 @@ export type ReadLink = ArtifactLink;
48
49
  /** A paragraph: a plain string, or runs of text and links in order. */
49
50
  export type ReadParagraph = ArtifactParagraph;
50
51
 
51
- /** A tool we looked through: its name, where it opens (in a new tab), and a line on what we read there. */
52
- export interface ReadTool {
53
- label: string;
54
- href?: string;
55
- /** One short line, e.g. "Page indexing, the last 90 days". */
56
- note?: string;
57
- }
58
-
59
- /** The 1.0.0 name for a tool; the same shape. */
60
- export type ReadSource = ReadTool;
61
-
62
- /** One row of a section's facts: a short label and its value, e.g. "Pages on Google" and "9 of 9". */
63
- export interface ReadFact {
64
- label: string;
65
- value: ReadParagraph;
66
- }
67
-
68
- /** A list of examples under its own short heading; each item a paragraph that can carry links. */
69
- export interface ReadExamples {
70
- title?: string;
71
- items: ReadParagraph[];
72
- }
73
-
74
52
  /** One row of today and after: what a thing is now, and what it becomes. */
75
53
  export interface ReadChange {
76
54
  now: ReadParagraph;
77
55
  after: ReadParagraph;
78
56
  }
79
57
 
80
- /** One section of a tier: a question, read top to bottom in five beats. */
81
- export interface ReadSection {
82
- /** The anchor the gist table links to, e.g. "technical-seo". */
83
- id: string;
84
- /** The section's trade name, e.g. "Technical SEO": the gist table's Section column, and the line over the question. */
85
- name: string;
86
- /** The question the section answers, in the owner's words, e.g. "Can Google read the site?". The section's heading. */
87
- question: string;
88
- /** The baseline's grade. Omit for a section not yet graded; the table shows a quiet dash. */
89
- 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"> {
90
64
  /** The one line in the gist table. */
91
65
  gist: ReadParagraph;
92
- /** Where we looked: the tools the readings came from, linked, at the top of the section. */
93
- tools?: ReadTool[];
94
- /** The 1.0.0 name for `tools`, still read and rendered in the same place; `tools` wins when both are set. */
95
- sources?: ReadTool[];
96
- /** What it showed: one or two short paragraphs. */
97
- found: ReadParagraph[];
98
- /** What it showed, as a compact two column table of facts under the paragraphs. */
99
- facts?: ReadFact[];
100
- /** What it showed, as lists of examples, each under its own short heading. */
101
- examples?: ReadExamples[];
102
- /** Proof pictures, each over its caption, e.g. a search result capture per market. */
103
- shots?: WriteupShot[];
104
- /** Why, in one sentence, set apart. */
105
- why: ReadParagraph;
106
- /** 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. */
107
69
  changes?: ReadChange[];
108
- /** What we recommend, as a checklist. */
109
- 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;
110
82
  }
111
83
 
112
84
  /** A tier, e.g. Health: its heading, the question it answers, and its sections in order. */
@@ -115,7 +87,7 @@ export interface ReadTier {
115
87
  id?: string;
116
88
  /** The tier's name, e.g. "Health". */
117
89
  title: string;
118
- /** The question the tier answers, in the owner's words. */
90
+ /** The questions the tier answers, in the owner's words. */
119
91
  lead?: string;
120
92
  sections: ReadSection[];
121
93
  }
@@ -159,18 +131,16 @@ export interface ReadLabels {
159
131
  gist: string;
160
132
  /** What a screen reader hears for a section with no grade. */
161
133
  notGraded: string;
162
- /** The beat at the top of a section: the tools we looked through. */
163
- tools: string;
164
- /** The beat that says what the tools showed. */
165
- found: string;
166
- /** The one sentence beat. */
167
- why: string;
168
134
  /** The today and after table's first column. */
169
135
  now: string;
170
136
  /** The today and after table's second column. */
171
137
  after: string;
172
138
  /** The checklist beat at the foot of a section. */
173
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;
174
144
  /** The plan's heading when it names none. */
175
145
  plan: string;
176
146
  }
@@ -181,12 +151,11 @@ export const readLabels: ReadLabels = {
181
151
  grade: "Grade",
182
152
  gist: "The Gist",
183
153
  notGraded: "Not graded yet",
184
- tools: "Where We Looked",
185
- found: "What It Showed",
186
- why: "Why",
187
154
  now: "Today",
188
155
  after: "After",
189
- recommend: "What We Recommend",
156
+ recommend: "Recommendations",
157
+ why: "Why",
158
+ sources: "See it yourself",
190
159
  plan: "The Plan",
191
160
  };
192
161
 
@@ -200,6 +169,8 @@ export interface ReadProps {
200
169
  lede: ReadParagraph;
201
170
  /** The heading over the gist table, e.g. "At a Glance". */
202
171
  gistTitle?: string;
172
+ /** A callout under the gist table, e.g. what the read leaves out and when it follows. */
173
+ note?: ReadParagraph;
203
174
  /** The key: what each letter means, in a few words each. */
204
175
  gradeKey?: Array<{ grade: WriteupGrade; label: string }>;
205
176
  /** The tiers in order. Each section appears in the gist table and in the body. */
@@ -214,51 +185,29 @@ function filled(paragraph: ReadParagraph | undefined | null): paragraph is ReadP
214
185
  return paragraph !== undefined && paragraph !== null && !isEmpty(paragraph);
215
186
  }
216
187
 
217
- 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;
218
191
  return (
219
- <div className="rd-beat rd-tools">
220
- <h4>{label}</h4>
221
- <ul className="rd-tools-list">
222
- {tools.map((tool, i) => (
223
- // biome-ignore lint/suspicious/noArrayIndexKey: tools are static content in a fixed order, and a label may repeat
224
- <li key={i}>
225
- {tool.href ? (
226
- <a href={tool.href} {...newTabProps(tool.href, true)}>
227
- {tool.label}
228
- <ArrowUpRight size={13} aria-hidden="true" />
229
- </a>
230
- ) : (
231
- <span className="rd-tool-name">{tool.label}</span>
232
- )}
233
- {tool.note && <span className="rd-tool-note">{tool.note}</span>}
234
- </li>
235
- ))}
236
- </ul>
237
- </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
+ )
238
198
  );
239
199
  }
240
200
 
241
- function Facts({ facts }: { facts: ReadFact[] }) {
242
- return (
243
- <table className="rd-facts">
244
- <tbody>
245
- {facts.map((fact, i) => (
246
- // biome-ignore lint/suspicious/noArrayIndexKey: facts are static content in a fixed order
247
- <tr key={i}>
248
- <th scope="row">{fact.label}</th>
249
- <td>
250
- <Runs paragraph={fact.value} />
251
- </td>
252
- </tr>
253
- ))}
254
- </tbody>
255
- </table>
256
- );
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;
257
206
  }
258
207
 
259
208
  function Changes({ changes, labels }: { changes: ReadChange[]; labels: ReadLabels }) {
260
209
  return (
261
- <div className="rd-beat rd-changes">
210
+ <div className="op-block rd-beat rd-changes">
262
211
  <table className="rd-changes-table">
263
212
  <thead>
264
213
  <tr>
@@ -288,21 +237,24 @@ function Changes({ changes, labels }: { changes: ReadChange[]; labels: ReadLabel
288
237
  );
289
238
  }
290
239
 
291
- 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 }) {
292
246
  return (
293
- <div className="rd-beat rd-recommend">
247
+ <div className="op-block rd-beat rd-recommend">
294
248
  <h4>{label}</h4>
295
- <ul className="rd-checks">
296
- {items.map((item, i) => (
297
- // biome-ignore lint/suspicious/noArrayIndexKey: recommendations are static content in a fixed order
298
- <li key={i}>
299
- <Check size={16} strokeWidth={2.5} aria-hidden="true" />
300
- <span>
301
- <Runs paragraph={item} />
302
- </span>
303
- </li>
304
- ))}
305
- </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
+ />
306
258
  </div>
307
259
  );
308
260
  }
@@ -361,7 +313,7 @@ function Gist({
361
313
  {tier.sections.map((section) => (
362
314
  <tr key={section.id}>
363
315
  <th scope="row" className="rd-gist-section">
364
- <a href={`#${section.id}`}>{section.name}</a>
316
+ <a href={`#${section.id}`}>{section.title}</a>
365
317
  </th>
366
318
  <td className="rd-gist-grade">
367
319
  {section.grade ? (
@@ -391,77 +343,45 @@ function Gist({
391
343
  );
392
344
  }
393
345
 
346
+ /** A section: the writeup's card, with today and after and the checklist handed in after its blocks. */
394
347
  function Section({ section, labels }: { section: ReadSection; labels: ReadLabels }) {
395
- const tools = (section.tools ?? section.sources ?? []).filter((tool) => tool?.label?.trim());
396
- const found = (section.found ?? []).filter(filled);
397
- const facts = (section.facts ?? []).filter((fact) => filled(fact?.value));
398
- const examples = (section.examples ?? [])
399
- .map((list) => ({ title: list?.title, items: (list?.items ?? []).filter(filled) }))
400
- .filter((list) => list.items.length > 0);
401
- const shots = section.shots ?? [];
402
348
  const changes = (section.changes ?? []).filter(
403
349
  (change) => filled(change?.now) || filled(change?.after),
404
350
  );
405
- const recommend = (section.recommend ?? []).filter(filled);
406
- const showed = found.length + facts.length + examples.length + shots.length > 0;
407
- return (
408
- <section className="op-topic rd-section" id={section.id}>
409
- <header className="op-topic-head">
410
- <div>
411
- <p className="rd-name">{section.name}</p>
412
- <h3>{section.question}</h3>
413
- </div>
414
- {section.grade && (
415
- <>
416
- <span className="op-sr-only">{`${labels.grade} ${gradeLabel[section.grade]}`}</span>
417
- <span className="rd-grade" aria-hidden="true">
418
- <GradeChip grade={section.grade} />
419
- </span>
420
- </>
421
- )}
422
- </header>
423
- {tools.length > 0 && <Tools tools={tools} label={labels.tools} />}
424
- {showed && (
425
- <div className="rd-beat rd-found">
426
- <h4>{labels.found}</h4>
427
- {found.map((paragraph, i) => (
428
- // biome-ignore lint/suspicious/noArrayIndexKey: paragraphs are static content in a fixed order
429
- <Paragraph paragraph={paragraph} key={i} />
430
- ))}
431
- {facts.length > 0 && <Facts facts={facts} />}
432
- {examples.map((list, i) => (
433
- // biome-ignore lint/suspicious/noArrayIndexKey: lists are static content in a fixed order, and a title may repeat
434
- <div className="rd-examples-block" key={i}>
435
- {list.title && <h5>{list.title}</h5>}
436
- <ul className="rd-examples">
437
- {list.items.map((example, j) => (
438
- // biome-ignore lint/suspicious/noArrayIndexKey: examples are static content in a fixed order
439
- <li key={j}>
440
- <Runs paragraph={example} />
441
- </li>
442
- ))}
443
- </ul>
444
- </div>
445
- ))}
446
- {shots.length > 0 && (
447
- <div className="rd-shots" data-count={Math.min(shots.length, 3)}>
448
- {shots.map((shot) => (
449
- <Shot shot={shot} key={shot.src} />
450
- ))}
451
- </div>
452
- )}
453
- </div>
454
- )}
455
- {filled(section.why) && (
456
- <div className="rd-beat rd-why">
457
- <h4>{labels.why}</h4>
458
- <Paragraph paragraph={section.why} />
459
- </div>
460
- )}
461
- {changes.length > 0 && <Changes changes={changes} labels={labels} />}
462
- {recommend.length > 0 && <Recommend items={recommend} label={labels.recommend} />}
463
- </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,
464
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} />;
465
385
  }
466
386
 
467
387
  function Plan({ plan, labels }: { plan: ReadPlan; labels: ReadLabels }) {
@@ -500,6 +420,7 @@ export function Read({
500
420
  date,
501
421
  lede,
502
422
  gistTitle,
423
+ note,
503
424
  gradeKey,
504
425
  tiers,
505
426
  plan,
@@ -523,7 +444,14 @@ export function Read({
523
444
  {filled(lede) && <Paragraph paragraph={lede} className="op-intro rd-lede" />}
524
445
  </header>
525
446
  {shownTiers.length > 0 && (
526
- <Gist tiers={shownTiers} title={gistTitle} gradeKey={gradeKey} labels={labels} />
447
+ <>
448
+ <Gist tiers={shownTiers} title={gistTitle} gradeKey={gradeKey} labels={labels} />
449
+ {filled(note) && (
450
+ <div className="rd-note" role="note">
451
+ <Paragraph paragraph={note} />
452
+ </div>
453
+ )}
454
+ </>
527
455
  )}
528
456
  {shownTiers.map((tier) => (
529
457
  <div className="rd-tier" key={tier.title}>
@@ -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,17 +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,
2177
+ .atelic-ui .rd-note a,
2178
+ .atelic-ui .rd-doc .op-why a,
2174
2179
  .atelic-ui .rd-beat a,
2175
2180
  .atelic-ui .rd-gist-read a,
2176
2181
  .atelic-ui .rd-plan a,
@@ -2249,22 +2254,6 @@
2249
2254
  text-wrap: pretty;
2250
2255
  min-width: 0;
2251
2256
  }
2252
-
2253
- /* ---- a section: the trade name over the question, then its beats ---- */
2254
- .atelic-ui .rd-name {
2255
- margin: 0 0 4px;
2256
- font-family: var(--font-mono);
2257
- font-size: 11.5px;
2258
- letter-spacing: 0.1em;
2259
- text-transform: uppercase;
2260
- color: var(--op-ink-faint);
2261
- }
2262
- .atelic-ui .rd-grade .op-grade {
2263
- width: 42px;
2264
- height: 42px;
2265
- border-radius: 12px;
2266
- font-size: 21px;
2267
- }
2268
2257
  .atelic-ui .rd-beat {
2269
2258
  margin-top: 20px;
2270
2259
  min-width: 0;
@@ -2286,127 +2275,19 @@
2286
2275
  .atelic-ui .rd-beat p:last-child {
2287
2276
  margin-bottom: 0;
2288
2277
  }
2289
- /* Where we looked: the tools in a row at the top of the section, each a
2290
- link with its line under it. */
2291
- .atelic-ui .rd-tools-list {
2292
- list-style: none;
2293
- margin: 0;
2294
- padding: 0;
2295
- display: flex;
2296
- flex-wrap: wrap;
2297
- gap: 10px 28px;
2298
- }
2299
- .atelic-ui .rd-tools-list li {
2300
- display: flex;
2301
- flex-direction: column;
2302
- gap: 1px;
2303
- min-width: 0;
2304
- max-width: 100%;
2305
- }
2306
- .atelic-ui .rd-tools-list a,
2307
- .atelic-ui .rd-tool-name {
2308
- display: inline-flex;
2309
- align-items: center;
2310
- gap: 3px;
2311
- font-size: 15px;
2312
- font-weight: 600;
2313
- color: var(--op-accent);
2314
- text-decoration: none;
2315
- }
2316
- .atelic-ui .rd-tool-name {
2317
- color: var(--op-ink);
2318
- }
2319
- .atelic-ui .rd-tools-list a:hover {
2320
- text-decoration: underline;
2321
- text-underline-offset: 2px;
2322
- }
2323
- .atelic-ui .rd-tools-list svg {
2324
- flex: none;
2325
- }
2326
- .atelic-ui .rd-tool-note {
2327
- font-size: 13px;
2328
- line-height: 1.35;
2329
- color: var(--op-ink-faint);
2330
- }
2331
-
2332
- /* What it showed: the facts as a compact table of label and value, in
2333
- the writeup's table dress (an ink rule over thin row rules). */
2334
- .atelic-ui .rd-facts {
2335
- width: 100%;
2336
- max-width: 620px;
2337
- margin: 12px 0 0;
2338
- border-collapse: collapse;
2339
- border-top: 1.5px solid var(--op-ink);
2340
- font-size: 15px;
2341
- line-height: 1.4;
2342
- }
2343
- .atelic-ui .rd-facts th,
2344
- .atelic-ui .rd-facts td {
2345
- padding: 7px 14px 7px 0;
2346
- border-bottom: 1px solid var(--neutral-100);
2347
- text-align: left;
2348
- vertical-align: top;
2349
- }
2350
- .atelic-ui .rd-facts th {
2351
- width: 55%;
2352
- font-weight: 400;
2353
- color: var(--op-ink-soft);
2354
- }
2355
- .atelic-ui .rd-facts td {
2356
- padding-right: 0;
2357
- font-weight: 600;
2358
- color: var(--op-ink);
2359
- font-variant-numeric: tabular-nums;
2360
- }
2361
- .atelic-ui .rd-facts tr:last-child > * {
2362
- border-bottom: 0;
2363
- }
2364
-
2365
- /* The examples: a list or more, each under its own short heading. */
2366
- .atelic-ui .rd-examples-block {
2367
- margin-top: 14px;
2368
- max-width: 68ch;
2369
- }
2370
- .atelic-ui .rd-examples-block h5 {
2371
- margin: 0 0 4px;
2372
- font-size: 15px;
2373
- font-weight: 650;
2374
- color: var(--op-ink);
2375
- }
2376
- .atelic-ui .rd-examples {
2377
- list-style: disc;
2378
- margin: 0;
2379
- padding-left: 1.2em;
2380
- color: var(--op-ink-soft);
2381
- text-wrap: pretty;
2382
- }
2383
- .atelic-ui .rd-examples li + li {
2384
- margin-top: 4px;
2385
- }
2386
- /* Why is one sentence, set apart: a tinted panel with the accent's rule. */
2387
- .atelic-ui .rd-why {
2278
+ /* The note under the gist table: what the read leaves out, on a tinted panel with the accent's rule. */
2279
+ .atelic-ui .rd-note {
2280
+ margin-top: 18px;
2388
2281
  padding: 12px 14px;
2389
2282
  border-left: 3px solid var(--op-accent);
2390
2283
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
2391
2284
  background: var(--surface-alt);
2392
2285
  }
2393
- .atelic-ui .rd-why p {
2394
- font-size: 16.5px;
2395
- font-weight: 550;
2396
- line-height: 1.45;
2286
+ .atelic-ui .rd-note p {
2287
+ font-size: 15.5px;
2288
+ line-height: 1.5;
2397
2289
  color: var(--op-ink);
2398
- }
2399
- /* The proof pictures sit in a row at one size, a search capture per
2400
- market; a phone stacks them. */
2401
- .atelic-ui .rd-shots {
2402
- display: grid;
2403
- grid-template-columns: minmax(0, 1fr);
2404
- gap: 14px;
2405
- margin-top: 14px;
2406
- }
2407
- /* A lone picture keeps a screen's size rather than filling the card. */
2408
- .atelic-ui .rd-shots[data-count="1"] {
2409
- max-width: 380px;
2290
+ text-wrap: pretty;
2410
2291
  }
2411
2292
 
2412
2293
  /* Today and after: what a thing is, and what it becomes. Two columns
@@ -2469,26 +2350,43 @@
2469
2350
  color: var(--op-accent);
2470
2351
  }
2471
2352
 
2472
- /* What we recommend: a checklist, each item led by the accent's check. */
2473
- .atelic-ui .rd-checks {
2474
- list-style: none;
2475
- margin: 0;
2476
- padding: 0;
2477
- display: grid;
2478
- gap: 8px;
2479
- 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);
2480
2356
  }
2481
- .atelic-ui .rd-checks li {
2482
- display: grid;
2483
- grid-template-columns: auto minmax(0, 1fr);
2484
- column-gap: 10px;
2485
- align-items: start;
2486
- line-height: 1.45;
2487
- 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;
2488
2360
  }
2489
- .atelic-ui .rd-checks svg {
2490
- margin-top: 0.2em;
2491
- 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;
2492
2390
  }
2493
2391
 
2494
2392
  /* ---- the plan: the steps in order, numbered ---- */
@@ -2583,9 +2481,6 @@
2583
2481
  padding-left: 20px;
2584
2482
  padding-right: 16px;
2585
2483
  }
2586
- .atelic-ui .rd-facts th {
2587
- width: 45%;
2588
- }
2589
2484
  .atelic-ui .rd-changes-table {
2590
2485
  display: table;
2591
2486
  table-layout: fixed;
@@ -2646,12 +2541,6 @@
2646
2541
  .atelic-ui .rd-changes-table tbody tr:last-child td {
2647
2542
  border-bottom: 0;
2648
2543
  }
2649
- .atelic-ui .rd-shots[data-count="2"] {
2650
- grid-template-columns: repeat(2, minmax(0, 1fr));
2651
- }
2652
- .atelic-ui .rd-shots[data-count="3"] {
2653
- grid-template-columns: repeat(3, minmax(0, 1fr));
2654
- }
2655
2544
  }
2656
2545
 
2657
2546
  /* ---- print: the writeup's rules carry the page; these hold the read's
@@ -2664,18 +2553,14 @@
2664
2553
  }
2665
2554
  .atelic-ui .rd-gist-table tr,
2666
2555
  .atelic-ui .rd-changes-table tr,
2667
- .atelic-ui .rd-facts,
2668
- .atelic-ui .rd-tools,
2669
2556
  .atelic-ui .rd-checks li,
2670
2557
  .atelic-ui .rd-closing,
2671
- .atelic-ui .rd-why,
2558
+ .atelic-ui .rd-note,
2672
2559
  .atelic-ui .rd-step,
2673
- .atelic-ui .rd-close,
2674
- .atelic-ui .rd-shots .op-shot {
2560
+ .atelic-ui .rd-close {
2675
2561
  break-inside: avoid;
2676
2562
  }
2677
2563
  .atelic-ui .rd-beat h4,
2678
- .atelic-ui .rd-examples-block h5,
2679
2564
  .atelic-ui .rd-plan h2 {
2680
2565
  break-after: avoid;
2681
2566
  }