@atelic-action/ui 1.0.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.
@@ -1,767 +0,0 @@
1
- import { ArrowUpRight, ChevronRight, CircleAlert, CircleCheck, CircleX, Star } from "lucide-react";
2
- import type { ReactNode } from "react";
3
- import {
4
- Callout,
5
- Checklist,
6
- type ChecklistItem,
7
- ColumnChart,
8
- type ColumnChartGroup,
9
- DataTable,
10
- Disclosure,
11
- StackedBar,
12
- type StackedBarSegment,
13
- type StatItem,
14
- StatRow,
15
- type ThresholdReading,
16
- ThresholdScale,
17
- type ThresholdZone,
18
- type Tone,
19
- } from "../components";
20
- import { newTabProps } from "../lib/newTabProps";
21
- import { GradeChip, gradeLabel, type WriteupGrade } from "./GradeChip";
22
- import { Shot, type WriteupShot } from "./Shot";
23
- import { SignOff, type WriteupPerson } from "./SignOff";
24
-
25
- export type { WriteupGrade } from "./GradeChip";
26
- export type { WriteupShot } from "./Shot";
27
- export type { WriteupPerson } from "./SignOff";
28
-
29
- /**
30
- * The writeup: the graded document an owner gets. It is a web page first: an
31
- * overview that links into every section, then the three tiers, each section
32
- * a card of small blocks and a line of sources that open the tools the
33
- * reading came from. Print is the same page with the chrome dropped and the
34
- * type brought down, flowing across letter pages; nothing is clipped.
35
- *
36
- * Every block is a component from @atelic-action/ui (the checklist, the
37
- * sortable table, the charts, the stat row, the callout, the disclosure);
38
- * this file owns only the document: the overview, the tiers, the section
39
- * card, the markets table, and the close. Its skeleton is the practice's
40
- * FOOTPRINT.md, and what it says follows the pitch surface rules
41
- * (Brand/voice.md): the trade's term linked to its definition, or plain
42
- * words, and no term an owner would have to look up.
43
- */
44
-
45
- /** How a mark, a bar segment, or a zone reads; the shared package's tones. */
46
- export type WriteupTone = Tone;
47
-
48
- export interface WriteupBullet {
49
- /** The point, in a full sentence. The icon sits beside it. */
50
- lead: string;
51
- /** The one explainer that says why it matters. */
52
- body?: ReactNode;
53
- /** A win the owner already has: marked good. Everything else reads as needing work. */
54
- win?: boolean;
55
- /** Overrides the tone, e.g. "info" for something not yet read. */
56
- tone?: WriteupTone;
57
- /** Sets the finding apart on a tinted ground, for something missing or owed. */
58
- box?: boolean;
59
- }
60
-
61
- /** A place a reading came from: the tool, opened in a new tab when it has an address. */
62
- export interface WriteupSource {
63
- label: string;
64
- href?: string;
65
- }
66
-
67
- /** 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. */
68
- export interface WriteupMark {
69
- mark: "good" | "warn" | "bad" | "none";
70
- /** What the mark means, e.g. "Loads" or "Page Not Found"; always read to a screen reader. */
71
- text: string;
72
- /** Show the text beside the icon. The rare exception: a mark is the icon alone unless this is true. */
73
- show?: boolean;
74
- /** What the cell sorts by; without it a mark sorts good, warning, bad, none. */
75
- sort?: number | string;
76
- }
77
-
78
- /** A cell whose display is not what it sorts by, e.g. "4.8" beside a star. */
79
- export interface WriteupSorted {
80
- display: ReactNode;
81
- sort: number | string;
82
- }
83
-
84
- export type WriteupCell = ReactNode | WriteupMark | WriteupSorted;
85
-
86
- function isMark(cell: WriteupCell): cell is WriteupMark {
87
- return typeof cell === "object" && cell !== null && "mark" in cell;
88
- }
89
-
90
- interface BlockBase {
91
- title?: string;
92
- lead?: ReactNode;
93
- note?: ReactNode;
94
- }
95
-
96
- /** A short list of pass or fail checks, the basics a section rests on. */
97
- export interface WriteupChecks extends BlockBase {
98
- kind: "checks";
99
- items: ChecklistItem[];
100
- }
101
-
102
- /** A table: the first column names the row, the rest are short facts or marks. */
103
- export interface WriteupTable extends BlockBase {
104
- kind: "table";
105
- columns: Array<{
106
- label: string;
107
- align?: "left" | "center" | "right";
108
- /** Lets the reader sort by this column. */
109
- sortable?: boolean;
110
- /** The direction a first click sorts in. */
111
- firstSort?: "asc" | "desc";
112
- }>;
113
- rows: Array<{ cells: WriteupCell[]; tone?: WriteupTone }>;
114
- /** The sort the table opens in. */
115
- sort?: { column: number; direction: "asc" | "desc" };
116
- }
117
-
118
- /** One stacked bar: the parts of a whole, with a key. */
119
- export interface WriteupBar extends BlockBase {
120
- kind: "bar";
121
- segments: StackedBarSegment[];
122
- }
123
-
124
- /** A scale with zones and one line per reading. */
125
- export interface WriteupScale extends BlockBase {
126
- kind: "scale";
127
- max: number;
128
- zones: ThresholdZone[];
129
- ticks: Array<{ at: number; label: string }>;
130
- readings: ThresholdReading[];
131
- }
132
-
133
- /** A few numbers worth reading first, each with its period and its comparisons. */
134
- export interface WriteupStats extends BlockBase {
135
- kind: "stats";
136
- items: StatItem[];
137
- }
138
-
139
- /** A column chart: a bar a period, the periods in labeled groups. */
140
- export interface WriteupColumns extends BlockBase {
141
- kind: "columns";
142
- groups: ColumnChartGroup[];
143
- legend?: Array<{ label: string; tone: WriteupTone }>;
144
- }
145
-
146
- /** Findings, each an icon beside its point with the explainer under it, and the owner's screen beside them. */
147
- export interface WriteupFindings {
148
- kind: "findings";
149
- items: WriteupBullet[];
150
- shot?: WriteupShot;
151
- shot2?: WriteupShot;
152
- }
153
-
154
- /** The owner's own screens, side by side at one size. */
155
- export interface WriteupShots {
156
- kind: "shots";
157
- title?: string;
158
- shots: WriteupShot[];
159
- }
160
-
161
- /** Blocks that can sit inside a fold (a `more` block). */
162
- export type WriteupInnerBlock =
163
- | WriteupChecks
164
- | WriteupTable
165
- | WriteupBar
166
- | WriteupScale
167
- | WriteupFindings
168
- | WriteupStats
169
- | WriteupColumns
170
- | WriteupShots;
171
-
172
- /**
173
- * A fold, the native Disclosure: the label says what is inside and the
174
- * summary says what it shows, so the detail needs no explaining once open.
175
- */
176
- export interface WriteupMore {
177
- kind: "more";
178
- label: string;
179
- summary?: ReactNode;
180
- blocks: WriteupInnerBlock[];
181
- }
182
-
183
- export type WriteupTopicBlock = WriteupInnerBlock | WriteupMore;
184
-
185
- export interface WriteupTopic {
186
- kind: "topic";
187
- id: string;
188
- /** The discipline's trade name, e.g. "Local SEO" or "Core Web Vitals". */
189
- title: string;
190
- /** Where the name is defined, e.g. the practice's glossary; the title links there. */
191
- href?: string;
192
- /** What a screen reader calls the title's link icon; defaults to "Opens in a new tab". */
193
- hrefLabel?: string;
194
- /** One line in the owner's words: what this is, as a person would say it. */
195
- oneLiner: string;
196
- /** The section's grade, beside the heading. */
197
- grade?: WriteupGrade;
198
- /** Why it earned that letter: the rule it met, in a sentence. */
199
- why?: ReactNode;
200
- /** The section's blocks, in order. */
201
- blocks?: WriteupTopicBlock[];
202
- /** Findings without blocks: rendered as one findings block with the shots beside it. */
203
- bullets?: WriteupBullet[];
204
- shot?: WriteupShot;
205
- shot2?: WriteupShot;
206
- shotBrief?: string;
207
- /** The topic carries no picture by design. */
208
- noShot?: boolean;
209
- /** The tools the readings came from, linked. */
210
- sources?: WriteupSource[];
211
- /** The label over the sources, e.g. "Where this comes from". */
212
- sourcesLabel?: string;
213
- }
214
-
215
- export interface WriteupPart {
216
- kind: "part";
217
- /** The anchor a header stop points at, e.g. "health". */
218
- id?: string;
219
- /** The tier's name, e.g. "Health". */
220
- title: string;
221
- /** The question the tier answers, in the owner's words. */
222
- lead: string;
223
- }
224
-
225
- export interface WriteupSummaryRow {
226
- /** The tier, e.g. "Health". */
227
- tier: string;
228
- /** The section's trade name, matching a topic title. */
229
- section: string;
230
- /** The topic's anchor, e.g. "#technical-seo"; the row links to it. */
231
- href?: string;
232
- grade: WriteupGrade;
233
- /** The grade in the earlier snapshot, on a later writeup: what moved. */
234
- before?: WriteupGrade;
235
- /** The read behind the grade, in the owner's words. */
236
- read: ReactNode;
237
- }
238
-
239
- /** The overview the writeup opens on: every section, its grade, and one plain read. */
240
- export interface WriteupSummary {
241
- kind: "summary";
242
- id?: string;
243
- title?: string;
244
- rows: WriteupSummaryRow[];
245
- /** What each letter means, in a few words each. */
246
- legend?: Array<{ grade: WriteupGrade; label: string }>;
247
- /** What a screen reader calls the earlier letter, e.g. "June 2026"; defaults to "Before". */
248
- beforeLabel?: string;
249
- }
250
-
251
- /** The paid facts line: what is rented, never graded (FOOTPRINT.md, Paid). */
252
- export interface WriteupRented {
253
- kind: "rented";
254
- label?: string;
255
- text: ReactNode;
256
- }
257
-
258
- export interface WriteupMarketRow {
259
- market: string;
260
- place?: string;
261
- /** One grade per column, in column order; null draws a quiet dash for a column not read. */
262
- grades: Array<WriteupGrade | null>;
263
- read: ReactNode;
264
- }
265
-
266
- /** The markets table: one row per market, one letter per column, the read beside it. */
267
- export interface WriteupMarkets {
268
- kind: "markets";
269
- title: string;
270
- lead?: string;
271
- columns: string[];
272
- rows: WriteupMarketRow[];
273
- }
274
-
275
- export type WriteupBlock =
276
- | WriteupPart
277
- | WriteupTopic
278
- | WriteupSummary
279
- | WriteupRented
280
- | WriteupMarkets;
281
-
282
- export interface WriteupSheet {
283
- id: string;
284
- blocks: WriteupBlock[];
285
- }
286
-
287
- export interface WriteupProps {
288
- /** The sender's wordmark text, e.g. "atelic"; drawn with the orange run. Print only. */
289
- sender: { name: string; href?: string };
290
- /** The recipient's logo, lockup right of the sender. Print only. */
291
- recipient: { logoSrc: string; logoAlt: string };
292
- preparedFor: string;
293
- date: string;
294
- title: string;
295
- /** One or two sentences under the title: what this is and where it was read from. */
296
- intro?: ReactNode;
297
- /** Groups of blocks. They run together on screen and in print. */
298
- sheets: WriteupSheet[];
299
- close: {
300
- signOff: string;
301
- person: WriteupPerson;
302
- /** The pointer to the hosted page, on the printed copy only. */
303
- twin?: { line: string; domain: string; qrSrc: string };
304
- };
305
- }
306
-
307
- const toneIcon = {
308
- good: CircleCheck,
309
- bad: CircleX,
310
- warn: CircleAlert,
311
- } as const;
312
-
313
- function Mark({ cell }: { cell: WriteupMark }) {
314
- if (cell.mark === "none") {
315
- return (
316
- <span className="op-mark-cell" data-tone="muted">
317
- <span className="op-dash" aria-hidden="true" />
318
- <span className="op-sr-only">{cell.text}</span>
319
- </span>
320
- );
321
- }
322
- const Icon = toneIcon[cell.mark];
323
- const visible = cell.show === true;
324
- return (
325
- <span className="op-mark-cell" data-tone={cell.mark}>
326
- <Icon size={17} aria-hidden="true" />
327
- <span className={visible ? undefined : "op-sr-only"}>{cell.text}</span>
328
- </span>
329
- );
330
- }
331
-
332
- /** A star rating as a table cell: the figure beside a star, sorted by the figure. */
333
- export function ratingCell(rating: number, tone?: WriteupTone): WriteupSorted {
334
- return {
335
- sort: rating,
336
- display: (
337
- <span className="op-rating" data-tone={tone}>
338
- <Star size={14} aria-hidden="true" />
339
- {rating.toFixed(1)}
340
- </span>
341
- ),
342
- };
343
- }
344
-
345
- const markRank = { good: 3, warn: 2, bad: 1, none: 0 } as const;
346
-
347
- /** A block's own heading, its one line lead, the block, and a closing note. */
348
- function Block({
349
- className,
350
- block,
351
- children,
352
- }: {
353
- className: string;
354
- block: BlockBase;
355
- children: ReactNode;
356
- }) {
357
- return (
358
- <div className={`op-block ${className}`}>
359
- {(block.title || block.lead) && (
360
- <div className="op-block-head">
361
- {block.title && <h4>{block.title}</h4>}
362
- {block.lead && <p>{block.lead}</p>}
363
- </div>
364
- )}
365
- {children}
366
- {block.note && <p className="op-note">{block.note}</p>}
367
- </div>
368
- );
369
- }
370
-
371
- function Table({ block }: { block: WriteupTable }) {
372
- const rows = block.rows.map((row) => ({
373
- tone: row.tone,
374
- cells: row.cells.map((cell) =>
375
- isMark(cell)
376
- ? { display: <Mark cell={cell} />, sort: cell.sort ?? markRank[cell.mark] }
377
- : cell,
378
- ),
379
- }));
380
- return (
381
- <Block className="op-table" block={block}>
382
- <DataTable columns={block.columns} rows={rows} defaultSort={block.sort} />
383
- </Block>
384
- );
385
- }
386
-
387
- function Findings({ block, brief }: { block: WriteupFindings; brief?: string }) {
388
- const hasShots = Boolean(block.shot || brief);
389
- return (
390
- <div className={`op-block op-findings${hasShots ? " has-shots" : ""}`}>
391
- <div className="op-findings-list">
392
- {block.items.map((item) => (
393
- <Callout
394
- key={item.lead}
395
- tone={item.tone ?? (item.win ? "good" : "warn")}
396
- title={item.lead}
397
- variant={item.box ? "box" : "plain"}
398
- >
399
- {item.body}
400
- </Callout>
401
- ))}
402
- </div>
403
- {hasShots && (
404
- <div className="op-shots">
405
- {block.shot ? (
406
- <>
407
- <Shot shot={block.shot} />
408
- {block.shot2 && <Shot shot={block.shot2} />}
409
- </>
410
- ) : (
411
- <figure className="op-shot">
412
- <span className="op-shot-brief">{brief}</span>
413
- </figure>
414
- )}
415
- </div>
416
- )}
417
- </div>
418
- );
419
- }
420
-
421
- function Shots({ block }: { block: WriteupShots }) {
422
- return (
423
- <Block className="op-shots-block" block={block}>
424
- <div className="op-shots-row" data-count={Math.min(block.shots.length, 3)}>
425
- {block.shots.map((shot) => (
426
- <Shot shot={shot} key={shot.src} />
427
- ))}
428
- </div>
429
- </Block>
430
- );
431
- }
432
-
433
- function More({ block }: { block: WriteupMore }) {
434
- return (
435
- <Disclosure className="op-block op-more" label={block.label} summary={block.summary}>
436
- <div className="op-more-body">
437
- {block.blocks.map((inner, i) => (
438
- // biome-ignore lint/suspicious/noArrayIndexKey: blocks are static content in a fixed order
439
- <TopicBlock block={inner} key={i} />
440
- ))}
441
- </div>
442
- </Disclosure>
443
- );
444
- }
445
-
446
- function TopicBlock({ block }: { block: WriteupTopicBlock }) {
447
- switch (block.kind) {
448
- case "checks":
449
- return (
450
- <Block className="op-checks" block={block}>
451
- <Checklist items={block.items} />
452
- </Block>
453
- );
454
- case "table":
455
- return <Table block={block} />;
456
- case "bar":
457
- return (
458
- <Block className="op-bar" block={block}>
459
- <StackedBar segments={block.segments} />
460
- </Block>
461
- );
462
- case "scale":
463
- return (
464
- <Block className="op-scale" block={block}>
465
- <ThresholdScale
466
- max={block.max}
467
- zones={block.zones}
468
- ticks={block.ticks}
469
- readings={block.readings}
470
- />
471
- </Block>
472
- );
473
- case "stats":
474
- return (
475
- <Block className="op-stats" block={block}>
476
- <StatRow stats={block.items} />
477
- </Block>
478
- );
479
- case "columns":
480
- return (
481
- <Block className="op-columns" block={block}>
482
- <ColumnChart groups={block.groups} legend={block.legend} />
483
- </Block>
484
- );
485
- case "shots":
486
- return <Shots block={block} />;
487
- case "more":
488
- return <More block={block} />;
489
- default:
490
- return <Findings block={block} />;
491
- }
492
- }
493
-
494
- function Topic({ topic }: { topic: WriteupTopic }) {
495
- const legacy: WriteupFindings | null = topic.bullets
496
- ? { kind: "findings", items: topic.bullets, shot: topic.shot, shot2: topic.shot2 }
497
- : null;
498
- const brief = topic.noShot || topic.shot ? undefined : (topic.shotBrief ?? "Screenshot");
499
- return (
500
- <section className="op-topic" id={topic.id}>
501
- <header className="op-topic-head">
502
- <div>
503
- <h3>
504
- {topic.href ? (
505
- <a href={topic.href} {...newTabProps(topic.href, true)}>
506
- {topic.title}
507
- <ArrowUpRight
508
- size={16}
509
- role="img"
510
- aria-label={topic.hrefLabel ?? "Opens in a new tab"}
511
- />
512
- </a>
513
- ) : (
514
- topic.title
515
- )}
516
- </h3>
517
- <p className="op-def">{topic.oneLiner}</p>
518
- {topic.grade && topic.why && (
519
- <p className="op-why">
520
- <b>{`Why ${gradeLabel[topic.grade]}:`}</b> {topic.why}
521
- </p>
522
- )}
523
- </div>
524
- {topic.grade && <GradeChip grade={topic.grade} />}
525
- </header>
526
- <div className="op-blocks">
527
- {topic.blocks?.map((block, i) => (
528
- // biome-ignore lint/suspicious/noArrayIndexKey: blocks are static content in a fixed order
529
- <TopicBlock block={block} key={i} />
530
- ))}
531
- {legacy && <Findings block={legacy} brief={brief} />}
532
- </div>
533
- {topic.sources && topic.sources.length > 0 && (
534
- <footer className="op-sources">
535
- <span className="op-sources-label">{topic.sourcesLabel ?? "Sources"}</span>
536
- <ul>
537
- {topic.sources.map((source) => (
538
- <li key={source.label}>
539
- {source.href ? (
540
- <a href={source.href} {...newTabProps(source.href, true)}>
541
- {source.label}
542
- <ArrowUpRight size={12} aria-hidden="true" />
543
- </a>
544
- ) : (
545
- source.label
546
- )}
547
- </li>
548
- ))}
549
- </ul>
550
- </footer>
551
- )}
552
- </section>
553
- );
554
- }
555
-
556
- function Summary({ summary }: { summary: WriteupSummary }) {
557
- const tiers: Array<{ tier: string; rows: WriteupSummaryRow[] }> = [];
558
- for (const row of summary.rows) {
559
- const last = tiers[tiers.length - 1];
560
- if (last && last.tier === row.tier) last.rows.push(row);
561
- else tiers.push({ tier: row.tier, rows: [row] });
562
- }
563
- const beforeLabel = summary.beforeLabel ?? "Before";
564
- return (
565
- <section className="op-ov" id={summary.id}>
566
- <div className="op-ov-head">
567
- {summary.title && <h2 className="op-ov-title">{summary.title}</h2>}
568
- {summary.legend && (
569
- <ul className="op-legend">
570
- {summary.legend.map((entry) => (
571
- <li key={entry.grade}>
572
- <GradeChip grade={entry.grade} />
573
- <span>{entry.label}</span>
574
- </li>
575
- ))}
576
- </ul>
577
- )}
578
- </div>
579
- <div className="op-ov-card">
580
- {tiers.map(({ tier, rows }) => (
581
- <div className="op-ov-tier" key={tier}>
582
- <div className="op-ov-tier-label">{tier}</div>
583
- <ul>
584
- {rows.map((row) => (
585
- <li className="op-ov-row" key={row.section}>
586
- <span className="op-ov-grade">
587
- {row.before && (
588
- <span className="op-ov-before">
589
- <span className="op-sr-only">{`${beforeLabel}: ${gradeLabel[row.before]}. `}</span>
590
- <span aria-hidden="true">
591
- <GradeChip grade={row.before} />
592
- </span>
593
- </span>
594
- )}
595
- <span className="op-sr-only">{`Grade ${gradeLabel[row.grade]}: `}</span>
596
- <span aria-hidden="true">
597
- <GradeChip grade={row.grade} />
598
- </span>
599
- </span>
600
- <b className="op-ov-name">
601
- {row.href ? <a href={row.href}>{row.section}</a> : row.section}
602
- </b>
603
- <span className="op-ov-read">{row.read}</span>
604
- {row.href && <ChevronRight className="op-ov-go" size={18} aria-hidden="true" />}
605
- </li>
606
- ))}
607
- </ul>
608
- </div>
609
- ))}
610
- </div>
611
- </section>
612
- );
613
- }
614
-
615
- function Markets({ markets }: { markets: WriteupMarkets }) {
616
- return (
617
- <section className="op-topic op-markets">
618
- <header className="op-topic-head">
619
- <div>
620
- <h3>{markets.title}</h3>
621
- {markets.lead && <p className="op-def">{markets.lead}</p>}
622
- </div>
623
- </header>
624
- <table className="op-markets-table">
625
- <thead>
626
- <tr>
627
- <th scope="col" className="op-markets-market">
628
- <span className="op-sr-only">Market</span>
629
- </th>
630
- {markets.columns.map((column) => (
631
- <th scope="col" className="op-markets-col" key={column}>
632
- {column}
633
- </th>
634
- ))}
635
- <th scope="col" className="op-markets-read">
636
- <span className="op-sr-only">Read</span>
637
- </th>
638
- </tr>
639
- </thead>
640
- <tbody>
641
- {markets.rows.map((row) => (
642
- <tr key={row.market}>
643
- <th scope="row" className="op-markets-market">
644
- {row.market}
645
- {row.place && <span className="op-markets-place">{row.place}</span>}
646
- </th>
647
- {markets.columns.map((column, i) => {
648
- const grade = row.grades[i] ?? null;
649
- return (
650
- <td className="op-markets-grade" key={column}>
651
- <span className="op-markets-label" aria-hidden="true">
652
- {column}
653
- </span>
654
- {grade ? (
655
- <>
656
- <span className="op-sr-only">{`${column}, grade ${gradeLabel[grade]}`}</span>
657
- <span aria-hidden="true">
658
- <GradeChip grade={grade} />
659
- </span>
660
- </>
661
- ) : (
662
- <>
663
- <span className="op-sr-only">{`${column}, not read`}</span>
664
- <span className="op-dash" aria-hidden="true" />
665
- </>
666
- )}
667
- </td>
668
- );
669
- })}
670
- <td className="op-markets-read">{row.read}</td>
671
- </tr>
672
- ))}
673
- </tbody>
674
- </table>
675
- </section>
676
- );
677
- }
678
-
679
- function Rented({ rented }: { rented: WriteupRented }) {
680
- return (
681
- <p className="op-rented">
682
- <span className="op-rented-label">{rented.label ?? "Rented"}</span>
683
- <span className="op-rented-text">{rented.text}</span>
684
- </p>
685
- );
686
- }
687
-
688
- function Part({ part }: { part: WriteupPart }) {
689
- return (
690
- <div className="op-part" id={part.id}>
691
- <h2>{part.title}</h2>
692
- <p>{part.lead}</p>
693
- </div>
694
- );
695
- }
696
-
697
- export function Writeup({
698
- sender,
699
- recipient,
700
- preparedFor,
701
- date,
702
- title,
703
- intro,
704
- sheets,
705
- close,
706
- }: WriteupProps) {
707
- return (
708
- <div className="op">
709
- <div className="op-doc">
710
- <header className="op-head">
711
- <div className="op-top">
712
- <span className="op-mark">
713
- {sender.href ? <a href={sender.href}>{sender.name}</a> : sender.name}
714
- <span className="op-run" aria-hidden="true" />
715
- </span>
716
- <span className="op-x" aria-hidden="true">
717
- ×
718
- </span>
719
- <img className="op-recipient" src={recipient.logoSrc} alt={recipient.logoAlt} />
720
- </div>
721
- <p className="op-meta">
722
- Prepared for <b>{preparedFor}</b>
723
- <span className="op-meta-sep" aria-hidden="true">
724
- {" · "}
725
- </span>
726
- <span className="op-meta-date">{date}</span>
727
- </p>
728
- <h1>{title}</h1>
729
- {intro && <p className="op-intro">{intro}</p>}
730
- </header>
731
- {sheets.map((sheet) => (
732
- <div className="op-sheet" id={sheet.id} key={sheet.id}>
733
- {sheet.blocks.map((block) =>
734
- block.kind === "part" ? (
735
- <Part key={block.title} part={block} />
736
- ) : block.kind === "summary" ? (
737
- <Summary key="summary" summary={block} />
738
- ) : block.kind === "rented" ? (
739
- <Rented key="rented" rented={block} />
740
- ) : block.kind === "markets" ? (
741
- <Markets key={`markets-${block.title}`} markets={block} />
742
- ) : (
743
- <Topic key={block.id} topic={block} />
744
- ),
745
- )}
746
- </div>
747
- ))}
748
- <footer className="op-close">
749
- <SignOff signOff={close.signOff} person={close.person} />
750
- {close.twin && (
751
- <div className="op-twin">
752
- <div className="op-twin-text">
753
- {close.twin.line}
754
- <b>{close.twin.domain}</b>
755
- </div>
756
- <img
757
- className="op-qr"
758
- src={close.twin.qrSrc}
759
- alt={`QR code for ${close.twin.domain}`}
760
- />
761
- </div>
762
- )}
763
- </footer>
764
- </div>
765
- </div>
766
- );
767
- }