@supersuit/artifacts 0.13.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/CHANGELOG.md CHANGED
@@ -3,6 +3,42 @@
3
3
  `@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
4
4
  host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
5
5
 
6
+ ## 0.15.0 (2026-09-29)
7
+
8
+ **A reader signed in with the wrong Google account can change it.** Before, "Sign out" only
9
+ cleared this host's grant; the sign-in authority still held the Google session and vouched for
10
+ the same account again, so the reader looped back as the wrong person. Now:
11
+
12
+ - `GET /api/reader/leave?to=/<id>&switch=1` clears the grant and redirects to
13
+ `<signInOrigin>/artifact/sign-in?to=<page>&switch=1`. Without `switch` it still lands on the page.
14
+ - `signInUrl(origin, page, { switchAccount: true })` builds that URL.
15
+ - The refusal door, the agreement and the confidential banner say "Use a different account" and
16
+ link to the switch.
17
+ - An open page now shows a signed-in reader "Signed in as <email> · Use a different account ·
18
+ Sign out" under the version line (not spoken). Before, it showed nothing at all.
19
+
20
+ - DETECTOR: a reader on a page of yours reports they cannot switch Google accounts.
21
+ - REMEDY: bump, AND make the host's `/artifact/sign-in` honor `switch=1` by signing its Firebase
22
+ user out before vouching (and ask Google with `prompt: 'select_account'`). A host whose sign-in
23
+ ignores `switch` still loops.
24
+ - Tests: the switch URL, LEAVE with and without switch (grant cleared, redirect, off-host `to`
25
+ refused), the three gated surfaces linking the switch, the open-page reader line present when
26
+ signed in and absent when not.
27
+
28
+ ## 0.14.0 (2026-09-28)
29
+
30
+ **A page can say who it is for.** `to: Isaiah` in front matter reads "For Isaiah" in three
31
+ places: after the kicker on the page (not spoken), in the link preview's title ("For Isaiah:
32
+ <title>", og:title, twitter:title and <title>), and as a line above the title on the share card.
33
+ 1 to 60 characters, one line; a republish without it clears it. Past versions keep the name they
34
+ were published with. `audience:` is unchanged: stored, never shown.
35
+
36
+ - DETECTOR: a page with `to:` whose preview does not name the reader.
37
+ - REMEDY: bump; publishers must forward `to`.
38
+ - Tests: parse and refusals, store round trip and clear-on-omit, the kicker on open, password,
39
+ gated and version pages, the three metadata titles, the share card markup and PNG, narration
40
+ leaving the name out. Metadata title, share card prop and clear-on-omit mutation-checked.
41
+
6
42
  ## 0.13.0 (2026-09-28)
7
43
 
8
44
  **A publisher can hear about new comments, and every comment has a link to itself.**
package/README.md CHANGED
@@ -149,9 +149,10 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
149
149
  | `title` (required) | The large line, and the unfurl title |
150
150
  | `summary` (required) | The italic teaser under it, and the unfurl description |
151
151
  | `subtitle` | A line between the two. A republish without it removes it |
152
+ | `to` | Who the page is for, e.g. `to: Marisol`. Shows as "For Marisol" after the kicker, leads the unfurl title ("For Marisol: <title>") and gets its own line on the share card. Never read aloud, and not an access control. One line, 1 to 60 characters; a republish without it removes it |
153
+ | `audience` | Free text stored with the page, never shown |
152
154
  | `id` | Republish in place instead of minting a new page |
153
155
  | `cover` | An image URL (absolute or site-relative) used as the unfurl image instead of the share card |
154
- | `audience` | Free text stored with the page |
155
156
  | `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent (or the timings will not load), the page is read by the browser's own voice instead, lighting the same words; a browser with no `speechSynthesis` shows no player |
156
157
  | `password` | Shuts the body behind a door (below) |
157
158
  | `access` | `freedom`, `invite` or `public`: a confidential page (below) |
@@ -9,7 +9,7 @@ export function bannerLines({ name, email, reason, owner }) {
9
9
  }
10
10
  export function ConfidentialBanner(props) {
11
11
  const l = bannerLines(props);
12
- return (_jsxs("aside", { "data-nospeak": true, className: "mx-auto max-w-2xl rounded-xl border px-5 py-4 text-sm leading-relaxed", style: { borderColor: props.brand.accent, color: props.brand.ink }, children: [_jsx("p", { className: "font-semibold", style: { color: props.brand.accent }, children: l.lead }), _jsxs("p", { className: "mt-1", children: [l.why, " ", l.rule] }), _jsxs("p", { className: "mt-2 text-xs opacity-60", children: [l.record, " Not you? ", _jsx("a", { href: props.signOutUrl, className: "underline", children: "Sign out" }), "."] })] }));
12
+ return (_jsxs("aside", { "data-nospeak": true, className: "mx-auto max-w-2xl rounded-xl border px-5 py-4 text-sm leading-relaxed", style: { borderColor: props.brand.accent, color: props.brand.ink }, children: [_jsx("p", { className: "font-semibold", style: { color: props.brand.accent }, children: l.lead }), _jsxs("p", { className: "mt-1", children: [l.why, " ", l.rule] }), _jsxs("p", { className: "mt-2 text-xs opacity-60", children: [l.record, " Not you? ", _jsx("a", { href: props.signOutUrl, className: "underline", children: "Use a different account" }), "."] })] }));
13
13
  }
14
14
  /** A tiled, rotated, near-invisible line of the reader's address over the whole page. */
15
15
  export function Watermark({ email }) {
@@ -30,7 +30,7 @@ export function SignInDoor({ brand, href, note = DEFAULT_SIGN_IN_NOTE }) {
30
30
  return (_jsxs("div", { "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center", children: [_jsx("p", { className: "text-sm opacity-70", children: note }), _jsx("a", { href: href, className: "mt-5 inline-block rounded-lg px-5 py-2 text-sm font-medium", style: { background: brand.accent, color: brand.ground }, children: "Sign in to read" })] }));
31
31
  }
32
32
  export function NotAllowedDoor({ brand, reader, signOutUrl }) {
33
- return (_jsxs("div", { "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center text-sm", children: [_jsxs("p", { className: "opacity-80", children: ["You are signed in as ", reader.email, ", and this page has not been shared with that address. If it was sent to a different address of yours, sign out and use that one."] }), _jsx("p", { className: "mt-3 opacity-60", children: "This attempt was recorded." }), _jsx("a", { href: signOutUrl, className: "mt-5 inline-block underline", style: { color: brand.accent }, children: "Sign out" })] }));
33
+ return (_jsxs("div", { "data-nospeak": true, className: "mx-auto max-w-sm px-6 pb-24 text-center text-sm", children: [_jsxs("p", { className: "opacity-80", children: ["You are signed in as ", reader.email, ", and this page has not been shared with that address. If it was sent to a different address of yours, switch to that account."] }), _jsx("p", { className: "mt-3 opacity-60", children: "This attempt was recorded." }), _jsx("a", { href: signOutUrl, className: "mt-5 inline-block underline", style: { color: brand.accent }, children: "Use a different account" })] }));
34
34
  }
35
35
  /** The agreement a reader makes before a confidential page opens. The exact string is stored
36
36
  * with their acknowledgement, so the record says what they agreed to, not what it says today. */
@@ -38,5 +38,5 @@ export function ackText(owner) {
38
38
  return `I understand this document is confidential. I will not share, forward, copy, screenshot, print, or download it, or discuss its contents with anyone ${owner} has not given access. I understand that my reading is recorded.`;
39
39
  }
40
40
  export function AckDoor({ brand, name, email, owner, pageId, signOutUrl }) {
41
- return (_jsxs("form", { method: "post", action: "/api/reader/ack", "data-nospeak": true, className: "mx-auto max-w-md px-6 pb-24 text-left text-sm", children: [_jsx("input", { type: "hidden", name: "id", value: pageId }), _jsxs("div", { className: "rounded-xl border px-5 py-5", style: { borderColor: brand.accent }, children: [_jsx("p", { className: "font-semibold", style: { color: brand.accent }, children: name ? `${name}, before you read` : 'Before you read' }), _jsx("p", { className: "mt-3 leading-relaxed", children: ackText(owner) }), _jsxs("label", { className: "mt-4 flex items-start gap-3", children: [_jsx("input", { type: "checkbox", name: "agree", value: "yes", required: true, className: "mt-1" }), _jsx("span", { children: "I agree to keep it confidential." })] }), _jsx("button", { type: "submit", className: "mt-5 w-full rounded-lg px-5 py-2 text-sm font-medium", style: { background: brand.accent, color: brand.ground }, children: "Agree and open" }), _jsxs("p", { className: "mt-3 text-xs opacity-60", children: ["Signed in as ", email, ". Your agreement is recorded with the time. Not you? ", _jsx("a", { href: signOutUrl, className: "underline", children: "Sign out" }), "."] })] })] }));
41
+ return (_jsxs("form", { method: "post", action: "/api/reader/ack", "data-nospeak": true, className: "mx-auto max-w-md px-6 pb-24 text-left text-sm", children: [_jsx("input", { type: "hidden", name: "id", value: pageId }), _jsxs("div", { className: "rounded-xl border px-5 py-5", style: { borderColor: brand.accent }, children: [_jsx("p", { className: "font-semibold", style: { color: brand.accent }, children: name ? `${name}, before you read` : 'Before you read' }), _jsx("p", { className: "mt-3 leading-relaxed", children: ackText(owner) }), _jsxs("label", { className: "mt-4 flex items-start gap-3", children: [_jsx("input", { type: "checkbox", name: "agree", value: "yes", required: true, className: "mt-1" }), _jsx("span", { children: "I agree to keep it confidential." })] }), _jsx("button", { type: "submit", className: "mt-5 w-full rounded-lg px-5 py-2 text-sm font-medium", style: { background: brand.accent, color: brand.ground }, children: "Agree and open" }), _jsxs("p", { className: "mt-3 text-xs opacity-60", children: ["Signed in as ", email, ". Your agreement is recorded with the time. Not you? ", _jsx("a", { href: signOutUrl, className: "underline", children: "Use a different account" }), "."] })] })] }));
42
42
  }
@@ -10,6 +10,10 @@ export type ArtifactMeta = {
10
10
  /** A line under the title. Title, subtitle and summary are three separate fields; the summary
11
11
  * is the teaser, and the unfurl carries the title and the summary. */
12
12
  subtitle?: string;
13
+ /** Who the page is for ("For <name>" in the kicker, the unfurl title and the share card). A
14
+ * label, not an access control: a republish without it removes it. Never spoken. Distinct from
15
+ * `audience`, which is stored and never shown. */
16
+ to?: string;
13
17
  audience?: string;
14
18
  cover?: string;
15
19
  id?: string;
@@ -47,6 +51,8 @@ export type ArtifactMeta = {
47
51
  /** Who sees shared comments: `owner` (the default) or every reader (`readers`). */
48
52
  commentsVisible?: CommentsVisible;
49
53
  };
54
+ /** The longest `to:` a page may carry: a name or two, never a sentence. */
55
+ export declare const MAX_TO_CHARS = 60;
50
56
  export declare function parseArtifactSource(text: string): {
51
57
  ok: true;
52
58
  meta: ArtifactMeta;
@@ -7,7 +7,9 @@ import { mergeWidgetState, scanWidgets } from './widgets.js';
7
7
  import { COMMENTS_MODES, mergeCommentsState } from './comments.js';
8
8
  import { THEME_MODES } from '../brand/theme.js';
9
9
  import { parseDefinitions } from './definitions.js';
10
- const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc', 'definitions', 'change', 'comments', 'comments_visible']);
10
+ /** The longest `to:` a page may carry: a name or two, never a sentence. */
11
+ export const MAX_TO_CHARS = 60;
12
+ const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc', 'definitions', 'change', 'comments', 'comments_visible']);
11
13
  export function parseArtifactSource(text) {
12
14
  const { data, content } = matter(text);
13
15
  const d = data;
@@ -33,6 +35,12 @@ export function parseArtifactSource(text) {
33
35
  if (typeof v === 'string' && v)
34
36
  meta[k] = v;
35
37
  }
38
+ if (d.to !== undefined && d.to !== null) {
39
+ const to = typeof d.to === 'string' ? d.to.trim() : '';
40
+ if (!to || to.length > MAX_TO_CHARS || /[\r\n]/.test(to))
41
+ return { ok: false, error: `to must be one line naming who the page is for, 1 to ${MAX_TO_CHARS} characters` };
42
+ meta.to = to;
43
+ }
36
44
  if (d.change !== undefined && d.change !== null) {
37
45
  if (typeof d.change !== 'string')
38
46
  return { ok: false, error: 'change must be one line of text' };
@@ -47,8 +47,12 @@ export declare const LISTED_REASON = "you were given access to it by name";
47
47
  export declare function decide(access: Access, reader: Reader | null, allow: AllowEntry[]): Decision;
48
48
  /** The name the banner addresses: the allowlist's name, then the account's, first word only. */
49
49
  export declare function firstName(reader: Reader, allow: AllowEntry[]): string | null;
50
- /** Where a reader goes to sign in, carrying the page they asked for. */
51
- export declare function signInUrl(signInOrigin: string, pageUrl: string): string;
50
+ /** Where a reader goes to sign in, carrying the page they asked for. `switchAccount` tells the
51
+ * authority to forget the Google account it already holds and ask which one, because otherwise
52
+ * it vouches for the same account again and the reader can never change it. */
53
+ export declare function signInUrl(signInOrigin: string, pageUrl: string, opts?: {
54
+ switchAccount?: boolean;
55
+ }): string;
52
56
  /** A path on this host that `enter` may send a reader to after it sets the grant: one page id,
53
57
  * nothing else, so the redirect cannot be pointed off the host. */
54
58
  export declare function safeReturnPath(to: string | null | undefined, prefix?: string): string | null;
@@ -95,9 +95,11 @@ export function firstName(reader, allow) {
95
95
  const full = listed?.name?.trim() || reader.name;
96
96
  return full ? full.split(/\s+/)[0] : null;
97
97
  }
98
- /** Where a reader goes to sign in, carrying the page they asked for. */
99
- export function signInUrl(signInOrigin, pageUrl) {
100
- return `${signInOrigin}/artifact/sign-in?to=${encodeURIComponent(pageUrl)}`;
98
+ /** Where a reader goes to sign in, carrying the page they asked for. `switchAccount` tells the
99
+ * authority to forget the Google account it already holds and ask which one, because otherwise
100
+ * it vouches for the same account again and the reader can never change it. */
101
+ export function signInUrl(signInOrigin, pageUrl, opts = {}) {
102
+ return `${signInOrigin}/artifact/sign-in?to=${encodeURIComponent(pageUrl)}${opts.switchAccount ? '&switch=1' : ''}`;
101
103
  }
102
104
  /** A path on this host that `enter` may send a reader to after it sets the grant: one page id,
103
105
  * nothing else, so the redirect cannot be pointed off the host. */
@@ -11,6 +11,8 @@ export type ArtifactRecord = {
11
11
  summary: string;
12
12
  template: 'document';
13
13
  subtitle?: string;
14
+ /** Who the page is for; see ArtifactMeta.to. */
15
+ to?: string;
14
16
  audience?: string;
15
17
  cover?: string;
16
18
  voice?: string;
@@ -54,6 +56,7 @@ export type VersionRecord = VersionEntry & {
54
56
  markdown: string;
55
57
  title?: string;
56
58
  subtitle?: string;
59
+ to?: string;
57
60
  summary?: string;
58
61
  };
59
62
  /** A change note is one line: whitespace collapsed, capped, empty means none. */
@@ -76,7 +76,7 @@ async function versionOf(col, id, n) {
76
76
  const cur = currentOf(a);
77
77
  if (n === cur) {
78
78
  return { version: n, at: a.updatedAt, markdown: a.markdown, title: a.title, summary: a.summary, current: true,
79
- ...(a.subtitle ? { subtitle: a.subtitle } : {}), ...(a.note ? { note: a.note } : {}) };
79
+ ...(a.subtitle ? { subtitle: a.subtitle } : {}), ...(a.to ? { to: a.to } : {}), ...(a.note ? { note: a.note } : {}) };
80
80
  }
81
81
  if (n > cur)
82
82
  return null;
@@ -123,6 +123,7 @@ async function saveArtifact(col, input) {
123
123
  summary: input.meta.summary,
124
124
  template: input.meta.template,
125
125
  ...(input.meta.subtitle ? { subtitle: input.meta.subtitle } : {}),
126
+ ...(input.meta.to ? { to: input.meta.to } : {}),
126
127
  ...(input.meta.audience ? { audience: input.meta.audience } : {}),
127
128
  ...(input.meta.cover ? { cover: input.meta.cover } : {}),
128
129
  ...(input.meta.voice ? { voice: input.meta.voice } : {}),
@@ -161,7 +162,7 @@ async function saveArtifact(col, input) {
161
162
  await history.doc(vid(current)).set({
162
163
  version: current, markdown: existing.markdown, at: existing.updatedAt,
163
164
  title: existing.title, summary: existing.summary,
164
- ...(existing.subtitle ? { subtitle: existing.subtitle } : {}), ...(existing.note ? { note: existing.note } : {}),
165
+ ...(existing.subtitle ? { subtitle: existing.subtitle } : {}), ...(existing.to ? { to: existing.to } : {}), ...(existing.note ? { note: existing.note } : {}),
165
166
  });
166
167
  }
167
168
  const next = fresh ? current + 1 : current;
@@ -170,6 +171,8 @@ async function saveArtifact(col, input) {
170
171
  const password = input.meta.password ? {} : { password: FieldValue.delete() };
171
172
  // A subtitle the file no longer carries is gone from the page, like any other content.
172
173
  const subtitle = input.meta.subtitle ? {} : { subtitle: FieldValue.delete() };
174
+ // Who it is for is a label on the content, never an access control: absent clears it.
175
+ const to = input.meta.to ? {} : { to: FieldValue.delete() };
173
176
  // Access is the opposite of password on purpose: absent leaves it alone, and only an explicit
174
177
  // `access: public` opens the page. Reopening a confidential page must never be a side effect.
175
178
  const access = input.meta.access === 'public' ? { access: FieldValue.delete() } : {};
@@ -186,7 +189,7 @@ async function saveArtifact(col, input) {
186
189
  ...(input.meta.commentsVisible ? {} : { commentsVisible: FieldValue.delete() }),
187
190
  };
188
191
  await ref.update({
189
- ...fields, ...password, ...subtitle, ...access, ...state, ...look, markdown: input.markdown, version: next,
192
+ ...fields, ...password, ...subtitle, ...to, ...access, ...state, ...look, markdown: input.markdown, version: next,
190
193
  // `updatedAt` is when this version went live; finishing its own publish does not move it.
191
194
  ...(fresh || input.markdown !== existing.markdown ? { updatedAt: now } : {}),
192
195
  note: nextNote ?? FieldValue.delete(),
@@ -3,11 +3,14 @@ export declare const SHARE_W = 1200;
3
3
  export declare const SHARE_H = 630;
4
4
  /** Titles get smaller as they get longer, so three lines always fit above the mark. */
5
5
  export declare function titleSize(title: string): number;
6
- export declare function ShareCard({ pack, title, fontName, backdrop }: {
6
+ export declare function ShareCard({ pack, title, to, fontName, backdrop }: {
7
7
  pack: BrandPack;
8
8
  title: string;
9
+ to?: string;
9
10
  fontName: string;
10
11
  backdrop?: string;
11
12
  }): import("react").JSX.Element;
12
13
  /** A 1200x630 PNG for one page. Caller has already checked `pack.share` exists. */
13
- export declare function renderShareCard(pack: BrandPack, title: string): Promise<Response>;
14
+ export declare function renderShareCard(pack: BrandPack, title: string, opts?: {
15
+ to?: string;
16
+ }): Promise<Response>;
@@ -24,8 +24,9 @@ export function titleSize(title) {
24
24
  return 62;
25
25
  return 52;
26
26
  }
27
- export function ShareCard({ pack, title, fontName, backdrop }) {
27
+ export function ShareCard({ pack, title, to, fontName, backdrop }) {
28
28
  const size = titleSize(title);
29
+ const kicker = { display: 'flex', fontSize: 22, letterSpacing: 5, textTransform: 'uppercase', color: pack.accent, fontWeight: 500 };
29
30
  return (_jsxs("div", { style: {
30
31
  display: 'flex',
31
32
  flexDirection: 'column',
@@ -36,12 +37,12 @@ export function ShareCard({ pack, title, fontName, backdrop }) {
36
37
  ...(backdrop ? { backgroundImage: `url(${backdrop})`, backgroundSize: `${SHARE_W}px ${SHARE_H}px` } : {}),
37
38
  color: pack.ink,
38
39
  fontFamily: fontName,
39
- }, children: [_jsx("div", { style: { display: 'flex', fontSize: 22, letterSpacing: 5, textTransform: 'uppercase', color: pack.accent, fontWeight: 500 }, children: pack.kicker }), _jsx("div", { style: { display: 'flex', marginTop: 28, fontSize: size, lineHeight: 1.18, maxWidth: 980, lineClamp: 3 }, children: title })] }));
40
+ }, children: [_jsx("div", { style: kicker, children: pack.kicker }), to ? _jsx("div", { style: { ...kicker, marginTop: 12, lineHeight: 1.4 }, children: `For ${to}` }) : null, _jsx("div", { style: { display: 'flex', marginTop: 28, fontSize: size, lineHeight: 1.18, maxWidth: 980, lineClamp: 3 }, children: title })] }));
40
41
  }
41
42
  /** A 1200x630 PNG for one page. Caller has already checked `pack.share` exists. */
42
- export async function renderShareCard(pack, title) {
43
+ export async function renderShareCard(pack, title, opts = {}) {
43
44
  const { font, backdrop } = await assetsFor(pack);
44
- return new ImageResponse(_jsx(ShareCard, { pack: pack, title: title, fontName: font.name, backdrop: backdrop }), {
45
+ return new ImageResponse(_jsx(ShareCard, { pack: pack, title: title, to: opts.to, fontName: font.name, backdrop: backdrop }), {
45
46
  width: SHARE_W,
46
47
  height: SHARE_H,
47
48
  fonts: [{ name: font.name, data: font.data, weight: font.weight, style: 'normal' }],
@@ -53,6 +53,9 @@ export function createArtifactRoutes(config) {
53
53
  const ownerEmail = config.ownerEmail?.trim().toLowerCase();
54
54
  const readerSecret = () => config.readerSecret?.();
55
55
  const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
56
+ // Signing out alone cannot change the account: the authority still holds the Google session
57
+ // and vouches for it again on the next sign-in. This leaves AND sends them to choose one.
58
+ const switchUrl = (id) => (signInOrigin ? `${signOutUrl(id)}&switch=1` : signOutUrl(id));
56
59
  const shareCardUrl = (id, updatedAt) => `${pageUrl(id)}/share.png?v=${encodeURIComponent(updatedAt)}`;
57
60
  // Readers' recordings are played through short-lived signed URLs, issued only beside the comment
58
61
  // or note they belong to; a host whose assets cannot sign them shows no play control.
@@ -66,6 +69,11 @@ export function createArtifactRoutes(config) {
66
69
  store, state: config.state, personal: config.personal, readers: config.readers, assets: config.assets, transcribe: config.transcribe,
67
70
  readerSecret, pageUrl, signInOrigin, siteUrl, clientIp: config.clientIp,
68
71
  });
72
+ /** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
73
+ * whole paragraph is data-nospeak, and narrationText has no `to` to read. */
74
+ function Kicker({ to }) {
75
+ return (_jsxs("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: [brand.kicker, to ? _jsx("span", { "data-nospeak": true, "data-to": true, children: ` · For ${to}` }) : null] }));
76
+ }
69
77
  async function generateMetadata({ params }) {
70
78
  const { id } = await params;
71
79
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
@@ -73,13 +81,15 @@ export function createArtifactRoutes(config) {
73
81
  return { title: 'Not found', robots: { index: false, follow: false } };
74
82
  const url = pageUrl(a.id);
75
83
  const image = absolute(a.cover) ?? (brand.share ? shareCardUrl(a.id, a.updatedAt) : absolute(config.defaultShareImage));
84
+ // Who it is for leads the unfurl, so a thread shows it before the reader opens anything.
85
+ const title = a.to ? `For ${a.to}: ${a.title}` : a.title;
76
86
  return {
77
- title: a.title,
87
+ title,
78
88
  description: a.summary,
79
89
  alternates: { canonical: pagePath(a.id) },
80
90
  robots: { index: false, follow: false },
81
91
  openGraph: {
82
- title: a.title,
92
+ title,
83
93
  description: a.summary,
84
94
  url,
85
95
  siteName: brand.name,
@@ -88,7 +98,7 @@ export function createArtifactRoutes(config) {
88
98
  },
89
99
  twitter: {
90
100
  card: image ? 'summary_large_image' : 'summary',
91
- title: a.title,
101
+ title,
92
102
  description: a.summary,
93
103
  ...(image ? { images: [image] } : {}),
94
104
  },
@@ -145,7 +155,7 @@ export function createArtifactRoutes(config) {
145
155
  ];
146
156
  const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
147
157
  if (!open) {
148
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
158
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
149
159
  }
150
160
  // Who is reading an open page, when they happen to be signed in: their comments follow them.
151
161
  // Only a host that signs readers in can know one; the read never fails the page.
@@ -164,23 +174,23 @@ export function createArtifactRoutes(config) {
164
174
  const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
165
175
  const allow = config.readers && reader ? await config.readers.allowList(id) : [];
166
176
  const d = config.readers ? decide(a.access, reader, allow) : { open: false, why: 'signed-out' };
167
- const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }));
177
+ const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }));
168
178
  if (!d.open) {
169
179
  if (d.why === 'not-allowed' && config.readers)
170
180
  void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
171
181
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
172
- ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: signOutUrl(id) })
182
+ ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: switchUrl(id) })
173
183
  : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined, note: config.signInNote })] }));
174
184
  }
175
185
  const r = reader;
176
186
  // The agreement comes before the body, every reader, once per page. No agreement, no body.
177
187
  if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
178
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: signOutUrl(id) })] }));
188
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: switchUrl(id) })] }));
179
189
  }
180
190
  const page = await render(false, r);
181
191
  if (n === null)
182
192
  void store.bumpViews(id);
183
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: signOutUrl(id) }) }), page, _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
193
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: switchUrl(id) }) }), page, _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
184
194
  }
185
195
  /** Every version of a page, newest first, or null when the store keeps none. A failed read hides
186
196
  * the History control rather than failing the page. */
@@ -205,7 +215,7 @@ export function createArtifactRoutes(config) {
205
215
  const title = v.title ?? a.title;
206
216
  const summary = v.summary ?? a.summary;
207
217
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
208
- return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history })] }), _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions })] })] }));
218
+ return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history })] }), _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions })] })] }));
209
219
  }
210
220
  /** The page itself, shared by open and gated pages. */
211
221
  async function Body(a, { top, reader }) {
@@ -223,7 +233,7 @@ export function createArtifactRoutes(config) {
223
233
  const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
224
234
  // Shared comments need the page to take them AND a host that keeps answers; personal notes need neither.
225
235
  const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
226
- return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), _jsx(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) })] }), a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : (
236
+ return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), _jsx(VersionLine, { a: a, n: a.version ?? (a.versions?.length ?? 0) + 1, at: a.updatedAt, history: await historyOf(a.id) }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }), a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : (
227
237
  // No recorded narration, or its timings would not load: the browser reads the page.
228
238
  _jsx(BrowserReader, { rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground }))] }));
229
239
  }
@@ -243,10 +253,13 @@ export function createArtifactRoutes(config) {
243
253
  res.headers.append('set-cookie', cookieLine(mintGrant(secret, reader), GRANT_TTL_SECONDS));
244
254
  return res;
245
255
  }
246
- /** GET /api/reader/leave?to=/<id>: sign out of this host's gated pages. */
256
+ /** GET /api/reader/leave?to=/<id>[&switch=1]: sign out of this host's pages. With switch=1 it
257
+ * goes on to the authority's account chooser, which is the only way the account changes. */
247
258
  async function LEAVE(request) {
248
259
  const to = safeReturnPath(request.nextUrl.searchParams.get('to'), prefix) ?? '/';
249
- const res = NextResponse.redirect(`${siteUrl}${to}`, 303);
260
+ const switching = request.nextUrl.searchParams.get('switch') === '1' && !!signInOrigin && to !== '/';
261
+ const res = NextResponse.redirect(switching ? signInUrl(signInOrigin, `${siteUrl}${to}`, { switchAccount: true }) : `${siteUrl}${to}`, 303);
262
+ res.headers.set('cache-control', 'no-store');
250
263
  res.headers.append('set-cookie', cookieLine('', 0));
251
264
  return res;
252
265
  }
@@ -497,7 +510,7 @@ export function createArtifactRoutes(config) {
497
510
  const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
498
511
  if (!a)
499
512
  return new NextResponse(`no artifact with id ${id}`, { status: 404 });
500
- return renderShareCard(pack, a.title);
513
+ return renderShareCard(pack, a.title, { to: a.to });
501
514
  }
502
515
  /** GET|POST /api/artifacts/<id>/versions, publish key: the history, and a note written onto one
503
516
  * version after the fact. POST body: { version: number, note: string | null }. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
5
5
  "license": "MIT",
6
6
  "type": "module",