@supersuit/artifacts 0.18.0 → 0.19.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,45 @@
|
|
|
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.19.0 (2026-09-30)
|
|
7
|
+
|
|
8
|
+
**A code block has a copy button, and it wraps.** Both were found on one page: an operator
|
|
9
|
+
published a setup page to open on somebody else's laptop, where a paste-in briefing WAS the page,
|
|
10
|
+
and there was nothing to press and nothing readable to select. The block rendered 9,089px wide
|
|
11
|
+
with every line clipped at the right edge, because a fence here usually holds prose written as
|
|
12
|
+
one long line per paragraph and `white-space: pre` does not wrap those.
|
|
13
|
+
|
|
14
|
+
`render.test.tsx` had a case called *gives fenced code a copy affordance* that only ever checked
|
|
15
|
+
for the `data-artifact-code` attribute the affordance would hang on, so the host shipped with the
|
|
16
|
+
hook and no button and the suite stayed green.
|
|
17
|
+
|
|
18
|
+
- New client component `CodeCopy` (`reader/code-copy.js`), mounted by `ArtifactMarkdown` only when
|
|
19
|
+
the markdown holds a fence. It reads the `<code>`, never `pre.textContent`, so its own label
|
|
20
|
+
does not land in the paste, and it falls back to a hidden textarea where `navigator.clipboard`
|
|
21
|
+
is undefined, which is any page served over plain http.
|
|
22
|
+
- Fences render `whitespace-pre-wrap [overflow-wrap:anywhere]` with `pr-16` to clear the button.
|
|
23
|
+
Soft wrapping inserts no characters, so what a reader copies is byte for byte what was written.
|
|
24
|
+
- DETECTOR: open any page with a fenced block. No button in its top-right corner, or a long line
|
|
25
|
+
running off the right edge, means this version is not deployed.
|
|
26
|
+
- REMEDY: bump.
|
|
27
|
+
- Tests: one copy button per block and it copies that block; the label stays out of the paste;
|
|
28
|
+
the no-clipboard fallback; nothing added twice and everything removed on unmount; the fence
|
|
29
|
+
carries `whitespace-pre-wrap`; and the mount happens only when a fence is present. The wrap
|
|
30
|
+
test is red against 0.18.1.
|
|
31
|
+
|
|
32
|
+
## 0.18.1 (2026-09-29)
|
|
33
|
+
|
|
34
|
+
**On a phone, the Comment button sits at the top of the screen.** It used to appear just under
|
|
35
|
+
the selection, which is exactly where iOS draws its own Copy / Look Up menu, so the two fought for
|
|
36
|
+
the same tap (the operator, on an iPhone: *"I keep trying to fight with the Apple pop-up"*). With a
|
|
37
|
+
coarse pointer the button is fixed at the top, below the safe area; a mouse keeps it by the text.
|
|
38
|
+
|
|
39
|
+
- New export `onTouch()` from the comment layer (the `(pointer: coarse)` test it uses).
|
|
40
|
+
- DETECTOR: on a phone, the Comment button appears beside the selection.
|
|
41
|
+
- REMEDY: bump.
|
|
42
|
+
- Tests: a GUARD that a coarse pointer puts the button at the top (`data-comment-chip-at="top"`,
|
|
43
|
+
fixed, below `safe-area-inset-top`); the mouse test asserts `selection`.
|
|
44
|
+
|
|
6
45
|
## 0.18.0 (2026-09-29)
|
|
7
46
|
|
|
8
47
|
**No page is ever read by the browser's own voice, and a page can turn its audio off.** 0.10.0
|
package/lib/artifacts/render.js
CHANGED
|
@@ -13,6 +13,7 @@ import { unified } from 'unified';
|
|
|
13
13
|
import { blocksOf, hasNotesWidget, headingsOf } from './widgets.js';
|
|
14
14
|
import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
|
|
15
15
|
import { VideoAutoplay } from '../reader/video-autoplay.js';
|
|
16
|
+
import { CodeCopy } from '../reader/code-copy.js';
|
|
16
17
|
import { DefinitionLayer } from '../reader/definition-layer.js';
|
|
17
18
|
import { remarkDefinitions } from './definitions.js';
|
|
18
19
|
// Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
|
|
@@ -66,6 +67,9 @@ const VIDEO = /\.mp4(?:[?#].*)?$/i;
|
|
|
66
67
|
* nothing until one is pressed. The alt is its caption, shown above the player. */
|
|
67
68
|
const AUDIO = /\.mp3(?:[?#].*)?$/i;
|
|
68
69
|
const VIDEO_IN_MD = /\]\([^)\s]+\.mp4(?:[?#][^)]*)?\)/i;
|
|
70
|
+
// A fenced block, which `links` and `notes` fences are too; mounting for those costs one
|
|
71
|
+
// no-op effect and is cheaper than parsing the markdown twice to find out.
|
|
72
|
+
const CODE_IN_MD = /^```/m;
|
|
69
73
|
const escapeAttr = (s) => s.replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
70
74
|
// Written as markup, not JSX: React renders `muted` as a property and leaves it out of server
|
|
71
75
|
// HTML, and iOS refuses to play a video unprompted whose markup is not muted. `playsinline` keeps
|
|
@@ -134,7 +138,13 @@ function baseComponents(block, figure) {
|
|
|
134
138
|
const cls = inner?.props?.className ?? '';
|
|
135
139
|
if (/language-(links|notes)/.test(cls))
|
|
136
140
|
return _jsx(_Fragment, { children: children });
|
|
137
|
-
return (_jsx("pre", { ...block(node), "data-artifact-code": true,
|
|
141
|
+
return (_jsx("pre", { ...block(node), "data-artifact-code": true,
|
|
142
|
+
// whitespace-pre-wrap, because a fence here usually holds PROSE meant to be pasted
|
|
143
|
+
// somewhere, written as one long line per paragraph. Under `pre` those lines do not
|
|
144
|
+
// wrap: a 7 KB briefing rendered as a box 9,089px wide with every line clipped at the
|
|
145
|
+
// right edge (2026-09-30). Soft wrapping inserts no characters, so what the reader
|
|
146
|
+
// copies is still byte for byte what the author wrote.
|
|
147
|
+
className: "my-6 overflow-x-auto whitespace-pre-wrap [overflow-wrap:anywhere] rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-code)] p-4 pr-16 text-sm text-[color:var(--a-strong)]", children: children }));
|
|
138
148
|
},
|
|
139
149
|
blockquote: ({ node, children }) => {
|
|
140
150
|
const t = textOf(children).trim();
|
|
@@ -268,5 +278,5 @@ export function ArtifactMarkdown({ markdown, notes, definitions, Section, figure
|
|
|
268
278
|
const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, children: markdown }));
|
|
269
279
|
return (
|
|
270
280
|
// break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
|
|
271
|
-
_jsxs("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: [on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body, VIDEO_IN_MD.test(markdown) ? _jsx(VideoAutoplay, {}) : null, defined ? (_jsxs(_Fragment, { children: [_jsx("style", { children: DEFINED_TERM_CSS }), _jsx(DefinitionLayer, {})] })) : null] }));
|
|
281
|
+
_jsxs("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: [on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body, VIDEO_IN_MD.test(markdown) ? _jsx(VideoAutoplay, {}) : null, CODE_IN_MD.test(markdown) ? _jsx(CodeCopy, {}) : null, defined ? (_jsxs(_Fragment, { children: [_jsx("style", { children: DEFINED_TERM_CSS }), _jsx(DefinitionLayer, {})] })) : null] }));
|
|
272
282
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function CodeCopy(): null;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// A copy button on every code block, and it is the point of a code block here rather than a
|
|
3
|
+
// convenience on top of it. This host's fences mostly hold something the reader is meant to
|
|
4
|
+
// PUT somewhere: a command to run, a prompt to paste into their own agent. Until now the only
|
|
5
|
+
// way to take one was to select it by hand, and on a phone, inside a horizontally scrolling
|
|
6
|
+
// box, that is most of a minute of fighting the selection handles.
|
|
7
|
+
//
|
|
8
|
+
// Earned 2026-09-30: an operator published a setup page to open on somebody else's laptop,
|
|
9
|
+
// with the paste-in block as the whole reason the page existed, and there was nothing to press.
|
|
10
|
+
import { useEffect } from 'react';
|
|
11
|
+
const COPY = 'Copy';
|
|
12
|
+
const DONE = 'Copied';
|
|
13
|
+
export function CodeCopy() {
|
|
14
|
+
useEffect(() => {
|
|
15
|
+
const pres = Array.from(document.querySelectorAll('pre[data-artifact-code]'));
|
|
16
|
+
const timers = [];
|
|
17
|
+
const added = [];
|
|
18
|
+
for (const pre of pres) {
|
|
19
|
+
if (pre.querySelector('[data-artifact-copy]'))
|
|
20
|
+
continue;
|
|
21
|
+
// The button is positioned against the pre, so the pre becomes the containing block. It
|
|
22
|
+
// is set here rather than in the class list because the server render must stay valid
|
|
23
|
+
// with no JavaScript: a page with this component disabled shows a plain block.
|
|
24
|
+
pre.style.position = 'relative';
|
|
25
|
+
const btn = document.createElement('button');
|
|
26
|
+
btn.type = 'button';
|
|
27
|
+
btn.dataset.artifactCopy = 'true';
|
|
28
|
+
btn.textContent = COPY;
|
|
29
|
+
btn.setAttribute('aria-label', 'Copy this block');
|
|
30
|
+
btn.className =
|
|
31
|
+
'absolute right-2 top-2 rounded-md border px-2 py-1 text-xs opacity-70 ' +
|
|
32
|
+
'transition hover:opacity-100 focus-visible:opacity-100';
|
|
33
|
+
btn.style.borderColor = 'var(--a-line)';
|
|
34
|
+
btn.style.color = 'var(--a-strong)';
|
|
35
|
+
btn.style.background = 'var(--a-code)';
|
|
36
|
+
btn.addEventListener('click', () => {
|
|
37
|
+
// The button lives inside the pre, so its own label would be copied with the code.
|
|
38
|
+
const code = pre.querySelector('code');
|
|
39
|
+
const text = (code ?? pre).textContent ?? '';
|
|
40
|
+
const done = () => {
|
|
41
|
+
btn.textContent = DONE;
|
|
42
|
+
timers.push(setTimeout(() => { btn.textContent = COPY; }, 1600));
|
|
43
|
+
};
|
|
44
|
+
// navigator.clipboard is undefined on a page served over plain http, which is every
|
|
45
|
+
// local preview, so the textarea path is a real fallback rather than old-browser
|
|
46
|
+
// defensiveness.
|
|
47
|
+
if (navigator.clipboard?.writeText) {
|
|
48
|
+
navigator.clipboard.writeText(text).then(done, () => { btn.textContent = 'Press to select'; });
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const ta = document.createElement('textarea');
|
|
52
|
+
ta.value = text;
|
|
53
|
+
ta.style.position = 'fixed';
|
|
54
|
+
ta.style.opacity = '0';
|
|
55
|
+
document.body.appendChild(ta);
|
|
56
|
+
ta.select();
|
|
57
|
+
try {
|
|
58
|
+
document.execCommand('copy');
|
|
59
|
+
done();
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
btn.textContent = 'Press to select';
|
|
63
|
+
}
|
|
64
|
+
ta.remove();
|
|
65
|
+
});
|
|
66
|
+
pre.appendChild(btn);
|
|
67
|
+
added.push(btn);
|
|
68
|
+
}
|
|
69
|
+
return () => {
|
|
70
|
+
for (const t of timers)
|
|
71
|
+
clearTimeout(t);
|
|
72
|
+
for (const b of added)
|
|
73
|
+
b.remove();
|
|
74
|
+
};
|
|
75
|
+
}, []);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { type CommentsMode } from '../artifacts/comments.js';
|
|
2
2
|
import { type VoiceHost } from './voice-recorder.js';
|
|
3
3
|
import { type DeviceAudio } from './device-audio.js';
|
|
4
|
+
/** A finger rather than a mouse: the comment button then goes where the OS menu is not. */
|
|
5
|
+
export declare function onTouch(): boolean;
|
|
4
6
|
export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }: {
|
|
5
7
|
artifactId: string;
|
|
6
8
|
/** The element holding the page's prose, the same root the read-aloud lights. */
|
|
@@ -74,6 +74,10 @@ const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map
|
|
|
74
74
|
id: n.id, kind: 'personal', where, value: n.value, name: 'You', mine: true, at: n.at,
|
|
75
75
|
...(typeof n.audioUrl === 'string' ? { audioUrl: n.audioUrl } : {}),
|
|
76
76
|
}));
|
|
77
|
+
/** A finger rather than a mouse: the comment button then goes where the OS menu is not. */
|
|
78
|
+
export function onTouch() {
|
|
79
|
+
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
|
|
80
|
+
}
|
|
77
81
|
export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }) {
|
|
78
82
|
const host = useMemo(() => voice ?? hostVoice(artifactId), [voice, artifactId]);
|
|
79
83
|
const audioDb = useMemo(() => audioStore ?? defaultDeviceAudio, [audioStore]);
|
|
@@ -464,7 +468,11 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
464
468
|
const open = layout.placed.find((p) => p.item.id === thread);
|
|
465
469
|
const openItem = open?.item ?? layout.earlier.find((i) => i.id === thread);
|
|
466
470
|
const count = tops.length;
|
|
467
|
-
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 ? (
|
|
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 ? (
|
|
472
|
+
// On a phone the button sits at the TOP of the screen, never beside the selection: that is
|
|
473
|
+
// where iOS draws its own Copy / Look Up menu, and the two fought for the same tap (Gary,
|
|
474
|
+
// 2026-09-29: "I keep trying to fight with the Apple pop-up"). A mouse keeps it by the text.
|
|
475
|
+
_jsx("button", { type: "button", "data-comment-chip": true, "data-comment-chip-at": onTouch() ? 'top' : 'selection', className: onTouch() ? 'fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-5 py-2 text-sm font-medium shadow-lg' : 'absolute z-50 -translate-x-full rounded-full px-3 py-1 text-xs font-medium shadow-lg', style: onTouch() ? { top: 'calc(env(safe-area-inset-top, 0px) + 12px)', background: accent, color: 'var(--a-on-accent, #111)' } : { left: chip.x, top: chip.y, background: accent, color: 'var(--a-on-accent, #111)' }, onMouseDown: (e) => e.preventDefault(), onClick: () => { setDraft(chip); setChip(null); }, children: "Comment" })) : null, draft ? (_jsx(Floating, { x: draft.x, y: draft.y, children: _jsx(CommentCard, { ownerName: ownerName, mode: comments, canShare: canShare, signedIn: signedIn && onServer, quote: draft.quote, accent: accent, signIn: signIn, voice: host, onSave: (body, share, memo) => saveNew(body, share, draft, memo), onCancel: () => setDraft(null) }) })) : null, openItem && open ? (_jsx(Floating, { x: open.left + 28, y: open.top, children: _jsx(Thread, { item: openItem, replies: items.filter((i) => i.value.parent === openItem.id), canReply: openItem.kind === 'shared' && canShare, accent: accent, audioDb: audioDb, onClose: () => setThread(null), onRemove: remove, onEdit: edit, onReply: reply }) })) : null, drag ? (_jsx("div", { "aria-hidden": true, className: "pointer-events-none fixed z-40 rounded-md border-2 border-dashed", style: { left: Math.min(drag.x1, drag.x2), top: Math.min(drag.y1, drag.y2), width: Math.abs(drag.x2 - drag.x1), height: Math.abs(drag.y2 - drag.y1), borderColor: accent, background: `color-mix(in srgb, ${accent} 12%, transparent)` } })) : null, _jsxs("div", { className: "fixed right-4 z-40 flex flex-col items-end gap-2", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)' }, children: [panel ? (_jsxs("div", { "data-comment-panel": true, className: "w-64 rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-xs text-[color:var(--a-strong)] shadow-xl", children: [_jsx("p", { className: "opacity-80", children: "Select any text, or hold and drag a box over anything, to leave a comment." }), isOwner ? _jsx("p", { "data-comment-owner": true, className: "mt-1 opacity-80", children: "This is your page: you see every comment, with the name of who left it." }) : null, _jsx("button", { type: "button", "data-comment-pins-toggle": true, className: "mt-2 underline", onClick: () => setShowPins((v) => !v), children: showPins ? 'Hide comments' : 'Show comments' })] })) : null, _jsx("button", { type: "button", "data-comment-button": true, "aria-expanded": panel, onClick: () => setPanel((v) => !v), className: "rounded-full border px-3 py-1.5 text-xs font-medium shadow-lg backdrop-blur", style: { borderColor: accent, color: accent, background: `color-mix(in srgb, ${ground} 85%, transparent)` }, children: count ? `Comments · ${count}` : 'Comment' })] })] })) : null;
|
|
468
476
|
return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": canShare ? 'yes' : 'no' }), mounted && layout.earlier.length ? (_jsxs("section", { "data-comments-earlier": true, "data-comment-ui": true, "data-nospeak": true, className: "mx-auto max-w-2xl px-6 pb-16 text-sm", children: [_jsx("p", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: "Comments on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-3 p-0", children: layout.earlier.map((item) => (_jsxs("li", { "data-comment-earlier-id": item.id, ...(highlight === item.id ? { 'data-comment-highlight': '' } : {}), className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: accent, borderStyle: item.kind === 'personal' ? 'dashed' : 'solid', ...(highlight === item.id ? { background: `color-mix(in srgb, ${accent} 14%, transparent)` } : {}) }, children: [item.value.anchor.kind === 'text' ? _jsxs("span", { className: "block text-[13px] italic opacity-70", children: ["\u201C", item.value.anchor.quote, "\u201D"] }) : _jsx("span", { className: "block text-[13px] italic opacity-70", children: "A box on part of the page that has changed" }), _jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: item.value.body }), _jsxs("span", { className: "block text-xs opacity-60", children: [item.kind === 'personal' ? 'only you' : item.name, ` · version ${item.value.version}`, item.mine ? _jsx("button", { type: "button", onClick: () => void remove(item), className: "ml-2 underline", children: "delete" }) : null] })] }, item.id))) })] })) : null, overlay ? createPortal(overlay, document.body) : null] }));
|
|
469
477
|
}
|
|
470
478
|
function Pin({ item, left, top, accent, ground, onOpen }) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.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",
|