@supersuit/artifacts 0.18.1 → 0.20.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,59 @@
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.20.0 (2026-10-07)
7
+
8
+ **A host can hear when a confidential page is read.** The reader record answered `GET /reads`
9
+ when asked and told nobody when it changed. One generic hook and one pure function close that,
10
+ and the package still sends nothing anywhere.
11
+
12
+ - New config `onReaderEvent(event)`: called after a reading heartbeat is recorded (`visit`),
13
+ after a signed-in person is refused at the door (`refused`, after the refusal is on the
14
+ record), and for every listed reader of every gated page when the host calls the new
15
+ `routes.sweepReaders()` (`sweep`, for a daily job). The event carries the page, the reader,
16
+ their `summary`, their list `entry` (with `addedAt`), and the page's `words` and `readSeconds`.
17
+ Awaited; anything it throws is swallowed.
18
+ - New pure `readAlertsDue(...)` and helpers (`readSecondsFor`, `wordCount`, `localDay`, the
19
+ thresholds as constants): first-open, refused, finished (90% scroll AND a third of the reading
20
+ time at 230 wpm), came-back (a later local day), quiet (listed 3 to 14 days ago, never opened),
21
+ each once per reader per page, never for `ignore`d addresses. First-open and came-back only
22
+ fire within a day of the visit, so a host turning alerts on over old history is not flooded.
23
+ - `ReaderSummary` gains `lastStarted`; `ReadersStore` gains optional `listed()` (the Firestore
24
+ store implements it), so a store written earlier still type-checks.
25
+ - DETECTOR: a host that passes `onReaderEvent` and gets a type error is on 0.19.x.
26
+ - REMEDY: bump. Nothing changes for a host that does not pass the hook: no extra reads, and a
27
+ refusal is recorded exactly as before.
28
+ - Tests: every alert rule, its once-only guard, the ignore list, the local-day zone, the quiet
29
+ window; the hook's payload for a beat, a refusal (ordered after the flag) and a sweep; a
30
+ throwing or rejecting sink costs the reader nothing; no extra reads without the hook. Each rule
31
+ was broken on purpose and went red.
32
+
33
+ ## 0.19.0 (2026-09-30)
34
+
35
+ **A code block has a copy button, and it wraps.** Both were found on one page: an operator
36
+ published a setup page to open on somebody else's laptop, where a paste-in briefing WAS the page,
37
+ and there was nothing to press and nothing readable to select. The block rendered 9,089px wide
38
+ with every line clipped at the right edge, because a fence here usually holds prose written as
39
+ one long line per paragraph and `white-space: pre` does not wrap those.
40
+
41
+ `render.test.tsx` had a case called *gives fenced code a copy affordance* that only ever checked
42
+ for the `data-artifact-code` attribute the affordance would hang on, so the host shipped with the
43
+ hook and no button and the suite stayed green.
44
+
45
+ - New client component `CodeCopy` (`reader/code-copy.js`), mounted by `ArtifactMarkdown` only when
46
+ the markdown holds a fence. It reads the `<code>`, never `pre.textContent`, so its own label
47
+ does not land in the paste, and it falls back to a hidden textarea where `navigator.clipboard`
48
+ is undefined, which is any page served over plain http.
49
+ - Fences render `whitespace-pre-wrap [overflow-wrap:anywhere]` with `pr-16` to clear the button.
50
+ Soft wrapping inserts no characters, so what a reader copies is byte for byte what was written.
51
+ - DETECTOR: open any page with a fenced block. No button in its top-right corner, or a long line
52
+ running off the right edge, means this version is not deployed.
53
+ - REMEDY: bump.
54
+ - Tests: one copy button per block and it copies that block; the label stays out of the paste;
55
+ the no-clipboard fallback; nothing added twice and everything removed on unmount; the fence
56
+ carries `whitespace-pre-wrap`; and the mount happens only when a fence is present. The wrap
57
+ test is red against 0.18.1.
58
+
6
59
  ## 0.18.1 (2026-09-29)
7
60
 
8
61
  **On a phone, the Comment button sits at the top of the screen.** It used to appear just under
package/README.md CHANGED
@@ -215,6 +215,32 @@ name, says why they can read it, tiles their address as a faint watermark, and r
215
215
  time, scroll depth, and attempts to print, copy at length, or save. Refused sign-ins are
216
216
  recorded too, which is how a forwarded link shows up.
217
217
 
218
+ ### Knowing when it is read (`onReaderEvent`, `readAlertsDue`)
219
+
220
+ The record answers when asked; `onReaderEvent` is how a host hears that it changed. It is called
221
+ after a reading heartbeat is recorded (`kind: 'visit'`), after a signed-in person is refused at
222
+ the door (`'refused'`, with the account they tried), and for every listed reader when the host
223
+ calls `routes.sweepReaders()` from a daily job (`'sweep'`, so a host can notice a page nobody
224
+ opened). Each event carries the page (id, title, url), the reader, their totals (`summary`, or
225
+ null), their list entry (with `addedAt`), and the page's `words` and `readSeconds` at 230 words a
226
+ minute. It is awaited and anything it throws is swallowed, so a broken sink never costs a reader
227
+ their page. Without it nothing extra is read.
228
+
229
+ The package sends nothing anywhere. `readAlertsDue({ sent, email, summary, refusedAttempt,
230
+ readSeconds, now, addedAt, timeZone, ignore })` turns one event into the alerts worth sending,
231
+ each at most once per reader per page (pass back what you already sent):
232
+
233
+ | Kind | When |
234
+ | --- | --- |
235
+ | `first-open` | their first visit, within the last day |
236
+ | `refused` | a refusal at the door, with the account tried |
237
+ | `finished` | scrolled to 90% AND read for a third of the page's reading time |
238
+ | `came-back` | a visit that began on a later local day (in `timeZone`) than the first |
239
+ | `quiet` | listed 3 to 14 days ago and never opened |
240
+
241
+ Addresses in `ignore` (the publisher's own) never alert. Where an alert goes, and how a host
242
+ remembers what it sent, is the host's business.
243
+
218
244
  ### The reader pass (the contract with your sign-in side)
219
245
 
220
246
  This package does not sign anyone in. It sends readers to your sign-in authority and trusts a
@@ -7,7 +7,8 @@ export { mintPublisherPass, verifyPublisherPass, PUBLISHER_PASS_TTL_SECONDS, typ
7
7
  export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
8
8
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
9
9
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
10
- export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc } from './readers-store.js';
10
+ export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc, type ReaderSummary } from './readers-store.js';
11
+ export { readAlertsDue, readSecondsFor, wordCount, localDay, READ_ALERT_KINDS, WORDS_PER_MINUTE, FINISHED_SCROLL, FINISHED_SHARE, QUIET_AFTER_DAYS, QUIET_WINDOW_DAYS, FRESH_HOURS, type ReadAlert, type ReadAlertKind, type ReadAlertInput, type ReaderEvent } from './read-alerts.js';
11
12
  export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT, type StateConfig, type SlotDef, type Shape, type Visibility, type Writers } from './state.js';
12
13
  export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
13
14
  export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
@@ -8,6 +8,7 @@ export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAM
8
8
  export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
9
9
  export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
10
10
  export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
11
+ export { readAlertsDue, readSecondsFor, wordCount, localDay, READ_ALERT_KINDS, WORDS_PER_MINUTE, FINISHED_SCROLL, FINISHED_SHARE, QUIET_AFTER_DAYS, QUIET_WINDOW_DAYS, FRESH_HOURS } from './read-alerts.js';
11
12
  export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT } from './state.js';
12
13
  export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
13
14
  export { stateView, responsesOf, responsesCsv } from './state-view.js';
@@ -0,0 +1,87 @@
1
+ import type { ReaderSummary } from './readers-store.js';
2
+ export declare const READ_ALERT_KINDS: readonly ["first-open", "refused", "finished", "came-back", "quiet"];
3
+ export type ReadAlertKind = (typeof READ_ALERT_KINDS)[number];
4
+ /** A normal adult reading pace, used to turn a page's word count into its reading time. */
5
+ export declare const WORDS_PER_MINUTE = 230;
6
+ /** How far down counts as reaching the end, in percent of the page. */
7
+ export declare const FINISHED_SCROLL = 90;
8
+ /** The share of the page's reading time a reader must actually spend for "finished". */
9
+ export declare const FINISHED_SHARE: number;
10
+ /** Days after a reader is listed before an unopened page earns one nudge. */
11
+ export declare const QUIET_AFTER_DAYS = 3;
12
+ /** Past this many days after listing, a never-opened page stays silent: a sweep run for the first
13
+ * time over an old list must not nudge about pages everyone has long forgotten. */
14
+ export declare const QUIET_WINDOW_DAYS = 14;
15
+ /** First-open and came-back describe something that just happened. A record older than this
16
+ * (a host turning alerts on over months of history) is not news, and does not alert. */
17
+ export declare const FRESH_HOURS = 24;
18
+ /** Words a reader reads: whitespace-separated tokens. */
19
+ export declare function wordCount(text: string): number;
20
+ /** Seconds a page takes to read at `wpm`. */
21
+ export declare function readSecondsFor(words: number, wpm?: number): number;
22
+ /** The calendar day an instant falls on in `timeZone`, as YYYY-MM-DD. An unknown zone reads as UTC
23
+ * rather than throwing, because a bad zone must never swallow an alert. */
24
+ export declare function localDay(at: string | Date, timeZone?: string): string;
25
+ export type ReadAlertInput = {
26
+ /** The kinds already sent for this reader on this page. */
27
+ sent: Iterable<ReadAlertKind>;
28
+ /** The reader the record is about: the account that read, or the account that was refused. */
29
+ email: string;
30
+ /** Their totals on this page (from `summarize`), or null when they have never opened it. */
31
+ summary?: Pick<ReaderSummary, 'sessions' | 'activeSeconds' | 'maxScroll' | 'firstSeen' | 'lastSeen'> & {
32
+ lastStarted?: string;
33
+ } | null;
34
+ /** True when this evaluation is for a refusal at the door that just happened. */
35
+ refusedAttempt?: boolean;
36
+ /** The page's reading time in seconds (`readSecondsFor`). Zero or absent: reaching the end is enough. */
37
+ readSeconds?: number;
38
+ now: string | Date;
39
+ /** When the reader was put on the page's list (AllowEntry.addedAt). Needed for quiet. */
40
+ addedAt?: string;
41
+ /** The zone "a later day" is judged in. Default UTC. */
42
+ timeZone?: string;
43
+ /** Addresses whose reads never alert: the publisher, the owners. Compared case-insensitively. */
44
+ ignore?: Iterable<string>;
45
+ quietAfterDays?: number;
46
+ };
47
+ export type ReadAlert = {
48
+ kind: ReadAlertKind;
49
+ email: string;
50
+ sessions: number;
51
+ activeSeconds: number;
52
+ maxScroll: number;
53
+ /** quiet only: whole days since the reader was listed. */
54
+ daysSinceAdded?: number;
55
+ };
56
+ /** The alerts due for one reader on one page, given what was already sent. Pure. */
57
+ export declare function readAlertsDue(input: ReadAlertInput): ReadAlert[];
58
+ /** What `onReaderEvent` hands a host: one reader's state on one gated page, just after it changed.
59
+ *
60
+ * - `visit`: a reading heartbeat was recorded (`summary` is their record so far).
61
+ * - `refused`: a signed-in person was turned away at the door (`reader` is the account tried).
62
+ * - `sweep`: a listed reader, visited by `sweepReaders()` (a daily job) whether or not anything
63
+ * changed, so a host can notice what did NOT happen (quiet). */
64
+ export type ReaderEvent = {
65
+ kind: 'visit' | 'refused' | 'sweep';
66
+ artifactId: string;
67
+ title: string;
68
+ /** The page's address, e.g. https://artifacts.example.com/abc23456. */
69
+ url: string;
70
+ reader: {
71
+ email: string;
72
+ name: string | null;
73
+ };
74
+ /** Their totals on this page, or null when they have never opened it. */
75
+ summary: ReaderSummary | null;
76
+ /** Their list entry on this page, or null (a member reading by membership, or a refusal). */
77
+ entry: {
78
+ email: string;
79
+ name?: string;
80
+ addedAt?: string;
81
+ } | null;
82
+ /** Words a reader reads on the page, and the seconds that takes at WORDS_PER_MINUTE. */
83
+ words: number;
84
+ readSeconds: number;
85
+ /** When the event happened, ISO. */
86
+ at: string;
87
+ };
@@ -0,0 +1,75 @@
1
+ export const READ_ALERT_KINDS = ['first-open', 'refused', 'finished', 'came-back', 'quiet'];
2
+ /** A normal adult reading pace, used to turn a page's word count into its reading time. */
3
+ export const WORDS_PER_MINUTE = 230;
4
+ /** How far down counts as reaching the end, in percent of the page. */
5
+ export const FINISHED_SCROLL = 90;
6
+ /** The share of the page's reading time a reader must actually spend for "finished". */
7
+ export const FINISHED_SHARE = 1 / 3;
8
+ /** Days after a reader is listed before an unopened page earns one nudge. */
9
+ export const QUIET_AFTER_DAYS = 3;
10
+ /** Past this many days after listing, a never-opened page stays silent: a sweep run for the first
11
+ * time over an old list must not nudge about pages everyone has long forgotten. */
12
+ export const QUIET_WINDOW_DAYS = 14;
13
+ /** First-open and came-back describe something that just happened. A record older than this
14
+ * (a host turning alerts on over months of history) is not news, and does not alert. */
15
+ export const FRESH_HOURS = 24;
16
+ const DAY_MS = 86_400_000;
17
+ const HOUR_MS = 3_600_000;
18
+ /** Words a reader reads: whitespace-separated tokens. */
19
+ export function wordCount(text) {
20
+ const t = text.trim();
21
+ return t ? t.split(/\s+/).length : 0;
22
+ }
23
+ /** Seconds a page takes to read at `wpm`. */
24
+ export function readSecondsFor(words, wpm = WORDS_PER_MINUTE) {
25
+ return words > 0 ? Math.round((words / wpm) * 60) : 0;
26
+ }
27
+ /** The calendar day an instant falls on in `timeZone`, as YYYY-MM-DD. An unknown zone reads as UTC
28
+ * rather than throwing, because a bad zone must never swallow an alert. */
29
+ export function localDay(at, timeZone = 'UTC') {
30
+ const d = typeof at === 'string' ? new Date(at) : at;
31
+ try {
32
+ return new Intl.DateTimeFormat('en-CA', { timeZone, year: 'numeric', month: '2-digit', day: '2-digit' }).format(d);
33
+ }
34
+ catch {
35
+ return d.toISOString().slice(0, 10);
36
+ }
37
+ }
38
+ /** The alerts due for one reader on one page, given what was already sent. Pure. */
39
+ export function readAlertsDue(input) {
40
+ const email = input.email.trim().toLowerCase();
41
+ const ignore = new Set([...(input.ignore ?? [])].map((e) => e.trim().toLowerCase()).filter(Boolean));
42
+ if (!email || ignore.has(email))
43
+ return [];
44
+ const sent = new Set(input.sent);
45
+ const now = (typeof input.now === 'string' ? new Date(input.now) : input.now).getTime();
46
+ const s = input.summary && input.summary.sessions > 0 ? input.summary : null;
47
+ const base = { email, sessions: s?.sessions ?? 0, activeSeconds: s?.activeSeconds ?? 0, maxScroll: s?.maxScroll ?? 0 };
48
+ const fresh = (at) => !!at && now - new Date(at).getTime() <= FRESH_HOURS * HOUR_MS;
49
+ const out = [];
50
+ if (input.refusedAttempt) {
51
+ if (!sent.has('refused'))
52
+ out.push({ kind: 'refused', ...base });
53
+ return out;
54
+ }
55
+ if (s) {
56
+ if (!sent.has('first-open') && fresh(s.firstSeen))
57
+ out.push({ kind: 'first-open', ...base });
58
+ const started = s.lastStarted;
59
+ const zone = input.timeZone ?? 'UTC';
60
+ if (!sent.has('came-back') && started && fresh(started) && localDay(started, zone) > localDay(s.firstSeen, zone))
61
+ out.push({ kind: 'came-back', ...base });
62
+ const need = (input.readSeconds ?? 0) * FINISHED_SHARE;
63
+ if (!sent.has('finished') && s.maxScroll >= FINISHED_SCROLL && s.activeSeconds >= need)
64
+ out.push({ kind: 'finished', ...base });
65
+ return out;
66
+ }
67
+ // Never opened. One nudge, once, inside the window, and never after any open has been told.
68
+ if (input.addedAt && !sent.has('quiet') && !sent.has('first-open')) {
69
+ const days = (now - new Date(input.addedAt).getTime()) / DAY_MS;
70
+ const after = input.quietAfterDays ?? QUIET_AFTER_DAYS;
71
+ if (days >= after && days < QUIET_WINDOW_DAYS)
72
+ out.push({ kind: 'quiet', ...base, daysSinceAdded: Math.floor(days) });
73
+ }
74
+ return out;
75
+ }
@@ -61,6 +61,12 @@ export interface ReadersStore {
61
61
  }): Promise<void>;
62
62
  sessions(artifactId: string): Promise<SessionDoc[]>;
63
63
  flags(artifactId: string): Promise<FlagDoc[]>;
64
+ /** Every page that has a list, with its list: what a daily sweep walks. Optional, so a store
65
+ * written before 0.20.0 still satisfies the interface; without it the sweep finds nothing. */
66
+ listed?(): Promise<{
67
+ artifactId: string;
68
+ readers: AllowEntry[];
69
+ }[]>;
64
70
  }
65
71
  export declare function createReadersStore(db: Firestore, base: string): ReadersStore;
66
72
  export type ReaderSummary = {
@@ -72,6 +78,8 @@ export type ReaderSummary = {
72
78
  maxScroll: number;
73
79
  firstSeen: string;
74
80
  lastSeen: string;
81
+ /** When their most recent visit began. Later than `firstSeen`'s day means they came back. */
82
+ lastStarted: string;
75
83
  flags: {
76
84
  kind: FlagKind;
77
85
  at: string;
@@ -104,6 +104,10 @@ export function createReadersStore(db, base) {
104
104
  const snap = await flags().where('artifactId', '==', artifactId).get();
105
105
  return snap.docs.map((d) => d.data());
106
106
  },
107
+ async listed() {
108
+ const snap = await access().get();
109
+ return snap.docs.map((d) => ({ artifactId: d.id, readers: listOf(d.data()) })).filter((p) => p.readers.length > 0);
110
+ },
107
111
  };
108
112
  }
109
113
  /** Per-reader totals for one page, most recently seen first, plus everyone refused at the door. */
@@ -112,7 +116,7 @@ export function summarize(sessionDocs, flagDocs) {
112
116
  for (const s of sessionDocs) {
113
117
  const r = by.get(s.email) ?? {
114
118
  email: s.email, name: s.name, member: s.member, sessions: 0, activeSeconds: 0, maxScroll: 0,
115
- firstSeen: s.startedAt, lastSeen: s.lastAt, flags: [],
119
+ firstSeen: s.startedAt, lastSeen: s.lastAt, lastStarted: s.startedAt, flags: [],
116
120
  };
117
121
  r.sessions += 1;
118
122
  r.activeSeconds += s.activeSeconds ?? 0;
@@ -121,6 +125,8 @@ export function summarize(sessionDocs, flagDocs) {
121
125
  r.firstSeen = s.startedAt;
122
126
  if (s.lastAt > r.lastSeen)
123
127
  r.lastSeen = s.lastAt;
128
+ if (s.startedAt > r.lastStarted)
129
+ r.lastStarted = s.startedAt;
124
130
  by.set(s.email, r);
125
131
  }
126
132
  const refused = new Map();
@@ -13,6 +13,7 @@ import { unified } from 'unified';
13
13
  import { blocksOf, hasNotesWidget, headingsOf } from './widgets.js';
14
14
  import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
15
15
  import { VideoAutoplay } from '../reader/video-autoplay.js';
16
+ import { CodeCopy } from '../reader/code-copy.js';
16
17
  import { DefinitionLayer } from '../reader/definition-layer.js';
17
18
  import { remarkDefinitions } from './definitions.js';
18
19
  // Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
@@ -66,6 +67,9 @@ const VIDEO = /\.mp4(?:[?#].*)?$/i;
66
67
  * nothing until one is pressed. The alt is its caption, shown above the player. */
67
68
  const AUDIO = /\.mp3(?:[?#].*)?$/i;
68
69
  const VIDEO_IN_MD = /\]\([^)\s]+\.mp4(?:[?#][^)]*)?\)/i;
70
+ // A fenced block, which `links` and `notes` fences are too; mounting for those costs one
71
+ // no-op effect and is cheaper than parsing the markdown twice to find out.
72
+ const CODE_IN_MD = /^```/m;
69
73
  const escapeAttr = (s) => s.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
70
74
  // Written as markup, not JSX: React renders `muted` as a property and leaves it out of server
71
75
  // HTML, and iOS refuses to play a video unprompted whose markup is not muted. `playsinline` keeps
@@ -134,7 +138,13 @@ function baseComponents(block, figure) {
134
138
  const cls = inner?.props?.className ?? '';
135
139
  if (/language-(links|notes)/.test(cls))
136
140
  return _jsx(_Fragment, { children: children });
137
- return (_jsx("pre", { ...block(node), "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-code)] p-4 text-sm text-[color:var(--a-strong)]", children: children }));
141
+ return (_jsx("pre", { ...block(node), "data-artifact-code": true,
142
+ // whitespace-pre-wrap, because a fence here usually holds PROSE meant to be pasted
143
+ // somewhere, written as one long line per paragraph. Under `pre` those lines do not
144
+ // wrap: a 7 KB briefing rendered as a box 9,089px wide with every line clipped at the
145
+ // right edge (2026-09-30). Soft wrapping inserts no characters, so what the reader
146
+ // copies is still byte for byte what the author wrote.
147
+ className: "my-6 overflow-x-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-code)] p-4 pr-16 text-sm text-[color:var(--a-strong)]", children: children }));
138
148
  },
139
149
  blockquote: ({ node, children }) => {
140
150
  const t = textOf(children).trim();
@@ -268,5 +278,5 @@ export function ArtifactMarkdown({ markdown, notes, definitions, Section, figure
268
278
  const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, children: markdown }));
269
279
  return (
270
280
  // break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
271
- _jsxs("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: [on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body, VIDEO_IN_MD.test(markdown) ? _jsx(VideoAutoplay, {}) : null, defined ? (_jsxs(_Fragment, { children: [_jsx("style", { children: DEFINED_TERM_CSS }), _jsx(DefinitionLayer, {})] })) : null] }));
281
+ _jsxs("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: [on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body, VIDEO_IN_MD.test(markdown) ? _jsx(VideoAutoplay, {}) : null, CODE_IN_MD.test(markdown) ? _jsx(CodeCopy, {}) : null, defined ? (_jsxs(_Fragment, { children: [_jsx("style", { children: DEFINED_TERM_CSS }), _jsx(DefinitionLayer, {})] })) : null] }));
272
282
  }
@@ -0,0 +1 @@
1
+ export declare function CodeCopy(): null;
@@ -0,0 +1,77 @@
1
+ 'use client';
2
+ // A copy button on every code block, and it is the point of a code block here rather than a
3
+ // convenience on top of it. This host's fences mostly hold something the reader is meant to
4
+ // PUT somewhere: a command to run, a prompt to paste into their own agent. Until now the only
5
+ // way to take one was to select it by hand, and on a phone, inside a horizontally scrolling
6
+ // box, that is most of a minute of fighting the selection handles.
7
+ //
8
+ // Earned 2026-09-30: an operator published a setup page to open on somebody else's laptop,
9
+ // with the paste-in block as the whole reason the page existed, and there was nothing to press.
10
+ import { useEffect } from 'react';
11
+ const COPY = 'Copy';
12
+ const DONE = 'Copied';
13
+ export function CodeCopy() {
14
+ useEffect(() => {
15
+ const pres = Array.from(document.querySelectorAll('pre[data-artifact-code]'));
16
+ const timers = [];
17
+ const added = [];
18
+ for (const pre of pres) {
19
+ if (pre.querySelector('[data-artifact-copy]'))
20
+ continue;
21
+ // The button is positioned against the pre, so the pre becomes the containing block. It
22
+ // is set here rather than in the class list because the server render must stay valid
23
+ // with no JavaScript: a page with this component disabled shows a plain block.
24
+ pre.style.position = 'relative';
25
+ const btn = document.createElement('button');
26
+ btn.type = 'button';
27
+ btn.dataset.artifactCopy = 'true';
28
+ btn.textContent = COPY;
29
+ btn.setAttribute('aria-label', 'Copy this block');
30
+ btn.className =
31
+ 'absolute right-2 top-2 rounded-md border px-2 py-1 text-xs opacity-70 ' +
32
+ 'transition hover:opacity-100 focus-visible:opacity-100';
33
+ btn.style.borderColor = 'var(--a-line)';
34
+ btn.style.color = 'var(--a-strong)';
35
+ btn.style.background = 'var(--a-code)';
36
+ btn.addEventListener('click', () => {
37
+ // The button lives inside the pre, so its own label would be copied with the code.
38
+ const code = pre.querySelector('code');
39
+ const text = (code ?? pre).textContent ?? '';
40
+ const done = () => {
41
+ btn.textContent = DONE;
42
+ timers.push(setTimeout(() => { btn.textContent = COPY; }, 1600));
43
+ };
44
+ // navigator.clipboard is undefined on a page served over plain http, which is every
45
+ // local preview, so the textarea path is a real fallback rather than old-browser
46
+ // defensiveness.
47
+ if (navigator.clipboard?.writeText) {
48
+ navigator.clipboard.writeText(text).then(done, () => { btn.textContent = 'Press to select'; });
49
+ return;
50
+ }
51
+ const ta = document.createElement('textarea');
52
+ ta.value = text;
53
+ ta.style.position = 'fixed';
54
+ ta.style.opacity = '0';
55
+ document.body.appendChild(ta);
56
+ ta.select();
57
+ try {
58
+ document.execCommand('copy');
59
+ done();
60
+ }
61
+ catch {
62
+ btn.textContent = 'Press to select';
63
+ }
64
+ ta.remove();
65
+ });
66
+ pre.appendChild(btn);
67
+ added.push(btn);
68
+ }
69
+ return () => {
70
+ for (const t of timers)
71
+ clearTimeout(t);
72
+ for (const b of added)
73
+ b.remove();
74
+ };
75
+ }, []);
76
+ return null;
77
+ }
@@ -6,6 +6,7 @@ import { type ArtifactAssets } from '../artifacts/assets.js';
6
6
  import type { BrandPack } from '../brand/pack.js';
7
7
  import { type Access } from '../artifacts/reader.js';
8
8
  import { type ReadersStore } from '../artifacts/readers-store.js';
9
+ import { type ReaderEvent } from '../artifacts/read-alerts.js';
9
10
  import type { PersonalStore } from '../artifacts/personal-store.js';
10
11
  import type { Transcriber } from '../artifacts/transcribe.js';
11
12
  export type ArtifactRoutesConfig = {
@@ -68,6 +69,12 @@ export type ArtifactRoutesConfig = {
68
69
  * own. Without it the transcribe route answers 404 and readers get the browser's live
69
70
  * transcript, or an empty box to type into. */
70
71
  transcribe?: Transcriber;
72
+ /** Told when a reader's state on a gated page changes: after a reading heartbeat is recorded,
73
+ * after a signed-in person is refused at the door, and for every listed reader when the host
74
+ * runs `sweepReaders()`. The host decides what, if anything, to do (`readAlertsDue` turns an
75
+ * event into the alerts worth sending). Awaited, and anything it throws is swallowed: a broken
76
+ * sink never costs a reader their page or their record. Without it nothing extra is read. */
77
+ onReaderEvent?: (event: ReaderEvent) => void | Promise<void>;
71
78
  };
72
79
  /** Ids are 8 chars from the safe alphabet; anything else is not a page and never reaches the store. */
73
80
  export declare const ARTIFACT_ID: RegExp;
@@ -193,7 +200,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
193
200
  name: string | null;
194
201
  at: string;
195
202
  }[];
196
- readers: import("../artifacts/readers-store.js").ReaderSummary[];
203
+ readers: import("../index.js").ReaderSummary[];
197
204
  refused: {
198
205
  email: string;
199
206
  name: string | null;
@@ -205,6 +212,10 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
205
212
  access: Access | null;
206
213
  views: number;
207
214
  }>>;
215
+ sweepReaders: () => Promise<{
216
+ pages: number;
217
+ readers: number;
218
+ }>;
208
219
  TRANSCRIBE: (req: NextRequest, { params }: {
209
220
  params: Promise<{
210
221
  id: string;
@@ -23,6 +23,7 @@ import { UpdatedTime } from '../reader/updated-time.js';
23
23
  import { VersionHistory } from '../reader/version-history.js';
24
24
  import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
25
25
  import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
26
+ import { readSecondsFor, wordCount } from '../artifacts/read-alerts.js';
26
27
  import { AckDoor, ConfidentialBanner, NO_PRINT_CSS, NotAllowedDoor, SignInDoor, Watermark, ackText } from '../artifacts/confidential.js';
27
28
  import { ARTIFACT_ID_RE } from './ids.js';
28
29
  import { createStateRoutes } from './state-routes.js';
@@ -80,6 +81,58 @@ export function createArtifactRoutes(config) {
80
81
  store, state: config.state, personal: config.personal, readers: config.readers, assets: config.assets, transcribe: config.transcribe,
81
82
  readerSecret, pageUrl, signInOrigin, siteUrl, clientIp: config.clientIp,
82
83
  });
84
+ // ---- Reader events (onReaderEvent). Nothing here runs, and nothing extra is read, without it.
85
+ const wordsCache = new Map();
86
+ /** Words a reader reads on the page, cached per version so a heartbeat does not re-parse it. */
87
+ function wordsOf(a) {
88
+ const c = wordsCache.get(a.id);
89
+ if (c && c.updatedAt === a.updatedAt)
90
+ return c.words;
91
+ const words = wordCount(narrationText(a));
92
+ wordsCache.set(a.id, { updatedAt: a.updatedAt, words });
93
+ return words;
94
+ }
95
+ const same = (x, y) => x.trim().toLowerCase() === y.trim().toLowerCase();
96
+ async function emitReaderEvent(e, a) {
97
+ if (!config.onReaderEvent)
98
+ return;
99
+ try {
100
+ const words = wordsOf(a);
101
+ await config.onReaderEvent({ ...e, artifactId: a.id, title: a.title, url: pageUrl(a.id), words, readSeconds: readSecondsFor(words), at: new Date().toISOString() });
102
+ }
103
+ catch {
104
+ // A broken sink never costs a reader their page or their record.
105
+ }
106
+ }
107
+ /** One reader's record on one page, read fresh, for an event. */
108
+ async function readerSummary(id, email) {
109
+ const [sessions, flags] = await Promise.all([config.readers.sessions(id), config.readers.flags(id)]);
110
+ return summarize(sessions, flags).readers.find((r) => same(r.email, email)) ?? null;
111
+ }
112
+ /** Tell `onReaderEvent` about every listed reader of every gated page, opened or not, so a host
113
+ * can notice what has NOT happened (a page nobody opened). Run it from a daily job. Needs a
114
+ * readers store with `listed()`; without one, or without the hook, it does nothing. */
115
+ async function sweepReaders() {
116
+ const readers = config.readers;
117
+ if (!config.onReaderEvent || !readers?.listed)
118
+ return { pages: 0, readers: 0 };
119
+ let pages = 0;
120
+ let count = 0;
121
+ for (const p of await readers.listed()) {
122
+ const a = ARTIFACT_ID.test(p.artifactId) ? await store.get(p.artifactId) : null;
123
+ if (!a?.access)
124
+ continue;
125
+ pages += 1;
126
+ const [sessions, flags] = await Promise.all([readers.sessions(a.id), readers.flags(a.id)]);
127
+ const sums = summarize(sessions, flags).readers;
128
+ for (const entry of p.readers) {
129
+ const summary = sums.find((r) => same(r.email, entry.email)) ?? null;
130
+ await emitReaderEvent({ kind: 'sweep', reader: { email: entry.email, name: entry.name ?? summary?.name ?? null }, summary, entry }, a);
131
+ count += 1;
132
+ }
133
+ }
134
+ return { pages, readers: count };
135
+ }
83
136
  /** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
84
137
  * whole paragraph is data-nospeak, and narrationText has no `to` to read. */
85
138
  function Kicker({ to, brand }) {
@@ -190,8 +243,14 @@ export function createArtifactRoutes(config) {
190
243
  const d = config.readers ? decide(a.access, reader, allow) : { open: false, why: 'signed-out' };
191
244
  const header = (_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary })] }));
192
245
  if (!d.open) {
193
- if (d.why === 'not-allowed' && config.readers)
194
- void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
246
+ if (d.why === 'not-allowed' && config.readers) {
247
+ const flagged = config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
248
+ // Told after the refusal is on the record, so a sink that reads it back finds it.
249
+ if (config.onReaderEvent)
250
+ await flagged.then(() => emitReaderEvent({ kind: 'refused', reader: { email: d.reader.email, name: d.reader.name }, summary: null, entry: null }, a));
251
+ else
252
+ void flagged;
253
+ }
195
254
  return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
196
255
  ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: switchUrl(id) })
197
256
  : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined, note: config.signInNote })] }));
@@ -337,7 +396,8 @@ export function createArtifactRoutes(config) {
337
396
  const a = await store.get(id);
338
397
  if (!a?.access)
339
398
  return new NextResponse(null, { status: 404 });
340
- if (!decide(a.access, reader, await config.readers.allowList(id)).open)
399
+ const allow = await config.readers.allowList(id);
400
+ if (!decide(a.access, reader, allow).open)
341
401
  return new NextResponse(null, { status: 403 });
342
402
  const country = request.headers.get('x-vercel-ip-country') ?? undefined;
343
403
  if (b.kind === 'beat') {
@@ -345,6 +405,11 @@ export function createArtifactRoutes(config) {
345
405
  const scroll = Math.max(0, Math.min(100, Math.round(Number(b.scroll) || 0)));
346
406
  const device = /Mobi|Android|iPhone|iPad/i.test(request.headers.get('user-agent') ?? '') ? 'mobile' : 'desktop';
347
407
  await config.readers.touchSession({ artifactId: id, session, reader, addSeconds: add, scroll, device, country });
408
+ if (config.onReaderEvent) {
409
+ const summary = await readerSummary(id, reader.email).catch(() => null);
410
+ const entry = allow.find((e) => same(e.email, reader.email)) ?? null;
411
+ await emitReaderEvent({ kind: 'visit', reader: { email: reader.email, name: reader.name }, summary, entry }, a);
412
+ }
348
413
  return new NextResponse(null, { status: 204 });
349
414
  }
350
415
  if (b.kind === 'flag' && FLAG_KINDS.includes(b.flag) && b.flag !== 'refused') {
@@ -572,5 +637,5 @@ export function createArtifactRoutes(config) {
572
637
  revalidatePath(pagePath(id));
573
638
  return NextResponse.json({ deleted: true });
574
639
  }
575
- return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
640
+ return { Page, generateMetadata, VersionPage, generateVersionMetadata, VERSIONS, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, sweepReaders, TRANSCRIBE: voiceRoutes.TRANSCRIBE, ...stateRoutes, ...personalRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
576
641
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.18.1",
3
+ "version": "0.20.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",