@supersuit/artifacts 0.38.0 → 0.40.1

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,123 @@
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.40.1
7
+
8
+ **0.40.0 was tagged and never published: this is the first release carrying it.** The publish
9
+ workflow runs the tests before it publishes, and one test failed there, so nothing reached npm
10
+ under 0.40.0. Everything in the 0.40.0 entry below ships in this version, unchanged.
11
+
12
+ - The failing test was the comment card's, and the defect was in the tests, not the card: a name
13
+ one test saved was still remembered by the next wherever the runtime has a working
14
+ `localStorage` (Node 24, which the workflow uses) and gone where it does not (Node 25), so
15
+ "asks for a first name before sending" passed on one and failed on the other. Every card test
16
+ now starts with its own empty storage.
17
+
18
+ DETECTOR: `npm view @supersuit/artifacts versions --json` lists no `0.40.0`. A host pinned to
19
+ exactly `0.40.0` cannot install.
20
+
21
+ REMEDY: take `0.40.1`. There is no code difference from what 0.40.0 described.
22
+
23
+ Tests: `src/reader/comment-card.test.tsx` gives each test a fresh storage; the old file fails three
24
+ tests when storage persists between tests, and the new one passes either way.
25
+
26
+ ## 0.40.0
27
+
28
+ **A per-reader page can be a feedback round: a window per reader, a name on every note where the
29
+ page asks for one, a reply the commenter can see.** Nothing changes for a page that uses none of it.
30
+
31
+ - A reader carries `closesAt`. The recipients route sets it (`add[].closes`), moves or clears it
32
+ (`closes: { rids, at }`), and reports it (`closesAt` in the listing and in `/views`). Past it the
33
+ state route refuses a new shared comment or an edit with `403 { closed: true }`; the page and the
34
+ reader's own comments stay readable, and deleting their own still works. `RecipientsStore.setCloses`
35
+ is optional, so a host's own store keeps working (the route answers 501 to a `closes` change
36
+ without it).
37
+ - The page draws it: whose draft, the publisher's note to that reader, and a live countdown, in
38
+ place of the `For <label>: <note>` line. A reader with no window sees what they saw before.
39
+ - `comment_name: required` (front matter, per-reader pages only): a group reader's shared comment
40
+ needs a typed first name (`400 { needsName: true }` without one); a reader on a named link is
41
+ never asked, in the card or the reply form, and a typed name is dropped.
42
+ - Under `comment_name: required`, and only there, **each browser on a group link is its own
43
+ reader**. Until now a group link was one reader (`r:<rid>`): everyone holding it saw every note
44
+ left through it as their own, could edit and delete them, and saw the publisher's reply to any of
45
+ them. With the line, a browser's notes are stored under `r:<rid>.<24 hex>`, which the server
46
+ derives (an HMAC keyed by the recipient secret) from the page, the link's verified rid and the
47
+ anonymous-reader cookie (`artifact_anon`) it sets with that browser's first note. A client cannot
48
+ name a key or work out another browser's, and the stored key is not the cookie. A browser that
49
+ clears its cookies is a new reader. A named link is unchanged, and so is a group link on a page
50
+ without the line (one shared reader, as in 0.39.0). The window, the feed's `reader.rid` and the
51
+ `/views` comments stay per link.
52
+ - `POST /api/artifacts/<id>/reply` (publish key): the publisher's one reply in a thread, optionally
53
+ pinned to the passage that changed. On a per-reader page only the thread's own writer is shown it
54
+ (under `comment_name: required`, only the browser that wrote it). It never reopens a resolved
55
+ thread under `open=1`.
56
+ - A `template: broadcast` page with comments on hands its comment layer the window and the name
57
+ rule, as a document does: a group reader is asked for a first name, and a closed window stops
58
+ offering Share. A broadcast draws no banner.
59
+ - An owner added with `closes` gets no window (the owner never has one).
60
+ - Feed rows gain `reader: { rid, label } | null`, `by` and `publisher`. No field is removed.
61
+
62
+ DETECTOR: on a page with at least one reader,
63
+ `curl -s -H "Authorization: Bearer $KEY" "$HOST/api/artifacts/<id>/recipients" | grep -c closesAt`
64
+ answers 0 on a host older than this (a page with no readers answers 0 on any version). For the
65
+ group link: a host is affected if it has sent one group link to people who should not read each
66
+ other's notes. `GET /api/artifacts/<id>/responses` shows it: rows in the `comments` slot whose
67
+ `reader` is the bare `r:<rid>` of a link `GET /recipients` lists with `group: true` were written as
68
+ the one shared reader.
69
+
70
+ REMEDY: take 0.40.0 and add the reply route file (README, "Replying as the publisher"). A host with
71
+ its own `RecipientsStore` adds `setCloses` to use feedback windows. A publisher that strips unknown
72
+ front matter keys lets `comment_name` through. To give the people on a group link their own notes,
73
+ republish the page with `comment_name: required`; the host needs nothing else (the cookie is the
74
+ one anonymous readers already get). Notes left through that link before the republish stay with
75
+ the publisher and are shown to no browser afterwards. Anything that matched a reader key to a link
76
+ with `key === 'r:' + rid` now reads the rid with `ridOfReaderKey(key)` instead, or it misses the
77
+ notes of a browser on a group link.
78
+
79
+ Tests: `recipients-store.test.ts`, `recipients-api.test.tsx`, `recipients.test.ts`,
80
+ `front-matter.test.ts`, `store.test.ts`, `comments.test.tsx` (closed window, names, reply
81
+ visibility, two browsers on one group link with and without the line), `comments-feed.test.tsx`,
82
+ `feedback-window.test.tsx`, `comment-card.test.tsx`, `comment-layer.test.tsx`,
83
+ `per-reader.test.tsx`, `broadcast-feedback.test.tsx`; `unchanged-default.test.tsx` and
84
+ `broadcast.test.tsx` unedited.
85
+
86
+ ## 0.39.0
87
+
88
+ **A session demo's frame fits its player, and is wider than the reading column on a wide screen.**
89
+ A ```` ```simulator ```` block drew its player in a frame of one fixed height (`height:`, 720px
90
+ unless the block said). A session demo draws a different device for each viewer, a short laptop on
91
+ a desktop and a tall phone on a phone, so one height suited neither: on a desktop a band of
92
+ nothing, some 300px, sat between the laptop and the caption.
93
+
94
+ - The frame now asks its embed page `fit=content` (the address gains `&fit=content`), and a page
95
+ that understands posts `{ type: 'embed:fit', height: <px> }` to its parent. The frame takes that
96
+ height (`EmbedFrame`, a client component; `fitFrom`, `FIT_MESSAGE`): only that message, only from
97
+ the frame's own window and the origin of the page it loaded, held between 240 and 2000px.
98
+ - `height:` still means what it meant: the height the frame opens at, and so the room the player is
99
+ given. A page that posts nothing keeps it. No published page needs a change.
100
+ - From 1024px a framed session demo is up to 64rem wide, centred on the column and clear of the
101
+ contents rail in the left margin from 1280px, with its caption centred under it. In a broadcast's
102
+ card, below 1024px, for the Messages phone, and for a caption with no player, the column is the
103
+ width, as before.
104
+
105
+ DETECTOR: open a page with a ```` ```simulator ```` block on a desktop. Affected: the frame is the
106
+ block's height whatever the device in it, with empty space under a laptop. Or
107
+ `curl -s <page> | grep -o 'simulator/embed[^"]*'` shows no `fit=content`.
108
+
109
+ REMEDY: take 0.39.0. A host whose embed page is not its own does nothing more: an unknown query key
110
+ is ignored and the frame keeps the block's height. A host that serves its own embed page makes it
111
+ answer `fit=content` in two steps, in this order: hold the player at the height the frame opened
112
+ with (inside a frame the viewport IS the frame, so a player sized from it shrinks as the frame
113
+ does and the two chase each other), then post its content's height as `embed:fit` whenever it
114
+ changes. Centre the player in the frame as well, for hosts that never listen.
115
+
116
+ Tests: `simulator.test.ts` (the address asks to fit and the Messages one does not; `fitFrom` takes
117
+ the message from the right origin, refuses every other shape, and clamps), `embed-frame.test.tsx`
118
+ (the frame opens at the block's height, takes a reported one, and ignores another window, origin
119
+ and message), `render.test.tsx` (the opening height with and without `height:`; the wide rule is on
120
+ a framed session demo only, never the Messages phone or a caption with no player). The wide rule
121
+ itself is CSS and is not run by a test here: it was checked in a browser at 390, 1100 and 1440px.
122
+
6
123
  ## 0.38.0
7
124
 
8
125
  **Readers' voice memos live in the private bucket.** A memo left on a page (`comments/<memo>`, or a
package/README.md CHANGED
@@ -253,6 +253,7 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
253
253
  | `definitions` | Terms defined inline (below). A republish without it removes them |
254
254
  | `links` | `per-reader`: the page opens only through a link made for one reader (see Per-reader links). Sticky: a republish whose file leaves the line out changes nothing |
255
255
  | `media` | `private`: the page's body media is kept private and served through short-lived signed URLs. Needs `links: per-reader`. Sticky like `links` |
256
+ | `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` |
256
257
  | `change` | One line saying what this version changed (see Version history) |
257
258
  | `template` | `document` (the default, the read-along reader) or `broadcast` (one message card sent to several people; see Broadcasts) |
258
259
 
@@ -748,6 +749,36 @@ type Ctx = { params: Promise<{ id: string }> }
748
749
  export function POST(request: NextRequest, ctx: Ctx) { return artifacts.RESOLVE(request, ctx) }
749
750
  ```
750
751
 
752
+
753
+ ### Replying as the publisher (0.40.0)
754
+
755
+ POST /api/artifacts/<id>/reply
756
+ Authorization: Bearer <publish key or publisher pass>
757
+ Content-Type: application/json
758
+
759
+ { "entry": "<entryId>", "body": "Named the river in the second paragraph.", "by": "Pat", "quote": "the river Ash" }
760
+
761
+ 200 { id, entry, parent, existing }
762
+
763
+ One reply per thread when calls do not overlap. `entry` may name a reply: the thread it belongs to
764
+ is answered. A second call for a thread already answered changes nothing and names the first reply
765
+ back with `existing: true`, so a caller that retries does not post twice. The check is a read and
766
+ then an append, so two overlapping calls can both post. `by` defaults to "the publisher". `quote`
767
+ (text on the page, at most 500 characters) pins the reply to the passage that changed, and the
768
+ thread then offers "Show what changed"; without it the reply sits where its thread does. On a
769
+ per-reader page the reply is shown to the reader whose comment started the thread and to no other
770
+ reader. It is allowed after that reader's window has closed. On a page that is not per-reader
771
+ and whose comments are owner-only (`comments_visible: owner`), `REPLY` still answers 200 and stores
772
+ the reply, but the commenter is not shown it: only the owner, the admins and the feed are. The feed lists it with
773
+ `publisher: true`, and a publisher reply never reopens a resolved thread under `open=1`. 404 for a
774
+ page or comment that is not there, 501 with no `state`.
775
+
776
+ // app/api/artifacts/[id]/reply/route.ts
777
+ import type { NextRequest } from 'next/server'
778
+ import { artifacts } from '@/lib/artifacts'
779
+ type Ctx = { params: Promise<{ id: string }> }
780
+ export function POST(request: NextRequest, ctx: Ctx) { return artifacts.REPLY(request, ctx) }
781
+
751
782
  ### Telling commenters their comments were resolved (`createResolveDigests`)
752
783
 
753
784
  When threads are resolved, each signed-in reader whose comments were in them can get ONE email per
@@ -800,12 +831,12 @@ appear (the privacy test covers this handler like every other).
800
831
  GET /api/comments?since=2026-09-28T12:00:00Z
801
832
  Authorization: Bearer <publish key>
802
833
 
803
- 200 { now, cursor, more, comments: [ { artifactId, title, entryId, parent, name, quote, region,
804
- body, transcript, audioUrl, at, resolved, link } ] }
834
+ 200 { now, cursor, more, comments: [ { artifactId, title, entryId, parent, name, reader, by, publisher,
835
+ quote, region, body, transcript, audioUrl, at, resolved, link } ] }
805
836
  ```
806
837
 
807
838
  Oldest first, at most 200, strictly after `since` (an ISO 8601 time with a zone; anything else is
808
- a 400). `name` is null for an anonymous reader; `quote` is the text the comment is pinned to, or
839
+ a 400). `name` is null for an anonymous reader; `reader` is `{ rid, label }` for a comment left through a per-reader link, else null; `by` is the name its writer typed, else null; `publisher` is true for a reply written through the reply route; `quote` is the text the comment is pinned to, or
809
840
  null for a box (`region: true`); `parent` is the comment a reply answers; `audioUrl` is a signed
810
841
  URL to the recording, when there is one and the host can sign it. `link` is the page with
811
842
  `#comment-<entryId>`: opening it scrolls to the pin and opens its thread (a reply opens its
@@ -863,7 +894,7 @@ Routes (spread into your route files by `createArtifactRoutes`): `RECIPIENTS`, `
863
894
  | Route | Who | What |
864
895
  |---|---|---|
865
896
  | `GET /api/artifacts/<id>/recipients` | publish key | Every reader, with the working link for each |
866
- | `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group? }], revoke?: [rid] or '*', expires?: ISO time or null }`. `add` answers each new reader's link; `expires: null` is Keep |
897
+ | `POST /api/artifacts/<id>/recipients` | publish key | Body `{ add?: [{ label, note?, group?, closes? }], revoke?: [rid] or '*', expires?: ISO time or null, closes?: { rids: [rid] or '*', at: ISO time or null } }`. `add` answers each new reader's link; `expires: null` is Keep; `closes` sets, moves or clears a reader's feedback window |
867
898
  | `GET /api/artifacts/<id>/views` | publish key | One line per reader: opened, came back, active seconds, plays, devices, comments. A page with no readers yet answers 200 with an empty list; a missing page answers 404 |
868
899
  | `POST /api/artifacts/<id>/seen` | the page itself (public) | The reader's own signal, below |
869
900
 
@@ -879,6 +910,58 @@ A view is the page rendered in a real browser, visible, for at least 2 seconds.
879
910
 
880
911
  `expires` sets the date the page, its media, its readers, their views and their comments go. `sweepExpired()` (call it daily from a cron route) deletes them and returns `{ pages, files, skipped, failed }`: pages removed, media files removed, ids it refused to delete (an expiry that is not a past ISO time, or a page republished without per-reader links), and ids that failed and will be retried by the next run. Your `assets` must provide `deletePage`, or every expired page lands in `failed` and nothing is deleted. With a private bucket configured it deletes from both buckets. A Keep that lands mid-sweep is honored. Deleting a page also removes every prior version of its body.
881
912
 
913
+ ### A feedback window (0.40.0)
914
+
915
+ A reader may carry `closesAt`, the time their feedback window closes. It is per reader, set when
916
+ the reader is added (`add[].closes`) or afterwards (`closes: { rids, at }`, where `rids` is a list
917
+ of reader ids or `'*'` for every reader but the owner, and `at: null` clears the window). The owner
918
+ never has one: `closes` on an `add` entry with `owner: true` is dropped. It is
919
+ separate from the page's `expires`: a reader past their window still opens the page and reads
920
+ their own comments. A time in the past closes the window now, and a window that cannot be read as
921
+ a time counts as closed. `closes` answers `windowed`, the reader ids it changed, beside `added` and
922
+ `revoked`. A host with its own `RecipientsStore` needs `setCloses` for this; without it the route
923
+ answers 501 to a `closes` change.
924
+
925
+ Once the window is closed, a new shared comment or an edit is refused with
926
+ `403 { error: "Feedback on this draft is closed. Your note was not sent.", closed: true }`.
927
+ Deleting their own comment (`remove`) still works. `GET /state` answers
928
+ `feedback: { closesAt, closed }` for a reader who has a window, and `GET /recipients` and
929
+ `GET /views` carry each reader's `closesAt` (null when there is none).
930
+
931
+ The page says so. A reader with a window sees, above the title, "A draft from <owner>, for
932
+ <label>" (a group link names nobody), the note the publisher wrote for them, and how long is left,
933
+ counted in days from two days up, then hours from two hours, then minutes, and live while the page
934
+ stays open. Past it the line reads "Feedback is closed. You can still read the draft and your own
935
+ notes." A reader with no window sees the `For <label>: <note>` line as before. In a thread, a
936
+ comment of theirs that nobody has answered shows "Waiting for a reply." while the window exists.
937
+
938
+ ### A name on every note: `comment_name: required` (0.40.0)
939
+
940
+ A per-reader page that says `comment_name: required` in its front matter puts a name on every
941
+ shared comment. A `group` reader (one link shared by several people) must type a first name:
942
+ without one the state route answers `400 { error: "Add your first name, so your note has a name on
943
+ it.", needsName: true }`, and the comment card, the reply form and a signed-in reader's card all
944
+ ask before they send. A reader on a named link is never asked, and a name sent for them is
945
+ dropped: the label is who they are. The line needs `links: per-reader` (a page without it is
946
+ refused), and a republish that leaves it out removes it. A page without the line takes names
947
+ exactly as before.
948
+
949
+ **Who a group link is.** Without `comment_name: required`, a group link is one shared reader:
950
+ everyone who opens it sees every note left through it as their own, can edit or delete any of
951
+ them, and sees the publisher's reply to any of them. Do not send one to people who should not read
952
+ each other's notes. With the line, each browser on a group link is its own reader: it sees, edits
953
+ and deletes only the notes written from that browser, and is shown only the publisher's replies to
954
+ those. The browser is known by the anonymous-reader cookie (`artifact_anon`, HttpOnly, a year), set
955
+ with its first note, and its notes are stored under a key the server derives from the link and that
956
+ cookie (`r:<rid>.<24 hex>`), never under the cookie itself; `ridOfReaderKey(key)` reads the
957
+ link's rid out of either shape. A browser that clears its cookies, or a
958
+ second device, is a new reader: the notes it left stay with the publisher and are no longer shown
959
+ to it. So does a reader who signs in on another page of the same host, since signing in clears the
960
+ anonymous cookie. Notes left through a group link before the page gained the line were written as
961
+ the one shared reader (`r:<rid>`) and are shown to no browser afterwards. A named link is one
962
+ reader either way. The feedback window, the feed's `reader: { rid, label }` and the `/views` line
963
+ stay per link: every browser on a group link shares them.
964
+
882
965
  ## Broadcasts (`template: broadcast`)
883
966
 
884
967
  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.
@@ -902,7 +985,7 @@ What the kind decides, so the file does not have to:
902
985
  - **Per-reader links, always.** `links: per-reader` is implied. The kind is sticky like `links:`: a republish that leaves `template:` out stays a broadcast, and one that says `document` is refused (409). `access`, `password` and `comments_visible: readers` are refused in words.
903
986
  - **Private media wherever there is media.** `media: private` is implied as soon as the body carries a stored private file or a local one (a remote URL alone stores nothing, so it does not count). A body that points at a local file (one the publisher will upload) must still say `media: private`, because that line is what makes the publisher upload it privately; without it the page is refused before anything is uploaded.
904
987
  - **Expires 90 days after it is published** (`BROADCAST_TTL_MS`), set by `POST /api/artifacts` when the page has no date yet, and answered as `expireAt` in its response. A date set on the recipients route stands, a republish never moves it, and Keep (`expires: null`) holds.
905
- - **Comments off** unless the file turns them on. **No narration player**, ever: a voice note is its own audio (`![Voice note](note.m4a)` draws a player). Write `voice: none` so the publisher does not record one.
988
+ - **Comments off** unless the file turns them on. With them on, a reader's feedback window and `comment_name: required` work as on a document: past the window the card stops offering Share, and a group reader is asked for a first name. A broadcast draws no banner, though: no "A draft from" line, no countdown and no "Feedback is closed" line above the card. **No narration player**, ever: a voice note is its own audio (`![Voice note](note.m4a)` draws a player). Write `voice: none` so the publisher does not record one.
906
989
  - **Silent view tracking**: the quiet view beacon, as on every per-reader page.
907
990
 
908
991
  Routes:
@@ -16,6 +16,12 @@ export declare function mergeCommentsState(state: StateConfig | undefined, comme
16
16
  };
17
17
  export declare const MAX_COMMENT_CHARS = 4000;
18
18
  export declare const MAX_COMMENT_NAME_CHARS = 80;
19
+ /** What a reader is told when their feedback window has closed (0.40.0). The card shows it under the
20
+ * words they typed, which stay in the box. */
21
+ export declare const FEEDBACK_CLOSED = "Feedback on this draft is closed. Your note was not sent.";
22
+ /** What a reader on an open (group) link is told when a comment arrives with no name, on a page
23
+ * that says `comment_name: required` (0.40.0). */
24
+ export declare const NAME_NEEDED = "Add your first name, so your note has a name on it.";
19
25
  export declare const COMMENT_TRANSCRIPTS: readonly ["browser", "host", "typed"];
20
26
  /** One shared comment or personal note. `parent` makes it a reply to a top-level comment (one
21
27
  * level only). `audio` and `transcript` are filled by voice memos; a memo may have an empty body. */
@@ -28,6 +34,9 @@ export type CommentValue = {
28
34
  transcript?: (typeof COMMENT_TRANSCRIPTS)[number];
29
35
  /** The name a reader typed so the owner can credit them. A signed-in reader's account name wins. */
30
36
  by?: string;
37
+ /** On a reply only: the publisher wrote it, through the reply route with the publish key (0.40.0).
38
+ * Never set by a reader: `validateComment` refuses the key. */
39
+ publisher?: true;
31
40
  /** On a thread's top-level comment only: the owner, an admin or the publisher marked the thread
32
41
  * done (0.33.0). Written by the resolve route, never by a reader: `validateComment` refuses the
33
42
  * key, and an edit keeps whatever the comment already carried. */
@@ -15,6 +15,12 @@ export function mergeCommentsState(state, comments, visible) {
15
15
  }
16
16
  export const MAX_COMMENT_CHARS = 4000;
17
17
  export const MAX_COMMENT_NAME_CHARS = 80;
18
+ /** What a reader is told when their feedback window has closed (0.40.0). The card shows it under the
19
+ * words they typed, which stay in the box. */
20
+ export const FEEDBACK_CLOSED = 'Feedback on this draft is closed. Your note was not sent.';
21
+ /** What a reader on an open (group) link is told when a comment arrives with no name, on a page
22
+ * that says `comment_name: required` (0.40.0). */
23
+ export const NAME_NEEDED = 'Add your first name, so your note has a name on it.';
18
24
  export const COMMENT_TRANSCRIPTS = ['browser', 'host', 'typed'];
19
25
  export const MAX_RESOLVE_NOTE_CHARS = 280;
20
26
  /** The resolve route's check on a note: absent, null or blank means no note; otherwise one line of
@@ -0,0 +1,5 @@
1
+ export declare function EmbedFrame({ src, title, height }: {
2
+ src: string;
3
+ title: string;
4
+ height: string;
5
+ }): import("react").JSX.Element;
@@ -0,0 +1,23 @@
1
+ 'use client';
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ // The frame an embed block plays in. It opens at the block's height and, when its embed page
4
+ // reports how tall the player is (simulator.ts, "A frame that fits"), takes that height, so a
5
+ // short device has no empty band under it and a tall one is not cut off.
6
+ import { useEffect, useRef, useState } from 'react';
7
+ import { fitFrom } from './simulator.js';
8
+ export function EmbedFrame({ src, title, height }) {
9
+ const ref = useRef(null);
10
+ const [fit, setFit] = useState(null);
11
+ useEffect(() => {
12
+ const onMessage = (e) => {
13
+ if (!ref.current || e.source !== ref.current.contentWindow)
14
+ return;
15
+ const h = fitFrom(e.data, e.origin, src);
16
+ if (h != null)
17
+ setFit(h);
18
+ };
19
+ window.addEventListener('message', onMessage);
20
+ return () => window.removeEventListener('message', onMessage);
21
+ }, [src]);
22
+ return (_jsx("iframe", { ref: ref, src: src, title: title, loading: "lazy", allow: "microphone; autoplay; fullscreen", className: "block w-full rounded-xl border border-[color:var(--a-line)]", style: { height: fit != null ? `${fit}px` : height, border: 0 } }));
23
+ }
@@ -60,6 +60,9 @@ export type ArtifactMeta = {
60
60
  comments?: CommentsMode;
61
61
  /** Who sees shared comments: `owner` (the default) or every reader (`readers`). */
62
62
  commentsVisible?: CommentsVisible;
63
+ /** `comment_name: required` (0.40.0): on a per-reader page, a comment through a group link must
64
+ * carry a typed first name, and a named link is never asked for one. Opt-in; content, like `toc:`. */
65
+ commentName?: 'required';
63
66
  };
64
67
  /** What a page is. A broadcast is its own kind, not a document with extra flags: it renders as a
65
68
  * message card, opens only through per-reader links, keeps its media private, has comments off
@@ -19,7 +19,7 @@ const PRIVATE_REF_IN_BODY = /^private:/;
19
19
  export const PACK_NAME = /^[a-z0-9][a-z0-9-]{0,39}$/;
20
20
  /** The longest `to:` a page may carry: a name or two, never a sentence. */
21
21
  export const MAX_TO_CHARS = 60;
22
- 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']);
22
+ 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']);
23
23
  export function parseArtifactSource(text,
24
24
  /** The kind a file that leaves `template:` out is read as: the stored page's kind on a
25
25
  * republish, so a broadcast stays one (sticky, like `links:`). Default `document`. */
@@ -87,6 +87,11 @@ opts = {}) {
87
87
  return { ok: false, error: 'media must be private' };
88
88
  meta.privateMedia = true;
89
89
  }
90
+ if (d.comment_name !== undefined && d.comment_name !== null) {
91
+ if (d.comment_name !== 'required')
92
+ return { ok: false, error: 'comment_name must be required' };
93
+ meta.commentName = 'required';
94
+ }
90
95
  if (d.theme !== undefined) {
91
96
  if (!THEME_MODES.includes(d.theme))
92
97
  return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
@@ -145,6 +150,8 @@ opts = {}) {
145
150
  // People on one broadcast never see each other's comments, so a per-reader page cannot share them.
146
151
  if (meta.links === 'per-reader' && meta.commentsVisible === 'readers')
147
152
  return { ok: false, error: 'a per-reader page keeps each reader\'s comments to the owner: drop comments_visible: readers' };
153
+ if (meta.commentName && meta.links !== 'per-reader')
154
+ return { ok: false, error: 'comment_name: required needs links: per-reader' };
148
155
  // Widgets declare their own slots, so a notes block (and a checklist with send) is merged into state: here, before the
149
156
  // page is stored, and the state API and the republish shape checks see it like any slot.
150
157
  // Errors name the line in the FILE: the body's lines are counted after the front matter's.
@@ -13,7 +13,7 @@ export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeCh
13
13
  export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
14
14
  export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
15
15
  export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
16
- export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, EMBED_KINDS, type SimulatorBlock, type EmbedBlock, type EmbedKind } from './simulator.js';
16
+ export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, fitFrom, FIT_MESSAGE, EMBED_KINDS, type SimulatorBlock, type EmbedBlock, type EmbedKind } from './simulator.js';
17
17
  export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence, type ChecklistBlock, type ChecklistItem, type ChecklistValue } from './checklist.js';
18
18
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
19
19
  export { createResolveDigests, noticesFor, composeDigest, resolverName, itemsOf, emailKey, burstKey, stopToken, verifyStopToken, maskEmail, RESOLVE_DIGEST_QUIET_MS, RESOLVE_DIGEST_MAX_WAIT_MS, RESOLVE_DIGEST_LEASE_MS, RESOLVE_DIGEST_MAX_ATTEMPTS, RESOLVE_DIGEST_STOP_PATH, DIGEST_BODY_CHARS, DIGEST_QUOTE_CHARS, type ResolveEvent, type DigestItem, type Burst, type Digest, type DigestStatus, type ResolveDigestStore, type DigestMessage, type DigestSender, type ResolveDigestOptions, type ComposeOptions, type FlushReport } from './resolve-digest.js';
@@ -24,6 +24,6 @@ export { deepgramTranscriber, openaiTranscriber, type Transcriber } from './tran
24
24
  export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
25
25
  export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey, type DeclaredSettings, type LiveSettings, type SettingsPatch } from './settings.js';
26
26
  export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
27
- export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
27
+ export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
28
28
  export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary } from './recipients-store.js';
29
29
  export { migrateMemosToPrivate, MEMO_KEY, type MemoMigration } from './migrate-memos.js';
@@ -13,7 +13,7 @@ export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeCh
13
13
  export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
14
14
  export { stateView, responsesOf, responsesCsv } from './state-view.js';
15
15
  export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
16
- export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, EMBED_KINDS } from './simulator.js';
16
+ export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, fitFrom, FIT_MESSAGE, EMBED_KINDS } from './simulator.js';
17
17
  export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence } from './checklist.js';
18
18
  export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
19
19
  export { createResolveDigests, noticesFor, composeDigest, resolverName, itemsOf, emailKey, burstKey, stopToken, verifyStopToken, maskEmail, RESOLVE_DIGEST_QUIET_MS, RESOLVE_DIGEST_MAX_WAIT_MS, RESOLVE_DIGEST_LEASE_MS, RESOLVE_DIGEST_MAX_ATTEMPTS, RESOLVE_DIGEST_STOP_PATH, DIGEST_BODY_CHARS, DIGEST_QUOTE_CHARS } from './resolve-digest.js';
@@ -24,6 +24,6 @@ export { deepgramTranscriber, openaiTranscriber } from './transcribe.js';
24
24
  export { AUDIO_TYPES, audioExtFor, audioTypeFor, baseMime, MAX_AUDIO_BYTES, MAX_MEMO_MS, MEMO_NAME, commentAudioPath, isCommentAudioPath, personalAudioPath, isPersonalAudioPath } from './audio.js';
25
25
  export { settleOnPublish, applySettingsPatch, withComments, declaredOf, passwordDigest, newPageKey } from './settings.js';
26
26
  // Per-reader links: the host-facing surface only. The signing and token primitives stay internal.
27
- export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
27
+ export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN, ridOfReaderKey } from './recipients.js';
28
28
  export { createRecipientsStore, memoryRecipientsStore, summarizeViews } from './recipients-store.js';
29
29
  export { migrateMemosToPrivate, MEMO_KEY } from './migrate-memos.js';
@@ -7,6 +7,7 @@ export type Recipient = {
7
7
  group?: true;
8
8
  addedAt: string;
9
9
  revokedAt?: string;
10
+ closesAt?: string | null;
10
11
  };
11
12
  export type RecipientsRecord = {
12
13
  recipients: Record<string, Recipient>;
@@ -29,6 +30,7 @@ export type RecipientSummary = {
29
30
  label: string;
30
31
  group: boolean;
31
32
  revoked: boolean;
33
+ closesAt: string | null;
32
34
  opened: boolean;
33
35
  firstOpenAt: string | null;
34
36
  cameBack: boolean;
@@ -40,15 +42,21 @@ export type RecipientSummary = {
40
42
  text: string;
41
43
  }[];
42
44
  };
45
+ type NewRecipient = {
46
+ label: string;
47
+ note?: string;
48
+ owner?: boolean;
49
+ group?: boolean;
50
+ closesAt?: string;
51
+ };
43
52
  export interface RecipientsStore {
44
53
  get(artifactId: string): Promise<RecipientsRecord | null>;
45
- add(artifactId: string, input: {
46
- label: string;
47
- note?: string;
48
- owner?: boolean;
49
- group?: boolean;
50
- }[]): Promise<Recipient[]>;
54
+ add(artifactId: string, input: NewRecipient[]): Promise<Recipient[]>;
51
55
  revoke(artifactId: string, rids: string[] | '*'): Promise<string[]>;
56
+ /** Set (an ISO time) or clear (null) the feedback window of the named readers, or of every reader
57
+ * with '*'. Never the owner's. Returns the rids changed. Optional so a host's own store keeps
58
+ * working; without it the recipients route answers 501 to a `closes` change. */
59
+ setCloses?(artifactId: string, rids: string[] | '*', closesAt: string | null): Promise<string[]>;
52
60
  setExpiry(artifactId: string, expireAt: string | null): Promise<void>;
53
61
  useToken(key: string, expiresAt: number): Promise<boolean>;
54
62
  recordView(v: {
@@ -73,3 +81,4 @@ export declare function summarizeViews(rec: RecipientsRecord, views: ViewDoc[],
73
81
  at: string;
74
82
  text: string;
75
83
  }[]): RecipientSummary[];
84
+ export {};
@@ -1,4 +1,4 @@
1
- import { newRid } from './recipients.js';
1
+ import { newRid, ridOfReaderKey } from './recipients.js';
2
2
  export const CAME_BACK_MS = 3_600_000;
3
3
  const LABEL_MAX = 60;
4
4
  const NOTE_MAX = 2000;
@@ -13,7 +13,9 @@ function makeRecipients(input, taken) {
13
13
  rid = newRid();
14
14
  taken.add(rid);
15
15
  const note = typeof i.note === 'string' && i.note.trim() ? i.note.trim().slice(0, NOTE_MAX) : undefined;
16
- return { rid, label, addedAt: at, ...(note ? { note } : {}), ...(i.owner ? { owner: true } : {}), ...(i.group ? { group: true } : {}) };
16
+ // The owner never carries a window: setCloses skips them and the page hides the closed state
17
+ // from them, so one stored here would offer Share on their link and then refuse it.
18
+ return { rid, label, addedAt: at, ...(note ? { note } : {}), ...(i.owner ? { owner: true } : {}), ...(i.group ? { group: true } : {}), ...(typeof i.closesAt === 'string' && !i.owner ? { closesAt: i.closesAt } : {}) };
17
19
  });
18
20
  }
19
21
  function revokeIn(rec, rids) {
@@ -29,6 +31,16 @@ function revokeIn(rec, rids) {
29
31
  rec.revokedAll = at;
30
32
  return hit;
31
33
  }
34
+ function closeIn(rec, rids, closesAt) {
35
+ const hit = [];
36
+ for (const r of Object.values(rec.recipients)) {
37
+ if (r.owner || (rids !== '*' && !rids.includes(r.rid)))
38
+ continue;
39
+ r.closesAt = closesAt;
40
+ hit.push(r.rid);
41
+ }
42
+ return hit;
43
+ }
32
44
  function addView(prev, v) {
33
45
  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: {} };
34
46
  doc.activeSeconds += Math.max(0, Math.min(60, v.addSeconds));
@@ -54,6 +66,7 @@ export function memoryRecipientsStore() {
54
66
  return structuredClone(out);
55
67
  },
56
68
  async revoke(id, rids) { const rec = recs.get(id); return rec ? revokeIn(rec, rids) : []; },
69
+ async setCloses(id, rids, closesAt) { const rec = recs.get(id); return rec ? closeIn(rec, rids, closesAt) : []; },
57
70
  async setExpiry(id, expireAt) {
58
71
  const rec = recs.get(id) ?? { recipients: {}, expireAt: null, keep: false };
59
72
  rec.expireAt = expireAt;
@@ -105,6 +118,18 @@ export function createRecipientsStore(db, base) {
105
118
  await recsCol().doc(id).set(patch, { merge: true });
106
119
  return hit;
107
120
  },
121
+ async setCloses(id, rids, closesAt) {
122
+ const rec = await read(id);
123
+ if (!rec)
124
+ return [];
125
+ const hit = closeIn(rec, rids, closesAt);
126
+ if (!hit.length)
127
+ return hit;
128
+ // A merge writes only this field on these readers: a revoke or an expiry that landed after the
129
+ // read above is left alone. `null` clears the window (read back as no window).
130
+ await recsCol().doc(id).set({ recipients: Object.fromEntries(hit.map((rid) => [rid, { closesAt }])) }, { merge: true });
131
+ return hit;
132
+ },
108
133
  async setExpiry(id, expireAt) {
109
134
  await recsCol().doc(id).set({ expireAt, keep: expireAt === null }, { merge: true });
110
135
  },
@@ -147,14 +172,14 @@ export function summarizeViews(rec, views, comments) {
147
172
  const mine = views.filter((v) => v.rid === r.rid).sort((a, b) => a.firstAt.localeCompare(b.firstAt));
148
173
  const firsts = mine.map((v) => Date.parse(v.firstAt));
149
174
  return {
150
- rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt,
175
+ rid: r.rid, label: r.label, group: r.group === true, revoked: !!r.revokedAt, closesAt: r.closesAt ?? null,
151
176
  opened: mine.length > 0,
152
177
  firstOpenAt: mine[0]?.firstAt ?? null,
153
178
  cameBack: firsts.length > 1 && firsts[firsts.length - 1] - firsts[0] >= CAME_BACK_MS,
154
179
  activeSeconds: mine.reduce((n, v) => n + v.activeSeconds, 0),
155
180
  plays: mine.reduce((n, v) => n + Object.values(v.plays).reduce((a, b) => a + b, 0), 0),
156
181
  devices: new Set(mine.map((v) => v.device)).size,
157
- comments: comments.filter((c) => c.readerKey === `r:${r.rid}`).map((c) => ({ at: c.at, text: c.text })),
182
+ comments: comments.filter((c) => ridOfReaderKey(c.readerKey) === r.rid).map((c) => ({ at: c.at, text: c.text })),
158
183
  };
159
184
  });
160
185
  }
@@ -25,6 +25,15 @@ export declare function verifyRenderToken(secret: string, id: string, rid: strin
25
25
  why: string;
26
26
  };
27
27
  export declare function beatKey(secret: string, id: string, rid: string, session: string): string;
28
+ /** One browser on a group link, as the 24 hex characters its reader key ends in (0.40.0). Derived
29
+ * here from the page, the link's verified rid and the anonymous id this server handed that browser,
30
+ * keyed by the recipient secret: a client cannot compute another browser's, and the stored key is
31
+ * not the cookie, so reading a key (the publisher's exports carry it) never lets anyone write as
32
+ * that reader. */
33
+ export declare function browserIdFor(secret: string, id: string, rid: string, anon: string): string;
34
+ /** The link a reader key was written through: `r:<rid>` (one link, one reader) or
35
+ * `r:<rid>.<browser>` (one browser on a group link). Null for any other key. */
36
+ export declare function ridOfReaderKey(key: string): string | null;
28
37
  /** Messaging apps' and search engines' preview fetchers. Apple's LinkPresentation presents as
29
38
  * `facebookexternalhit/1.1 Facebot Twitterbot/1.0`. A request with no user agent is treated as one. */
30
39
  export declare const UNFURL_AGENTS: RegExp;
@@ -77,6 +77,22 @@ export function verifyRenderToken(secret, id, rid, token, now = Date.now()) {
77
77
  export function beatKey(secret, id, rid, session) {
78
78
  return mac(secret, `beat\n${id}\n${rid}\n${session}`);
79
79
  }
80
+ /** One browser on a group link, as the 24 hex characters its reader key ends in (0.40.0). Derived
81
+ * here from the page, the link's verified rid and the anonymous id this server handed that browser,
82
+ * keyed by the recipient secret: a client cannot compute another browser's, and the stored key is
83
+ * not the cookie, so reading a key (the publisher's exports carry it) never lets anyone write as
84
+ * that reader. */
85
+ export function browserIdFor(secret, id, rid, anon) {
86
+ if (!secret)
87
+ throw new Error('a signing secret is required');
88
+ return createHmac('sha256', secret).update(`browser\n${id}\n${rid}\n${anon}`).digest('hex').slice(0, 24);
89
+ }
90
+ const RECIPIENT_KEY = /^r:([a-z0-9]{12})(\.[0-9a-f]{24})?$/;
91
+ /** The link a reader key was written through: `r:<rid>` (one link, one reader) or
92
+ * `r:<rid>.<browser>` (one browser on a group link). Null for any other key. */
93
+ export function ridOfReaderKey(key) {
94
+ return RECIPIENT_KEY.exec(key)?.[1] ?? null;
95
+ }
80
96
  /** Messaging apps' and search engines' preview fetchers. Apple's LinkPresentation presents as
81
97
  * `facebookexternalhit/1.1 Facebot Twitterbot/1.0`. A request with no user agent is treated as one. */
82
98
  export const UNFURL_AGENTS = /facebookexternalhit|facebot|twitterbot|slackbot|whatsapp|linkedinbot|discordbot|telegrambot|skypeuripreview|redditbot|applebot|googlebot|bingbot|embedly|iframely|pinterest|bitlybot|vkshare|mastodon|preview/i;
@@ -17,6 +17,7 @@ import { blocksOf, checklistSyncs, checklistsOf, hasNotesWidget, headingsOf } fr
17
17
  import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
18
18
  import { Checklist } from '../widgets/checklist.js';
19
19
  import { embedSrc, parseEmbedFence } from './simulator.js';
20
+ import { EmbedFrame } from './embed-frame.js';
20
21
  import { VideoAutoplay } from '../reader/video-autoplay.js';
21
22
  import { CodeCopy } from '../reader/code-copy.js';
22
23
  import { DefinitionLayer } from '../reader/definition-layer.js';
@@ -110,6 +111,17 @@ function InlineAudio({ src, label }) {
110
111
  const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio data-artifact-media="${escapeAttr(mediaName(src))}" controls preload="none" class="block w-full"></audio>`;
111
112
  return (_jsxs("span", { className: "my-6 block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3", children: [label ? _jsx("span", { className: "mb-2 block text-sm text-[color:var(--a-strong)]", children: label }) : null, _jsx("span", { className: "block", dangerouslySetInnerHTML: { __html: html } })] }));
112
113
  }
114
+ // A session demo on a wide screen is wider than the reading column, so the device it draws (a
115
+ // laptop, or a phone beside what was made) has room: up to 64rem, centred on the column, and clear
116
+ // of the contents rail fixed in the left margin from 1280px (w-56 at left-6, w-64 at left-12).
117
+ // Below 1024px, in a broadcast's card, for the Messages phone, and for a caption with no player
118
+ // over it (a host that names no embed page), the column is the width.
119
+ const WIDE = '[data-artifact-embed="simulator"][data-wide]';
120
+ const WIDE_EMBED_CSS = `@media (min-width:1024px){${WIDE}{width:max(100%,min(64rem,calc(100vw - 4rem)));margin-left:50%;transform:translateX(-50%)}` +
121
+ `${WIDE} figcaption{text-align:center}` +
122
+ `[data-broadcast] ${WIDE}{width:auto;margin-left:0;transform:none}[data-broadcast] ${WIDE} figcaption{text-align:inherit}}` +
123
+ `@media (min-width:1280px){${WIDE}{width:max(100%,min(64rem,calc(100vw - 36rem)))}}` +
124
+ `@media (min-width:1536px){${WIDE}{width:max(100%,min(64rem,calc(100vw - 40rem)))}}`;
113
125
  /** An embed block (```simulator, ```messages): the player in a frame when the host names one, its
114
126
  * caption alone when it does not, and nothing when the block does not parse (a page published
115
127
  * before validation existed). */
@@ -118,7 +130,8 @@ function EmbedFence({ kind, source, ctx }) {
118
130
  if (!r.ok)
119
131
  return null;
120
132
  const src = ctx ? embedSrc(kind, ctx.embed, r.block.value) : null;
121
- return (_jsxs("figure", { "data-artifact-embed": kind, ...(kind === 'simulator' ? { 'data-artifact-simulator': '' } : {}), className: "my-8", children: [src ? (_jsx("iframe", { src: src, title: r.block.caption, loading: "lazy", allow: "microphone; autoplay; fullscreen", className: "block w-full rounded-xl border border-[color:var(--a-line)]", style: { height: r.block.height, border: 0 } })) : null, _jsx("figcaption", { className: "mt-2 text-sm opacity-80", children: r.block.caption })] }));
133
+ const wide = kind === 'simulator' && src != null;
134
+ return (_jsxs("figure", { "data-artifact-embed": kind, ...(kind === 'simulator' ? { 'data-artifact-simulator': '' } : {}), ...(wide ? { 'data-wide': '' } : {}), className: "my-8", children: [wide ? _jsx("style", { children: WIDE_EMBED_CSS }) : null, src ? _jsx(EmbedFrame, { src: src, title: r.block.caption, height: r.block.height }) : null, _jsx("figcaption", { className: "mt-2 text-sm opacity-80", children: r.block.caption })] }));
122
135
  }
123
136
  /** The one image a paragraph holds and nothing else (whitespace aside), or null. */
124
137
  function soleImage(node) {