@supersuit/artifacts 0.48.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 +51 -0
- package/README.md +30 -4
- package/lib/artifacts/confidential.d.ts +3 -2
- package/lib/artifacts/confidential.js +3 -2
- package/lib/artifacts/reader.d.ts +1 -1
- package/lib/artifacts/reader.js +1 -1
- package/lib/artifacts/toc-spy.d.ts +5 -0
- package/lib/artifacts/toc-spy.js +14 -6
- package/lib/brand/pack.d.ts +16 -1
- package/lib/brand/pack.js +23 -8
- package/lib/brand/wrapper.d.ts +2 -1
- package/lib/brand/wrapper.js +3 -2
- package/lib/reader/analytics-plan.d.ts +41 -0
- package/lib/reader/analytics-plan.js +33 -0
- package/lib/reader/artifact-reader.js +1 -1
- package/lib/reader/index.d.ts +2 -0
- package/lib/reader/index.js +2 -0
- package/lib/reader/page-analytics.d.ts +21 -0
- package/lib/reader/page-analytics.js +119 -0
- package/lib/reader/settings-sheet.js +2 -2
- package/lib/routes/artifacts.d.ts +7 -0
- package/lib/routes/artifacts.js +26 -7
- package/package.json +2 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,57 @@
|
|
|
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
|
+
|
|
31
|
+
## 0.49.0 (2026-10-16)
|
|
32
|
+
|
|
33
|
+
**The default look is Supersuit's, the Maison.** The product the default pack is named for became
|
|
34
|
+
Supersuit on 2026-10-14, and its maker's house look, the Maison, became the brand on 2026-10-15.
|
|
35
|
+
`freedomDefault` (the export keeps its name, so no host breaks) now reads "A page from Supersuit"
|
|
36
|
+
and names Supersuit in metadata (`siteName`); paints night `#17171a` with mist `#d8d1c4` and bronze
|
|
37
|
+
`#a8834f` by default and travertine `#e8e0d2` with ink `#1c1a17` and bronze deepened with ink in
|
|
38
|
+
light mode, each text and accent pairing at 4.5:1 or better; and sets DM Serif Display over DM Sans
|
|
39
|
+
by name (Georgia and the system face if a host does not load them; README, "The default look's
|
|
40
|
+
faces"). The default mark (`BrandMark` with no `Mark`) is a short rule in the accent instead of a
|
|
41
|
+
dove: the dove stood for the agent, and no figure stands in for her until her look is designed.
|
|
42
|
+
What a reader reads moves with the name: the confidential door's default line ("the Google account
|
|
43
|
+
your Supersuit account uses"), the member reason ("you are a Supersuit user"), and the share
|
|
44
|
+
sheet's "Any Supersuit account" and "Supersuit accounts get in without being on the list." The
|
|
45
|
+
`freedom` access level keeps its identifier. A pack of a host's own is untouched, and the share
|
|
46
|
+
card keeps the bundled Newsreader so no tracing config changes.
|
|
47
|
+
|
|
48
|
+
- DETECTOR: a host whose operators have no pack of their own (`freedomDefault`, or a spread of it
|
|
49
|
+
with only the kicker changed) renders on the night ground with bronze, not the old near-black and
|
|
50
|
+
gold, and its kicker says Supersuit.
|
|
51
|
+
- REMEDY: nothing required. To draw the default look in its own faces, load DM Serif Display and
|
|
52
|
+
DM Sans in the host's root layout (README).
|
|
53
|
+
- Tests: pack.test.tsx (the default look: tokens only, 4.5:1 pairings, the house pair, no dove, no
|
|
54
|
+
Freedom), the default-pages golden regenerated on purpose (palette, faces and kicker only), and
|
|
55
|
+
the kicker, metadata and door expectations in artifacts, to, extension-points and gated.
|
|
56
|
+
|
|
6
57
|
## 0.48.0
|
|
7
58
|
|
|
8
59
|
**Unpublishing keeps the history.** `POST /api/artifacts?id=<id>&unpublish=1` takes a page off the
|
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ site shell behind that: the store, the route handlers, the read-along reader, br
|
|
|
5
5
|
password pages, confidential pages with a signed-in reader list, and the share card a link
|
|
6
6
|
unfurls as. You mount it in a Next.js App Router app with a handful of one-line route files.
|
|
7
7
|
|
|
8
|
-
It is the shell that serves every
|
|
8
|
+
It is the shell that serves every Supersuit operator's `artifacts.<theirname>` page, published
|
|
9
9
|
openly because none of it is secret and all of it is better with more eyes on it.
|
|
10
10
|
|
|
11
11
|
```bash
|
|
@@ -23,7 +23,7 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
|
|
|
23
23
|
| `createArtifactStore(db, path)` | Firestore store for pages and their version history, under any collection path |
|
|
24
24
|
| `createReadersStore(db, path)` | The record for confidential pages: allowlist, agreements, reading sessions, flags |
|
|
25
25
|
| `createArtifactAssets(bucket, prefix, { privateBucket? })` | Uploaded images for a page, in a Cloud Storage bucket; private media in a separate private bucket |
|
|
26
|
-
| `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
|
|
26
|
+
| `freedomDefault`, `BrandPack` | The default look (Supersuit's, the Maison) and the type any other look implements |
|
|
27
27
|
| `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
|
|
28
28
|
| `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
|
|
29
29
|
| `headingsOf`, `scanWidgets`, `placeNotes` | The notes widget's pure parts: heading slugs, fence validation, where each note shows |
|
|
@@ -164,7 +164,7 @@ anyone else. `PageMenu`, `roleLine` and `menuItems` are exported from `/reader`.
|
|
|
164
164
|
The owner and the hub's admins change a page's settings from the menu, with no republish. Each item
|
|
165
165
|
opens a sheet (from the bottom on a phone, under the button on a desk):
|
|
166
166
|
|
|
167
|
-
- **Share settings.** Who can open it: anyone with the link, any
|
|
167
|
+
- **Share settings.** Who can open it: anyone with the link, any Supersuit account (plus the list),
|
|
168
168
|
or only the list. The list itself: add or remove an email. On a page open to anyone, a password
|
|
169
169
|
on or off (on makes a 12-character key). The link to send, with a Copy button: on a password page
|
|
170
170
|
it carries the key. Under them, read-only lines: what a stranger with the bare URL sees and what
|
|
@@ -314,7 +314,7 @@ their change alone.
|
|
|
314
314
|
|
|
315
315
|
## A confidential page (`access:`)
|
|
316
316
|
|
|
317
|
-
`access: freedom` opens the page only to a signed-in reader with an active
|
|
317
|
+
`access: freedom` opens the page only to a signed-in reader with an active Supersuit account or
|
|
318
318
|
on the page's list; `access: invite` opens it only to the list. Everyone else gets the title,
|
|
319
319
|
the summary and a sign-in door; the body never reaches them.
|
|
320
320
|
|
|
@@ -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,
|
|
@@ -1181,6 +1197,16 @@ an optional full-page `Wrapper` and `Mark`, and an optional `share` block
|
|
|
1181
1197
|
(a font loader and a backdrop) that draws the unfurl card. `freedomDefault` is the default look.
|
|
1182
1198
|
Keep a pack carrying your own trademark in your own app, not in a pull request here.
|
|
1183
1199
|
|
|
1200
|
+
**The default look's faces.** Since 0.49.0 `freedomDefault` is Supersuit's own look, the Maison:
|
|
1201
|
+
night with mist type and bronze by default, travertine with ink behind the light toggle, every
|
|
1202
|
+
colour one of seven stone tokens or a mix of two, and the mark above the kicker a short rule in
|
|
1203
|
+
the accent (no dove). It sets DM Serif Display for headings and DM Sans for reading, by name. The
|
|
1204
|
+
package does not fetch them, so a host that wants them loads them itself, for example
|
|
1205
|
+
`import '@fontsource/dm-serif-display/400.css'` and `import '@fontsource/dm-sans/400.css'` (plus
|
|
1206
|
+
500 and 600) in its root layout, which serves the files from the host's own origin. A host that
|
|
1207
|
+
does not falls back to Georgia and the system face. The share card still sets its title in the
|
|
1208
|
+
bundled Newsreader, so no host's `outputFileTracingIncludes` has to change.
|
|
1209
|
+
|
|
1184
1210
|
**Light, dark or system.** A pack's `ground`, `ink` and `accent` are its dark palette; `light` is
|
|
1185
1211
|
the other one (absent, warm paper with the pack's accent), and `mode` is the one a page opens in
|
|
1186
1212
|
(`dark` by default; a page's `theme:` overrides it). Pages paint with CSS variables
|
|
@@ -25,9 +25,10 @@ export declare function Watermark({ email }: {
|
|
|
25
25
|
}): import("react").JSX.Element;
|
|
26
26
|
/** Printing shows this and nothing else, whichever way the print dialog was reached. */
|
|
27
27
|
export declare const NO_PRINT_CSS = "@media print { body * { visibility: hidden !important; } body::after { content: \"This page is confidential and cannot be printed.\"; visibility: visible; position: fixed; top: 40%; left: 0; right: 0; text-align: center; font: 16px sans-serif; color: #000; } }";
|
|
28
|
-
/** The door's default line, for a host whose readers sign in with a
|
|
28
|
+
/** The door's default line, for a host whose readers sign in with a Supersuit account (the product
|
|
29
|
+
* called Freedom until 2026-10-14; the access level keeps its `freedom` identifier). A host
|
|
29
30
|
* that signs readers in some other way passes `signInNote` to say so in its own words. */
|
|
30
|
-
export declare const DEFAULT_SIGN_IN_NOTE = "This page is confidential. Sign in with the Google account your
|
|
31
|
+
export declare const DEFAULT_SIGN_IN_NOTE = "This page is confidential. Sign in with the Google account your Supersuit account uses, or the address it was shared with.";
|
|
31
32
|
export declare function SignInDoor({ brand, href, note }: {
|
|
32
33
|
brand: BrandPack;
|
|
33
34
|
href?: string;
|
|
@@ -19,9 +19,10 @@ export function Watermark({ email }) {
|
|
|
19
19
|
}
|
|
20
20
|
/** Printing shows this and nothing else, whichever way the print dialog was reached. */
|
|
21
21
|
export const NO_PRINT_CSS = `@media print { body * { visibility: hidden !important; } body::after { content: "This page is confidential and cannot be printed."; visibility: visible; position: fixed; top: 40%; left: 0; right: 0; text-align: center; font: 16px sans-serif; color: #000; } }`;
|
|
22
|
-
/** The door's default line, for a host whose readers sign in with a
|
|
22
|
+
/** The door's default line, for a host whose readers sign in with a Supersuit account (the product
|
|
23
|
+
* called Freedom until 2026-10-14; the access level keeps its `freedom` identifier). A host
|
|
23
24
|
* that signs readers in some other way passes `signInNote` to say so in its own words. */
|
|
24
|
-
export const DEFAULT_SIGN_IN_NOTE = 'This page is confidential. Sign in with the Google account your
|
|
25
|
+
export const DEFAULT_SIGN_IN_NOTE = 'This page is confidential. Sign in with the Google account your Supersuit account uses, or the address it was shared with.';
|
|
25
26
|
export function SignInDoor({ brand, href, note = DEFAULT_SIGN_IN_NOTE }) {
|
|
26
27
|
// No href means the host configured no sign-in authority: the door stays, with no way through.
|
|
27
28
|
if (!href) {
|
|
@@ -50,7 +50,7 @@ export type Decision = {
|
|
|
50
50
|
why: 'not-allowed';
|
|
51
51
|
reader: Reader;
|
|
52
52
|
};
|
|
53
|
-
export declare const MEMBER_REASON = "you are a
|
|
53
|
+
export declare const MEMBER_REASON = "you are a Supersuit user";
|
|
54
54
|
export declare const LISTED_REASON = "you were given access to it by name";
|
|
55
55
|
export declare const STAFF_REASON = "you are its owner or an admin of this hub";
|
|
56
56
|
/** Whether this reader may open a page with this access level. The allowlist opens both
|
package/lib/artifacts/reader.js
CHANGED
|
@@ -90,7 +90,7 @@ export function mintGrant(secret, reader, now = Date.now()) {
|
|
|
90
90
|
export function verifyGrant(secret, grant, now = Date.now()) {
|
|
91
91
|
return decode(secret, 'artifact-grant', 'g1', grant, now);
|
|
92
92
|
}
|
|
93
|
-
export const MEMBER_REASON = 'you are a
|
|
93
|
+
export const MEMBER_REASON = 'you are a Supersuit user';
|
|
94
94
|
export const LISTED_REASON = 'you were given access to it by name';
|
|
95
95
|
export const STAFF_REASON = 'you are its owner or an admin of this hub';
|
|
96
96
|
/** Whether this reader may open a page with this access level. The allowlist opens both
|
|
@@ -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;
|
package/lib/artifacts/toc-spy.js
CHANGED
|
@@ -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
|
-
|
|
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');
|
package/lib/brand/pack.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export type BrandPack = {
|
|
|
38
38
|
};
|
|
39
39
|
/** The small uppercase line above a title, e.g. "From Sam Rivera". */
|
|
40
40
|
kicker: string;
|
|
41
|
-
/** Unused since 0.33.1: the player bar names no narrator
|
|
41
|
+
/** Unused since 0.33.1: the player bar names no narrator.
|
|
42
42
|
* Optional so a pack that still sets it compiles; nothing reads it. */
|
|
43
43
|
narratorLabel?: (voice?: string) => string;
|
|
44
44
|
/** Optional full-page backdrop. Receives the page as children. */
|
|
@@ -140,4 +140,19 @@ export type ShareCardAssets = {
|
|
|
140
140
|
* a blur or a glow is baked in here; the renderer only sets type. Absent, `ground` fills. */
|
|
141
141
|
backdrop?: () => Promise<string>;
|
|
142
142
|
};
|
|
143
|
+
/**
|
|
144
|
+
* THE DEFAULT LOOK: Supersuit's own, for every operator with no pack of their own. Named
|
|
145
|
+
* `freedomDefault` because the product was Freedom until 2026-10-14; the export keeps its name so
|
|
146
|
+
* no host breaks, and everything a reader sees says Supersuit.
|
|
147
|
+
*
|
|
148
|
+
* Since 0.49.0 it is the Maison, the house look of Supersuit's maker: stone, and colour only for
|
|
149
|
+
* context. Night (#17171a) with mist type and bronze by default; travertine with ink one tap away,
|
|
150
|
+
* its accent bronze deepened with ink so a link reads at 4.5:1. Every value is one of the house's
|
|
151
|
+
* seven tokens or a mix of two (night, walnut, travertine, marble, bronze, ink, mist). The dark
|
|
152
|
+
* palette stays plain hex because the share card draws with it and its renderer has no color-mix().
|
|
153
|
+
*
|
|
154
|
+
* Type is the house pair: DM Serif Display to inscribe, DM Sans to read. Both are named, not
|
|
155
|
+
* fetched: a host loads them (README, "The default look's faces"), and one that does not falls back
|
|
156
|
+
* to Georgia and the system face rather than asking a font service for them on every page view.
|
|
157
|
+
*/
|
|
143
158
|
export declare const freedomDefault: BrandPack;
|
package/lib/brand/pack.js
CHANGED
|
@@ -1,16 +1,31 @@
|
|
|
1
1
|
import { newsreader } from './default-share.js';
|
|
2
|
+
/**
|
|
3
|
+
* THE DEFAULT LOOK: Supersuit's own, for every operator with no pack of their own. Named
|
|
4
|
+
* `freedomDefault` because the product was Freedom until 2026-10-14; the export keeps its name so
|
|
5
|
+
* no host breaks, and everything a reader sees says Supersuit.
|
|
6
|
+
*
|
|
7
|
+
* Since 0.49.0 it is the Maison, the house look of Supersuit's maker: stone, and colour only for
|
|
8
|
+
* context. Night (#17171a) with mist type and bronze by default; travertine with ink one tap away,
|
|
9
|
+
* its accent bronze deepened with ink so a link reads at 4.5:1. Every value is one of the house's
|
|
10
|
+
* seven tokens or a mix of two (night, walnut, travertine, marble, bronze, ink, mist). The dark
|
|
11
|
+
* palette stays plain hex because the share card draws with it and its renderer has no color-mix().
|
|
12
|
+
*
|
|
13
|
+
* Type is the house pair: DM Serif Display to inscribe, DM Sans to read. Both are named, not
|
|
14
|
+
* fetched: a host loads them (README, "The default look's faces"), and one that does not falls back
|
|
15
|
+
* to Georgia and the system face rather than asking a font service for them on every page view.
|
|
16
|
+
*/
|
|
2
17
|
export const freedomDefault = {
|
|
3
18
|
id: 'freedom-default',
|
|
4
|
-
name: '
|
|
5
|
-
ground: '#
|
|
6
|
-
ink: '#
|
|
7
|
-
accent: '#
|
|
8
|
-
light: { ground: '#
|
|
19
|
+
name: 'Supersuit',
|
|
20
|
+
ground: '#17171a',
|
|
21
|
+
ink: '#d8d1c4',
|
|
22
|
+
accent: '#a8834f',
|
|
23
|
+
light: { ground: '#e8e0d2', ink: '#1c1a17', accent: 'color-mix(in srgb,#a8834f 60%,#1c1a17)' },
|
|
9
24
|
type: {
|
|
10
|
-
display: '
|
|
11
|
-
body: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
25
|
+
display: '"DM Serif Display", Georgia, "Times New Roman", serif',
|
|
26
|
+
body: '"DM Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
12
27
|
},
|
|
13
|
-
kicker: 'A page from
|
|
28
|
+
kicker: 'A page from Supersuit',
|
|
14
29
|
// Pages unfurl as their title on the ground colour. A pack with a backdrop of its own
|
|
15
30
|
// overrides this whole field.
|
|
16
31
|
share: { font: newsreader },
|
package/lib/brand/wrapper.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ export declare function BrandGround({ pack, mode, children }: {
|
|
|
9
9
|
mode?: ThemeMode;
|
|
10
10
|
children: ReactNode;
|
|
11
11
|
}): import("react").JSX.Element;
|
|
12
|
-
/** The mark above the kicker: the pack's own, else a
|
|
12
|
+
/** The mark above the kicker: the pack's own, else a short rule in the accent. Not a dove: the dove
|
|
13
|
+
* stood for the agent, and no bird or figure stands in for her until her own look is designed. */
|
|
13
14
|
export declare function BrandMark({ pack, className }: {
|
|
14
15
|
pack: BrandPack;
|
|
15
16
|
className?: string;
|
package/lib/brand/wrapper.js
CHANGED
|
@@ -15,9 +15,10 @@ export function BrandGround({ pack, mode, children }) {
|
|
|
15
15
|
// isolation puts the fixed backdrop (z-index -1) above this ground and below the page.
|
|
16
16
|
style: { background: 'var(--a-ground)', color: 'var(--a-ink)', fontFamily: pack.type.body, position: 'relative', isolation: 'isolate' }, children: [css, pack.backdrop?.kind === 'bubbles' ? _jsx(Bubbles, { backdrop: pack.backdrop }) : null, children] }));
|
|
17
17
|
}
|
|
18
|
-
/** The mark above the kicker: the pack's own, else a
|
|
18
|
+
/** The mark above the kicker: the pack's own, else a short rule in the accent. Not a dove: the dove
|
|
19
|
+
* stood for the agent, and no bird or figure stands in for her until her own look is designed. */
|
|
19
20
|
export function BrandMark({ pack, className }) {
|
|
20
21
|
if (pack.Mark)
|
|
21
22
|
return _jsx(pack.Mark, { className: className });
|
|
22
|
-
return (_jsx("span", { "data-brand-mark": pack.id, "aria-hidden": true, className: className, style: {
|
|
23
|
+
return (_jsx("span", { "data-brand-mark": pack.id, "aria-hidden": true, className: className, style: { display: 'inline-block', width: 28, height: 1, verticalAlign: 'middle', background: pack.accent } }));
|
|
23
24
|
}
|
|
@@ -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);
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/reader/index.js
CHANGED
|
@@ -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
|
+
}
|
|
@@ -13,7 +13,7 @@ import { useEffect, useRef, useState } from 'react';
|
|
|
13
13
|
import { NO_ZOOM_FONT } from './no-zoom.js';
|
|
14
14
|
export const ACCESS_LABEL = {
|
|
15
15
|
public: 'Anyone with the link',
|
|
16
|
-
freedom: 'Any
|
|
16
|
+
freedom: 'Any Supersuit account',
|
|
17
17
|
invite: 'Only the list',
|
|
18
18
|
};
|
|
19
19
|
export const COMMENTS_LABEL = { anyone: 'Anyone', 'signed-in': 'Signed-in readers', off: 'Nobody' };
|
|
@@ -132,7 +132,7 @@ export function SettingsSheet({ kind, settings, phone, vars, onClose, onCopy, sa
|
|
|
132
132
|
const primary = { ...btn, background: 'var(--a-accent, #111)', borderColor: 'var(--a-accent, #111)', color: 'var(--a-ground, #fff)', fontWeight: 600 };
|
|
133
133
|
const muted = { margin: '4px 0 0', fontSize: 12, opacity: 0.65 };
|
|
134
134
|
const radio = (name, value, current, set, label, note) => (_jsxs("label", { style: choice, "data-settings-choice": `${name}:${value}`, children: [_jsx("input", { type: "radio", name: name, value: value, checked: current === value, onChange: () => set(value), style: { width: 18, height: 18, margin: 0, accentColor: 'var(--a-accent, #111)' } }), _jsxs("span", { children: [label, note ? _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: note }) : null] })] }, value));
|
|
135
|
-
const share = (_jsxs(_Fragment, { children: [_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can open it" }), s.levels.map((l) => radio('access', l, access, setAccess, ACCESS_LABEL[l], l === 'freedom' ? 'Plus anyone on the list' : undefined))] }), access === 'public' ? (_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Password" }), _jsxs("label", { style: choice, "data-settings-choice": "password", children: [_jsx("input", { type: "checkbox", checked: password, onChange: (e) => setPassword(e.target.checked), style: { width: 18, height: 18, margin: 0, accentColor: 'var(--a-accent, #111)' } }), _jsxs("span", { children: ["Require a password", _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: "The link to send carries the key" })] })] })] })) : null, dirty || busy ? (_jsxs("div", { style: { display: 'flex', gap: 8, margin: '0 0 14px' }, children: [_jsx("button", { type: "button", "data-settings-save": true, onClick: () => void save(), disabled: busy, style: primary, children: busy ? 'Saving' : 'Save' }), _jsx("button", { type: "button", onClick: () => { setAccess(s.access); setPassword(s.password); }, disabled: busy, style: btn, children: "Undo" })] })) : null, s.list && s.access !== 'public' ? (_jsxs("fieldset", { style: fieldset, "data-settings-list": true, children: [_jsx("legend", { style: legend, children: `The list (${s.list.length})` }), s.list.length === 0 ? _jsx("p", { style: { ...muted, margin: '0 0 6px' }, children: "Nobody yet." }) : null, _jsx("ul", { style: { listStyle: 'none', margin: '0 0 8px', padding: 0 }, children: s.list.map((e) => (_jsxs("li", { "data-settings-list-entry": e.email, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', borderBottom: '1px solid var(--a-line, rgba(127,127,127,0.2))' }, children: [_jsx("span", { style: { flex: 1, minWidth: 0, overflowWrap: 'anywhere' }, children: e.name ? _jsxs(_Fragment, { children: [e.name, _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: e.email })] }) : e.email }), _jsx("button", { type: "button", "aria-label": `Remove ${e.email}`, onClick: () => void listChange({ remove: [e.email] }), disabled: busy, style: btn, children: "Remove" })] }, e.email))) }), _jsxs("form", { onSubmit: onAdd, style: { display: 'flex', gap: 8 }, children: [_jsx("input", { type: "email", inputMode: "email", autoComplete: "off", placeholder: "name@example.com", "aria-label": "Email to add", value: email, onChange: (e) => setEmail(e.target.value), style: { flex: 1, minWidth: 0, font: 'inherit', ...NO_ZOOM_FONT, padding: phone ? '8px 10px' : '6px 8px', borderRadius: 8, border: '1px solid var(--a-line, rgba(127,127,127,0.4))', background: 'transparent', color: 'inherit' } }), _jsx("button", { type: "submit", disabled: busy, style: btn, children: "Add" })] }), s.access === 'invite' ? null : _jsx("p", { style: muted, children: "
|
|
135
|
+
const share = (_jsxs(_Fragment, { children: [_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can open it" }), s.levels.map((l) => radio('access', l, access, setAccess, ACCESS_LABEL[l], l === 'freedom' ? 'Plus anyone on the list' : undefined))] }), access === 'public' ? (_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Password" }), _jsxs("label", { style: choice, "data-settings-choice": "password", children: [_jsx("input", { type: "checkbox", checked: password, onChange: (e) => setPassword(e.target.checked), style: { width: 18, height: 18, margin: 0, accentColor: 'var(--a-accent, #111)' } }), _jsxs("span", { children: ["Require a password", _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: "The link to send carries the key" })] })] })] })) : null, dirty || busy ? (_jsxs("div", { style: { display: 'flex', gap: 8, margin: '0 0 14px' }, children: [_jsx("button", { type: "button", "data-settings-save": true, onClick: () => void save(), disabled: busy, style: primary, children: busy ? 'Saving' : 'Save' }), _jsx("button", { type: "button", onClick: () => { setAccess(s.access); setPassword(s.password); }, disabled: busy, style: btn, children: "Undo" })] })) : null, s.list && s.access !== 'public' ? (_jsxs("fieldset", { style: fieldset, "data-settings-list": true, children: [_jsx("legend", { style: legend, children: `The list (${s.list.length})` }), s.list.length === 0 ? _jsx("p", { style: { ...muted, margin: '0 0 6px' }, children: "Nobody yet." }) : null, _jsx("ul", { style: { listStyle: 'none', margin: '0 0 8px', padding: 0 }, children: s.list.map((e) => (_jsxs("li", { "data-settings-list-entry": e.email, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', borderBottom: '1px solid var(--a-line, rgba(127,127,127,0.2))' }, children: [_jsx("span", { style: { flex: 1, minWidth: 0, overflowWrap: 'anywhere' }, children: e.name ? _jsxs(_Fragment, { children: [e.name, _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: e.email })] }) : e.email }), _jsx("button", { type: "button", "aria-label": `Remove ${e.email}`, onClick: () => void listChange({ remove: [e.email] }), disabled: busy, style: btn, children: "Remove" })] }, e.email))) }), _jsxs("form", { onSubmit: onAdd, style: { display: 'flex', gap: 8 }, children: [_jsx("input", { type: "email", inputMode: "email", autoComplete: "off", placeholder: "name@example.com", "aria-label": "Email to add", value: email, onChange: (e) => setEmail(e.target.value), style: { flex: 1, minWidth: 0, font: 'inherit', ...NO_ZOOM_FONT, padding: phone ? '8px 10px' : '6px 8px', borderRadius: 8, border: '1px solid var(--a-line, rgba(127,127,127,0.4))', background: 'transparent', color: 'inherit' } }), _jsx("button", { type: "submit", disabled: busy, style: btn, children: "Add" })] }), s.access === 'invite' ? null : _jsx("p", { style: muted, children: "Supersuit accounts get in without being on the list." })] })) : null, _jsxs("div", { style: { margin: '0 0 14px' }, "data-settings-link": true, children: [_jsx("p", { style: legend, children: "The link to send" }), _jsxs("div", { style: { display: 'flex', gap: 8, alignItems: 'center' }, children: [_jsx("code", { style: { flex: 1, minWidth: 0, fontSize: 13, overflowWrap: 'anywhere' }, children: s.link }), _jsx("button", { type: "button", onClick: () => void copy(), style: btn, children: "Copy" })] })] }), s.facts.length ? (_jsx("dl", { style: { display: 'grid', gap: 8, margin: 0 }, "data-settings-facts": true, children: s.facts.map((f) => (_jsxs("div", { children: [_jsx("dt", { style: { fontSize: 12, fontWeight: 600, opacity: 0.7 }, children: f.label }), _jsx("dd", { style: { margin: 0, fontSize: 13, opacity: 0.85 }, children: f.text })] }, f.label))) })) : null] }));
|
|
136
136
|
const commentSheet = (_jsxs(_Fragment, { children: [_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can comment" }), ['anyone', 'signed-in', 'off'].map((m) => radio('comments', m, comments, setComments, COMMENTS_LABEL[m], m === 'anyone' && s.gated ? 'This page is private, so only signed-in readers it is open to can' : undefined))] }), comments !== 'off' ? (_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can read the comments" }), ['owner', 'readers'].map((v) => radio('visible', v, visible, setVisible, VISIBLE_LABEL[v], v === 'owner' ? 'Each commenter also sees their own' : undefined))] })) : null, dirty || busy ? (_jsxs("div", { style: { display: 'flex', gap: 8 }, children: [_jsx("button", { type: "button", "data-settings-save": true, onClick: () => void save(), disabled: busy, style: primary, children: busy ? 'Saving' : 'Save' }), _jsx("button", { type: "button", onClick: () => { setComments(s.comments); setVisible(s.commentsVisible); }, disabled: busy, style: btn, children: "Undo" })] })) : null] }));
|
|
137
137
|
return (_jsxs(_Fragment, { children: [_jsx("div", { "aria-hidden": true, "data-settings-scrim": true, onClick: onClose, style: { position: 'fixed', inset: 0, zIndex: 60, background: phone ? 'rgba(0,0,0,0.28)' : 'transparent' } }), _jsxs("div", { ref: panel, role: "dialog", "aria-modal": "true", "aria-label": kind === 'share' ? 'Share settings' : 'Comment settings', tabIndex: -1, "data-settings-sheet": kind, "data-nospeak": true, "data-comment-ui": true, style: style, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }, children: [_jsx("h2", { style: h, children: kind === 'share' ? 'Share settings' : 'Comment settings' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", style: { ...btn, border: 0, padding: '2px 6px', fontSize: 20, lineHeight: 1 }, children: "\u00D7" })] }), kind === 'share' ? share : commentSheet, _jsx("p", { role: "status", "aria-live": "polite", "data-settings-said": true, style: { margin: said ? '10px 0 0' : 0, fontSize: 13, minHeight: 0 }, children: said ?? '' })] })] }));
|
|
138
138
|
}
|
|
@@ -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;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -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.
|
|
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",
|