@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.
@@ -55,7 +55,8 @@ export function artifactConfig({ pages, extend }: ArtifactConfigOptions): UserCo
55
55
  const config: UserConfig = {
56
56
  // Every stylesheet rides in the one base sheet the root route links
57
57
  // (src/styles/base.css), the documents' own included, since they come from
58
- // @atelic-action/ui. Nothing imports CSS from a component, so there is no
58
+ // @atelic-action/artifact and its pieces from @atelic-action/ui. Nothing
59
+ // imports CSS from a component, so there is no
59
60
  // component bundle to fold: `build.cssCodeSplit: false`, which sat here to
60
61
  // fold it, now fails the build, because TanStack Start looks for the
61
62
  // `style.css` that setting emits and none is written. A site that gives a
@@ -95,10 +96,11 @@ export function artifactConfig({ pages, extend }: ArtifactConfigOptions): UserCo
95
96
  "@": resolve(process.cwd(), "./src"),
96
97
  },
97
98
  },
98
- // @atelic-action/ui ships TSX and CSS as written, so the prerender's server build
99
- // has to compile it rather than hand it to Node as an external.
99
+ // @atelic-action/ui and @atelic-action/artifact (the documents) both ship
100
+ // TSX and CSS as written, so the prerender's server build has to compile
101
+ // them rather than hand them to Node as externals.
100
102
  ssr: {
101
- noExternal: ["@atelic-action/ui"],
103
+ noExternal: ["@atelic-action/ui", "@atelic-action/artifact"],
102
104
  },
103
105
  };
104
106
  return extend ? mergeConfig(config, extend) : config;
@@ -1,340 +0,0 @@
1
- import { type FormEvent, useEffect, useRef, useState } from "react";
2
- import { Button } from "../primitives/Button";
3
-
4
- /**
5
- * PBKDF2 work factor and hash. Mirrored by scripts/seal-document.mjs;
6
- * the two must agree exactly or every sealed payload stops opening.
7
- */
8
- const PBKDF2_ITERATIONS = 200000;
9
- const PBKDF2_HASH = "SHA-256";
10
-
11
- declare global {
12
- interface Window {
13
- /** HubSpot tracking queue; the loader drains anything pushed before it loads. */
14
- _hsq?: unknown[];
15
- }
16
- }
17
-
18
- export interface GatePerson {
19
- /** Stable handle for the reader, used in the payload and in `onUnlock`. */
20
- id: string;
21
- /**
22
- * Never read, and a payload sealed today does not carry it. The reader's
23
- * address is the secret their key derives from, so a payload that lists
24
- * it hands the key to anyone who reads the page's script; payloads sealed
25
- * before 2026-10-08 did. The address a reader types is the one this
26
- * component uses.
27
- */
28
- email?: string;
29
- /** Base64: this reader's PBKDF2 salt, GCM nonce, and wrapped content key. */
30
- salt: string;
31
- iv: string;
32
- wrapped: string;
33
- }
34
-
35
- export interface GatePayload {
36
- /** Base64: the document's GCM nonce and ciphertext. */
37
- civ: string;
38
- ct: string;
39
- people: GatePerson[];
40
- }
41
-
42
- export interface GateProps {
43
- /** Built by scripts/seal-document.mjs. Carries no plaintext. */
44
- payload: GatePayload;
45
- /** localStorage key holding the reader's email, so a return visit walks straight in. */
46
- storageKey?: string;
47
- /**
48
- * Query parameter a sent link may carry the reader's email in, so the
49
- * link opens the document in one click. Checked once on mount and
50
- * stripped from the address bar either way.
51
- */
52
- queryParam?: string;
53
- /**
54
- * When set, a reader whose email ends with this domain is pushed to
55
- * HubSpot on unlock, so the open lands on their contact timeline.
56
- * Readers on another domain identify nobody. Leave it unset and the
57
- * gate tracks no one.
58
- */
59
- identifyDomain?: string;
60
- /** Gate copy. Defaults are neutral; a client document overrides what it needs. */
61
- kicker?: string;
62
- heading?: string;
63
- sub?: string;
64
- placeholder?: string;
65
- inputLabel?: string;
66
- errorText?: string;
67
- /** Fired once, after a successful unlock. */
68
- onUnlock?: (person: GatePerson) => void;
69
- }
70
-
71
- /**
72
- * An email is its letters and digits, uppercased, so "Dana@Example.com",
73
- * "dana@example.com" and even a stray space all open the same document.
74
- * Mirrored in the build script.
75
- */
76
- export function normalizeEmail(email: string): string {
77
- return email.toUpperCase().replace(/[^A-Z0-9]/g, "");
78
- }
79
-
80
- function fromBase64(value: string): Uint8Array {
81
- const binary = atob(value);
82
- const bytes = new Uint8Array(binary.length);
83
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
84
- return bytes;
85
- }
86
-
87
- interface Opened {
88
- person: GatePerson;
89
- /** The address that opened the document, as the reader gave it. */
90
- email: string;
91
- html: string;
92
- }
93
-
94
- /**
95
- * Try the entered email against every reader in the payload. An email that
96
- * matches nobody fails the GCM tag check on each attempt and returns null,
97
- * which is the only signal the gate ever gives: it never reveals who is on
98
- * the list or how close a guess came.
99
- */
100
- async function openDocument(payload: GatePayload, rawEmail: string): Promise<Opened | null> {
101
- const subtle = globalThis.crypto?.subtle;
102
- if (!subtle) return null;
103
- const normalized = normalizeEmail(rawEmail);
104
- if (!normalized) return null;
105
-
106
- const material = await subtle.importKey(
107
- "raw",
108
- new TextEncoder().encode(normalized),
109
- "PBKDF2",
110
- false,
111
- ["deriveKey"],
112
- );
113
-
114
- for (const person of payload.people) {
115
- try {
116
- const wrapper = await subtle.deriveKey(
117
- {
118
- name: "PBKDF2",
119
- salt: fromBase64(person.salt) as BufferSource,
120
- iterations: PBKDF2_ITERATIONS,
121
- hash: PBKDF2_HASH,
122
- },
123
- material,
124
- { name: "AES-GCM", length: 256 },
125
- false,
126
- ["decrypt"],
127
- );
128
- const contentKeyBytes = await subtle.decrypt(
129
- { name: "AES-GCM", iv: fromBase64(person.iv) as BufferSource },
130
- wrapper,
131
- fromBase64(person.wrapped) as BufferSource,
132
- );
133
- const contentKey = await subtle.importKey(
134
- "raw",
135
- contentKeyBytes,
136
- { name: "AES-GCM" },
137
- false,
138
- ["decrypt"],
139
- );
140
- const plain = await subtle.decrypt(
141
- { name: "AES-GCM", iv: fromBase64(payload.civ) as BufferSource },
142
- contentKey,
143
- fromBase64(payload.ct) as BufferSource,
144
- );
145
- return { person, email: rawEmail, html: new TextDecoder().decode(plain) };
146
- } catch {
147
- // Wrong email for this reader. Try the next one; say nothing.
148
- }
149
- }
150
- return null;
151
- }
152
-
153
- /**
154
- * Tells HubSpot who opened the document, from the address the reader gave:
155
- * the payload names no one. The key forgives what the reader types (case,
156
- * spaces, even a missing at sign), so the address is sent only when it reads
157
- * as one, and only on the domain the site names.
158
- */
159
- function identifyReader(rawEmail: string, domain: string | undefined) {
160
- const email = rawEmail.trim().toLowerCase();
161
- if (!domain || !/^[^\s@]+@[^\s@]+$/.test(email)) return;
162
- if (!email.endsWith(`@${domain.toLowerCase()}`)) return;
163
- window._hsq = window._hsq || [];
164
- window._hsq.push(["identify", { email }]);
165
- window._hsq.push(["setPath", window.location.pathname]);
166
- window._hsq.push(["trackPageView"]);
167
- }
168
-
169
- function readStored(key: string): string {
170
- try {
171
- return globalThis.localStorage?.getItem(key) ?? "";
172
- } catch {
173
- return ""; // Storage blocked (private mode); the reader just retypes their email.
174
- }
175
- }
176
-
177
- function writeStored(key: string, value: string) {
178
- try {
179
- globalThis.localStorage?.setItem(key, value);
180
- } catch {
181
- // Same: persistence is a courtesy, never a requirement.
182
- }
183
- }
184
-
185
- /** Read and strip the gate's query parameter in one motion. */
186
- function takeQueryParam(name: string): string {
187
- try {
188
- const url = new URL(window.location.href);
189
- const value = url.searchParams.get(name) ?? "";
190
- if (value) {
191
- url.searchParams.delete(name);
192
- window.history.replaceState(window.history.state, "", url.toString());
193
- }
194
- return value;
195
- } catch {
196
- return "";
197
- }
198
- }
199
-
200
- /**
201
- * The gate: an email field standing in front of a sealed document.
202
- *
203
- * The document ships as ciphertext inside the page, so there is no server to
204
- * ask and nothing to gate at the edge. The reader's own email address derives
205
- * the key that unwraps the content key, the document decrypts in the browser,
206
- * and the gate replaces itself with it. Wrong emails get one line of copy and
207
- * no hint. A sent link can carry the email in a query parameter (`?email=`)
208
- * to open the document in one click.
209
- *
210
- * Sized for a document sent to named people: the reader types the one thing
211
- * they always know, no sign in, no inbox round trip. The access gate
212
- * (ACCESS-GATE.md, GateLogin in this folder) is the other shape, for a whole
213
- * site preview behind an email tied session with inbox verified identity.
214
- */
215
- export function Gate({
216
- payload,
217
- storageKey = "gate-email",
218
- queryParam = "email",
219
- identifyDomain,
220
- kicker = "Private Document",
221
- heading = "Enter Your Email",
222
- sub = "This document opens with the email address it was sent to.",
223
- placeholder = "you@company.com",
224
- inputLabel = "Your email",
225
- errorText = "That email does not open this document. Use the address it was sent to.",
226
- onUnlock,
227
- }: GateProps) {
228
- const [html, setHtml] = useState<string | null>(null);
229
- const [failed, setFailed] = useState(false);
230
- const [busy, setBusy] = useState(false);
231
- const [value, setValue] = useState("");
232
-
233
- // Held in a ref so a caller passing an inline function cannot re-run the
234
- // auto unlock effect on every render.
235
- const onUnlockRef = useRef(onUnlock);
236
- onUnlockRef.current = onUnlock;
237
-
238
- // Silent auto unlock, in order: a sent link's query parameter first, then
239
- // the stored email of a reader who has been here before. A value that no
240
- // longer opens anything (the payload was rebuilt) fails quietly; a failed
241
- // query value pre fills the field and surfaces the error, since the
242
- // reader arrived expecting the door to open.
243
- useEffect(() => {
244
- let cancelled = false;
245
-
246
- async function attempt() {
247
- const fromQuery = takeQueryParam(queryParam);
248
- if (fromQuery) {
249
- const opened = await openDocument(payload, fromQuery);
250
- if (cancelled) return;
251
- if (opened) {
252
- writeStored(storageKey, fromQuery);
253
- identifyReader(opened.email, identifyDomain);
254
- setHtml(opened.html);
255
- onUnlockRef.current?.(opened.person);
256
- return;
257
- }
258
- setValue(fromQuery);
259
- setFailed(true);
260
- return;
261
- }
262
-
263
- const stored = readStored(storageKey);
264
- if (!stored) return;
265
- const opened = await openDocument(payload, stored);
266
- if (cancelled || !opened) return;
267
- identifyReader(opened.email, identifyDomain);
268
- setHtml(opened.html);
269
- onUnlockRef.current?.(opened.person);
270
- }
271
-
272
- attempt();
273
- return () => {
274
- cancelled = true;
275
- };
276
- }, [payload, storageKey, identifyDomain, queryParam]);
277
-
278
- async function handleSubmit(event: FormEvent<HTMLFormElement>) {
279
- event.preventDefault();
280
- if (busy) return;
281
- setBusy(true);
282
- setFailed(false);
283
- const opened = await openDocument(payload, value);
284
- setBusy(false);
285
- if (!opened) {
286
- setFailed(true);
287
- return;
288
- }
289
- writeStored(storageKey, value);
290
- identifyReader(opened.email, identifyDomain);
291
- setHtml(opened.html);
292
- onUnlockRef.current?.(opened.person);
293
- }
294
-
295
- if (html !== null) {
296
- return (
297
- <div className="gate-doc">
298
- <div className="wrap">
299
- {/* biome-ignore lint/security/noDangerouslySetInnerHtml: the document is the practice's own HTML, sealed at build time and decrypted here. Injecting it is the whole point of the component. */}
300
- <div className="gate-content" dangerouslySetInnerHTML={{ __html: html }} />
301
- </div>
302
- </div>
303
- );
304
- }
305
-
306
- return (
307
- <div className="gate-hall">
308
- <div className="wrap">
309
- <div className="gate-card">
310
- {kicker && <p className="gate-eyebrow">{kicker}</p>}
311
- <h1 className="gate-headline">{heading}</h1>
312
- {sub && <p className="gate-lead">{sub}</p>}
313
- <form className="gate-form" onSubmit={handleSubmit} noValidate>
314
- <input
315
- className="gate-input"
316
- type="email"
317
- inputMode="email"
318
- name="email"
319
- value={value}
320
- onChange={(event) => setValue(event.target.value)}
321
- placeholder={placeholder}
322
- aria-label={inputLabel}
323
- autoComplete="email"
324
- autoCapitalize="none"
325
- spellCheck={false}
326
- />
327
- <Button type="submit" size="lg">
328
- {busy ? "Opening…" : "Open Document"}
329
- </Button>
330
- </form>
331
- {failed && (
332
- <p className="gate-error" role="alert">
333
- {errorText}
334
- </p>
335
- )}
336
- </div>
337
- </div>
338
- </div>
339
- );
340
- }
@@ -1,188 +0,0 @@
1
- import { Callout } from "../components";
2
- import { newTabProps } from "../lib/newTabProps";
3
- import { Shot, type WriteupShot } from "./Shot";
4
- import { SignOff, type WriteupPerson } from "./SignOff";
5
-
6
- /**
7
- * The note: the first touch, for a business whose site publishes no email
8
- * address, written as a short page and left at `<short>.atelic.me/notes`. It
9
- * is the first touch email set as a document, so it carries no grades, no
10
- * sections, and no tables: a title, the opener, what the customer sees and
11
- * what sits under the hood, where the work would start, and the close. It
12
- * reads on a phone first and prints to one letter sheet.
13
- *
14
- * It is the writeup's sibling, not a variant of it: a site wraps it in
15
- * ArtifactShell exactly as it wraps the writeup, and it reuses the writeup's
16
- * pieces where they fit (the Callout a finding is set in, the owner's screen,
17
- * the sign off). Every prop is plain data, so a routine can write a note as
18
- * JSON; the one exception is the sign off person's `role`, which takes a node
19
- * and is equally happy with a string.
20
- */
21
-
22
- /** A link inside running text: the words the reader clicks and where they go. */
23
- export interface NoteLink {
24
- text: string;
25
- href: string;
26
- }
27
-
28
- /** A paragraph: a plain string, or runs of text and links in order. */
29
- export type NoteParagraph = string | Array<string | NoteLink>;
30
-
31
- /** One defect and what it costs, with the owner's screen under it when there is one. */
32
- export interface NoteFinding {
33
- /** The defect, in a full sentence. */
34
- lead: string;
35
- /** The consequence: what it costs the business. */
36
- body: string;
37
- /**
38
- * Where the defect can be seen for oneself. The lead links there: in a new
39
- * tab for an http(s) link, in place otherwise.
40
- */
41
- href?: string;
42
- /** The owner's own screen, rendered under the finding. */
43
- shot?: WriteupShot;
44
- }
45
-
46
- /** One side of the read, e.g. "What your customer sees", with one or two findings. */
47
- export interface NoteLane {
48
- heading: string;
49
- findings: NoteFinding[];
50
- }
51
-
52
- /** One place the work would start. */
53
- export interface NoteStep {
54
- /** The step, set in bold. */
55
- lead: string;
56
- /** What it does, after the lead. */
57
- body?: string;
58
- }
59
-
60
- /** Where the work would start: the line that introduces it, then the steps. */
61
- export interface NoteStart {
62
- /** The line over the steps, e.g. "I would start by:". */
63
- lead: string;
64
- items: NoteStep[];
65
- }
66
-
67
- /** The close: who is writing and the offer, then the sign off. */
68
- export interface NoteClose {
69
- /** The sender paragraph, then the offer line with its scheduling link and the exit sentence. */
70
- paragraphs: NoteParagraph[];
71
- signOff: string;
72
- person: WriteupPerson;
73
- }
74
-
75
- export interface NoteProps {
76
- title: string;
77
- /** The business, set in the dateline: "Prepared for <business>, <date>". */
78
- preparedFor: string;
79
- /** The ISO date, e.g. "2026-10-09". */
80
- date: string;
81
- /** The street sighting and a kudos, then the gap sentence. */
82
- opener: NoteParagraph;
83
- /** "What your customer sees" and "What I see under the hood", in that order. */
84
- lanes: NoteLane[];
85
- start: NoteStart;
86
- close: NoteClose;
87
- }
88
-
89
- /** A link opens in a new tab when it leaves the page, and in place when it is an anchor or a mail link. */
90
- function NoteAnchor({ href, children }: { href: string; children: string }) {
91
- return (
92
- <a href={href} {...newTabProps(href, /^https?:/i.test(href))}>
93
- {children}
94
- </a>
95
- );
96
- }
97
-
98
- /** A paragraph with nothing to read: a blank string, or runs that are all blank strings. A link always counts. */
99
- function isEmpty(paragraph: NoteParagraph): boolean {
100
- const runs = typeof paragraph === "string" ? [paragraph] : paragraph;
101
- return runs.every((run) => typeof run === "string" && run.trim() === "");
102
- }
103
-
104
- function Paragraph({ paragraph, className }: { paragraph: NoteParagraph; className?: string }) {
105
- const runs = typeof paragraph === "string" ? [paragraph] : paragraph;
106
- return (
107
- <p className={className}>
108
- {runs.map((run, i) =>
109
- typeof run === "string" ? (
110
- run
111
- ) : (
112
- // biome-ignore lint/suspicious/noArrayIndexKey: runs are static content in a fixed order
113
- <NoteAnchor href={run.href} key={i}>
114
- {run.text}
115
- </NoteAnchor>
116
- ),
117
- )}
118
- </p>
119
- );
120
- }
121
-
122
- function Finding({ finding }: { finding: NoteFinding }) {
123
- return (
124
- <li className={finding.shot ? "nt-finding has-shot" : "nt-finding"}>
125
- <Callout
126
- tone="warn"
127
- title={
128
- finding.href ? <NoteAnchor href={finding.href}>{finding.lead}</NoteAnchor> : finding.lead
129
- }
130
- >
131
- {finding.body}
132
- </Callout>
133
- {finding.shot && <Shot shot={finding.shot} />}
134
- </li>
135
- );
136
- }
137
-
138
- export function Note({ title, preparedFor, date, opener, lanes, start, close }: NoteProps) {
139
- const shownLanes = lanes.filter((lane) => lane.findings.length > 0);
140
- const paragraphs = close.paragraphs.filter((paragraph) => !isEmpty(paragraph));
141
- return (
142
- <div className="nt">
143
- <article className="nt-doc">
144
- <header className="nt-head">
145
- <p className="nt-meta">
146
- Prepared for <b>{preparedFor}</b>, <span className="nt-meta-date">{date}</span>
147
- </p>
148
- <h1>{title}</h1>
149
- </header>
150
- {!isEmpty(opener) && <Paragraph paragraph={opener} className="nt-opener" />}
151
- {shownLanes.length > 0 && (
152
- <div className="nt-lanes">
153
- {shownLanes.map((lane) => (
154
- <section className="nt-lane" key={lane.heading}>
155
- <h2>{lane.heading}</h2>
156
- <ul className="nt-findings">
157
- {lane.findings.map((finding) => (
158
- <Finding finding={finding} key={finding.lead} />
159
- ))}
160
- </ul>
161
- </section>
162
- ))}
163
- </div>
164
- )}
165
- {start.items.length > 0 && (
166
- <section className="nt-start">
167
- <p className="nt-start-lead">{start.lead}</p>
168
- <ul>
169
- {start.items.map((step) => (
170
- <li key={step.lead}>
171
- <b>{step.lead}</b>
172
- {step.body && <> {step.body}</>}
173
- </li>
174
- ))}
175
- </ul>
176
- </section>
177
- )}
178
- <footer className="nt-close">
179
- {paragraphs.map((paragraph, i) => (
180
- // biome-ignore lint/suspicious/noArrayIndexKey: paragraphs are static content in a fixed order
181
- <Paragraph paragraph={paragraph} key={i} />
182
- ))}
183
- <SignOff signOff={close.signOff} person={close.person} />
184
- </footer>
185
- </article>
186
- </div>
187
- );
188
- }
@@ -1,131 +0,0 @@
1
- import type { ReactNode } from "react";
2
- import { GradeChip, type WriteupGrade } from "./GradeChip";
3
- import { SignOff, type WriteupPerson } from "./SignOff";
4
-
5
- /**
6
- * The monthly page: what an Operate client gets each month, as one printed
7
- * letter sheet and its hosted twin. Its shape is the practice's
8
- * FOOTPRINT.md, under What the Owner Gets: the title and the period, then
9
- * the leads block answering the four questions the Statement of Work
10
- * promises, in that order, each a figure and one plain sentence; then the
11
- * seven grades with what moved since last month and since the baseline; then
12
- * the sign off. It wears the writeup's grade chip and sign off, and the
13
- * artifact shell's header on screen; print drops the header and the credit
14
- * band, so the sheet is the document alone.
15
- */
16
-
17
- export interface ReportLead {
18
- /** The figure, e.g. "31 leads" or "$1,240". */
19
- figure: ReactNode;
20
- /** One plain sentence on what the figure means to the owner. */
21
- sentence: ReactNode;
22
- }
23
-
24
- /** The four questions, answered in this order on every monthly page. */
25
- export interface ReportLeads {
26
- cameIn: ReportLead;
27
- fromWhere: ReportLead;
28
- cost: ReportLead;
29
- closed: ReportLead;
30
- }
31
-
32
- export interface ReportGradeRow {
33
- /** The tier, e.g. "Health". */
34
- tier: string;
35
- /** The section's trade name, e.g. "Local SEO". */
36
- section: string;
37
- /** The grade this month: the one judgement, in full color. */
38
- now: WriteupGrade;
39
- /** The grade last month; omit on the first month. */
40
- lastMonth?: WriteupGrade;
41
- /** The grade in the baseline snapshot, the first taken on private sources. */
42
- baseline?: WriteupGrade;
43
- /** One line on what moved, in the owner's words. */
44
- moved: string;
45
- }
46
-
47
- export interface ReportProps {
48
- title: string;
49
- /** The period as printed, e.g. "September 2026". */
50
- period: string;
51
- leads: ReportLeads;
52
- /** The seven sections, in the footprint's order. */
53
- grades: ReportGradeRow[];
54
- /** The sign off, the same lockup the writeup closes on. */
55
- close: { signOff: string; person: WriteupPerson };
56
- }
57
-
58
- /**
59
- * The questions are the monthly page's contract with the Statement of Work,
60
- * the same on every client's page, so they live here rather than in content.
61
- */
62
- const QUESTIONS: { key: keyof ReportLeads; label: string }[] = [
63
- { key: "cameIn", label: "What Came In" },
64
- { key: "fromWhere", label: "From Where" },
65
- { key: "cost", label: "What It Cost" },
66
- { key: "closed", label: "What It Closed" },
67
- ];
68
-
69
- function Earlier({ grade, label }: { grade?: WriteupGrade; label: string }) {
70
- return (
71
- <td className="rp-earlier" data-label={label}>
72
- {grade && <GradeChip grade={grade} />}
73
- </td>
74
- );
75
- }
76
-
77
- export function Report({ title, period, leads, grades, close }: ReportProps) {
78
- let lastTier = "";
79
- return (
80
- <div className="rp">
81
- <article className="rp-sheet">
82
- <header className="rp-head">
83
- <div className="rp-period">{period}</div>
84
- <h1>{title}</h1>
85
- </header>
86
- <div className="rp-leads">
87
- {QUESTIONS.map(({ key, label }) => (
88
- <section className="rp-lead" key={key}>
89
- <h2 className="rp-q">{label}</h2>
90
- <div className="rp-figure">{leads[key].figure}</div>
91
- <p>{leads[key].sentence}</p>
92
- </section>
93
- ))}
94
- </div>
95
- <table className="rp-grades">
96
- <thead>
97
- <tr>
98
- <th scope="col">Tier</th>
99
- <th scope="col">Section</th>
100
- <th scope="col">Now</th>
101
- <th scope="col">Last Month</th>
102
- <th scope="col">Baseline</th>
103
- <th scope="col">What Moved</th>
104
- </tr>
105
- </thead>
106
- <tbody>
107
- {grades.map((row) => {
108
- const tier = row.tier === lastTier ? "" : row.tier;
109
- lastTier = row.tier;
110
- return (
111
- <tr key={row.section} className={tier ? "is-tier-start" : undefined}>
112
- <td className="rp-tier">{tier}</td>
113
- <td className="rp-section">{row.section}</td>
114
- <td className="rp-now" data-label="Now">
115
- <GradeChip grade={row.now} />
116
- </td>
117
- <Earlier grade={row.lastMonth} label="Last Month" />
118
- <Earlier grade={row.baseline} label="Baseline" />
119
- <td className="rp-moved">{row.moved}</td>
120
- </tr>
121
- );
122
- })}
123
- </tbody>
124
- </table>
125
- <footer className="rp-close">
126
- <SignOff signOff={close.signOff} person={close.person} />
127
- </footer>
128
- </article>
129
- </div>
130
- );
131
- }