@supersuit/artifacts 0.27.0 → 0.29.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,41 @@
|
|
|
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.29.0
|
|
7
|
+
|
|
8
|
+
**The "comment can't be shown" notice is a readable card with a way in.** 0.28.0 drew it as a pill
|
|
9
|
+
across the top of the page, over the title, and told a signed-out reader to sign in with nothing to
|
|
10
|
+
press.
|
|
11
|
+
|
|
12
|
+
- A card at the top of the screen, inset from the edges, at most 24rem wide, with the message, a
|
|
13
|
+
close button and, for a signed-out reader, a **Sign in** button.
|
|
14
|
+
- Sign in returns to the exact address, `#comment-<id>` included, so the reader lands on the
|
|
15
|
+
comment once signed in.
|
|
16
|
+
- DETECTOR: open a link to a comment you cannot see, signed out. A pill with no button means 0.28.0.
|
|
17
|
+
- REMEDY: update the dependency.
|
|
18
|
+
- Tests: `comment-layer.test.tsx` (the Sign in link returns to the comment's address). `test:packed`
|
|
19
|
+
green.
|
|
20
|
+
|
|
21
|
+
## 0.28.0
|
|
22
|
+
|
|
23
|
+
**A link to a comment opens for the page's admins, and says so when it cannot.** On an owner-only
|
|
24
|
+
page (`comments_visible: owner`, the default) the state API showed every comment to exactly one
|
|
25
|
+
person, `ownerEmail`. An admin opening a comment's link (the link in every comment notification)
|
|
26
|
+
got the page with nothing to scroll to, and nothing said why.
|
|
27
|
+
|
|
28
|
+
- `StateRoutesContext.admins` (passed from `ArtifactRoutesConfig.admins`, which version history
|
|
29
|
+
already used): admins, like the owner, read every comment with names and see the owner panel.
|
|
30
|
+
- The owner's view names an anonymous commenter by the name they declared (0.24.0's `by`),
|
|
31
|
+
where it said "a reader".
|
|
32
|
+
- A `#comment-<id>` link to a comment this reader cannot see now shows a closable line: "That
|
|
33
|
+
comment can't be shown to you. Comments on this page are visible to its owner and admins", with
|
|
34
|
+
"sign in if that is you" when signed out. It used to do nothing.
|
|
35
|
+
- DETECTOR: as an admin who is not `ownerEmail`, open a comment notification's link on an
|
|
36
|
+
owner-only page. No pin and no thread means 0.27.0 or earlier.
|
|
37
|
+
- REMEDY: update the dependency; a host that sets `admins` needs no other change.
|
|
38
|
+
- Tests: `comments.test.tsx` (an admin sees every comment with names; the owner sees a declared
|
|
39
|
+
name), `comment-layer.test.tsx` (an unseeable link says so). `test:packed` green.
|
|
40
|
+
|
|
6
41
|
## 0.27.0
|
|
7
42
|
|
|
8
43
|
**A page can play a Messages-app phone of real threads inline: the ```messages block.** The
|
|
@@ -37,6 +37,18 @@ import { boxOf, headingAt, indexText, offsetAt, placeAnchor, regionFrom } from '
|
|
|
37
37
|
const isValue = (v) => !!v && typeof v === 'object' && typeof v.body === 'string' && !!v.anchor && typeof v.anchor === 'object';
|
|
38
38
|
const LONG_PRESS_MS = 400;
|
|
39
39
|
const HASH = /^#comment-([A-Za-z0-9_-]{1,128})$/;
|
|
40
|
+
/** The sign-in link, returning to this exact address, hash included, so a reader who signs in to
|
|
41
|
+
* see a linked comment lands on it. */
|
|
42
|
+
function signInBack(signIn) {
|
|
43
|
+
try {
|
|
44
|
+
const u = new URL(signIn);
|
|
45
|
+
u.searchParams.set('to', window.location.href);
|
|
46
|
+
return u.toString();
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return signIn;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
40
52
|
/** The entry id a `#comment-<id>` hash names, or null. */
|
|
41
53
|
const commentHashId = (hash) => HASH.exec(hash)?.[1] ?? null;
|
|
42
54
|
const DRAG_PX = 8;
|
|
@@ -85,6 +97,9 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
85
97
|
const personalUrl = `/api/artifacts/${artifactId}/personal`;
|
|
86
98
|
const [mounted, setMounted] = useState(false);
|
|
87
99
|
const [shared, setShared] = useState([]);
|
|
100
|
+
// Whether the shared comments have arrived, so a link to one this reader cannot see can say so.
|
|
101
|
+
const [loaded, setLoaded] = useState(false);
|
|
102
|
+
const [missing, setMissing] = useState(false);
|
|
88
103
|
const [serverNotes, setServerNotes] = useState([]);
|
|
89
104
|
const [device, setDevice] = useState([]);
|
|
90
105
|
const [onServer, setOnServer] = useState(signedIn);
|
|
@@ -124,9 +139,12 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
124
139
|
refreshDevice();
|
|
125
140
|
let live = true;
|
|
126
141
|
if (comments !== 'off') {
|
|
127
|
-
void call(stateUrl).then((r) => { if (live
|
|
128
|
-
|
|
142
|
+
void call(stateUrl).then((r) => { if (!live)
|
|
143
|
+
return; if (r.status === 200)
|
|
144
|
+
setShared(sharedFrom(r.body)); setLoaded(true); });
|
|
129
145
|
}
|
|
146
|
+
else
|
|
147
|
+
setLoaded(true);
|
|
130
148
|
if (signedIn) {
|
|
131
149
|
void (async () => {
|
|
132
150
|
const r = await call(personalUrl);
|
|
@@ -343,14 +361,20 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
343
361
|
// `tick` re-measures after a resize.
|
|
344
362
|
}, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
345
363
|
// 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
|
|
347
|
-
//
|
|
364
|
+
// Held until the comments arrive; an id that is not among them (deleted, or not this reader's to
|
|
365
|
+
// see) says so in a line, rather than leaving the reader wondering why nothing happened.
|
|
348
366
|
useEffect(() => {
|
|
349
367
|
if (!target || !mounted)
|
|
350
368
|
return;
|
|
351
369
|
const item = items.find((i) => i.id === target);
|
|
352
|
-
if (!item)
|
|
370
|
+
if (!item) {
|
|
371
|
+
if (loaded) {
|
|
372
|
+
setMissing(true);
|
|
373
|
+
setTarget(null);
|
|
374
|
+
}
|
|
353
375
|
return;
|
|
376
|
+
}
|
|
377
|
+
setMissing(false);
|
|
354
378
|
const parent = item.value.parent ? items.find((i) => i.id === item.value.parent) : undefined;
|
|
355
379
|
const id = (parent ?? item).id;
|
|
356
380
|
setTarget(null);
|
|
@@ -365,7 +389,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
365
389
|
setHighlight(id);
|
|
366
390
|
setScrollTo(`[data-comment-earlier-id="${id}"]`);
|
|
367
391
|
}
|
|
368
|
-
}, [target, mounted, items, layout]);
|
|
392
|
+
}, [target, mounted, items, layout, loaded]);
|
|
369
393
|
// Effects run after the DOM is committed, so the pin or list item drawn for the target is there.
|
|
370
394
|
useEffect(() => {
|
|
371
395
|
if (!scrollTo)
|
|
@@ -468,7 +492,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
468
492
|
const open = layout.placed.find((p) => p.item.id === thread);
|
|
469
493
|
const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
|
|
470
494
|
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 ? (
|
|
495
|
+
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 inset-x-3 top-3 z-50 mx-auto max-w-sm rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-4 font-sans text-sm leading-snug text-[color:var(--a-strong)] shadow-xl", children: [_jsxs("div", { className: "flex items-start gap-3", children: [_jsx("p", { className: "m-0 flex-1", children: "That comment can't be shown to you. Comments on this page are visible to its owner and admins." }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setMissing(false), className: "-mr-1 -mt-1 px-1 text-lg leading-none opacity-60", children: "\u00D7" })] }), !signedIn && signIn ? (_jsx("a", { "data-comment-missing-sign-in": true, href: signInBack(signIn), className: "mt-3 inline-block rounded-full px-4 py-1.5 font-medium no-underline", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: "Sign in" })) : null] })) : 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
496
|
// On a phone the button sits at the TOP of the screen, never beside the selection: that is
|
|
473
497
|
// where iOS draws its own Copy / Look Up menu, and the two fought for the same tap (Gary,
|
|
474
498
|
// 2026-09-29: "I keep trying to fight with the Apple pop-up"). A mouse keeps it by the text.
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -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
|
|
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.
|
|
3
|
+
"version": "0.29.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",
|