@atelic-action/artifact 4.2.1 → 5.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -17
- package/package.json +4 -4
- package/src/documents/FirstCall.tsx +6 -6
- package/src/documents/Note.tsx +2 -2
- package/src/documents/Report.tsx +2 -2
- package/src/documents/{Read.tsx → TheRead.tsx} +251 -76
- package/src/documents/{Writeup.tsx → Topic.tsx} +202 -362
- package/src/documents/documents.css +87 -34
- package/src/documents/index.ts +28 -2
|
@@ -5,9 +5,7 @@ import {
|
|
|
5
5
|
newTabProps,
|
|
6
6
|
Runs,
|
|
7
7
|
Shot,
|
|
8
|
-
SignOff,
|
|
9
8
|
type WriteupGrade,
|
|
10
|
-
type WriteupPerson,
|
|
11
9
|
type WriteupShot,
|
|
12
10
|
} from "@atelic-action/ui/artifact";
|
|
13
11
|
import {
|
|
@@ -18,7 +16,6 @@ import {
|
|
|
18
16
|
type ColumnChartGroup,
|
|
19
17
|
DataTable,
|
|
20
18
|
Disclosure,
|
|
21
|
-
ReportCard,
|
|
22
19
|
StackedBar,
|
|
23
20
|
type StackedBarSegment,
|
|
24
21
|
type StatItem,
|
|
@@ -31,28 +28,25 @@ import {
|
|
|
31
28
|
import { ArrowUpRight, CircleAlert, CircleCheck, CircleX, Star } from "lucide-react";
|
|
32
29
|
import type { ReactNode } from "react";
|
|
33
30
|
|
|
34
|
-
export type { WriteupGrade, WriteupPerson, WriteupShot } from "@atelic-action/ui/artifact";
|
|
35
|
-
|
|
36
31
|
/**
|
|
37
|
-
* The
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
32
|
+
* The section card: the trade name linked to its definition, the one liner,
|
|
33
|
+
* the grade, the why line, the blocks, and the sources at the foot. The read
|
|
34
|
+
* renders every section through it, handing what the card itself does not
|
|
35
|
+
* know (today and after, what we recommend) as `extra`, set after the blocks
|
|
36
|
+
* and before the sources.
|
|
42
37
|
*
|
|
43
38
|
* Every block is a component from @atelic-action/ui (the checklist, the
|
|
44
39
|
* sortable table, the charts, the stat row, the callout, the disclosure);
|
|
45
|
-
* this file owns only the
|
|
46
|
-
*
|
|
47
|
-
* FOOTPRINT.md, and what it says follows the pitch surface rules
|
|
40
|
+
* this file owns only the card around them and the shapes a block's content
|
|
41
|
+
* is written in. What it says follows the pitch surface rules
|
|
48
42
|
* (Brand/voice.md): the trade's term linked to its definition, or plain
|
|
49
43
|
* words, and no term an owner would have to look up.
|
|
50
44
|
*/
|
|
51
45
|
|
|
52
46
|
/** How a mark, a bar segment, or a zone reads; the shared package's tones. */
|
|
53
|
-
export type
|
|
47
|
+
export type ReadTone = Tone;
|
|
54
48
|
|
|
55
|
-
export interface
|
|
49
|
+
export interface ReadBullet {
|
|
56
50
|
/** The point, in a full sentence. The icon sits beside it. */
|
|
57
51
|
lead: string;
|
|
58
52
|
/** The one explainer that says why it matters: a node, or a paragraph of runs when the content arrives as JSON. */
|
|
@@ -60,19 +54,19 @@ export interface WriteupBullet {
|
|
|
60
54
|
/** A win the owner already has: marked good. Everything else reads as needing work. */
|
|
61
55
|
win?: boolean;
|
|
62
56
|
/** Overrides the tone, e.g. "info" for something not yet read. */
|
|
63
|
-
tone?:
|
|
57
|
+
tone?: ReadTone;
|
|
64
58
|
/** Sets the finding apart on a tinted ground, for something missing or owed. */
|
|
65
59
|
box?: boolean;
|
|
66
60
|
}
|
|
67
61
|
|
|
68
62
|
/** A place a reading came from: the tool, opened in a new tab when it has an address. */
|
|
69
|
-
export interface
|
|
63
|
+
export interface ReadSource {
|
|
70
64
|
label: string;
|
|
71
65
|
href?: string;
|
|
72
66
|
}
|
|
73
67
|
|
|
74
68
|
/** A yes or no cell: the icon alone, with the text read to a screen reader. A figure a reader needs (a rank, a count) is a column of its own. */
|
|
75
|
-
export interface
|
|
69
|
+
export interface ReadMark {
|
|
76
70
|
mark: "good" | "warn" | "bad" | "none";
|
|
77
71
|
/** What the mark means, e.g. "Loads" or "Page Not Found"; always read to a screen reader. */
|
|
78
72
|
text: string;
|
|
@@ -83,14 +77,14 @@ export interface WriteupMark {
|
|
|
83
77
|
}
|
|
84
78
|
|
|
85
79
|
/** A cell whose display is not what it sorts by, e.g. "4.8" beside a star. */
|
|
86
|
-
export interface
|
|
80
|
+
export interface ReadSorted {
|
|
87
81
|
display: ReactNode;
|
|
88
82
|
sort: number | string;
|
|
89
83
|
}
|
|
90
84
|
|
|
91
|
-
export type
|
|
85
|
+
export type ReadCell = ReactNode | ReadMark | ReadSorted;
|
|
92
86
|
|
|
93
|
-
function isMark(cell:
|
|
87
|
+
function isMark(cell: ReadCell): cell is ReadMark {
|
|
94
88
|
return typeof cell === "object" && cell !== null && "mark" in cell;
|
|
95
89
|
}
|
|
96
90
|
|
|
@@ -101,13 +95,13 @@ interface BlockBase {
|
|
|
101
95
|
}
|
|
102
96
|
|
|
103
97
|
/** A short list of pass or fail checks, the basics a section rests on. */
|
|
104
|
-
export interface
|
|
98
|
+
export interface ReadChecks extends BlockBase {
|
|
105
99
|
kind: "checks";
|
|
106
100
|
items: ChecklistItem[];
|
|
107
101
|
}
|
|
108
102
|
|
|
109
103
|
/** A table: the first column names the row, the rest are short facts or marks. */
|
|
110
|
-
export interface
|
|
104
|
+
export interface ReadTable extends BlockBase {
|
|
111
105
|
kind: "table";
|
|
112
106
|
columns: Array<{
|
|
113
107
|
label: string;
|
|
@@ -119,19 +113,19 @@ export interface WriteupTable extends BlockBase {
|
|
|
119
113
|
/** The column hugs its content, for a swatch or a mark. */
|
|
120
114
|
fit?: boolean;
|
|
121
115
|
}>;
|
|
122
|
-
rows: Array<{ cells:
|
|
116
|
+
rows: Array<{ cells: ReadCell[]; tone?: ReadTone }>;
|
|
123
117
|
/** The sort the table opens in. */
|
|
124
118
|
sort?: { column: number; direction: "asc" | "desc" };
|
|
125
119
|
}
|
|
126
120
|
|
|
127
121
|
/** One stacked bar: the parts of a whole, with a key. */
|
|
128
|
-
export interface
|
|
122
|
+
export interface ReadBar extends BlockBase {
|
|
129
123
|
kind: "bar";
|
|
130
124
|
segments: StackedBarSegment[];
|
|
131
125
|
}
|
|
132
126
|
|
|
133
127
|
/** A scale with zones and one line per reading. */
|
|
134
|
-
export interface
|
|
128
|
+
export interface ReadScale extends BlockBase {
|
|
135
129
|
kind: "scale";
|
|
136
130
|
max: number;
|
|
137
131
|
zones: ThresholdZone[];
|
|
@@ -139,60 +133,139 @@ export interface WriteupScale extends BlockBase {
|
|
|
139
133
|
readings: ThresholdReading[];
|
|
140
134
|
}
|
|
141
135
|
|
|
136
|
+
/** One run of Google's phone test: the five figures its report leads with, as the API returns them. */
|
|
137
|
+
export interface ReadSpeedRun {
|
|
138
|
+
/** The performance score, 0 to 100. */
|
|
139
|
+
score: number;
|
|
140
|
+
/** First Contentful Paint, in milliseconds. */
|
|
141
|
+
fcpMs: number;
|
|
142
|
+
/** Largest Contentful Paint, in milliseconds. */
|
|
143
|
+
lcpMs: number;
|
|
144
|
+
/** Total Blocking Time, in milliseconds. */
|
|
145
|
+
tbtMs: number;
|
|
146
|
+
/** Cumulative Layout Shift, unitless. */
|
|
147
|
+
cls: number;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** A column of the speed table: its heading and the explainer behind its info icon. */
|
|
151
|
+
export interface ReadSpeedColumn {
|
|
152
|
+
label: string;
|
|
153
|
+
hint: string;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The words the speed block uses, for a document in another register. */
|
|
157
|
+
export interface ReadSpeedLabels {
|
|
158
|
+
/** What a screen reader hears before a run's grade letter. */
|
|
159
|
+
gradeWord: string;
|
|
160
|
+
score: ReadSpeedColumn;
|
|
161
|
+
firstPaint: ReadSpeedColumn;
|
|
162
|
+
mainContent: ReadSpeedColumn;
|
|
163
|
+
frozen: ReadSpeedColumn;
|
|
164
|
+
shift: ReadSpeedColumn;
|
|
165
|
+
/** The small callout over the table: why there are three runs. */
|
|
166
|
+
whyThreeTitle: string;
|
|
167
|
+
whyThree: string;
|
|
168
|
+
/** The words before the date the runs were made, e.g. "These ran on". */
|
|
169
|
+
ranOn: string;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export const readSpeedLabels: ReadSpeedLabels = {
|
|
173
|
+
gradeWord: "Grade",
|
|
174
|
+
score: {
|
|
175
|
+
label: "Score",
|
|
176
|
+
hint: "Google’s own mark for the page, out of 100, from the four measures beside it. We grade it the way a school grades: 90 is an A, 80 a B, 60 a C, and under 60 a D.",
|
|
177
|
+
},
|
|
178
|
+
firstPaint: {
|
|
179
|
+
label: "First paint",
|
|
180
|
+
hint: "The first moment anything appears on the screen. Google calls it First Contentful Paint and draws its line for good at 1.8 s.",
|
|
181
|
+
},
|
|
182
|
+
mainContent: {
|
|
183
|
+
label: "Main content",
|
|
184
|
+
hint: "When the biggest thing on the screen, usually the headline or the photo behind it, has arrived. Google calls it Largest Contentful Paint and draws its line at 2.5 s.",
|
|
185
|
+
},
|
|
186
|
+
frozen: {
|
|
187
|
+
label: "Frozen for",
|
|
188
|
+
hint: "How long the page ignores a tap or a scroll while its scripts run. Google calls it Total Blocking Time and draws its line at 0.2 s.",
|
|
189
|
+
},
|
|
190
|
+
shift: {
|
|
191
|
+
label: "Layout shift",
|
|
192
|
+
hint: "How much the page jumps around while it loads, so a button moves as a thumb reaches for it. Google calls it Cumulative Layout Shift and draws its line at 0.1.",
|
|
193
|
+
},
|
|
194
|
+
whyThreeTitle: "Why three runs.",
|
|
195
|
+
whyThree:
|
|
196
|
+
"A single run swings with Google’s own servers and the moment it ran, so we run the test three times a minute apart. The middle score is the reading; the best and the worst are the range.",
|
|
197
|
+
ranOn: "These ran on",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Google's phone test: three runs, the five figures each, listed best score
|
|
202
|
+
* first; a small callout over the table says the middle one is the reading. The letter
|
|
203
|
+
* beside each score and the row's tone follow the score, graded the way a
|
|
204
|
+
* school grades (FOOTPRINT.md). The figures are the snapshot's, never typed.
|
|
205
|
+
*/
|
|
206
|
+
export interface ReadSpeed extends BlockBase {
|
|
207
|
+
kind: "speed";
|
|
208
|
+
runs: ReadSpeedRun[];
|
|
209
|
+
/** The day the runs were made, ISO, for the callout under the table. */
|
|
210
|
+
ran?: string;
|
|
211
|
+
labels?: Partial<ReadSpeedLabels>;
|
|
212
|
+
}
|
|
213
|
+
|
|
142
214
|
/** A few numbers worth reading first, each with its period and its comparisons. */
|
|
143
|
-
export interface
|
|
215
|
+
export interface ReadStats extends BlockBase {
|
|
144
216
|
kind: "stats";
|
|
145
217
|
items: StatItem[];
|
|
146
218
|
}
|
|
147
219
|
|
|
148
220
|
/** A column chart: a bar a period, the periods in labeled groups. */
|
|
149
|
-
export interface
|
|
221
|
+
export interface ReadColumns extends BlockBase {
|
|
150
222
|
kind: "columns";
|
|
151
223
|
groups: ColumnChartGroup[];
|
|
152
|
-
legend?: Array<{ label: string; tone:
|
|
224
|
+
legend?: Array<{ label: string; tone: ReadTone }>;
|
|
153
225
|
}
|
|
154
226
|
|
|
155
227
|
/** Findings, each an icon beside its point with the explainer under it, and the owner's screen beside them. */
|
|
156
|
-
export interface
|
|
228
|
+
export interface ReadFindings {
|
|
157
229
|
kind: "findings";
|
|
158
|
-
items:
|
|
230
|
+
items: ReadBullet[];
|
|
159
231
|
shot?: WriteupShot;
|
|
160
232
|
shot2?: WriteupShot;
|
|
161
233
|
}
|
|
162
234
|
|
|
163
235
|
/** The owner's own screens, side by side at one size. */
|
|
164
|
-
export interface
|
|
236
|
+
export interface ReadShots {
|
|
165
237
|
kind: "shots";
|
|
166
238
|
title?: string;
|
|
167
239
|
shots: WriteupShot[];
|
|
168
240
|
}
|
|
169
241
|
|
|
170
242
|
/** Blocks that can sit inside a fold (a `more` block). */
|
|
171
|
-
export type
|
|
172
|
-
|
|
|
173
|
-
|
|
|
174
|
-
|
|
|
175
|
-
|
|
|
176
|
-
|
|
|
177
|
-
|
|
|
178
|
-
|
|
|
179
|
-
|
|
|
243
|
+
export type ReadInnerBlock =
|
|
244
|
+
| ReadChecks
|
|
245
|
+
| ReadTable
|
|
246
|
+
| ReadBar
|
|
247
|
+
| ReadScale
|
|
248
|
+
| ReadSpeed
|
|
249
|
+
| ReadFindings
|
|
250
|
+
| ReadStats
|
|
251
|
+
| ReadColumns
|
|
252
|
+
| ReadShots;
|
|
180
253
|
|
|
181
254
|
/**
|
|
182
255
|
* A fold, the native Disclosure: the label says what is inside and the
|
|
183
256
|
* summary says what it shows, so the detail needs no explaining once open.
|
|
184
257
|
*/
|
|
185
|
-
export interface
|
|
258
|
+
export interface ReadMore {
|
|
186
259
|
kind: "more";
|
|
187
260
|
label: string;
|
|
188
261
|
summary?: ReactNode;
|
|
189
|
-
blocks:
|
|
262
|
+
blocks: ReadInnerBlock[];
|
|
190
263
|
}
|
|
191
264
|
|
|
192
|
-
export type
|
|
265
|
+
export type ReadBlock = ReadInnerBlock | ReadMore;
|
|
193
266
|
|
|
194
|
-
|
|
195
|
-
|
|
267
|
+
/** The section card's own content: what the card knows how to draw on its own. */
|
|
268
|
+
export interface ReadTopic {
|
|
196
269
|
id: string;
|
|
197
270
|
/** The discipline's trade name, e.g. "Local SEO" or "Core Web Vitals". */
|
|
198
271
|
title: string;
|
|
@@ -204,126 +277,27 @@ export interface WriteupTopic {
|
|
|
204
277
|
oneLiner: string;
|
|
205
278
|
/** The section's grade, beside the heading. */
|
|
206
279
|
grade?: WriteupGrade;
|
|
207
|
-
/** Why it earned that letter: the
|
|
280
|
+
/** Why it earned that letter: the summary of what we saw, in a sentence or two. */
|
|
208
281
|
why?: ReactNode;
|
|
209
282
|
/** The word before the grade on the why line; defaults to "Why". */
|
|
210
283
|
whyLabel?: string;
|
|
211
284
|
/** What a screen reader hears before the grade's letter; defaults to "Grade". */
|
|
212
285
|
gradeWord?: string;
|
|
213
286
|
/** The section's blocks, in order. */
|
|
214
|
-
blocks?:
|
|
215
|
-
/** Findings without blocks: rendered as one findings block with the shots beside it. */
|
|
216
|
-
bullets?: WriteupBullet[];
|
|
217
|
-
shot?: WriteupShot;
|
|
218
|
-
shot2?: WriteupShot;
|
|
219
|
-
shotBrief?: string;
|
|
220
|
-
/** The topic carries no picture by design. */
|
|
221
|
-
noShot?: boolean;
|
|
287
|
+
blocks?: ReadBlock[];
|
|
222
288
|
/** The tools the readings came from, linked. */
|
|
223
|
-
sources?:
|
|
224
|
-
/** The label over the sources, e.g. "
|
|
289
|
+
sources?: ReadSource[];
|
|
290
|
+
/** The label over the sources, e.g. "See it yourself". */
|
|
225
291
|
sourcesLabel?: string;
|
|
226
292
|
}
|
|
227
293
|
|
|
228
|
-
export interface WriteupPart {
|
|
229
|
-
kind: "part";
|
|
230
|
-
/** The anchor a header stop points at, e.g. "health". */
|
|
231
|
-
id?: string;
|
|
232
|
-
/** The tier's name, e.g. "Health". */
|
|
233
|
-
title: string;
|
|
234
|
-
/** The question the tier answers, in the owner's words. */
|
|
235
|
-
lead: string;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
export interface WriteupSummaryRow {
|
|
239
|
-
/** The tier, e.g. "Health". */
|
|
240
|
-
tier: string;
|
|
241
|
-
/** The section's trade name, matching a topic title. */
|
|
242
|
-
section: string;
|
|
243
|
-
/** The topic's anchor, e.g. "#technical-seo"; the row links to it. */
|
|
244
|
-
href?: string;
|
|
245
|
-
grade: WriteupGrade;
|
|
246
|
-
/** The grade in the earlier snapshot, on a later writeup: what moved. */
|
|
247
|
-
before?: WriteupGrade;
|
|
248
|
-
/** The read behind the grade, in the owner's words. */
|
|
249
|
-
read: ReactNode;
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
/** The overview the writeup opens on: every section, its grade, and one plain read. */
|
|
253
|
-
export interface WriteupSummary {
|
|
254
|
-
kind: "summary";
|
|
255
|
-
id?: string;
|
|
256
|
-
title?: string;
|
|
257
|
-
rows: WriteupSummaryRow[];
|
|
258
|
-
/** What each letter means, in a few words each. */
|
|
259
|
-
legend?: Array<{ grade: WriteupGrade; label: string }>;
|
|
260
|
-
/** What a screen reader calls the earlier letter, e.g. "June 2026"; defaults to "Before". */
|
|
261
|
-
beforeLabel?: string;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
/** The paid facts line: what is rented, never graded (FOOTPRINT.md, Paid). */
|
|
265
|
-
export interface WriteupRented {
|
|
266
|
-
kind: "rented";
|
|
267
|
-
label?: string;
|
|
268
|
-
text: ReactNode;
|
|
269
|
-
}
|
|
270
|
-
|
|
271
|
-
export interface WriteupMarketRow {
|
|
272
|
-
market: string;
|
|
273
|
-
place?: string;
|
|
274
|
-
/** One grade per column, in column order; null draws a quiet dash for a column not read. */
|
|
275
|
-
grades: Array<WriteupGrade | null>;
|
|
276
|
-
read: ReactNode;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
/** The markets table: one row per market, one letter per column, the read beside it. */
|
|
280
|
-
export interface WriteupMarkets {
|
|
281
|
-
kind: "markets";
|
|
282
|
-
title: string;
|
|
283
|
-
lead?: string;
|
|
284
|
-
columns: string[];
|
|
285
|
-
rows: WriteupMarketRow[];
|
|
286
|
-
}
|
|
287
|
-
|
|
288
|
-
export type WriteupBlock =
|
|
289
|
-
| WriteupPart
|
|
290
|
-
| WriteupTopic
|
|
291
|
-
| WriteupSummary
|
|
292
|
-
| WriteupRented
|
|
293
|
-
| WriteupMarkets;
|
|
294
|
-
|
|
295
|
-
export interface WriteupSheet {
|
|
296
|
-
id: string;
|
|
297
|
-
blocks: WriteupBlock[];
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
export interface WriteupProps {
|
|
301
|
-
/** The sender's wordmark text, e.g. "atelic"; drawn with the orange run. Print only. */
|
|
302
|
-
sender: { name: string; href?: string };
|
|
303
|
-
/** The recipient's logo, lockup right of the sender. Print only. */
|
|
304
|
-
recipient: { logoSrc: string; logoAlt: string };
|
|
305
|
-
preparedFor: string;
|
|
306
|
-
date: string;
|
|
307
|
-
title: string;
|
|
308
|
-
/** One or two sentences under the title: what this is and where it was read from. */
|
|
309
|
-
intro?: ReactNode;
|
|
310
|
-
/** Groups of blocks. They run together on screen and in print. */
|
|
311
|
-
sheets: WriteupSheet[];
|
|
312
|
-
close: {
|
|
313
|
-
signOff: string;
|
|
314
|
-
person: WriteupPerson;
|
|
315
|
-
/** The pointer to the hosted page, on the printed copy only. */
|
|
316
|
-
twin?: { line: string; domain: string; qrSrc: string };
|
|
317
|
-
};
|
|
318
|
-
}
|
|
319
|
-
|
|
320
294
|
const toneIcon = {
|
|
321
295
|
good: CircleCheck,
|
|
322
296
|
bad: CircleX,
|
|
323
297
|
warn: CircleAlert,
|
|
324
298
|
} as const;
|
|
325
299
|
|
|
326
|
-
function Mark({ cell }: { cell:
|
|
300
|
+
function Mark({ cell }: { cell: ReadMark }) {
|
|
327
301
|
if (cell.mark === "none") {
|
|
328
302
|
return (
|
|
329
303
|
<span className="op-mark-cell" data-tone="muted">
|
|
@@ -343,7 +317,7 @@ function Mark({ cell }: { cell: WriteupMark }) {
|
|
|
343
317
|
}
|
|
344
318
|
|
|
345
319
|
/** A star rating as a table cell: the figure beside a star, sorted by the figure. */
|
|
346
|
-
export function ratingCell(rating: number, tone?:
|
|
320
|
+
export function ratingCell(rating: number, tone?: ReadTone): ReadSorted {
|
|
347
321
|
return {
|
|
348
322
|
sort: rating,
|
|
349
323
|
display: (
|
|
@@ -381,7 +355,7 @@ function Block({
|
|
|
381
355
|
);
|
|
382
356
|
}
|
|
383
357
|
|
|
384
|
-
function Table({ block }: { block:
|
|
358
|
+
function Table({ block }: { block: ReadTable }) {
|
|
385
359
|
const rows = block.rows.map((row) => ({
|
|
386
360
|
tone: row.tone,
|
|
387
361
|
cells: row.cells.map((cell) =>
|
|
@@ -397,8 +371,70 @@ function Table({ block }: { block: WriteupTable }) {
|
|
|
397
371
|
);
|
|
398
372
|
}
|
|
399
373
|
|
|
374
|
+
/** The letter for a score on a 100 point scale, graded the way a school grades. */
|
|
375
|
+
function scoreGrade(score: number): WriteupGrade {
|
|
376
|
+
if (score >= 90) return "a";
|
|
377
|
+
if (score >= 80) return "b";
|
|
378
|
+
if (score >= 60) return "c";
|
|
379
|
+
return "d";
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const gradeTone: Record<WriteupGrade, ReadTone> = { a: "good", b: "info", c: "warn", d: "bad" };
|
|
383
|
+
|
|
384
|
+
const seconds = (ms: number) => `${(ms / 1000).toFixed(1)} s`;
|
|
385
|
+
const shift = (cls: number) => (cls < 0.005 ? "0" : cls.toFixed(2));
|
|
386
|
+
|
|
387
|
+
function Speed({ block }: { block: ReadSpeed }) {
|
|
388
|
+
const labels = { ...readSpeedLabels, ...block.labels };
|
|
389
|
+
const sorted = [...block.runs].sort((a, b) => b.score - a.score);
|
|
390
|
+
const rows = sorted.map((run) => {
|
|
391
|
+
const grade = scoreGrade(run.score);
|
|
392
|
+
return {
|
|
393
|
+
tone: gradeTone[grade],
|
|
394
|
+
cells: [
|
|
395
|
+
<span className="op-speed-score" key="score">
|
|
396
|
+
<span className="op-sr-only">{`${labels.gradeWord} ${gradeLabel[grade]}`}</span>
|
|
397
|
+
<span aria-hidden="true">
|
|
398
|
+
<GradeChip grade={grade} />
|
|
399
|
+
</span>
|
|
400
|
+
{run.score}
|
|
401
|
+
</span>,
|
|
402
|
+
seconds(run.fcpMs),
|
|
403
|
+
seconds(run.lcpMs),
|
|
404
|
+
seconds(run.tbtMs),
|
|
405
|
+
shift(run.cls),
|
|
406
|
+
],
|
|
407
|
+
};
|
|
408
|
+
});
|
|
409
|
+
const column = (c: ReadSpeedColumn) => ({
|
|
410
|
+
label: c.label,
|
|
411
|
+
hint: c.hint,
|
|
412
|
+
align: "right" as const,
|
|
413
|
+
});
|
|
414
|
+
return (
|
|
415
|
+
<>
|
|
416
|
+
<Callout tone="muted" title={labels.whyThreeTitle} className="op-speed-why">
|
|
417
|
+
{labels.whyThree}
|
|
418
|
+
{block.ran && ` ${labels.ranOn} ${block.ran}.`}
|
|
419
|
+
</Callout>
|
|
420
|
+
<Block className="op-table op-speed" block={block}>
|
|
421
|
+
<DataTable
|
|
422
|
+
columns={[
|
|
423
|
+
column(labels.score),
|
|
424
|
+
column(labels.firstPaint),
|
|
425
|
+
column(labels.mainContent),
|
|
426
|
+
column(labels.frozen),
|
|
427
|
+
column(labels.shift),
|
|
428
|
+
]}
|
|
429
|
+
rows={rows}
|
|
430
|
+
/>
|
|
431
|
+
</Block>
|
|
432
|
+
</>
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
400
436
|
/** A string or runs of text and links, the shape JSON content arrives in; a node of elements is anything else. */
|
|
401
|
-
function isRuns(value: unknown): value is ArtifactParagraph {
|
|
437
|
+
export function isRuns(value: unknown): value is ArtifactParagraph {
|
|
402
438
|
if (typeof value === "string") return true;
|
|
403
439
|
return (
|
|
404
440
|
Array.isArray(value) &&
|
|
@@ -416,8 +452,8 @@ function bodyNode(body: ReactNode | ArtifactParagraph | undefined): ReactNode {
|
|
|
416
452
|
return isRuns(body) ? <Runs paragraph={body} /> : body;
|
|
417
453
|
}
|
|
418
454
|
|
|
419
|
-
function Findings({ block
|
|
420
|
-
const hasShots = Boolean(block.shot
|
|
455
|
+
function Findings({ block }: { block: ReadFindings }) {
|
|
456
|
+
const hasShots = Boolean(block.shot);
|
|
421
457
|
return (
|
|
422
458
|
<div className={`op-block op-findings${hasShots ? " has-shots" : ""}`}>
|
|
423
459
|
<div className="op-findings-list">
|
|
@@ -435,25 +471,17 @@ function Findings({ block, brief }: { block: WriteupFindings; brief?: string })
|
|
|
435
471
|
);
|
|
436
472
|
})}
|
|
437
473
|
</div>
|
|
438
|
-
{
|
|
474
|
+
{block.shot && (
|
|
439
475
|
<div className="op-shots">
|
|
440
|
-
{block.shot
|
|
441
|
-
|
|
442
|
-
<Shot shot={block.shot} />
|
|
443
|
-
{block.shot2 && <Shot shot={block.shot2} />}
|
|
444
|
-
</>
|
|
445
|
-
) : (
|
|
446
|
-
<figure className="op-shot">
|
|
447
|
-
<span className="op-shot-brief">{brief}</span>
|
|
448
|
-
</figure>
|
|
449
|
-
)}
|
|
476
|
+
<Shot shot={block.shot} />
|
|
477
|
+
{block.shot2 && <Shot shot={block.shot2} />}
|
|
450
478
|
</div>
|
|
451
479
|
)}
|
|
452
480
|
</div>
|
|
453
481
|
);
|
|
454
482
|
}
|
|
455
483
|
|
|
456
|
-
function Shots({ block }: { block:
|
|
484
|
+
function Shots({ block }: { block: ReadShots }) {
|
|
457
485
|
return (
|
|
458
486
|
<Block className="op-shots-block" block={block}>
|
|
459
487
|
<div className="op-shots-row" data-count={Math.min(block.shots.length, 3)}>
|
|
@@ -465,7 +493,7 @@ function Shots({ block }: { block: WriteupShots }) {
|
|
|
465
493
|
);
|
|
466
494
|
}
|
|
467
495
|
|
|
468
|
-
function More({ block }: { block:
|
|
496
|
+
function More({ block }: { block: ReadMore }) {
|
|
469
497
|
return (
|
|
470
498
|
<Disclosure className="op-block op-more" label={block.label} summary={block.summary}>
|
|
471
499
|
<div className="op-more-body">
|
|
@@ -478,7 +506,7 @@ function More({ block }: { block: WriteupMore }) {
|
|
|
478
506
|
);
|
|
479
507
|
}
|
|
480
508
|
|
|
481
|
-
function TopicBlock({ block }: { block:
|
|
509
|
+
function TopicBlock({ block }: { block: ReadBlock }) {
|
|
482
510
|
switch (block.kind) {
|
|
483
511
|
case "checks":
|
|
484
512
|
return (
|
|
@@ -488,6 +516,8 @@ function TopicBlock({ block }: { block: WriteupTopicBlock }) {
|
|
|
488
516
|
);
|
|
489
517
|
case "table":
|
|
490
518
|
return <Table block={block} />;
|
|
519
|
+
case "speed":
|
|
520
|
+
return <Speed block={block} />;
|
|
491
521
|
case "bar":
|
|
492
522
|
return (
|
|
493
523
|
<Block className="op-bar" block={block}>
|
|
@@ -527,17 +557,10 @@ function TopicBlock({ block }: { block: WriteupTopicBlock }) {
|
|
|
527
557
|
}
|
|
528
558
|
|
|
529
559
|
/**
|
|
530
|
-
* The section card
|
|
531
|
-
*
|
|
532
|
-
* renders its sections through it too, handing anything of its own (today and
|
|
533
|
-
* after, what we recommend) as `extra`, set after the blocks and before the
|
|
534
|
-
* sources, so the two documents share one layout.
|
|
560
|
+
* The section card. `extra` is set after the blocks and before the sources,
|
|
561
|
+
* so a document's own beats sit inside the card with the rest.
|
|
535
562
|
*/
|
|
536
|
-
export function Topic({ topic, extra }: { topic:
|
|
537
|
-
const legacy: WriteupFindings | null = topic.bullets
|
|
538
|
-
? { kind: "findings", items: topic.bullets, shot: topic.shot, shot2: topic.shot2 }
|
|
539
|
-
: null;
|
|
540
|
-
const brief = topic.noShot || topic.shot ? undefined : (topic.shotBrief ?? "Screenshot");
|
|
563
|
+
export function Topic({ topic, extra }: { topic: ReadTopic; extra?: ReactNode }) {
|
|
541
564
|
return (
|
|
542
565
|
<section className="op-topic" id={topic.id}>
|
|
543
566
|
<header className="op-topic-head">
|
|
@@ -582,7 +605,6 @@ export function Topic({ topic, extra }: { topic: WriteupTopic; extra?: ReactNode
|
|
|
582
605
|
// biome-ignore lint/suspicious/noArrayIndexKey: blocks are static content in a fixed order
|
|
583
606
|
<TopicBlock block={block} key={i} />
|
|
584
607
|
))}
|
|
585
|
-
{legacy && <Findings block={legacy} brief={brief} />}
|
|
586
608
|
{extra}
|
|
587
609
|
</div>
|
|
588
610
|
{topic.sources && topic.sources.length > 0 && (
|
|
@@ -608,185 +630,3 @@ export function Topic({ topic, extra }: { topic: WriteupTopic; extra?: ReactNode
|
|
|
608
630
|
</section>
|
|
609
631
|
);
|
|
610
632
|
}
|
|
611
|
-
|
|
612
|
-
function Summary({ summary }: { summary: WriteupSummary }) {
|
|
613
|
-
const groups: Array<{ title: string; rows: WriteupSummaryRow[] }> = [];
|
|
614
|
-
for (const row of summary.rows) {
|
|
615
|
-
const last = groups[groups.length - 1];
|
|
616
|
-
if (last && last.title === row.tier) last.rows.push(row);
|
|
617
|
-
else groups.push({ title: row.tier, rows: [row] });
|
|
618
|
-
}
|
|
619
|
-
return (
|
|
620
|
-
<ReportCard
|
|
621
|
-
className="op-ov"
|
|
622
|
-
id={summary.id}
|
|
623
|
-
title={summary.title}
|
|
624
|
-
legend={summary.legend}
|
|
625
|
-
labels={{ before: summary.beforeLabel }}
|
|
626
|
-
groups={groups.map((group) => ({
|
|
627
|
-
title: group.title,
|
|
628
|
-
rows: group.rows.map((row) => ({
|
|
629
|
-
name: row.section,
|
|
630
|
-
href: row.href,
|
|
631
|
-
grade: row.grade,
|
|
632
|
-
before: row.before,
|
|
633
|
-
line: row.read,
|
|
634
|
-
})),
|
|
635
|
-
}))}
|
|
636
|
-
/>
|
|
637
|
-
);
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
function Markets({ markets }: { markets: WriteupMarkets }) {
|
|
641
|
-
return (
|
|
642
|
-
<section className="op-topic op-markets">
|
|
643
|
-
<header className="op-topic-head">
|
|
644
|
-
<div>
|
|
645
|
-
<h3>{markets.title}</h3>
|
|
646
|
-
{markets.lead && <p className="op-def">{markets.lead}</p>}
|
|
647
|
-
</div>
|
|
648
|
-
</header>
|
|
649
|
-
<table className="op-markets-table">
|
|
650
|
-
<thead>
|
|
651
|
-
<tr>
|
|
652
|
-
<th scope="col" className="op-markets-market">
|
|
653
|
-
<span className="op-sr-only">Market</span>
|
|
654
|
-
</th>
|
|
655
|
-
{markets.columns.map((column) => (
|
|
656
|
-
<th scope="col" className="op-markets-col" key={column}>
|
|
657
|
-
{column}
|
|
658
|
-
</th>
|
|
659
|
-
))}
|
|
660
|
-
<th scope="col" className="op-markets-read">
|
|
661
|
-
<span className="op-sr-only">Read</span>
|
|
662
|
-
</th>
|
|
663
|
-
</tr>
|
|
664
|
-
</thead>
|
|
665
|
-
<tbody>
|
|
666
|
-
{markets.rows.map((row) => (
|
|
667
|
-
<tr key={row.market}>
|
|
668
|
-
<th scope="row" className="op-markets-market">
|
|
669
|
-
{row.market}
|
|
670
|
-
{row.place && <span className="op-markets-place">{row.place}</span>}
|
|
671
|
-
</th>
|
|
672
|
-
{markets.columns.map((column, i) => {
|
|
673
|
-
const grade = row.grades[i] ?? null;
|
|
674
|
-
return (
|
|
675
|
-
<td className="op-markets-grade" key={column}>
|
|
676
|
-
<span className="op-markets-label" aria-hidden="true">
|
|
677
|
-
{column}
|
|
678
|
-
</span>
|
|
679
|
-
{grade ? (
|
|
680
|
-
<>
|
|
681
|
-
<span className="op-sr-only">{`${column}, grade ${gradeLabel[grade]}`}</span>
|
|
682
|
-
<span aria-hidden="true">
|
|
683
|
-
<GradeChip grade={grade} />
|
|
684
|
-
</span>
|
|
685
|
-
</>
|
|
686
|
-
) : (
|
|
687
|
-
<>
|
|
688
|
-
<span className="op-sr-only">{`${column}, not read`}</span>
|
|
689
|
-
<span className="op-dash" aria-hidden="true" />
|
|
690
|
-
</>
|
|
691
|
-
)}
|
|
692
|
-
</td>
|
|
693
|
-
);
|
|
694
|
-
})}
|
|
695
|
-
<td className="op-markets-read">{row.read}</td>
|
|
696
|
-
</tr>
|
|
697
|
-
))}
|
|
698
|
-
</tbody>
|
|
699
|
-
</table>
|
|
700
|
-
</section>
|
|
701
|
-
);
|
|
702
|
-
}
|
|
703
|
-
|
|
704
|
-
function Rented({ rented }: { rented: WriteupRented }) {
|
|
705
|
-
return (
|
|
706
|
-
<p className="op-rented">
|
|
707
|
-
<span className="op-rented-label">{rented.label ?? "Rented"}</span>
|
|
708
|
-
<span className="op-rented-text">{rented.text}</span>
|
|
709
|
-
</p>
|
|
710
|
-
);
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
function Part({ part }: { part: WriteupPart }) {
|
|
714
|
-
return (
|
|
715
|
-
<div className="op-part" id={part.id}>
|
|
716
|
-
<h2>{part.title}</h2>
|
|
717
|
-
<p>{part.lead}</p>
|
|
718
|
-
</div>
|
|
719
|
-
);
|
|
720
|
-
}
|
|
721
|
-
|
|
722
|
-
export function Writeup({
|
|
723
|
-
sender,
|
|
724
|
-
recipient,
|
|
725
|
-
preparedFor,
|
|
726
|
-
date,
|
|
727
|
-
title,
|
|
728
|
-
intro,
|
|
729
|
-
sheets,
|
|
730
|
-
close,
|
|
731
|
-
}: WriteupProps) {
|
|
732
|
-
return (
|
|
733
|
-
<div className="op">
|
|
734
|
-
<div className="op-doc">
|
|
735
|
-
<header className="op-head">
|
|
736
|
-
<div className="op-top">
|
|
737
|
-
<span className="op-mark">
|
|
738
|
-
{sender.href ? <a href={sender.href}>{sender.name}</a> : sender.name}
|
|
739
|
-
<span className="op-run" aria-hidden="true" />
|
|
740
|
-
</span>
|
|
741
|
-
<span className="op-x" aria-hidden="true">
|
|
742
|
-
×
|
|
743
|
-
</span>
|
|
744
|
-
<img className="op-recipient" src={recipient.logoSrc} alt={recipient.logoAlt} />
|
|
745
|
-
</div>
|
|
746
|
-
<p className="op-meta">
|
|
747
|
-
Prepared for <b>{preparedFor}</b>
|
|
748
|
-
<span className="op-meta-sep" aria-hidden="true">
|
|
749
|
-
{" · "}
|
|
750
|
-
</span>
|
|
751
|
-
<span className="op-meta-date">{date}</span>
|
|
752
|
-
</p>
|
|
753
|
-
<h1>{title}</h1>
|
|
754
|
-
{intro && <p className="op-intro">{intro}</p>}
|
|
755
|
-
</header>
|
|
756
|
-
{sheets.map((sheet) => (
|
|
757
|
-
<div className="op-sheet" id={sheet.id} key={sheet.id}>
|
|
758
|
-
{sheet.blocks.map((block) =>
|
|
759
|
-
block.kind === "part" ? (
|
|
760
|
-
<Part key={block.title} part={block} />
|
|
761
|
-
) : block.kind === "summary" ? (
|
|
762
|
-
<Summary key="summary" summary={block} />
|
|
763
|
-
) : block.kind === "rented" ? (
|
|
764
|
-
<Rented key="rented" rented={block} />
|
|
765
|
-
) : block.kind === "markets" ? (
|
|
766
|
-
<Markets key={`markets-${block.title}`} markets={block} />
|
|
767
|
-
) : (
|
|
768
|
-
<Topic key={block.id} topic={block} />
|
|
769
|
-
),
|
|
770
|
-
)}
|
|
771
|
-
</div>
|
|
772
|
-
))}
|
|
773
|
-
<footer className="op-close">
|
|
774
|
-
<SignOff signOff={close.signOff} person={close.person} />
|
|
775
|
-
{close.twin && (
|
|
776
|
-
<div className="op-twin">
|
|
777
|
-
<div className="op-twin-text">
|
|
778
|
-
{close.twin.line}
|
|
779
|
-
<b>{close.twin.domain}</b>
|
|
780
|
-
</div>
|
|
781
|
-
<img
|
|
782
|
-
className="op-qr"
|
|
783
|
-
src={close.twin.qrSrc}
|
|
784
|
-
alt={`QR code for ${close.twin.domain}`}
|
|
785
|
-
/>
|
|
786
|
-
</div>
|
|
787
|
-
)}
|
|
788
|
-
</footer>
|
|
789
|
-
</div>
|
|
790
|
-
</div>
|
|
791
|
-
);
|
|
792
|
-
}
|