@supersuit/artifacts 0.26.0 → 0.28.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,49 @@
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.28.0
7
+
8
+ **A link to a comment opens for the page's admins, and says so when it cannot.** On an owner-only
9
+ page (`comments_visible: owner`, the default) the state API showed every comment to exactly one
10
+ person, `ownerEmail`. An admin opening a comment's link (the link in every comment notification)
11
+ got the page with nothing to scroll to, and nothing said why.
12
+
13
+ - `StateRoutesContext.admins` (passed from `ArtifactRoutesConfig.admins`, which version history
14
+ already used): admins, like the owner, read every comment with names and see the owner panel.
15
+ - The owner's view names an anonymous commenter by the name they declared (0.24.0's `by`),
16
+ where it said "a reader".
17
+ - A `#comment-<id>` link to a comment this reader cannot see now shows a closable line: "That
18
+ comment can't be shown to you. Comments on this page are visible to its owner and admins", with
19
+ "sign in if that is you" when signed out. It used to do nothing.
20
+ - DETECTOR: as an admin who is not `ownerEmail`, open a comment notification's link on an
21
+ owner-only page. No pin and no thread means 0.27.0 or earlier.
22
+ - REMEDY: update the dependency; a host that sets `admins` needs no other change.
23
+ - Tests: `comments.test.tsx` (an admin sees every comment with names; the owner sees a declared
24
+ name), `comment-layer.test.tsx` (an unseeable link says so). `test:packed` green.
25
+
26
+ ## 0.27.0
27
+
28
+ **A page can play a Messages-app phone of real threads inline: the ```messages block.** The
29
+ second embedded player after ```simulator, so both now run through one embed mechanism.
30
+
31
+ ```messages
32
+ chat: fritz # optional: open on one chat; without it the chat list
33
+ caption: Fritz, in his own words.
34
+ height: 760px # optional, default 760px
35
+ ```
36
+
37
+ - The HOST names the player: new `ArtifactRoutesConfig.messagesEmbed` (an https embed page). The
38
+ block draws as a lazy frame on `<embed>?chat=<id>`, with the caption under it; a host without it
39
+ draws the caption alone, never the block as code. Malformed blocks are refused at publish.
40
+ - `simulator` and `messages` share `parseEmbedFence` and `embedSrc` (new exports, with
41
+ `EMBED_KINDS`, `EmbedBlock`, `EmbedKind`); `parseSimulatorFence` and `simulatorSrc` keep working.
42
+ `ArtifactMarkdown` takes `messages?: { embed }`.
43
+ - DETECTOR: a page with a ```messages block showing `chat:` as code means 0.26.0 or earlier, or no
44
+ `messagesEmbed`.
45
+ - REMEDY: update the dependency and set `messagesEmbed`.
46
+ - Tests: `simulator.test.ts` (messages parse, optional chat, refusals, src with and without a chat),
47
+ `render.test.tsx`, `widgets.test.ts`. `test:packed` green.
48
+
6
49
  ## 0.26.0
7
50
 
8
51
  **Read-along that leaves the reader in charge.** A tap on any word started the audio, and while it
@@ -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, type SimulatorBlock } from './simulator.js';
16
+ export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, 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 { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment, type CommentsMode, type CommentsVisible, type CommentValue } from './comments.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 } from './simulator.js';
16
+ export { parseSimulatorFence, simulatorSrc, parseEmbedFence, embedSrc, 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 { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment } from './comments.js';
@@ -14,7 +14,7 @@ export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps>
14
14
  export declare function sectionOutline(markdown: string, headings?: Heading[]): SectionOutline[];
15
15
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
16
16
  * page carries a ```notes block. */
17
- export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, }: {
17
+ export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, messages, }: {
18
18
  markdown: string;
19
19
  /** The page a ```checklist keeps its ticks under, and whether its Send can reach the owner
20
20
  * (a host that keeps answers, on a page that declares the slot). On a page whose checklists
@@ -38,5 +38,7 @@ export declare function ArtifactMarkdown({ markdown, notes, checklist, definitio
38
38
  definitions?: Definition[];
39
39
  /** The host's Supersuit Session demo, for ```simulator blocks. Without it a block shows its caption only. */
40
40
  simulator?: SimulatorCtx;
41
+ /** The host's Messages phone, for ```messages blocks. Without it a block shows its caption only. */
42
+ messages?: SimulatorCtx;
41
43
  }): import("react").JSX.Element;
42
44
  export {};
@@ -16,7 +16,7 @@ import { unified } from 'unified';
16
16
  import { blocksOf, checklistSyncs, checklistsOf, hasNotesWidget, headingsOf } from './widgets.js';
17
17
  import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
18
18
  import { Checklist } from '../widgets/checklist.js';
19
- import { parseSimulatorFence, simulatorSrc } from './simulator.js';
19
+ import { embedSrc, parseEmbedFence } from './simulator.js';
20
20
  import { VideoAutoplay } from '../reader/video-autoplay.js';
21
21
  import { CodeCopy } from '../reader/code-copy.js';
22
22
  import { DefinitionLayer } from '../reader/definition-layer.js';
@@ -95,14 +95,15 @@ function InlineAudio({ src, label }) {
95
95
  const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio controls preload="none" class="block w-full"></audio>`;
96
96
  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 } })] }));
97
97
  }
98
- /** A ```simulator block: the demo in a frame when the host has one, its caption alone when it does
99
- * not, and nothing when the block does not parse (a page published before validation existed). */
100
- function SimulatorFence({ source, ctx }) {
101
- const r = parseSimulatorFence(source.replace(/\n$/, ''), 0);
98
+ /** An embed block (```simulator, ```messages): the player in a frame when the host names one, its
99
+ * caption alone when it does not, and nothing when the block does not parse (a page published
100
+ * before validation existed). */
101
+ function EmbedFence({ kind, source, ctx }) {
102
+ const r = parseEmbedFence(kind, source.replace(/\n$/, ''), 0);
102
103
  if (!r.ok)
103
104
  return null;
104
- const src = ctx ? simulatorSrc(ctx.embed, r.block.scenario) : null;
105
- return (_jsxs("figure", { "data-artifact-simulator": true, 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 })] }));
105
+ const src = ctx ? embedSrc(kind, ctx.embed, r.block.value) : null;
106
+ 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 })] }));
106
107
  }
107
108
  /** The one image a paragraph holds and nothing else (whitespace aside), or null. */
108
109
  function soleImage(node) {
@@ -132,7 +133,7 @@ function ChecklistFence({ line, ctx }) {
132
133
  return null;
133
134
  return (_jsx(Checklist, { items: c.items.map((it) => ({ id: it.id, title: checklistMarkdown(it.text, true), description: it.description ? checklistMarkdown(it.description, false) : null })), artifactId: ctx.artifactId, send: ctx.send && ctx.artifactId && !ctx.sync ? c.send : undefined, sync: ctx.sync && !!ctx.artifactId, owner: ctx.owner, accent: ctx.accent }));
134
135
  }
135
- function baseComponents(block, figure, checklists, simulator) {
136
+ function baseComponents(block, figure, checklists, embeds) {
136
137
  return {
137
138
  p: ({ node, children }) => {
138
139
  // An image alone in its paragraph that the pack draws itself replaces the paragraph, so the
@@ -172,8 +173,8 @@ function baseComponents(block, figure, checklists, simulator) {
172
173
  // answers) it draws nothing: its settings are not prose and never shown as code.
173
174
  if (lang === 'notes')
174
175
  return null;
175
- if (lang === 'simulator')
176
- return _jsx(SimulatorFence, { source: textOf(children), ctx: simulator });
176
+ if (lang === 'simulator' || lang === 'messages')
177
+ return _jsx(EmbedFence, { kind: lang, source: textOf(children), ctx: embeds?.[lang] });
177
178
  if (!className) {
178
179
  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 });
179
180
  }
@@ -183,7 +184,7 @@ function baseComponents(block, figure, checklists, simulator) {
183
184
  // A links fence renders its own block; do not wrap it in <pre>.
184
185
  const inner = Array.isArray(children) ? children[0] : children;
185
186
  const cls = inner?.props?.className ?? '';
186
- if (/language-(links|notes|checklist|simulator)/.test(cls))
187
+ if (/language-(links|notes|checklist|simulator|messages)/.test(cls))
187
188
  return _jsx(_Fragment, { children: children });
188
189
  return (_jsx("pre", { ...block(node), "data-artifact-code": true,
189
190
  // whitespace-pre-wrap, because a fence here usually holds PROSE meant to be pasted
@@ -216,20 +217,20 @@ const HEADING_CLASS = {
216
217
  };
217
218
  /** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
218
219
  * so a contents link and a note both land on it. */
219
- function pageComponents(headings, block, figure, checklists, simulator) {
220
+ function pageComponents(headings, block, figure, checklists, embeds) {
220
221
  const byLine = new Map(headings.map((h) => [h.line, h]));
221
222
  const heading = (depth) => function Heading({ node, children }) {
222
223
  const Tag = `h${depth}`;
223
224
  const h = byLine.get(node?.position?.start.line ?? -1);
224
225
  return _jsx(Tag, { id: h?.slug, ...block(node), className: HEADING_CLASS[depth], children: children });
225
226
  };
226
- return { ...baseComponents(block, figure, checklists, simulator), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
227
+ return { ...baseComponents(block, figure, checklists, embeds), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
227
228
  }
228
229
  /** Components for a page with a notes block: every heading gets its slug as an id, a note
229
230
  * control, and its notes after it. The heading is found by its source line, so the slug is the
230
231
  * one headingsOf derived, the same one a note stores. */
231
- function notesComponents(headings, block, figure, checklists, simulator) {
232
- const components = baseComponents(block, figure, checklists, simulator);
232
+ function notesComponents(headings, block, figure, checklists, embeds) {
233
+ const components = baseComponents(block, figure, checklists, embeds);
233
234
  const byLine = new Map(headings.map((h) => [h.line, h]));
234
235
  const heading = (depth) => function NotedHeading({ node, children }) {
235
236
  const Tag = `h${depth}`;
@@ -308,8 +309,9 @@ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;
308
309
  'clip:rect(0,0,0,0);white-space:nowrap;border:0}';
309
310
  /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
310
311
  * page carries a ```notes block. */
311
- export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, }) {
312
+ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, messages, }) {
312
313
  const on = !!notes && hasNotesWidget(markdown);
314
+ const embeds = { simulator, messages };
313
315
  const headings = headingsOf(markdown);
314
316
  const blocks = blocksOf(markdown);
315
317
  const block = (node) => {
@@ -325,7 +327,7 @@ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Sect
325
327
  // page whose checklists sync, that same slot is the shared set, so it turns sync on instead.
326
328
  ? { byLine: lists, artifactId: checklist?.artifactId, send: !!checklist?.send, sync: !!checklist?.send && checklistSyncs(lists.values()), owner: checklist?.owner, accent: checklist?.accent }
327
329
  : undefined;
328
- const components = on ? notesComponents(headings, block, figure, checklists, simulator) : pageComponents(headings, block, figure, checklists, simulator);
330
+ const components = on ? notesComponents(headings, block, figure, checklists, embeds) : pageComponents(headings, block, figure, checklists, embeds);
329
331
  if (outline.length)
330
332
  components.section = sectionComponent(Section, outline, block);
331
333
  const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, urlTransform: artifactUrlTransform, children: markdown }));
@@ -1,10 +1,29 @@
1
+ export type EmbedKind = 'simulator' | 'messages';
2
+ export declare const EMBED_KINDS: EmbedKind[];
3
+ export declare const isEmbedKind: (k: unknown) => k is EmbedKind;
4
+ export type EmbedBlock = {
5
+ value: string | null;
6
+ caption: string;
7
+ height: string;
8
+ };
1
9
  export type SimulatorBlock = {
2
10
  scenario: string;
3
11
  caption: string;
4
12
  height: string;
5
13
  };
6
- /** Parses a simulator fence's body. `line` is the line the fence opens on, so every error names
7
- * the line the author sees. */
14
+ /** Parses an embed fence's body. `line` is the line the fence opens on, so every error names the
15
+ * line the author sees. */
16
+ export declare function parseEmbedFence(kind: EmbedKind, value: string, line: number): {
17
+ ok: true;
18
+ block: EmbedBlock;
19
+ } | {
20
+ ok: false;
21
+ error: string;
22
+ };
23
+ /** The address a player loads: the host's embed page, what to open, any fixed switches. Null for an
24
+ * 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;
26
+ /** The ```simulator block (kept for callers of 0.25.0). */
8
27
  export declare function parseSimulatorFence(value: string, line: number): {
9
28
  ok: true;
10
29
  block: SimulatorBlock;
@@ -12,6 +31,4 @@ export declare function parseSimulatorFence(value: string, line: number): {
12
31
  ok: false;
13
32
  error: string;
14
33
  };
15
- /** The address the player loads: the host's embed page, the scenario, the timeline off. Null for
16
- * an embed address that is not https, so a misconfigured host frames nothing. */
17
- export declare function simulatorSrc(embed: string, scenario: string): string | null;
34
+ export declare const simulatorSrc: (embed: string, scenario: string) => string | null;
@@ -1,21 +1,29 @@
1
- // The ```simulator fence: a Supersuit Session demo played inline on the page. The block names a
2
- // scenario and a caption; the HOST names the demo's embed page (ArtifactRoutesConfig.simulatorEmbed),
3
- // so this public package points at nobody's service by default, and a host without a demo draws
4
- // the caption alone rather than the block's source as code.
1
+ // Embedded players: fenced blocks that play a host-named page inline, in a frame.
5
2
  //
6
- // ```simulator
7
- // scenario: diane-meeting-prep
8
- // caption: Step into the hour before Diane's first meeting. Press Start.
9
- // height: 760px # optional, default 720px
10
- // ```
3
+ // ```simulator ```messages
4
+ // scenario: diane-meeting-prep chat: fritz # optional: open on one chat
5
+ // caption: Step into the hour ... caption: Fritz, in his own words.
6
+ // height: 760px # optional height: 760px # optional, default 760px (simulator 720px)
7
+ // ``` ```
11
8
  //
12
- // The same syntax the Superskills book's reader takes, so one block works in both.
13
- const SCENARIO = /^[a-z0-9-]+$/;
9
+ // `simulator` is a Supersuit Session demo; `messages` is a Messages-app phone of real threads. The
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
12
+ // service by default, and a host without the player draws the caption alone rather than the block's
13
+ // source as code. `simulator` keeps the syntax the Superskills book's reader takes.
14
+ const KINDS = {
15
+ simulator: { key: 'scenario', required: true, fixed: { timeline: 'off' }, height: '720px' },
16
+ messages: { key: 'chat', required: false, fixed: {}, height: '760px' },
17
+ };
18
+ export const EMBED_KINDS = Object.keys(KINDS);
19
+ export const isEmbedKind = (k) => typeof k === 'string' && Object.hasOwn(KINDS, k);
20
+ const ID = /^[a-z0-9-]+$/;
14
21
  const HEIGHT = /^\d{3,4}px$/;
15
- const KEYS = ['scenario', 'caption', 'height'];
16
- /** Parses a simulator fence's body. `line` is the line the fence opens on, so every error names
17
- * the line the author sees. */
18
- export function parseSimulatorFence(value, line) {
22
+ /** Parses an embed fence's body. `line` is the line the fence opens on, so every error names the
23
+ * line the author sees. */
24
+ export function parseEmbedFence(kind, value, line) {
25
+ const spec = KINDS[kind];
26
+ const keys = [spec.key, 'caption', 'height'];
19
27
  const got = {};
20
28
  const rows = value ? value.split('\n') : [];
21
29
  for (let i = 0; i < rows.length; i++) {
@@ -25,25 +33,25 @@ export function parseSimulatorFence(value, line) {
25
33
  const at = line + 1 + i;
26
34
  const m = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(raw);
27
35
  if (!m)
28
- return { ok: false, error: `line ${at}: the simulator block takes lines like "scenario: <id>"` };
29
- const key = m[1];
30
- if (!KEYS.includes(key))
31
- return { ok: false, error: `line ${at}: the simulator block has an unknown key: ${m[1]}` };
32
- got[key] = { v: m[2].replace(/\s+#.*$/, '').trim(), at };
36
+ return { ok: false, error: `line ${at}: the ${kind} block takes lines like "${spec.key}: <id>"` };
37
+ if (!keys.includes(m[1]))
38
+ return { ok: false, error: `line ${at}: the ${kind} block has an unknown key: ${m[1]}` };
39
+ got[m[1]] = { v: m[2].replace(/\s+#.*$/, '').trim(), at };
33
40
  }
34
- if (!got.scenario)
35
- return { ok: false, error: `line ${line}: the simulator block needs a scenario` };
36
- if (!SCENARIO.test(got.scenario.v))
37
- return { ok: false, error: `line ${got.scenario.at}: a simulator scenario is lowercase letters, digits and dashes` };
41
+ const id = got[spec.key];
42
+ if (!id && spec.required)
43
+ return { ok: false, error: `line ${line}: the ${kind} block needs a ${spec.key}` };
44
+ if (id && !ID.test(id.v))
45
+ return { ok: false, error: `line ${id.at}: a ${kind} ${spec.key} is lowercase letters, digits and dashes` };
38
46
  if (!got.caption?.v)
39
- return { ok: false, error: `line ${line}: the simulator block needs a caption, the line a reader sees under the demo` };
47
+ return { ok: false, error: `line ${line}: the ${kind} block needs a caption, the line a reader sees under it` };
40
48
  if (got.height && !HEIGHT.test(got.height.v))
41
- return { ok: false, error: `line ${got.height.at}: a simulator height is a number of pixels, like 760px` };
42
- return { ok: true, block: { scenario: got.scenario.v, caption: got.caption.v, height: got.height?.v ?? '720px' } };
49
+ return { ok: false, error: `line ${got.height.at}: a ${kind} height is a number of pixels, like 760px` };
50
+ return { ok: true, block: { value: id?.v ?? null, caption: got.caption.v, height: got.height?.v ?? spec.height } };
43
51
  }
44
- /** The address the player loads: the host's embed page, the scenario, the timeline off. Null for
45
- * an embed address that is not https, so a misconfigured host frames nothing. */
46
- export function simulatorSrc(embed, scenario) {
52
+ /** The address a player loads: the host's embed page, what to open, any fixed switches. Null for an
53
+ * embed address that is not https, so a misconfigured host frames nothing. */
54
+ export function embedSrc(kind, embed, value) {
47
55
  let u;
48
56
  try {
49
57
  u = new URL(embed);
@@ -53,7 +61,16 @@ export function simulatorSrc(embed, scenario) {
53
61
  }
54
62
  if (u.protocol !== 'https:')
55
63
  return null;
56
- u.searchParams.set('scenario', scenario);
57
- u.searchParams.set('timeline', 'off');
64
+ const spec = KINDS[kind];
65
+ if (value)
66
+ u.searchParams.set(spec.key, value);
67
+ for (const [k, v] of Object.entries(spec.fixed))
68
+ u.searchParams.set(k, v);
58
69
  return u.toString();
59
70
  }
71
+ /** The ```simulator block (kept for callers of 0.25.0). */
72
+ export function parseSimulatorFence(value, line) {
73
+ const r = parseEmbedFence('simulator', value, line);
74
+ return r.ok ? { ok: true, block: { scenario: r.block.value, caption: r.block.caption, height: r.block.height } } : r;
75
+ }
76
+ export const simulatorSrc = (embed, scenario) => embedSrc('simulator', embed, scenario);
@@ -16,7 +16,7 @@ import remarkParse from 'remark-parse';
16
16
  import remarkGfm from 'remark-gfm';
17
17
  import { blockId } from './anchor.js';
18
18
  import { MAX_NOTE_CHARS, NOTES_SLOT } from './notes-place.js';
19
- import { parseSimulatorFence } from './simulator.js';
19
+ import { isEmbedKind, parseEmbedFence } from './simulator.js';
20
20
  import { CHECKLIST_SLOT, assignChecklistIds, parseChecklistFence } from './checklist.js';
21
21
  export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from './notes-place.js';
22
22
  /** Widgets the design names and this version does not draw yet. A fence using one is refused at
@@ -102,9 +102,9 @@ export function scanWidgets(body, offset) {
102
102
  notes = w;
103
103
  }
104
104
  for (const f of fences) {
105
- if (f.lang !== 'simulator')
105
+ if (!isEmbedKind(f.lang))
106
106
  continue;
107
- const r = parseSimulatorFence(f.value, (f.position?.start.line ?? 1) + offset);
107
+ const r = parseEmbedFence(f.lang, f.value, (f.position?.start.line ?? 1) + offset);
108
108
  if (!r.ok)
109
109
  return r;
110
110
  }
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  // Comments on any part of a page, Figma style. Select text and a "Comment" chip appears; hold and
4
4
  // drag over anything that is not text (an image, a table, the gap between paragraphs) and a box
5
5
  // draws. Either opens the card (comment-card.tsx), which says before anything is typed whether
@@ -85,6 +85,9 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
85
85
  const personalUrl = `/api/artifacts/${artifactId}/personal`;
86
86
  const [mounted, setMounted] = useState(false);
87
87
  const [shared, setShared] = useState([]);
88
+ // Whether the shared comments have arrived, so a link to one this reader cannot see can say so.
89
+ const [loaded, setLoaded] = useState(false);
90
+ const [missing, setMissing] = useState(false);
88
91
  const [serverNotes, setServerNotes] = useState([]);
89
92
  const [device, setDevice] = useState([]);
90
93
  const [onServer, setOnServer] = useState(signedIn);
@@ -124,9 +127,12 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
124
127
  refreshDevice();
125
128
  let live = true;
126
129
  if (comments !== 'off') {
127
- void call(stateUrl).then((r) => { if (live && r.status === 200)
128
- setShared(sharedFrom(r.body)); });
130
+ void call(stateUrl).then((r) => { if (!live)
131
+ return; if (r.status === 200)
132
+ setShared(sharedFrom(r.body)); setLoaded(true); });
129
133
  }
134
+ else
135
+ setLoaded(true);
130
136
  if (signedIn) {
131
137
  void (async () => {
132
138
  const r = await call(personalUrl);
@@ -343,14 +349,20 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
343
349
  // `tick` re-measures after a resize.
344
350
  }, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
345
351
  // Show the comment the address names once it has loaded: a reply is shown in its parent's thread.
346
- // Held until the comments arrive; an id that never arrives (deleted, or not this reader's to see)
347
- // simply does nothing.
352
+ // Held until the comments arrive; an id that is not among them (deleted, or not this reader's to
353
+ // see) says so in a line, rather than leaving the reader wondering why nothing happened.
348
354
  useEffect(() => {
349
355
  if (!target || !mounted)
350
356
  return;
351
357
  const item = items.find((i) => i.id === target);
352
- if (!item)
358
+ if (!item) {
359
+ if (loaded) {
360
+ setMissing(true);
361
+ setTarget(null);
362
+ }
353
363
  return;
364
+ }
365
+ setMissing(false);
354
366
  const parent = item.value.parent ? items.find((i) => i.id === item.value.parent) : undefined;
355
367
  const id = (parent ?? item).id;
356
368
  setTarget(null);
@@ -365,7 +377,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
365
377
  setHighlight(id);
366
378
  setScrollTo(`[data-comment-earlier-id="${id}"]`);
367
379
  }
368
- }, [target, mounted, items, layout]);
380
+ }, [target, mounted, items, layout, loaded]);
369
381
  // Effects run after the DOM is committed, so the pin or list item drawn for the target is there.
370
382
  useEffect(() => {
371
383
  if (!scrollTo)
@@ -468,7 +480,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
468
480
  const open = layout.placed.find((p) => p.item.id === thread);
469
481
  const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
470
482
  const count = tops.length;
471
- const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (
483
+ const overlay = mounted ? (_jsxs("div", { "data-comment-ui": true, "data-nospeak": true, className: "font-sans", style: theme, children: [missing ? (_jsxs("div", { "data-comment-missing": true, role: "status", className: "fixed left-1/2 top-4 z-50 flex -translate-x-1/2 items-center gap-2 rounded-full border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] px-4 py-2 text-sm text-[color:var(--a-strong)] shadow-lg", children: [_jsxs("span", { children: ["That comment can't be shown to you. Comments on this page are visible to its owner and admins", signedIn ? '' : '; sign in if that is you', "."] }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setMissing(false), className: "opacity-70", children: "\u00D7" })] })) : null, showPins ? layout.placed.map(({ item, left, top, box }) => (_jsxs("div", { children: [box ? (_jsx("div", { "data-comment-box": item.id, "aria-hidden": true, className: "pointer-events-none absolute z-30 rounded-md border-2", style: { left: box.left, top: box.top, width: box.width, height: box.height, borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', opacity: thread === item.id ? 1 : 0.55 } })) : null, _jsx(Pin, { item: item, left: left, top: top, accent: accent, ground: ground, onOpen: () => setThread(thread === item.id ? null : item.id) })] }, item.id))) : null, chip && !draft ? (
472
484
  // On a phone the button sits at the TOP of the screen, never beside the selection: that is
473
485
  // where iOS draws its own Copy / Look Up menu, and the two fought for the same tap (Gary,
474
486
  // 2026-09-29: "I keep trying to fight with the Apple pop-up"). A mouse keeps it by the text.
@@ -31,6 +31,9 @@ export type ArtifactRoutesConfig = {
31
31
  * https://example.com/simulator/embed), for ```simulator blocks. Without it a block shows its
32
32
  * caption only. No default: this package points at nobody's service. */
33
33
  simulatorEmbed?: string;
34
+ /** The embed page of this host's Messages phone (an https URL), for ```messages blocks: a
35
+ * `chat=<id>` opens one thread. Without it a block shows its caption only. No default. */
36
+ messagesEmbed?: string;
34
37
  /** Read at request time, so a rotated key needs no rebuild. */
35
38
  publishKey: () => string | undefined;
36
39
  /** Verifies publisher passes (`p1`, see artifacts/publisher.ts): a signed, hour-long stand-in
@@ -78,7 +78,7 @@ export function createArtifactRoutes(config) {
78
78
  const stateRoutes = createStateRoutes({
79
79
  store, state: config.state, readers: config.readers, readerSecret, publishKey: config.publishKey,
80
80
  publisherSecret: config.publisherSecret, publisherHost: config.publisherHost, pageUrl, signInOrigin, siteUrl,
81
- clientIp: config.clientIp, ownerEmail: config.ownerEmail, audioUrl,
81
+ clientIp: config.clientIp, ownerEmail: config.ownerEmail, admins: config.admins, audioUrl,
82
82
  });
83
83
  const personalRoutes = createPersonalRoutes({ store, personal: config.personal, readers: config.readers, readerSecret, pageUrl, signInOrigin, siteUrl, audioUrl });
84
84
  const voiceRoutes = createVoiceRoutes({
@@ -303,7 +303,7 @@ export function createArtifactRoutes(config) {
303
303
  const title = v.title ?? a.title;
304
304
  const summary = v.summary ?? a.summary;
305
305
  const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
306
- return (_jsxs(_Fragment, { children: [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, { a: a, n: n, at: v.at, history: history, reader: reader }), version: n, 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, { a: a, n: n, at: v.at, history: history, reader: reader })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
306
+ return (_jsxs(_Fragment, { children: [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, { a: a, n: n, at: v.at, history: history, reader: reader }), version: n, 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, { a: a, n: n, at: v.at, history: history, reader: reader })] })), 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 }) })] }));
307
307
  }
308
308
  /** The page itself, shared by open and gated pages. */
309
309
  async function Body(a, { top, reader }) {
@@ -328,8 +328,8 @@ export function createArtifactRoutes(config) {
328
328
  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, 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] }));
329
329
  return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [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,
330
330
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
331
- 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 }) }));
332
- })() }), _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: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
331
+ 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 }) }));
332
+ })() }), _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: !!reader && ((!!ownerEmail && reader.email.trim().toLowerCase() === ownerEmail.trim().toLowerCase()) || (config.admins ?? []).some((e) => e.trim().toLowerCase() === reader.email.trim().toLowerCase())), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
333
333
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
334
334
  recorded at publish, or there is none. */] }));
335
335
  }
@@ -33,6 +33,9 @@ export type StateRoutesContext = {
33
33
  /** The page owner's sign-in email. Signed in as them, a page's comments show every entry with
34
34
  * full names, whatever `comments_visible:` says. */
35
35
  ownerEmail?: string;
36
+ /** The page's admins' sign-in emails. Like the owner they read every comment with names, so a
37
+ * link to any comment opens for them. */
38
+ admins?: readonly string[];
36
39
  /** A signed URL to play a comment's recording. Called only for `comments/` paths, and only for
37
40
  * comments the asker is already being shown. Without it a recording has no play control. */
38
41
  audioUrl?: (id: string, path: string) => Promise<string>;
@@ -84,7 +84,10 @@ export function createStateRoutes(ctx) {
84
84
  return { a, reader, anonId, signIn };
85
85
  }
86
86
  const ownerEmail = ctx.ownerEmail?.trim().toLowerCase();
87
- const isOwner = (r) => !!r && !!ownerEmail && r.email === ownerEmail;
87
+ const admins = new Set((ctx.admins ?? []).map((e) => e.trim().toLowerCase()));
88
+ // The owner and the page's admins read every comment, with names.
89
+ const isOwner = (r) => !!r && ((!!ownerEmail && r.email.trim().toLowerCase() === ownerEmail) || admins.has(r.email.trim().toLowerCase()));
90
+ const declared = (v) => { const by = v?.by; return typeof by === 'string' && by.trim() ? by.trim() : null; };
88
91
  /** Comments are on for this page: the slot exists because `comments:` put it there. */
89
92
  const commentsOn = (a) => !!a.comments && a.comments !== 'off' && Object.hasOwn(a.state.slots, COMMENTS_SLOT);
90
93
  const writerFor = (r) => ({ key: readerKeyFor.signedIn(r.uid), uid: r.uid, email: r.email, name: r.name, anonymous: false });
@@ -109,7 +112,7 @@ export function createStateRoutes(ctx) {
109
112
  if (owner && commentsOn(a)) {
110
113
  slots[COMMENTS_SLOT].shared = entries.filter((e) => e.slot === COMMENTS_SLOT)
111
114
  .sort((x, y) => x.at.localeCompare(y.at) || x.id.localeCompare(y.id))
112
- .map((e) => ({ id: e.id, name: e.writer.name?.trim() || 'a reader', value: e.value, at: e.at, mine: e.readerKey === readerKey }));
115
+ .map((e) => ({ id: e.id, name: e.writer.name?.trim() || declared(e.value) || 'a reader', value: e.value, at: e.at, mine: e.readerKey === readerKey }));
113
116
  }
114
117
  if (commentsOn(a) && slots[COMMENTS_SLOT]) {
115
118
  const v = slots[COMMENTS_SLOT];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.26.0",
3
+ "version": "0.28.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",