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