@supersuit/artifacts 0.38.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,43 @@
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
+
6
43
  ## 0.38.0
7
44
 
8
45
  **Readers' voice memos live in the private bucket.** A memo left on a page (`comments/<memo>`, or a
@@ -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';
@@ -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';
@@ -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.38.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",