@atelic-action/ui 0.15.1 → 0.16.1

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
@@ -1,6 +1,6 @@
1
1
  # @atelic-action/ui
2
2
 
3
- Shared UI for the Atelic templates: the site chrome (header, menu, footer, sticky CTA bar, and credit band), the not found page and the routing that keeps it alive, the scroll spy hook, and the one base stylesheet. The marketing and artifact templates install it instead of carrying their own copies, so a chrome fix lands once and every site picks it up with `bun update`.
3
+ Shared UI for the Atelic templates: the site chrome (header, menu, footer, sticky CTA bar, and credit band), the not found page and the routing that keeps it alive, the scroll spy hook, and the one base stylesheet. The marketing and artifact templates install it instead of carrying their own copies, so a chrome fix lands once and every site picks it up with `bun update @atelic-action/ui --latest`.
4
4
 
5
5
  The package ships source, not a build. Its TSX and CSS arrive as written and compile inside each site's own Vite. The one exception is the access gate's server side, which ships compiled (see [The Access Gate](#the-access-gate)).
6
6
 
@@ -10,7 +10,9 @@ The package ships source, not a build. Its TSX and CSS arrive as written and com
10
10
  bun add @atelic-action/ui
11
11
  ```
12
12
 
13
- Peer dependencies: `react` and `react-dom` at `^19.0.0`, and `lucide-react` at `^1.23.0`.
13
+ The package is below 1.0, where a caret range admits patches only, so a site takes a new minor with `bun update @atelic-action/ui --latest`: a plain `bun update` leaves it on the minor it has and reports nothing wrong.
14
+
15
+ Peer dependencies: `react` and `react-dom` at `^19.0.0`, and `lucide-react` at `^1.23.0`. Three more are optional, needed only by a site whose `vite.config.ts` imports `@atelic-action/ui/vite`: `vite`, `@tanstack/react-start`, and `@vitejs/plugin-react`. The commands the package installs run under `bun`, with `node` for the sealing command.
14
16
 
15
17
  ## Vite Config
16
18
 
@@ -46,7 +48,7 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
46
48
  | `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
47
49
  | `@atelic-action/ui/components` | `NotFound`, the access gate's `GateLogin` and `Watermark` (with `readCookie`), and the page components (`Checklist`, `DataTable`, `StatRow`, `Callout`, `Collapsible`, `StackedBar`, `ColumnChart`, `ThresholdScale`, `StarRating`, `Disclosure`), with their prop types (see [The Not Found Page](#the-not-found-page) and [Page Components](#page-components)) |
48
50
  | `@atelic-action/ui/artifact` | `Writeup`, `Report`, `Gate`, `GradeChip`, `SignOff`, `ArtifactShell`, the `ArtifactConfig` types, `buildPageHead`, the reader helpers, `AppErrorBoundary`, `newTabProps`, and their prop types (see [The Artifact Documents](#the-artifact-documents)) |
49
- | `@atelic-action/ui/artifact/readers` | The reader helpers alone, built, for what Node runs as shipped: a site's browser tests and its reader script |
51
+ | `@atelic-action/ui/artifact/readers` | The reader helpers alone, built, for what Node runs as shipped: a site's browser tests |
50
52
  | `@atelic-action/ui/primitives` | `Button`, `Chip`, `Eyebrow`, `Lead`, `SectionHeading`, and their prop types (see [Primitives](#primitives)) |
51
53
  | `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
52
54
  | `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
@@ -55,7 +57,8 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
55
57
  | `@atelic-action/ui/styles/primitives.css` | Styles for everything under `primitives`, the `.btn` classes included |
56
58
  | `@atelic-action/ui/styles/gate.css` | Styles for `GateLogin` and `Watermark` |
57
59
  | `@atelic-action/ui/styles/artifact.css` | Styles for everything under `artifact`: the writeup, the monthly page, the sealed document stage, the grade chip, and the sign off |
58
- | `@atelic-action/ui/vite` | `privateChunks`, the Vite plugin that keeps private content in `/assets/doc/`, with `isPrivateContent` and `DOC_DIR` |
60
+ | `@atelic-action/ui/vite` | `artifactConfig`, the whole Vite configuration of an artifact site as one call, with `artifactPages`; and `privateChunks`, the Vite plugin that keeps private content in `/assets/doc/`, with `isPrivateContent` and `DOC_DIR` (see [The Artifact Vite Config](#the-artifact-vite-config)) |
61
+ | The `bin` commands | Eight commands a site's `package.json` calls by name, from `atelic-check-access` to `atelic-seal-document` (see [The Commands](#the-commands)) |
59
62
  | `@atelic-action/ui/gate` | `signToken`, `verifyToken`, `safeNext`, `needsSession`, and the token types |
60
63
  | `@atelic-action/ui/gate/request-link` | `POST`, the handler that emails a sign in link, and `createRequestLink` |
61
64
  | `@atelic-action/ui/gate/verify` | `GET`, the handler that turns a link into a session |
@@ -196,7 +199,7 @@ What it holds to, each with a test in `tst/gate/`:
196
199
 
197
200
  What it does not do, by having no store: a link can be used more than once until it expires, and link requests are not rate limited. Rotating the secret ends every session at once. The `gate_email` and `gate_wm` cookies that feed the watermark are readable and unsigned on purpose: the watermark is drawn in the reader's own browser, so it deters and attributes nothing a reader set on removing it could not already remove.
198
201
 
199
- This is one of the two corners of the package that ship compiled (the other is the Vite plugin, under [The Artifact Documents](#the-artifact-documents)). Vercel runs a function's and a middleware's package imports as shipped and compiles nothing inside `node_modules`, so `bun run build` emits `src/gate/` to `dist/gate/`, and `prepack` runs it before every publish.
202
+ This is one of the two corners of the package that ship compiled (the other is the Vite entry, under [The Artifact Vite Config](#the-artifact-vite-config)). Vercel runs a function's and a middleware's package imports as shipped and compiles nothing inside `node_modules`, so `bun run build` emits `src/gate/` to `dist/gate/`, and `prepack` runs it before every publish.
200
203
 
201
204
  ### The Screen and the Watermark
202
205
 
@@ -247,15 +250,62 @@ export function ArtifactShell(props: Omit<ArtifactShellProps, "currentPath">) {
247
250
 
248
251
  The documents import no stylesheet of their own. A site imports `@atelic-action/ui/styles/artifact.css` in its base sheet after `gate.css` and before its `theme.css`, so every page carries the rules whichever route it is. 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; the order inside the sheet is the order the template's build bundled them in and is load bearing.
249
252
 
250
- `@atelic-action/ui/vite` is `privateChunks`, the Vite plugin that writes every chunk carrying private content to `/assets/doc/`, where the access wall holds it, and fails the build when one lands anywhere else. A `vite.config.ts` is run by Node as shipped, so the plugin is built to `dist/vite/` like the gate:
253
+ ### The Artifact Vite Config
254
+
255
+ `@atelic-action/ui/vite` holds the whole Vite configuration of an artifact site, so a site's `vite.config.ts` is one call that names its pages:
251
256
 
252
257
  ```ts
253
258
  // vite.config.ts
254
- import { privateChunks } from "@atelic-action/ui/vite";
259
+ import { artifactConfig } from "@atelic-action/ui/vite";
255
260
 
256
- export default defineConfig({ plugins: [tanstackStart(), react(), privateChunks()] });
261
+ export default artifactConfig({ pages: ["/writeup", "/proposal", "/report"] });
257
262
  ```
258
263
 
264
+ `artifactConfig` returns the TanStack Start plugin with prerendering on, the crawler following links but kept out of `/images/` (where a prerendered page would overwrite a real file), one prerendered page for `/`, for each path the site names, and for `/404`, and no sitemap; the React plugin; `privateChunks()`; the `@` alias to the site's `src`, resolved from the directory Vite is run in; and `ssr.noExternal: ["@atelic-action/ui"]`. Nothing links to an artifact page, so a page a site leaves out of `pages` ships with no HTML in it. `artifactPages` is the list alone.
265
+
266
+ A site with a real need of its own passes `extend`, which is merged over the result with Vite's `mergeConfig`:
267
+
268
+ ```ts
269
+ export default artifactConfig({
270
+ pages: ["/writeup"],
271
+ extend: { server: { port: 5180 } },
272
+ });
273
+ ```
274
+
275
+ `privateChunks` is the plugin inside it, exported for a site that writes its own configuration: it writes every chunk carrying private content to `/assets/doc/`, where the access wall holds it, and fails the build when one lands anywhere else.
276
+
277
+ A `vite.config.ts` is run by Node as shipped, so `src/vite/` is built to `dist/vite/` like the gate. The entry imports `@tanstack/react-start` and `@vitejs/plugin-react` when it loads, so a site that imports anything from it has both installed; they are optional peers only because a site that never imports this entry needs neither.
278
+
279
+ ### The Commands
280
+
281
+ The package installs the scripts every artifact site used to carry as files, so a site's `package.json` calls a command by name and holds no script of its own. Each reads the site it is run in from the working directory: `src/site.config.ts`, `public/`, and `dist/client` are the site's, found from where the command is run, which is the site's root.
282
+
283
+ | Command | What It Does | Where a Site Calls It |
284
+ |---|---|---|
285
+ | `atelic-check-access` | Fails the build when `src/site.config.ts` says `access: "private"`, the build is on Vercel, and the wall's three variables are not set; warns on a local build of a private site | First step of `build` |
286
+ | `atelic-stamp-health` | Writes `dist/client/health.json` with the status, the commit, and the build time | Last step of `build` |
287
+ | `atelic-check-links` | Asks for every external link in the prerendered pages and exits 1 when one is dead | `check:links`, after a build |
288
+ | `atelic-check-wall <host> [path ...]` | Asks a deployed host for each document and held file with no cookie, and passes only when the wall answers every one and the sign in functions respond | `check:wall`, before any private link goes out |
289
+ | `atelic-reader-link <email> [path] [--token <token>] [--qr \| --no-qr]` | Mints a named reader: prints the sealed line for `analytics.identify` and the link to send, and writes `public/images/qr.png` for a public writeup's first reader | `reader` |
290
+ | `atelic-serve-static` | Serves `dist/client` on `127.0.0.1` the way Vercel does, on `PORT` or 4173 | The `webServer` command in `playwright.config.ts` |
291
+ | `atelic-print-sheets <url or dist/client> <out.pdf> [sheets] [page] [port]` | Prints a page to PDF with headless Chrome, reports the sheet count, and fails when a count is given and missed | `print` |
292
+ | `atelic-seal-document <config.json> [out.json]` | Seals a document into the payload `Gate` reads, one wrapped key for each reader. The payload names no one: no address, and readers numbered `r1`, `r2` whatever the config calls them | `seal` |
293
+
294
+ A site's scripts, whole:
295
+
296
+ ```json
297
+ {
298
+ "build": "atelic-check-access && tsc -b && vite build && cp dist/client/404/index.html dist/client/404.html && atelic-stamp-health",
299
+ "check:links": "atelic-check-links",
300
+ "check:wall": "atelic-check-wall",
301
+ "reader": "atelic-reader-link",
302
+ "print": "atelic-print-sheets",
303
+ "seal": "atelic-seal-document"
304
+ }
305
+ ```
306
+
307
+ The logic lives in `src/scripts/` as modules with their functions exported, which is what the tests import, and each file in `bin/` only reads the command line and calls one. The TypeScript commands open on `#!/usr/bin/env bun` and run as source, since `bun` runs TypeScript inside `node_modules`. The sealing command is plain `.mjs` under `#!/usr/bin/env node`, and the print command is `bash`; it needs Chrome (the `CHROME` variable names another binary), `python3`, and `curl`.
308
+
259
309
  ## Email
260
310
 
261
311
  `@atelic-action/ui/email` is the runner email design as React components, ported one to one from the jq library the runners compose their mail from (homebase `runners/lib/email.jq`). The components are born inline: every layout is a table, every style is an inline style object, and there is no CSS file and no `className`, because Gmail strips a style block and ignores media queries on some accounts. The same components therefore mount on a web page as happily as they render into a mail client.
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ // atelic-check-access: fails a build when a private artifact has no wall.
3
+ import { main } from "../src/scripts/check-access";
4
+
5
+ await main();
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ // atelic-check-links: checks every external link in the built pages.
3
+ import { main } from "../src/scripts/check-links";
4
+
5
+ await main();
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ // atelic-check-wall <host> [path ...]: probes a deployed host's access wall.
3
+ import { main } from "../src/scripts/check-wall";
4
+
5
+ await main(process.argv.slice(2));
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env bash
2
+ # Print an artifact page to PDF with headless Chrome and report its sheet
3
+ # count. The writeup flows across as many letter pages as it needs, its
4
+ # collapsibles printed open, so its count is a record of the print and not a
5
+ # layout check. The monthly page is one sheet by construction, so pass 1 for
6
+ # it and the script fails when it prints as anything else.
7
+ #
8
+ # Usage: atelic-print-sheets <url | dist/client> <out.pdf> [expected sheets] [page] [port]
9
+ # atelic-print-sheets dist/client public/writeup.pdf
10
+ # atelic-print-sheets dist/client ../2026-10-01-report.pdf 1 /report
11
+ # atelic-print-sheets https://summit.atelic.me/writeup ../2026-09-10-writeup.pdf
12
+ # A site offers the same as `bun run print <...>`. Both paths are read from
13
+ # the directory the command is run in, which is the site's root.
14
+ #
15
+ # A directory is served on a local port for the print (the page loads its
16
+ # fonts and images by absolute path) and the server is stopped after; the
17
+ # page is the path printed from it, /writeup unless named. A URL is printed
18
+ # as given. The writeup's PDF belongs in public/ as writeup.pdf beside the
19
+ # page (on a private artifact the wall holds it like the page, see
20
+ # ACCESS-GATE.md), and a dated copy goes in the client folder of the
21
+ # practice repo.
22
+ set -euo pipefail
23
+
24
+ TARGET=${1:?url or built directory required}
25
+ OUT=${2:?output pdf path required}
26
+ EXPECT=${3:-}
27
+ PAGE=${4:-/writeup}
28
+ PORT=${5:-8765}
29
+ CHROME=${CHROME:-"/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"}
30
+ [[ -x "$CHROME" ]] || { echo "Chrome not found at $CHROME" >&2; exit 1; }
31
+ # Chromium refuses to start as root with its sandbox on, and a cloud run is root.
32
+ SANDBOX=()
33
+ [[ $(id -u) -eq 0 ]] && SANDBOX=(--no-sandbox)
34
+
35
+ URL="$TARGET"
36
+ if [[ -d "$TARGET" ]]; then
37
+ PAGE="/${PAGE#/}"
38
+ PAGE="${PAGE%/}"
39
+ [[ -f "$TARGET$PAGE/index.html" ]] || { echo "no $PAGE/index.html in $TARGET (run bun build first)" >&2; exit 1; }
40
+ if curl -s -o /dev/null "http://127.0.0.1:$PORT/"; then
41
+ echo "port $PORT is already in use" >&2
42
+ exit 1
43
+ fi
44
+ python3 -m http.server "$PORT" --bind 127.0.0.1 --directory "$TARGET" >/dev/null 2>&1 &
45
+ SERVER=$!
46
+ trap 'kill $SERVER 2>/dev/null || true' EXIT
47
+ for _ in 1 2 3 4 5 6 7 8 9 10; do
48
+ curl -s -o /dev/null "http://127.0.0.1:$PORT/" && break
49
+ sleep 0.3
50
+ done
51
+ URL="http://127.0.0.1:$PORT$PAGE/"
52
+ fi
53
+
54
+ "$CHROME" --headless=new ${SANDBOX[@]+"${SANDBOX[@]}"} --disable-gpu --no-pdf-header-footer \
55
+ --virtual-time-budget=8000 --print-to-pdf="$OUT" "$URL" 2>/dev/null
56
+ [[ -s "$OUT" ]] || { echo "Chrome wrote no PDF for $URL" >&2; exit 1; }
57
+
58
+ PAGES=$(python3 - "$OUT" <<'PY'
59
+ import re, sys
60
+ data = open(sys.argv[1], "rb").read()
61
+ m = re.findall(rb"/Type\s*/Pages\b[^>]*?/Count\s+(\d+)", data)
62
+ print(max(int(x) for x in m) if m else 0)
63
+ PY
64
+ )
65
+ echo "printed $OUT: $PAGES sheets, $(wc -c <"$OUT" | tr -d ' ') bytes"
66
+ if [[ -n "$EXPECT" && "$PAGES" != "$EXPECT" ]]; then
67
+ echo "expected $EXPECT sheets; a sheet overflowed or one is missing" >&2
68
+ exit 1
69
+ fi
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ // atelic-reader-link <email> [path] [--token <token>] [--qr | --no-qr]: mints a reader link and its QR.
3
+ import { main } from "../src/scripts/reader-link";
4
+
5
+ await main(process.argv.slice(2));
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // atelic-seal-document <config.json> [out.json]: seals a document for its readers.
3
+ import { argv } from "node:process";
4
+ import { cli } from "../src/scripts/build-gate-payload.mjs";
5
+
6
+ await cli(argv.slice(2));
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ // atelic-serve-static: serves dist/client for the browser tests.
3
+ import { main } from "../src/scripts/serve-static";
4
+
5
+ main();
@@ -0,0 +1,5 @@
1
+ #!/usr/bin/env bun
2
+ // atelic-stamp-health: writes dist/client/health.json after a build.
3
+ import { main } from "../src/scripts/stamp-health";
4
+
5
+ main();
@@ -0,0 +1,24 @@
1
+ import { type UserConfig } from "vite";
2
+ export interface ArtifactConfigOptions {
3
+ /**
4
+ * The path of each artifact page the site carries, such as "/writeup".
5
+ * The root and the 404 page are always prerendered and need no entry.
6
+ */
7
+ pages: readonly string[];
8
+ /**
9
+ * The escape hatch for a site with a real need of its own: merged over
10
+ * the result with Vite's `mergeConfig`, so a site adds a setting without
11
+ * going back to a configuration written by hand.
12
+ */
13
+ extend?: UserConfig;
14
+ }
15
+ /** One entry of the prerender list. */
16
+ export interface ArtifactPage {
17
+ path: string;
18
+ prerender: {
19
+ enabled: true;
20
+ };
21
+ }
22
+ /** The prerender list for a site: the root, each page it names, then the 404. */
23
+ export declare function artifactPages(pages: readonly string[]): ArtifactPage[];
24
+ export declare function artifactConfig({ pages, extend }: ArtifactConfigOptions): UserConfig;
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The whole Vite configuration of an artifact site, as one call. Every site
3
+ * cut from the artifact template carried its own copy of this configuration;
4
+ * here it is written once, and a site's vite.config.ts names its pages:
5
+ *
6
+ * import { artifactConfig } from "@atelic-action/ui/vite";
7
+ *
8
+ * export default artifactConfig({ pages: ["/writeup", "/proposal", "/report"] });
9
+ *
10
+ * The site's own paths (the `@` alias to its src) are found from the
11
+ * directory Vite is run in, which is the site's root.
12
+ */
13
+ import { resolve } from "node:path";
14
+ import { tanstackStart } from "@tanstack/react-start/plugin/vite";
15
+ import react from "@vitejs/plugin-react";
16
+ import { mergeConfig } from "vite";
17
+ import { privateChunks } from "./private-chunks.js";
18
+ /** The prerender list for a site: the root, each page it names, then the 404. */
19
+ export function artifactPages(pages) {
20
+ const named = pages.filter((path) => path !== "/" && path !== "/404");
21
+ return [
22
+ // The root holds no document: it sends the reader to /writeup
23
+ // (vercel.json does the same on the host), and its prerendered
24
+ // index.html is what a static preview serves at the bare host.
25
+ { path: "/", prerender: { enabled: true } },
26
+ ...named.map((path) => ({ path, prerender: { enabled: true } })),
27
+ // The 404 page, rendered by the catch all route and copied to
28
+ // dist/client/404.html by the build for the host to serve on any miss.
29
+ { path: "/404", prerender: { enabled: true } },
30
+ ];
31
+ }
32
+ export function artifactConfig({ pages, extend }) {
33
+ const config = {
34
+ // Every stylesheet rides in the one base sheet the root route links
35
+ // (src/styles/base.css), the documents' own included, since they come from
36
+ // @atelic-action/ui. Nothing imports CSS from a component, so there is no
37
+ // component bundle to fold: `build.cssCodeSplit: false`, which sat here to
38
+ // fold it, now fails the build, because TanStack Start looks for the
39
+ // `style.css` that setting emits and none is written. A site that gives a
40
+ // component its own stylesheet again restores the setting through `extend`.
41
+ plugins: [
42
+ tanstackStart({
43
+ prerender: {
44
+ enabled: true,
45
+ crawlLinks: true,
46
+ // The writeup links each screenshot crop to its full image, and
47
+ // the crawler follows every href it finds: without this filter it
48
+ // prerenders /images/<shot>.png as an HTML page and writes that
49
+ // over the real file in dist/client (found on a client's writeup,
50
+ // 2026-09-24).
51
+ filter: ({ path }) => !path.startsWith("/images/"),
52
+ },
53
+ // Nothing links to an artifact page, so the crawler would miss every
54
+ // one of them; each is listed by the site to be prerendered at all. A
55
+ // site names the pages it uses and no others.
56
+ pages: artifactPages(pages),
57
+ // No sitemap. A sitemap is a page asking to be crawled, and every
58
+ // page here is a document prepared for one business; public/robots.txt
59
+ // disallows the whole host to match.
60
+ sitemap: { enabled: false },
61
+ }),
62
+ react(),
63
+ // A document's content builds into /assets/doc/, which the access wall
64
+ // holds back, and the build fails if any lands where the login page
65
+ // can load it (privateChunks beside this file, needsSession in
66
+ // @atelic-action/ui/gate).
67
+ privateChunks(),
68
+ ],
69
+ resolve: {
70
+ alias: {
71
+ // The site's src, from the directory Vite is run in: this file lives
72
+ // in the package, so nothing here can be found relative to it.
73
+ "@": resolve(process.cwd(), "./src"),
74
+ },
75
+ },
76
+ // @atelic-action/ui ships TSX and CSS as written, so the prerender's server build
77
+ // has to compile it rather than hand it to Node as an external.
78
+ ssr: {
79
+ noExternal: ["@atelic-action/ui"],
80
+ },
81
+ };
82
+ return extend ? mergeConfig(config, extend) : config;
83
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * What a site's vite.config.ts imports (`@atelic-action/ui/vite`): the
3
+ * configuration of an artifact site as one call, and the plugin inside it
4
+ * for a site that writes its own.
5
+ */
6
+ export { type ArtifactConfigOptions, type ArtifactPage, artifactConfig, artifactPages, } from "./artifact-config.js";
7
+ export { DOC_DIR, isPrivateContent, privateChunks } from "./private-chunks.js";
@@ -0,0 +1,7 @@
1
+ /**
2
+ * What a site's vite.config.ts imports (`@atelic-action/ui/vite`): the
3
+ * configuration of an artifact site as one call, and the plugin inside it
4
+ * for a site that writes its own.
5
+ */
6
+ export { artifactConfig, artifactPages, } from "./artifact-config.js";
7
+ export { DOC_DIR, isPrivateContent, privateChunks } from "./private-chunks.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "0.15.1",
3
+ "version": "0.16.1",
4
4
  "description": "Shared UI for the Atelic templates: site chrome, base styles, the component library, and the access gate they install",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",
@@ -13,8 +13,19 @@
13
13
  },
14
14
  "files": [
15
15
  "src",
16
- "dist"
16
+ "dist",
17
+ "bin"
17
18
  ],
19
+ "bin": {
20
+ "atelic-check-access": "./bin/atelic-check-access.ts",
21
+ "atelic-check-links": "./bin/atelic-check-links.ts",
22
+ "atelic-check-wall": "./bin/atelic-check-wall.ts",
23
+ "atelic-print-sheets": "./bin/atelic-print-sheets.sh",
24
+ "atelic-reader-link": "./bin/atelic-reader-link.ts",
25
+ "atelic-seal-document": "./bin/atelic-seal-document.mjs",
26
+ "atelic-serve-static": "./bin/atelic-serve-static.ts",
27
+ "atelic-stamp-health": "./bin/atelic-stamp-health.ts"
28
+ },
18
29
  "sideEffects": [
19
30
  "**/*.css"
20
31
  ],
@@ -56,8 +67,8 @@
56
67
  "default": "./dist/gate/verify.js"
57
68
  },
58
69
  "./vite": {
59
- "types": "./dist/vite/private-chunks.d.ts",
60
- "default": "./dist/vite/private-chunks.js"
70
+ "types": "./dist/vite/index.d.ts",
71
+ "default": "./dist/vite/index.js"
61
72
  },
62
73
  "./package.json": "./package.json"
63
74
  },
@@ -73,24 +84,36 @@
73
84
  "gallery:build": "vite build gallery"
74
85
  },
75
86
  "dependencies": {
76
- "jose": "^6.2.12"
87
+ "jose": "^6.2.12",
88
+ "qrcode": "^1.5.4"
77
89
  },
78
90
  "peerDependencies": {
91
+ "@tanstack/react-start": "^1.168.0",
92
+ "@vitejs/plugin-react": "^5.0.0 || ^6.0.0",
79
93
  "lucide-react": "^1.23.0",
80
94
  "react": "^19.0.0",
81
95
  "react-dom": "^19.0.0",
82
96
  "vite": "^7.0.0 || ^8.0.0"
83
97
  },
84
98
  "peerDependenciesMeta": {
99
+ "@tanstack/react-start": {
100
+ "optional": true
101
+ },
102
+ "@vitejs/plugin-react": {
103
+ "optional": true
104
+ },
85
105
  "vite": {
86
106
  "optional": true
87
107
  }
88
108
  },
89
109
  "devDependencies": {
90
110
  "@biomejs/biome": "^2.5.13",
111
+ "@tanstack/react-start": "^1.168.60",
91
112
  "@testing-library/jest-dom": "^6.6.2",
92
113
  "@testing-library/react": "^16.0.1",
114
+ "@types/bun": "^1.4.2",
93
115
  "@types/node": "^24.0.0",
116
+ "@types/qrcode": "^1.5.6",
94
117
  "@types/react": "^19.0.0",
95
118
  "@types/react-dom": "^19.0.0",
96
119
  "@vitejs/plugin-react": "^6.1.1",
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Seals a document into the payload the `Gate` component reads.
3
+ *
4
+ * One content key encrypts the document once (AES-GCM). That key is then
5
+ * wrapped once per reader, under a key derived from that reader's own email
6
+ * address with PBKDF2. So every reader opens the same document with the one
7
+ * thing they always know, and the emitted payload carries no plaintext: not
8
+ * the document, and not even a hash the reader list could be checked
9
+ * against. Nor a reader's address: it is the secret their key derives from,
10
+ * so listing it beside their wrapped key would hand the document to anyone
11
+ * who reads the page's script. Payloads sealed before 2026-10-08 did list
12
+ * it; the gate now tells HubSpot who opened the document from the address
13
+ * the reader typed. The secret is still never anything beyond that address,
14
+ * which is the gate's deliberate, documented security tier. See GATE.md.
15
+ *
16
+ * Node's webcrypto is the same algorithm surface the browser exposes, so the
17
+ * two sides are byte compatible by construction rather than by agreement.
18
+ * The parameters below are the contract; changing one here without changing
19
+ * the matching constant in `Gate` (@atelic-action/ui/artifact) makes every
20
+ * existing payload unopenable.
21
+ *
22
+ * CLI (bin/atelic-seal-document.mjs runs this file's `main` under Node):
23
+ * atelic-seal-document <config.json> [out.json]
24
+ * bun run seal <config.json> [out.json] (the form a site offers)
25
+ *
26
+ * Config shape (paths resolve relative to the config file):
27
+ * { "content": "./proposal.html",
28
+ * "people": [{ "id": "kyle", "email": "kyle@example.com" }] }
29
+ */
30
+ import { webcrypto } from "node:crypto";
31
+ import { readFileSync, writeFileSync } from "node:fs";
32
+ import { dirname, resolve } from "node:path";
33
+ import { argv, exit, stderr, stdout } from "node:process";
34
+
35
+ /** PBKDF2 work factor. Mirrored by Gate; changing it breaks old payloads. */
36
+ export const PBKDF2_ITERATIONS = 200000;
37
+ export const PBKDF2_HASH = "SHA-256";
38
+ /** Byte lengths: content key, GCM nonce, per person PBKDF2 salt. */
39
+ export const KEY_BYTES = 32;
40
+ export const IV_BYTES = 12;
41
+ export const SALT_BYTES = 16;
42
+
43
+ const encoder = new TextEncoder();
44
+
45
+ /**
46
+ * The one normalization both sides share: an email is its letters and
47
+ * digits, uppercased. So "Dana@Example.com", "dana@example.com" and a stray
48
+ * space are all the same key, and a reader typing their own address cannot
49
+ * miss by punctuation or case. Mirrored in Gate; the two implementations
50
+ * must agree exactly.
51
+ */
52
+ export function normalizeEmail(email) {
53
+ return String(email ?? "")
54
+ .toUpperCase()
55
+ .replace(/[^A-Z0-9]/g, "");
56
+ }
57
+
58
+ function toBase64(bytes) {
59
+ return Buffer.from(bytes).toString("base64");
60
+ }
61
+
62
+ function randomBytes(length) {
63
+ return webcrypto.getRandomValues(new Uint8Array(length));
64
+ }
65
+
66
+ /** Derive one reader's wrapping key from their email and their own salt. */
67
+ async function deriveKey(email, salt) {
68
+ const material = await webcrypto.subtle.importKey(
69
+ "raw",
70
+ encoder.encode(normalizeEmail(email)),
71
+ "PBKDF2",
72
+ false,
73
+ ["deriveKey"],
74
+ );
75
+ return webcrypto.subtle.deriveKey(
76
+ { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: PBKDF2_HASH },
77
+ material,
78
+ { name: "AES-GCM", length: 256 },
79
+ false,
80
+ ["encrypt", "decrypt"],
81
+ );
82
+ }
83
+
84
+ /**
85
+ * Encrypt `contentHtml` once and wrap the content key for each person.
86
+ * `people` is [{ id, email }]. The email is the reader's secret, the thing
87
+ * their key derives from, so it never reaches the payload; nor does the id,
88
+ * which is often a first name. A sealed reader is an ordinal (`r1`, `r2`)
89
+ * and their wrap, and nothing that says who they are.
90
+ */
91
+ export async function sealDocument(contentHtml, people) {
92
+ if (typeof contentHtml !== "string" || contentHtml.length === 0) {
93
+ throw new Error("contentHtml is empty; nothing to seal");
94
+ }
95
+ if (!Array.isArray(people) || people.length === 0) {
96
+ throw new Error("no people; a payload nobody can open is not worth building");
97
+ }
98
+
99
+ const contentKeyBytes = randomBytes(KEY_BYTES);
100
+ const contentKey = await webcrypto.subtle.importKey(
101
+ "raw",
102
+ contentKeyBytes,
103
+ { name: "AES-GCM" },
104
+ false,
105
+ ["encrypt"],
106
+ );
107
+ const civ = randomBytes(IV_BYTES);
108
+ const ct = new Uint8Array(
109
+ await webcrypto.subtle.encrypt(
110
+ { name: "AES-GCM", iv: civ },
111
+ contentKey,
112
+ encoder.encode(contentHtml),
113
+ ),
114
+ );
115
+
116
+ const seen = new Set();
117
+ const sealed = [];
118
+ for (const person of people) {
119
+ const id = person?.id;
120
+ if (!id) throw new Error("every person needs an id");
121
+ if (seen.has(id)) throw new Error(`duplicate person id: ${id}`);
122
+ seen.add(id);
123
+ if (!person.email || !normalizeEmail(person.email)) {
124
+ throw new Error(`person ${id} has no email (or none that survives normalization)`);
125
+ }
126
+
127
+ const salt = randomBytes(SALT_BYTES);
128
+ const iv = randomBytes(IV_BYTES);
129
+ const key = await deriveKey(person.email, salt);
130
+ const wrapped = new Uint8Array(
131
+ await webcrypto.subtle.encrypt({ name: "AES-GCM", iv }, key, contentKeyBytes),
132
+ );
133
+ // The id a config gives a reader is often their first name, which
134
+ // beside the site's domain all but spells the address that is their
135
+ // key. So the payload carries a plain ordinal, and the config's id
136
+ // stays in the config, where it names a reader in an error message.
137
+ sealed.push({
138
+ id: `r${sealed.length + 1}`,
139
+ salt: toBase64(salt),
140
+ iv: toBase64(iv),
141
+ wrapped: toBase64(wrapped),
142
+ });
143
+ }
144
+
145
+ return { civ: toBase64(civ), ct: toBase64(ct), people: sealed };
146
+ }
147
+
148
+ /**
149
+ * The command: `args` is the command line after the command's own name.
150
+ * Both paths resolve against the working directory.
151
+ */
152
+ export async function main(args = argv.slice(2)) {
153
+ const [configPath, outPath] = args;
154
+ if (!configPath) {
155
+ stderr.write("usage: atelic-seal-document <config.json> [out.json]\n");
156
+ exit(1);
157
+ }
158
+
159
+ const configFile = resolve(configPath);
160
+ const config = JSON.parse(readFileSync(configFile, "utf8"));
161
+ if (!config.content) throw new Error(`${configPath} has no "content" path`);
162
+
163
+ const contentHtml = readFileSync(resolve(dirname(configFile), config.content), "utf8");
164
+ const payload = await sealDocument(contentHtml, config.people);
165
+
166
+ const out = resolve(outPath ?? "gate-payload.json");
167
+ writeFileSync(out, `${JSON.stringify(payload, null, "\t")}\n`);
168
+ stdout.write(`[gate] ${out} (${payload.people.length} readers)\n`);
169
+ }
170
+
171
+ /** Runs `main` and turns a failure into the one line the command prints. */
172
+ export function cli(args = argv.slice(2)) {
173
+ return main(args).catch((error) => {
174
+ stderr.write(`[gate] ${error.message}\n`);
175
+ exit(1);
176
+ });
177
+ }