@atelic-action/ui 0.14.0 → 0.15.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
@@ -33,8 +33,9 @@ Import the stylesheets in this order, from the root route or the site's base she
33
33
  4. `@atelic-action/ui/styles/components.css`
34
34
  5. `@atelic-action/ui/styles/primitives.css`
35
35
  6. `@atelic-action/ui/styles/gate.css`, on a site that mounts the access gate
36
- 7. The site's own CSS
37
- 8. The site's `theme.css`, last
36
+ 7. `@atelic-action/ui/styles/artifact.css`, on a site that hosts the artifact documents
37
+ 8. The site's own CSS
38
+ 9. The site's `theme.css`, last
38
39
 
39
40
  The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outside a layer wins over them whatever its specificity. The package reads the theme tokens (`--ink`, `--surface`, `--primary`, `--nav-height`, and the rest) and defines none, so `theme.css` stays the one file a site edits to rebrand. The chrome's buttons wear the `.btn` classes, which `primitives.css` defines and a site may restyle.
40
41
 
@@ -44,6 +45,8 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
44
45
  |---|---|
45
46
  | `@atelic-action/ui/chrome` | `SiteHeader`, `SiteMenu`, `Footer`, `CreditBar`, `StickyCTABar`, `BrandLockup`, `SkipLink`, and their prop types |
46
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
+ | `@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 |
47
50
  | `@atelic-action/ui/primitives` | `Button`, `Chip`, `Eyebrow`, `Lead`, `SectionHeading`, and their prop types (see [Primitives](#primitives)) |
48
51
  | `@atelic-action/ui/email` | The email components, the theme provider, the plain text helpers, and their prop types (see [Email](#email)) |
49
52
  | `@atelic-action/ui/email/render` | `renderEmail` and `renderFailureEmail`, the only entry that imports `react-dom/server` |
@@ -51,6 +54,8 @@ The package sheets sit inside `@layer atelic-ui`, so any rule a site writes outs
51
54
  | `@atelic-action/ui/hooks` | `useScrollSpy` and its `PageStop` type |
52
55
  | `@atelic-action/ui/styles/primitives.css` | Styles for everything under `primitives`, the `.btn` classes included |
53
56
  | `@atelic-action/ui/styles/gate.css` | Styles for `GateLogin` and `Watermark` |
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` |
54
59
  | `@atelic-action/ui/gate` | `signToken`, `verifyToken`, `safeNext`, `needsSession`, and the token types |
55
60
  | `@atelic-action/ui/gate/request-link` | `POST`, the handler that emails a sign in link, and `createRequestLink` |
56
61
  | `@atelic-action/ui/gate/verify` | `GET`, the handler that turns a link into a session |
@@ -191,7 +196,7 @@ What it holds to, each with a test in `tst/gate/`:
191
196
 
192
197
  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.
193
198
 
194
- This is the one corner of the package that ships compiled. 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.
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.
195
200
 
196
201
  ### The Screen and the Watermark
197
202
 
@@ -217,6 +222,40 @@ The two pieces a reader sees are ordinary components, in `@atelic-action/ui/comp
217
222
 
218
223
  `GateLogin` posts to `/api/auth/request-link`; a site served under a base path passes its own `endpoint`. Its button wears the site's `.btn` classes, as the chrome's do. `Watermark` reads the `gate_email` cookie and draws nothing without one. `readCookie`, exported beside them, is the reader `Watermark` uses and a site's own code can: empty during prerender, for a missing cookie, and for one that will not decode. `GateLogin` says so when an address is not an email, holds a second submit while the first is in flight, and announces each outcome to a screen reader.
219
224
 
225
+ ## The Artifact Documents
226
+
227
+ `@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
+ - **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
+ - **The shell:** `ArtifactShell`, the `.mkt` scope with the skip link, the dark header, the credit band, and the watermark around a page.
231
+ - **The contract and the head:** the `ArtifactConfig` types, and `buildPageHead` and `canonicalUrl` with their `PageMeta` type.
232
+ - **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`.
233
+ - **The error boundary:** `AppErrorBoundary` and its `ErrorFallback`.
234
+ - **`newTabProps`,** for the links a content module writes.
235
+
236
+ The package imports no router, so `ArtifactShell` takes the path being read as its `currentPath` prop. A site keeps a wrapper a few lines long that reads the path from its own router and passes it down:
237
+
238
+ ```tsx
239
+ import { ArtifactShell as Shell, type ArtifactShellProps } from "@atelic-action/ui/artifact";
240
+ import { useLocation } from "@tanstack/react-router";
241
+
242
+ export function ArtifactShell(props: Omit<ArtifactShellProps, "currentPath">) {
243
+ const currentPath = useLocation({ select: (location) => location.pathname });
244
+ return <Shell {...props} currentPath={currentPath} />;
245
+ }
246
+ ```
247
+
248
+ 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
+ `@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
+
252
+ ```ts
253
+ // vite.config.ts
254
+ import { privateChunks } from "@atelic-action/ui/vite";
255
+
256
+ export default defineConfig({ plugins: [tanstackStart(), react(), privateChunks()] });
257
+ ```
258
+
220
259
  ## Email
221
260
 
222
261
  `@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,138 @@
1
+ /**
2
+ * The contract between an artifact and the components that host it.
3
+ *
4
+ * An artifact is a document, not a business's website, so this is a fraction
5
+ * of a marketing site's config: who it is from, who it was prepared for,
6
+ * where it is hosted, who may read it, and whether an open reports to the
7
+ * CRM. Everything a reader sees on the page itself arrives as content, never
8
+ * from here.
9
+ */
10
+ /**
11
+ * Copy for the access-gate login screen. Present in the config so the login
12
+ * page is themed and worded per artifact with no code edits. The gate itself
13
+ * is turned on by setting GATE_SESSION_SECRET on the Vercel project, not by
14
+ * this block; `access` below says whether it must be. See ACCESS-GATE.md.
15
+ */
16
+ export interface GateConfig {
17
+ /** Mono eyebrow above the headline, e.g. "Private preview". */
18
+ eyebrow?: string;
19
+ /** Login headline. */
20
+ headline: string;
21
+ /** Sub-lead under the headline. */
22
+ lead: string;
23
+ /** Fine-print attribution, e.g. "Built by Jane Maker". Defaults to the sender's name. */
24
+ builtBy?: string;
25
+ /** Contact email shown in the fine print. Defaults to the sender's email. */
26
+ contactEmail?: string;
27
+ }
28
+ /**
29
+ * A named reader, sealed. This config ships in the script every page loads,
30
+ * so it lists neither the reader's email nor their token: `id` is a digest
31
+ * of the token, and `sealed` is the email encrypted under a key derived from
32
+ * it. Only the reader's own link opens it (readers.ts).
33
+ */
34
+ export interface ArtifactReader {
35
+ id: string;
36
+ sealed: string;
37
+ }
38
+ /**
39
+ * Who the document is from: the practice, on every artifact. The lockup in
40
+ * the header renders from `logo` exactly as the marketing chrome's does, so
41
+ * a client branded artifact host keeps the sender's chrome over the client's
42
+ * palette (GATE.md, Branding an Artifact Host).
43
+ */
44
+ export interface ArtifactSender {
45
+ /** The sender's name, worn in the header and the gate's fine print. */
46
+ name: string;
47
+ /**
48
+ * The chrome identity. `src` is the mark image; omit it for a pure text
49
+ * wordmark. `tile` renders a CSS glyph tile instead of an image (a dark
50
+ * rounded square bearing the given character in the brand's own webfont,
51
+ * with the accent bar beneath, since an SVG-as-img mark cannot load
52
+ * webfonts). `wordmark` overrides the rendered text when it should differ
53
+ * from `name`; `run` appends the trailing fade bar after it.
54
+ */
55
+ logo: {
56
+ src?: string;
57
+ alt: string;
58
+ tile?: string;
59
+ wordmark?: string;
60
+ run?: boolean;
61
+ };
62
+ /** Where the header lockup points; the sender's own site. */
63
+ href?: string;
64
+ /** Reply address, shown in the access gate's fine print. */
65
+ email: string;
66
+ }
67
+ /** Who the document was prepared for; the business's name suffixes every title. */
68
+ export interface ArtifactRecipient {
69
+ name: string;
70
+ /**
71
+ * The recipient's mark for the header lockup ("atelic × <mark>"), cut for
72
+ * the dark bar (a light mark on transparent). Unset, the header wears the
73
+ * sender's lockup alone. ArtifactShell passes it to the ui header as
74
+ * `brand.recipient` (@atelic-action/ui 0.6.0 and later).
75
+ *
76
+ * A logo is a brand mark, not a private signal, so the file lives under
77
+ * public/brand/, which the access wall leaves open: the router's pending
78
+ * page carries the header, so every page preloads this mark, the login
79
+ * page included, and one under public/images/ would be held there. The
80
+ * writeup's printed copy is white paper, so the lockup it prints in place
81
+ * of the header takes a mark cut for a light ground (`recipient.logoSrc`
82
+ * in the writeup's content).
83
+ */
84
+ logo?: {
85
+ src: string;
86
+ alt: string;
87
+ };
88
+ }
89
+ export interface ArtifactConfig {
90
+ /** Canonical origin of the hosted twin, no trailing slash, e.g. "https://og.atelic.me". */
91
+ url: string;
92
+ /**
93
+ * Who may read it, which decides where it may be hosted (the practice's
94
+ * FOOTPRINT.md, Privacy). "public" when everything on it was read from
95
+ * public signals, a prospect's writeup; "private" when anything came from
96
+ * the owner's own consoles, which is every customer's writeup and every
97
+ * monthly page. A private artifact deploys only behind the armed wall:
98
+ * the build fails on Vercel without the wall's variables
99
+ * (scripts/check-access.ts), and scripts/check-wall.ts proves the live
100
+ * host answers with the wall before any link goes out (ACCESS-GATE.md).
101
+ */
102
+ access: "public" | "private";
103
+ sender: ArtifactSender;
104
+ recipient: ArtifactRecipient;
105
+ /** Social card image; a site-relative path resolves against `url`. */
106
+ ogImage?: string;
107
+ /**
108
+ * Robots line for every page. Artifacts default to "noindex, nofollow"
109
+ * and there is no reason to raise it: a document handed to one business
110
+ * is never search inventory.
111
+ */
112
+ robots?: string;
113
+ /**
114
+ * Analytics wiring. When `hubspotPortalId` is set, the HubSpot tracking
115
+ * code loads and records the open; because an artifact lives on its own
116
+ * host, every visit is attributable to that engagement. `hubspotRegion`
117
+ * picks the loader host and defaults to "na1"; na2 portals load from
118
+ * js-na2.hs-scripts.com.
119
+ *
120
+ * `identify` says who each link identifies, and every cut says it: a
121
+ * reader for every named person a link goes to, or an empty list. When a
122
+ * visit arrives with `?k=<token>` (the token on that person's link), the
123
+ * visitor is identified to HubSpot by their email, so the open lands on
124
+ * their contact timeline. Visits without a token stay anonymous. Mint a
125
+ * reader with `bun run reader <email>`.
126
+ *
127
+ * A private artifact needs nobody listed: its readers sign in at the
128
+ * wall and are identified by the email they signed in with. DEPLOY.md,
129
+ * Who Opened It, is the procedure.
130
+ */
131
+ analytics?: {
132
+ hubspotPortalId: string;
133
+ hubspotRegion?: "na1" | "na2" | "eu1";
134
+ identify: ArtifactReader[];
135
+ };
136
+ /** Copy for the access-gate login screen (see GateConfig). */
137
+ gate?: GateConfig;
138
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The contract between an artifact and the components that host it.
3
+ *
4
+ * An artifact is a document, not a business's website, so this is a fraction
5
+ * of a marketing site's config: who it is from, who it was prepared for,
6
+ * where it is hosted, who may read it, and whether an open reports to the
7
+ * CRM. Everything a reader sees on the page itself arrives as content, never
8
+ * from here.
9
+ */
10
+ export {};
@@ -0,0 +1,33 @@
1
+ import type { ArtifactReader } from "./artifactConfig.js";
2
+ /** What a config lists a token under: a digest, so the token itself never ships. */
3
+ export declare function readerId(token: string): Promise<string>;
4
+ /** A fresh token for one reader's links: twelve characters, safe in a URL. */
5
+ export declare function mintToken(): string;
6
+ /** Seal a reader's email under their token: the entry site.config.ts lists. */
7
+ export declare function sealReader(email: string, token: string): Promise<ArtifactReader>;
8
+ /** The email a link's token opens, or undefined when it opens nobody's. */
9
+ export declare function openReader(readers: ArtifactReader[] | undefined, token: string | null | undefined): Promise<string | undefined>;
10
+ /**
11
+ * The email this visit should be identified as, or undefined to leave it
12
+ * anonymous.
13
+ *
14
+ * On a private artifact the sign in decides, and a link's token never
15
+ * overrides it: the wall checked who this is, and a token only says who a
16
+ * link was sent to. So a signed in reader is identified by their own email,
17
+ * and a reader on the sender's domain is nobody's open even through someone
18
+ * else's link, since that is the practice reading its own document. A
19
+ * visit with no sign in (the wall not armed, as in local dev) falls back to
20
+ * the link.
21
+ *
22
+ * A public artifact has no wall, so a `gate_email` cookie there proves
23
+ * nothing and is never read: the link's token is the only identity.
24
+ */
25
+ export declare function whoIsReading(visit: {
26
+ readers: ArtifactReader[] | undefined;
27
+ search: string;
28
+ access: "public" | "private";
29
+ gateEmail: string;
30
+ senderEmail: string;
31
+ }): Promise<string | undefined>;
32
+ /** The link to send a reader: the artifact's page with their token on it. */
33
+ export declare function readerLink(siteUrl: string, token: string, path?: string): string;
@@ -0,0 +1,117 @@
1
+ /**
2
+ * Who opened it. An artifact reports every open to the practice's HubSpot
3
+ * portal, and an open lands on a person's contact record only when the visit
4
+ * says who they are. A visit says so one of two ways:
5
+ *
6
+ * - **Their link.** Every link sent to a named person carries
7
+ * `?k=<their token>`, and site.config.ts lists that reader.
8
+ * - **The wall.** A reader who signed in on a private host is known by the
9
+ * email they signed in with, read from the `gate_email` cookie.
10
+ *
11
+ * site.config.ts ships in the script every page loads, so a reader is listed
12
+ * sealed, never in the clear: `id` is a digest of the token, and `sealed` is
13
+ * the email encrypted under a key derived from the token. Only the link can
14
+ * open it, and the link is in one person's inbox. Reading the page's source
15
+ * gives nobody's address.
16
+ *
17
+ * Nothing here touches the page or the network, so it tests without a
18
+ * browser. The root route does the one impure thing, the push to HubSpot.
19
+ *
20
+ * This file is also built to dist/ and exported on its own as
21
+ * `@atelic-action/ui/artifact/readers`, because a site's browser tests and
22
+ * its reader script import it under Node, which runs no TypeScript inside
23
+ * node_modules. So it keeps the built corner's rules: no JSX, and `.js` on
24
+ * its relative imports.
25
+ */
26
+ const encoder = new TextEncoder();
27
+ const decoder = new TextDecoder();
28
+ function toBase64Url(bytes) {
29
+ return btoa(String.fromCharCode(...bytes))
30
+ .replaceAll("+", "-")
31
+ .replaceAll("/", "_")
32
+ .replaceAll("=", "");
33
+ }
34
+ function fromBase64Url(text) {
35
+ const padded = text.replaceAll("-", "+").replaceAll("_", "/");
36
+ return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
37
+ }
38
+ async function digest(text) {
39
+ return crypto.subtle.digest("SHA-256", encoder.encode(text));
40
+ }
41
+ /** What a config lists a token under: a digest, so the token itself never ships. */
42
+ export async function readerId(token) {
43
+ const bytes = new Uint8Array(await digest(`id:${token}`));
44
+ return Array.from(bytes.slice(0, 8), (byte) => byte.toString(16).padStart(2, "0")).join("");
45
+ }
46
+ async function keyFor(token) {
47
+ return crypto.subtle.importKey("raw", await digest(`key:${token}`), "AES-GCM", false, [
48
+ "encrypt",
49
+ "decrypt",
50
+ ]);
51
+ }
52
+ /** A fresh token for one reader's links: twelve characters, safe in a URL. */
53
+ export function mintToken() {
54
+ return toBase64Url(crypto.getRandomValues(new Uint8Array(9)));
55
+ }
56
+ /** Seal a reader's email under their token: the entry site.config.ts lists. */
57
+ export async function sealReader(email, token) {
58
+ const iv = crypto.getRandomValues(new Uint8Array(12));
59
+ const sealed = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv }, await keyFor(token), encoder.encode(email)));
60
+ const packed = new Uint8Array(iv.length + sealed.length);
61
+ packed.set(iv);
62
+ packed.set(sealed, iv.length);
63
+ return { id: await readerId(token), sealed: toBase64Url(packed) };
64
+ }
65
+ /** The email a link's token opens, or undefined when it opens nobody's. */
66
+ export async function openReader(readers, token) {
67
+ if (!token || !readers?.length)
68
+ return undefined;
69
+ const id = await readerId(token);
70
+ const reader = readers.find((entry) => entry.id === id);
71
+ if (!reader)
72
+ return undefined;
73
+ try {
74
+ const packed = fromBase64Url(reader.sealed);
75
+ const opened = await crypto.subtle.decrypt({ name: "AES-GCM", iv: packed.slice(0, 12) }, await keyFor(token), packed.slice(12));
76
+ return decoder.decode(opened);
77
+ }
78
+ catch {
79
+ return undefined;
80
+ }
81
+ }
82
+ function domainOf(email) {
83
+ return email.slice(email.lastIndexOf("@") + 1).toLowerCase();
84
+ }
85
+ /**
86
+ * The email this visit should be identified as, or undefined to leave it
87
+ * anonymous.
88
+ *
89
+ * On a private artifact the sign in decides, and a link's token never
90
+ * overrides it: the wall checked who this is, and a token only says who a
91
+ * link was sent to. So a signed in reader is identified by their own email,
92
+ * and a reader on the sender's domain is nobody's open even through someone
93
+ * else's link, since that is the practice reading its own document. A
94
+ * visit with no sign in (the wall not armed, as in local dev) falls back to
95
+ * the link.
96
+ *
97
+ * A public artifact has no wall, so a `gate_email` cookie there proves
98
+ * nothing and is never read: the link's token is the only identity.
99
+ */
100
+ export async function whoIsReading(visit) {
101
+ if (visit.access === "private") {
102
+ const signedIn = visit.gateEmail.trim();
103
+ if (signedIn.includes("@")) {
104
+ if (domainOf(signedIn) === domainOf(visit.senderEmail))
105
+ return undefined;
106
+ return signedIn;
107
+ }
108
+ }
109
+ const token = new URLSearchParams(visit.search).get("k");
110
+ return openReader(visit.readers, token);
111
+ }
112
+ /** The link to send a reader: the artifact's page with their token on it. */
113
+ export function readerLink(siteUrl, token, path = "/writeup") {
114
+ const url = new URL(path, siteUrl);
115
+ url.searchParams.set("k", token);
116
+ return url.toString();
117
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Keeps a document's content out of every file the login page can load.
3
+ *
4
+ * With the wall armed, /assets/ stays open because the login page needs its
5
+ * own hashed CSS and JS to render (needsSession in src/gate/wall.ts). A content module that
6
+ * lands in a chunk there would be readable by anyone who opens /login, which
7
+ * is exactly how the example writeup and report first shipped: their route
8
+ * heads imported the content, so all of it rode in the entry bundle. So the
9
+ * client build writes every chunk carrying a private content module into
10
+ * /assets/doc/, which the wall holds back, and fails outright when one turns
11
+ * up anywhere else.
12
+ *
13
+ * Public content is the short list that is safe to ship to every page: the
14
+ * 404's copy, the sealed proposal (ciphertext by design, GATE.md), and the
15
+ * heads in `*.meta.ts`, which carry a title and nothing a snapshot produced.
16
+ * Every other module under src/content/ is treated as private, so a new
17
+ * artifact starts behind the wall.
18
+ */
19
+ import type { Plugin } from "vite";
20
+ /** Where the client build writes the chunks the wall holds back. */
21
+ export declare const DOC_DIR = "assets/doc";
22
+ /** Whether a module id is content that must never ship to the login page. */
23
+ export declare function isPrivateContent(id: string): boolean;
24
+ export declare function privateChunks(): Plugin;
@@ -0,0 +1,42 @@
1
+ /** Where the client build writes the chunks the wall holds back. */
2
+ export const DOC_DIR = "assets/doc";
3
+ const PUBLIC_CONTENT = /\/src\/content\/(not-found\.tsx|proposal[^/]*|[^/]+\.meta\.ts)$/;
4
+ /** Whether a module id is content that must never ship to the login page. */
5
+ export function isPrivateContent(id) {
6
+ const path = id.split("?")[0].replaceAll("\\", "/");
7
+ return path.includes("/src/content/") && !PUBLIC_CONTENT.test(path);
8
+ }
9
+ export function privateChunks() {
10
+ return {
11
+ name: "atelic:private-chunks",
12
+ configEnvironment(name) {
13
+ if (name !== "client")
14
+ return;
15
+ return {
16
+ build: {
17
+ rollupOptions: {
18
+ output: {
19
+ chunkFileNames: (chunk) => chunk.moduleIds.some(isPrivateContent)
20
+ ? `${DOC_DIR}/[name]-[hash].js`
21
+ : "assets/[name]-[hash].js",
22
+ },
23
+ },
24
+ },
25
+ };
26
+ },
27
+ generateBundle(_options, bundle) {
28
+ if (this.environment?.name !== "client")
29
+ return;
30
+ for (const file of Object.values(bundle)) {
31
+ if (file.type !== "chunk" || file.fileName.startsWith(`${DOC_DIR}/`))
32
+ continue;
33
+ const leaked = Object.entries(file.modules)
34
+ .filter(([id, module]) => module.renderedLength > 0 && isPrivateContent(id))
35
+ .map(([id]) => id.slice(id.indexOf("/src/") + 1));
36
+ if (leaked.length > 0) {
37
+ this.error(`${file.fileName} would ship ${leaked.join(", ")} outside ${DOC_DIR}/, where the login page can load it with no session. A route's head is the usual cause: give it a *.meta.ts module and keep the content to the component.`);
38
+ }
39
+ }
40
+ },
41
+ };
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atelic-action/ui",
3
- "version": "0.14.0",
3
+ "version": "0.15.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",
@@ -19,6 +19,11 @@
19
19
  "**/*.css"
20
20
  ],
21
21
  "exports": {
22
+ "./artifact": "./src/artifact/index.ts",
23
+ "./artifact/readers": {
24
+ "types": "./dist/artifact/readers.d.ts",
25
+ "default": "./dist/artifact/readers.js"
26
+ },
22
27
  "./chrome": "./src/chrome/index.ts",
23
28
  "./components": "./src/components/index.ts",
24
29
  "./email": "./src/email/index.ts",
@@ -27,6 +32,7 @@
27
32
  "./primitives": "./src/primitives/index.ts",
28
33
  "./routing": "./src/routing/index.ts",
29
34
  "./tokens": "./src/tokens/index.ts",
35
+ "./styles/artifact.css": "./src/styles/artifact.css",
30
36
  "./styles/base.css": "./src/styles/base.css",
31
37
  "./styles/chrome.css": "./src/styles/chrome.css",
32
38
  "./styles/components.css": "./src/styles/components.css",
@@ -49,11 +55,15 @@
49
55
  "types": "./dist/gate/verify.d.ts",
50
56
  "default": "./dist/gate/verify.js"
51
57
  },
58
+ "./vite": {
59
+ "types": "./dist/vite/private-chunks.d.ts",
60
+ "default": "./dist/vite/private-chunks.js"
61
+ },
52
62
  "./package.json": "./package.json"
53
63
  },
54
64
  "scripts": {
55
65
  "build": "rm -rf dist && tsc -p tsconfig.server.json",
56
- "build:smoke": "node --input-type=module -e \"for (const entry of ['', '/middleware', '/request-link', '/verify']) await import('@atelic-action/ui/gate' + entry)\"",
66
+ "build:smoke": "node --input-type=module -e \"for (const entry of ['gate', 'gate/middleware', 'gate/request-link', 'gate/verify', 'vite', 'artifact/readers']) await import('@atelic-action/ui/' + entry)\"",
57
67
  "prepack": "bun run build",
58
68
  "lint": "biome check .",
59
69
  "lint:fix": "biome check . --write",
@@ -68,7 +78,13 @@
68
78
  "peerDependencies": {
69
79
  "lucide-react": "^1.23.0",
70
80
  "react": "^19.0.0",
71
- "react-dom": "^19.0.0"
81
+ "react-dom": "^19.0.0",
82
+ "vite": "^7.0.0 || ^8.0.0"
83
+ },
84
+ "peerDependenciesMeta": {
85
+ "vite": {
86
+ "optional": true
87
+ }
72
88
  },
73
89
  "devDependencies": {
74
90
  "@biomejs/biome": "^2.5.13",
@@ -0,0 +1,36 @@
1
+ import { Component, type ReactNode } from "react";
2
+
3
+ import { ErrorFallback } from "./ErrorFallback";
4
+
5
+ interface AppErrorBoundaryState {
6
+ error: unknown;
7
+ hasError: boolean;
8
+ }
9
+
10
+ /**
11
+ * Plain React error boundary rendering the dependency-free ErrorFallback.
12
+ * Replaces Sentry.ErrorBoundary so @sentry/react stays out of the critical
13
+ * bundle: on PSI-class hardware the SDK's hydration cost ran ahead of first
14
+ * paint and Google clocked the page as seconds slower than it is. When a
15
+ * DSN is configured, Sentry's global handlers (attached by the lazy init in
16
+ * router.tsx) still capture errors, including ones caught here.
17
+ */
18
+ export class AppErrorBoundary extends Component<{ children: ReactNode }, AppErrorBoundaryState> {
19
+ state: AppErrorBoundaryState = { error: null, hasError: false };
20
+
21
+ static getDerivedStateFromError(error: unknown): AppErrorBoundaryState {
22
+ return { error, hasError: true };
23
+ }
24
+
25
+ render() {
26
+ if (this.state.hasError) {
27
+ return (
28
+ <ErrorFallback
29
+ error={this.state.error}
30
+ resetError={() => this.setState({ error: null, hasError: false })}
31
+ />
32
+ );
33
+ }
34
+ return this.props.children;
35
+ }
36
+ }
@@ -0,0 +1,50 @@
1
+ import type { ReactNode } from "react";
2
+ import { CreditBar } from "../chrome/CreditBar";
3
+ import { SiteHeader } from "../chrome/SiteHeader";
4
+ import { SkipLink } from "../chrome/SkipLink";
5
+ import { Watermark } from "../components/Watermark";
6
+ import type { PageStop } from "../hooks/useScrollSpy";
7
+ import type { ArtifactConfig } from "./artifactConfig";
8
+
9
+ export interface ArtifactShellProps {
10
+ site: ArtifactConfig;
11
+ /**
12
+ * The path of the page being read. The package imports no router, so the
13
+ * site reads it from its own and passes it down.
14
+ */
15
+ currentPath: string;
16
+ /** The page's own stops; the header renders them scroll spied. */
17
+ stops?: PageStop[];
18
+ children: ReactNode;
19
+ }
20
+
21
+ /**
22
+ * The wrapper every artifact page uses: the `.mkt` theming
23
+ * scope, the skip link, the header, the credit band, and the access gate's
24
+ * watermark, composed from the chrome. The header is always the dark
25
+ * variant with the sender's lockup and never a site nav or a CTA, because the
26
+ * document's own close is the ask. There is no footer and no sticky bar by
27
+ * design: a document ends on its own sign off, and the credit band is the
28
+ * one line allowed after it.
29
+ */
30
+ export function ArtifactShell({ site, currentPath, stops, children }: ArtifactShellProps) {
31
+ const { name, href, logo } = site.sender;
32
+ // The lockup names both parties of a sent document: the sender's wordmark
33
+ // and run, then the recipient's mark after a times sign (ui 0.6.0).
34
+ const recipient = site.recipient.logo;
35
+
36
+ return (
37
+ <div className="mkt">
38
+ <SkipLink />
39
+ <SiteHeader
40
+ brand={{ name, href, logo, recipient }}
41
+ variant="dark"
42
+ currentPath={currentPath}
43
+ stops={stops}
44
+ />
45
+ <main id="main">{children}</main>
46
+ <CreditBar />
47
+ <Watermark />
48
+ </div>
49
+ );
50
+ }