@supersuit/artifacts 0.59.0 → 0.61.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 +24 -0
- package/README.md +81 -2
- package/lib/artifacts/front-matter.d.ts +5 -0
- package/lib/artifacts/front-matter.js +13 -1
- package/lib/artifacts/goal.d.ts +47 -0
- package/lib/artifacts/goal.js +70 -0
- package/lib/artifacts/index.d.ts +2 -1
- package/lib/artifacts/index.js +1 -0
- package/lib/artifacts/recipients-store.d.ts +36 -1
- package/lib/artifacts/recipients-store.js +28 -4
- package/lib/artifacts/render.js +17 -5
- package/lib/artifacts/store.d.ts +3 -0
- package/lib/artifacts/store.js +2 -0
- package/lib/reader/index.d.ts +1 -0
- package/lib/reader/index.js +1 -0
- package/lib/reader/media-viewer.d.ts +1 -0
- package/lib/reader/media-viewer.js +391 -0
- package/lib/reader/media-zoom.d.ts +23 -0
- package/lib/reader/media-zoom.js +30 -0
- package/lib/reader/quiet-view.js +18 -0
- package/lib/routes/artifacts.d.ts +5 -0
- package/lib/routes/artifacts.js +5 -3
- package/lib/routes/recipients-routes.d.ts +4 -0
- package/lib/routes/recipients-routes.js +20 -2
- package/package.json +1 -1
- package/template/hub/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,30 @@
|
|
|
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.61.0 (2026-11-08)
|
|
7
|
+
|
|
8
|
+
**Send outcomes: one success indicator for any send** (issue #12, item 1, and outcomes). A page declares a `goal` (`read` by default, `respond`, `ack`, or `{ click: <cta id> }`); a link titled `cta:<id>` is a CTA whose press the page records through `POST /seen` (`event: click`, under the beat, refused for an id the page does not carry); and every reader's line in `GET /views` and `summarizeViews` gains `lastOpenAt`, `sessions`, `opens`, `clicks`, `goal`, `outcome` (`not-opened`, `opened`, `read`, `returned`, `acted`), `succeeded` and `daysSinceSent`, all under the before-send rule. Thresholds are exported (`OUTCOME_READ_SECONDS` 30, `OUTCOME_RETURN_GAP_MS` one hour) and a host overrides them with `outcomeThresholds` on the routes, `summarizeViews`'s fourth argument, or `?readSeconds=` on `/views`. `ack` is new: the only acknowledgement before was the confidential page's agreement, keyed to a signed-in email, which a per-reader page cannot carry, so `ack` is a press of the CTA named `ack`. `GET /api/artifacts/<id>` answers `goal`. Existing fields are unchanged; a page with no goal reads as `read`; a view stored before clicks reads as none.
|
|
9
|
+
|
|
10
|
+
- DETECTOR: `GET /views` rows carry no `outcome`; `goal:` in front matter is refused as an unknown key.
|
|
11
|
+
- REMEDY: update. Nothing to configure. Add `goal:` to a page, and `"cta:<id>"` titles to the links that are its CTAs.
|
|
12
|
+
- TESTS: `src/artifacts/outcomes.test.ts` (goal parsing and refusals, thresholds, clicks stored, a render before the send never counted, a second session on a known device counted as returned, each goal reaching and missing success, host thresholds), `src/routes/outcomes.test.tsx` (the click signal and its refusals, `/views` fields and thresholds by config and query, `GET` goal, the CTA link markup), `store.test.ts` (goal persisted and cleared), `quiet-view.test.tsx` (the page sends clicks, waiting for the beat), `index.test.ts` (exports). `seen.test.tsx`'s unknown-event case now uses `hover`, since `click` is known.
|
|
13
|
+
|
|
14
|
+
## 0.60.1 (2026-11-06)
|
|
15
|
+
|
|
16
|
+
**The golden default page carries the viewer's image mark.** The 0.60.0 tag never reached npm: its publish run failed on `unchanged-default.test.tsx`, because every image now carries `data-artifact-image`. That attribute is the only difference in the regenerated golden. 0.60.1 is the first published version with the full screen viewer below.
|
|
17
|
+
|
|
18
|
+
- DETECTOR: `npm view @supersuit/artifacts@0.60.0` finds nothing.
|
|
19
|
+
- REMEDY: use 0.60.1.
|
|
20
|
+
- TESTS: `unchanged-default.test.tsx`; the full suite ran before the tag.
|
|
21
|
+
|
|
22
|
+
## 0.60.0 (2026-11-06, tagged, never published)
|
|
23
|
+
|
|
24
|
+
**Every image and video on a page opens full screen, with pinch to zoom and arrows through the page.** A new client component, `MediaViewer` (`src/reader/media-viewer.tsx`), mounted by `ArtifactMarkdown` and exported from `./reader`; the zoom arithmetic is pure, in `media-zoom.ts`. No new dependency. Body images and covers carry `data-artifact-image`; a pack's images are found through `data-artifact-figure`. The viewer is a portal on `<body>`, so the comment layer's gestures never see it, and it styles itself inline, so it needs nothing from a host's Tailwind build. iOS Safari needed `touch-action: none` and cancelled `touchmove` and `gesture*` events together. Margin taps close with a mouse only, because a near miss on an arrow closed the viewer on an iPhone.
|
|
25
|
+
|
|
26
|
+
- DETECTOR: tap an image on a page. Nothing happens on an old version.
|
|
27
|
+
- REMEDY: update. Nothing to configure. A host that draws its own `<img>` around the shell adds `data-artifact-image` to include it.
|
|
28
|
+
- TESTS: `media-viewer.test.tsx`, `media-zoom.test.ts`, `render.test.tsx`; `check-packed` asserts the marks reach a served page. Verified by hand in iOS 26.2 Safari in the Simulator with real touches (a two-finger pinch reached 3.3x with `visualViewport.scale` at 1.00; pan, swipe, double tap, arrows, both video kinds) and in desktop Chrome by keyboard. WebDriver touch actions proved unreliable there, even on a plain button; drive the Simulator window with mouse events and a held Option key instead.
|
|
29
|
+
|
|
6
30
|
## 0.59.0 (2026-11-02)
|
|
7
31
|
|
|
8
32
|
**The deployable hub host ships in the package** (supersuit-dev#1630). `template/hub/` is a complete
|
package/README.md
CHANGED
|
@@ -211,6 +211,16 @@ as a change for the password and comments (as before, it takes the password off
|
|
|
211
211
|
off), and never for `access:`, which a file that names none leaves alone. A page last published
|
|
212
212
|
before 0.31.0 has no `declared` and takes its file on its next publish, as every publish did.
|
|
213
213
|
|
|
214
|
+
## Full screen images and video
|
|
215
|
+
|
|
216
|
+
Tap any image or video on a page and it fills the screen. `ArtifactMarkdown` mounts this itself, so every page and every host has it with no setting, and a page with no JavaScript stays a plain page. It covers images in the body, a page's cover, an image a brand pack draws, and both kinds of video.
|
|
217
|
+
|
|
218
|
+
- **Zoom:** pinch, double tap, or (with a mouse) click. A trackpad pinch and the `+`, `-` and `0` keys also work. Drag to look around a zoomed picture.
|
|
219
|
+
- **Move:** arrows on screen, a swipe, or the arrow keys, through every image and video on the page in order.
|
|
220
|
+
- **Close:** the X, a swipe down, Escape, or a mouse click off the picture. A finger off the picture does nothing, because the arrows sit there.
|
|
221
|
+
- **Video:** a silent video loops and behaves like a picture. A `sound:` clip keeps its controls and gets an expand button on the page, because tapping it plays it. It moves to the viewer and comes back at the second it reached. Swipes do not work on it, because its controls take the touch; use the arrows.
|
|
222
|
+
- **Left alone:** an image inside a link stays a link, and is not in the sequence. Drawing a comment box on an image does not open the viewer.
|
|
223
|
+
|
|
214
224
|
## Version history
|
|
215
225
|
|
|
216
226
|
Every page says when it was last updated under the summary, and to its admins that line also names
|
|
@@ -301,6 +311,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
301
311
|
| `comment_name` | `required`: on a per-reader page, a comment through a group link needs a typed first name, and each browser on a group link is its own reader (see Per-reader links). Needs `links: per-reader` |
|
|
302
312
|
| `forward` | `off`, `signed-in` or `open`: whether a reader of a per-reader page may make a link to pass it on (see Forward links). Absent is `off`. Needs `links: per-reader`. Sticky like `links` |
|
|
303
313
|
| `history` | `everyone` or `admins`: who sees this page's version number and past versions, overriding the host's `history` for this one page (see Version history). Absent, the host decides. Refused as `everyone` on a per-reader page |
|
|
314
|
+
| `goal` | What a send of the page is for: `read` (the default), `respond`, `ack`, or `click: <cta id>` (see Send outcomes). A republish without the line goes back to `read` |
|
|
304
315
|
| `change` | One line saying what this version changed (see Version history) |
|
|
305
316
|
| `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
|
|
306
317
|
|
|
@@ -1061,8 +1072,8 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
|
|
|
1061
1072
|
|---|---|---|
|
|
1062
1073
|
| `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
|
|
1063
1074
|
| `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes?, provenance? }], revoke?: [rid] or '*', expires?: ISO time or null, closes?: { rids: [rid] or '*', at: ISO time or null }, sent?: { rids: [rid] or '*', at?: ISO time } }`. `add` answers each new reader's link and its `provenance` (see Who minted a link); `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window; `sent` records when links reached their readers (see A render before the send) |
|
|
1064
|
-
| `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments, `sentAt`, `beforeSend` and `provenance
|
|
1065
|
-
| `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
|
|
1075
|
+
| `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments, `sentAt`, `beforeSend` and `provenance`, plus each send's outcome (see Send outcomes). Each comment is `{ id, parent, at, text }` (see Comment ids in /views). A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
|
|
1076
|
+
| `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below: a view, beats, plays, and CTA clicks |
|
|
1066
1077
|
|
|
1067
1078
|
Config, both on `createArtifactRoutes`: `recipients` (a store from `createRecipientsStore(db, '<collection path>')`, or `memoryRecipientsStore()` in tests) and `recipientSecret`, a function returning the signing secret, read on every request. A secret shorter than 32 characters is treated as unset: per-reader pages stay shut and the routes answer 404 or 501. Signing with an empty secret throws.
|
|
1068
1079
|
|
|
@@ -1223,6 +1234,74 @@ any stored entry in any slot, and a browser that removes its only entry and writ
|
|
|
1223
1234
|
one shared reader, and every note left per browser stops being shown to its writer: the notes stay with
|
|
1224
1235
|
the publisher, under their per-browser keys.
|
|
1225
1236
|
|
|
1237
|
+
### Send outcomes (Unreleased)
|
|
1238
|
+
|
|
1239
|
+
One send is one page to one reader. Every reader's line in `GET /views` (and every row
|
|
1240
|
+
`summarizeViews` returns) says how far that send got, on one ladder that works for any page, so a
|
|
1241
|
+
host can draw each send in the column where it landed without reimplementing anything.
|
|
1242
|
+
|
|
1243
|
+
**The page says what a send is for**, in front matter:
|
|
1244
|
+
|
|
1245
|
+
```markdown
|
|
1246
|
+
---
|
|
1247
|
+
title: Next week
|
|
1248
|
+
summary: Two times that work.
|
|
1249
|
+
links: per-reader
|
|
1250
|
+
goal:
|
|
1251
|
+
click: book-call
|
|
1252
|
+
---
|
|
1253
|
+
Pick one: [Book a call](https://cal.example.com/robin "cta:book-call").
|
|
1254
|
+
```
|
|
1255
|
+
|
|
1256
|
+
| `goal:` | The send succeeded when |
|
|
1257
|
+
|---|---|
|
|
1258
|
+
| `read` (the default, and what a page without the line is) | the reader read it: at least `readSeconds` active seconds |
|
|
1259
|
+
| `respond` | the reader left a comment (a page needs `comments:` on for this to happen) |
|
|
1260
|
+
| `ack` | the reader pressed the page's ack CTA, a link titled `cta:ack`, e.g. `[Got it](#got-it "cta:ack")` |
|
|
1261
|
+
| `{ click: <cta id> }` | the reader pressed the CTA with that id |
|
|
1262
|
+
|
|
1263
|
+
**A CTA is a link whose title is `cta:<id>`** (lowercase letters, digits, dashes, underscores, up to
|
|
1264
|
+
64). The title is never shown: the link renders with `data-artifact-cta="<id>"`, and an in-page CTA
|
|
1265
|
+
(`#...`) does not open a tab. On a reader's own link, a press sends `{ event: "click", beat, cta }`
|
|
1266
|
+
to `POST /seen`, under the same signature, render token and beat as every other signal (a press
|
|
1267
|
+
before the view lands waits for it). The route refuses a click without the beat (403), a malformed
|
|
1268
|
+
id or an id the page does not carry (400), and a revoked or expired reader (410); the owner's records
|
|
1269
|
+
nothing. Each session keeps up to 50 clicks. Publishing refuses a `goal` it does not know (`goal must be read, respond, ack, or { click:
|
|
1270
|
+
<cta id> }`), a click goal whose CTA the body does not carry, `goal: ack` with no `cta:ack` link, and
|
|
1271
|
+
a `cta:` title whose id could never be recorded.
|
|
1272
|
+
|
|
1273
|
+
**`ack` is new.** The package's only acknowledgement before this was the confidential page's
|
|
1274
|
+
agreement (`POST /api/reader/ack`), which is keyed to a signed-in email on an `access:` page, and a
|
|
1275
|
+
per-reader page cannot carry `access:`. So `ack` is the smallest thing that is the reader's own
|
|
1276
|
+
word: a press of the CTA named `ack`.
|
|
1277
|
+
|
|
1278
|
+
Each reader's line gains, every field under the before-send rule (a render before the recorded
|
|
1279
|
+
send is never the reader, nor anything later on its device; a comment written before the send is
|
|
1280
|
+
not their response):
|
|
1281
|
+
|
|
1282
|
+
| Field | What it is |
|
|
1283
|
+
|---|---|
|
|
1284
|
+
| `lastOpenAt` | When the reader's latest session began, or null |
|
|
1285
|
+
| `sessions` | How many sessions the reader had |
|
|
1286
|
+
| `opens` | `[{ at, activeSeconds, device }]`, one per session, oldest first |
|
|
1287
|
+
| `clicks` | `[{ cta, at }]`, every CTA the reader pressed, oldest first |
|
|
1288
|
+
| `goal` | The page's goal (`read` when it has none) |
|
|
1289
|
+
| `outcome` | The highest rung reached: `not-opened`, `opened`, `read` (at least `readSeconds` active), `returned` (a second session at least `returnGapMs` after the first, on any device, the known one included), `acted` (the goal's action: a comment for `respond`, the `ack` CTA for `ack`, that CTA for a click goal; never for `read`) |
|
|
1290
|
+
| `succeeded` | Whether the send reached the goal: for `read`, reading; for every other goal, acting |
|
|
1291
|
+
| `daysSinceSent` | Whole days from `sentAt` to now, or null with no recorded send, for drawing age |
|
|
1292
|
+
|
|
1293
|
+
`sentAt` was already on the line. The response also carries the page's `goal` and the `thresholds`
|
|
1294
|
+
it measured by. Every field that was there before is unchanged.
|
|
1295
|
+
|
|
1296
|
+
**The thresholds are exported**: `OUTCOME_READ_SECONDS` (30: a skim of a short page, about what a
|
|
1297
|
+
phone reader gives a sent link), `OUTCOME_RETURN_GAP_MS` (an hour, the line `cameBack` already
|
|
1298
|
+
draws, so two tabs in one sitting are not a return), together `OUTCOME_THRESHOLDS`. A host passes
|
|
1299
|
+
its own as `outcomeThresholds: { readSeconds?, returnGapMs? }` on `createArtifactRoutes`, as
|
|
1300
|
+
`summarizeViews(rec, views, comments, { goal, thresholds, now })`, or for one request as
|
|
1301
|
+
`GET /views?readSeconds=<n>`. `OUTCOMES` lists the rungs in order; `parseGoal`, `ctasIn` and
|
|
1302
|
+
`outcomeThresholds` are exported for hosts that check a page themselves. `GET /api/artifacts/<id>`
|
|
1303
|
+
answers the page's `goal` too.
|
|
1304
|
+
|
|
1226
1305
|
## Broadcasts (`template: broadcast`)
|
|
1227
1306
|
|
|
1228
1307
|
A broadcast is its own page kind, not a document with extra flags. It is one message sent to several people, each through their own link, and it renders as one card: the sender's words at the top, then the media in the order the words put it (a video with sound and controls, photos, a voice note's player, link cards), then that person's own note. None of the document's furniture: no table of contents, no version line, no read-along, and no page menu for a reader.
|
|
@@ -3,6 +3,7 @@ import { type StateConfig } from './state.js';
|
|
|
3
3
|
import { type CommentsMode, type CommentsVisible } from './comments.js';
|
|
4
4
|
import { type ThemeMode } from '../brand/theme.js';
|
|
5
5
|
import { type Definition } from './definitions.js';
|
|
6
|
+
import { type ArtifactGoal } from './goal.js';
|
|
6
7
|
export type ArtifactMeta = {
|
|
7
8
|
title: string;
|
|
8
9
|
summary: string;
|
|
@@ -72,6 +73,10 @@ export type ArtifactMeta = {
|
|
|
72
73
|
* link to pass it on, and whether they must sign in first. Absent is off. A door like `links:`,
|
|
73
74
|
* so sticky: a republish that leaves the line out changes nothing. */
|
|
74
75
|
forward?: ForwardMode;
|
|
76
|
+
/** `goal: read | respond | ack | { click: <cta id> }` (issue #12): what a send of this page is
|
|
77
|
+
* for, so each reader's outcome says whether the send succeeded. Absent is `read`. Content, like
|
|
78
|
+
* `toc:`: a republish without the line goes back to read. */
|
|
79
|
+
goal?: ArtifactGoal;
|
|
75
80
|
};
|
|
76
81
|
export declare const FORWARD_MODES: readonly ["off", "signed-in", "open"];
|
|
77
82
|
export type ForwardMode = (typeof FORWARD_MODES)[number];
|
|
@@ -7,6 +7,7 @@ import { mergeChecklistState, mergeWidgetState, scanWidgets } from './widgets.js
|
|
|
7
7
|
import { COMMENTS_MODES, mergeCommentsState } from './comments.js';
|
|
8
8
|
import { THEME_MODES } from '../brand/theme.js';
|
|
9
9
|
import { parseDefinitions } from './definitions.js';
|
|
10
|
+
import { parseGoal, checkGoalAgainstBody } from './goal.js';
|
|
10
11
|
export const FORWARD_MODES = ['off', 'signed-in', 'open'];
|
|
11
12
|
export const PAGE_HISTORY = ['admins', 'everyone'];
|
|
12
13
|
export const ARTIFACT_KINDS = ['document', 'broadcast'];
|
|
@@ -21,7 +22,7 @@ const PRIVATE_REF_IN_BODY = /^private:/;
|
|
|
21
22
|
export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
22
23
|
/** The longest `to:` a page may carry: a name or two, never a sentence. */
|
|
23
24
|
export const MAX_TO_CHARS = 60;
|
|
24
|
-
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media', 'comment_name', 'history', 'forward']);
|
|
25
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'to', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'pack', 'toc', 'definitions', 'change', 'comments', 'comments_visible', 'links', 'media', 'comment_name', 'history', 'forward', 'goal']);
|
|
25
26
|
export function parseArtifactSource(text,
|
|
26
27
|
/** The kind a file that leaves `template:` out is read as: the stored page's kind on a
|
|
27
28
|
* republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
|
|
@@ -99,6 +100,17 @@ opts = {}) {
|
|
|
99
100
|
return { ok: false, error: 'forward must be off, signed-in or open' };
|
|
100
101
|
meta.forward = d.forward;
|
|
101
102
|
}
|
|
103
|
+
if (d.goal !== undefined && d.goal !== null) {
|
|
104
|
+
const g = parseGoal(d.goal);
|
|
105
|
+
if (!g.ok)
|
|
106
|
+
return { ok: false, error: g.error };
|
|
107
|
+
meta.goal = g.goal;
|
|
108
|
+
}
|
|
109
|
+
{
|
|
110
|
+
const why = checkGoalAgainstBody(meta.goal, content);
|
|
111
|
+
if (why)
|
|
112
|
+
return { ok: false, error: why };
|
|
113
|
+
}
|
|
102
114
|
if (d.theme !== undefined) {
|
|
103
115
|
if (!THEME_MODES.includes(d.theme))
|
|
104
116
|
return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/** `read` (the default): the reader spent OUTCOME_READ_SECONDS on the page. `respond`: the reader
|
|
2
|
+
* left a comment. `ack`: the reader pressed the page's ack CTA (a link titled "cta:ack").
|
|
3
|
+
* `{ click }`: the reader pressed the CTA with that id. */
|
|
4
|
+
export type ArtifactGoal = 'read' | 'respond' | 'ack' | {
|
|
5
|
+
click: string;
|
|
6
|
+
};
|
|
7
|
+
export declare const GOAL_KINDS: readonly ["read", "respond", "ack"];
|
|
8
|
+
export declare const DEFAULT_GOAL: ArtifactGoal;
|
|
9
|
+
/** A CTA id: lowercase letters, digits, dashes, underscores, up to 64. */
|
|
10
|
+
export declare const CTA_ID: RegExp;
|
|
11
|
+
/** The CTA id `goal: ack` counts. */
|
|
12
|
+
export declare const ACK_CTA = "ack";
|
|
13
|
+
/** A link title that marks the link as a CTA: `[Book a call](https://... "cta:book-call")`. */
|
|
14
|
+
export declare const CTA_TITLE: RegExp;
|
|
15
|
+
/** Clicks kept per session; a page that sends more is not a reader pressing a button. */
|
|
16
|
+
export declare const MAX_CLICKS_PER_SESSION = 50;
|
|
17
|
+
export type Outcome = 'not-opened' | 'opened' | 'read' | 'returned' | 'acted';
|
|
18
|
+
/** The rungs, lowest first. A send's outcome is the highest one it reached. */
|
|
19
|
+
export declare const OUTCOMES: readonly Outcome[];
|
|
20
|
+
/** Active seconds that make an open a read. Thirty seconds is a skim of a short page, which is
|
|
21
|
+
* as much as a phone reader gives a sent link; a host with long pages passes its own. */
|
|
22
|
+
export declare const OUTCOME_READ_SECONDS = 30;
|
|
23
|
+
/** A second open at least this long after the first is a return: an hour, the same line the
|
|
24
|
+
* summary's `cameBack` draws, so two tabs in one sitting are never a return. */
|
|
25
|
+
export declare const OUTCOME_RETURN_GAP_MS = 3600000;
|
|
26
|
+
export type OutcomeThresholds = {
|
|
27
|
+
readSeconds: number;
|
|
28
|
+
returnGapMs: number;
|
|
29
|
+
};
|
|
30
|
+
export declare const OUTCOME_THRESHOLDS: Readonly<OutcomeThresholds>;
|
|
31
|
+
/** A host's own thresholds over the defaults; anything not a finite non-negative number is ignored. */
|
|
32
|
+
export declare function outcomeThresholds(over?: Partial<OutcomeThresholds>): OutcomeThresholds;
|
|
33
|
+
/** The front matter's `goal:` value, checked. Does not look at the body; parseArtifactSource does. */
|
|
34
|
+
export declare function parseGoal(v: unknown): {
|
|
35
|
+
ok: true;
|
|
36
|
+
goal: ArtifactGoal;
|
|
37
|
+
} | {
|
|
38
|
+
ok: false;
|
|
39
|
+
error: string;
|
|
40
|
+
};
|
|
41
|
+
/** The CTA id `goal` counts, or null for read and respond. */
|
|
42
|
+
export declare function goalCta(goal: ArtifactGoal): string | null;
|
|
43
|
+
/** The CTA ids a page carries, each once, in order. */
|
|
44
|
+
export declare function ctasIn(markdown: string): string[];
|
|
45
|
+
/** A body's CTAs and its goal, checked together: a CTA id that could never be recorded, and a goal
|
|
46
|
+
* whose CTA the page does not carry, are refused rather than shipped. */
|
|
47
|
+
export declare function checkGoalAgainstBody(goal: ArtifactGoal | undefined, markdown: string): string | null;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// What a send is for, and how far one reader got toward it (issue #12). A page declares one `goal`;
|
|
2
|
+
// each per-reader send then lands on one rung of OUTCOMES, computed under the before-send rule in
|
|
3
|
+
// summarizeViews, so a host can draw every send in the column where it landed.
|
|
4
|
+
export const GOAL_KINDS = ['read', 'respond', 'ack'];
|
|
5
|
+
export const DEFAULT_GOAL = 'read';
|
|
6
|
+
/** A CTA id: lowercase letters, digits, dashes, underscores, up to 64. */
|
|
7
|
+
export const CTA_ID = /^[a-z0-9][a-z0-9_-]{0,63}$/;
|
|
8
|
+
/** The CTA id `goal: ack` counts. */
|
|
9
|
+
export const ACK_CTA = 'ack';
|
|
10
|
+
/** A link title that marks the link as a CTA: `[Book a call](https://... "cta:book-call")`. */
|
|
11
|
+
export const CTA_TITLE = /^cta:\s*(.*)$/;
|
|
12
|
+
/** Clicks kept per session; a page that sends more is not a reader pressing a button. */
|
|
13
|
+
export const MAX_CLICKS_PER_SESSION = 50;
|
|
14
|
+
/** The rungs, lowest first. A send's outcome is the highest one it reached. */
|
|
15
|
+
export const OUTCOMES = ['not-opened', 'opened', 'read', 'returned', 'acted'];
|
|
16
|
+
/** Active seconds that make an open a read. Thirty seconds is a skim of a short page, which is
|
|
17
|
+
* as much as a phone reader gives a sent link; a host with long pages passes its own. */
|
|
18
|
+
export const OUTCOME_READ_SECONDS = 30;
|
|
19
|
+
/** A second open at least this long after the first is a return: an hour, the same line the
|
|
20
|
+
* summary's `cameBack` draws, so two tabs in one sitting are never a return. */
|
|
21
|
+
export const OUTCOME_RETURN_GAP_MS = 3_600_000;
|
|
22
|
+
export const OUTCOME_THRESHOLDS = Object.freeze({ readSeconds: OUTCOME_READ_SECONDS, returnGapMs: OUTCOME_RETURN_GAP_MS });
|
|
23
|
+
/** A host's own thresholds over the defaults; anything not a finite non-negative number is ignored. */
|
|
24
|
+
export function outcomeThresholds(over = {}) {
|
|
25
|
+
const ok = (n) => typeof n === 'number' && Number.isFinite(n) && n >= 0;
|
|
26
|
+
return { readSeconds: ok(over.readSeconds) ? over.readSeconds : OUTCOME_READ_SECONDS, returnGapMs: ok(over.returnGapMs) ? over.returnGapMs : OUTCOME_RETURN_GAP_MS };
|
|
27
|
+
}
|
|
28
|
+
const GOAL_ERROR = 'goal must be read, respond, ack, or { click: <cta id> }';
|
|
29
|
+
/** The front matter's `goal:` value, checked. Does not look at the body; parseArtifactSource does. */
|
|
30
|
+
export function parseGoal(v) {
|
|
31
|
+
if (typeof v === 'string')
|
|
32
|
+
return GOAL_KINDS.includes(v) ? { ok: true, goal: v } : { ok: false, error: GOAL_ERROR };
|
|
33
|
+
if (v && typeof v === 'object' && !Array.isArray(v)) {
|
|
34
|
+
const keys = Object.keys(v);
|
|
35
|
+
const cta = v.click;
|
|
36
|
+
if (keys.length === 1 && keys[0] === 'click' && typeof cta === 'string' && CTA_ID.test(cta))
|
|
37
|
+
return { ok: true, goal: { click: cta } };
|
|
38
|
+
}
|
|
39
|
+
return { ok: false, error: GOAL_ERROR };
|
|
40
|
+
}
|
|
41
|
+
/** The CTA id `goal` counts, or null for read and respond. */
|
|
42
|
+
export function goalCta(goal) {
|
|
43
|
+
return goal === 'ack' ? ACK_CTA : typeof goal === 'object' ? goal.click : null;
|
|
44
|
+
}
|
|
45
|
+
const LINK_TITLE = /\]\(\s*<?[^\s)>]*>?\s+(?:"([^"]*)"|'([^']*)')\s*\)/g;
|
|
46
|
+
/** Every link title in a body that starts `cta:`, as written (the id may be malformed). */
|
|
47
|
+
function ctaTitles(markdown) {
|
|
48
|
+
const out = [];
|
|
49
|
+
for (const m of markdown.matchAll(LINK_TITLE)) {
|
|
50
|
+
const t = CTA_TITLE.exec((m[1] ?? m[2] ?? '').trim());
|
|
51
|
+
if (t)
|
|
52
|
+
out.push(t[1].trim());
|
|
53
|
+
}
|
|
54
|
+
return out;
|
|
55
|
+
}
|
|
56
|
+
/** The CTA ids a page carries, each once, in order. */
|
|
57
|
+
export function ctasIn(markdown) {
|
|
58
|
+
return [...new Set(ctaTitles(markdown).filter((id) => CTA_ID.test(id)))];
|
|
59
|
+
}
|
|
60
|
+
/** A body's CTAs and its goal, checked together: a CTA id that could never be recorded, and a goal
|
|
61
|
+
* whose CTA the page does not carry, are refused rather than shipped. */
|
|
62
|
+
export function checkGoalAgainstBody(goal, markdown) {
|
|
63
|
+
const bad = ctaTitles(markdown).find((id) => !CTA_ID.test(id));
|
|
64
|
+
if (bad !== undefined)
|
|
65
|
+
return `a CTA id is lowercase letters, digits, dashes and underscores: "cta:${bad}" is not one`;
|
|
66
|
+
const cta = goal ? goalCta(goal) : null;
|
|
67
|
+
if (!cta || ctasIn(markdown).includes(cta))
|
|
68
|
+
return null;
|
|
69
|
+
return goal === 'ack' ? `goal: ack needs a link marked "cta:${ACK_CTA}" for the reader to press` : `goal names a CTA the page does not carry: ${cta} (mark a link with "cta:${cta}")`;
|
|
70
|
+
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -26,6 +26,7 @@ export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_
|
|
|
26
26
|
export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
|
|
27
27
|
export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
|
|
28
28
|
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
|
|
29
|
-
export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary, type Provenance } from './recipients-store.js';
|
|
29
|
+
export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary, type SummarizeOptions, type Provenance } from './recipients-store.js';
|
|
30
|
+
export { parseGoal, goalCta, ctasIn, outcomeThresholds, GOAL_KINDS, DEFAULT_GOAL, CTA_ID, ACK_CTA, MAX_CLICKS_PER_SESSION, OUTCOMES, OUTCOME_READ_SECONDS, OUTCOME_RETURN_GAP_MS, OUTCOME_THRESHOLDS, type ArtifactGoal, type Outcome, type OutcomeThresholds } from './goal.js';
|
|
30
31
|
export { migrateMemosToPrivate, MEMO_KEY, type MemoMigration } from './migrate-memos.js';
|
|
31
32
|
export { parseTranscriptFence, scanVoiceNotes, transcriptFenceFromWords, wordsFromWhisperJson, isAudioSrc, TRANSCRIPT_LANG, MAX_TRANSCRIPT_WORDS, type TranscriptParagraphs, type TimedWord, type VoiceNoteBlock } from './voice-note.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -27,5 +27,6 @@ export { settleOnPublish, applySettingsPatch, withComments, declaredOf, password
|
|
|
27
27
|
// Per-reader links: the host-facing surface only. The signing and token primitives stay internal.
|
|
28
28
|
export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
|
|
29
29
|
export { createRecipientsStore, memoryRecipientsStore, summarizeViews } from './recipients-store.js';
|
|
30
|
+
export { parseGoal, goalCta, ctasIn, outcomeThresholds, GOAL_KINDS, DEFAULT_GOAL, CTA_ID, ACK_CTA, MAX_CLICKS_PER_SESSION, OUTCOMES, OUTCOME_READ_SECONDS, OUTCOME_RETURN_GAP_MS, OUTCOME_THRESHOLDS } from './goal.js';
|
|
30
31
|
export { migrateMemosToPrivate, MEMO_KEY } from './migrate-memos.js';
|
|
31
32
|
export { parseTranscriptFence, scanVoiceNotes, transcriptFenceFromWords, wordsFromWhisperJson, isAudioSrc, TRANSCRIPT_LANG, MAX_TRANSCRIPT_WORDS } from './voice-note.js';
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Firestore } from 'firebase-admin/firestore';
|
|
2
|
+
import { type ArtifactGoal, type Outcome, type OutcomeThresholds } from './goal.js';
|
|
2
3
|
/** `sentAt`: when the sender says the link reached the reader (the publish key marks it). A view
|
|
3
4
|
* that began before then was not the reader's, whoever rendered it. */
|
|
4
5
|
export type Recipient = {
|
|
@@ -44,6 +45,8 @@ export type RecipientsRecord = {
|
|
|
44
45
|
keep: boolean;
|
|
45
46
|
revokedAll?: string;
|
|
46
47
|
};
|
|
48
|
+
/** `clicks` (issue #12): each CTA the reader pressed in this session, in order. Absent on a view
|
|
49
|
+
* recorded before clicks existed, which reads as none. */
|
|
47
50
|
export type ViewDoc = {
|
|
48
51
|
artifactId: string;
|
|
49
52
|
rid: string;
|
|
@@ -53,6 +56,10 @@ export type ViewDoc = {
|
|
|
53
56
|
lastAt: string;
|
|
54
57
|
activeSeconds: number;
|
|
55
58
|
plays: Record<string, number>;
|
|
59
|
+
clicks?: {
|
|
60
|
+
cta: string;
|
|
61
|
+
at: string;
|
|
62
|
+
}[];
|
|
56
63
|
};
|
|
57
64
|
/** What rendered the link before it was sent: never counted as the reader, shown so a publisher
|
|
58
65
|
* can see it happened (usually themselves, checking the page). Null when no send was recorded. */
|
|
@@ -62,6 +69,10 @@ export type BeforeSend = {
|
|
|
62
69
|
activeSeconds: number;
|
|
63
70
|
firstAt: string | null;
|
|
64
71
|
} | null;
|
|
72
|
+
/** One reader's line. The fields from `lastOpenAt` on (issue #12) are the send's outcome, every one
|
|
73
|
+
* of them under the before-send rule: `opens` is each of the reader's sessions, `clicks` each CTA
|
|
74
|
+
* they pressed, `outcome` the highest rung of OUTCOMES the send reached, `succeeded` whether it
|
|
75
|
+
* reached the page's `goal`, and `daysSinceSent` whole days from `sentAt` (null with no send). */
|
|
65
76
|
export type RecipientSummary = {
|
|
66
77
|
rid: string;
|
|
67
78
|
label: string;
|
|
@@ -85,6 +96,28 @@ export type RecipientSummary = {
|
|
|
85
96
|
at: string;
|
|
86
97
|
text: string;
|
|
87
98
|
}[];
|
|
99
|
+
lastOpenAt: string | null;
|
|
100
|
+
sessions: number;
|
|
101
|
+
opens: {
|
|
102
|
+
at: string;
|
|
103
|
+
activeSeconds: number;
|
|
104
|
+
device: string;
|
|
105
|
+
}[];
|
|
106
|
+
clicks: {
|
|
107
|
+
cta: string;
|
|
108
|
+
at: string;
|
|
109
|
+
}[];
|
|
110
|
+
goal: ArtifactGoal;
|
|
111
|
+
outcome: Outcome;
|
|
112
|
+
succeeded: boolean;
|
|
113
|
+
daysSinceSent: number | null;
|
|
114
|
+
};
|
|
115
|
+
/** What summarizeViews measures outcomes by: the page's goal (default read), the thresholds (default
|
|
116
|
+
* OUTCOME_THRESHOLDS) and the clock `daysSinceSent` counts to (default now). */
|
|
117
|
+
export type SummarizeOptions = {
|
|
118
|
+
goal?: ArtifactGoal;
|
|
119
|
+
thresholds?: Partial<OutcomeThresholds>;
|
|
120
|
+
now?: Date;
|
|
88
121
|
};
|
|
89
122
|
type NewRecipient = {
|
|
90
123
|
label: string;
|
|
@@ -110,6 +143,7 @@ export interface RecipientsStore {
|
|
|
110
143
|
setSent?(artifactId: string, rids: string[] | '*', sentAt: string): Promise<string[]>;
|
|
111
144
|
setExpiry(artifactId: string, expireAt: string | null): Promise<void>;
|
|
112
145
|
useToken(key: string, expiresAt: number): Promise<boolean>;
|
|
146
|
+
/** `click` (issue #12): a CTA id the reader pressed, kept on the session with its time. */
|
|
113
147
|
recordView(v: {
|
|
114
148
|
artifactId: string;
|
|
115
149
|
rid: string;
|
|
@@ -118,6 +152,7 @@ export interface RecipientsStore {
|
|
|
118
152
|
at: string;
|
|
119
153
|
addSeconds: number;
|
|
120
154
|
play?: string;
|
|
155
|
+
click?: string;
|
|
121
156
|
}): Promise<void>;
|
|
122
157
|
views(artifactId: string): Promise<ViewDoc[]>;
|
|
123
158
|
expiring(nowIso: string): Promise<string[]>;
|
|
@@ -133,5 +168,5 @@ export declare function summarizeViews(rec: RecipientsRecord, views: ViewDoc[],
|
|
|
133
168
|
parent?: string | null;
|
|
134
169
|
at: string;
|
|
135
170
|
text: string;
|
|
136
|
-
}[]): RecipientSummary[];
|
|
171
|
+
}[], opts?: SummarizeOptions): RecipientSummary[];
|
|
137
172
|
export {};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { newRid, ridOfReaderKey } from './recipients.js';
|
|
2
|
+
import { DEFAULT_GOAL, MAX_CLICKS_PER_SESSION, goalCta, outcomeThresholds } from './goal.js';
|
|
2
3
|
export const CAME_BACK_MS = 3_600_000;
|
|
3
4
|
const LABEL_MAX = 60;
|
|
4
5
|
const NOTE_MAX = 2000;
|
|
@@ -56,12 +57,14 @@ function sentIn(rec, rids, sentAt) {
|
|
|
56
57
|
return hit;
|
|
57
58
|
}
|
|
58
59
|
function addView(prev, v) {
|
|
59
|
-
const doc = prev ? { ...prev, plays: { ...prev.plays } } : { artifactId: v.artifactId, rid: v.rid, session: v.session, device: v.device, firstAt: v.at, lastAt: v.at, activeSeconds: 0, plays: {} };
|
|
60
|
+
const doc = prev ? { ...prev, plays: { ...prev.plays }, ...(prev.clicks ? { clicks: [...prev.clicks] } : {}) } : { artifactId: v.artifactId, rid: v.rid, session: v.session, device: v.device, firstAt: v.at, lastAt: v.at, activeSeconds: 0, plays: {} };
|
|
60
61
|
doc.activeSeconds += Math.max(0, Math.min(60, v.addSeconds));
|
|
61
62
|
if (v.at > doc.lastAt)
|
|
62
63
|
doc.lastAt = v.at;
|
|
63
64
|
if (v.play)
|
|
64
65
|
doc.plays[v.play] = (doc.plays[v.play] ?? 0) + 1;
|
|
66
|
+
if (v.click && (doc.clicks?.length ?? 0) < MAX_CLICKS_PER_SESSION)
|
|
67
|
+
doc.clicks = [...(doc.clicks ?? []), { cta: v.click, at: v.at }];
|
|
65
68
|
return doc;
|
|
66
69
|
}
|
|
67
70
|
const viewId = (artifactId, rid, session) => `${artifactId}__${rid}__${session}`;
|
|
@@ -192,7 +195,11 @@ export function createRecipientsStore(db, base) {
|
|
|
192
195
|
};
|
|
193
196
|
}
|
|
194
197
|
/** One line per reader (never the owner), in the order they were added. */
|
|
195
|
-
export function summarizeViews(rec, views, comments) {
|
|
198
|
+
export function summarizeViews(rec, views, comments, opts = {}) {
|
|
199
|
+
const goal = opts.goal ?? DEFAULT_GOAL;
|
|
200
|
+
const t = outcomeThresholds(opts.thresholds);
|
|
201
|
+
const nowMs = (opts.now ?? new Date()).getTime();
|
|
202
|
+
const cta = goalCta(goal);
|
|
196
203
|
return Object.values(rec.recipients).filter((r) => !r.owner).sort((a, b) => a.addedAt.localeCompare(b.addedAt)).map((r) => {
|
|
197
204
|
const all = views.filter((v) => v.rid === r.rid).sort((a, b) => a.firstAt.localeCompare(b.firstAt));
|
|
198
205
|
// A RENDER BEFORE THE SEND IS NEVER THE READER (2026-10-25). The link was opened on the
|
|
@@ -209,6 +216,15 @@ export function summarizeViews(rec, views, comments) {
|
|
|
209
216
|
const early = all.filter((v) => preDevices.has(v.device));
|
|
210
217
|
const mine = all.filter((v) => !preDevices.has(v.device));
|
|
211
218
|
const firsts = mine.map((v) => Date.parse(v.firstAt));
|
|
219
|
+
const activeSeconds = mine.reduce((n, v) => n + v.activeSeconds, 0);
|
|
220
|
+
const theirs = comments.filter((c) => ridOfReaderKey(c.readerKey) === r.rid);
|
|
221
|
+
const clicks = mine.flatMap((v) => v.clicks ?? []).sort((a, b) => a.at.localeCompare(b.at));
|
|
222
|
+
// The outcome, by the same rule: a comment written before the send was not the reader's either.
|
|
223
|
+
const responded = theirs.some((c) => !Number.isFinite(sentMs) || !(Date.parse(c.at) < sentMs));
|
|
224
|
+
const acted = goal === 'respond' ? responded : cta !== null && clicks.some((c) => c.cta === cta);
|
|
225
|
+
const read = mine.length > 0 && activeSeconds >= t.readSeconds;
|
|
226
|
+
const returned = firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= t.returnGapMs;
|
|
227
|
+
const outcome = acted ? 'acted' : returned ? 'returned' : read ? 'read' : mine.length ? 'opened' : 'not-opened';
|
|
212
228
|
return {
|
|
213
229
|
rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null,
|
|
214
230
|
sentAt: r.sentAt ?? null,
|
|
@@ -219,10 +235,18 @@ export function summarizeViews(rec, views, comments) {
|
|
|
219
235
|
opened: mine.length > 0,
|
|
220
236
|
firstOpenAt: mine[0]?.firstAt ?? null,
|
|
221
237
|
cameBack: firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= CAME_BACK_MS,
|
|
222
|
-
activeSeconds
|
|
238
|
+
activeSeconds,
|
|
223
239
|
plays: mine.reduce((n, v) => n + Object.values(v.plays).reduce((a, b) => a + b, 0), 0),
|
|
224
240
|
devices: new Set(mine.map((v) => v.device)).size,
|
|
225
|
-
comments:
|
|
241
|
+
comments: theirs.map((c) => ({ id: c.id ?? null, parent: c.parent ?? null, at: c.at, text: c.text })),
|
|
242
|
+
lastOpenAt: mine[mine.length - 1]?.firstAt ?? null,
|
|
243
|
+
sessions: mine.length,
|
|
244
|
+
opens: mine.map((v) => ({ at: v.firstAt, activeSeconds: v.activeSeconds, device: v.device })),
|
|
245
|
+
clicks,
|
|
246
|
+
goal, outcome,
|
|
247
|
+
// `read` succeeds on reading; every other goal only on its action.
|
|
248
|
+
succeeded: goal === 'read' ? read : acted,
|
|
249
|
+
daysSinceSent: Number.isFinite(sentMs) ? Math.max(0, Math.floor((nowMs - sentMs) / 86_400_000)) : null,
|
|
226
250
|
};
|
|
227
251
|
});
|
|
228
252
|
}
|
package/lib/artifacts/render.js
CHANGED
|
@@ -21,11 +21,13 @@ import { embedSrc, isEmbedKind, parseEmbedFence, EMBED_KINDS } from './simulator
|
|
|
21
21
|
import { EmbedFrame } from './embed-frame.js';
|
|
22
22
|
import { VideoAutoplay } from '../reader/video-autoplay.js';
|
|
23
23
|
import { CodeCopy } from '../reader/code-copy.js';
|
|
24
|
+
import { MediaViewer } from '../reader/media-viewer.js';
|
|
24
25
|
import { DefinitionLayer } from '../reader/definition-layer.js';
|
|
25
26
|
import { remarkDefinitions } from './definitions.js';
|
|
26
27
|
import { remarkLetterBreaks } from './letter-breaks.js';
|
|
27
28
|
import { AUDIO_SRC, remarkVoiceNotes, TRANSCRIPT_LANG } from './voice-note.js';
|
|
28
29
|
import { VoiceNote } from '../widgets/voice-note.js';
|
|
30
|
+
import { CTA_ID, CTA_TITLE } from './goal.js';
|
|
29
31
|
// Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
|
|
30
32
|
const GOLD = 'var(--a-accent)';
|
|
31
33
|
function hostOf(url) {
|
|
@@ -65,6 +67,11 @@ const CALLOUT = /^\[!(note|warning)\]\s*/i;
|
|
|
65
67
|
// else is a path on the author's disk (../meeting-transcripts/x.md), which the
|
|
66
68
|
// publisher never uploads, so the anchor would 404. Those render as their text.
|
|
67
69
|
const REACHABLE = /^(?:[a-z][a-z0-9+.-]*:|\/|#)/i;
|
|
70
|
+
/** The CTA id a link's title marks, or null. */
|
|
71
|
+
function ctaOf(title) {
|
|
72
|
+
const m = typeof title === 'string' ? CTA_TITLE.exec(title.trim()) : null;
|
|
73
|
+
return m && CTA_ID.test(m[1].trim()) ? m[1].trim() : null;
|
|
74
|
+
}
|
|
68
75
|
export function isReachableHref(href) {
|
|
69
76
|
return typeof href === 'string' && REACHABLE.test(href);
|
|
70
77
|
}
|
|
@@ -204,11 +211,16 @@ function baseComponents(block, figure, checklists, embeds, voiceNotes) {
|
|
|
204
211
|
}
|
|
205
212
|
return typeof src === 'string' && VIDEO.test(src) ? (_jsx(InlineVideo, { src: src, label: alt ?? '' })) : typeof src === 'string' && AUDIO.test(src) ? (_jsx(InlineAudio, { src: src, label: alt ?? '' })) : (
|
|
206
213
|
// eslint-disable-next-line @next/next/no-img-element
|
|
207
|
-
_jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" }));
|
|
214
|
+
_jsx("img", { "data-artifact-image": true, src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" }));
|
|
215
|
+
},
|
|
216
|
+
a: ({ href, children, title }) => {
|
|
217
|
+
// A link titled `cta:<id>` is a CTA (issue #12): the page records a press of it for a
|
|
218
|
+
// per-reader link, and the title, which is markup for this, is never shown.
|
|
219
|
+
const cta = ctaOf(title);
|
|
220
|
+
return !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href,
|
|
221
|
+
// A phone link opens an app, not a tab, and an in-page CTA (an ack) stays on the page.
|
|
222
|
+
target: PHONE_LINK.test(href) || (cta && href.startsWith('#')) ? undefined : '_blank', ...(cta ? { 'data-artifact-cta': cta } : {}), rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] }));
|
|
208
223
|
},
|
|
209
|
-
a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href,
|
|
210
|
-
// A phone link opens an app, not a tab.
|
|
211
|
-
target: PHONE_LINK.test(href) ? undefined : '_blank', rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] })),
|
|
212
224
|
code: ({ node, className, children }) => {
|
|
213
225
|
const lang = /language-(\w+)/.exec(className ?? '')?.[1];
|
|
214
226
|
if (lang === 'links')
|
|
@@ -385,5 +397,5 @@ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Sect
|
|
|
385
397
|
const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, urlTransform: artifactUrlTransform, children: markdown }));
|
|
386
398
|
return (
|
|
387
399
|
// break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
|
|
388
|
-
_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] }));
|
|
400
|
+
_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, _jsx(MediaViewer, {}), defined ? (_jsxs(_Fragment, { children: [_jsx("style", { children: DEFINED_TERM_CSS }), _jsx(DefinitionLayer, {})] })) : null] }));
|
|
389
401
|
}
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Firestore } from 'firebase-admin/firestore';
|
|
2
2
|
import type { ArtifactKind, ArtifactMeta, ForwardMode } from './front-matter.js';
|
|
3
|
+
import type { ArtifactGoal } from './goal.js';
|
|
3
4
|
import type { Access } from './reader.js';
|
|
4
5
|
import type { StateConfig } from './state.js';
|
|
5
6
|
import type { ThemeMode } from '../brand/theme.js';
|
|
@@ -35,6 +36,8 @@ export type ArtifactRecord = {
|
|
|
35
36
|
commentName?: 'required';
|
|
36
37
|
/** Whether a reader may make a forward link (front-matter `forward:`); absent is off. */
|
|
37
38
|
forward?: ForwardMode;
|
|
39
|
+
/** What a send of this page is for (front-matter `goal:`); absent is `read`. */
|
|
40
|
+
goal?: ArtifactGoal;
|
|
38
41
|
/** The page's own `history:` line; absent, the host's setting decides. */
|
|
39
42
|
history?: 'admins' | 'everyone';
|
|
40
43
|
definitions?: Definition[];
|
package/lib/artifacts/store.js
CHANGED
|
@@ -172,6 +172,7 @@ async function saveArtifact(col, input) {
|
|
|
172
172
|
...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
|
|
173
173
|
...(input.meta.commentName ? { commentName: input.meta.commentName } : {}),
|
|
174
174
|
...(input.meta.forward ? { forward: input.meta.forward } : {}),
|
|
175
|
+
...(input.meta.goal ? { goal: input.meta.goal } : {}),
|
|
175
176
|
...(input.meta.history ? { history: input.meta.history } : {}),
|
|
176
177
|
...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
|
|
177
178
|
};
|
|
@@ -227,6 +228,7 @@ async function saveArtifact(col, input) {
|
|
|
227
228
|
...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
|
|
228
229
|
...(input.meta.commentName ? {} : { commentName: FieldValue.delete() }),
|
|
229
230
|
...(input.meta.history ? {} : { history: FieldValue.delete() }),
|
|
231
|
+
...(input.meta.goal ? {} : { goal: FieldValue.delete() }),
|
|
230
232
|
...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
|
|
231
233
|
};
|
|
232
234
|
// `voice: none` is the page declaring it has no audio. An update merges, so the recording it
|
package/lib/reader/index.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming
|
|
|
2
2
|
export { begunCount } from './karaoke.js';
|
|
3
3
|
export { PlayerBar } from './player-bar.js';
|
|
4
4
|
export { QuietView } from './quiet-view.js';
|
|
5
|
+
export { MediaViewer } from './media-viewer.js';
|
|
5
6
|
export { CommentLayer } from './comment-layer.js';
|
|
6
7
|
export { PageMenu, roleLine, menuItems, type PageMenuProps, type PageRole } from './page-menu.js';
|
|
7
8
|
export { SettingsSheet, settingsChanges, ACCESS_LABEL, COMMENTS_LABEL, VISIBLE_LABEL, REOPEN_KEY, type PageSettings, type SettingsFact, type SheetKind, type AccessLevel } from './settings-sheet.js';
|
package/lib/reader/index.js
CHANGED
|
@@ -4,6 +4,7 @@ export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifac
|
|
|
4
4
|
export { begunCount } from './karaoke.js';
|
|
5
5
|
export { PlayerBar } from './player-bar.js';
|
|
6
6
|
export { QuietView } from './quiet-view.js';
|
|
7
|
+
export { MediaViewer } from './media-viewer.js';
|
|
7
8
|
export { CommentLayer } from './comment-layer.js';
|
|
8
9
|
export { PageMenu, roleLine, menuItems } from './page-menu.js';
|
|
9
10
|
export { SettingsSheet, settingsChanges, ACCESS_LABEL, COMMENTS_LABEL, VISIBLE_LABEL, REOPEN_KEY } from './settings-sheet.js';
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function MediaViewer(): import("react").ReactPortal | null;
|