@atelic-action/artifact 0.0.0-stage → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +153 -2
- package/package.json +68 -5
- package/src/documents/Gate.tsx +342 -0
- package/src/documents/Note.tsx +158 -0
- package/src/documents/Paragraph.tsx +65 -0
- package/src/documents/Read.tsx +544 -0
- package/src/documents/Report.tsx +135 -0
- package/src/documents/Shot.tsx +38 -0
- package/src/documents/Writeup.tsx +771 -0
- package/src/documents/documents.css +2722 -0
- package/src/documents/index.ts +11 -0
package/README.md
CHANGED
|
@@ -1,3 +1,154 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @atelic-action/artifact
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The documents the practice hands or sends a business, as a published package and as the site a business's folder is cut from. This repo is two things at once:
|
|
4
|
+
|
|
5
|
+
- **The package**, `@atelic-action/artifact` on npm: the composed documents (the writeup, the read, the monthly page, the note, and the sealed document), each with its prop types and its default labels, and the stylesheet they wear. It ships `src/documents/` and nothing else, as source, the way `@atelic-action/ui` does, and depends on that package for every piece a document is built from.
|
|
6
|
+
- **The seed**: the routes, the content stubs, the config, the stub files the host finds by path, and the tests a new folder of `atelic-action/writeups` is cut from, and the template site that shows every document on fictional content.
|
|
7
|
+
|
|
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
|
+
|
|
10
|
+
Five artifacts live in the template site: the **writeup** at `/writeup`, the graded document an owner gets, a web page first with its printed copy derived from it; the **read** at `/read`, what a customer gets at the end of an engagement's first week, the baseline's grades explained by cause with the plan in order; 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; and the **note** at `/notes`, the first touch for a business whose site publishes no email address, set as a short page. The bare host sends a reader to the writeup. Every artifact prerenders to static HTML, stays out of every index, and carries the practice's brand by default.
|
|
11
|
+
|
|
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
|
+
|
|
14
|
+
The example inside is **Summit Movement Studio**, a fictional physical therapy practice with invented numbers that exercise the components. Replace it; never ship it.
|
|
15
|
+
|
|
16
|
+
This repo was cut from [template-marketing](https://github.com/atelic-action/template-marketing) on 2026-09-09 with its history kept, so it shares the stack below. The two split apart on 2026-09-10: each template now owns its own files and nothing merges between them. It was named `template-artifact` until 2026-10-15, when it became the package's home as well; GitHub redirects the old name.
|
|
17
|
+
|
|
18
|
+
## Component Index
|
|
19
|
+
|
|
20
|
+
Every export of the package, all from the one entry, `@atelic-action/artifact`. The route column is the template page that shows it on fictional content.
|
|
21
|
+
|
|
22
|
+
| Export | What it is | Route |
|
|
23
|
+
|---|---|---|
|
|
24
|
+
| `Writeup`, `WriteupProps` | The graded document an owner gets: the overview, the three tiers of section cards, the close | `/writeup` |
|
|
25
|
+
| `ratingCell` | A star rating table cell for a writeup table, sorted by its figure | `/writeup` |
|
|
26
|
+
| `WriteupBlock`, `WriteupTopic`, `WriteupPart`, `WriteupSheet`, `WriteupSummary`, `WriteupSummaryRow`, `WriteupMarkets`, `WriteupRented`, `WriteupMark`, `WriteupCell`, `WriteupTone`, and the rest of the `Writeup*` types | The shapes a writeup's content is written in | none |
|
|
27
|
+
| `WriteupGrade`, `WriteupPerson`, `WriteupShot` | The grade letter, the sign off person, and the owner's screen, shared by every document (the first two are re exported from ui) | none |
|
|
28
|
+
| `Read`, `ReadProps` | The baseline's grades explained by cause: the gist table; each section read top to bottom as where we looked (the tools, linked, at the top), what it showed (a short paragraph or two, a compact table of facts, lists of examples under their own headings, and any proof pictures), why (one sentence on a tinted panel), today and after (a two column table), and what we recommend (a checklist); then the plan in order. A beat with no data is left out, heading and all | `/read` |
|
|
29
|
+
| `readLabels`, `ReadLabels` | The fixed words the read sets (Prepared for, the gist table's columns, Where We Looked, What It Showed, Why, Today, After, What We Recommend, the plan's heading), each overridable through `labels` | `/read` |
|
|
30
|
+
| `ReadTier`, `ReadSection`, `ReadTool`, `ReadFact`, `ReadExamples`, `ReadChange`, `ReadStep`, `ReadPlan`, `ReadClose`, `ReadLink`, `ReadParagraph` | The shapes a read's content is written in, all plain data so a routine can write a read as JSON. A section's `tools` were its `sources` in 1.0.0; `sources` is still read, and rendered at the top as tools, and `ReadSource` names the same shape as `ReadTool` | none |
|
|
31
|
+
| `Report`, `ReportProps` | The monthly page: the four questions, the seven grades now and before, the sign off, on one letter sheet | `/report` |
|
|
32
|
+
| `ReportLead`, `ReportLeads`, `ReportGradeRow` | The shapes a monthly page's content is written in | none |
|
|
33
|
+
| `Note`, `NoteProps` | The first touch set as a page: the opener, two lanes of findings, where the work would start, the close | `/notes` |
|
|
34
|
+
| `NoteLane`, `NoteFinding`, `NoteStart`, `NoteStep`, `NoteClose`, `NoteLink`, `NoteParagraph` | The shapes a note's content is written in | none |
|
|
35
|
+
| `Gate`, `GateProps`, `GatePayload`, `GatePerson` | The sealed document, opened in the browser by the reader's own address, and the payload it opens | `/proposal` |
|
|
36
|
+
| `normalizeEmail` | How a typed address becomes the key a payload opens with; the sealing command mirrors it | none |
|
|
37
|
+
| `documents.css` | The documents' styles, at `@atelic-action/artifact/styles/documents.css` | every route |
|
|
38
|
+
|
|
39
|
+
What a document is built from stays in `@atelic-action/ui`, and this package imports it rather than copying it: `GradeChip`, `gradeLabel`, `SignOff`, 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. Two small helpers live here unexported, because ui 1.1.0 does not export them: `Shot` (the owner's screen, in the writeup, the note, and the read) and `Paragraph` (the plain data paragraph of the note and the read).
|
|
40
|
+
|
|
41
|
+
## Install
|
|
42
|
+
|
|
43
|
+
```bash
|
|
44
|
+
bun add @atelic-action/artifact @atelic-action/ui
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
Peer dependencies: `@atelic-action/ui` at `^1.1.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.
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
import { Writeup } from "@atelic-action/artifact";
|
|
51
|
+
import { buildPageHead } from "@atelic-action/ui/artifact";
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### CSS Import Order
|
|
55
|
+
|
|
56
|
+
The documents' sheet goes after every ui sheet and before the site's `theme.css`:
|
|
57
|
+
|
|
58
|
+
1. The site's fonts (`fonts.css`)
|
|
59
|
+
2. `@atelic-action/ui/styles/base.css`, `chrome.css`, `components.css`, `primitives.css`, and `gate.css`, in that order
|
|
60
|
+
3. `@atelic-action/ui/styles/artifact.css`, the pieces the documents share (the grade chip, the sign off, the shell's logo)
|
|
61
|
+
4. `@atelic-action/artifact/styles/documents.css`
|
|
62
|
+
5. The site's `theme.css`, last
|
|
63
|
+
|
|
64
|
+
Every rule in the sheet sits in the `atelic-ui` layer and under the `.atelic-ui` root class, exactly as ui's sheets do, so any rule a site writes outside a layer still wins over it. The sheet holds two `@page` rules, the monthly page's and then the writeup's, and the later one wins for every page a site prints; their order is load bearing.
|
|
65
|
+
|
|
66
|
+
Under ui 1.x the overlap is deliberate: ui's `artifact.css` still carries the documents' rules beside the pieces, so a site importing both sheets carries those rules twice, identical and in the same order, until ui 2.0.0 drops them from its sheet (ATE-671, step 3). On the template that is about 39 KB of the built base sheet, 6 KB gzipped.
|
|
67
|
+
|
|
68
|
+
## Tech Stack
|
|
69
|
+
|
|
70
|
+
| Category | Technology | Purpose |
|
|
71
|
+
|----------|------------|---------|
|
|
72
|
+
| Runtime | Bun | Fast JS runtime with built in tooling |
|
|
73
|
+
| Framework | React 19 + TypeScript | Strict mode throughout |
|
|
74
|
+
| Meta framework | TanStack Start | File based routing with static prerendering (SSG) |
|
|
75
|
+
| Styling | Semantic CSS tokens | One theme file per artifact; component CSS on top; no utility framework |
|
|
76
|
+
| Confidentiality | Web Crypto | The sealed document gate, plus an optional email tied wall over the whole host |
|
|
77
|
+
| Error tracking | Sentry | Optional, activates only when a DSN is set |
|
|
78
|
+
| Testing | Vitest + Testing Library | Tests in `tst/` mirroring `src/` |
|
|
79
|
+
| Lint and format | Biome | Pre commit hook lints; pre push mirrors CI |
|
|
80
|
+
|
|
81
|
+
## Quick Start
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
bun install
|
|
85
|
+
bun dev
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
No environment variables required. Visit [http://localhost:5173](http://localhost:5173).
|
|
89
|
+
|
|
90
|
+
## Building a Client Artifact
|
|
91
|
+
|
|
92
|
+
A business's artifacts live in one folder of `atelic-action/writeups`, named by the business's short name and served at `<short>.atelic.me` as its own Vercel project with the folder as its root directory, one path per artifact. That host is the business's one address with the practice: the website build, when one is previewed, sits on it at `/proto`, proxied from its own project ([DEPLOY.md](DEPLOY.md), The Website Build Under /proto). Cut it from this one with `git subtree add --prefix=<short> https://github.com/atelic-action/artifact.git main`, run in `writeups`. A cut is a site, not the package, so it then takes the package from npm instead of carrying its source: delete `src/documents/` and `tst/documents/`, give `package.json` the folder's own name and drop the package's fields (`version`, `files`, `exports`, `sideEffects`, `peerDependencies`), and run `bun add @atelic-action/artifact`. The routes and content already import the documents by the package's name, so nothing else changes. Nothing is pulled forward from here afterward: a document reaches the folder with `bun update @atelic-action/artifact`, and everything else with logic in it is `@atelic-action/ui`'s and arrives with `bun update @atelic-action/ui` ([CLAUDE.md](CLAUDE.md), What a Cut Takes From Here). Delete the artifacts the engagement does not need (the route, the content module, and the prerender entry), then touch four surfaces:
|
|
93
|
+
|
|
94
|
+
1. **Content**, in `src/content/`. One module per artifact: the real read, every number dated and defensible in the room, graded and shaped by the practice's `FOOTPRINT.md` and written to its pitch surface rules (`Brand/voice.md`, Pitch Surfaces 15 to 21). In `writeup.tsx`, `sheets[]` only groups the blocks; print flows the page across as many letter pages as it needs, and `writeupStops` names the header's stops.
|
|
95
|
+
2. **Images**, split by what they are. Logos are brand marks and go in `public/brand/` (the recipient's logo, e.g. `public/brand/<name>.svg`), which the access wall leaves open so the header and the login page can draw them. The owner's own screens (the full screenshot for the click through and a tight crop for the column) and the QR for the twin, pointing at the bare host (`qr "https://<short>.atelic.me" > public/images/qr.png` with the `qrcode` package), go in `public/images/`, which the wall holds on a private artifact. A cut made before this split moves its recipient logo from `public/images/` to `public/brand/` and repoints `recipient.logo.src`.
|
|
96
|
+
3. **Config**, in `src/site.config.ts`. `url` is the host, `access` is `"public"` or `"private"` (private when anything came from the owner's own consoles), `recipient` is the business it was prepared for (with its `logo` under `/brand/`), `sender` is the practice, and `analytics` is the HubSpot loader so an open reports to the CRM, with `identify` naming who each link identifies so the open lands on that person's contact (`bun run reader <email>` mints a reader, sealed so no address ships in the page's script, and prints their link; a private artifact needs nobody listed, because the wall identifies whoever signs in; [DEPLOY.md](DEPLOY.md), Who Opened It).
|
|
97
|
+
4. **The printed copy**: `bun build`, then `bun run print dist/client public/writeup.pdf`, which prints with every collapsible open and reports the sheet count, or `bun run print dist/client <out.pdf> 1 /report` for the monthly page, which fails if it misses its one sheet. Commit the writeup's PDF beside the page, and file a dated copy in the client's folder of the practice repo.
|
|
98
|
+
|
|
99
|
+
A private artifact deploys only behind the armed wall, which holds the screenshots, the PDF, and the built content chunks as well as the pages, and `bun run check:wall <short>.atelic.me` must pass before any link goes out ([ACCESS-GATE.md](ACCESS-GATE.md)).
|
|
100
|
+
|
|
101
|
+
The theme stays the practice's (Geist, ink, the orange) unless the artifact should wear the client's brand, in which case `src/styles/theme.css` and the two font files swap and nothing else moves. Check the phone at 390 wide in a real browser before deploying.
|
|
102
|
+
|
|
103
|
+
## Documentation Map
|
|
104
|
+
|
|
105
|
+
| Doc | Covers |
|
|
106
|
+
|-----|--------|
|
|
107
|
+
| [CLAUDE.md](CLAUDE.md) | Architecture, conventions, and gotchas (agents start here) |
|
|
108
|
+
| [src/components/README.md](src/components/README.md) | The component catalog: every component, its props, where it's used |
|
|
109
|
+
| [src/components/CLAUDE.md](src/components/CLAUDE.md) | Rules for changing or growing the components |
|
|
110
|
+
| [src/content/README.md](src/content/README.md) | Authoring the content modules |
|
|
111
|
+
| [src/styles/README.md](src/styles/README.md) | The theming system and how to derive a new palette |
|
|
112
|
+
| [GATE.md](GATE.md) | The sealed document gate: opens with the reader's own email, with HubSpot open tracking |
|
|
113
|
+
| [ACCESS-GATE.md](ACCESS-GATE.md) | The email tied sign in wall over the whole host, required for a private artifact, with the build guard and the wall check |
|
|
114
|
+
| [DEPLOY.md](DEPLOY.md) | The deploy motion: Vercel, the subdomain, the wall for a private artifact, the observability layers |
|
|
115
|
+
| [LAUNCH.md](LAUNCH.md) | The per artifact send checklist template, stamped into a Linear ticket |
|
|
116
|
+
| [TEARDOWN.md](TEARDOWN.md) | The reverse motion: retiring a subdomain and its Vercel project |
|
|
117
|
+
|
|
118
|
+
## Scripts
|
|
119
|
+
|
|
120
|
+
| Command | Description |
|
|
121
|
+
|---------|-------------|
|
|
122
|
+
| `bun dev` | Dev server |
|
|
123
|
+
| `bun build` | Access guard + typecheck + build + prerender every page |
|
|
124
|
+
| `bun test` / `bun test:run` | Tests (watch / once) |
|
|
125
|
+
| `bun lint` / `bun lint:fix` | Biome check / auto fix |
|
|
126
|
+
| `bun run print <url or dist/client> <out.pdf> [sheets] [page]` | Prints a page (the writeup unless named) through headless Chrome, reports the sheet count, and checks it when one is given |
|
|
127
|
+
| `bun run check:wall <host> [path ...]` | Proves a private artifact's live host answers every document with the wall |
|
|
128
|
+
| `bun run seal <config.json> [out.json]` | Seals a document for its named readers ([GATE.md](GATE.md)) |
|
|
129
|
+
|
|
130
|
+
## Deployment
|
|
131
|
+
|
|
132
|
+
Static files. `vercel.json` points Vercel at `dist/client` with no framework preset. The full motion, the subdomain, the observability layers, and why every `.atelic.me` host is noindexed automatically live in [DEPLOY.md](DEPLOY.md).
|
|
133
|
+
|
|
134
|
+
## Contributing Back
|
|
135
|
+
|
|
136
|
+
A document is fixed or grown here, in `src/documents/`, with its test in `tst/documents/`, and released as a version of this package; a folder takes it with `bun update @atelic-action/artifact`. A piece a document is built from (the grade chip, the sign off, a page component, the chrome, the wall, a command) is fixed in `@atelic-action/ui` and released there first, then taken here by bumping the ui range. An improvement to the seed itself (a route, a stub file, the example content) is made here and copied into the folders by hand.
|
|
137
|
+
|
|
138
|
+
## Releasing
|
|
139
|
+
|
|
140
|
+
1. Bump `version` in `package.json` and merge it to `main`: patch for a fix, minor for a new document or prop, major for a breaking prop, type, or class rename.
|
|
141
|
+
2. Tag the merged commit `vX.Y.Z` with the same version and push the tag:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
git tag v1.0.0
|
|
145
|
+
git push origin v1.0.0
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
3. The Publish workflow (`.github/workflows/publish.yml`) checks that the tag matches the version, runs lint, the tests, and the build, and publishes to npm. It authenticates through npm trusted publishing, as ui's does: the package's Trusted Publisher names this repo and `publish.yml`, and its allowed actions must permit direct `npm publish`, since a configuration created after 2026-09-03 allows only `npm stage publish` by default. No token is stored, and it needs npm 11.5.1 or later, which Node 24 ships.
|
|
149
|
+
|
|
150
|
+
Two things differ from ui. A Trusted Publisher is set on a package that already exists, so the first version, 1.0.0, is published by hand by an owner of the `@atelic-action` scope (`npm publish --access public` from a clean checkout of the tag) before the Trusted Publisher is added and the workflow takes over. And this repo is private, so npm attaches no provenance to what it publishes; the package's source is public on npm all the same, which is why `files` ships only `src/documents/` (README.md and package.json ride along, as npm always includes them), and why nothing in them may name a client.
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
package/package.json
CHANGED
|
@@ -1,6 +1,69 @@
|
|
|
1
1
|
{
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
2
|
+
"name": "@atelic-action/artifact",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "The composed documents the Atelic practice hands or sends a business (the writeup, the read, the monthly page, the note, and the sealed document), built from the pieces in @atelic-action/ui",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/atelic-action/artifact.git"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"src/documents"
|
|
13
|
+
],
|
|
14
|
+
"sideEffects": [
|
|
15
|
+
"**/*.css"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/documents/index.ts",
|
|
19
|
+
"./styles/documents.css": "./src/documents/documents.css",
|
|
20
|
+
"./package.json": "./package.json"
|
|
21
|
+
},
|
|
22
|
+
"scripts": {
|
|
23
|
+
"prepare": "git config core.hooksPath .githooks 2>/dev/null || true",
|
|
24
|
+
"dev": "vite",
|
|
25
|
+
"build": "atelic-check-access && tsc -b && vite build && cp dist/client/404/index.html dist/client/404.html && atelic-stamp-health",
|
|
26
|
+
"preview": "vite preview",
|
|
27
|
+
"test": "vitest",
|
|
28
|
+
"test:run": "vitest run",
|
|
29
|
+
"test:e2e": "playwright test",
|
|
30
|
+
"lint": "biome check .",
|
|
31
|
+
"lint:fix": "biome check . --write",
|
|
32
|
+
"check:links": "atelic-check-links",
|
|
33
|
+
"check:wall": "atelic-check-wall",
|
|
34
|
+
"reader": "atelic-reader-link",
|
|
35
|
+
"print": "atelic-print-sheets",
|
|
36
|
+
"seal": "atelic-seal-document",
|
|
37
|
+
"format": "biome format . --write"
|
|
38
|
+
},
|
|
39
|
+
"peerDependencies": {
|
|
40
|
+
"@atelic-action/ui": "^1.1.0",
|
|
41
|
+
"lucide-react": "^1.23.0",
|
|
42
|
+
"react": "^19.0.0"
|
|
43
|
+
},
|
|
44
|
+
"devDependencies": {
|
|
45
|
+
"@atelic-action/ui": "^1.1.0",
|
|
46
|
+
"@biomejs/biome": "^2.0.0",
|
|
47
|
+
"@playwright/test": "^1.60.0",
|
|
48
|
+
"@sentry/react": "^10.0.0",
|
|
49
|
+
"@tanstack/react-router": "^1.170.41",
|
|
50
|
+
"@tanstack/react-router-devtools": "^1.167.2",
|
|
51
|
+
"@tanstack/react-start": "^1.168.60",
|
|
52
|
+
"@testing-library/jest-dom": "^6.6.2",
|
|
53
|
+
"@testing-library/react": "^16.0.1",
|
|
54
|
+
"@types/node": "^24.10.1",
|
|
55
|
+
"@types/react": "^19.0.0",
|
|
56
|
+
"@types/react-dom": "^19.0.0",
|
|
57
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
58
|
+
"jsdom": "^25.0.1",
|
|
59
|
+
"lucide-react": "^1.23.0",
|
|
60
|
+
"react": "^19.0.0",
|
|
61
|
+
"react-dom": "^19.0.0",
|
|
62
|
+
"typescript": "^5.6.3",
|
|
63
|
+
"vite": "^7.0.0",
|
|
64
|
+
"vitest": "^4.0.0"
|
|
65
|
+
},
|
|
66
|
+
"trustedDependencies": [
|
|
67
|
+
"@biomejs/biome"
|
|
68
|
+
]
|
|
69
|
+
}
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import { Button } from "@atelic-action/ui/primitives";
|
|
2
|
+
import { type FormEvent, useEffect, useRef, useState } from "react";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* PBKDF2 work factor and hash. Mirrored by the sealing command, which
|
|
6
|
+
* @atelic-action/ui ships (atelic-seal-document, its src/scripts/seal-document.mjs);
|
|
7
|
+
* the two must agree exactly or every sealed payload stops opening, and
|
|
8
|
+
* tst/documents/gate.test.tsx seals with that command to prove it.
|
|
9
|
+
*/
|
|
10
|
+
const PBKDF2_ITERATIONS = 200000;
|
|
11
|
+
const PBKDF2_HASH = "SHA-256";
|
|
12
|
+
|
|
13
|
+
declare global {
|
|
14
|
+
interface Window {
|
|
15
|
+
/** HubSpot tracking queue; the loader drains anything pushed before it loads. */
|
|
16
|
+
_hsq?: unknown[];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface GatePerson {
|
|
21
|
+
/** Stable handle for the reader, used in the payload and in `onUnlock`. */
|
|
22
|
+
id: string;
|
|
23
|
+
/**
|
|
24
|
+
* Never read, and a payload sealed today does not carry it. The reader's
|
|
25
|
+
* address is the secret their key derives from, so a payload that lists
|
|
26
|
+
* it hands the key to anyone who reads the page's script; payloads sealed
|
|
27
|
+
* before 2026-10-08 did. The address a reader types is the one this
|
|
28
|
+
* component uses.
|
|
29
|
+
*/
|
|
30
|
+
email?: string;
|
|
31
|
+
/** Base64: this reader's PBKDF2 salt, GCM nonce, and wrapped content key. */
|
|
32
|
+
salt: string;
|
|
33
|
+
iv: string;
|
|
34
|
+
wrapped: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface GatePayload {
|
|
38
|
+
/** Base64: the document's GCM nonce and ciphertext. */
|
|
39
|
+
civ: string;
|
|
40
|
+
ct: string;
|
|
41
|
+
people: GatePerson[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface GateProps {
|
|
45
|
+
/** Built by atelic-seal-document, from @atelic-action/ui. Carries no plaintext. */
|
|
46
|
+
payload: GatePayload;
|
|
47
|
+
/** localStorage key holding the reader's email, so a return visit walks straight in. */
|
|
48
|
+
storageKey?: string;
|
|
49
|
+
/**
|
|
50
|
+
* Query parameter a sent link may carry the reader's email in, so the
|
|
51
|
+
* link opens the document in one click. Checked once on mount and
|
|
52
|
+
* stripped from the address bar either way.
|
|
53
|
+
*/
|
|
54
|
+
queryParam?: string;
|
|
55
|
+
/**
|
|
56
|
+
* When set, a reader whose email ends with this domain is pushed to
|
|
57
|
+
* HubSpot on unlock, so the open lands on their contact timeline.
|
|
58
|
+
* Readers on another domain identify nobody. Leave it unset and the
|
|
59
|
+
* gate tracks no one.
|
|
60
|
+
*/
|
|
61
|
+
identifyDomain?: string;
|
|
62
|
+
/** Gate copy. Defaults are neutral; a client document overrides what it needs. */
|
|
63
|
+
kicker?: string;
|
|
64
|
+
heading?: string;
|
|
65
|
+
sub?: string;
|
|
66
|
+
placeholder?: string;
|
|
67
|
+
inputLabel?: string;
|
|
68
|
+
errorText?: string;
|
|
69
|
+
/** Fired once, after a successful unlock. */
|
|
70
|
+
onUnlock?: (person: GatePerson) => void;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* An email is its letters and digits, uppercased, so "Dana@Example.com",
|
|
75
|
+
* "dana@example.com" and even a stray space all open the same document.
|
|
76
|
+
* Mirrored in the build script.
|
|
77
|
+
*/
|
|
78
|
+
export function normalizeEmail(email: string): string {
|
|
79
|
+
return email.toUpperCase().replace(/[^A-Z0-9]/g, "");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function fromBase64(value: string): Uint8Array {
|
|
83
|
+
const binary = atob(value);
|
|
84
|
+
const bytes = new Uint8Array(binary.length);
|
|
85
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
86
|
+
return bytes;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
interface Opened {
|
|
90
|
+
person: GatePerson;
|
|
91
|
+
/** The address that opened the document, as the reader gave it. */
|
|
92
|
+
email: string;
|
|
93
|
+
html: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Try the entered email against every reader in the payload. An email that
|
|
98
|
+
* matches nobody fails the GCM tag check on each attempt and returns null,
|
|
99
|
+
* which is the only signal the gate ever gives: it never reveals who is on
|
|
100
|
+
* the list or how close a guess came.
|
|
101
|
+
*/
|
|
102
|
+
async function openDocument(payload: GatePayload, rawEmail: string): Promise<Opened | null> {
|
|
103
|
+
const subtle = globalThis.crypto?.subtle;
|
|
104
|
+
if (!subtle) return null;
|
|
105
|
+
const normalized = normalizeEmail(rawEmail);
|
|
106
|
+
if (!normalized) return null;
|
|
107
|
+
|
|
108
|
+
const material = await subtle.importKey(
|
|
109
|
+
"raw",
|
|
110
|
+
new TextEncoder().encode(normalized),
|
|
111
|
+
"PBKDF2",
|
|
112
|
+
false,
|
|
113
|
+
["deriveKey"],
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
for (const person of payload.people) {
|
|
117
|
+
try {
|
|
118
|
+
const wrapper = await subtle.deriveKey(
|
|
119
|
+
{
|
|
120
|
+
name: "PBKDF2",
|
|
121
|
+
salt: fromBase64(person.salt) as BufferSource,
|
|
122
|
+
iterations: PBKDF2_ITERATIONS,
|
|
123
|
+
hash: PBKDF2_HASH,
|
|
124
|
+
},
|
|
125
|
+
material,
|
|
126
|
+
{ name: "AES-GCM", length: 256 },
|
|
127
|
+
false,
|
|
128
|
+
["decrypt"],
|
|
129
|
+
);
|
|
130
|
+
const contentKeyBytes = await subtle.decrypt(
|
|
131
|
+
{ name: "AES-GCM", iv: fromBase64(person.iv) as BufferSource },
|
|
132
|
+
wrapper,
|
|
133
|
+
fromBase64(person.wrapped) as BufferSource,
|
|
134
|
+
);
|
|
135
|
+
const contentKey = await subtle.importKey(
|
|
136
|
+
"raw",
|
|
137
|
+
contentKeyBytes,
|
|
138
|
+
{ name: "AES-GCM" },
|
|
139
|
+
false,
|
|
140
|
+
["decrypt"],
|
|
141
|
+
);
|
|
142
|
+
const plain = await subtle.decrypt(
|
|
143
|
+
{ name: "AES-GCM", iv: fromBase64(payload.civ) as BufferSource },
|
|
144
|
+
contentKey,
|
|
145
|
+
fromBase64(payload.ct) as BufferSource,
|
|
146
|
+
);
|
|
147
|
+
return { person, email: rawEmail, html: new TextDecoder().decode(plain) };
|
|
148
|
+
} catch {
|
|
149
|
+
// Wrong email for this reader. Try the next one; say nothing.
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Tells HubSpot who opened the document, from the address the reader gave:
|
|
157
|
+
* the payload names no one. The key forgives what the reader types (case,
|
|
158
|
+
* spaces, even a missing at sign), so the address is sent only when it reads
|
|
159
|
+
* as one, and only on the domain the site names.
|
|
160
|
+
*/
|
|
161
|
+
function identifyReader(rawEmail: string, domain: string | undefined) {
|
|
162
|
+
const email = rawEmail.trim().toLowerCase();
|
|
163
|
+
if (!domain || !/^[^\s@]+@[^\s@]+$/.test(email)) return;
|
|
164
|
+
if (!email.endsWith(`@${domain.toLowerCase()}`)) return;
|
|
165
|
+
window._hsq = window._hsq || [];
|
|
166
|
+
window._hsq.push(["identify", { email }]);
|
|
167
|
+
window._hsq.push(["setPath", window.location.pathname]);
|
|
168
|
+
window._hsq.push(["trackPageView"]);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function readStored(key: string): string {
|
|
172
|
+
try {
|
|
173
|
+
return globalThis.localStorage?.getItem(key) ?? "";
|
|
174
|
+
} catch {
|
|
175
|
+
return ""; // Storage blocked (private mode); the reader just retypes their email.
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function writeStored(key: string, value: string) {
|
|
180
|
+
try {
|
|
181
|
+
globalThis.localStorage?.setItem(key, value);
|
|
182
|
+
} catch {
|
|
183
|
+
// Same: persistence is a courtesy, never a requirement.
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Read and strip the gate's query parameter in one motion. */
|
|
188
|
+
function takeQueryParam(name: string): string {
|
|
189
|
+
try {
|
|
190
|
+
const url = new URL(window.location.href);
|
|
191
|
+
const value = url.searchParams.get(name) ?? "";
|
|
192
|
+
if (value) {
|
|
193
|
+
url.searchParams.delete(name);
|
|
194
|
+
window.history.replaceState(window.history.state, "", url.toString());
|
|
195
|
+
}
|
|
196
|
+
return value;
|
|
197
|
+
} catch {
|
|
198
|
+
return "";
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The gate: an email field standing in front of a sealed document.
|
|
204
|
+
*
|
|
205
|
+
* The document ships as ciphertext inside the page, so there is no server to
|
|
206
|
+
* ask and nothing to gate at the edge. The reader's own email address derives
|
|
207
|
+
* the key that unwraps the content key, the document decrypts in the browser,
|
|
208
|
+
* and the gate replaces itself with it. Wrong emails get one line of copy and
|
|
209
|
+
* no hint. A sent link can carry the email in a query parameter (`?email=`)
|
|
210
|
+
* to open the document in one click.
|
|
211
|
+
*
|
|
212
|
+
* Sized for a document sent to named people: the reader types the one thing
|
|
213
|
+
* they always know, no sign in, no inbox round trip. The access gate
|
|
214
|
+
* (ACCESS-GATE.md, GateLogin in @atelic-action/ui/components) is the other shape, for a whole
|
|
215
|
+
* site preview behind an email tied session with inbox verified identity.
|
|
216
|
+
*/
|
|
217
|
+
export function Gate({
|
|
218
|
+
payload,
|
|
219
|
+
storageKey = "gate-email",
|
|
220
|
+
queryParam = "email",
|
|
221
|
+
identifyDomain,
|
|
222
|
+
kicker = "Private Document",
|
|
223
|
+
heading = "Enter Your Email",
|
|
224
|
+
sub = "This document opens with the email address it was sent to.",
|
|
225
|
+
placeholder = "you@company.com",
|
|
226
|
+
inputLabel = "Your email",
|
|
227
|
+
errorText = "That email does not open this document. Use the address it was sent to.",
|
|
228
|
+
onUnlock,
|
|
229
|
+
}: GateProps) {
|
|
230
|
+
const [html, setHtml] = useState<string | null>(null);
|
|
231
|
+
const [failed, setFailed] = useState(false);
|
|
232
|
+
const [busy, setBusy] = useState(false);
|
|
233
|
+
const [value, setValue] = useState("");
|
|
234
|
+
|
|
235
|
+
// Held in a ref so a caller passing an inline function cannot re-run the
|
|
236
|
+
// auto unlock effect on every render.
|
|
237
|
+
const onUnlockRef = useRef(onUnlock);
|
|
238
|
+
onUnlockRef.current = onUnlock;
|
|
239
|
+
|
|
240
|
+
// Silent auto unlock, in order: a sent link's query parameter first, then
|
|
241
|
+
// the stored email of a reader who has been here before. A value that no
|
|
242
|
+
// longer opens anything (the payload was rebuilt) fails quietly; a failed
|
|
243
|
+
// query value pre fills the field and surfaces the error, since the
|
|
244
|
+
// reader arrived expecting the door to open.
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
let cancelled = false;
|
|
247
|
+
|
|
248
|
+
async function attempt() {
|
|
249
|
+
const fromQuery = takeQueryParam(queryParam);
|
|
250
|
+
if (fromQuery) {
|
|
251
|
+
const opened = await openDocument(payload, fromQuery);
|
|
252
|
+
if (cancelled) return;
|
|
253
|
+
if (opened) {
|
|
254
|
+
writeStored(storageKey, fromQuery);
|
|
255
|
+
identifyReader(opened.email, identifyDomain);
|
|
256
|
+
setHtml(opened.html);
|
|
257
|
+
onUnlockRef.current?.(opened.person);
|
|
258
|
+
return;
|
|
259
|
+
}
|
|
260
|
+
setValue(fromQuery);
|
|
261
|
+
setFailed(true);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const stored = readStored(storageKey);
|
|
266
|
+
if (!stored) return;
|
|
267
|
+
const opened = await openDocument(payload, stored);
|
|
268
|
+
if (cancelled || !opened) return;
|
|
269
|
+
identifyReader(opened.email, identifyDomain);
|
|
270
|
+
setHtml(opened.html);
|
|
271
|
+
onUnlockRef.current?.(opened.person);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
attempt();
|
|
275
|
+
return () => {
|
|
276
|
+
cancelled = true;
|
|
277
|
+
};
|
|
278
|
+
}, [payload, storageKey, identifyDomain, queryParam]);
|
|
279
|
+
|
|
280
|
+
async function handleSubmit(event: FormEvent<HTMLFormElement>) {
|
|
281
|
+
event.preventDefault();
|
|
282
|
+
if (busy) return;
|
|
283
|
+
setBusy(true);
|
|
284
|
+
setFailed(false);
|
|
285
|
+
const opened = await openDocument(payload, value);
|
|
286
|
+
setBusy(false);
|
|
287
|
+
if (!opened) {
|
|
288
|
+
setFailed(true);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
writeStored(storageKey, value);
|
|
292
|
+
identifyReader(opened.email, identifyDomain);
|
|
293
|
+
setHtml(opened.html);
|
|
294
|
+
onUnlockRef.current?.(opened.person);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (html !== null) {
|
|
298
|
+
return (
|
|
299
|
+
<div className="gate-doc">
|
|
300
|
+
<div className="wrap">
|
|
301
|
+
{/* 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. */}
|
|
302
|
+
<div className="gate-content" dangerouslySetInnerHTML={{ __html: html }} />
|
|
303
|
+
</div>
|
|
304
|
+
</div>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
return (
|
|
309
|
+
<div className="gate-hall">
|
|
310
|
+
<div className="wrap">
|
|
311
|
+
<div className="gate-card">
|
|
312
|
+
{kicker && <p className="gate-eyebrow">{kicker}</p>}
|
|
313
|
+
<h1 className="gate-headline">{heading}</h1>
|
|
314
|
+
{sub && <p className="gate-lead">{sub}</p>}
|
|
315
|
+
<form className="gate-form" onSubmit={handleSubmit} noValidate>
|
|
316
|
+
<input
|
|
317
|
+
className="gate-input"
|
|
318
|
+
type="email"
|
|
319
|
+
inputMode="email"
|
|
320
|
+
name="email"
|
|
321
|
+
value={value}
|
|
322
|
+
onChange={(event) => setValue(event.target.value)}
|
|
323
|
+
placeholder={placeholder}
|
|
324
|
+
aria-label={inputLabel}
|
|
325
|
+
autoComplete="email"
|
|
326
|
+
autoCapitalize="none"
|
|
327
|
+
spellCheck={false}
|
|
328
|
+
/>
|
|
329
|
+
<Button type="submit" size="lg">
|
|
330
|
+
{busy ? "Opening…" : "Open Document"}
|
|
331
|
+
</Button>
|
|
332
|
+
</form>
|
|
333
|
+
{failed && (
|
|
334
|
+
<p className="gate-error" role="alert">
|
|
335
|
+
{errorText}
|
|
336
|
+
</p>
|
|
337
|
+
)}
|
|
338
|
+
</div>
|
|
339
|
+
</div>
|
|
340
|
+
</div>
|
|
341
|
+
);
|
|
342
|
+
}
|