@supersuit/artifacts 0.27.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,26 @@
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
+
6
26
  ## 0.27.0
7
27
 
8
28
  **A page can play a Messages-app phone of real threads inline: the ```messages block.** The
@@ -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.
@@ -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({
@@ -329,7 +329,7 @@ export function createArtifactRoutes(config) {
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
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 === 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,
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.27.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",