@atelic-action/artifact 5.0.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/artifact",
3
- "version": "5.0.0",
3
+ "version": "5.1.0",
4
4
  "description": "The composed documents the Atelic practice hands or sends a business (the read, the monthly page, the note, the first call sheet, 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": "^3.4.0",
40
+ "@atelic-action/ui": "^3.5.0",
41
41
  "lucide-react": "^1.23.0",
42
42
  "react": "^19.0.0"
43
43
  },
44
44
  "devDependencies": {
45
- "@atelic-action/ui": "^3.4.0",
45
+ "@atelic-action/ui": "^3.5.0",
46
46
  "@biomejs/biome": "^2.0.0",
47
47
  "@playwright/test": "^1.60.0",
48
48
  "@sentry/react": "^10.0.0",
@@ -133,6 +133,84 @@ export interface ReadScale extends BlockBase {
133
133
  readings: ThresholdReading[];
134
134
  }
135
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
+
136
214
  /** A few numbers worth reading first, each with its period and its comparisons. */
137
215
  export interface ReadStats extends BlockBase {
138
216
  kind: "stats";
@@ -167,6 +245,7 @@ export type ReadInnerBlock =
167
245
  | ReadTable
168
246
  | ReadBar
169
247
  | ReadScale
248
+ | ReadSpeed
170
249
  | ReadFindings
171
250
  | ReadStats
172
251
  | ReadColumns
@@ -292,6 +371,68 @@ function Table({ block }: { block: ReadTable }) {
292
371
  );
293
372
  }
294
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
+
295
436
  /** A string or runs of text and links, the shape JSON content arrives in; a node of elements is anything else. */
296
437
  export function isRuns(value: unknown): value is ArtifactParagraph {
297
438
  if (typeof value === "string") return true;
@@ -375,6 +516,8 @@ function TopicBlock({ block }: { block: ReadBlock }) {
375
516
  );
376
517
  case "table":
377
518
  return <Table block={block} />;
519
+ case "speed":
520
+ return <Speed block={block} />;
378
521
  case "bar":
379
522
  return (
380
523
  <Block className="op-bar" block={block}>
@@ -1118,6 +1118,58 @@
1118
1118
  .atelic-ui .op-table td:has(.op-wrap) {
1119
1119
  white-space: normal;
1120
1120
  }
1121
+ /* the phone test: a small grade chip before each run's score, at the
1122
+ column's left edge on a wide screen */
1123
+ .atelic-ui .op-speed .op-grade {
1124
+ display: inline-grid;
1125
+ place-items: center;
1126
+ width: 22px;
1127
+ height: 22px;
1128
+ margin: 0 8px 0 0;
1129
+ padding: 0;
1130
+ font-size: 12px;
1131
+ }
1132
+ .atelic-ui .op-speed-score {
1133
+ display: inline-flex;
1134
+ align-items: center;
1135
+ font-variant-numeric: tabular-nums;
1136
+ }
1137
+ .atelic-ui .op-speed tbody th {
1138
+ white-space: nowrap;
1139
+ font-weight: 600;
1140
+ }
1141
+ @media print, (min-width: 721px) {
1142
+ .atelic-ui .op-speed-score {
1143
+ display: flex;
1144
+ justify-content: space-between;
1145
+ width: 100%;
1146
+ }
1147
+ }
1148
+ /* the why over the table: one small line, the title run into the body */
1149
+ .atelic-ui .op-speed-why {
1150
+ margin-top: 12px;
1151
+ font-size: 13px;
1152
+ line-height: 1.45;
1153
+ color: var(--op-ink-soft);
1154
+ }
1155
+ .atelic-ui .op-speed-why .callout-icon {
1156
+ width: 14px;
1157
+ height: 14px;
1158
+ margin-top: 2px;
1159
+ }
1160
+ .atelic-ui .op-speed-why .callout-title,
1161
+ .atelic-ui .op-speed-why .callout-body {
1162
+ display: inline;
1163
+ font-size: inherit;
1164
+ line-height: inherit;
1165
+ }
1166
+ .atelic-ui .op-speed-why .callout-title {
1167
+ font-weight: 600;
1168
+ color: var(--op-ink);
1169
+ }
1170
+ .atelic-ui .op-speed-why .callout-title::after {
1171
+ content: " ";
1172
+ }
1121
1173
  .atelic-ui .op-mark-cell {
1122
1174
  display: inline-flex;
1123
1175
  align-items: center;
@@ -25,10 +25,14 @@ export {
25
25
  type ReadShots,
26
26
  type ReadSorted,
27
27
  type ReadSource,
28
+ type ReadSpeed,
29
+ type ReadSpeedLabels,
30
+ type ReadSpeedRun,
28
31
  type ReadStats,
29
32
  type ReadTable,
30
33
  type ReadTone,
31
34
  type ReadTopic,
32
35
  ratingCell,
36
+ readSpeedLabels,
33
37
  Topic,
34
38
  } from "./Topic";