@supersuit/artifacts 0.38.0 → 0.40.1
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 +117 -0
- package/README.md +88 -5
- package/lib/artifacts/comments.d.ts +9 -0
- package/lib/artifacts/comments.js +6 -0
- package/lib/artifacts/embed-frame.d.ts +5 -0
- package/lib/artifacts/embed-frame.js +23 -0
- package/lib/artifacts/front-matter.d.ts +3 -0
- package/lib/artifacts/front-matter.js +8 -1
- package/lib/artifacts/index.d.ts +2 -2
- package/lib/artifacts/index.js +2 -2
- package/lib/artifacts/recipients-store.d.ts +15 -6
- package/lib/artifacts/recipients-store.js +29 -4
- package/lib/artifacts/recipients.d.ts +9 -0
- package/lib/artifacts/recipients.js +16 -0
- package/lib/artifacts/render.js +14 -1
- package/lib/artifacts/simulator.d.ts +7 -0
- package/lib/artifacts/simulator.js +32 -1
- package/lib/artifacts/state-store.d.ts +5 -0
- package/lib/artifacts/state-store.js +5 -0
- package/lib/artifacts/store.d.ts +1 -0
- package/lib/artifacts/store.js +2 -0
- package/lib/reader/comment-card.d.ts +10 -2
- package/lib/reader/comment-card.js +15 -9
- package/lib/reader/comment-layer.d.ts +7 -1
- package/lib/reader/comment-layer.js +74 -15
- package/lib/reader/feedback-window.d.ts +13 -0
- package/lib/reader/feedback-window.js +39 -0
- package/lib/routes/artifacts.d.ts +5 -0
- package/lib/routes/artifacts.js +5 -4
- package/lib/routes/per-reader.d.ts +2 -0
- package/lib/routes/per-reader.js +1 -1
- package/lib/routes/recipients-routes.js +22 -4
- package/lib/routes/state-routes.d.ts +1 -0
- package/lib/routes/state-routes.js +132 -11
- package/package.json +1 -1
|
@@ -32,3 +32,10 @@ export declare function parseSimulatorFence(value: string, line: number): {
|
|
|
32
32
|
error: string;
|
|
33
33
|
};
|
|
34
34
|
export declare const simulatorSrc: (embed: string, scenario: string) => string | null;
|
|
35
|
+
/** The message an embed page sends its frame, and the heights a frame will take. */
|
|
36
|
+
export declare const FIT_MESSAGE = "embed:fit";
|
|
37
|
+
export declare const FIT_MIN = 240;
|
|
38
|
+
export declare const FIT_MAX = 2000;
|
|
39
|
+
/** The height (px) a frame takes from a message, or null: only `embed:fit` with a finite number,
|
|
40
|
+
* only from the origin of the page the frame loaded, and never outside FIT_MIN..FIT_MAX. */
|
|
41
|
+
export declare function fitFrom(data: unknown, origin: string, src: string): number | null;
|
|
@@ -11,8 +11,16 @@
|
|
|
11
11
|
// (ArtifactRoutesConfig.simulatorEmbed / messagesEmbed), so this public package points at nobody's
|
|
12
12
|
// service by default, and a host without the player draws the caption alone rather than the block's
|
|
13
13
|
// source as code. `simulator` keeps the syntax the Superskills book's reader takes.
|
|
14
|
+
//
|
|
15
|
+
// A FRAME THAT FITS (0.39.0). `height:` is the height a frame OPENS at, and the room its player is
|
|
16
|
+
// given. A session demo draws a different device for each viewer (a short laptop on a desktop, a
|
|
17
|
+
// tall phone on a phone), so no one height suits both: the frame asked its embed page `fit=content`,
|
|
18
|
+
// and a page that understands posts `{ type: 'embed:fit', height: <px> }` to its parent, which the
|
|
19
|
+
// frame then takes (EmbedFrame). A page that posts nothing keeps the block's height. The embed
|
|
20
|
+
// page must hold its player's size once asked, never size it from its own viewport, or the two
|
|
21
|
+
// chase each other; that is why the frame listens only for this message and for no player's own.
|
|
14
22
|
const KINDS = {
|
|
15
|
-
simulator: { key: 'scenario', required: true, fixed: { timeline: 'off' }, height: '720px' },
|
|
23
|
+
simulator: { key: 'scenario', required: true, fixed: { timeline: 'off', fit: 'content' }, height: '720px' },
|
|
16
24
|
messages: { key: 'chat', required: false, fixed: {}, height: '760px' },
|
|
17
25
|
};
|
|
18
26
|
export const EMBED_KINDS = Object.keys(KINDS);
|
|
@@ -74,3 +82,26 @@ export function parseSimulatorFence(value, line) {
|
|
|
74
82
|
return r.ok ? { ok: true, block: { scenario: r.block.value, caption: r.block.caption, height: r.block.height } } : r;
|
|
75
83
|
}
|
|
76
84
|
export const simulatorSrc = (embed, scenario) => embedSrc('simulator', embed, scenario);
|
|
85
|
+
/** The message an embed page sends its frame, and the heights a frame will take. */
|
|
86
|
+
export const FIT_MESSAGE = 'embed:fit';
|
|
87
|
+
export const FIT_MIN = 240;
|
|
88
|
+
export const FIT_MAX = 2000;
|
|
89
|
+
/** The height (px) a frame takes from a message, or null: only `embed:fit` with a finite number,
|
|
90
|
+
* only from the origin of the page the frame loaded, and never outside FIT_MIN..FIT_MAX. */
|
|
91
|
+
export function fitFrom(data, origin, src) {
|
|
92
|
+
if (!data || typeof data !== 'object')
|
|
93
|
+
return null;
|
|
94
|
+
const d = data;
|
|
95
|
+
if (d.type !== FIT_MESSAGE || typeof d.height !== 'number' || !Number.isFinite(d.height))
|
|
96
|
+
return null;
|
|
97
|
+
let expected;
|
|
98
|
+
try {
|
|
99
|
+
expected = new URL(src).origin;
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
if (origin !== expected)
|
|
105
|
+
return null;
|
|
106
|
+
return Math.max(FIT_MIN, Math.min(FIT_MAX, Math.ceil(d.height)));
|
|
107
|
+
}
|
|
@@ -82,6 +82,11 @@ export declare const readerKeyFor: {
|
|
|
82
82
|
signedIn: (uid: string) => string;
|
|
83
83
|
anonymous: (id: string) => string;
|
|
84
84
|
recipient: (rid: string) => string;
|
|
85
|
+
/** One browser on a group link, on a page with `comment_name: required`: the link and the id
|
|
86
|
+
* `browserIdFor` derives. `ridOfReaderKey` reads the link back out of either `r:` shape. */
|
|
87
|
+
groupBrowser: (rid: string, browser: string) => string;
|
|
88
|
+
/** The publisher's one reply in a thread, keyed by the thread's top comment. */
|
|
89
|
+
publisher: (entryId: string) => string;
|
|
85
90
|
};
|
|
86
91
|
export declare function createStateStore(db: Firestore, base: string): StateStore;
|
|
87
92
|
/** The same contract in memory: for tests, and for hosts' own tests. */
|
|
@@ -18,6 +18,11 @@ export const readerKeyFor = {
|
|
|
18
18
|
signedIn: (uid) => `u:${uid}`,
|
|
19
19
|
anonymous: (id) => `a:${id}`,
|
|
20
20
|
recipient: (rid) => `r:${rid}`,
|
|
21
|
+
/** One browser on a group link, on a page with `comment_name: required`: the link and the id
|
|
22
|
+
* `browserIdFor` derives. `ridOfReaderKey` reads the link back out of either `r:` shape. */
|
|
23
|
+
groupBrowser: (rid, browser) => `r:${rid}.${browser}`,
|
|
24
|
+
/** The publisher's one reply in a thread, keyed by the thread's top comment. */
|
|
25
|
+
publisher: (entryId) => `p:${entryId}`,
|
|
21
26
|
};
|
|
22
27
|
const writerOf = (w) => ({
|
|
23
28
|
...(w.uid ? { uid: w.uid } : {}), ...(w.email ? { email: w.email } : {}), name: w.name, anonymous: w.anonymous,
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -32,6 +32,7 @@ export type ArtifactRecord = {
|
|
|
32
32
|
/** The brand pack the page asked for by name; see ArtifactMeta.pack. */
|
|
33
33
|
pack?: string;
|
|
34
34
|
toc?: boolean;
|
|
35
|
+
commentName?: 'required';
|
|
35
36
|
definitions?: Definition[];
|
|
36
37
|
/** Who may leave shared comments; absent is off. Personal notes need nothing. */
|
|
37
38
|
comments?: CommentsMode;
|
package/lib/artifacts/store.js
CHANGED
|
@@ -163,6 +163,7 @@ async function saveArtifact(col, input) {
|
|
|
163
163
|
...(input.meta.theme ? { theme: input.meta.theme } : {}),
|
|
164
164
|
...(input.meta.pack ? { pack: input.meta.pack } : {}),
|
|
165
165
|
...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
|
|
166
|
+
...(input.meta.commentName ? { commentName: input.meta.commentName } : {}),
|
|
166
167
|
...(input.meta.definitions?.length ? { definitions: input.meta.definitions } : {}),
|
|
167
168
|
};
|
|
168
169
|
if (input.id) {
|
|
@@ -215,6 +216,7 @@ async function saveArtifact(col, input) {
|
|
|
215
216
|
...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
|
|
216
217
|
...(input.meta.pack ? {} : { pack: FieldValue.delete() }),
|
|
217
218
|
...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
|
|
219
|
+
...(input.meta.commentName ? {} : { commentName: FieldValue.delete() }),
|
|
218
220
|
...(input.meta.definitions?.length ? {} : { definitions: FieldValue.delete() }),
|
|
219
221
|
};
|
|
220
222
|
// `voice: none` is the page declaring it has no audio. An update merges, so the recording it
|
|
@@ -11,15 +11,23 @@ export type SavedMemo = {
|
|
|
11
11
|
transcript?: CommentValue['transcript'];
|
|
12
12
|
};
|
|
13
13
|
/** The warning, word for word. `They` is fixed copy: the card never guesses a pronoun. */
|
|
14
|
-
export declare function warningLine(ownerName: string, mode: CommentsMode): string;
|
|
14
|
+
export declare function warningLine(ownerName: string, mode: CommentsMode, closed?: boolean): string;
|
|
15
15
|
export declare const DEVICE_LINE = "Saved on this device. Sign in to keep it everywhere.";
|
|
16
16
|
/** Where a reader's declared name is remembered, so their next comment is already signed. */
|
|
17
17
|
export declare const NAME_KEY = "artifacts:comment-name";
|
|
18
|
-
export declare
|
|
18
|
+
export declare const recallName: () => string;
|
|
19
|
+
export declare const rememberName: (name: string) => void;
|
|
20
|
+
export declare function CommentCard({ ownerName, mode, canShare, signedIn, closed, requireName, knownName, quote, initial, accent, signIn, voice, onSave, onCancel, }: {
|
|
19
21
|
ownerName: string;
|
|
20
22
|
mode: CommentsMode;
|
|
21
23
|
canShare: boolean;
|
|
22
24
|
signedIn: boolean;
|
|
25
|
+
/** The reader's feedback window has closed: the warning says so. */
|
|
26
|
+
closed?: boolean;
|
|
27
|
+
/** A shared comment is not sent without a first name (`comment_name: required`, on an open link). */
|
|
28
|
+
requireName?: boolean;
|
|
29
|
+
/** A named link on a `comment_name: required` page already says who this is: no name field. */
|
|
30
|
+
knownName?: string | null;
|
|
23
31
|
/** The quoted text the comment is pinned to, shown above the box. */
|
|
24
32
|
quote?: string;
|
|
25
33
|
initial?: string;
|
|
@@ -14,7 +14,9 @@ import { NO_ZOOM_FONT } from './no-zoom.js';
|
|
|
14
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
|
-
export function warningLine(ownerName, mode) {
|
|
17
|
+
export function warningLine(ownerName, mode, closed = false) {
|
|
18
|
+
if (closed)
|
|
19
|
+
return `Only you will see this. Feedback on this draft is closed, so this is a personal note. ${ownerName} won't see it.`;
|
|
18
20
|
const why = mode === 'off'
|
|
19
21
|
? `${ownerName} has not opened this page to comments`
|
|
20
22
|
: `${ownerName} takes comments on this page from signed-in readers`;
|
|
@@ -23,17 +25,17 @@ export function warningLine(ownerName, mode) {
|
|
|
23
25
|
export const DEVICE_LINE = 'Saved on this device. Sign in to keep it everywhere.';
|
|
24
26
|
/** Where a reader's declared name is remembered, so their next comment is already signed. */
|
|
25
27
|
export const NAME_KEY = 'artifacts:comment-name';
|
|
26
|
-
const
|
|
28
|
+
export const recallName = () => { try {
|
|
27
29
|
return localStorage.getItem(NAME_KEY) ?? '';
|
|
28
30
|
}
|
|
29
31
|
catch {
|
|
30
32
|
return '';
|
|
31
33
|
} };
|
|
32
|
-
const
|
|
34
|
+
export const rememberName = (name) => { try {
|
|
33
35
|
localStorage.setItem(NAME_KEY, name);
|
|
34
36
|
}
|
|
35
37
|
catch { /* a browser that keeps nothing still comments */ } };
|
|
36
|
-
export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initial = '', accent = 'currentColor', signIn, voice = null, onSave, onCancel, }) {
|
|
38
|
+
export function CommentCard({ ownerName, mode, canShare, signedIn, closed = false, requireName = false, knownName = null, quote, initial = '', accent = 'currentColor', signIn, voice = null, onSave, onCancel, }) {
|
|
37
39
|
const [text, setText] = useState(initial);
|
|
38
40
|
const [share, setShare] = useState(canShare);
|
|
39
41
|
const [busy, setBusy] = useState(false);
|
|
@@ -41,7 +43,7 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
|
|
|
41
43
|
const [memo, setMemo] = useState(null);
|
|
42
44
|
const [transcript, setTranscript] = useState(undefined);
|
|
43
45
|
const [status, setStatus] = useState(null);
|
|
44
|
-
const [name, setName] = useState(
|
|
46
|
+
const [name, setName] = useState(recallName);
|
|
45
47
|
// Refs, because the host's transcript arrives after an await and must see the box as it is then.
|
|
46
48
|
const base = useRef('');
|
|
47
49
|
const edited = useRef(false);
|
|
@@ -49,7 +51,7 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
|
|
|
49
51
|
const personal = !canShare || !share;
|
|
50
52
|
// A reader with no account is asked for a name, so the owner can credit the feedback. Optional:
|
|
51
53
|
// a comment without one still goes through, signed "a reader".
|
|
52
|
-
const askName = !personal && !signedIn;
|
|
54
|
+
const askName = !personal && !knownName && (requireName || !signedIn);
|
|
53
55
|
// Where a memo goes: a shared comment, the reader's account, or (neither) this device only.
|
|
54
56
|
const scope = () => (canShare && share ? 'comment' : signedIn ? 'personal' : null);
|
|
55
57
|
const recorded = async (m) => {
|
|
@@ -76,15 +78,19 @@ export function CommentCard({ ownerName, mode, canShare, signedIn, quote, initia
|
|
|
76
78
|
const body = text.trim();
|
|
77
79
|
if ((!body && !memo) || busy)
|
|
78
80
|
return;
|
|
81
|
+
if (requireName && askName && !name.trim()) {
|
|
82
|
+
setError('Add your first name first.');
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
79
85
|
setBusy(true);
|
|
80
86
|
const by = askName && name.trim() ? name.trim() : undefined;
|
|
81
87
|
if (by)
|
|
82
|
-
|
|
88
|
+
rememberName(by);
|
|
83
89
|
const saved = memo ? { memo, uploaded: uploaded.current, ...(body && transcript ? { transcript } : {}) } : undefined;
|
|
84
90
|
const err = saved || by ? await onSave(body, canShare && share, saved, by) : await onSave(body, canShare && share);
|
|
85
91
|
setBusy(false);
|
|
86
92
|
setError(err);
|
|
87
93
|
};
|
|
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))
|
|
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" })] })] }));
|
|
94
|
+
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, closed) })) : 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))
|
|
95
|
+
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: requireName ? `Your first name, so ${ownerName} knows who this is from` : `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" })] })] }));
|
|
90
96
|
}
|
|
@@ -5,7 +5,7 @@ import { type DeviceAudio } from './device-audio.js';
|
|
|
5
5
|
export declare const resolvedLine: (r: CommentResolution) => string;
|
|
6
6
|
/** A finger rather than a mouse: the comment button then goes where the OS menu is not. */
|
|
7
7
|
export declare function onTouch(): boolean;
|
|
8
|
-
export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }: {
|
|
8
|
+
export declare function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, closesAt, requireName, knownName, }: {
|
|
9
9
|
artifactId: string;
|
|
10
10
|
/** The element holding the page's prose, the same root the read-aloud lights. */
|
|
11
11
|
rootId: string;
|
|
@@ -24,4 +24,10 @@ export declare function CommentLayer({ artifactId, rootId, ownerName, comments,
|
|
|
24
24
|
voice?: VoiceHost;
|
|
25
25
|
/** Where an anonymous reader's recordings live; defaults to this browser's IndexedDB. */
|
|
26
26
|
audioStore?: DeviceAudio;
|
|
27
|
+
/** When this reader's feedback window closes (an ISO time), or null. Past it, a note is personal. */
|
|
28
|
+
closesAt?: string | null;
|
|
29
|
+
/** A shared comment needs a first name (`comment_name: required`, on an open link). */
|
|
30
|
+
requireName?: boolean;
|
|
31
|
+
/** Who the reader is, on a named link of a `comment_name: required` page: the card does not ask. */
|
|
32
|
+
knownName?: string | null;
|
|
27
33
|
}): import("react").JSX.Element;
|
|
@@ -31,8 +31,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
31
31
|
import { createPortal } from 'react-dom';
|
|
32
32
|
import { PINS_SET, announcePins, signInBack } from './page-bridge.js';
|
|
33
33
|
import { MAX_QUOTE_CHARS, textAnchorFrom } from '../artifacts/anchor.js';
|
|
34
|
-
import { COMMENTS_SLOT, MAX_RESOLVE_NOTE_CHARS, resolutionOf } from '../artifacts/comments.js';
|
|
35
|
-
import { CommentCard } from './comment-card.js';
|
|
34
|
+
import { COMMENTS_SLOT, MAX_COMMENT_NAME_CHARS, MAX_RESOLVE_NOTE_CHARS, resolutionOf } from '../artifacts/comments.js';
|
|
35
|
+
import { CommentCard, recallName, rememberName } from './comment-card.js';
|
|
36
36
|
import { AudioPlay, hostVoice, newMemoId } from './voice-recorder.js';
|
|
37
37
|
import { deviceAudio as defaultDeviceAudio, isDeviceAudio } from './device-audio.js';
|
|
38
38
|
import { audioExtFor } from '../artifacts/audio.js';
|
|
@@ -87,7 +87,7 @@ const personalFrom = (notes, where) => notes.filter((n) => isValue(n.value)).map
|
|
|
87
87
|
export function onTouch() {
|
|
88
88
|
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
|
|
89
89
|
}
|
|
90
|
-
export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, }) {
|
|
90
|
+
export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare, signedIn, isOwner, version, accent, ground, signIn, voice, audioStore, closesAt = null, requireName = false, knownName = null, }) {
|
|
91
91
|
const host = useMemo(() => voice ?? hostVoice(artifactId), [voice, artifactId]);
|
|
92
92
|
const audioDb = useMemo(() => audioStore ?? defaultDeviceAudio, [audioStore]);
|
|
93
93
|
const stateUrl = `/api/artifacts/${artifactId}/state`;
|
|
@@ -116,6 +116,49 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
116
116
|
const [scrollTo, setScrollTo] = useState(null);
|
|
117
117
|
/** A resolved thread the address named, opened expanded rather than collapsed. */
|
|
118
118
|
const [expanded, setExpanded] = useState(null);
|
|
119
|
+
// The window is checked on mount and again the moment it passes, so a page left open stops
|
|
120
|
+
// offering to share without a reload. A time that cannot be read is closed.
|
|
121
|
+
const [closed, setClosed] = useState(false);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (!closesAt) {
|
|
124
|
+
setClosed(false);
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
const ms = Date.parse(closesAt) - Date.now();
|
|
128
|
+
if (!(ms > 0)) {
|
|
129
|
+
setClosed(true);
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
setClosed(false);
|
|
133
|
+
// setTimeout cannot wait longer than about 24 days: past that, wake early and look at the clock again.
|
|
134
|
+
let t;
|
|
135
|
+
const arm = () => {
|
|
136
|
+
const left = Date.parse(closesAt) - Date.now();
|
|
137
|
+
if (!(left > 0)) {
|
|
138
|
+
setClosed(true);
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
t = setTimeout(arm, Math.min(left, 2_147_000_000));
|
|
142
|
+
};
|
|
143
|
+
t = setTimeout(arm, Math.min(ms, 2_147_000_000));
|
|
144
|
+
return () => clearTimeout(t);
|
|
145
|
+
}, [closesAt]);
|
|
146
|
+
const share = canShare && !closed;
|
|
147
|
+
/** Scroll to where a publisher reply is pinned (the passage that changed) and select it. */
|
|
148
|
+
const jump = (i) => {
|
|
149
|
+
const root = document.getElementById(rootId);
|
|
150
|
+
if (!root)
|
|
151
|
+
return;
|
|
152
|
+
const at = placeAnchor(root, indexText(root), i.value.anchor);
|
|
153
|
+
if (!at)
|
|
154
|
+
return;
|
|
155
|
+
window.scrollTo({ top: Math.max(0, boxOf(at).top - 120), behavior: 'smooth' });
|
|
156
|
+
if (at.kind === 'text') {
|
|
157
|
+
const sel = document.getSelection();
|
|
158
|
+
sel?.removeAllRanges();
|
|
159
|
+
sel?.addRange(at.range);
|
|
160
|
+
}
|
|
161
|
+
};
|
|
119
162
|
const call = useCallback(async (url, init) => {
|
|
120
163
|
try {
|
|
121
164
|
const r = await fetch(url, { credentials: 'same-origin', cache: 'no-store', ...init });
|
|
@@ -492,8 +535,10 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
492
535
|
setShared(sharedFrom(r.body));
|
|
493
536
|
return null;
|
|
494
537
|
};
|
|
495
|
-
const reply = async (parent, body) => {
|
|
496
|
-
const value = { anchor: parent.value.anchor, body, version, parent: parent.id };
|
|
538
|
+
const reply = async (parent, body, by) => {
|
|
539
|
+
const value = { anchor: parent.value.anchor, body, version, parent: parent.id, ...(requireName && !knownName && by ? { by } : {}) };
|
|
540
|
+
if (requireName && !knownName && by)
|
|
541
|
+
rememberName(by);
|
|
497
542
|
const r = await call(stateUrl, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: COMMENTS_SLOT, op: 'append', value }) });
|
|
498
543
|
if (r.status !== 200)
|
|
499
544
|
return typeof r.body.error === 'string' ? r.body.error : 'that did not save; try again';
|
|
@@ -516,8 +561,8 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
|
|
|
516
561
|
// On a phone the button sits at the TOP of the screen, never beside the selection: that is
|
|
517
562
|
// where iOS draws its own Copy / Look Up menu, and the two fought for the same tap (Gary,
|
|
518
563
|
// 2026-09-29: "I keep trying to fight with the Apple pop-up"). A mouse keeps it by the text.
|
|
519
|
-
_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:
|
|
520
|
-
return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share":
|
|
564
|
+
_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: share, closed: closed, requireName: requireName, knownName: knownName, 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' && share, awaiting: closesAt != null, askName: requireName && !knownName, onJump: jump, canResolve: openItem.kind === 'shared' && isOwner, startExpanded: expanded === openItem.id, accent: accent, audioDb: audioDb, onClose: () => { setThread(null); setExpanded(null); }, onRemove: remove, onEdit: edit, onReply: reply, onResolve: resolveThread }, openItem.id) })) : 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;
|
|
565
|
+
return (_jsxs(_Fragment, { children: [_jsx("span", { hidden: true, "data-comment-layer": comments, "data-comment-can-share": share ? '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: [resolvedOf(item) ? _jsxs("span", { "data-comment-resolved-line": true, className: "block text-[13px] font-medium", style: { color: accent }, children: ["\u2713 ", resolvedLine(resolvedOf(item))] }) : null, 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] }));
|
|
521
566
|
}
|
|
522
567
|
function Pin({ item, left, top, accent, ground, onOpen }) {
|
|
523
568
|
const personal = item.kind === 'personal';
|
|
@@ -530,7 +575,7 @@ function Floating({ x, y, children }) {
|
|
|
530
575
|
const left = typeof window === 'undefined' ? x : Math.max(window.scrollX + 8, Math.min(x, window.scrollX + window.innerWidth - width - 8));
|
|
531
576
|
return _jsx("div", { className: "absolute z-50", style: { left, top: y }, children: children });
|
|
532
577
|
}
|
|
533
|
-
function Thread({ item, replies, canReply, canResolve = false, startExpanded = false, accent, audioDb, onClose, onRemove, onEdit, onReply, onResolve }) {
|
|
578
|
+
function Thread({ item, replies, canReply, awaiting = false, askName = false, onJump, canResolve = false, startExpanded = false, accent, audioDb, onClose, onRemove, onEdit, onReply, onResolve }) {
|
|
534
579
|
const [replying, setReplying] = useState(false);
|
|
535
580
|
const [resolving, setResolving] = useState(false);
|
|
536
581
|
const [error, setError] = useState(null);
|
|
@@ -541,8 +586,13 @@ function Thread({ item, replies, canReply, canResolve = false, startExpanded = f
|
|
|
541
586
|
if (resolved && !showResolved) {
|
|
542
587
|
return (_jsxs("div", { "data-comment-thread": true, "data-comment-thread-resolved": true, "data-comment-ui": true, "data-nospeak": true, className: shell, children: [_jsxs("div", { className: "flex items-start justify-between gap-2", children: [_jsxs("p", { "data-comment-resolved-line": true, className: "m-0 flex-1 text-[14px]", children: [_jsx("span", { "aria-hidden": true, style: { color: accent }, children: "\u2713 " }), resolvedLine(resolved)] }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), _jsxs("p", { className: "m-0 mt-1 text-xs opacity-60", children: [resolved.by ? `by ${resolved.by}` : null, _jsxs("button", { type: "button", "data-comment-expand": true, className: resolved.by ? 'ml-3 underline' : 'underline', onClick: () => setShowResolved(true), children: ["Show comment", replies.length ? ` and ${replies.length} ${replies.length === 1 ? 'reply' : 'replies'}` : ''] }), reopen] }), error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null] }));
|
|
543
588
|
}
|
|
544
|
-
return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: shell, children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : resolved ? 'Resolved comment' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), resolved ? _jsxs("p", { "data-comment-resolved-line": true, className: "m-0 mb-2 text-[13px]", style: { color: accent }, children: ["\u2713 ", resolvedLine(resolved), resolved.by ? _jsx("span", { className: "opacity-70", children: ` · ${resolved.by}` }) : null] }) : null, item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) =>
|
|
545
|
-
|
|
589
|
+
return (_jsxs("div", { "data-comment-thread": true, "data-comment-ui": true, "data-nospeak": true, className: shell, children: [_jsxs("div", { className: "mb-2 flex items-center justify-between text-[11px] uppercase tracking-[0.15em]", style: { color: accent }, children: [_jsx("span", { children: item.kind === 'personal' ? 'Only you' : resolved ? 'Resolved comment' : 'Comment' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", className: "opacity-70 hover:opacity-100", children: "\u00D7" })] }), resolved ? _jsxs("p", { "data-comment-resolved-line": true, className: "m-0 mb-2 text-[13px]", style: { color: accent }, children: ["\u2713 ", resolvedLine(resolved), resolved.by ? _jsx("span", { className: "opacity-70", children: ` · ${resolved.by}` }) : null] }) : null, item.value.anchor.kind === 'text' ? _jsx("p", { className: "mb-2 line-clamp-2 border-l-2 pl-2 text-[13px] italic opacity-70", style: { borderColor: accent }, children: item.value.anchor.quote }) : null, _jsx(Entry, { item: item, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit }), replies.length ? _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 border-l pl-3 p-0", children: replies.map((r) => {
|
|
590
|
+
// A publisher reply pinned somewhere other than its thread points at the passage that changed.
|
|
591
|
+
const moved = r.value.publisher === true && r.value.anchor.kind === 'text' && (item.value.anchor.kind !== 'text' || r.value.anchor.quote !== item.value.anchor.quote);
|
|
592
|
+
return _jsx("li", { className: "m-0", children: _jsx(Entry, { item: r, accent: accent, audioDb: audioDb, onRemove: onRemove, onEdit: onEdit, onJump: moved ? onJump : undefined }) }, r.id);
|
|
593
|
+
}) }) : null, awaiting && item.mine && item.kind === 'shared' && !resolved && !replies.some((r) => r.value.publisher === true)
|
|
594
|
+
? _jsx("p", { "data-comment-waiting": true, className: "m-0 mt-2 text-xs opacity-70", children: "Waiting for a reply." }) : null, canReply ? (replying
|
|
595
|
+
? _jsx(InlineForm, { label: "Reply", accent: accent, askName: askName, onCancel: () => setReplying(false), onSubmit: async (b, by) => { const e = await onReply(item, b, by); if (!e)
|
|
546
596
|
setReplying(false); return e; } })
|
|
547
597
|
: _jsx("button", { type: "button", "data-comment-reply": true, onClick: () => setReplying(true), className: "mt-2 text-xs underline", children: "Reply" })) : null, canResolve && onResolve && !replying ? (resolved ? _jsx("p", { className: "m-0 mt-2", children: reopen })
|
|
548
598
|
: resolving
|
|
@@ -568,17 +618,26 @@ function useAudioSrc(item, audioDb) {
|
|
|
568
618
|
}, [key, audioDb]);
|
|
569
619
|
return item.audioUrl ?? local;
|
|
570
620
|
}
|
|
571
|
-
function Entry({ item, accent, audioDb, onRemove, onEdit }) {
|
|
621
|
+
function Entry({ item, accent, audioDb, onRemove, onEdit, onJump }) {
|
|
572
622
|
const [editing, setEditing] = useState(false);
|
|
573
623
|
const src = useAudioSrc(item, audioDb);
|
|
574
624
|
if (editing)
|
|
575
625
|
return _jsx(InlineForm, { label: "Save", initial: item.value.body, onCancel: () => setEditing(false), onSubmit: async (b) => { const e = await onEdit(item, b); if (!e)
|
|
576
626
|
setEditing(false); return e; } });
|
|
577
|
-
return (_jsxs("div", { children: [src ? _jsx(AudioPlay, { src: src, accent: accent }) : null, item.value.body ? _jsx("p", { className: "m-0 whitespace-pre-wrap text-[15px]", children: item.value.body }) : null, _jsxs("p", { className: "m-0 text-xs opacity-60", children: [item.kind === 'personal' ? (item.where === 'device' ? 'only you, on this device' : 'only you') : item.name, item.mine ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-comment-edit": true, onClick: () => setEditing(true), className: "ml-2 underline", children: "edit" }), _jsx("button", { type: "button", "data-comment-delete": true, onClick: () => void onRemove(item), className: "ml-2 underline", children: "delete" })] })) : null] })] }));
|
|
627
|
+
return (_jsxs("div", { children: [src ? _jsx(AudioPlay, { src: src, accent: accent }) : null, item.value.body ? _jsx("p", { className: "m-0 whitespace-pre-wrap text-[15px]", children: item.value.body }) : null, _jsxs("p", { className: "m-0 text-xs opacity-60", children: [item.kind === 'personal' ? (item.where === 'device' ? 'only you, on this device' : 'only you') : item.name, item.mine ? (_jsxs(_Fragment, { children: [_jsx("button", { type: "button", "data-comment-edit": true, onClick: () => setEditing(true), className: "ml-2 underline", children: "edit" }), _jsx("button", { type: "button", "data-comment-delete": true, onClick: () => void onRemove(item), className: "ml-2 underline", children: "delete" })] })) : null] }), onJump ? _jsx("button", { type: "button", "data-comment-jump": true, onClick: () => onJump(item), className: "mt-1 text-xs underline", children: "Show what changed" }) : null] }));
|
|
578
628
|
}
|
|
579
|
-
function InlineForm({ label, initial = '', accent = 'currentColor', optional = false, placeholder, maxLength, onSubmit, onCancel }) {
|
|
629
|
+
function InlineForm({ label, initial = '', accent = 'currentColor', optional = false, askName = false, placeholder, maxLength, onSubmit, onCancel }) {
|
|
580
630
|
const [text, setText] = useState(initial);
|
|
581
631
|
const [error, setError] = useState(null);
|
|
582
|
-
|
|
583
|
-
|
|
632
|
+
const [name, setName] = useState(askName ? recallName : '');
|
|
633
|
+
return (_jsxs("form", { className: "mt-2", onSubmit: async (e) => {
|
|
634
|
+
e.preventDefault();
|
|
635
|
+
if (!optional && !text.trim())
|
|
636
|
+
return;
|
|
637
|
+
if (askName && !name.trim()) {
|
|
638
|
+
setError('Add your first name first.');
|
|
639
|
+
return;
|
|
640
|
+
}
|
|
641
|
+
setError(await onSubmit(text.trim(), askName ? name.trim() : undefined));
|
|
642
|
+
}, children: [_jsx("textarea", { value: text, autoFocus: true, rows: 2, onChange: (e) => setText(optional ? e.target.value.replace(/[\r\n]+/g, ' ') : e.target.value), "aria-label": optional && placeholder ? placeholder : label, placeholder: placeholder, maxLength: maxLength, className: "w-full rounded-lg border border-[color:var(--a-line)] bg-transparent p-2", style: NO_ZOOM_FONT }), askName ? (_jsxs("label", { "data-comment-name": true, className: "mt-1 block text-xs", children: [_jsx("span", { className: "block opacity-80", children: "Your first name" }), _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-transparent px-2 py-1" })] })) : null, error ? _jsx("p", { role: "alert", className: "text-xs text-amber-500", children: error }) : null, _jsxs("div", { className: "mt-1 flex gap-3 text-xs", children: [_jsx("button", { type: "submit", disabled: !optional && !text.trim(), className: "rounded-full px-3 py-0.5 font-medium disabled:opacity-40", style: { background: accent, color: 'var(--a-on-accent, #111)' }, children: label }), _jsx("button", { type: "button", onClick: onCancel, className: "opacity-70", children: "Cancel" })] })] }));
|
|
584
643
|
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare const FEEDBACK_CLOSED_LINE = "Feedback is closed. You can still read the draft and your own notes.";
|
|
2
|
+
/** "3 days left", "23 hours left", "45 minutes left"; null once the window has passed or cannot be read. */
|
|
3
|
+
export declare function timeLeft(closesAt: string, now: number): string | null;
|
|
4
|
+
export declare function FeedbackWindow({ owner, label, note, group, closesAt, now }: {
|
|
5
|
+
owner: string;
|
|
6
|
+
label: string;
|
|
7
|
+
note: string | null;
|
|
8
|
+
/** An open link, shared by several people: nobody is named. */
|
|
9
|
+
group: boolean;
|
|
10
|
+
closesAt: string;
|
|
11
|
+
/** The clock, for tests. Left out, the banner reads the real one and ticks. */
|
|
12
|
+
now?: number;
|
|
13
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
// The banner a reviewer sees on a draft sent for feedback (0.40.0): whose draft it is, the one line
|
|
4
|
+
// the owner wrote about what they want, and how long the reader has, counted down while the page is
|
|
5
|
+
// open. Everything here is data-nospeak: it is furniture, never part of the piece.
|
|
6
|
+
import { useEffect, useState } from 'react';
|
|
7
|
+
export const FEEDBACK_CLOSED_LINE = 'Feedback is closed. You can still read the draft and your own notes.';
|
|
8
|
+
/** "3 days left", "23 hours left", "45 minutes left"; null once the window has passed or cannot be read. */
|
|
9
|
+
export function timeLeft(closesAt, now) {
|
|
10
|
+
const ms = Date.parse(closesAt) - now;
|
|
11
|
+
if (!(ms > 0))
|
|
12
|
+
return null;
|
|
13
|
+
const minutes = Math.ceil(ms / 60_000);
|
|
14
|
+
if (minutes >= 2880)
|
|
15
|
+
return `${Math.floor(minutes / 1440)} days left`;
|
|
16
|
+
if (minutes >= 120)
|
|
17
|
+
return `${Math.floor(minutes / 60)} hours left`;
|
|
18
|
+
if (minutes >= 2)
|
|
19
|
+
return `${minutes} minutes left`;
|
|
20
|
+
return '1 minute left';
|
|
21
|
+
}
|
|
22
|
+
export function FeedbackWindow({ owner, label, note, group, closesAt, now }) {
|
|
23
|
+
const [at, setAt] = useState(() => now ?? Date.now());
|
|
24
|
+
// Markup the server rendered (or a render held for a while) keeps its text through hydration,
|
|
25
|
+
// because the line suppresses the mismatch. So on mount the clock is read again and the line is
|
|
26
|
+
// drawn afresh (a new key), before the first tick: a stale count, or an open window that has
|
|
27
|
+
// already closed, never waits 30 seconds to be corrected.
|
|
28
|
+
const [live, setLive] = useState(false);
|
|
29
|
+
useEffect(() => {
|
|
30
|
+
if (now !== undefined)
|
|
31
|
+
return;
|
|
32
|
+
setAt(Date.now());
|
|
33
|
+
setLive(true);
|
|
34
|
+
const t = setInterval(() => setAt(Date.now()), 30_000);
|
|
35
|
+
return () => clearInterval(t);
|
|
36
|
+
}, [now]);
|
|
37
|
+
const left = timeLeft(closesAt, at);
|
|
38
|
+
return (_jsx("aside", { "data-feedback-window": true, "data-nospeak": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6", children: _jsxs("div", { className: "rounded-xl border border-[color:var(--a-line-strong)] px-4 py-3 text-[15px]", children: [_jsx("p", { className: "m-0 font-medium", children: `A draft from ${owner}${group ? '' : `, for ${label}`}` }), note ? _jsx("p", { "data-feedback-note": true, className: "m-0 mt-1", children: note }) : null, _jsx("p", { "data-feedback-left": true, "aria-live": "polite", suppressHydrationWarning: true, className: "m-0 mt-1 opacity-80", children: left ? `Feedback is open: ${left}.` : FEEDBACK_CLOSED_LINE }, live ? 'live' : 'first')] }) }));
|
|
39
|
+
}
|
|
@@ -203,6 +203,11 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
203
203
|
id: string;
|
|
204
204
|
}>;
|
|
205
205
|
}) => Promise<NextResponse<unknown>>;
|
|
206
|
+
REPLY: (req: NextRequest, { params }: {
|
|
207
|
+
params: Promise<{
|
|
208
|
+
id: string;
|
|
209
|
+
}>;
|
|
210
|
+
}) => Promise<NextResponse<unknown>>;
|
|
206
211
|
Page: ({ params, searchParams }: PageProps) => Promise<import("react").JSX.Element>;
|
|
207
212
|
generateMetadata: ({ params }: Params) => Promise<Metadata>;
|
|
208
213
|
VersionPage: ({ params, searchParams }: VersionProps) => Promise<import("react").JSX.Element>;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -21,6 +21,7 @@ import { ArtifactReader } from '../reader/artifact-reader.js';
|
|
|
21
21
|
import { CommentLayer } from '../reader/comment-layer.js';
|
|
22
22
|
import { ReaderWatch } from '../reader/reader-watch.js';
|
|
23
23
|
import { UpdatedTime } from '../reader/updated-time.js';
|
|
24
|
+
import { FeedbackWindow } from '../reader/feedback-window.js';
|
|
24
25
|
import { perReaderGate, withPrivateUrls, NO_LINK, UNAVAILABLE } from './per-reader.js';
|
|
25
26
|
import { ridCookieName, mintRenderToken, usableSecret, isUnfurlAgent, RID_COOKIE_MAX_AGE } from '../artifacts/recipients.js';
|
|
26
27
|
import { PageMenu } from '../reader/page-menu.js';
|
|
@@ -325,7 +326,7 @@ export function createArtifactRoutes(config) {
|
|
|
325
326
|
const remember = g.fromLink && !bot
|
|
326
327
|
? [pagePath(id), `/api/artifacts/${id}`].map((p) => `document.cookie=${JSON.stringify(`${ridCookieName(id)}=${g.rid}.${g.sig}; Path=${p}; Max-Age=${RID_COOKIE_MAX_AGE}; SameSite=Lax; Secure`)}`).join(';')
|
|
327
328
|
: null;
|
|
328
|
-
const perReader = bot ? undefined : { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, token: mintRenderToken(secret, id, g.rid) };
|
|
329
|
+
const perReader = bot ? undefined : { rid: g.rid, sig: g.sig, label: g.label, note: g.note, owner: g.owner, group: g.group, closesAt: g.closesAt, token: mintRenderToken(secret, id, g.rid) };
|
|
329
330
|
const markdown = await media(a.markdown);
|
|
330
331
|
const body = broadcast
|
|
331
332
|
? await BroadcastCard({ ...a, markdown }, { reader: null, perReader, bot })
|
|
@@ -457,7 +458,7 @@ export function createArtifactRoutes(config) {
|
|
|
457
458
|
const cover = !bot && a.cover && !/!\[[^\]]*\]\(/.test(a.markdown) ? a.cover : null;
|
|
458
459
|
return (_jsxs(_Fragment, { children: [admin ? (_jsx(PageMenu, { role: roleOf(reader), name: reader ? reader.name ?? reader.email : null, share: null, comments: mode !== 'off', signIn: null, signOut: signOutUrl(a.id), history: null, markdown: null, settings: null })) : null, _jsx("main", { "data-broadcast": true, className: "mx-auto max-w-lg px-4 pt-16 pb-20 sm:pt-24", children: _jsxs("article", { id: "broadcast-root", "data-broadcast-card": true, className: "rounded-2xl border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-5 py-6 sm:px-7 sm:py-8", children: [_jsx("p", { "data-nospeak": true, "data-broadcast-from": true, className: "text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: `From ${owner}` }), _jsx("h1", { className: "mt-3 text-2xl sm:text-3xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: a.title }), cover ? (
|
|
459
460
|
// eslint-disable-next-line @next/next/no-img-element
|
|
460
|
-
_jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
|
|
461
|
+
_jsx("img", { src: cover, alt: "", className: "mt-5 w-full rounded-xl border border-[color:var(--a-line)]" })) : null, _jsx("div", { "data-broadcast-body": true, className: "mt-4", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, checklist: { artifactId: a.id, owner, accent: brand.accent }, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }), perReader?.note ? (_jsxs("div", { "data-for-reader": true, className: "mt-6 rounded-xl border border-[color:var(--a-line)] px-4 py-3", children: [_jsx("p", { "data-nospeak": true, className: "text-[11px] uppercase tracking-[0.2em] opacity-70", children: `For ${perReader.label}` }), _jsx("p", { className: "mt-1 text-lg", children: perReader.note })] })) : null] }) }), mode !== 'off' ? (_jsx(CommentLayer, { artifactId: a.id, rootId: "broadcast-root", ownerName: owner, comments: mode, canShare: reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone', signedIn: reader !== null, isOwner: admin, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null })) : null, perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null] }));
|
|
461
462
|
}
|
|
462
463
|
/** The page itself, shared by open and gated pages. */
|
|
463
464
|
async function Body(a, { top, reader, perReader }) {
|
|
@@ -482,10 +483,10 @@ export function createArtifactRoutes(config) {
|
|
|
482
483
|
const n = a.version ?? (a.versions?.length ?? 0) + 1;
|
|
483
484
|
const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { n: n, at: a.updatedAt }), top && reader ? (_jsxs("p", { "data-nospeak": true, "data-reader-line": true, className: "mt-2 text-xs opacity-60", children: [`Signed in as ${reader.email} · `, _jsx("a", { href: switchUrl(a.id), className: "underline", children: "Use a different account" }), ' · ', _jsx("a", { href: signOutUrl(a.id), className: "underline", children: "Sign out" })] })) : null] }));
|
|
484
485
|
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] }));
|
|
485
|
-
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, 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,
|
|
486
|
+
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [perReader?.closesAt && !perReader.owner ? (_jsx(FeedbackWindow, { owner: owner, label: perReader.label, note: perReader.note, group: perReader.group, closesAt: perReader.closesAt })) : perReader?.note ? (_jsx("p", { "data-for-reader": true, className: "mx-auto mt-6 mb-0 max-w-2xl px-6 text-lg", children: `For ${perReader.label}: ${perReader.note}` })) : null, 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,
|
|
486
487
|
// Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
|
|
487
488
|
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 }) }));
|
|
488
|
-
})() }), _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: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
489
|
+
})() }), _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: isAdmin(reader), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, closesAt: perReader && !perReader.owner ? perReader.closesAt : null, requireName: a.commentName === 'required' && perReader?.group === true, knownName: a.commentName === 'required' && perReader && !perReader.group && !perReader.owner ? perReader.label : null }), perReader && !perReader.owner ? _jsx(QuietView, { id: a.id, rid: perReader.rid, sig: perReader.sig, token: perReader.token }) : null, a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
489
490
|
2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
|
|
490
491
|
recorded at publish, or there is none. */] }));
|
|
491
492
|
}
|
package/lib/routes/per-reader.js
CHANGED
|
@@ -23,7 +23,7 @@ export async function perReaderGate(o) {
|
|
|
23
23
|
// A malformed expireAt parses to NaN: treat it as expired, never as no expiry.
|
|
24
24
|
if (rec.expireAt && !(Date.parse(rec.expireAt) > (o.now ?? Date.now())))
|
|
25
25
|
return { state: 'shut', why: 'expired' };
|
|
26
|
-
return { state: 'open', rid: pick.rid, sig: pick.sig, label: r.label, note: r.note ?? null, owner: r.owner === true, fromLink: pick.fromLink };
|
|
26
|
+
return { state: 'open', rid: pick.rid, sig: pick.sig, label: r.label, note: r.note ?? null, owner: r.owner === true, group: r.group === true, closesAt: r.closesAt ?? null, fromLink: pick.fromLink };
|
|
27
27
|
}
|
|
28
28
|
const REF_IN_MD = /\]\((private:[A-Za-z0-9][A-Za-z0-9._-]{0,140})\)/g;
|
|
29
29
|
export async function withPrivateUrls(markdown, id, privateUrl) {
|