@atelic-action/artifact 5.1.2 → 5.2.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 CHANGED
@@ -7,7 +7,7 @@ The documents the practice hands or sends a business, as a published package and
7
7
 
8
8
  Three layers, each a version the next depends on (ATE-671): `@atelic-action/ui` holds the pieces, this package holds the documents built from them, and each business's folder holds only its content, routes, and config. A document fix reaches a folder by bumping this package; a piece fix by bumping ui here, then this package in the folder.
9
9
 
10
- Five artifacts live in the template site: the **read** at `/the-read`, the graded document a business gets from the practice, built from one snapshot at one of two depths (a prospect's, from public readings alone; a customer's, from their own consoles as well), a web page first with its printed copy derived from it, the grades explained by cause with the plan in order between the scorecard and the cards; the **proposal** at `/proposal`, sealed into the page and opened by the reader's own email address; the **monthly page** at `/report`, what an Operate client gets each month on one letter sheet; the **note** at `/notes`, the first touch for a business whose site publishes no email address, set as a short page; and the **first call sheet** at `/first-call`, the desk's page for the first call with a business that booked off a cold first touch, used to guide the call and not sent for now, written so it could be shared with them later. The bare host sends a reader to the read. Every artifact prerenders to static HTML, stays out of every index, and carries the practice's brand by default.
10
+ Five artifacts live in the template site: the **read** at `/the-read`, the graded document a business gets from the practice, built from one snapshot at one of two depths (a prospect's, from public readings alone; a customer's, from their own consoles as well), a web page first with its printed copy derived from it, the grades explained by cause, and the plan for the work set in a tab of its own ahead of the read; the **proposal** at `/proposal`, sealed into the page and opened by the reader's own email address; the **monthly page** at `/report`, what an Operate client gets each month on one letter sheet; the **note** at `/notes`, the first touch for a business whose site publishes no email address, set as a short page; and the **first call sheet** at `/first-call`, the desk's page for the first call with a business that booked off a cold first touch, used to guide the call and not sent for now, written so it could be shared with them later. The bare host sends a reader to the read. Every artifact prerenders to static HTML, stays out of every index, and carries the practice's brand by default.
11
11
 
12
12
  The method behind them (the seven sections, the four grades from A to D, the rented line, what moved, and when an artifact is private) is the practice repo's `FOOTPRINT.md`. This repo builds to it and never restates it.
13
13
 
@@ -21,9 +21,11 @@ Every export of the package, all from the one entry, `@atelic-action/artifact`.
21
21
 
22
22
  | Export | What it is | Route |
23
23
  |---|---|---|
24
- | `TheRead`, `TheReadProps` | The graded document a business gets, built from one snapshot: the dated lede; the scorecard, a `ReportCard` with each row a link to its card and the earlier letter beside a row that moved, with an optional `note` callout over it for what the read leaves out; the plan, numbered and in order, between the scorecard and the cards, so the whole program reads before any one card's detail; then the tiers, each section on the card (the trade name linked to its definition, the one liner, the grade, the why line as the summary, the blocks, and the sources at the foot) plus the read's own today and after (a two column table) and what we recommend (a checklist), a tier opening on a `markets` table of letters and closing on the `rented` line when it has them; the closing line and the sign off. `access` says which depth it is (`"public"`, from public readings alone, or `"private"`, from the owner's consoles too), and a public read with no note gets the `publicNote` label over its scorecard. `print` adds the lockup and the twin's QR to the printed copy. A beat with no data is left out | `/the-read` |
25
- | `readLabels`, `ReadLabels` | The fixed words the read sets (Prepared for, the scorecard's screen reader words, Before, Rented, Today, After, Recommendations, the plan's heading, the public note), each overridable through `labels` | `/the-read` |
26
- | `ReadAccess`, `ReadTier`, `ReadSection`, `ReadChange`, `ReadRecommendation`, `ReadMarkets`, `ReadMarketRow`, `ReadRented`, `ReadStep`, `ReadPlan`, `ReadClose`, `ReadPrint`, `ReadLink`, `ReadParagraph` | The shapes a read's content is written in. `ReadSection` is a `ReadTopic` plus `gist`, `before`, `changes`, and `recommend`; its `why`, a finding's `body`, a market row's `read`, and the rented `text` take a paragraph (a string, or runs of text and links) as well as a node, so a routine can write a read as JSON | none |
24
+ | `TheRead`, `TheReadProps` | The graded document a business gets, built from one snapshot: the dated lede; the scorecard, a `ReportCard` with each row a link to its card and the earlier letter beside a row that moved, with an optional `note` callout over it for what the read leaves out; the plan's numbered steps between the scorecard and the cards when `plan` has any (superseded by the plan tab, and removed at the next major); then the tiers, each section on the card (the trade name linked to its definition, the one liner, the grade, the why line as the summary, the blocks, and the sources at the foot) plus the read's own today and after (a two column table) and what we recommend (a checklist), a tier opening on a `markets` table of letters and closing on the `rented` line when it has them; the closing line and the sign off. `access` says which depth it is (`"public"`, from public readings alone, or `"private"`, from the owner's consoles too), and a public read with no note gets the `publicNote` label over its scorecard. `print` adds the lockup and the twin's QR to the printed copy. A beat with no data is left out. Given a `planDocument`, the page becomes two tabs under the shared head, The Plan first and open, then The Read, which holds everything that sat between the head and the sign off; the sign off stays under both. The tabs are ui's `Tabs`, so a link or a hash naming a card in the read opens the read tab and lands on it, and on paper the plan prints first and the read starts a new page, each under its tab's name. `selectedTab` and `onSelectTab` let a route hold the open tab, for instance to swap the header's stops with it; left out, the plan tab opens first. Without a `planDocument` the page is the one it always was | `/the-read` |
25
+ | `readLabels`, `ReadLabels` | The fixed words the read sets (Prepared for, the scorecard's screen reader words, Before, Rented, Today, After, Recommendations, the plan's heading, the public note, the two tabs' names, and the tab row's name for a screen reader), each overridable through `labels` | `/the-read` |
26
+ | `PlanDocument`, `PlanSection`, `PlanPart`, `PlanBlock` | The shapes the plan tab is written in, all plain data, so a routine can write a plan as JSON. A plan is an optional lede and its sections; a section is an `id` (the anchor a header stop points at), a `heading`, an optional `timeline` line set small under the heading (when the work happens and whose it is), its blocks in order, and any parts, each a smaller heading with the same body. A block is a paragraph, a bulleted or numbered list, or a table with an optional head row, and every piece of text in one is a paragraph, so a link into the read is a run whose `href` is a card's anchor. A block with nothing in it is left out. On a phone a table's rows stack, each cell wearing its column's name | `/the-read` |
27
+ | `thePlanTabId`, `theReadTabId` | The two tabs' ids (`"plan"`, unless the plan document names its own, and `"read"`), so a route compares the open tab without a string of its own. No anchor on the page may share either | none |
28
+ | `ReadAccess`, `ReadTier`, `ReadSection`, `ReadChange`, `ReadRecommendation`, `ReadMarkets`, `ReadMarketRow`, `ReadRented`, `ReadStep`, `ReadPlan` (both superseded by the plan tab), `ReadClose`, `ReadPrint`, `ReadLink`, `ReadParagraph` | The shapes a read's content is written in. `ReadSection` is a `ReadTopic` plus `gist`, `before`, `changes`, and `recommend`; its `why`, a finding's `body`, a market row's `read`, and the rented `text` take a paragraph (a string, or runs of text and links) as well as a node, so a routine can write a read as JSON | none |
27
29
  | `Topic` | The section card on its own: the trade name linked to its definition, the one liner, the grade, the why line, the blocks, and the sources, with an `extra` slot after the blocks. The read renders every section through it | `/the-read` |
28
30
  | `ratingCell` | A star rating table cell for a `table` block, sorted by its figure | `/the-read` |
29
31
  | `ReadTopic`, `ReadBlock`, `ReadInnerBlock`, `ReadChecks`, `ReadTable`, `ReadBar`, `ReadScale`, `ReadStats`, `ReadColumns`, `ReadFindings`, `ReadShots`, `ReadMore`, `ReadBullet`, `ReadSource`, `ReadMark`, `ReadCell`, `ReadSorted`, `ReadTone` | The shapes a section card and its blocks are written in. In a `table` block, a cell past the first given as `null` is empty: it reaches ui's `DataTable` untouched, draws its short gray rule, reads the column's `emptyLabel` (or "None") to a screen reader, and sorts last either way, while a typed dash stays content, since it can mean unchanged. A `none` mark draws the same rule with its own `text` but sorts as a mark, lowest of the four, and the markets table's unread grade draws it too | none |
@@ -40,7 +42,7 @@ Every export of the package, all from the one entry, `@atelic-action/artifact`.
40
42
  | `normalizeEmail` | How a typed address becomes the key a payload opens with; the sealing command mirrors it | none |
41
43
  | `documents.css` | The documents' styles, at `@atelic-action/artifact/styles/documents.css` | every route |
42
44
 
43
- What a document is built from stays in `@atelic-action/ui`, and this package imports it rather than copying it: `GradeChip`, `gradeLabel`, `SignOff`, `Shot` (the owner's screen), `Paragraph`, `Runs`, `isEmpty`, `ArtifactAnchor` (the plain data paragraph of the note and the read), and `newTabProps` from `@atelic-action/ui/artifact`; `Callout`, `Checklist`, `ColumnChart`, `DataTable`, `Disclosure`, `StackedBar`, `StatRow`, `ThresholdScale`, and their types from `@atelic-action/ui/components`; `Button` from `@atelic-action/ui/primitives`. The shell around a page (`ArtifactShell`), the head (`buildPageHead`), the contract (`ArtifactConfig`), the reader helpers, the error boundary, the access wall, the Vite configuration, and the commands are ui's too, and a site imports them from there.
45
+ What a document is built from stays in `@atelic-action/ui`, and this package imports it rather than copying it: `GradeChip`, `gradeLabel`, `SignOff`, `Shot` (the owner's screen), `Paragraph`, `Runs`, `isEmpty`, `ArtifactAnchor` (the plain data paragraph of the note and the read), and `newTabProps` from `@atelic-action/ui/artifact`; `Callout`, `Checklist`, `ColumnChart`, `DataTable`, `Disclosure`, `StackedBar`, `StatRow`, `Tabs`, `ThresholdScale`, and their types from `@atelic-action/ui/components`; `Button` from `@atelic-action/ui/primitives`. The shell around a page (`ArtifactShell`), the head (`buildPageHead`), the contract (`ArtifactConfig`), the reader helpers, the error boundary, the access wall, the Vite configuration, and the commands are ui's too, and a site imports them from there.
44
46
 
45
47
  ## Install
46
48
 
@@ -48,7 +50,7 @@ What a document is built from stays in `@atelic-action/ui`, and this package imp
48
50
  bun add @atelic-action/artifact @atelic-action/ui
49
51
  ```
50
52
 
51
- Peer dependencies: `@atelic-action/ui` at `^3.9.1`, `react` at `^19.0.0`, and `lucide-react` at `^1.23.0`. The package ships TSX and CSS as source, compiled inside the site's own Vite like ui's; a site whose `vite.config.ts` is one `artifactConfig` call from `@atelic-action/ui/vite` needs nothing more, for the build, the prerender, and the dev server alike. The default labels a document sets live in the document: the read's `readLabels` and the sealed document's gate copy are overridden through props, and the monthly page's four questions are fixed on purpose, since they are its contract with the Statement of Work.
53
+ Peer dependencies: `@atelic-action/ui` at `^3.10.0`, `react` at `^19.0.0`, and `lucide-react` at `^1.23.0`. The package ships TSX and CSS as source, compiled inside the site's own Vite like ui's; a site whose `vite.config.ts` is one `artifactConfig` call from `@atelic-action/ui/vite` needs nothing more, for the build, the prerender, and the dev server alike. The default labels a document sets live in the document: the read's `readLabels` and the sealed document's gate copy are overridden through props, and the monthly page's four questions are fixed on purpose, since they are its contract with the Statement of Work.
52
54
 
53
55
  ```tsx
54
56
  import { TheRead } from "@atelic-action/artifact";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/artifact",
3
- "version": "5.1.2",
3
+ "version": "5.2.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.9.1",
40
+ "@atelic-action/ui": "^3.10.0",
41
41
  "lucide-react": "^1.23.0",
42
42
  "react": "^19.0.0"
43
43
  },
44
44
  "devDependencies": {
45
- "@atelic-action/ui": "^3.9.1",
45
+ "@atelic-action/ui": "^3.10.0",
46
46
  "@biomejs/biome": "^2.0.0",
47
47
  "@playwright/test": "^1.60.0",
48
48
  "@sentry/react": "^10.0.0",
@@ -11,7 +11,7 @@ import {
11
11
  type WriteupGrade,
12
12
  type WriteupPerson,
13
13
  } from "@atelic-action/ui/artifact";
14
- import { IconList, ReportCard, type Tone } from "@atelic-action/ui/components";
14
+ import { IconList, ReportCard, Tabs, type Tone } from "@atelic-action/ui/components";
15
15
  import { ArrowUpRight, Check } from "lucide-react";
16
16
  import type { ReactNode } from "react";
17
17
  import { isRuns, type ReadTopic, Topic } from "./Topic";
@@ -42,6 +42,16 @@ import { isRuns, type ReadTopic, Topic } from "./Topic";
42
42
  * routine can write a read as JSON. `print` is the printed copy's own: the
43
43
  * sender and recipient lockup where the header was, and the pointer to the
44
44
  * hosted twin with its QR under the sign off.
45
+ *
46
+ * A read may carry a plan document beside it. Then the head stays on top,
47
+ * a row of two tabs follows, The Plan first and The Read second, and the
48
+ * sign off stays at the foot under both. The plan tab holds the plan as a
49
+ * document of its own: a lede, then sections, each a heading, an optional
50
+ * timeline line, and paragraphs, lists, and tables, with parts under it. The
51
+ * read tab holds everything that sat between the head and the foot before.
52
+ * On paper there are no tabs: the plan prints first, then the read on a new
53
+ * page, each under its tab's name. Without a plan document the page is the
54
+ * one it always was.
45
55
  */
46
56
 
47
57
  /** Whether anything on the page came from the owner's own consoles. */
@@ -137,7 +147,11 @@ export interface ReadStep {
137
147
  cards?: ReadLink[];
138
148
  }
139
149
 
140
- /** The plan: the steps in order, numbered, between the gist and the cards, so the whole program reads before any one card's detail. */
150
+ /**
151
+ * The plan: the steps in order, numbered, between the gist and the cards, so the whole program reads before any one card's detail.
152
+ *
153
+ * Superseded by the plan tab (`planDocument`), and to be removed at the next major.
154
+ */
141
155
  export interface ReadPlan {
142
156
  /** The anchor a header stop points at, e.g. "plan". */
143
157
  id?: string;
@@ -148,6 +162,51 @@ export interface ReadPlan {
148
162
  steps: ReadStep[];
149
163
  }
150
164
 
165
+ /**
166
+ * One block of the plan document's running text: a paragraph, a bulleted or
167
+ * numbered list, or a table with an optional head row. Every piece of text is
168
+ * a paragraph, so a link into the read is a run with an anchor, e.g.
169
+ * `{ text: "the map", href: "#local-seo" }`. A block with nothing in it is
170
+ * left out.
171
+ */
172
+ export type PlanBlock =
173
+ | { kind: "paragraph"; text: ReadParagraph }
174
+ | { kind: "bullets"; items: ReadParagraph[] }
175
+ | { kind: "numbered"; items: ReadParagraph[] }
176
+ | { kind: "table"; head?: string[]; rows: ReadParagraph[][] };
177
+
178
+ /** A part of a plan section: a smaller heading under the section's, with its own body. */
179
+ export interface PlanPart {
180
+ /** The anchor a header stop or a link points at, e.g. "month-one". */
181
+ id: string;
182
+ heading: string;
183
+ /** A short line under the heading saying when and whose, e.g. "2026-11, on me". */
184
+ timeline?: string;
185
+ blocks: PlanBlock[];
186
+ }
187
+
188
+ /** A section of the plan document: a heading, an optional timeline line, its blocks, then its parts. */
189
+ export interface PlanSection extends PlanPart {
190
+ parts?: PlanPart[];
191
+ }
192
+
193
+ /** The plan as a document of its own, set in the plan tab ahead of the read. */
194
+ export interface PlanDocument {
195
+ /** The tab's id, so `#plan` opens it; `thePlanTabId` when left out. Never the read tab's id. */
196
+ id?: string;
197
+ /** The tab's name; the `planTab` label when left out. */
198
+ title?: string;
199
+ /** A paragraph over the sections. */
200
+ lede?: ReadParagraph;
201
+ sections: PlanSection[];
202
+ }
203
+
204
+ /** The read tab's id, so a route holding the selected tab can tell the two apart. */
205
+ export const theReadTabId = "read";
206
+
207
+ /** The plan tab's id when the plan document names none. */
208
+ export const thePlanTabId = "plan";
209
+
151
210
  /** The close: one line, then the sign off. */
152
211
  export interface ReadClose {
153
212
  /** One sentence, e.g. what follows and when. */
@@ -194,9 +253,15 @@ export interface ReadLabels {
194
253
  plan: string;
195
254
  /** The note over the scorecard on a public read that sets none: what the read leaves out. */
196
255
  publicNote: string;
256
+ /** The plan tab's name when the plan document names none. */
257
+ planTab?: string;
258
+ /** The read tab's name. */
259
+ readTab?: string;
260
+ /** What a screen reader calls the row of tabs. */
261
+ tabsLabel?: string;
197
262
  }
198
263
 
199
- export const readLabels: ReadLabels = {
264
+ export const readLabels: Required<ReadLabels> = {
200
265
  preparedFor: "Prepared for",
201
266
  gist: "The Gist",
202
267
  grade: "Grade",
@@ -211,6 +276,9 @@ export const readLabels: ReadLabels = {
211
276
  plan: "The Plan",
212
277
  publicNote:
213
278
  "This page is read from the outside, through what anyone can open. A customer's read adds what the owner's own consoles show.",
279
+ planTab: "The Plan",
280
+ readTab: "The Read",
281
+ tabsLabel: "The plan and the read",
214
282
  };
215
283
 
216
284
  export interface TheReadProps {
@@ -235,7 +303,14 @@ export interface TheReadProps {
235
303
  gradeKey?: Array<{ grade: WriteupGrade; label: string }>;
236
304
  /** The tiers in order. Each section appears on the scorecard and in the body. */
237
305
  tiers: ReadTier[];
306
+ /** The numbered steps between the scorecard and the cards. Superseded by `planDocument`, and to be removed at the next major. */
238
307
  plan: ReadPlan;
308
+ /** The plan as a document of its own: set, the page becomes two tabs, The Plan first and The Read second. */
309
+ planDocument?: PlanDocument;
310
+ /** Held by the caller: the open tab's id. Pass `onSelectTab` with it; the plan tab opens first when left out. */
311
+ selectedTab?: string;
312
+ /** Called with a tab's id whenever the reader, a link, or a hash opens it, e.g. to swap the header's stops. */
313
+ onSelectTab?: (id: string) => void;
239
314
  close: ReadClose;
240
315
  labels?: Partial<ReadLabels>;
241
316
  /** The printed copy's lockup and twin; the page prints without them when left out. */
@@ -512,6 +587,111 @@ function Plan({ plan, labels }: { plan: ReadPlan; labels: ReadLabels }) {
512
587
  );
513
588
  }
514
589
 
590
+ /** The plan document's blocks in order, each left out when it has nothing in it. */
591
+ function PlanBlocks({ blocks }: { blocks: PlanBlock[] | undefined }) {
592
+ return (
593
+ <>
594
+ {(blocks ?? []).map((block, i) => (
595
+ // biome-ignore lint/suspicious/noArrayIndexKey: blocks are static content in a fixed order
596
+ <PlanBlockView block={block} key={i} />
597
+ ))}
598
+ </>
599
+ );
600
+ }
601
+
602
+ function PlanBlockView({ block }: { block: PlanBlock | undefined | null }) {
603
+ if (!block) return null;
604
+ if (block.kind === "paragraph") {
605
+ return filled(block.text) ? <Paragraph paragraph={block.text} /> : null;
606
+ }
607
+ if (block.kind === "bullets" || block.kind === "numbered") {
608
+ const items = (block.items ?? []).filter(filled);
609
+ if (items.length === 0) return null;
610
+ const List = block.kind === "bullets" ? "ul" : "ol";
611
+ return (
612
+ <List className="rd-plan-list">
613
+ {items.map((item, i) => (
614
+ // biome-ignore lint/suspicious/noArrayIndexKey: items are static content in a fixed order
615
+ <li key={i}>
616
+ <Runs paragraph={item} />
617
+ </li>
618
+ ))}
619
+ </List>
620
+ );
621
+ }
622
+ if (block.kind === "table") {
623
+ const rows = (block.rows ?? []).filter((row) => (row ?? []).some(filled));
624
+ if (rows.length === 0) return null;
625
+ const head = (block.head ?? []).some((cell) => cell?.trim()) ? (block.head ?? []) : undefined;
626
+ const columns = Math.max(head?.length ?? 0, ...rows.map((row) => row.length));
627
+ return (
628
+ <table className="rd-plan-table">
629
+ {head && (
630
+ <thead>
631
+ <tr>
632
+ {Array.from({ length: columns }, (_, i) => (
633
+ // biome-ignore lint/suspicious/noArrayIndexKey: columns are static content in a fixed order
634
+ <th scope="col" key={i}>
635
+ {head[i] ?? ""}
636
+ </th>
637
+ ))}
638
+ </tr>
639
+ </thead>
640
+ )}
641
+ <tbody>
642
+ {rows.map((row, r) => (
643
+ // biome-ignore lint/suspicious/noArrayIndexKey: rows are static content in a fixed order
644
+ <tr key={r}>
645
+ {Array.from({ length: columns }, (_, i) => {
646
+ const cell = row[i];
647
+ const label = head?.[i]?.trim();
648
+ return (
649
+ // biome-ignore lint/suspicious/noArrayIndexKey: cells are static content in a fixed order
650
+ <td key={i} data-label={label || undefined}>
651
+ {filled(cell) && <Runs paragraph={cell} />}
652
+ </td>
653
+ );
654
+ })}
655
+ </tr>
656
+ ))}
657
+ </tbody>
658
+ </table>
659
+ );
660
+ }
661
+ return null;
662
+ }
663
+
664
+ /** A section's or a part's body under its heading: the timeline line, then the blocks. */
665
+ function PlanBody({ part }: { part: PlanPart }) {
666
+ return (
667
+ <>
668
+ {part.timeline?.trim() && <p className="rd-plan-timeline">{part.timeline}</p>}
669
+ <PlanBlocks blocks={part.blocks} />
670
+ </>
671
+ );
672
+ }
673
+
674
+ /** The plan tab's panel: the lede, then each section under its heading, its parts under theirs. */
675
+ function PlanDocumentView({ doc }: { doc: PlanDocument }) {
676
+ return (
677
+ <div className="rd-plan-doc">
678
+ {filled(doc.lede) && <Paragraph paragraph={doc.lede} className="rd-plan-doc-lede" />}
679
+ {(doc.sections ?? []).filter(Boolean).map((section) => (
680
+ <section className="rd-plan-section" id={section.id} key={section.id}>
681
+ <h2>{section.heading}</h2>
682
+ <PlanBody part={section} />
683
+ {(section.parts ?? []).filter(Boolean).map((part) => (
684
+ <section className="rd-plan-part" id={part.id} key={part.id}>
685
+ <h3>{part.heading}</h3>
686
+ <PlanBody part={part} />
687
+ </section>
688
+ ))}
689
+ </section>
690
+ ))}
691
+ </div>
692
+ );
693
+ }
694
+
515
695
  function Tier({ tier, labels }: { tier: ReadTier; labels: ReadLabels }) {
516
696
  const rented =
517
697
  tier.rented && (isRuns(tier.rented.text) ? filled(tier.rented.text) : tier.rented.text != null)
@@ -554,6 +734,9 @@ export function TheRead({
554
734
  gradeKey,
555
735
  tiers,
556
736
  plan,
737
+ planDocument,
738
+ selectedTab,
739
+ onSelectTab,
557
740
  close,
558
741
  labels: overrides,
559
742
  print,
@@ -562,6 +745,29 @@ export function TheRead({
562
745
  const shownTiers = tiers.filter((tier) => tier.sections.length > 0);
563
746
  const shownNote = filled(note) ? note : access === "public" ? labels.publicNote : undefined;
564
747
  const signOff = Boolean(close.signOff && close.person);
748
+ const planTab = planDocument?.id?.trim() || thePlanTabId;
749
+ // Everything between the head and the foot: the whole page without a plan
750
+ // document, the read tab's panel with one.
751
+ const body = (
752
+ <>
753
+ {shownTiers.length > 0 && (
754
+ <Gist
755
+ tiers={shownTiers}
756
+ id={gistId}
757
+ title={gistTitle}
758
+ lead={gistLead}
759
+ note={shownNote}
760
+ gradeKey={gradeKey}
761
+ labels={labels}
762
+ />
763
+ )}
764
+ {plan.steps.length > 0 && <Plan plan={plan} labels={labels} />}
765
+ {shownTiers.map((tier) => (
766
+ <Tier tier={tier} labels={labels} key={tier.title} />
767
+ ))}
768
+ {filled(close.line) && <Paragraph paragraph={close.line} className="rd-closing" />}
769
+ </>
770
+ );
565
771
  return (
566
772
  <div className="op rd" data-access={access}>
567
773
  <article className="op-doc rd-doc">
@@ -596,22 +802,26 @@ export function TheRead({
596
802
  <h1>{title}</h1>
597
803
  {filled(lede) && <Paragraph paragraph={lede} className="op-intro rd-lede" />}
598
804
  </header>
599
- {shownTiers.length > 0 && (
600
- <Gist
601
- tiers={shownTiers}
602
- id={gistId}
603
- title={gistTitle}
604
- lead={gistLead}
605
- note={shownNote}
606
- gradeKey={gradeKey}
607
- labels={labels}
805
+ {planDocument ? (
806
+ <Tabs
807
+ className="rd-tabs"
808
+ ariaLabel={labels.tabsLabel ?? readLabels.tabsLabel}
809
+ headingLevel={2}
810
+ defaultId={planTab}
811
+ selectedId={selectedTab}
812
+ onSelect={onSelectTab}
813
+ tabs={[
814
+ {
815
+ id: planTab,
816
+ label: planDocument.title?.trim() || (labels.planTab ?? readLabels.planTab),
817
+ content: <PlanDocumentView doc={planDocument} />,
818
+ },
819
+ { id: theReadTabId, label: labels.readTab ?? readLabels.readTab, content: body },
820
+ ]}
608
821
  />
822
+ ) : (
823
+ body
609
824
  )}
610
- {plan.steps.length > 0 && <Plan plan={plan} labels={labels} />}
611
- {shownTiers.map((tier) => (
612
- <Tier tier={tier} labels={labels} key={tier.title} />
613
- ))}
614
- {filled(close.line) && <Paragraph paragraph={close.line} className="rd-closing" />}
615
825
  {(signOff || print?.twin) && (
616
826
  <footer className="op-close rd-close">
617
827
  {signOff && close.signOff && close.person && (
@@ -935,7 +935,8 @@
935
935
  margin: 34px 0 10px;
936
936
  scroll-margin-top: calc(var(--nav-height) + 16px);
937
937
  }
938
- .atelic-ui .op-part h2 {
938
+ .atelic-ui .op-part h2,
939
+ .atelic-ui .rd-plan-section > h2 {
939
940
  font-size: 27px;
940
941
  font-weight: 650;
941
942
  letter-spacing: -0.025em;
@@ -1494,7 +1495,8 @@
1494
1495
  .atelic-ui .op-part {
1495
1496
  margin: 40px 0 12px;
1496
1497
  }
1497
- .atelic-ui .op-part h2 {
1498
+ .atelic-ui .op-part h2,
1499
+ .atelic-ui .rd-plan-section > h2 {
1498
1500
  font-size: 30px;
1499
1501
  }
1500
1502
  .atelic-ui .op-part p {
@@ -2100,6 +2102,7 @@
2100
2102
  .atelic-ui .rd-doc .op-why a,
2101
2103
  .atelic-ui .rd-beat a,
2102
2104
  .atelic-ui .rd-plan a,
2105
+ .atelic-ui .rd-plan-doc a,
2103
2106
  .atelic-ui .rd-closing a {
2104
2107
  color: var(--op-accent);
2105
2108
  text-decoration: underline;
@@ -2154,13 +2157,15 @@
2154
2157
 
2155
2158
  /* Today and after: what a thing is, and what it becomes. Two columns
2156
2159
  wide; on a phone each row stacks, each half wearing its label. */
2157
- .atelic-ui .rd-changes-table {
2160
+ .atelic-ui .rd-changes-table,
2161
+ .atelic-ui .rd-plan-table {
2158
2162
  width: 100%;
2159
2163
  border-collapse: collapse;
2160
2164
  font-size: 15px;
2161
2165
  line-height: 1.45;
2162
2166
  }
2163
- .atelic-ui .rd-changes-table thead {
2167
+ .atelic-ui .rd-changes-table thead,
2168
+ .atelic-ui .rd-plan-table thead {
2164
2169
  position: absolute;
2165
2170
  width: 1px;
2166
2171
  height: 1px;
@@ -2171,28 +2176,38 @@
2171
2176
  .atelic-ui .rd-changes-table,
2172
2177
  .atelic-ui .rd-changes-table tbody,
2173
2178
  .atelic-ui .rd-changes-table tr,
2174
- .atelic-ui .rd-changes-table td {
2179
+ .atelic-ui .rd-changes-table td,
2180
+ .atelic-ui .rd-plan-table,
2181
+ .atelic-ui .rd-plan-table tbody,
2182
+ .atelic-ui .rd-plan-table tr,
2183
+ .atelic-ui .rd-plan-table td {
2175
2184
  display: block;
2176
2185
  }
2177
- .atelic-ui .rd-changes-table tbody tr {
2186
+ .atelic-ui .rd-changes-table tbody tr,
2187
+ .atelic-ui .rd-plan-table tbody tr {
2178
2188
  padding: 10px 0;
2179
2189
  border-bottom: 1px solid var(--neutral-100);
2180
2190
  }
2181
- .atelic-ui .rd-changes-table tbody tr:first-child {
2191
+ .atelic-ui .rd-changes-table tbody tr:first-child,
2192
+ .atelic-ui .rd-plan-table tbody tr:first-child {
2182
2193
  border-top: 1.5px solid var(--op-ink);
2183
2194
  }
2184
- .atelic-ui .rd-changes-table tbody tr:last-child {
2195
+ .atelic-ui .rd-changes-table tbody tr:last-child,
2196
+ .atelic-ui .rd-plan-table tbody tr:last-child {
2185
2197
  border-bottom: 0;
2186
2198
  padding-bottom: 0;
2187
2199
  }
2188
- .atelic-ui .rd-changes-table td {
2200
+ .atelic-ui .rd-changes-table td,
2201
+ .atelic-ui .rd-plan-table td {
2189
2202
  text-align: left;
2190
2203
  text-wrap: pretty;
2191
2204
  }
2192
- .atelic-ui .rd-changes-table td + td {
2205
+ .atelic-ui .rd-changes-table td + td,
2206
+ .atelic-ui .rd-plan-table td + td {
2193
2207
  margin-top: 6px;
2194
2208
  }
2195
- .atelic-ui .rd-changes-table td::before {
2209
+ .atelic-ui .rd-changes-table td::before,
2210
+ .atelic-ui .rd-plan-table td[data-label]::before {
2196
2211
  content: attr(data-label);
2197
2212
  display: block;
2198
2213
  margin-bottom: 1px;
@@ -2339,6 +2354,78 @@
2339
2354
  color: var(--op-ink-soft);
2340
2355
  }
2341
2356
 
2357
+ /* ---- the plan tab: the row of tabs under the head, and the plan as a
2358
+ document, its sections set as the tiers are and its tables the today
2359
+ and after table's ---- */
2360
+ .atelic-ui .rd-tabs {
2361
+ margin-top: 28px;
2362
+ }
2363
+ .atelic-ui .rd-plan-doc-lede {
2364
+ margin: 0;
2365
+ max-width: 68ch;
2366
+ font-size: 16.5px;
2367
+ line-height: 1.5;
2368
+ color: var(--op-ink-soft);
2369
+ text-wrap: pretty;
2370
+ }
2371
+ .atelic-ui .rd-plan-section {
2372
+ margin-top: 34px;
2373
+ scroll-margin-top: calc(var(--nav-height) + 16px);
2374
+ }
2375
+ .atelic-ui .rd-plan-doc > .rd-plan-section:first-child {
2376
+ margin-top: 0;
2377
+ }
2378
+ .atelic-ui .rd-plan-part {
2379
+ margin-top: 24px;
2380
+ scroll-margin-top: calc(var(--nav-height) + 16px);
2381
+ }
2382
+ .atelic-ui .rd-plan-part > h3 {
2383
+ margin: 0;
2384
+ font-size: 20px;
2385
+ font-weight: 650;
2386
+ letter-spacing: -0.015em;
2387
+ line-height: 1.2;
2388
+ color: var(--op-ink);
2389
+ }
2390
+ .atelic-ui .rd-plan-timeline {
2391
+ margin: 6px 0 0;
2392
+ font-family: var(--font-mono);
2393
+ font-size: 12.5px;
2394
+ letter-spacing: 0.04em;
2395
+ color: var(--op-ink-faint);
2396
+ }
2397
+ .atelic-ui .rd-plan-section > p:not(.rd-plan-timeline),
2398
+ .atelic-ui .rd-plan-part > p:not(.rd-plan-timeline) {
2399
+ margin: 12px 0 0;
2400
+ max-width: 68ch;
2401
+ color: var(--op-ink);
2402
+ text-wrap: pretty;
2403
+ }
2404
+ .atelic-ui .rd-plan-list {
2405
+ margin: 12px 0 0;
2406
+ padding-left: 1.4em;
2407
+ max-width: 68ch;
2408
+ color: var(--op-ink);
2409
+ }
2410
+ .atelic-ui ul.rd-plan-list {
2411
+ list-style: disc;
2412
+ }
2413
+ .atelic-ui ol.rd-plan-list {
2414
+ list-style: decimal;
2415
+ }
2416
+ .atelic-ui .rd-plan-list li + li {
2417
+ margin-top: 6px;
2418
+ }
2419
+ .atelic-ui .rd-plan-list li::marker {
2420
+ color: var(--op-accent);
2421
+ }
2422
+ .atelic-ui .rd-plan-table {
2423
+ margin-top: 14px;
2424
+ }
2425
+ .atelic-ui .rd-plan-table td {
2426
+ color: var(--op-ink);
2427
+ }
2428
+
2342
2429
  /* ---- the close: one line, then the sign off under its rule ---- */
2343
2430
  .atelic-ui .rd-closing {
2344
2431
  margin: 34px 0 0;
@@ -2354,7 +2441,11 @@
2354
2441
  display: table;
2355
2442
  table-layout: fixed;
2356
2443
  }
2357
- .atelic-ui .rd-changes-table thead {
2444
+ .atelic-ui .rd-plan-table {
2445
+ display: table;
2446
+ }
2447
+ .atelic-ui .rd-changes-table thead,
2448
+ .atelic-ui .rd-plan-table thead {
2358
2449
  display: table-header-group;
2359
2450
  position: static;
2360
2451
  width: auto;
@@ -2362,17 +2453,22 @@
2362
2453
  overflow: visible;
2363
2454
  clip: auto;
2364
2455
  }
2365
- .atelic-ui .rd-changes-table tbody {
2456
+ .atelic-ui .rd-changes-table tbody,
2457
+ .atelic-ui .rd-plan-table tbody {
2366
2458
  display: table-row-group;
2367
2459
  }
2368
2460
  .atelic-ui .rd-changes-table tr,
2369
- .atelic-ui .rd-changes-table tbody tr {
2461
+ .atelic-ui .rd-changes-table tbody tr,
2462
+ .atelic-ui .rd-plan-table tr,
2463
+ .atelic-ui .rd-plan-table tbody tr {
2370
2464
  display: table-row;
2371
2465
  padding: 0;
2372
2466
  border: 0;
2373
2467
  }
2374
2468
  .atelic-ui .rd-changes-table th,
2375
- .atelic-ui .rd-changes-table td {
2469
+ .atelic-ui .rd-changes-table td,
2470
+ .atelic-ui .rd-plan-table th,
2471
+ .atelic-ui .rd-plan-table td {
2376
2472
  display: table-cell;
2377
2473
  width: 50%;
2378
2474
  padding: 9px 18px 9px 0;
@@ -2380,20 +2476,34 @@
2380
2476
  text-align: left;
2381
2477
  vertical-align: top;
2382
2478
  }
2479
+ /* Today and after splits in half; the plan's columns take what they need. */
2480
+ .atelic-ui .rd-plan-table th,
2481
+ .atelic-ui .rd-plan-table td {
2482
+ width: auto;
2483
+ }
2383
2484
  .atelic-ui .rd-changes-table th + th,
2384
- .atelic-ui .rd-changes-table td + td {
2485
+ .atelic-ui .rd-changes-table td + td,
2486
+ .atelic-ui .rd-plan-table th + th,
2487
+ .atelic-ui .rd-plan-table td + td {
2385
2488
  margin-top: 0;
2386
2489
  padding-left: 18px;
2387
2490
  padding-right: 0;
2388
2491
  border-left: 1px solid var(--neutral-100);
2389
2492
  }
2390
- .atelic-ui .rd-changes-table td::before {
2493
+ .atelic-ui .rd-changes-table td::before,
2494
+ .atelic-ui .rd-plan-table td[data-label]::before {
2391
2495
  content: none;
2392
2496
  }
2393
- .atelic-ui .rd-changes-table tbody tr:first-child {
2497
+ .atelic-ui .rd-changes-table tbody tr:first-child,
2498
+ .atelic-ui .rd-plan-table tbody tr:first-child {
2394
2499
  border-top: 0;
2395
2500
  }
2396
- .atelic-ui .rd-changes-table thead th {
2501
+ /* A plan table with no head row keeps its rule over the first row. */
2502
+ .atelic-ui .rd-plan-table:not(:has(thead)) tbody tr:first-child td {
2503
+ border-top: 1.5px solid var(--op-ink);
2504
+ }
2505
+ .atelic-ui .rd-changes-table thead th,
2506
+ .atelic-ui .rd-plan-table thead th {
2397
2507
  padding-top: 0;
2398
2508
  padding-bottom: 8px;
2399
2509
  border-bottom: 1.5px solid var(--op-ink);
@@ -2407,7 +2517,8 @@
2407
2517
  .atelic-ui .rd-changes-table thead th.rd-after {
2408
2518
  color: var(--op-accent);
2409
2519
  }
2410
- .atelic-ui .rd-changes-table tbody tr:last-child td {
2520
+ .atelic-ui .rd-changes-table tbody tr:last-child td,
2521
+ .atelic-ui .rd-plan-table tbody tr:last-child td {
2411
2522
  border-bottom: 0;
2412
2523
  }
2413
2524
  }
@@ -2420,6 +2531,8 @@
2420
2531
  border-color: var(--neutral-200);
2421
2532
  }
2422
2533
  .atelic-ui .rd-changes-table tr,
2534
+ .atelic-ui .rd-plan-table tr,
2535
+ .atelic-ui .rd-plan-list li,
2423
2536
  .atelic-ui .rd-checks li,
2424
2537
  .atelic-ui .rd-closing,
2425
2538
  .atelic-ui .rd-note,
@@ -2428,9 +2541,27 @@
2428
2541
  break-inside: avoid;
2429
2542
  }
2430
2543
  .atelic-ui .rd-beat h4,
2431
- .atelic-ui .rd-plan h2 {
2544
+ .atelic-ui .rd-plan h2,
2545
+ .atelic-ui .rd-plan-section h2,
2546
+ .atelic-ui .rd-plan-part h3,
2547
+ .atelic-ui .rd-plan-timeline,
2548
+ .atelic-ui .rd-tabs .tabs-print-heading {
2432
2549
  break-after: avoid;
2433
2550
  }
2551
+ /* The plan prints first and the read starts a page of its own, each
2552
+ under its tab's name, set as a tier's heading is. */
2553
+ .atelic-ui .rd-tabs > .tabs-panel + .tabs-panel {
2554
+ break-before: page;
2555
+ margin-top: 0;
2556
+ }
2557
+ .atelic-ui .rd-tabs .tabs-print-heading {
2558
+ margin: 0 0 18px;
2559
+ font-size: 30px;
2560
+ font-weight: 650;
2561
+ letter-spacing: -0.025em;
2562
+ line-height: 1.1;
2563
+ color: var(--op-ink);
2564
+ }
2434
2565
  }
2435
2566
 
2436
2567
  /* ---- the first call sheet (FirstCall): the read's topic and findings