@atelic-action/artifact 4.2.0 → 5.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,6 +1,8 @@
1
1
  import {
2
2
  type ArtifactLink,
3
3
  type ArtifactParagraph,
4
+ GradeChip,
5
+ gradeLabel,
4
6
  isEmpty,
5
7
  newTabProps,
6
8
  Paragraph,
@@ -12,36 +14,39 @@ import {
12
14
  import { IconList, ReportCard, type Tone } from "@atelic-action/ui/components";
13
15
  import { ArrowUpRight, Check } from "lucide-react";
14
16
  import type { ReactNode } from "react";
15
- import { Topic, type WriteupTopic } from "./Writeup";
17
+ import { isRuns, type ReadTopic, Topic } from "./Topic";
16
18
 
17
19
  /**
18
- * The read: what a customer gets at the end of an engagement's first week,
19
- * before anything changes. Where the writeup says what letter each section
20
- * earned, the read says why, and what changes. Its shape is the practice's
21
- * FOOTPRINT.md, under What the Owner Gets: a title and a dated lede; the gist
22
- * table (every section, its grade, and one line), with a note under it for
23
- * what the read leaves out; then the tiers; then the plan, in order; then one
24
- * closing line and the sign off.
20
+ * The read: the graded document a business gets from the practice, built from
21
+ * one snapshot. Its shape is the practice's FOOTPRINT.md, under What the
22
+ * Owner Gets: a title and a dated lede; the scorecard (every section under
23
+ * its tier, its grade, and one line, with the earlier letter beside it on a
24
+ * later read), with a note over it for what the read leaves out; the plan in
25
+ * order; then the tiers, each section the card in Topic.tsx (the trade name
26
+ * linked to its definition, the one liner, the grade, the why line as the
27
+ * summary of what we saw, the blocks, and the sources at the foot) plus what
28
+ * is the read's own, today and after and what we recommend; a tier may open
29
+ * on a table of letters per market and close on the paid facts line, which
30
+ * is never graded; then one closing line and the sign off. A beat with
31
+ * nothing in it is left out.
25
32
  *
26
- * A section is the writeup's section card, the same `Topic`, so the two
27
- * documents read alike (Forni, 2026-10-16): the trade name linked to its
28
- * definition, the one liner, the grade, and the why line, which here is the
29
- * summary of what we saw in one or two sentences; then the writeup's blocks
30
- * (findings with the owner's screens beside them, the basics, a table, a
31
- * fold); then what is the read's own, today and after as a two column table
32
- * and what we recommend as a checklist; and the tools we looked through at
33
- * the foot, as the sources. A beat with nothing in it is left out.
33
+ * The same page at two depths, and `access` says which. A public read is
34
+ * built from public readings alone and may be hosted open; a private one
35
+ * adds what the owner's own consoles show and deploys only behind the wall.
36
+ * The component draws whatever beats the content carries, so the depth
37
+ * lives in the content and `access` is the declaration the host is checked
38
+ * against (the content test, and `access` in site.config.ts).
34
39
  *
35
- * It wears the writeup's column, head, grade chip and key, tier heading,
36
- * section card, and close (the `.op` classes), and adds only what is its own
37
- * (the `.rd` classes). The gist table is drawn from the tiers, so a tier
38
- * added later (Conversion, once the analytics access lands) is one more entry
39
- * in `tiers` and appears in the table and the body alike; until then `note`
40
- * says so under the table. Every prop but a finding's body and the sign off
41
- * person's `role` is plain data, and both are equally happy with a string, so
42
- * a routine can write a read as JSON.
40
+ * Every prop but a finding's body, a why line, and the sign off person's
41
+ * `role` is plain data, and all three are equally happy with a string, so a
42
+ * routine can write a read as JSON. `print` is the printed copy's own: the
43
+ * sender and recipient lockup where the header was, and the pointer to the
44
+ * hosted twin with its QR under the sign off.
43
45
  */
44
46
 
47
+ /** Whether anything on the page came from the owner's own consoles. */
48
+ export type ReadAccess = "public" | "private";
49
+
45
50
  /** A link inside running text: the words the reader clicks and where they go. */
46
51
  export type ReadLink = ArtifactLink;
47
52
 
@@ -55,13 +60,15 @@ export interface ReadChange {
55
60
  }
56
61
 
57
62
  /**
58
- * One section of a tier: the writeup's section card (its `title`, `href`,
59
- * `oneLiner`, `grade`, `why`, `blocks`, and `sources`), plus the read's own:
60
- * the gist table's line, today and after, and what we recommend.
63
+ * One section of a tier: the card (its `title`, `href`, `oneLiner`, `grade`,
64
+ * `why`, `blocks`, and `sources`), plus the scorecard's line, the earlier
65
+ * letter, today and after, and what we recommend.
61
66
  */
62
- export interface ReadSection extends Omit<WriteupTopic, "kind" | "why"> {
63
- /** The one line in the gist table. */
67
+ export interface ReadSection extends Omit<ReadTopic, "why"> {
68
+ /** The one line on the scorecard. */
64
69
  gist: ReadParagraph;
70
+ /** The grade in the earlier snapshot, on a later read: what moved. */
71
+ before?: WriteupGrade;
65
72
  /** 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. */
66
73
  why?: ReactNode | ReadParagraph;
67
74
  /** Today and after: what each thing is, and what it becomes. Set after the blocks. */
@@ -80,6 +87,29 @@ export interface ReadRecommendation {
80
87
  tone?: Tone;
81
88
  }
82
89
 
90
+ export interface ReadMarketRow {
91
+ market: string;
92
+ place?: string;
93
+ /** One grade per column, in column order; null draws a quiet dash for a column not read. */
94
+ grades: Array<WriteupGrade | null>;
95
+ read: ReactNode | ReadParagraph;
96
+ }
97
+
98
+ /** The markets table: one row per market or site, one letter per column, the read beside it. */
99
+ export interface ReadMarkets {
100
+ title: string;
101
+ lead?: string;
102
+ columns: string[];
103
+ rows: ReadMarketRow[];
104
+ }
105
+
106
+ /** The paid facts line: what is rented, never graded (FOOTPRINT.md, Paid). */
107
+ export interface ReadRented {
108
+ /** The word before the line; defaults to the `rented` label. */
109
+ label?: string;
110
+ text: ReactNode | ReadParagraph;
111
+ }
112
+
83
113
  /** A tier, e.g. Health: its heading, the question it answers, and its sections in order. */
84
114
  export interface ReadTier {
85
115
  /** The anchor a header stop points at, e.g. "health". */
@@ -90,7 +120,11 @@ export interface ReadTier {
90
120
  href?: string;
91
121
  /** The questions the tier answers, in the owner's words. */
92
122
  lead?: string;
123
+ /** A table of letters per market or site, over the sections. */
124
+ markets?: ReadMarkets;
93
125
  sections: ReadSection[];
126
+ /** The paid facts line, under the sections. Visibility's, by FOOTPRINT.md. */
127
+ rented?: ReadRented;
94
128
  }
95
129
 
96
130
  /** One step of the plan: a bold title and a sentence or two. */
@@ -117,21 +151,35 @@ export interface ReadPlan {
117
151
  /** The close: one line, then the sign off. */
118
152
  export interface ReadClose {
119
153
  /** One sentence, e.g. what follows and when. */
120
- line: ReadParagraph;
154
+ line?: ReadParagraph;
121
155
  signOff?: string;
122
156
  person?: WriteupPerson;
123
157
  }
124
158
 
159
+ /** The printed copy's own: the lockup where the header was, and the pointer to the hosted twin. */
160
+ export interface ReadPrint {
161
+ /** The sender's wordmark text, e.g. "atelic"; drawn with the orange run. */
162
+ sender: { name: string; href?: string };
163
+ /** The recipient's logo, lockup right of the sender. */
164
+ recipient: { logoSrc: string; logoAlt: string };
165
+ /** The pointer to the hosted page under the sign off, with its QR. */
166
+ twin?: { line: string; domain: string; qrSrc: string };
167
+ }
168
+
125
169
  /** The fixed words the page sets, each overridable. */
126
170
  export interface ReadLabels {
127
171
  /** The dateline's words before the business, e.g. "Prepared for". */
128
172
  preparedFor: string;
129
- /** What a screen reader calls the gist card when it has no title. */
173
+ /** What a screen reader calls the scorecard when it has no title. */
130
174
  gist: string;
131
- /** What a screen reader hears before a letter, on the gist card and each section's card. */
175
+ /** What a screen reader hears before a letter, on the scorecard and each section's card. */
132
176
  grade: string;
133
177
  /** What a screen reader hears for a section with no grade. */
134
178
  notGraded: string;
179
+ /** What a screen reader calls the earlier letter on the scorecard, e.g. "Before" or "June 2026". */
180
+ before: string;
181
+ /** The word before the paid facts line when it names none. */
182
+ rented: string;
135
183
  /** The today and after table's first column. */
136
184
  now: string;
137
185
  /** The today and after table's second column. */
@@ -144,6 +192,8 @@ export interface ReadLabels {
144
192
  sources: string;
145
193
  /** The plan's heading when it names none. */
146
194
  plan: string;
195
+ /** The note over the scorecard on a public read that sets none: what the read leaves out. */
196
+ publicNote: string;
147
197
  }
148
198
 
149
199
  export const readLabels: ReadLabels = {
@@ -151,15 +201,21 @@ export const readLabels: ReadLabels = {
151
201
  gist: "The Gist",
152
202
  grade: "Grade",
153
203
  notGraded: "Not graded yet",
204
+ before: "Before",
205
+ rented: "Rented",
154
206
  now: "Today",
155
207
  after: "After",
156
208
  recommend: "Recommendations",
157
209
  why: "Why",
158
210
  sources: "See it yourself",
159
211
  plan: "The Plan",
212
+ publicNote:
213
+ "This page is read from the outside, through what anyone can open. A customer's read adds what the owner's own consoles show.",
160
214
  };
161
215
 
162
- export interface ReadProps {
216
+ export interface TheReadProps {
217
+ /** Whether anything on the page came from the owner's own consoles; a private read deploys only behind the wall. */
218
+ access: ReadAccess;
163
219
  title: string;
164
220
  /** The business, set in the dateline. */
165
221
  preparedFor: string;
@@ -169,19 +225,21 @@ export interface ReadProps {
169
225
  lede: ReadParagraph;
170
226
  /** The heading over the scorecard, e.g. "The Scorecard". */
171
227
  gistTitle?: string;
172
- /** The line under the scorecard's heading, e.g. what it is. */
228
+ /** The line under the scorecard's heading, e.g. what it is. Set only with a `gistTitle`, since it reads as the heading's subtitle. */
173
229
  gistLead?: string;
174
- /** The anchor a header stop points at for the scorecard, e.g. "scorecard". */
230
+ /** The anchor a header stop points at for the scorecard, e.g. "scorecard": on the heading with a `gistTitle`, on the card itself without one. */
175
231
  gistId?: string;
176
- /** A callout over the scorecard, under its heading, e.g. what the read leaves out and when it follows. */
232
+ /** A callout over the scorecard, under its heading, e.g. what the read leaves out and when it follows. A public read that sets none gets the `publicNote` label. */
177
233
  note?: ReadParagraph;
178
234
  /** The key: what each letter means, in a few words each. */
179
235
  gradeKey?: Array<{ grade: WriteupGrade; label: string }>;
180
- /** The tiers in order. Each section appears in the gist table and in the body. */
236
+ /** The tiers in order. Each section appears on the scorecard and in the body. */
181
237
  tiers: ReadTier[];
182
238
  plan: ReadPlan;
183
239
  close: ReadClose;
184
240
  labels?: Partial<ReadLabels>;
241
+ /** The printed copy's lockup and twin; the page prints without them when left out. */
242
+ print?: ReadPrint;
185
243
  }
186
244
 
187
245
  /** A paragraph with something to read. Content may arrive as JSON, so a missing one counts as empty. */
@@ -189,19 +247,6 @@ function filled(paragraph: ReadParagraph | undefined | null): paragraph is ReadP
189
247
  return paragraph !== undefined && paragraph !== null && !isEmpty(paragraph);
190
248
  }
191
249
 
192
- /** A string or runs of text and links, the shape JSON content arrives in; a node of elements is anything else. */
193
- function isRuns(value: unknown): value is ReadParagraph {
194
- if (typeof value === "string") return true;
195
- return (
196
- Array.isArray(value) &&
197
- value.every(
198
- (run) =>
199
- typeof run === "string" ||
200
- (typeof run === "object" && run !== null && "text" in run && "href" in run),
201
- )
202
- );
203
- }
204
-
205
250
  /** Content as a node: runs are rendered, a blank is dropped, and a node passes through. */
206
251
  function asNode(value: ReactNode | ReadParagraph | undefined | null): ReactNode {
207
252
  if (value === undefined || value === null) return undefined;
@@ -276,8 +321,8 @@ function Gist({
276
321
  id?: string;
277
322
  title?: string;
278
323
  lead?: string;
279
- note?: ReadProps["note"];
280
- gradeKey?: ReadProps["gradeKey"];
324
+ note?: ReadParagraph;
325
+ gradeKey?: TheReadProps["gradeKey"];
281
326
  labels: ReadLabels;
282
327
  }) {
283
328
  return (
@@ -294,17 +339,19 @@ function Gist({
294
339
  </div>
295
340
  )}
296
341
  <ReportCard
342
+ id={title ? undefined : id}
297
343
  className="rd-gist"
298
344
  label={title ?? labels.gist}
299
345
  legend={gradeKey}
300
346
  legendAt="foot"
301
- labels={{ grade: labels.grade, notGraded: labels.notGraded }}
347
+ labels={{ grade: labels.grade, notGraded: labels.notGraded, before: labels.before }}
302
348
  groups={tiers.map((tier) => ({
303
349
  title: tier.title,
304
350
  rows: tier.sections.map((section) => ({
305
351
  name: section.title,
306
352
  href: `#${section.id}`,
307
353
  grade: section.grade,
354
+ before: section.before,
308
355
  line: <Runs paragraph={section.gist} />,
309
356
  })),
310
357
  }))}
@@ -313,7 +360,7 @@ function Gist({
313
360
  );
314
361
  }
315
362
 
316
- /** A section: the writeup's card, with today and after and the checklist handed in after its blocks. */
363
+ /** A section: the card, with today and after and the checklist handed in after its blocks. */
317
364
  function Section({ section, labels }: { section: ReadSection; labels: ReadLabels }) {
318
365
  const changes = (section.changes ?? []).filter(
319
366
  (change) => filled(change?.now) || filled(change?.after),
@@ -333,16 +380,21 @@ function Section({ section, labels }: { section: ReadSection; labels: ReadLabels
333
380
  : block,
334
381
  );
335
382
  const sources = (section.sources ?? []).filter((source) => source?.label?.trim());
336
- const topic: WriteupTopic = {
337
- kind: "topic",
338
- ...section,
383
+ const {
384
+ gist: _gist,
385
+ before: _before,
386
+ changes: _changes,
387
+ recommend: _recommend,
388
+ ...card
389
+ } = section;
390
+ const topic: ReadTopic = {
391
+ ...card,
339
392
  why: asNode(section.why),
340
393
  whyLabel: section.whyLabel ?? labels.why,
341
394
  gradeWord: section.gradeWord ?? labels.grade,
342
395
  blocks,
343
396
  sources,
344
397
  sourcesLabel: section.sourcesLabel ?? labels.sources,
345
- noShot: section.noShot ?? true,
346
398
  };
347
399
  const extra =
348
400
  changes.length + recommend.length > 0 ? (
@@ -354,6 +406,79 @@ function Section({ section, labels }: { section: ReadSection; labels: ReadLabels
354
406
  return <Topic topic={topic} extra={extra} />;
355
407
  }
356
408
 
409
+ function Markets({ markets }: { markets: ReadMarkets }) {
410
+ return (
411
+ <section className="op-topic op-markets">
412
+ <header className="op-topic-head">
413
+ <div>
414
+ <h3>{markets.title}</h3>
415
+ {markets.lead && <p className="op-def">{markets.lead}</p>}
416
+ </div>
417
+ </header>
418
+ <table className="op-markets-table">
419
+ <thead>
420
+ <tr>
421
+ <th scope="col" className="op-markets-market">
422
+ <span className="op-sr-only">Market</span>
423
+ </th>
424
+ {markets.columns.map((column) => (
425
+ <th scope="col" className="op-markets-col" key={column}>
426
+ {column}
427
+ </th>
428
+ ))}
429
+ <th scope="col" className="op-markets-read">
430
+ <span className="op-sr-only">Read</span>
431
+ </th>
432
+ </tr>
433
+ </thead>
434
+ <tbody>
435
+ {markets.rows.map((row) => (
436
+ <tr key={row.market}>
437
+ <th scope="row" className="op-markets-market">
438
+ {row.market}
439
+ {row.place && <span className="op-markets-place">{row.place}</span>}
440
+ </th>
441
+ {markets.columns.map((column, i) => {
442
+ const grade = row.grades[i] ?? null;
443
+ return (
444
+ <td className="op-markets-grade" key={column}>
445
+ <span className="op-markets-label" aria-hidden="true">
446
+ {column}
447
+ </span>
448
+ {grade ? (
449
+ <>
450
+ <span className="op-sr-only">{`${column}, grade ${gradeLabel[grade]}`}</span>
451
+ <span aria-hidden="true">
452
+ <GradeChip grade={grade} />
453
+ </span>
454
+ </>
455
+ ) : (
456
+ <>
457
+ <span className="op-sr-only">{`${column}, not read`}</span>
458
+ <span className="op-dash" aria-hidden="true" />
459
+ </>
460
+ )}
461
+ </td>
462
+ );
463
+ })}
464
+ <td className="op-markets-read">{asNode(row.read)}</td>
465
+ </tr>
466
+ ))}
467
+ </tbody>
468
+ </table>
469
+ </section>
470
+ );
471
+ }
472
+
473
+ function Rented({ rented, label }: { rented: ReadRented; label: string }) {
474
+ return (
475
+ <p className="op-rented">
476
+ <span className="op-rented-label">{rented.label ?? label}</span>
477
+ <span className="op-rented-text">{asNode(rented.text)}</span>
478
+ </p>
479
+ );
480
+ }
481
+
357
482
  function Plan({ plan, labels }: { plan: ReadPlan; labels: ReadLabels }) {
358
483
  return (
359
484
  <section className="rd-plan" id={plan.id}>
@@ -387,7 +512,37 @@ function Plan({ plan, labels }: { plan: ReadPlan; labels: ReadLabels }) {
387
512
  );
388
513
  }
389
514
 
390
- export function Read({
515
+ function Tier({ tier, labels }: { tier: ReadTier; labels: ReadLabels }) {
516
+ const rented =
517
+ tier.rented && (isRuns(tier.rented.text) ? filled(tier.rented.text) : tier.rented.text != null)
518
+ ? tier.rented
519
+ : undefined;
520
+ return (
521
+ <div className="rd-tier">
522
+ <div className="op-part" id={tier.id}>
523
+ <h2>
524
+ {tier.href ? (
525
+ <a href={tier.href} {...newTabProps(tier.href, true)}>
526
+ {tier.title}
527
+ <ArrowUpRight size={14} aria-hidden="true" />
528
+ </a>
529
+ ) : (
530
+ tier.title
531
+ )}
532
+ </h2>
533
+ {tier.lead && <p>{tier.lead}</p>}
534
+ </div>
535
+ {tier.markets && tier.markets.rows.length > 0 && <Markets markets={tier.markets} />}
536
+ {tier.sections.map((section) => (
537
+ <Section section={section} labels={labels} key={section.id} />
538
+ ))}
539
+ {rented && <Rented rented={rented} label={labels.rented} />}
540
+ </div>
541
+ );
542
+ }
543
+
544
+ export function TheRead({
545
+ access,
391
546
  title,
392
547
  preparedFor,
393
548
  date,
@@ -401,13 +556,36 @@ export function Read({
401
556
  plan,
402
557
  close,
403
558
  labels: overrides,
404
- }: ReadProps) {
559
+ print,
560
+ }: TheReadProps) {
405
561
  const labels = { ...readLabels, ...overrides };
406
562
  const shownTiers = tiers.filter((tier) => tier.sections.length > 0);
563
+ const shownNote = filled(note) ? note : access === "public" ? labels.publicNote : undefined;
564
+ const signOff = Boolean(close.signOff && close.person);
407
565
  return (
408
- <div className="op rd">
566
+ <div className="op rd" data-access={access}>
409
567
  <article className="op-doc rd-doc">
410
568
  <header className="op-head">
569
+ {print && (
570
+ <div className="op-top">
571
+ <span className="op-mark">
572
+ {print.sender.href ? (
573
+ <a href={print.sender.href}>{print.sender.name}</a>
574
+ ) : (
575
+ print.sender.name
576
+ )}
577
+ <span className="op-run" aria-hidden="true" />
578
+ </span>
579
+ <span className="op-x" aria-hidden="true">
580
+ ×
581
+ </span>
582
+ <img
583
+ className="op-recipient"
584
+ src={print.recipient.logoSrc}
585
+ alt={print.recipient.logoAlt}
586
+ />
587
+ </div>
588
+ )}
411
589
  <p className="op-meta">
412
590
  {labels.preparedFor} <b>{preparedFor}</b>
413
591
  <span className="op-meta-sep" aria-hidden="true">
@@ -424,36 +602,34 @@ export function Read({
424
602
  id={gistId}
425
603
  title={gistTitle}
426
604
  lead={gistLead}
427
- note={note}
605
+ note={shownNote}
428
606
  gradeKey={gradeKey}
429
607
  labels={labels}
430
608
  />
431
609
  )}
432
610
  {plan.steps.length > 0 && <Plan plan={plan} labels={labels} />}
433
611
  {shownTiers.map((tier) => (
434
- <div className="rd-tier" key={tier.title}>
435
- <div className="op-part" id={tier.id}>
436
- <h2>
437
- {tier.href ? (
438
- <a href={tier.href} {...newTabProps(tier.href, true)}>
439
- {tier.title}
440
- <ArrowUpRight size={14} aria-hidden="true" />
441
- </a>
442
- ) : (
443
- tier.title
444
- )}
445
- </h2>
446
- {tier.lead && <p>{tier.lead}</p>}
447
- </div>
448
- {tier.sections.map((section) => (
449
- <Section section={section} labels={labels} key={section.id} />
450
- ))}
451
- </div>
612
+ <Tier tier={tier} labels={labels} key={tier.title} />
452
613
  ))}
453
614
  {filled(close.line) && <Paragraph paragraph={close.line} className="rd-closing" />}
454
- {close.signOff && close.person && (
615
+ {(signOff || print?.twin) && (
455
616
  <footer className="op-close rd-close">
456
- <SignOff signOff={close.signOff} person={close.person} />
617
+ {signOff && close.signOff && close.person && (
618
+ <SignOff signOff={close.signOff} person={close.person} />
619
+ )}
620
+ {print?.twin && (
621
+ <div className="op-twin">
622
+ <div className="op-twin-text">
623
+ {print.twin.line}
624
+ <b>{print.twin.domain}</b>
625
+ </div>
626
+ <img
627
+ className="op-qr"
628
+ src={print.twin.qrSrc}
629
+ alt={`QR code for ${print.twin.domain}`}
630
+ />
631
+ </div>
632
+ )}
457
633
  </footer>
458
634
  )}
459
635
  </article>