@supersuit/artifacts 0.23.0 → 0.24.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 +24 -0
- package/lib/artifacts/comments.d.ts +3 -0
- package/lib/artifacts/comments.js +9 -2
- package/lib/artifacts/state-view.js +8 -2
- package/lib/reader/comment-card.d.ts +3 -1
- package/lib/reader/comment-card.js +23 -5
- package/lib/reader/comment-layer.js +3 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,30 @@
|
|
|
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.24.0
|
|
7
|
+
|
|
8
|
+
**A reader is asked for their name when they comment, so the owner can credit the feedback.**
|
|
9
|
+
Comments from readers without an account arrived as "a reader", with nothing to thank or follow up
|
|
10
|
+
with.
|
|
11
|
+
|
|
12
|
+
- The comment card shows `Your name, so <owner> can credit your feedback` above the share switch
|
|
13
|
+
when the reader is not signed in and is sharing with the owner. It is optional; a comment
|
|
14
|
+
without it still saves, as "a reader". The name is remembered on the device
|
|
15
|
+
(`localStorage` key `artifacts:comment-name`), so the next comment is already signed.
|
|
16
|
+
- `CommentValue` gains `by?: string` (one line, at most 80 characters, trimmed; a blank one is
|
|
17
|
+
dropped). New export `MAX_COMMENT_NAME_CHARS`. A signed-in reader's account name always wins.
|
|
18
|
+
- The shared thread shows the declared name where it used to show "a reader", and the publisher's
|
|
19
|
+
`/responses` (JSON and CSV) carries it in `name` for an anonymous row, so feeds that already read
|
|
20
|
+
`name` (an owner's comment notifications) credit the reader with no change of their own.
|
|
21
|
+
- DETECTOR: open a page with `comments: anyone` while signed out and select text. No name field
|
|
22
|
+
in the card means the host is on 0.23.0 or earlier; a comment posted with `by` is refused as
|
|
23
|
+
"a comment is { anchor, body, version, parent? }" on those versions.
|
|
24
|
+
- REMEDY: update the dependency. No route or front-matter change.
|
|
25
|
+
- Tests: `comments.test.ts` (keeps, trims, drops blank, refuses long or multi-line names),
|
|
26
|
+
`state-view.test.ts` (declared name shown, account name wins, responses and CSV carry it, "a
|
|
27
|
+
reader" when none), `comment-card.test.tsx` (asked when signed out and sharing, remembered,
|
|
28
|
+
passed to save; not asked when signed in or for a personal note). `test:packed` green.
|
|
29
|
+
|
|
6
30
|
## 0.23.0
|
|
7
31
|
|
|
8
32
|
**A checklist can keep its ticks in the cloud, shared by everyone on the page.** Two people
|
|
@@ -15,6 +15,7 @@ export declare function mergeCommentsState(state: StateConfig | undefined, comme
|
|
|
15
15
|
error: string;
|
|
16
16
|
};
|
|
17
17
|
export declare const MAX_COMMENT_CHARS = 4000;
|
|
18
|
+
export declare const MAX_COMMENT_NAME_CHARS = 80;
|
|
18
19
|
export declare const COMMENT_TRANSCRIPTS: readonly ["browser", "host", "typed"];
|
|
19
20
|
/** One shared comment or personal note. `parent` makes it a reply to a top-level comment (one
|
|
20
21
|
* level only). `audio` and `transcript` are filled by voice memos; a memo may have an empty body. */
|
|
@@ -25,6 +26,8 @@ export type CommentValue = {
|
|
|
25
26
|
parent?: string;
|
|
26
27
|
audio?: string;
|
|
27
28
|
transcript?: (typeof COMMENT_TRANSCRIPTS)[number];
|
|
29
|
+
/** The name a reader typed so the owner can credit them. A signed-in reader's account name wins. */
|
|
30
|
+
by?: string;
|
|
28
31
|
};
|
|
29
32
|
/** The server's check on a comment, called by the state POST for the `comments` slot. `existing`
|
|
30
33
|
* returns a comment THIS WRITER can see by its entry id, so a reply can only name a thread its
|
|
@@ -14,6 +14,7 @@ export function mergeCommentsState(state, comments, visible) {
|
|
|
14
14
|
return { ok: true, state: { ...base, slots: { ...base.slots, [COMMENTS_SLOT]: slot } } };
|
|
15
15
|
}
|
|
16
16
|
export const MAX_COMMENT_CHARS = 4000;
|
|
17
|
+
export const MAX_COMMENT_NAME_CHARS = 80;
|
|
17
18
|
export const COMMENT_TRANSCRIPTS = ['browser', 'host', 'typed'];
|
|
18
19
|
const ENTRY_ID = /^[A-Za-z0-9_-]{1,128}$/;
|
|
19
20
|
// An uploaded asset path under the page (comments/<id>.webm), never a URL and never `..`.
|
|
@@ -23,8 +24,8 @@ const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
|
23
24
|
* returns a comment THIS WRITER can see by its entry id, so a reply can only name a thread its
|
|
24
25
|
* writer was shown. The browser checks too; this is the one that counts. */
|
|
25
26
|
export function validateComment(v, existing) {
|
|
26
|
-
if (!isMap(v) || Object.keys(v).some((k) => !['anchor', 'body', 'version', 'parent', 'audio', 'transcript'].includes(k)))
|
|
27
|
-
return { ok: false, error: 'a comment is { anchor, body, version, parent? }' };
|
|
27
|
+
if (!isMap(v) || Object.keys(v).some((k) => !['anchor', 'body', 'version', 'parent', 'audio', 'transcript', 'by'].includes(k)))
|
|
28
|
+
return { ok: false, error: 'a comment is { anchor, body, version, parent?, by? }' };
|
|
28
29
|
const anchor = parseAnchor(v.anchor);
|
|
29
30
|
if (!anchor)
|
|
30
31
|
return { ok: false, error: 'a comment needs the place on the page it is pinned to' };
|
|
@@ -53,5 +54,11 @@ export function validateComment(v, existing) {
|
|
|
53
54
|
value.audio = v.audio;
|
|
54
55
|
if (v.transcript !== undefined)
|
|
55
56
|
value.transcript = v.transcript;
|
|
57
|
+
if (v.by !== undefined) {
|
|
58
|
+
if (typeof v.by !== 'string' || /[\r\n]/.test(v.by) || v.by.trim().length > MAX_COMMENT_NAME_CHARS)
|
|
59
|
+
return { ok: false, error: `a comment's name is one line of at most ${MAX_COMMENT_NAME_CHARS} characters` };
|
|
60
|
+
if (v.by.trim())
|
|
61
|
+
value.by = v.by.trim();
|
|
62
|
+
}
|
|
56
63
|
return { ok: true, value };
|
|
57
64
|
}
|
|
@@ -4,6 +4,12 @@ import { createHash } from 'node:crypto';
|
|
|
4
4
|
import { SHARED_LIMIT, slotVisibility } from './state.js';
|
|
5
5
|
const byAt = (a, b) => a.at.localeCompare(b.at) || a.id.localeCompare(b.id);
|
|
6
6
|
const firstWord = (name) => (name?.trim() ? name.trim().split(/\s+/)[0] : 'a reader');
|
|
7
|
+
// The name an anonymous commenter typed into the comment box, so the owner can credit them. An
|
|
8
|
+
// account name always wins, because the account is the stronger claim to who wrote it.
|
|
9
|
+
const declared = (value) => {
|
|
10
|
+
const by = value && typeof value === 'object' && !Array.isArray(value) ? value.by : undefined;
|
|
11
|
+
return typeof by === 'string' && by.trim() ? by.trim() : null;
|
|
12
|
+
};
|
|
7
13
|
// A `one` entry's raw id is `<page>__<slot>__<readerKey>`, and the reader key inside it (`u:<uid>`
|
|
8
14
|
// or `a:<anonId>`) is a credential: whoever reads it can write as that reader by minting the same
|
|
9
15
|
// anonymous cookie. A `shared` slot handed that id straight to every reader, so it is hashed to
|
|
@@ -41,7 +47,7 @@ export function stateView(state, entries, readerKey) {
|
|
|
41
47
|
if (visibility === 'shared')
|
|
42
48
|
view.shared = here.slice(-SHARED_LIMIT).map((e) => ({
|
|
43
49
|
id: def.shape === 'one' ? opaqueOneId(e.id) : e.id,
|
|
44
|
-
name: firstWord(e.writer.name), value: e.value, at: e.at, mine: e.readerKey === readerKey,
|
|
50
|
+
name: e.writer.name?.trim() ? firstWord(e.writer.name) : (declared(e.value) ?? 'a reader'), value: e.value, at: e.at, mine: e.readerKey === readerKey,
|
|
45
51
|
}));
|
|
46
52
|
out[slot] = view;
|
|
47
53
|
}
|
|
@@ -49,7 +55,7 @@ export function stateView(state, entries, readerKey) {
|
|
|
49
55
|
}
|
|
50
56
|
export function responsesOf(entries) {
|
|
51
57
|
return entries.slice().sort(byAt).map((e) => ({
|
|
52
|
-
slot: e.slot, id: e.id, value: e.value, at: e.at, email: e.writer.email ?? null, name: e.writer.name, anonymous: e.writer.anonymous, reader: e.readerKey,
|
|
58
|
+
slot: e.slot, id: e.id, value: e.value, at: e.at, email: e.writer.email ?? null, name: e.writer.name ?? declared(e.value), anonymous: e.writer.anonymous, reader: e.readerKey,
|
|
53
59
|
}));
|
|
54
60
|
}
|
|
55
61
|
// A cell a spreadsheet reads as a formula (leading =, +, -, @, tab or CR) gets an escaping
|
|
@@ -13,6 +13,8 @@ export type SavedMemo = {
|
|
|
13
13
|
/** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
|
|
14
14
|
export declare function warningLine(ownerName: string, mode: CommentsMode): string;
|
|
15
15
|
export declare const DEVICE_LINE = "Saved on this device. Sign in to keep it everywhere.";
|
|
16
|
+
/** Where a reader's declared name is remembered, so their next comment is already signed. */
|
|
17
|
+
export declare const NAME_KEY = "artifacts:comment-name";
|
|
16
18
|
export declare function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial, accent, signIn, voice, onSave, onCancel, }: {
|
|
17
19
|
ownerName: string;
|
|
18
20
|
mode: CommentsMode;
|
|
@@ -26,6 +28,6 @@ export declare function CommentCard({ ownerName, mode, canShare, signedIn, quote
|
|
|
26
28
|
/** The page's voice routes. Null: a memo is kept but never uploaded or transcribed here. */
|
|
27
29
|
voice?: VoiceHost | null;
|
|
28
30
|
/** Resolves to an error to show, or null when saved. */
|
|
29
|
-
onSave: (body: string, share: boolean, memo?: SavedMemo) => Promise<string | null>;
|
|
31
|
+
onSave: (body: string, share: boolean, memo?: SavedMemo, by?: string) => Promise<string | null>;
|
|
30
32
|
onCancel: () => void;
|
|
31
33
|
}): import("react").JSX.Element;
|
|
@@ -11,7 +11,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
|
|
|
11
11
|
// since, in which case your words win. With neither, the memo is kept and the box stays empty.
|
|
12
12
|
import { useRef, useState } from 'react';
|
|
13
13
|
import { NO_ZOOM_FONT } from './no-zoom.js';
|
|
14
|
-
import { MAX_COMMENT_CHARS } from '../artifacts/comments.js';
|
|
14
|
+
import { MAX_COMMENT_CHARS, MAX_COMMENT_NAME_CHARS } from '../artifacts/comments.js';
|
|
15
15
|
import { VoiceRecorder } from './voice-recorder.js';
|
|
16
16
|
/** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
|
|
17
17
|
export function warningLine(ownerName, mode) {
|
|
@@ -21,6 +21,18 @@ export function warningLine(ownerName, mode) {
|
|
|
21
21
|
return `Only you will see this. ${why}, so this is a personal note. They won't see it.`;
|
|
22
22
|
}
|
|
23
23
|
export const DEVICE_LINE = 'Saved on this device. Sign in to keep it everywhere.';
|
|
24
|
+
/** Where a reader's declared name is remembered, so their next comment is already signed. */
|
|
25
|
+
export const NAME_KEY = 'artifacts:comment-name';
|
|
26
|
+
const recall = () => { try {
|
|
27
|
+
return localStorage.getItem(NAME_KEY) ?? '';
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return '';
|
|
31
|
+
} };
|
|
32
|
+
const remember = (name) => { try {
|
|
33
|
+
localStorage.setItem(NAME_KEY, name);
|
|
34
|
+
}
|
|
35
|
+
catch { /* a browser that keeps nothing still comments */ } };
|
|
24
36
|
export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial = '', accent = 'currentColor', signIn, voice = null, onSave, onCancel, }) {
|
|
25
37
|
const [text, setText] = useState(initial);
|
|
26
38
|
const [share, setShare] = useState(canShare);
|
|
@@ -29,11 +41,15 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
|
|
|
29
41
|
const [memo, setMemo] = useState(null);
|
|
30
42
|
const [transcript, setTranscript] = useState(undefined);
|
|
31
43
|
const [status, setStatus] = useState(null);
|
|
44
|
+
const [name, setName] = useState(recall);
|
|
32
45
|
// Refs, because the host's transcript arrives after an await and must see the box as it is then.
|
|
33
46
|
const base = useRef('');
|
|
34
47
|
const edited = useRef(false);
|
|
35
48
|
const uploaded = useRef(null);
|
|
36
49
|
const personal = !canShare || !share;
|
|
50
|
+
// A reader with no account is asked for a name, so the owner can credit the feedback. Optional:
|
|
51
|
+
// a comment without one still goes through, signed "a reader".
|
|
52
|
+
const askName = !personal && !signedIn;
|
|
37
53
|
// Where a memo goes: a shared comment, the reader's account, or (neither) this device only.
|
|
38
54
|
const scope = () => (canShare && share ? 'comment' : signedIn ? 'personal' : null);
|
|
39
55
|
const recorded = async (m) => {
|
|
@@ -61,12 +77,14 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
|
|
|
61
77
|
if ((!body && !memo) || busy)
|
|
62
78
|
return;
|
|
63
79
|
setBusy(true);
|
|
64
|
-
const
|
|
65
|
-
|
|
66
|
-
|
|
80
|
+
const by = askName && name.trim() ? name.trim() : undefined;
|
|
81
|
+
if (by)
|
|
82
|
+
remember(by);
|
|
83
|
+
const saved = memo ? { memo, uploaded: uploaded.current, ...(body && transcript ? { transcript } : {}) } : undefined;
|
|
84
|
+
const err = saved || by ? await onSave(body, canShare && share, saved, by) : await onSave(body, canShare && share);
|
|
67
85
|
setBusy(false);
|
|
68
86
|
setError(err);
|
|
69
87
|
};
|
|
70
88
|
return (_jsxs("form", { "data-comment-card": true, "data-comment-ui": true, "data-nospeak": true, onSubmit: submit, className: "w-[min(22rem,calc(100vw-2rem))] rounded-xl border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-left font-sans text-sm text-[color:var(--a-strong)] shadow-xl", children: [!canShare ? (_jsx("p", { "data-comment-warning": true, role: "note", className: "mb-2 rounded-lg border px-3 py-2 text-[13px] leading-snug", style: { borderColor: accent }, children: warningLine(ownerName, mode) })) : null, quote ? _jsx("p", { className: "mb-2 line-clamp-3 border-l-2 pl-2 text-[13px] italic opacity-75", style: { borderColor: accent }, children: quote }) : null, _jsx("textarea", { value: text, autoFocus: true, style: NO_ZOOM_FONT, onChange: (e) => { setText(e.target.value); edited.current = true; setTranscript('typed'); }, onKeyDown: (e) => { if (e.key === 'Enter' && (e.metaKey || e.ctrlKey))
|
|
71
|
-
void submit(); }, maxLength: MAX_COMMENT_CHARS, rows: 3, "aria-label": personal ? 'A personal note' : `A comment for ${ownerName}`, placeholder: personal ? 'A note for yourself' : `A comment for ${ownerName}`, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] p-2 text-[15px]" }), _jsx(VoiceRecorder, { accent: accent, onStart: () => { base.current = text.trim(); setMemo(null); setTranscript(undefined); setError(null); }, onLive: (heard) => { setText(base.current ? `${base.current} ${heard}` : heard); setTranscript('browser'); }, onStop: (m) => void recorded(m), onError: setError }), memo ? (_jsxs("p", { "data-voice-kept": true, className: "m-0 mt-1 text-xs opacity-70", children: [status ?? 'Recording kept with this comment.', _jsx("button", { type: "button", "data-voice-discard": true, className: "ml-2 underline", onClick: () => { setMemo(null); uploaded.current = null; }, children: "Discard recording" })] })) : null, canShare ? (_jsxs("fieldset", { className: "mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[13px]", children: [_jsx("legend", { className: "sr-only", children: "Who sees this" }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "share", checked: share, onChange: () => setShare(true) }), `Share with ${ownerName}`] }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "mine", checked: !share, onChange: () => setShare(false) }), "Just for me"] })] })) : null, personal && !signedIn ? (_jsx("p", { "data-comment-device": true, className: "mt-2 text-xs opacity-70", children: signIn ? _jsxs(_Fragment, { children: ["Saved on this device. ", _jsx("a", { href: signIn, className: "underline", children: "Sign in to keep it everywhere." })] }) : DEVICE_LINE })) : null, error ? _jsx("p", { role: "alert", className: "mt-2 text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx("button", { type: "submit", "data-comment-save": true, disabled: busy || (!text.trim() && !memo), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: personal ? 'Save note' : 'Comment' }), _jsx("button", { type: "button", onClick: onCancel, className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
|
|
89
|
+
void submit(); }, maxLength: MAX_COMMENT_CHARS, rows: 3, "aria-label": personal ? 'A personal note' : `A comment for ${ownerName}`, placeholder: personal ? 'A note for yourself' : `A comment for ${ownerName}`, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] p-2 text-[15px]" }), _jsx(VoiceRecorder, { accent: accent, onStart: () => { base.current = text.trim(); setMemo(null); setTranscript(undefined); setError(null); }, onLive: (heard) => { setText(base.current ? `${base.current} ${heard}` : heard); setTranscript('browser'); }, onStop: (m) => void recorded(m), onError: setError }), memo ? (_jsxs("p", { "data-voice-kept": true, className: "m-0 mt-1 text-xs opacity-70", children: [status ?? 'Recording kept with this comment.', _jsx("button", { type: "button", "data-voice-discard": true, className: "ml-2 underline", onClick: () => { setMemo(null); uploaded.current = null; }, children: "Discard recording" })] })) : null, askName ? (_jsxs("label", { "data-comment-name": true, className: "mt-2 block text-[13px]", children: [_jsx("span", { className: "block opacity-80", children: `Your name, so ${ownerName} can credit your feedback` }), _jsx("input", { value: name, onChange: (e) => setName(e.target.value), maxLength: MAX_COMMENT_NAME_CHARS, autoComplete: "name", placeholder: "Your name", style: NO_ZOOM_FONT, className: "mt-1 w-full rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-ground,transparent)] px-2 py-1 text-[15px]" })] })) : null, canShare ? (_jsxs("fieldset", { className: "mt-2 flex flex-wrap gap-x-4 gap-y-1 text-[13px]", children: [_jsx("legend", { className: "sr-only", children: "Who sees this" }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "share", checked: share, onChange: () => setShare(true) }), `Share with ${ownerName}`] }), _jsxs("label", { className: "flex items-center gap-1.5", children: [_jsx("input", { type: "radio", name: "comment-share", "data-comment-share": "mine", checked: !share, onChange: () => setShare(false) }), "Just for me"] })] })) : null, personal && !signedIn ? (_jsx("p", { "data-comment-device": true, className: "mt-2 text-xs opacity-70", children: signIn ? _jsxs(_Fragment, { children: ["Saved on this device. ", _jsx("a", { href: signIn, className: "underline", children: "Sign in to keep it everywhere." })] }) : DEVICE_LINE })) : null, error ? _jsx("p", { role: "alert", className: "mt-2 text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-3 flex items-center gap-3", children: [_jsx("button", { type: "submit", "data-comment-save": true, disabled: busy || (!text.trim() && !memo), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: personal ? 'Save note' : 'Comment' }), _jsx("button", { type: "button", onClick: onCancel, className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
|
|
72
90
|
}
|
|
@@ -377,8 +377,8 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
377
377
|
* there, uploaded now when the reader switched between Share and Just for me after recording. */
|
|
378
378
|
const memoPath = async (m, scope) => m.uploaded?.scope === scope ? m.uploaded.path : host.upload(m.memo, scope);
|
|
379
379
|
const LOST = 'The recording did not upload. Try again, or discard it and save the text.';
|
|
380
|
-
const saveNew = async (body, share, d, memo) => {
|
|
381
|
-
const value = { anchor: d.anchor, body, version, ...(memo?.transcript ? { transcript: memo.transcript } : {}) };
|
|
380
|
+
const saveNew = async (body, share, d, memo, by) => {
|
|
381
|
+
const value = { anchor: d.anchor, body, version, ...(memo?.transcript ? { transcript: memo.transcript } : {}), ...(share && by ? { by } : {}) };
|
|
382
382
|
const toDevice = !share && !(signedIn && onServer);
|
|
383
383
|
if (memo && !toDevice) {
|
|
384
384
|
const path = await memoPath(memo, share ? 'comment' : 'personal');
|
|
@@ -472,7 +472,7 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
472
472
|
// On a phone the button sits at the TOP of the screen, never beside the selection: that is
|
|
473
473
|
// where iOS draws its own Copy / Look Up menu, and the two fought for the same tap (Gary,
|
|
474
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;
|
|
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, by) => saveNew(body, share, draft, memo, by), 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;
|
|
476
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] }));
|
|
477
477
|
}
|
|
478
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.24.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",
|