@supersuit/artifacts 0.46.1 → 0.47.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,27 @@
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.47.0
7
+
8
+ **Figures and Helm scenes play inline, as two new embed fences.** A ```` ```figure ```` block
9
+ (`figure: <id>`, `caption:`, optional `height:`, default 560px) plays an interactive figure, and a
10
+ ```` ```helm ```` block (`scene: <id>`, `caption:`, optional `height:`, default 760px, optional
11
+ `autoplay: true`) plays a scene on a Helm phone, both in the same fitted, lazy frame and under the
12
+ same caption as `simulator` and `messages`. They are rows in the one table of embed kinds, which
13
+ now also carries per-kind options (helm's `autoplay`, passed to the embed page only when true), so
14
+ a fifth kind is one line. Publish refuses a block with no id or no caption, naming the line. Embed
15
+ blocks, the old two included, no longer take a comment anchor: the player and its caption were
16
+ never selectable text. The host names the pages with two new config fields, `figureEmbed` and
17
+ `helmEmbed`; there is no default. `ArtifactMarkdown` takes every kind's page as `embeds`
18
+ (`simulator` and `messages` still work).
19
+
20
+ - DETECTOR: a page carrying a ```` ```figure ```` or ```` ```helm ```` block. Before, it drew as a code block.
21
+ - REMEDY: update the dependency, and set `figureEmbed` / `helmEmbed` in `createArtifactRoutes` to
22
+ the host's figure and Helm embed pages. Without them the blocks show their caption only.
23
+ - Tests: simulator.test.ts ("the figure block", "the helm block", "every embed kind"),
24
+ render.test.tsx ("the figure and helm blocks", "an embed block is not read aloud"),
25
+ widgets.test.ts ("scanWidgets and the figure and helm blocks", "blocksOf and embed blocks").
26
+
6
27
  ## 0.46.1
7
28
 
8
29
  0.46.0 republished: its tag's publish run refused on two settings-menu tests that still listed the
package/README.md CHANGED
@@ -676,6 +676,43 @@ Paste it under the audio line, then correct any word (a name, a phrase in anothe
676
676
  and keep its two times. `transcriptFenceFromWords` and `wordsFromWhisperJson` (from
677
677
  `@supersuit/artifacts/artifacts`) do the same from code.
678
678
 
679
+ ## Embedded players (`simulator`, `messages`, `figure`, `helm`)
680
+
681
+ Four fences play a page the HOST serves, inline, in a frame under a caption:
682
+
683
+ | Fence | Names | Default height | Plays |
684
+ | --- | --- | --- | --- |
685
+ | `simulator` | `scenario: <id>` (required) | 720px | a Supersuit Session demo |
686
+ | `messages` | `chat: <id>` (optional) | 760px | a Messages-app phone, open on one thread or the list |
687
+ | `figure` | `figure: <id>` (required) | 560px | an interactive figure |
688
+ | `helm` | `scene: <id>` (required), `autoplay: true` (optional) | 760px | a scene on a Helm phone |
689
+
690
+ ````markdown
691
+ ```figure
692
+ figure: doug-week
693
+ caption: A week of Doug, before and after.
694
+ height: 560px # optional
695
+ ```
696
+
697
+ ```helm
698
+ scene: doug-walk
699
+ caption: A year later, on the reservoir loop.
700
+ autoplay: true # optional, true or false
701
+ ```
702
+ ````
703
+
704
+ - **Every block needs a `caption`**, the line a reader sees under the player. Publish refuses a
705
+ block with no caption, no required id, an id that is not lowercase letters, digits and dashes, a
706
+ height that is not pixels, or an unknown key, naming the line in the file.
707
+ - **The host names each player's embed page** (`simulatorEmbed`, `messagesEmbed`, `figureEmbed`,
708
+ `helmEmbed` in `createArtifactRoutes`, each an https URL; or `embeds` on `ArtifactMarkdown`). The
709
+ frame loads it with the id (`?figure=doug-week`, `?scene=doug-walk`) and `fit=content`. There is
710
+ no default: a host that names no page for a kind shows that block's caption alone, never its
711
+ source as code, which is also what print and a page with the player blocked show.
712
+ - **The frame fits its player.** It opens at `height:` and takes the height the embed page posts
713
+ as `{ type: 'embed:fit', height }` from its own origin.
714
+ - **Narration skips it**, caption included, and it carries no comment anchor.
715
+
679
716
  ## Comments and personal notes
680
717
 
681
718
  Every page takes comments pinned to any part of it. A reader selects text (a **Comment** chip
@@ -1,5 +1,5 @@
1
1
  export { parseArtifactSource, ARTIFACT_KINDS, PAGE_HISTORY, type ArtifactMeta, type ArtifactKind, type PageHistory } from './front-matter.js';
2
- export { ArtifactMarkdown, artifactUrlTransform } from './render.js';
2
+ export { ArtifactMarkdown, artifactUrlTransform, type EmbedCtx } from './render.js';
3
3
  export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
4
4
  export { createArtifactStore, newArtifactId, cleanNote, kindOf, listOfKind, MAX_CHANGE_NOTE_CHARS, type ArtifactListItem, type ArtifactStore, type ArtifactRecord, type SaveResult, type SaveInput, type VersionEntry, type VersionRecord } from './store.js';
5
5
  export { isPublishAuthed, isPublisherAuthed, type PublishAuth } from './auth.js';
@@ -1,12 +1,15 @@
1
1
  import { type ComponentType } from 'react';
2
2
  import type { FigureProps, SectionOutline, SectionProps } from '../brand/pack.js';
3
3
  import { type Heading } from './widgets.js';
4
+ import { type EmbedKind } from './simulator.js';
4
5
  import { type Definition } from './definitions.js';
5
6
  export declare function isReachableHref(href: unknown): href is string;
6
7
  export declare function artifactUrlTransform(url: string): string;
7
8
  type SimulatorCtx = {
8
9
  embed: string;
9
10
  };
11
+ /** The host's embed page for each embed kind it serves (ArtifactMarkdown `embeds`). */
12
+ export type EmbedCtx = Partial<Record<EmbedKind, SimulatorCtx>>;
10
13
  /** A pack's figure resolver (BrandPack.figure). */
11
14
  export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps> | undefined;
12
15
  /** The page's top-level sections: every heading among the body's top-level blocks at the
@@ -14,7 +17,7 @@ export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps>
14
17
  export declare function sectionOutline(markdown: string, headings?: Heading[]): SectionOutline[];
15
18
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
16
19
  * page carries a ```notes block. */
17
- export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, messages, }: {
20
+ export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, messages, embeds: hosted, }: {
18
21
  markdown: string;
19
22
  /** The page a ```checklist keeps its ticks under, and whether its Send can reach the owner
20
23
  * (a host that keeps answers, on a page that declares the slot). On a page whose checklists
@@ -40,5 +43,9 @@ export declare function ArtifactMarkdown({ markdown, notes, checklist, definitio
40
43
  simulator?: SimulatorCtx;
41
44
  /** The host's Messages phone, for ```messages blocks. Without it a block shows its caption only. */
42
45
  messages?: SimulatorCtx;
46
+ /** The host's embed page for every embed kind it serves (```simulator, ```messages, ```figure,
47
+ * ```helm). A kind with none shows its caption only. `simulator` and `messages` above still work
48
+ * and win over this. */
49
+ embeds?: EmbedCtx;
43
50
  }): import("react").JSX.Element;
44
51
  export {};
@@ -17,7 +17,7 @@ import { unified } from 'unified';
17
17
  import { blocksOf, checklistSyncs, checklistsOf, hasNotesWidget, headingsOf } from './widgets.js';
18
18
  import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
19
19
  import { Checklist } from '../widgets/checklist.js';
20
- import { embedSrc, parseEmbedFence } from './simulator.js';
20
+ import { embedSrc, isEmbedKind, parseEmbedFence, EMBED_KINDS } from './simulator.js';
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';
@@ -114,6 +114,8 @@ function InlineAudio({ src, label }) {
114
114
  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>`;
115
115
  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 } })] }));
116
116
  }
117
+ /** Fences that draw something other than code, so `pre` must not wrap them. */
118
+ const OWN_BLOCK = new RegExp(`language-(links|notes|checklist|transcript|${EMBED_KINDS.join('|')})\\b`);
117
119
  // A session demo on a wide screen is wider than the reading column, so the device it draws (a
118
120
  // laptop, or a phone beside what was made) has room: up to 64rem, centred on the column, and clear
119
121
  // of the contents rail fixed in the left margin from 1280px (w-56 at left-6, w-64 at left-12).
@@ -125,14 +127,14 @@ const WIDE_EMBED_CSS = `@media (min-width:1024px){${WIDE}{width:max(100%,min(64r
125
127
  `[data-broadcast] ${WIDE}{width:auto;margin-left:0;transform:none}[data-broadcast] ${WIDE} figcaption{text-align:inherit}}` +
126
128
  `@media (min-width:1280px){${WIDE}{width:max(100%,min(64rem,calc(100vw - 36rem)))}}` +
127
129
  `@media (min-width:1536px){${WIDE}{width:max(100%,min(64rem,calc(100vw - 40rem)))}}`;
128
- /** An embed block (```simulator, ```messages): the player in a frame when the host names one, its
130
+ /** An embed block (```simulator, ```messages, ```figure, ```helm): the player in a frame when the host names one, its
129
131
  * caption alone when it does not, and nothing when the block does not parse (a page published
130
132
  * before validation existed). */
131
133
  function EmbedFence({ kind, source, ctx }) {
132
134
  const r = parseEmbedFence(kind, source.replace(/\n$/, ''), 0);
133
135
  if (!r.ok)
134
136
  return null;
135
- const src = ctx ? embedSrc(kind, ctx.embed, r.block.value) : null;
137
+ const src = ctx ? embedSrc(kind, ctx.embed, r.block.value, r.block.options) : null;
136
138
  const wide = kind === 'simulator' && src != null;
137
139
  return (
138
140
  // data-nospeak: the narration skips every fenced block (narration.ts), so the caption must not
@@ -219,7 +221,7 @@ function baseComponents(block, figure, checklists, embeds, voiceNotes) {
219
221
  // A transcript not under an audio line draws nothing (publish refuses one; an old page may hold it).
220
222
  if (lang === TRANSCRIPT_LANG)
221
223
  return null;
222
- if (lang === 'simulator' || lang === 'messages')
224
+ if (isEmbedKind(lang))
223
225
  return _jsx(EmbedFence, { kind: lang, source: textOf(children), ctx: embeds?.[lang] });
224
226
  if (!className) {
225
227
  return _jsx("code", { className: "rounded bg-[color:var(--a-surface-strong)] px-1.5 py-0.5 text-[0.9em] text-[color:var(--a-strong)]", children: children });
@@ -230,7 +232,7 @@ function baseComponents(block, figure, checklists, embeds, voiceNotes) {
230
232
  // A links fence renders its own block; do not wrap it in <pre>.
231
233
  const inner = Array.isArray(children) ? children[0] : children;
232
234
  const cls = inner?.props?.className ?? '';
233
- if (/language-(links|notes|checklist|simulator|messages|transcript)/.test(cls))
235
+ if (OWN_BLOCK.test(cls))
234
236
  return _jsx(_Fragment, { children: children });
235
237
  return (_jsx("pre", { ...block(node), "data-artifact-code": true,
236
238
  // whitespace-pre-wrap, because a fence here usually holds PROSE meant to be pasted
@@ -355,9 +357,9 @@ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;
355
357
  'clip:rect(0,0,0,0);white-space:nowrap;border:0}';
356
358
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
357
359
  * page carries a ```notes block. */
358
- export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, messages, }) {
360
+ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, messages, embeds: hosted, }) {
359
361
  const on = !!notes && hasNotesWidget(markdown);
360
- const embeds = { simulator, messages };
362
+ const embeds = { ...hosted, ...(simulator ? { simulator } : {}), ...(messages ? { messages } : {}) };
361
363
  const headings = headingsOf(markdown);
362
364
  const blocks = blocksOf(markdown);
363
365
  const block = (node) => {
@@ -1,10 +1,12 @@
1
- export type EmbedKind = 'simulator' | 'messages';
1
+ export type EmbedKind = 'simulator' | 'messages' | 'figure' | 'helm';
2
2
  export declare const EMBED_KINDS: EmbedKind[];
3
3
  export declare const isEmbedKind: (k: unknown) => k is EmbedKind;
4
+ /** `options` holds a kind's further keys (helm's `autoplay`) as written, and is absent when none were. */
4
5
  export type EmbedBlock = {
5
6
  value: string | null;
6
7
  caption: string;
7
8
  height: string;
9
+ options?: Record<string, string>;
8
10
  };
9
11
  export type SimulatorBlock = {
10
12
  scenario: string;
@@ -22,7 +24,7 @@ export declare function parseEmbedFence(kind: EmbedKind, value: string, line: nu
22
24
  };
23
25
  /** The address a player loads: the host's embed page, what to open, any fixed switches. Null for an
24
26
  * embed address that is not https, so a misconfigured host frames nothing. */
25
- export declare function embedSrc(kind: EmbedKind, embed: string, value: string | null): string | null;
27
+ export declare function embedSrc(kind: EmbedKind, embed: string, value: string | null, options?: Record<string, string>): string | null;
26
28
  /** The ```simulator block (kept for callers of 0.25.0). */
27
29
  export declare function parseSimulatorFence(value: string, line: number): {
28
30
  ok: true;
@@ -8,7 +8,7 @@
8
8
  //
9
9
  // `simulator` is a Supersuit Session demo; `messages` is a Messages-app phone of real threads. The
10
10
  // block names what to open and a caption; the HOST names each player's embed page
11
- // (ArtifactRoutesConfig.simulatorEmbed / messagesEmbed), so this public package points at nobody's
11
+ // (ArtifactRoutesConfig.simulatorEmbed / messagesEmbed / figureEmbed / helmEmbed), 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
14
  //
@@ -19,9 +19,12 @@
19
19
  // frame then takes (EmbedFrame). A page that posts nothing keeps the block's height. The embed
20
20
  // page must hold its player's size once asked, never size it from its own viewport, or the two
21
21
  // chase each other; that is why the frame listens only for this message and for no player's own.
22
+ const BOOL = { values: ['true', 'false'], off: 'false' };
22
23
  const KINDS = {
23
24
  simulator: { key: 'scenario', required: true, fixed: { timeline: 'off', fit: 'content' }, height: '720px' },
24
25
  messages: { key: 'chat', required: false, fixed: {}, height: '760px' },
26
+ figure: { key: 'figure', required: true, fixed: { fit: 'content' }, height: '560px' },
27
+ helm: { key: 'scene', required: true, fixed: { fit: 'content' }, height: '760px', options: { autoplay: BOOL } },
25
28
  };
26
29
  export const EMBED_KINDS = Object.keys(KINDS);
27
30
  export const isEmbedKind = (k) => typeof k === 'string' && Object.hasOwn(KINDS, k);
@@ -31,7 +34,8 @@ const HEIGHT = /^\d{3,4}px$/;
31
34
  * line the author sees. */
32
35
  export function parseEmbedFence(kind, value, line) {
33
36
  const spec = KINDS[kind];
34
- const keys = [spec.key, 'caption', 'height'];
37
+ const opts = spec.options ?? {};
38
+ const keys = [spec.key, 'caption', 'height', ...Object.keys(opts)];
35
39
  const got = {};
36
40
  const rows = value ? value.split('\n') : [];
37
41
  for (let i = 0; i < rows.length; i++) {
@@ -55,11 +59,23 @@ export function parseEmbedFence(kind, value, line) {
55
59
  return { ok: false, error: `line ${line}: the ${kind} block needs a caption, the line a reader sees under it` };
56
60
  if (got.height && !HEIGHT.test(got.height.v))
57
61
  return { ok: false, error: `line ${got.height.at}: a ${kind} height is a number of pixels, like 760px` };
58
- return { ok: true, block: { value: id?.v ?? null, caption: got.caption.v, height: got.height?.v ?? spec.height } };
62
+ const options = {};
63
+ for (const [name, o] of Object.entries(opts)) {
64
+ const g = got[name];
65
+ if (!g)
66
+ continue;
67
+ if (!o.values.includes(g.v))
68
+ return { ok: false, error: `line ${g.at}: a ${kind} ${name} is ${o.values.join(' or ')}` };
69
+ options[name] = g.v;
70
+ }
71
+ const block = { value: id?.v ?? null, caption: got.caption.v, height: got.height?.v ?? spec.height };
72
+ if (Object.keys(options).length)
73
+ block.options = options;
74
+ return { ok: true, block };
59
75
  }
60
76
  /** The address a player loads: the host's embed page, what to open, any fixed switches. Null for an
61
77
  * embed address that is not https, so a misconfigured host frames nothing. */
62
- export function embedSrc(kind, embed, value) {
78
+ export function embedSrc(kind, embed, value, options) {
63
79
  let u;
64
80
  try {
65
81
  u = new URL(embed);
@@ -72,6 +88,11 @@ export function embedSrc(kind, embed, value) {
72
88
  const spec = KINDS[kind];
73
89
  if (value)
74
90
  u.searchParams.set(spec.key, value);
91
+ for (const [name, o] of Object.entries(spec.options ?? {})) {
92
+ const v = options?.[name];
93
+ if (v && v !== o.off && o.values.includes(v))
94
+ u.searchParams.set(name, v);
95
+ }
75
96
  for (const [k, v] of Object.entries(spec.fixed))
76
97
  u.searchParams.set(k, v);
77
98
  return u.toString();
@@ -292,7 +292,9 @@ export function blocksOf(markdown) {
292
292
  content = plain(n);
293
293
  }
294
294
  else if (n.type === 'code') {
295
- if (n.lang === 'links' || n.lang === NOTES_SLOT || n.lang === 'checklist' || n.lang === TRANSCRIPT_LANG)
295
+ // An embed block draws a player and a caption the reader cannot comment on (data-nospeak, no
296
+ // data-block), so it takes no anchor either.
297
+ if (n.lang === 'links' || n.lang === NOTES_SLOT || n.lang === 'checklist' || n.lang === TRANSCRIPT_LANG || isEmbedKind(n.lang))
296
298
  continue;
297
299
  kind = 'code';
298
300
  content = n.value;
@@ -37,6 +37,13 @@ export type ArtifactRoutesConfig = {
37
37
  /** The embed page of this host's Messages phone (an https URL), for ```messages blocks: a
38
38
  * `chat=<id>` opens one thread. Without it a block shows its caption only. No default. */
39
39
  messagesEmbed?: string;
40
+ /** The embed page of this host's interactive figures (an https URL, e.g.
41
+ * https://example.com/figures/embed), for ```figure blocks: `figure=<id>` opens one. Without it
42
+ * a block shows its caption only. No default. */
43
+ figureEmbed?: string;
44
+ /** The embed page of this host's Helm player (an https URL), for ```helm blocks: `scene=<id>`
45
+ * plays one, `autoplay=true` when the block asks. Without it a block shows its caption only. No default. */
46
+ helmEmbed?: string;
40
47
  /** Read at request time, so a rotated key needs no rebuild. */
41
48
  publishKey: () => string | undefined;
42
49
  /** Verifies publisher passes (`p1`, see artifacts/publisher.ts): a signed, hour-long stand-in
@@ -92,6 +92,11 @@ export function createArtifactRoutes(config) {
92
92
  // The version in the URL is what makes a re-publish show up: every unfurler caches by URL.
93
93
  const signInOrigin = config.signInOrigin;
94
94
  const owner = config.owner ?? brand.name;
95
+ // The embed page of each player this host serves, for every ArtifactMarkdown below.
96
+ const embeds = {};
97
+ for (const [kind, url] of [['simulator', config.simulatorEmbed], ['messages', config.messagesEmbed], ['figure', config.figureEmbed], ['helm', config.helmEmbed]])
98
+ if (url)
99
+ embeds[kind] = { embed: url };
95
100
  const ownerEmail = config.ownerEmail?.trim().toLowerCase();
96
101
  const admins = new Set([ownerEmail, ...(config.admins ?? []).map((e) => e.trim().toLowerCase())].filter((e) => !!e));
97
102
  /** Whether this reader may see this page's version number and past versions: the page's own
@@ -465,7 +470,7 @@ export function createArtifactRoutes(config) {
465
470
  const title = v.title ?? a.title;
466
471
  const summary = v.summary ?? a.summary;
467
472
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
468
- return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, history: history, viewing: n, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, { title, subtitle: v.subtitle, summary, to: v.to }, v.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { n: n, at: v.at, numbered: true }), version: n, showVersion: true, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { n: n, at: v.at, numbered: true })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
473
+ return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, history: history, viewing: n, settings: await settingsFor(a, reader), source: isAdmin(reader) ? sourceOf(a.id, { title, subtitle: v.subtitle, summary, to: v.to }, v.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { n: n, at: v.at, numbered: true }), version: n, showVersion: true, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { n: n, at: v.at, numbered: true })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, embeds: embeds, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
469
474
  }
470
475
  /** A broadcast: one message card. The sender's words at the top, then the media (a video with
471
476
  * sound and controls, photos, a voice note's player, link cards) in the order the words put
@@ -480,7 +485,7 @@ export function createArtifactRoutes(config) {
480
485
  const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
481
486
  return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
482
487
  // eslint-disable-next-line @next/next/no-img-element
483
- _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
488
+ _jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, embeds: embeds }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
484
489
  }
485
490
  /** The page itself, shared by open and gated pages. */
486
491
  async function Body(a, { top, reader, perReader }) {
@@ -507,7 +512,7 @@ export function createArtifactRoutes(config) {
507
512
  const header = pack.Cover ? (_jsx(pack.Cover, { title: a.title, subtitle: a.subtitle, summary: a.summary, to: a.to, markdown: a.markdown, kicker: _jsx(Kicker, { to: a.to, brand: brand }), meta: meta, version: n, showVersion: seesHistory(a, reader), updatedAt: a.updatedAt, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx(Kicker, { to: a.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), a.subtitle ? (_jsx("p", { className: "mx-auto mt-4 max-w-xl text-xl sm:text-2xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: a.summary }), meta] }));
508
513
  return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.closesAt && !perReader.owner ? (_jsx(FeedbackWindow, { owner: owner, label: perReader.label, note: perReader.note, group: perReader.group, closesAt: perReader.closesAt })) : perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
509
514
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
510
- checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }));
515
+ checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, embeds: embeds }) }));
511
516
  })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
512
517
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
513
518
  recorded at publish, or there is none. */] }));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.46.1",
3
+ "version": "0.47.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",