@supersuit/artifacts 0.37.0 → 0.39.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,66 @@
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.39.0
7
+
8
+ **A session demo's frame fits its player, and is wider than the reading column on a wide screen.**
9
+ A ```` ```simulator ```` block drew its player in a frame of one fixed height (`height:`, 720px
10
+ unless the block said). A session demo draws a different device for each viewer, a short laptop on
11
+ a desktop and a tall phone on a phone, so one height suited neither: on a desktop a band of
12
+ nothing, some 300px, sat between the laptop and the caption.
13
+
14
+ - The frame now asks its embed page `fit=content` (the address gains `&fit=content`), and a page
15
+ that understands posts `{ type: 'embed:fit', height: <px> }` to its parent. The frame takes that
16
+ height (`EmbedFrame`, a client component; `fitFrom`, `FIT_MESSAGE`): only that message, only from
17
+ the frame's own window and the origin of the page it loaded, held between 240 and 2000px.
18
+ - `height:` still means what it meant: the height the frame opens at, and so the room the player is
19
+ given. A page that posts nothing keeps it. No published page needs a change.
20
+ - From 1024px a framed session demo is up to 64rem wide, centred on the column and clear of the
21
+ contents rail in the left margin from 1280px, with its caption centred under it. In a broadcast's
22
+ card, below 1024px, for the Messages phone, and for a caption with no player, the column is the
23
+ width, as before.
24
+
25
+ DETECTOR: open a page with a ```` ```simulator ```` block on a desktop. Affected: the frame is the
26
+ block's height whatever the device in it, with empty space under a laptop. Or
27
+ `curl -s <page> | grep -o 'simulator/embed[^"]*'` shows no `fit=content`.
28
+
29
+ REMEDY: take 0.39.0. A host whose embed page is not its own does nothing more: an unknown query key
30
+ is ignored and the frame keeps the block's height. A host that serves its own embed page makes it
31
+ answer `fit=content` in two steps, in this order: hold the player at the height the frame opened
32
+ with (inside a frame the viewport IS the frame, so a player sized from it shrinks as the frame
33
+ does and the two chase each other), then post its content's height as `embed:fit` whenever it
34
+ changes. Centre the player in the frame as well, for hosts that never listen.
35
+
36
+ Tests: `simulator.test.ts` (the address asks to fit and the Messages one does not; `fitFrom` takes
37
+ the message from the right origin, refuses every other shape, and clamps), `embed-frame.test.tsx`
38
+ (the frame opens at the block's height, takes a reported one, and ignores another window, origin
39
+ and message), `render.test.tsx` (the opening height with and without `height:`; the wide rule is on
40
+ a framed session demo only, never the Messages phone or a caption with no player). The wide rule
41
+ itself is CSS and is not run by a test here: it was checked in a browser at 390, 1100 and 1440px.
42
+
43
+ ## 0.38.0
44
+
45
+ **Readers' voice memos live in the private bucket.** A memo left on a page (`comments/<memo>`, or a
46
+ reader's own `personal/<dir>/<memo>`) was written to the PUBLIC bucket. Every URL the package handed
47
+ out was signed, but that bucket carries a bucket-wide public read grant, so the memo itself answered
48
+ an unsigned GET to anyone holding its path. Memos are now written to and read from the private
49
+ bucket (`privateBucket`), under the same key. A host with no private bucket defines no memo method,
50
+ so memos stay on the reader's device instead of going anywhere public.
51
+
52
+ DETECTOR: an unsigned `curl -o /dev/null -w '%{http_code}'` of
53
+ `https://storage.googleapis.com/<public bucket>/<prefix>/<id>/comments/<memo>.webm` answers 200.
54
+
55
+ REMEDY: take 0.38.0, then move what is already there:
56
+ `node scripts/migrate-memos-private.mjs --public <bucket> --private <bucket> --prefix tenants/<t>/artifacts`
57
+ (copies each memo to the same key in the private bucket, confirms it arrived, then deletes the
58
+ public one; safe to re-run; `--dry-run` lists). Until a memo is moved the package reads it from the
59
+ public bucket, so nothing stops playing during the move. The same unsigned GET then answers 403.
60
+
61
+ Tests: `assets.test.ts` (memos sign, size, read and play in the private bucket and never touch the
62
+ public one; an unmoved memo is still read; no private bucket, no memo methods),
63
+ `migrate-memos.test.ts` (moves only memos, never deletes a public memo whose copy did not arrive,
64
+ dry run, same bucket refused).
65
+
6
66
  ## 0.37.0
7
67
 
8
68
  **A broadcast is its own page kind.** `template: broadcast` in front matter makes a page one
package/README.md CHANGED
@@ -673,12 +673,13 @@ recognition, the words appear in the box as the reader speaks. A memo stops at t
673
673
 
674
674
  Recordings go up through the same `uploads/<name>` route the publisher uses, to
675
675
  `<page>/comments/<memo>.<ext>` or, for a signed-in reader's personal note,
676
- `<page>/personal/<dir>/<memo>.<ext>`. They are never made public: playback is a signed URL
677
- issued beside the comment or note it belongs to. That needs the four audio methods
678
- `createArtifactAssets` provides (`signAudioUpload`, `audioSize`, `readAudio`, `audioUrl`); a host
679
- with its own `ArtifactAssets` and none of them keeps memos on the reader's device. On a bucket
680
- with bucket-wide public read, a recording is readable by anyone holding its path (the paths are
681
- unguessable); keep the bucket per-object public to have the signed-URL guarantee.
676
+ `<page>/personal/<dir>/<memo>.<ext>`, in the PRIVATE bucket (`privateBucket`, the same one
677
+ `media: private` uses). They are never made public: playback is a signed URL, valid about an hour,
678
+ issued beside the comment or note it belongs to, and an unsigned GET is refused (403). That needs the
679
+ four audio methods `createArtifactAssets` provides (`signAudioUpload`, `audioSize`, `readAudio`,
680
+ `audioUrl`), which it defines only when it is given a private bucket; a host without one, or with
681
+ its own `ArtifactAssets` and none of them, keeps memos on the reader's device. Memos recorded before
682
+ 0.38.0 sat in the public bucket: `scripts/migrate-memos-private.mjs` moves them (see CHANGELOG).
682
683
 
683
684
  To transcribe on the server, pass `transcribe` and add the route. The key stays on the server;
684
685
  without `transcribe` the route answers 404 and readers keep the browser's live transcript (or an
@@ -108,12 +108,44 @@ export function createArtifactAssets(bucket, prefix, options = {}) {
108
108
  }
109
109
  return `https://storage.googleapis.com/${bucket.name}/${key}`;
110
110
  },
111
+ async deletePage(id) {
112
+ // An empty or path-shaped id would make the prefix wider than one page.
113
+ if (!ARTIFACT_ID_RE.test(id))
114
+ throw new Error('not an artifact id');
115
+ let n = 0;
116
+ for (const b of priv ? [bucket, priv] : [bucket]) {
117
+ const [files] = await b.getFiles({ prefix: `${clean}/${id}/` });
118
+ for (const f of files)
119
+ await f.delete();
120
+ n += files.length;
121
+ }
122
+ return n;
123
+ },
124
+ };
125
+ if (!priv)
126
+ return assets;
127
+ const privateFile = (id, stored) => priv.file(`${clean}/${id}/private/${stored}`);
128
+ // READERS' VOICE MEMOS LIVE IN THE PRIVATE BUCKET (0.38.0). Until then they went to the public
129
+ // bucket, unlisted and read through signed URLs, but a bucket-wide public read grant served any
130
+ // memo to anyone holding its path, unsigned. A memo recorded before 0.38.0 is still read from the
131
+ // public bucket until scripts/migrate-memos-private.mjs moves it; nothing new is written there.
132
+ const memo = (id, path) => priv.file(`${clean}/${id}/${path}`);
133
+ const legacyMemo = (id, path) => bucket.file(`${clean}/${id}/${path}`);
134
+ const memoFile = async (id, path) => {
135
+ const f = memo(id, path);
136
+ if ((await f.exists())[0])
137
+ return f;
138
+ const old = legacyMemo(id, path);
139
+ return (await old.exists())[0] ? old : null;
140
+ };
141
+ return {
142
+ ...assets,
111
143
  // The size cap rides in the signature (`x-goog-content-length-range`), so the bucket itself
112
144
  // refuses an oversize body; the browser must send the returned headers verbatim.
113
145
  async signAudioUpload(id, path, contentType) {
114
146
  const range = `0,${MAX_AUDIO_BYTES}`;
115
147
  const headers = { 'content-type': contentType, 'cache-control': 'private, max-age=3600', 'x-goog-content-length-range': range };
116
- const [uploadUrl] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({
148
+ const [uploadUrl] = await memo(id, path).getSignedUrl({
117
149
  version: 'v4',
118
150
  action: 'write',
119
151
  expires: Date.now() + 15 * 60 * 1000,
@@ -123,44 +155,24 @@ export function createArtifactAssets(bucket, prefix, options = {}) {
123
155
  return { uploadUrl, headers };
124
156
  },
125
157
  async audioSize(id, path) {
126
- const file = bucket.file(`${clean}/${id}/${path}`);
127
- const [there] = await file.exists();
128
- if (!there)
158
+ const file = await memoFile(id, path);
159
+ if (!file)
129
160
  return null;
130
161
  const [meta] = await file.getMetadata();
131
162
  return Number(meta.size ?? 0);
132
163
  },
133
164
  async readAudio(id, path) {
134
- const file = bucket.file(`${clean}/${id}/${path}`);
135
- const [there] = await file.exists();
136
- if (!there)
165
+ const file = await memoFile(id, path);
166
+ if (!file)
137
167
  return null;
138
168
  const [bytes] = await file.download();
139
169
  return bytes;
140
170
  },
141
171
  async audioUrl(id, path) {
142
- const [url] = await bucket.file(`${clean}/${id}/${path}`).getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
172
+ const file = (await memoFile(id, path)) ?? memo(id, path);
173
+ const [url] = await file.getSignedUrl({ version: 'v4', action: 'read', expires: Date.now() + 60 * 60 * 1000 });
143
174
  return url;
144
175
  },
145
- async deletePage(id) {
146
- // An empty or path-shaped id would make the prefix wider than one page.
147
- if (!ARTIFACT_ID_RE.test(id))
148
- throw new Error('not an artifact id');
149
- let n = 0;
150
- for (const b of priv ? [bucket, priv] : [bucket]) {
151
- const [files] = await b.getFiles({ prefix: `${clean}/${id}/` });
152
- for (const f of files)
153
- await f.delete();
154
- n += files.length;
155
- }
156
- return n;
157
- },
158
- };
159
- if (!priv)
160
- return assets;
161
- const privateFile = (id, stored) => priv.file(`${clean}/${id}/private/${stored}`);
162
- return {
163
- ...assets,
164
176
  async putPrivate(id, name, bytes, contentType) {
165
177
  const stored = hashedName(name, bytes);
166
178
  await privateFile(id, stored).save(bytes, { contentType, resumable: false, metadata: { cacheControl: 'private, max-age=3600' } });
@@ -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
+ }
@@ -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';
@@ -26,3 +26,4 @@ export { settleOnPublish, applySettingsPatch, withComments, declaredOf, password
26
26
  export type { PageSettings, SettingsFact, AccessLevel } from '../reader/settings-sheet.js';
27
27
  export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
28
28
  export { createRecipientsStore, memoryRecipientsStore, summarizeViews, type RecipientsStore, type Recipient, type RecipientsRecord, type ViewDoc, type RecipientSummary } from './recipients-store.js';
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';
@@ -26,3 +26,4 @@ export { settleOnPublish, applySettingsPatch, withComments, declaredOf, password
26
26
  // Per-reader links: the host-facing surface only. The signing and token primitives stay internal.
27
27
  export { ridLink, parseRidParam, verifyRid, isUnfurlAgent, usableSecret, RECIPIENT_SECRET_MIN } from './recipients.js';
28
28
  export { createRecipientsStore, memoryRecipientsStore, summarizeViews } from './recipients-store.js';
29
+ export { migrateMemosToPrivate, MEMO_KEY } from './migrate-memos.js';
@@ -0,0 +1,14 @@
1
+ import type { Bucket } from '@google-cloud/storage';
2
+ /** A reader's memo, by key under the prefix: `<id>/comments/<memo>.<ext>` or `<id>/personal/<dir>/<memo>.<ext>`. */
3
+ export declare const MEMO_KEY: RegExp;
4
+ export interface MemoMigration {
5
+ moved: string[];
6
+ alreadyPrivate: string[];
7
+ failed: {
8
+ key: string;
9
+ error: string;
10
+ }[];
11
+ }
12
+ export declare function migrateMemosToPrivate(bucket: Bucket, privateBucket: Bucket, prefix: string, { dryRun }?: {
13
+ dryRun?: boolean | undefined;
14
+ }): Promise<MemoMigration>;
@@ -0,0 +1,36 @@
1
+ /** A reader's memo, by key under the prefix: `<id>/comments/<memo>.<ext>` or `<id>/personal/<dir>/<memo>.<ext>`. */
2
+ export const MEMO_KEY = /^[^/]+\/(?:comments\/[^/]+|personal\/[^/]+\/[^/]+)\.(?:webm|m4a|mp4|ogg|wav)$/;
3
+ export async function migrateMemosToPrivate(bucket, privateBucket, prefix, { dryRun = false } = {}) {
4
+ if (privateBucket.name === bucket.name)
5
+ throw new Error('the private bucket must be a different bucket from the public one');
6
+ const clean = prefix.replace(/^\/+|\/+$/g, '');
7
+ const [files] = await bucket.getFiles({ prefix: `${clean}/` });
8
+ const out = { moved: [], alreadyPrivate: [], failed: [] };
9
+ for (const f of files) {
10
+ const rel = f.name.slice(clean.length + 1);
11
+ if (!MEMO_KEY.test(rel))
12
+ continue;
13
+ try {
14
+ const dest = privateBucket.file(f.name);
15
+ if ((await dest.exists())[0]) {
16
+ if (!dryRun)
17
+ await f.delete();
18
+ out.alreadyPrivate.push(f.name);
19
+ continue;
20
+ }
21
+ if (dryRun) {
22
+ out.moved.push(f.name);
23
+ continue;
24
+ }
25
+ await f.copy(dest);
26
+ if (!(await dest.exists())[0])
27
+ throw new Error('the copy did not arrive in the private bucket');
28
+ await f.delete();
29
+ out.moved.push(f.name);
30
+ }
31
+ catch (e) {
32
+ out.failed.push({ key: f.name, error: e.message });
33
+ }
34
+ }
35
+ return out;
36
+ }
@@ -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) {
@@ -32,3 +32,10 @@ export declare function parseSimulatorFence(value: string, line: number): {
32
32
  error: string;
33
33
  };
34
34
  export declare const simulatorSrc: (embed: string, scenario: string) => string | null;
35
+ /** The message an embed page sends its frame, and the heights a frame will take. */
36
+ export declare const FIT_MESSAGE = "embed:fit";
37
+ export declare const FIT_MIN = 240;
38
+ export declare const FIT_MAX = 2000;
39
+ /** The height (px) a frame takes from a message, or null: only `embed:fit` with a finite number,
40
+ * only from the origin of the page the frame loaded, and never outside FIT_MIN..FIT_MAX. */
41
+ export declare function fitFrom(data: unknown, origin: string, src: string): number | null;
@@ -11,8 +11,16 @@
11
11
  // (ArtifactRoutesConfig.simulatorEmbed / messagesEmbed), so this public package points at nobody's
12
12
  // service by default, and a host without the player draws the caption alone rather than the block's
13
13
  // source as code. `simulator` keeps the syntax the Superskills book's reader takes.
14
+ //
15
+ // A FRAME THAT FITS (0.39.0). `height:` is the height a frame OPENS at, and the room its player is
16
+ // given. A session demo draws a different device for each viewer (a short laptop on a desktop, a
17
+ // tall phone on a phone), so no one height suits both: the frame asked its embed page `fit=content`,
18
+ // and a page that understands posts `{ type: 'embed:fit', height: <px> }` to its parent, which the
19
+ // frame then takes (EmbedFrame). A page that posts nothing keeps the block's height. The embed
20
+ // page must hold its player's size once asked, never size it from its own viewport, or the two
21
+ // chase each other; that is why the frame listens only for this message and for no player's own.
14
22
  const KINDS = {
15
- simulator: { key: 'scenario', required: true, fixed: { timeline: 'off' }, height: '720px' },
23
+ simulator: { key: 'scenario', required: true, fixed: { timeline: 'off', fit: 'content' }, height: '720px' },
16
24
  messages: { key: 'chat', required: false, fixed: {}, height: '760px' },
17
25
  };
18
26
  export const EMBED_KINDS = Object.keys(KINDS);
@@ -74,3 +82,26 @@ export function parseSimulatorFence(value, line) {
74
82
  return r.ok ? { ok: true, block: { scenario: r.block.value, caption: r.block.caption, height: r.block.height } } : r;
75
83
  }
76
84
  export const simulatorSrc = (embed, scenario) => embedSrc('simulator', embed, scenario);
85
+ /** The message an embed page sends its frame, and the heights a frame will take. */
86
+ export const FIT_MESSAGE = 'embed:fit';
87
+ export const FIT_MIN = 240;
88
+ export const FIT_MAX = 2000;
89
+ /** The height (px) a frame takes from a message, or null: only `embed:fit` with a finite number,
90
+ * only from the origin of the page the frame loaded, and never outside FIT_MIN..FIT_MAX. */
91
+ export function fitFrom(data, origin, src) {
92
+ if (!data || typeof data !== 'object')
93
+ return null;
94
+ const d = data;
95
+ if (d.type !== FIT_MESSAGE || typeof d.height !== 'number' || !Number.isFinite(d.height))
96
+ return null;
97
+ let expected;
98
+ try {
99
+ expected = new URL(src).origin;
100
+ }
101
+ catch {
102
+ return null;
103
+ }
104
+ if (origin !== expected)
105
+ return null;
106
+ return Math.max(FIT_MIN, Math.min(FIT_MAX, Math.ceil(d.height)));
107
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "description": "Publish markdown as branded, read-aloud pages from a Next.js app: the artifacts store, the route factory, the read-along reader, brand packs, password and confidential pages, and share cards.",
5
5
  "license": "MIT",
6
6
  "type": "module",