@atelic-action/ui 0.14.0 → 0.15.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 +42 -3
- package/dist/artifact/artifactConfig.d.ts +138 -0
- package/dist/artifact/artifactConfig.js +10 -0
- package/dist/artifact/readers.d.ts +33 -0
- package/dist/artifact/readers.js +117 -0
- package/dist/vite/private-chunks.d.ts +24 -0
- package/dist/vite/private-chunks.js +42 -0
- package/package.json +19 -3
- package/src/artifact/AppErrorBoundary.tsx +36 -0
- package/src/artifact/ArtifactShell.tsx +50 -0
- package/src/artifact/ErrorFallback.tsx +70 -0
- package/src/artifact/Gate.tsx +328 -0
- package/src/artifact/GradeChip.tsx +29 -0
- package/src/artifact/Report.tsx +131 -0
- package/src/artifact/SignOff.tsx +37 -0
- package/src/artifact/Writeup.tsx +791 -0
- package/src/artifact/artifactConfig.ts +134 -0
- package/src/artifact/index.ts +25 -0
- package/src/artifact/pageHead.ts +58 -0
- package/src/artifact/readers.ts +146 -0
- package/src/styles/artifact.css +1862 -0
- package/src/vite/private-chunks.ts +65 -0
|
@@ -0,0 +1,134 @@
|
|
|
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
|
+
/**
|
|
12
|
+
* Copy for the access-gate login screen. Present in the config so the login
|
|
13
|
+
* page is themed and worded per artifact with no code edits. The gate itself
|
|
14
|
+
* is turned on by setting GATE_SESSION_SECRET on the Vercel project, not by
|
|
15
|
+
* this block; `access` below says whether it must be. See ACCESS-GATE.md.
|
|
16
|
+
*/
|
|
17
|
+
export interface GateConfig {
|
|
18
|
+
/** Mono eyebrow above the headline, e.g. "Private preview". */
|
|
19
|
+
eyebrow?: string;
|
|
20
|
+
/** Login headline. */
|
|
21
|
+
headline: string;
|
|
22
|
+
/** Sub-lead under the headline. */
|
|
23
|
+
lead: string;
|
|
24
|
+
/** Fine-print attribution, e.g. "Built by Jane Maker". Defaults to the sender's name. */
|
|
25
|
+
builtBy?: string;
|
|
26
|
+
/** Contact email shown in the fine print. Defaults to the sender's email. */
|
|
27
|
+
contactEmail?: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A named reader, sealed. This config ships in the script every page loads,
|
|
32
|
+
* so it lists neither the reader's email nor their token: `id` is a digest
|
|
33
|
+
* of the token, and `sealed` is the email encrypted under a key derived from
|
|
34
|
+
* it. Only the reader's own link opens it (readers.ts).
|
|
35
|
+
*/
|
|
36
|
+
export interface ArtifactReader {
|
|
37
|
+
id: string;
|
|
38
|
+
sealed: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Who the document is from: the practice, on every artifact. The lockup in
|
|
43
|
+
* the header renders from `logo` exactly as the marketing chrome's does, so
|
|
44
|
+
* a client branded artifact host keeps the sender's chrome over the client's
|
|
45
|
+
* palette (GATE.md, Branding an Artifact Host).
|
|
46
|
+
*/
|
|
47
|
+
export interface ArtifactSender {
|
|
48
|
+
/** The sender's name, worn in the header and the gate's fine print. */
|
|
49
|
+
name: string;
|
|
50
|
+
/**
|
|
51
|
+
* The chrome identity. `src` is the mark image; omit it for a pure text
|
|
52
|
+
* wordmark. `tile` renders a CSS glyph tile instead of an image (a dark
|
|
53
|
+
* rounded square bearing the given character in the brand's own webfont,
|
|
54
|
+
* with the accent bar beneath, since an SVG-as-img mark cannot load
|
|
55
|
+
* webfonts). `wordmark` overrides the rendered text when it should differ
|
|
56
|
+
* from `name`; `run` appends the trailing fade bar after it.
|
|
57
|
+
*/
|
|
58
|
+
logo: { src?: string; alt: string; tile?: string; wordmark?: string; run?: boolean };
|
|
59
|
+
/** Where the header lockup points; the sender's own site. */
|
|
60
|
+
href?: string;
|
|
61
|
+
/** Reply address, shown in the access gate's fine print. */
|
|
62
|
+
email: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Who the document was prepared for; the business's name suffixes every title. */
|
|
66
|
+
export interface ArtifactRecipient {
|
|
67
|
+
name: string;
|
|
68
|
+
/**
|
|
69
|
+
* The recipient's mark for the header lockup ("atelic × <mark>"), cut for
|
|
70
|
+
* the dark bar (a light mark on transparent). Unset, the header wears the
|
|
71
|
+
* sender's lockup alone. ArtifactShell passes it to the ui header as
|
|
72
|
+
* `brand.recipient` (@atelic-action/ui 0.6.0 and later).
|
|
73
|
+
*
|
|
74
|
+
* A logo is a brand mark, not a private signal, so the file lives under
|
|
75
|
+
* public/brand/, which the access wall leaves open: the router's pending
|
|
76
|
+
* page carries the header, so every page preloads this mark, the login
|
|
77
|
+
* page included, and one under public/images/ would be held there. The
|
|
78
|
+
* writeup's printed copy is white paper, so the lockup it prints in place
|
|
79
|
+
* of the header takes a mark cut for a light ground (`recipient.logoSrc`
|
|
80
|
+
* in the writeup's content).
|
|
81
|
+
*/
|
|
82
|
+
logo?: { src: string; alt: string };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface ArtifactConfig {
|
|
86
|
+
/** Canonical origin of the hosted twin, no trailing slash, e.g. "https://og.atelic.me". */
|
|
87
|
+
url: string;
|
|
88
|
+
/**
|
|
89
|
+
* Who may read it, which decides where it may be hosted (the practice's
|
|
90
|
+
* FOOTPRINT.md, Privacy). "public" when everything on it was read from
|
|
91
|
+
* public signals, a prospect's writeup; "private" when anything came from
|
|
92
|
+
* the owner's own consoles, which is every customer's writeup and every
|
|
93
|
+
* monthly page. A private artifact deploys only behind the armed wall:
|
|
94
|
+
* the build fails on Vercel without the wall's variables
|
|
95
|
+
* (scripts/check-access.ts), and scripts/check-wall.ts proves the live
|
|
96
|
+
* host answers with the wall before any link goes out (ACCESS-GATE.md).
|
|
97
|
+
*/
|
|
98
|
+
access: "public" | "private";
|
|
99
|
+
sender: ArtifactSender;
|
|
100
|
+
recipient: ArtifactRecipient;
|
|
101
|
+
/** Social card image; a site-relative path resolves against `url`. */
|
|
102
|
+
ogImage?: string;
|
|
103
|
+
/**
|
|
104
|
+
* Robots line for every page. Artifacts default to "noindex, nofollow"
|
|
105
|
+
* and there is no reason to raise it: a document handed to one business
|
|
106
|
+
* is never search inventory.
|
|
107
|
+
*/
|
|
108
|
+
robots?: string;
|
|
109
|
+
/**
|
|
110
|
+
* Analytics wiring. When `hubspotPortalId` is set, the HubSpot tracking
|
|
111
|
+
* code loads and records the open; because an artifact lives on its own
|
|
112
|
+
* host, every visit is attributable to that engagement. `hubspotRegion`
|
|
113
|
+
* picks the loader host and defaults to "na1"; na2 portals load from
|
|
114
|
+
* js-na2.hs-scripts.com.
|
|
115
|
+
*
|
|
116
|
+
* `identify` says who each link identifies, and every cut says it: a
|
|
117
|
+
* reader for every named person a link goes to, or an empty list. When a
|
|
118
|
+
* visit arrives with `?k=<token>` (the token on that person's link), the
|
|
119
|
+
* visitor is identified to HubSpot by their email, so the open lands on
|
|
120
|
+
* their contact timeline. Visits without a token stay anonymous. Mint a
|
|
121
|
+
* reader with `bun run reader <email>`.
|
|
122
|
+
*
|
|
123
|
+
* A private artifact needs nobody listed: its readers sign in at the
|
|
124
|
+
* wall and are identified by the email they signed in with. DEPLOY.md,
|
|
125
|
+
* Who Opened It, is the procedure.
|
|
126
|
+
*/
|
|
127
|
+
analytics?: {
|
|
128
|
+
hubspotPortalId: string;
|
|
129
|
+
hubspotRegion?: "na1" | "na2" | "eu1";
|
|
130
|
+
identify: ArtifactReader[];
|
|
131
|
+
};
|
|
132
|
+
/** Copy for the access-gate login screen (see GateConfig). */
|
|
133
|
+
gate?: GateConfig;
|
|
134
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
export { newTabProps } from "../lib/newTabProps";
|
|
2
|
+
export { AppErrorBoundary } from "./AppErrorBoundary";
|
|
3
|
+
export { ArtifactShell, type ArtifactShellProps } from "./ArtifactShell";
|
|
4
|
+
export type {
|
|
5
|
+
ArtifactConfig,
|
|
6
|
+
ArtifactReader,
|
|
7
|
+
ArtifactRecipient,
|
|
8
|
+
ArtifactSender,
|
|
9
|
+
GateConfig,
|
|
10
|
+
} from "./artifactConfig";
|
|
11
|
+
export { ErrorFallback } from "./ErrorFallback";
|
|
12
|
+
export { Gate, type GatePayload, type GatePerson, type GateProps, normalizeEmail } from "./Gate";
|
|
13
|
+
export { GradeChip, gradeLabel } from "./GradeChip";
|
|
14
|
+
export { buildPageHead, canonicalUrl, type PageMeta } from "./pageHead";
|
|
15
|
+
export * from "./Report";
|
|
16
|
+
export {
|
|
17
|
+
mintToken,
|
|
18
|
+
openReader,
|
|
19
|
+
readerId,
|
|
20
|
+
readerLink,
|
|
21
|
+
sealReader,
|
|
22
|
+
whoIsReading,
|
|
23
|
+
} from "./readers";
|
|
24
|
+
export { SignOff } from "./SignOff";
|
|
25
|
+
export * from "./Writeup";
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import type { ArtifactConfig } from "./artifactConfig";
|
|
2
|
+
|
|
3
|
+
export interface PageMeta {
|
|
4
|
+
/**
|
|
5
|
+
* The page topic, e.g. "Monthly Report". The recipient's name is
|
|
6
|
+
* appended automatically so every page reads "Topic | Business". Keep
|
|
7
|
+
* topics short enough that the full title stays under about 60 characters.
|
|
8
|
+
*/
|
|
9
|
+
title: string;
|
|
10
|
+
/** Escape hatch: use title exactly as given, no recipient suffix. */
|
|
11
|
+
titleAbsolute?: boolean;
|
|
12
|
+
description: string;
|
|
13
|
+
/** Path from the host root, e.g. "/report". */
|
|
14
|
+
path: string;
|
|
15
|
+
ogType?: string;
|
|
16
|
+
ogImage?: string;
|
|
17
|
+
/**
|
|
18
|
+
* Per-page robots override. Beats the artifact wide value, which is
|
|
19
|
+
* already noindex; here so a page can never be opened up by accident.
|
|
20
|
+
*/
|
|
21
|
+
robots?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Canonical URL for a path under the artifact's origin. */
|
|
25
|
+
export function canonicalUrl(site: ArtifactConfig, path: string): string {
|
|
26
|
+
return path === "/" ? `${site.url}/` : `${site.url}${path}`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Builds the head() meta and link entries for an artifact page: title,
|
|
31
|
+
* description, robots, canonical, Open Graph, and Twitter card. There is no
|
|
32
|
+
* structured data anywhere in this template on purpose. Structured data is
|
|
33
|
+
* how a page asks to be understood by a search engine, and a document sent
|
|
34
|
+
* to one business is asking nothing of the kind.
|
|
35
|
+
*/
|
|
36
|
+
export function buildPageHead(site: ArtifactConfig, page: PageMeta) {
|
|
37
|
+
const canonical = canonicalUrl(site, page.path);
|
|
38
|
+
// og:image must be a full URL per the OG spec; site-relative paths
|
|
39
|
+
// (self-hosted images under public/) resolve against the host origin.
|
|
40
|
+
const ogImageRaw = page.ogImage ?? site.ogImage;
|
|
41
|
+
const ogImage = ogImageRaw?.startsWith("/") ? `${site.url}${ogImageRaw}` : ogImageRaw;
|
|
42
|
+
const title = page.titleAbsolute ? page.title : `${page.title} | ${site.recipient.name}`;
|
|
43
|
+
return {
|
|
44
|
+
meta: [
|
|
45
|
+
{ title },
|
|
46
|
+
{ name: "description", content: page.description },
|
|
47
|
+
{ name: "robots", content: page.robots ?? site.robots ?? "noindex, nofollow" },
|
|
48
|
+
{ property: "og:type", content: page.ogType ?? "website" },
|
|
49
|
+
{ property: "og:site_name", content: site.recipient.name },
|
|
50
|
+
{ property: "og:title", content: page.title },
|
|
51
|
+
{ property: "og:description", content: page.description },
|
|
52
|
+
{ property: "og:url", content: canonical },
|
|
53
|
+
...(ogImage ? [{ property: "og:image", content: ogImage }] : []),
|
|
54
|
+
{ name: "twitter:card", content: "summary_large_image" },
|
|
55
|
+
],
|
|
56
|
+
links: [{ rel: "canonical", href: canonical }],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import type { ArtifactReader } from "./artifactConfig.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Who opened it. An artifact reports every open to the practice's HubSpot
|
|
5
|
+
* portal, and an open lands on a person's contact record only when the visit
|
|
6
|
+
* says who they are. A visit says so one of two ways:
|
|
7
|
+
*
|
|
8
|
+
* - **Their link.** Every link sent to a named person carries
|
|
9
|
+
* `?k=<their token>`, and site.config.ts lists that reader.
|
|
10
|
+
* - **The wall.** A reader who signed in on a private host is known by the
|
|
11
|
+
* email they signed in with, read from the `gate_email` cookie.
|
|
12
|
+
*
|
|
13
|
+
* site.config.ts ships in the script every page loads, so a reader is listed
|
|
14
|
+
* sealed, never in the clear: `id` is a digest of the token, and `sealed` is
|
|
15
|
+
* the email encrypted under a key derived from the token. Only the link can
|
|
16
|
+
* open it, and the link is in one person's inbox. Reading the page's source
|
|
17
|
+
* gives nobody's address.
|
|
18
|
+
*
|
|
19
|
+
* Nothing here touches the page or the network, so it tests without a
|
|
20
|
+
* browser. The root route does the one impure thing, the push to HubSpot.
|
|
21
|
+
*
|
|
22
|
+
* This file is also built to dist/ and exported on its own as
|
|
23
|
+
* `@atelic-action/ui/artifact/readers`, because a site's browser tests and
|
|
24
|
+
* its reader script import it under Node, which runs no TypeScript inside
|
|
25
|
+
* node_modules. So it keeps the built corner's rules: no JSX, and `.js` on
|
|
26
|
+
* its relative imports.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const encoder = new TextEncoder();
|
|
30
|
+
const decoder = new TextDecoder();
|
|
31
|
+
|
|
32
|
+
function toBase64Url(bytes: Uint8Array): string {
|
|
33
|
+
return btoa(String.fromCharCode(...bytes))
|
|
34
|
+
.replaceAll("+", "-")
|
|
35
|
+
.replaceAll("/", "_")
|
|
36
|
+
.replaceAll("=", "");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fromBase64Url(text: string): Uint8Array<ArrayBuffer> {
|
|
40
|
+
const padded = text.replaceAll("-", "+").replaceAll("_", "/");
|
|
41
|
+
return Uint8Array.from(atob(padded), (char) => char.charCodeAt(0));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function digest(text: string): Promise<ArrayBuffer> {
|
|
45
|
+
return crypto.subtle.digest("SHA-256", encoder.encode(text));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** What a config lists a token under: a digest, so the token itself never ships. */
|
|
49
|
+
export async function readerId(token: string): Promise<string> {
|
|
50
|
+
const bytes = new Uint8Array(await digest(`id:${token}`));
|
|
51
|
+
return Array.from(bytes.slice(0, 8), (byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function keyFor(token: string): Promise<CryptoKey> {
|
|
55
|
+
return crypto.subtle.importKey("raw", await digest(`key:${token}`), "AES-GCM", false, [
|
|
56
|
+
"encrypt",
|
|
57
|
+
"decrypt",
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** A fresh token for one reader's links: twelve characters, safe in a URL. */
|
|
62
|
+
export function mintToken(): string {
|
|
63
|
+
return toBase64Url(crypto.getRandomValues(new Uint8Array(9)));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Seal a reader's email under their token: the entry site.config.ts lists. */
|
|
67
|
+
export async function sealReader(email: string, token: string): Promise<ArtifactReader> {
|
|
68
|
+
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
69
|
+
const sealed = new Uint8Array(
|
|
70
|
+
await crypto.subtle.encrypt(
|
|
71
|
+
{ name: "AES-GCM", iv },
|
|
72
|
+
await keyFor(token),
|
|
73
|
+
encoder.encode(email),
|
|
74
|
+
),
|
|
75
|
+
);
|
|
76
|
+
const packed = new Uint8Array(iv.length + sealed.length);
|
|
77
|
+
packed.set(iv);
|
|
78
|
+
packed.set(sealed, iv.length);
|
|
79
|
+
return { id: await readerId(token), sealed: toBase64Url(packed) };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The email a link's token opens, or undefined when it opens nobody's. */
|
|
83
|
+
export async function openReader(
|
|
84
|
+
readers: ArtifactReader[] | undefined,
|
|
85
|
+
token: string | null | undefined,
|
|
86
|
+
): Promise<string | undefined> {
|
|
87
|
+
if (!token || !readers?.length) return undefined;
|
|
88
|
+
const id = await readerId(token);
|
|
89
|
+
const reader = readers.find((entry) => entry.id === id);
|
|
90
|
+
if (!reader) return undefined;
|
|
91
|
+
try {
|
|
92
|
+
const packed = fromBase64Url(reader.sealed);
|
|
93
|
+
const opened = await crypto.subtle.decrypt(
|
|
94
|
+
{ name: "AES-GCM", iv: packed.slice(0, 12) },
|
|
95
|
+
await keyFor(token),
|
|
96
|
+
packed.slice(12),
|
|
97
|
+
);
|
|
98
|
+
return decoder.decode(opened);
|
|
99
|
+
} catch {
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function domainOf(email: string): string {
|
|
105
|
+
return email.slice(email.lastIndexOf("@") + 1).toLowerCase();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The email this visit should be identified as, or undefined to leave it
|
|
110
|
+
* anonymous.
|
|
111
|
+
*
|
|
112
|
+
* On a private artifact the sign in decides, and a link's token never
|
|
113
|
+
* overrides it: the wall checked who this is, and a token only says who a
|
|
114
|
+
* link was sent to. So a signed in reader is identified by their own email,
|
|
115
|
+
* and a reader on the sender's domain is nobody's open even through someone
|
|
116
|
+
* else's link, since that is the practice reading its own document. A
|
|
117
|
+
* visit with no sign in (the wall not armed, as in local dev) falls back to
|
|
118
|
+
* the link.
|
|
119
|
+
*
|
|
120
|
+
* A public artifact has no wall, so a `gate_email` cookie there proves
|
|
121
|
+
* nothing and is never read: the link's token is the only identity.
|
|
122
|
+
*/
|
|
123
|
+
export async function whoIsReading(visit: {
|
|
124
|
+
readers: ArtifactReader[] | undefined;
|
|
125
|
+
search: string;
|
|
126
|
+
access: "public" | "private";
|
|
127
|
+
gateEmail: string;
|
|
128
|
+
senderEmail: string;
|
|
129
|
+
}): Promise<string | undefined> {
|
|
130
|
+
if (visit.access === "private") {
|
|
131
|
+
const signedIn = visit.gateEmail.trim();
|
|
132
|
+
if (signedIn.includes("@")) {
|
|
133
|
+
if (domainOf(signedIn) === domainOf(visit.senderEmail)) return undefined;
|
|
134
|
+
return signedIn;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const token = new URLSearchParams(visit.search).get("k");
|
|
138
|
+
return openReader(visit.readers, token);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** The link to send a reader: the artifact's page with their token on it. */
|
|
142
|
+
export function readerLink(siteUrl: string, token: string, path = "/writeup"): string {
|
|
143
|
+
const url = new URL(path, siteUrl);
|
|
144
|
+
url.searchParams.set("k", token);
|
|
145
|
+
return url.toString();
|
|
146
|
+
}
|