@atelic-action/ui 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ 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
+ 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
14
 
15
15
  ## Vite Config
16
16
 
@@ -46,7 +46,7 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
46
46
  | `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
47
47
  | `@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
48
  | `@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 |
49
+ | `@atelic-action/ui/artifact/readers` | The reader helpers alone, built, for what Node runs as shipped: a site's browser tests |
50
50
  | `@atelic-action/ui/primitives` | `Button`, `Chip`, `Eyebrow`, `Lead`, `SectionHeading`, and their prop types (see [Primitives](#primitives)) |
51
51
  | `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
52
52
  | `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
@@ -55,7 +55,8 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
55
55
  | `@atelic-action/ui/styles/primitives.css` | Styles for everything under `primitives`, the `.btn` classes included |
56
56
  | `@atelic-action/ui/styles/gate.css` | Styles for `GateLogin` and `Watermark` |
57
57
  | `@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` |
58
+ | `@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)) |
59
+ | 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
60
  | `@atelic-action/ui/gate` | `signToken`, `verifyToken`, `safeNext`, `needsSession`, and the token types |
60
61
  | `@atelic-action/ui/gate/request-link` | `POST`, the handler that emails a sign in link, and `createRequestLink` |
61
62
  | `@atelic-action/ui/gate/verify` | `GET`, the handler that turns a link into a session |
@@ -196,7 +197,7 @@ What it holds to, each with a test in `tst/gate/`:
196
197
 
197
198
  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
199
 
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.
200
+ 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
201
 
201
202
  ### The Screen and the Watermark
202
203
 
@@ -226,7 +227,7 @@ The two pieces a reader sees are ordinary components, in `@atelic-action/ui/comp
226
227
 
227
228
  `@atelic-action/ui/artifact` is everything the artifact template shared across its cuts, so a fix reaches every business's documents with one version bump:
228
229
 
229
- - **The documents:** `Writeup`, `Report`, and `Gate` (the sealed document, with its `GatePayload` type), plus the `GradeChip` and `SignOff` the first two share, and every content type they take (`WriteupProps`, `ReportProps`, `GateProps`, and the rest).
230
+ - **The documents:** `Writeup`, `Report`, and `Gate` (the sealed document, with its `GatePayload` type; a reader's address is the key, so a payload never lists it, and the component identifies a reader to HubSpot by the address they typed), plus the `GradeChip` and `SignOff` the first two share, and every content type they take (`WriteupProps`, `ReportProps`, `GateProps`, and the rest).
230
231
  - **The shell:** `ArtifactShell`, the `.mkt` scope with the skip link, the dark header, the credit band, and the watermark around a page.
231
232
  - **The contract and the head:** the `ArtifactConfig` types, and `buildPageHead` and `canonicalUrl` with their `PageMeta` type.
232
233
  - **Who opened it:** `mintToken`, `sealReader`, `openReader`, `readerId`, `readerLink`, and `whoIsReading`. The same six are also built and exported alone as `@atelic-action/ui/artifact/readers`, because a Playwright spec and a script run under Node, which compiles nothing inside `node_modules`.
@@ -247,15 +248,62 @@ export function ArtifactShell(props: Omit<ArtifactShellProps, "currentPath">) {
247
248
 
248
249
  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
250
 
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:
251
+ ### The Artifact Vite Config
252
+
253
+ `@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
254
 
252
255
  ```ts
253
256
  // vite.config.ts
254
- import { privateChunks } from "@atelic-action/ui/vite";
257
+ import { artifactConfig } from "@atelic-action/ui/vite";
258
+
259
+ export default artifactConfig({ pages: ["/writeup", "/proposal", "/report"] });
260
+ ```
261
+
262
+ `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.
263
+
264
+ A site with a real need of its own passes `extend`, which is merged over the result with Vite's `mergeConfig`:
265
+
266
+ ```ts
267
+ export default artifactConfig({
268
+ pages: ["/writeup"],
269
+ extend: { server: { port: 5180 } },
270
+ });
271
+ ```
272
+
273
+ `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.
274
+
275
+ 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.
276
+
277
+ ### The Commands
255
278
 
256
- export default defineConfig({ plugins: [tanstackStart(), react(), privateChunks()] });
279
+ 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.
280
+
281
+ | Command | What It Does | Where a Site Calls It |
282
+ |---|---|---|
283
+ | `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` |
284
+ | `atelic-stamp-health` | Writes `dist/client/health.json` with the status, the commit, and the build time | Last step of `build` |
285
+ | `atelic-check-links` | Asks for every external link in the prerendered pages and exits 1 when one is dead | `check:links`, after a build |
286
+ | `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 |
287
+ | `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` |
288
+ | `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` |
289
+ | `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` |
290
+ | `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` |
291
+
292
+ A site's scripts, whole:
293
+
294
+ ```json
295
+ {
296
+ "build": "atelic-check-access && tsc -b && vite build && cp dist/client/404/index.html dist/client/404.html && atelic-stamp-health",
297
+ "check:links": "atelic-check-links",
298
+ "check:wall": "atelic-check-wall",
299
+ "reader": "atelic-reader-link",
300
+ "print": "atelic-print-sheets",
301
+ "seal": "atelic-seal-document"
302
+ }
257
303
  ```
258
304
 
305
+ 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`.
306
+
259
307
  ## Email
260
308
 
261
309
  `@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.0",
3
+ "version": "0.16.0",
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",
@@ -19,10 +19,13 @@ export interface GatePerson {
19
19
  /** Stable handle for the reader, used in the payload and in `onUnlock`. */
20
20
  id: string;
21
21
  /**
22
- * The reader's email address: the secret their key derives from, and,
23
- * when it matches `identifyDomain`, the identity pushed to HubSpot.
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.
24
27
  */
25
- email: string;
28
+ email?: string;
26
29
  /** Base64: this reader's PBKDF2 salt, GCM nonce, and wrapped content key. */
27
30
  salt: string;
28
31
  iv: string;
@@ -83,6 +86,8 @@ function fromBase64(value: string): Uint8Array {
83
86
 
84
87
  interface Opened {
85
88
  person: GatePerson;
89
+ /** The address that opened the document, as the reader gave it. */
90
+ email: string;
86
91
  html: string;
87
92
  }
88
93
 
@@ -137,7 +142,7 @@ async function openDocument(payload: GatePayload, rawEmail: string): Promise<Ope
137
142
  contentKey,
138
143
  fromBase64(payload.ct) as BufferSource,
139
144
  );
140
- return { person, html: new TextDecoder().decode(plain) };
145
+ return { person, email: rawEmail, html: new TextDecoder().decode(plain) };
141
146
  } catch {
142
147
  // Wrong email for this reader. Try the next one; say nothing.
143
148
  }
@@ -145,11 +150,18 @@ async function openDocument(payload: GatePayload, rawEmail: string): Promise<Ope
145
150
  return null;
146
151
  }
147
152
 
148
- function identifyReader(person: GatePerson, domain: string | undefined) {
149
- if (!domain || !person.email) return;
150
- if (!person.email.toLowerCase().endsWith(`@${domain.toLowerCase()}`)) return;
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;
151
163
  window._hsq = window._hsq || [];
152
- window._hsq.push(["identify", { email: person.email }]);
164
+ window._hsq.push(["identify", { email }]);
153
165
  window._hsq.push(["setPath", window.location.pathname]);
154
166
  window._hsq.push(["trackPageView"]);
155
167
  }
@@ -238,7 +250,7 @@ export function Gate({
238
250
  if (cancelled) return;
239
251
  if (opened) {
240
252
  writeStored(storageKey, fromQuery);
241
- identifyReader(opened.person, identifyDomain);
253
+ identifyReader(opened.email, identifyDomain);
242
254
  setHtml(opened.html);
243
255
  onUnlockRef.current?.(opened.person);
244
256
  return;
@@ -252,7 +264,7 @@ export function Gate({
252
264
  if (!stored) return;
253
265
  const opened = await openDocument(payload, stored);
254
266
  if (cancelled || !opened) return;
255
- identifyReader(opened.person, identifyDomain);
267
+ identifyReader(opened.email, identifyDomain);
256
268
  setHtml(opened.html);
257
269
  onUnlockRef.current?.(opened.person);
258
270
  }
@@ -275,7 +287,7 @@ export function Gate({
275
287
  return;
276
288
  }
277
289
  writeStored(storageKey, value);
278
- identifyReader(opened.person, identifyDomain);
290
+ identifyReader(opened.email, identifyDomain);
279
291
  setHtml(opened.html);
280
292
  onUnlockRef.current?.(opened.person);
281
293
  }