@supersuit/artifacts 0.49.0 → 0.50.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,31 @@
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.50.0 (2026-10-19)
7
+
8
+ **Product analytics, with video of each visit.** A host that passes `analytics: { posthogKey,
9
+ posthogHost?, tenant? }` to `createArtifactRoutes` gets PostHog on its pages: session replay (text
10
+ fields masked, PostHog's default), heatmaps, autocapture, and `$pageleave` with scroll depth, plus
11
+ two kinds of reading PostHog cannot see on its own: `section_viewed` (the heading's id and text and
12
+ the visible seconds spent in it, timed by the contents rail's own rule, now `currentSection` in
13
+ `toc-spy.tsx`) and `narration_play` / `narration_pause` / `narration_complete` (the narration
14
+ `<audio>` now carries `data-artifact-narration`). Every event carries `artifact_id`, `page_kind`
15
+ and `tenant`. The rules live in one pure function, `analyticsPlan` (`reader/analytics-plan.ts`):
16
+ no key, no script at all (posthog-js is imported only by `PageAnalytics`, after mount); never the
17
+ owner or an admin, signed in, previewing `?as=reader`, or on the owner's own per-reader link; never
18
+ a link-preview fetcher; never a past version. A reader is identified only where the page already
19
+ knows them: a gated page by the signed-in email, a per-reader link as `recipient:<rid>` with its
20
+ label. Public and password pages stay anonymous, even for a signed-in reader. A gated page also
21
+ masks all text in its replays, because its body is confidential and a replay is a copy of it.
22
+
23
+ - DETECTOR: a host that wants analytics has none; with no `analytics` config nothing changes and
24
+ no analytics code is downloaded.
25
+ - REMEDY: pass `analytics` per tenant (a public `phc_` project key). Turn on session replay and
26
+ heatmaps in the PostHog project's settings, which is where PostHog switches them on.
27
+ - Tests: analytics-plan.test.ts (no key, owner, bot, anonymous kinds, gated and per-reader
28
+ identity), page-analytics-mount.test.tsx (where it mounts on real routes, and as whom),
29
+ page-analytics.test.ts (section timing, narration events).
30
+
6
31
  ## 0.49.0 (2026-10-16)
7
32
 
8
33
  **The default look is Supersuit's, the Maison.** The product the default pack is named for became
package/README.md CHANGED
@@ -1174,6 +1174,22 @@ export const config = { matcher: ['/:id([a-z0-9]{8})'] }
1174
1174
 
1175
1175
  Any per-reader page served to a known unfurl fetcher (or a request with no user agent) carries its text only: no media, no signed URL, no reader's note, no cookie. `readHeader` on `createArtifactRoutes` reads the agent (default through `next/headers`). `DELETE /api/artifacts/<id>` removes the page's files from both buckets (`deletePage`), then its readers, their notes and views and every comment, before the record, and keeps the record (502) if the files could not go, so it can be run again.
1176
1176
 
1177
+ ## Product analytics (`analytics`)
1178
+
1179
+ Pass `analytics: { posthogKey: 'phc_...', posthogHost?: 'https://us.i.posthog.com', tenant?: 'name' }`
1180
+ to `createArtifactRoutes` and the host's pages record visits in PostHog: session replay, heatmaps,
1181
+ autocapture, scroll depth on leave, `section_viewed` (heading id, text, seconds) and
1182
+ `narration_play` / `narration_pause` / `narration_complete`. Without it no page loads any analytics
1183
+ code. Switch session replay and heatmaps on in the PostHog project as well.
1184
+
1185
+ Who is recorded, and as whom (`analyticsPlan`, `src/reader/analytics-plan.ts`):
1186
+
1187
+ - Never the owner or an admin (signed in, previewing `?as=reader`, or on the owner's own per-reader
1188
+ link), never a link-preview fetcher, never a past version.
1189
+ - A gated page identifies the signed-in reader by email and masks all text in its replays.
1190
+ - A per-reader link identifies as `recipient:<rid>` with the label the publisher gave it.
1191
+ - A public or password page stays anonymous.
1192
+
1177
1193
  ## Brand packs
1178
1194
 
1179
1195
  A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
@@ -1,3 +1,8 @@
1
+ /** How far below the top of the screen a heading may sit and still be the section being read. */
2
+ export declare const READING_LINE = 140;
3
+ /** The section being read: the last heading at or above the reading line, else the first. The
4
+ * page analytics times sections by the same rule, so a section's dwell matches the rail. */
5
+ export declare function currentSection(ids: string[], doc?: Document): string | undefined;
1
6
  export declare function TocSpy({ slugs }: {
2
7
  slugs: string[];
3
8
  }): null;
@@ -2,6 +2,19 @@
2
2
  // Marks the contents entry for the section being read. Renders nothing: it only sets
3
3
  // aria-current on the links the server drew, which is also what styles them.
4
4
  import { useEffect } from 'react';
5
+ /** How far below the top of the screen a heading may sit and still be the section being read. */
6
+ export const READING_LINE = 140;
7
+ /** The section being read: the last heading at or above the reading line, else the first. The
8
+ * page analytics times sections by the same rule, so a section's dwell matches the rail. */
9
+ export function currentSection(ids, doc = document) {
10
+ let current = ids[0];
11
+ for (const id of ids) {
12
+ const el = doc.getElementById(id);
13
+ if (el && el.getBoundingClientRect().top <= READING_LINE)
14
+ current = id;
15
+ }
16
+ return current;
17
+ }
5
18
  export function TocSpy({ slugs }) {
6
19
  const key = slugs.join('|');
7
20
  useEffect(() => {
@@ -9,12 +22,7 @@ export function TocSpy({ slugs }) {
9
22
  let frame = 0;
10
23
  const mark = () => {
11
24
  frame = 0;
12
- let current = ids[0];
13
- for (const id of ids) {
14
- const el = document.getElementById(id);
15
- if (el && el.getBoundingClientRect().top <= 140)
16
- current = id;
17
- }
25
+ const current = currentSection(ids);
18
26
  document.querySelectorAll('a[data-toc-slug]').forEach((a) => {
19
27
  if (a.dataset.tocSlug === current)
20
28
  a.setAttribute('aria-current', 'location');
@@ -0,0 +1,41 @@
1
+ export type AnalyticsConfig = {
2
+ /** A PostHog project API key (the public `phc_...` one, safe in a browser). */
3
+ posthogKey: string;
4
+ /** The ingestion host. Default PostHog's US cloud. */
5
+ posthogHost?: string;
6
+ /** Sent with every event as `tenant`, so one project can hold several hosts' pages. */
7
+ tenant?: string;
8
+ };
9
+ export type PageKind = 'public' | 'password' | 'gated' | 'per-reader';
10
+ export type AnalyticsPlan = {
11
+ key: string;
12
+ host: string;
13
+ pageId: string;
14
+ kind: PageKind;
15
+ tenant: string | null;
16
+ /** Who to identify the reader as, or null to keep them anonymous. */
17
+ identity: {
18
+ distinctId: string;
19
+ email?: string;
20
+ name?: string;
21
+ } | null;
22
+ };
23
+ export declare const DEFAULT_POSTHOG_HOST = "https://us.i.posthog.com";
24
+ export declare function analyticsPlan(input: {
25
+ config: AnalyticsConfig | null | undefined;
26
+ pageId: string;
27
+ kind: PageKind;
28
+ /** The owner or an admin is looking (signed in, previewing as a reader, or on the owner's own link). */
29
+ owner: boolean;
30
+ bot: boolean;
31
+ /** A gated page's signed-in reader. */
32
+ reader?: {
33
+ email: string;
34
+ name?: string | null;
35
+ } | null;
36
+ /** A per-reader link's recipient. */
37
+ recipient?: {
38
+ rid: string;
39
+ label: string;
40
+ } | null;
41
+ }): AnalyticsPlan | null;
@@ -0,0 +1,33 @@
1
+ // Whether a page view is recorded in the host's product analytics, and as whom. Pure, so the route
2
+ // decides on the server and the browser is handed either nothing or exactly what to send.
3
+ //
4
+ // THE RULES (0.50.0):
5
+ // - A host with no analytics key records nothing, and its pages load no analytics script at all.
6
+ // - The owner and the host's admins are never recorded, signed in or on their own per-reader link.
7
+ // - A link-preview fetcher is never handed the script.
8
+ // - A reader is identified only where the page already knows who they are: a gated page by the
9
+ // signed-in email, a per-reader link by its opaque recipient id and the label the publisher gave
10
+ // it (never a phone or an email, which the recipients store does not hold). A public or password
11
+ // page stays anonymous, even when its reader happens to be signed in.
12
+ export const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com';
13
+ export function analyticsPlan(input) {
14
+ const key = input.config?.posthogKey?.trim();
15
+ if (!key || input.owner || input.bot)
16
+ return null;
17
+ let identity = null;
18
+ if (input.kind === 'gated' && input.reader?.email) {
19
+ const email = input.reader.email.trim().toLowerCase();
20
+ identity = { distinctId: email, email, ...(input.reader.name ? { name: input.reader.name } : {}) };
21
+ }
22
+ else if (input.kind === 'per-reader' && input.recipient?.rid) {
23
+ identity = { distinctId: `recipient:${input.recipient.rid}`, name: input.recipient.label };
24
+ }
25
+ return {
26
+ key,
27
+ host: input.config.posthogHost?.trim() || DEFAULT_POSTHOG_HOST,
28
+ pageId: input.pageId,
29
+ kind: input.kind,
30
+ tenant: input.config.tenant ?? null,
31
+ identity,
32
+ };
33
+ }
@@ -275,7 +275,7 @@ export function ArtifactReader({ src, words, rootId, accent, ground, }) {
275
275
  else
276
276
  a.pause();
277
277
  };
278
- return (_jsxs(_Fragment, { children: [_jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => {
278
+ return (_jsxs(_Fragment, { children: [_jsx("audio", { ref: audioRef, "data-artifact-narration": true, src: src, preload: "metadata", onPlay: () => {
279
279
  startedRef.current = true;
280
280
  playingRef.current = true;
281
281
  setPlaying(true);
@@ -13,3 +13,5 @@ export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom
13
13
  export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE, type VoiceHost, type VoiceMemo, type VoiceScope } from './voice-recorder.js';
14
14
  export { deviceAudio, isDeviceAudio, type DeviceAudio } from './device-audio.js';
15
15
  export { type SavedMemo } from './comment-card.js';
16
+ export { PageAnalytics, trackSections, narrationEvent, MIN_SECTION_SECONDS } from './page-analytics.js';
17
+ export { analyticsPlan, DEFAULT_POSTHOG_HOST, type AnalyticsConfig, type AnalyticsPlan, type PageKind } from './analytics-plan.js';
@@ -14,3 +14,5 @@ export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDe
14
14
  export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom } from './comment-place.js';
15
15
  export { VoiceRecorder, AudioPlay, hostVoice, pickRecordingType, newMemoId, recognitionCtor, LIVE_LINE } from './voice-recorder.js';
16
16
  export { deviceAudio, isDeviceAudio } from './device-audio.js';
17
+ export { PageAnalytics, trackSections, narrationEvent, MIN_SECTION_SECONDS } from './page-analytics.js';
18
+ export { analyticsPlan, DEFAULT_POSTHOG_HOST } from './analytics-plan.js';
@@ -0,0 +1,21 @@
1
+ import type { AnalyticsPlan } from './analytics-plan.js';
2
+ /** Sections read for less than this are passed over, not read. */
3
+ export declare const MIN_SECTION_SECONDS = 2;
4
+ type Capture = (event: string, props?: Record<string, unknown>) => void;
5
+ /** Times the section being read, by the contents rail's own rule, and reports each one as the
6
+ * reader leaves it. Only visible time counts. Returns a stop function that flushes the last one. */
7
+ export declare function trackSections(ids: string[], capture: Capture, doc?: Document, now?: () => number): {
8
+ check: () => void;
9
+ stop: () => void;
10
+ };
11
+ /** The narration's events: play, pause with where it stopped, complete. */
12
+ export declare function narrationEvent(type: string, audio: {
13
+ currentTime: number;
14
+ duration: number;
15
+ ended?: boolean;
16
+ }): [string, Record<string, unknown>] | null;
17
+ export declare function PageAnalytics({ plan, rootId }: {
18
+ plan: AnalyticsPlan;
19
+ rootId?: string;
20
+ }): null;
21
+ export {};
@@ -0,0 +1,119 @@
1
+ 'use client';
2
+ // The host's product analytics on a page: PostHog, with session replay, heatmaps, autocapture and
3
+ // scroll depth on leave, plus the reading this package knows about and PostHog cannot: which
4
+ // section was read and for how long, and the narration played, paused and finished.
5
+ //
6
+ // The server decides whether this mounts at all and as whom (analytics-plan.ts). posthog-js is
7
+ // imported only here, after mount, so a host with no key never downloads it.
8
+ //
9
+ // Text fields are masked in replays (PostHog's default). A gated page masks ALL text too, because
10
+ // its body is confidential and a replay is a copy of it held by a third party: the replay still
11
+ // shows where the reader went, how far and how fast, without the words.
12
+ import { useEffect } from 'react';
13
+ import { currentSection } from '../artifacts/toc-spy.js';
14
+ /** Sections read for less than this are passed over, not read. */
15
+ export const MIN_SECTION_SECONDS = 2;
16
+ /** Times the section being read, by the contents rail's own rule, and reports each one as the
17
+ * reader leaves it. Only visible time counts. Returns a stop function that flushes the last one. */
18
+ export function trackSections(ids, capture, doc = document, now = Date.now) {
19
+ let current;
20
+ let since = null;
21
+ const visible = () => doc.visibilityState === 'visible';
22
+ const flush = () => {
23
+ if (!current || since === null)
24
+ return;
25
+ const seconds = Math.round((now() - since) / 1000);
26
+ if (seconds >= MIN_SECTION_SECONDS) {
27
+ const heading = doc.getElementById(current)?.textContent?.trim() ?? '';
28
+ capture('section_viewed', { section_id: current, heading, seconds });
29
+ }
30
+ since = null;
31
+ };
32
+ const check = () => {
33
+ if (!visible()) {
34
+ flush();
35
+ return;
36
+ }
37
+ const next = currentSection(ids, doc);
38
+ if (next !== current) {
39
+ flush();
40
+ current = next;
41
+ }
42
+ if (since === null)
43
+ since = now();
44
+ };
45
+ return { check, stop: flush };
46
+ }
47
+ /** The narration's events: play, pause with where it stopped, complete. */
48
+ export function narrationEvent(type, audio) {
49
+ const at = { position_seconds: Math.round(audio.currentTime), duration_seconds: Number.isFinite(audio.duration) ? Math.round(audio.duration) : null };
50
+ if (type === 'play')
51
+ return ['narration_play', at];
52
+ if (type === 'pause' && !audio.ended)
53
+ return ['narration_pause', at];
54
+ if (type === 'ended')
55
+ return ['narration_complete', at];
56
+ return null;
57
+ }
58
+ export function PageAnalytics({ plan, rootId = 'artifact-narration-root' }) {
59
+ const { key, host, pageId, kind, tenant } = plan;
60
+ const identity = plan.identity ? JSON.stringify(plan.identity) : '';
61
+ useEffect(() => {
62
+ let cancelled = false;
63
+ let cleanup = () => { };
64
+ void import('posthog-js').then(({ default: posthog }) => {
65
+ if (cancelled)
66
+ return;
67
+ posthog.init(key, {
68
+ api_host: host,
69
+ person_profiles: 'identified_only',
70
+ capture_pageview: true,
71
+ capture_pageleave: true,
72
+ autocapture: true,
73
+ enable_heatmaps: true,
74
+ disable_session_recording: false,
75
+ session_recording: { maskAllInputs: true, ...(kind === 'gated' ? { maskTextSelector: '*' } : {}) },
76
+ });
77
+ posthog.register({ artifact_id: pageId, page_kind: kind, ...(tenant ? { tenant } : {}) });
78
+ if (identity) {
79
+ const who = JSON.parse(identity);
80
+ posthog.identify(who.distinctId, { ...(who.email ? { email: who.email } : {}), ...(who.name ? { name: who.name } : {}) });
81
+ }
82
+ const capture = (e, p) => posthog.capture(e, p);
83
+ const root = document.getElementById(rootId);
84
+ const ids = root ? Array.from(root.querySelectorAll('h2[id], h3[id]'), (h) => h.id) : [];
85
+ const sections = ids.length ? trackSections(ids, capture) : null;
86
+ let frame = 0;
87
+ const onScroll = () => { if (sections && !frame)
88
+ frame = requestAnimationFrame(() => { frame = 0; sections.check(); }); };
89
+ const onVisibility = () => sections?.check();
90
+ const onHide = () => sections?.stop();
91
+ const onMedia = (e) => {
92
+ const el = e.target;
93
+ if (!el?.hasAttribute?.('data-artifact-narration'))
94
+ return;
95
+ const ev = narrationEvent(e.type, el);
96
+ if (ev)
97
+ capture(...ev);
98
+ };
99
+ sections?.check();
100
+ window.addEventListener('scroll', onScroll, { passive: true });
101
+ document.addEventListener('visibilitychange', onVisibility);
102
+ window.addEventListener('pagehide', onHide);
103
+ for (const t of ['play', 'pause', 'ended'])
104
+ document.addEventListener(t, onMedia, true);
105
+ cleanup = () => {
106
+ sections?.stop();
107
+ if (frame)
108
+ cancelAnimationFrame(frame);
109
+ window.removeEventListener('scroll', onScroll);
110
+ document.removeEventListener('visibilitychange', onVisibility);
111
+ window.removeEventListener('pagehide', onHide);
112
+ for (const t of ['play', 'pause', 'ended'])
113
+ document.removeEventListener(t, onMedia, true);
114
+ };
115
+ }).catch(() => { });
116
+ return () => { cancelled = true; cleanup(); };
117
+ }, [key, host, pageId, kind, tenant, identity, rootId]);
118
+ return null;
119
+ }
@@ -10,6 +10,7 @@ import { type Access, type AllowEntry } from '../artifacts/reader.js';
10
10
  import { type ReadersStore } from '../artifacts/readers-store.js';
11
11
  import { type ReaderEvent } from '../artifacts/read-alerts.js';
12
12
  import type { ResolveEvent } from '../artifacts/resolve-digest.js';
13
+ import { type AnalyticsConfig } from '../reader/analytics-plan.js';
13
14
  import type { PersonalStore } from '../artifacts/personal-store.js';
14
15
  import type { Transcriber } from '../artifacts/transcribe.js';
15
16
  export type ArtifactRoutesConfig = {
@@ -121,6 +122,12 @@ export type ArtifactRoutesConfig = {
121
122
  recipients?: RecipientsStore;
122
123
  /** Signs per-reader links and render tokens. At least 32 characters. Read at request time. */
123
124
  recipientSecret?: () => string | undefined;
125
+ /** Product analytics for this host's pages (0.50.0): PostHog with session replay, heatmaps,
126
+ * autocapture, scroll depth on leave, and section_viewed / narration_* events. Without a key no
127
+ * page loads any analytics script. Never records the owner or an admin, never a link-preview
128
+ * fetcher, never a past version; identifies a reader only on a gated page (their email) or a
129
+ * per-reader link (its recipient id and label). See reader/analytics-plan.ts. */
130
+ analytics?: AnalyticsConfig | null;
124
131
  };
125
132
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
126
133
  export declare const ARTIFACT_ID: RegExp;
@@ -36,6 +36,8 @@ import { ARTIFACT_ID_RE } from './ids.js';
36
36
  import { createStateRoutes } from './state-routes.js';
37
37
  import { createRecipientsRoutes } from './recipients-routes.js';
38
38
  import { QuietView } from '../reader/quiet-view.js';
39
+ import { PageAnalytics } from '../reader/page-analytics.js';
40
+ import { analyticsPlan } from '../reader/analytics-plan.js';
39
41
  import { createPersonalRoutes } from './personal-routes.js';
40
42
  import { createVoiceRoutes } from './voice-routes.js';
41
43
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
@@ -317,7 +319,7 @@ export function createArtifactRoutes(config) {
317
319
  if (!a)
318
320
  notFound();
319
321
  const { pack, brand } = lookOf(a);
320
- const render = (top, reader) => (n === null ? Body(a, { top, reader }) : VersionBody(a, n, { top, reader }));
322
+ const render = (top, reader, untracked = false) => (n === null ? Body(a, { top, reader, untracked }) : VersionBody(a, n, { top, reader }));
321
323
  if (a.access)
322
324
  return GatedPage(a, id, render, n);
323
325
  const broadcast = kindOf(a) === 'broadcast';
@@ -338,7 +340,7 @@ export function createArtifactRoutes(config) {
338
340
  if (isAdmin(me)) {
339
341
  const markdown = await media(a.markdown);
340
342
  const viewer = wantsReaderView(sp) ? null : me;
341
- const page = broadcast ? await BroadcastCard({ ...a, markdown }, { reader: viewer, bot }) : await Body({ ...a, markdown }, { top: true, reader: viewer });
343
+ const page = broadcast ? await BroadcastCard({ ...a, markdown }, { reader: viewer, bot, untracked: true }) : await Body({ ...a, markdown }, { top: true, reader: viewer, untracked: true });
342
344
  return _jsxs(BrandGround, { pack: pack, mode: a.theme, children: [viewer ? null : _jsx(ViewingAsReader, { back: pagePath(id) }), page] });
343
345
  }
344
346
  const secret = usableSecret(config.recipientSecret?.());
@@ -389,7 +391,7 @@ export function createArtifactRoutes(config) {
389
391
  // rendered for no one signed in, with a bar back to their own view. Anyone else asking gets the
390
392
  // page they already get.
391
393
  const asReader = isAdmin(reader) && wantsReaderView(sp);
392
- const page = await render(true, asReader ? null : reader);
394
+ const page = await render(true, asReader ? null : reader, asReader);
393
395
  if (n === null && !asReader)
394
396
  void store.bumpViews(id);
395
397
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, asReader ? _jsx(ViewingAsReader, { back: n === null ? pagePath(id) : `${pagePath(id)}/v/${n}` }) : null, page] }));
@@ -481,7 +483,7 @@ export function createArtifactRoutes(config) {
481
483
  * them, then this reader's own note. None of the document's furniture: no table of contents, no
482
484
  * version line, no read-along (a voice note is its own audio), and no page menu for a reader.
483
485
  * Comments only when the file turned them on; the quiet view beacon always, for a reader. */
484
- async function BroadcastCard(a, { reader, perReader, bot }) {
486
+ async function BroadcastCard(a, { reader, perReader, bot, untracked = false }) {
485
487
  const { brand } = lookOf(a);
486
488
  const mode = !bot && config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
487
489
  const admin = isAdmin(reader);
@@ -489,10 +491,10 @@ export function createArtifactRoutes(config) {
489
491
  const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
490
492
  return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
491
493
  // eslint-disable-next-line @next/next/no-img-element
492
- _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
494
+ _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked, bot })] }));
493
495
  }
494
496
  /** The page itself, shared by open and gated pages. */
495
- async function Body(a, { top, reader, perReader }) {
497
+ async function Body(a, { top, reader, perReader, untracked = false }) {
496
498
  const { pack, brand } = lookOf(a);
497
499
  let words = [];
498
500
  if (a.narration && a.timings) {
@@ -517,10 +519,27 @@ export function createArtifactRoutes(config) {
517
519
  return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.closesAt && !perReader.owner ? (_jsx(FeedbackWindow, { owner: owner, label: perReader.label, note: perReader.note, group: perReader.group, closesAt: perReader.closesAt })) : perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, header, 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] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
518
520
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
519
521
  checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, embeds: embeds }) }));
520
- })() }), _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: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
522
+ })() }), _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: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, await Analytics(a, { reader, perReader, untracked }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
521
523
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
522
524
  recorded at publish, or there is none. */] }));
523
525
  }
526
+ /** The page's analytics, or nothing (analytics-plan.ts has the rules). `untracked` is the owner or
527
+ * an admin previewing as a reader, who arrives here with no reader at all. */
528
+ async function Analytics(a, { reader, perReader, untracked, bot }) {
529
+ if (!config.analytics?.posthogKey)
530
+ return null;
531
+ const ua = bot === undefined ? await (config.readHeader ?? defaultReadHeader)('user-agent').catch(() => undefined) : undefined;
532
+ const plan = analyticsPlan({
533
+ config: config.analytics,
534
+ pageId: a.id,
535
+ kind: perReader ? 'per-reader' : a.access ? 'gated' : a.password ? 'password' : 'public',
536
+ owner: untracked || isAdmin(reader) || !!perReader?.owner,
537
+ bot: bot ?? (ua !== undefined && isUnfurlAgent(ua)),
538
+ reader: a.access ? reader : null,
539
+ recipient: perReader ? { rid: perReader.rid, label: perReader.label } : null,
540
+ });
541
+ return plan ? _jsx(PageAnalytics, { plan: plan }) : null;
542
+ }
524
543
  const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
525
544
  /** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
526
545
  * reader. Swaps the five-minute pass for this host's grant and lands them on the page, with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.49.0",
3
+ "version": "0.50.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",
@@ -80,6 +80,7 @@
80
80
  },
81
81
  "dependencies": {
82
82
  "gray-matter": "^4.0.3",
83
+ "posthog-js": "1.438.6",
83
84
  "react-markdown": "^9.0.3",
84
85
  "remark-gfm": "^4.0.1",
85
86
  "remark-parse": "^11.0.0",