@supersuit/artifacts 0.23.0 → 0.25.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 +51 -0
- package/lib/artifacts/comments.d.ts +3 -0
- package/lib/artifacts/comments.js +9 -2
- package/lib/artifacts/index.d.ts +1 -0
- package/lib/artifacts/index.js +1 -0
- package/lib/artifacts/render.d.ts +7 -1
- package/lib/artifacts/render.js +20 -8
- package/lib/artifacts/simulator.d.ts +17 -0
- package/lib/artifacts/simulator.js +59 -0
- package/lib/artifacts/state-view.js +8 -2
- package/lib/artifacts/widgets.js +8 -0
- 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/lib/routes/artifacts.d.ts +4 -0
- package/lib/routes/artifacts.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,57 @@
|
|
|
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.25.0
|
|
7
|
+
|
|
8
|
+
**A page can play a Supersuit Session demo inline: the ```simulator block.** A book or guide that
|
|
9
|
+
wanted to show a session had to send readers away to another site.
|
|
10
|
+
|
|
11
|
+
```simulator
|
|
12
|
+
scenario: diane-meeting-prep
|
|
13
|
+
caption: Step into the hour before Diane's first meeting. Press Start.
|
|
14
|
+
height: 760px # optional, default 720px
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
- Same syntax as the Superskills book's reader, so one block works in both. `scenario` is
|
|
18
|
+
lowercase letters, digits and dashes; `caption` is required; `height` is `NNNpx`. A malformed
|
|
19
|
+
block is refused at publish with the line, like a checklist.
|
|
20
|
+
- The HOST names the demo: new `ArtifactRoutesConfig.simulatorEmbed` (an https embed page, e.g.
|
|
21
|
+
`https://example.com/simulator/embed`). The block draws as a lazy-loaded frame on
|
|
22
|
+
`<embed>?scenario=<id>&timeline=off`, with the caption under it. No default: a host without a
|
|
23
|
+
demo draws the caption alone, never the block's source as code.
|
|
24
|
+
- New exports `parseSimulatorFence`, `simulatorSrc`, `SimulatorBlock`; `ArtifactMarkdown` takes
|
|
25
|
+
`simulator?: { embed }`.
|
|
26
|
+
- DETECTOR: publish a page with a ```simulator block. A code block showing `scenario:` means the
|
|
27
|
+
host is on 0.24.0 or earlier, or has not set `simulatorEmbed`.
|
|
28
|
+
- REMEDY: update the dependency and set `simulatorEmbed` in the host's routes config.
|
|
29
|
+
- Tests: `simulator.test.ts` (parse, defaults, refusals with lines, https-only embed),
|
|
30
|
+
`widgets.test.ts` (refused at publish, passes when well formed), `render.test.tsx` (frame when the
|
|
31
|
+
host names a demo, caption only when not, never a code block). `test:packed` green.
|
|
32
|
+
|
|
33
|
+
## 0.24.0
|
|
34
|
+
|
|
35
|
+
**A reader is asked for their name when they comment, so the owner can credit the feedback.**
|
|
36
|
+
Comments from readers without an account arrived as "a reader", with nothing to thank or follow up
|
|
37
|
+
with.
|
|
38
|
+
|
|
39
|
+
- The comment card shows `Your name, so <owner> can credit your feedback` above the share switch
|
|
40
|
+
when the reader is not signed in and is sharing with the owner. It is optional; a comment
|
|
41
|
+
without it still saves, as "a reader". The name is remembered on the device
|
|
42
|
+
(`localStorage` key `artifacts:comment-name`), so the next comment is already signed.
|
|
43
|
+
- `CommentValue` gains `by?: string` (one line, at most 80 characters, trimmed; a blank one is
|
|
44
|
+
dropped). New export `MAX_COMMENT_NAME_CHARS`. A signed-in reader's account name always wins.
|
|
45
|
+
- The shared thread shows the declared name where it used to show "a reader", and the publisher's
|
|
46
|
+
`/responses` (JSON and CSV) carries it in `name` for an anonymous row, so feeds that already read
|
|
47
|
+
`name` (an owner's comment notifications) credit the reader with no change of their own.
|
|
48
|
+
- DETECTOR: open a page with `comments: anyone` while signed out and select text. No name field
|
|
49
|
+
in the card means the host is on 0.23.0 or earlier; a comment posted with `by` is refused as
|
|
50
|
+
"a comment is { anchor, body, version, parent? }" on those versions.
|
|
51
|
+
- REMEDY: update the dependency. No route or front-matter change.
|
|
52
|
+
- Tests: `comments.test.ts` (keeps, trims, drops blank, refuses long or multi-line names),
|
|
53
|
+
`state-view.test.ts` (declared name shown, account name wins, responses and CSV carry it, "a
|
|
54
|
+
reader" when none), `comment-card.test.tsx` (asked when signed out and sharing, remembered,
|
|
55
|
+
passed to save; not asked when signed in or for a personal note). `test:packed` green.
|
|
56
|
+
|
|
6
57
|
## 0.23.0
|
|
7
58
|
|
|
8
59
|
**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
|
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -13,6 +13,7 @@ export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeCh
|
|
|
13
13
|
export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
|
|
14
14
|
export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
|
|
15
15
|
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
|
|
16
|
+
export { parseSimulatorFence, simulatorSrc, type SimulatorBlock } from './simulator.js';
|
|
16
17
|
export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence, type ChecklistBlock, type ChecklistItem, type ChecklistValue } from './checklist.js';
|
|
17
18
|
export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID, type Anchor, type TextAnchor, type RegionAnchor } from './anchor.js';
|
|
18
19
|
export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment, type CommentsMode, type CommentsVisible, type CommentValue } from './comments.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -13,6 +13,7 @@ export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeCh
|
|
|
13
13
|
export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
|
|
14
14
|
export { stateView, responsesOf, responsesCsv } from './state-view.js';
|
|
15
15
|
export { slugify, headingsOf, blocksOf, scanWidgets, mergeWidgetState, mergeChecklistState, checklistsOf, checklistSendIds, checklistAnswerIds, checklistSyncs, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
|
|
16
|
+
export { parseSimulatorFence, simulatorSrc } from './simulator.js';
|
|
16
17
|
export { CHECKLIST_SLOT, checklistStorageKey, checkChecklistValue, parseChecklistFence } from './checklist.js';
|
|
17
18
|
export { textAnchorFrom, findText, blockId, parseAnchor, CONTEXT_CHARS, MAX_QUOTE_CHARS, BLOCK_ID } from './anchor.js';
|
|
18
19
|
export { COMMENTS_SLOT, COMMENTS_MODES, COMMENT_TRANSCRIPTS, MAX_COMMENT_CHARS, mergeCommentsState, validateComment } from './comments.js';
|
|
@@ -4,6 +4,9 @@ import { type Heading } from './widgets.js';
|
|
|
4
4
|
import { type Definition } from './definitions.js';
|
|
5
5
|
export declare function isReachableHref(href: unknown): href is string;
|
|
6
6
|
export declare function artifactUrlTransform(url: string): string;
|
|
7
|
+
type SimulatorCtx = {
|
|
8
|
+
embed: string;
|
|
9
|
+
};
|
|
7
10
|
/** A pack's figure resolver (BrandPack.figure). */
|
|
8
11
|
export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps> | undefined;
|
|
9
12
|
/** The page's top-level sections: every heading among the body's top-level blocks at the
|
|
@@ -11,7 +14,7 @@ export type FigureFor = (src: string, alt: string) => ComponentType<FigureProps>
|
|
|
11
14
|
export declare function sectionOutline(markdown: string, headings?: Heading[]): SectionOutline[];
|
|
12
15
|
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
13
16
|
* page carries a ```notes block. */
|
|
14
|
-
export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, }: {
|
|
17
|
+
export declare function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, }: {
|
|
15
18
|
markdown: string;
|
|
16
19
|
/** The page a ```checklist keeps its ticks under, and whether its Send can reach the owner
|
|
17
20
|
* (a host that keeps answers, on a page that declares the slot). On a page whose checklists
|
|
@@ -33,4 +36,7 @@ export declare function ArtifactMarkdown({ markdown, notes, checklist, definitio
|
|
|
33
36
|
};
|
|
34
37
|
/** Terms to define inline (front matter `definitions:`): the first occurrence of each is underlined. */
|
|
35
38
|
definitions?: Definition[];
|
|
39
|
+
/** The host's Supersuit Session demo, for ```simulator blocks. Without it a block shows its caption only. */
|
|
40
|
+
simulator?: SimulatorCtx;
|
|
36
41
|
}): import("react").JSX.Element;
|
|
42
|
+
export {};
|
package/lib/artifacts/render.js
CHANGED
|
@@ -16,6 +16,7 @@ import { unified } from 'unified';
|
|
|
16
16
|
import { blocksOf, checklistSyncs, checklistsOf, hasNotesWidget, headingsOf } from './widgets.js';
|
|
17
17
|
import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
|
|
18
18
|
import { Checklist } from '../widgets/checklist.js';
|
|
19
|
+
import { parseSimulatorFence, simulatorSrc } from './simulator.js';
|
|
19
20
|
import { VideoAutoplay } from '../reader/video-autoplay.js';
|
|
20
21
|
import { CodeCopy } from '../reader/code-copy.js';
|
|
21
22
|
import { DefinitionLayer } from '../reader/definition-layer.js';
|
|
@@ -94,6 +95,15 @@ function InlineAudio({ src, label }) {
|
|
|
94
95
|
const html = `<audio src="${escapeAttr(src)}" aria-label="${escapeAttr(label)}" data-artifact-audio controls preload="none" class="block w-full"></audio>`;
|
|
95
96
|
return (_jsxs("span", { className: "my-6 block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3", children: [label ? _jsx("span", { className: "mb-2 block text-sm text-[color:var(--a-strong)]", children: label }) : null, _jsx("span", { className: "block", dangerouslySetInnerHTML: { __html: html } })] }));
|
|
96
97
|
}
|
|
98
|
+
/** A ```simulator block: the demo in a frame when the host has one, its caption alone when it does
|
|
99
|
+
* not, and nothing when the block does not parse (a page published before validation existed). */
|
|
100
|
+
function SimulatorFence({ source, ctx }) {
|
|
101
|
+
const r = parseSimulatorFence(source.replace(/\n$/, ''), 0);
|
|
102
|
+
if (!r.ok)
|
|
103
|
+
return null;
|
|
104
|
+
const src = ctx ? simulatorSrc(ctx.embed, r.block.scenario) : null;
|
|
105
|
+
return (_jsxs("figure", { "data-artifact-simulator": true, className: "my-8", children: [src ? (_jsx("iframe", { src: src, title: r.block.caption, loading: "lazy", allow: "microphone; autoplay; fullscreen", className: "block w-full rounded-xl border border-[color:var(--a-line)]", style: { height: r.block.height, border: 0 } })) : null, _jsx("figcaption", { className: "mt-2 text-sm opacity-80", children: r.block.caption })] }));
|
|
106
|
+
}
|
|
97
107
|
/** The one image a paragraph holds and nothing else (whitespace aside), or null. */
|
|
98
108
|
function soleImage(node) {
|
|
99
109
|
const kids = (node?.children ?? []).filter((c) => !(c.type === 'text' && !c.value?.trim()));
|
|
@@ -122,7 +132,7 @@ function ChecklistFence({ line, ctx }) {
|
|
|
122
132
|
return null;
|
|
123
133
|
return (_jsx(Checklist, { items: c.items.map((it) => ({ id: it.id, title: checklistMarkdown(it.text, true), description: it.description ? checklistMarkdown(it.description, false) : null })), artifactId: ctx.artifactId, send: ctx.send && ctx.artifactId && !ctx.sync ? c.send : undefined, sync: ctx.sync && !!ctx.artifactId, owner: ctx.owner, accent: ctx.accent }));
|
|
124
134
|
}
|
|
125
|
-
function baseComponents(block, figure, checklists) {
|
|
135
|
+
function baseComponents(block, figure, checklists, simulator) {
|
|
126
136
|
return {
|
|
127
137
|
p: ({ node, children }) => {
|
|
128
138
|
// An image alone in its paragraph that the pack draws itself replaces the paragraph, so the
|
|
@@ -162,6 +172,8 @@ function baseComponents(block, figure, checklists) {
|
|
|
162
172
|
// answers) it draws nothing: its settings are not prose and never shown as code.
|
|
163
173
|
if (lang === 'notes')
|
|
164
174
|
return null;
|
|
175
|
+
if (lang === 'simulator')
|
|
176
|
+
return _jsx(SimulatorFence, { source: textOf(children), ctx: simulator });
|
|
165
177
|
if (!className) {
|
|
166
178
|
return _jsx("code", { className: "rounded bg-[color:var(--a-surface-strong)] px-1.5 py-0.5 text-[0.9em] text-[color:var(--a-strong)]", children: children });
|
|
167
179
|
}
|
|
@@ -171,7 +183,7 @@ function baseComponents(block, figure, checklists) {
|
|
|
171
183
|
// A links fence renders its own block; do not wrap it in <pre>.
|
|
172
184
|
const inner = Array.isArray(children) ? children[0] : children;
|
|
173
185
|
const cls = inner?.props?.className ?? '';
|
|
174
|
-
if (/language-(links|notes|checklist)/.test(cls))
|
|
186
|
+
if (/language-(links|notes|checklist|simulator)/.test(cls))
|
|
175
187
|
return _jsx(_Fragment, { children: children });
|
|
176
188
|
return (_jsx("pre", { ...block(node), "data-artifact-code": true,
|
|
177
189
|
// whitespace-pre-wrap, because a fence here usually holds PROSE meant to be pasted
|
|
@@ -204,20 +216,20 @@ const HEADING_CLASS = {
|
|
|
204
216
|
};
|
|
205
217
|
/** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
|
|
206
218
|
* so a contents link and a note both land on it. */
|
|
207
|
-
function pageComponents(headings, block, figure, checklists) {
|
|
219
|
+
function pageComponents(headings, block, figure, checklists, simulator) {
|
|
208
220
|
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
209
221
|
const heading = (depth) => function Heading({ node, children }) {
|
|
210
222
|
const Tag = `h${depth}`;
|
|
211
223
|
const h = byLine.get(node?.position?.start.line ?? -1);
|
|
212
224
|
return _jsx(Tag, { id: h?.slug, ...block(node), className: HEADING_CLASS[depth], children: children });
|
|
213
225
|
};
|
|
214
|
-
return { ...baseComponents(block, figure, checklists), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
|
|
226
|
+
return { ...baseComponents(block, figure, checklists, simulator), h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
|
|
215
227
|
}
|
|
216
228
|
/** Components for a page with a notes block: every heading gets its slug as an id, a note
|
|
217
229
|
* control, and its notes after it. The heading is found by its source line, so the slug is the
|
|
218
230
|
* one headingsOf derived, the same one a note stores. */
|
|
219
|
-
function notesComponents(headings, block, figure, checklists) {
|
|
220
|
-
const components = baseComponents(block, figure, checklists);
|
|
231
|
+
function notesComponents(headings, block, figure, checklists, simulator) {
|
|
232
|
+
const components = baseComponents(block, figure, checklists, simulator);
|
|
221
233
|
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
222
234
|
const heading = (depth) => function NotedHeading({ node, children }) {
|
|
223
235
|
const Tag = `h${depth}`;
|
|
@@ -296,7 +308,7 @@ const DEFINED_TERM_CSS = '.artifact-defined-term{text-decoration-line:underline;
|
|
|
296
308
|
'clip:rect(0,0,0,0);white-space:nowrap;border:0}';
|
|
297
309
|
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
298
310
|
* page carries a ```notes block. */
|
|
299
|
-
export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, }) {
|
|
311
|
+
export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Section, figure, simulator, }) {
|
|
300
312
|
const on = !!notes && hasNotesWidget(markdown);
|
|
301
313
|
const headings = headingsOf(markdown);
|
|
302
314
|
const blocks = blocksOf(markdown);
|
|
@@ -313,7 +325,7 @@ export function ArtifactMarkdown({ markdown, notes, checklist, definitions, Sect
|
|
|
313
325
|
// page whose checklists sync, that same slot is the shared set, so it turns sync on instead.
|
|
314
326
|
? { byLine: lists, artifactId: checklist?.artifactId, send: !!checklist?.send, sync: !!checklist?.send && checklistSyncs(lists.values()), owner: checklist?.owner, accent: checklist?.accent }
|
|
315
327
|
: undefined;
|
|
316
|
-
const components = on ? notesComponents(headings, block, figure, checklists) : pageComponents(headings, block, figure, checklists);
|
|
328
|
+
const components = on ? notesComponents(headings, block, figure, checklists, simulator) : pageComponents(headings, block, figure, checklists, simulator);
|
|
317
329
|
if (outline.length)
|
|
318
330
|
components.section = sectionComponent(Section, outline, block);
|
|
319
331
|
const body = (_jsx(ReactMarkdown, { remarkPlugins: plugins, components: components, urlTransform: artifactUrlTransform, children: markdown }));
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type SimulatorBlock = {
|
|
2
|
+
scenario: string;
|
|
3
|
+
caption: string;
|
|
4
|
+
height: string;
|
|
5
|
+
};
|
|
6
|
+
/** Parses a simulator fence's body. `line` is the line the fence opens on, so every error names
|
|
7
|
+
* the line the author sees. */
|
|
8
|
+
export declare function parseSimulatorFence(value: string, line: number): {
|
|
9
|
+
ok: true;
|
|
10
|
+
block: SimulatorBlock;
|
|
11
|
+
} | {
|
|
12
|
+
ok: false;
|
|
13
|
+
error: string;
|
|
14
|
+
};
|
|
15
|
+
/** The address the player loads: the host's embed page, the scenario, the timeline off. Null for
|
|
16
|
+
* an embed address that is not https, so a misconfigured host frames nothing. */
|
|
17
|
+
export declare function simulatorSrc(embed: string, scenario: string): string | null;
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The ```simulator fence: a Supersuit Session demo played inline on the page. The block names a
|
|
2
|
+
// scenario and a caption; the HOST names the demo's embed page (ArtifactRoutesConfig.simulatorEmbed),
|
|
3
|
+
// so this public package points at nobody's service by default, and a host without a demo draws
|
|
4
|
+
// the caption alone rather than the block's source as code.
|
|
5
|
+
//
|
|
6
|
+
// ```simulator
|
|
7
|
+
// scenario: diane-meeting-prep
|
|
8
|
+
// caption: Step into the hour before Diane's first meeting. Press Start.
|
|
9
|
+
// height: 760px # optional, default 720px
|
|
10
|
+
// ```
|
|
11
|
+
//
|
|
12
|
+
// The same syntax the Superskills book's reader takes, so one block works in both.
|
|
13
|
+
const SCENARIO = /^[a-z0-9-]+$/;
|
|
14
|
+
const HEIGHT = /^\d{3,4}px$/;
|
|
15
|
+
const KEYS = ['scenario', 'caption', 'height'];
|
|
16
|
+
/** Parses a simulator fence's body. `line` is the line the fence opens on, so every error names
|
|
17
|
+
* the line the author sees. */
|
|
18
|
+
export function parseSimulatorFence(value, line) {
|
|
19
|
+
const got = {};
|
|
20
|
+
const rows = value ? value.split('\n') : [];
|
|
21
|
+
for (let i = 0; i < rows.length; i++) {
|
|
22
|
+
const raw = rows[i].trim();
|
|
23
|
+
if (!raw || raw.startsWith('#'))
|
|
24
|
+
continue;
|
|
25
|
+
const at = line + 1 + i;
|
|
26
|
+
const m = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(raw);
|
|
27
|
+
if (!m)
|
|
28
|
+
return { ok: false, error: `line ${at}: the simulator block takes lines like "scenario: <id>"` };
|
|
29
|
+
const key = m[1];
|
|
30
|
+
if (!KEYS.includes(key))
|
|
31
|
+
return { ok: false, error: `line ${at}: the simulator block has an unknown key: ${m[1]}` };
|
|
32
|
+
got[key] = { v: m[2].replace(/\s+#.*$/, '').trim(), at };
|
|
33
|
+
}
|
|
34
|
+
if (!got.scenario)
|
|
35
|
+
return { ok: false, error: `line ${line}: the simulator block needs a scenario` };
|
|
36
|
+
if (!SCENARIO.test(got.scenario.v))
|
|
37
|
+
return { ok: false, error: `line ${got.scenario.at}: a simulator scenario is lowercase letters, digits and dashes` };
|
|
38
|
+
if (!got.caption?.v)
|
|
39
|
+
return { ok: false, error: `line ${line}: the simulator block needs a caption, the line a reader sees under the demo` };
|
|
40
|
+
if (got.height && !HEIGHT.test(got.height.v))
|
|
41
|
+
return { ok: false, error: `line ${got.height.at}: a simulator height is a number of pixels, like 760px` };
|
|
42
|
+
return { ok: true, block: { scenario: got.scenario.v, caption: got.caption.v, height: got.height?.v ?? '720px' } };
|
|
43
|
+
}
|
|
44
|
+
/** The address the player loads: the host's embed page, the scenario, the timeline off. Null for
|
|
45
|
+
* an embed address that is not https, so a misconfigured host frames nothing. */
|
|
46
|
+
export function simulatorSrc(embed, scenario) {
|
|
47
|
+
let u;
|
|
48
|
+
try {
|
|
49
|
+
u = new URL(embed);
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
if (u.protocol !== 'https:')
|
|
55
|
+
return null;
|
|
56
|
+
u.searchParams.set('scenario', scenario);
|
|
57
|
+
u.searchParams.set('timeline', 'off');
|
|
58
|
+
return u.toString();
|
|
59
|
+
}
|
|
@@ -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
|
package/lib/artifacts/widgets.js
CHANGED
|
@@ -16,6 +16,7 @@ import remarkParse from 'remark-parse';
|
|
|
16
16
|
import remarkGfm from 'remark-gfm';
|
|
17
17
|
import { blockId } from './anchor.js';
|
|
18
18
|
import { MAX_NOTE_CHARS, NOTES_SLOT } from './notes-place.js';
|
|
19
|
+
import { parseSimulatorFence } from './simulator.js';
|
|
19
20
|
import { CHECKLIST_SLOT, assignChecklistIds, parseChecklistFence } from './checklist.js';
|
|
20
21
|
export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from './notes-place.js';
|
|
21
22
|
/** Widgets the design names and this version does not draw yet. A fence using one is refused at
|
|
@@ -100,6 +101,13 @@ export function scanWidgets(body, offset) {
|
|
|
100
101
|
}
|
|
101
102
|
notes = w;
|
|
102
103
|
}
|
|
104
|
+
for (const f of fences) {
|
|
105
|
+
if (f.lang !== 'simulator')
|
|
106
|
+
continue;
|
|
107
|
+
const r = parseSimulatorFence(f.value, (f.position?.start.line ?? 1) + offset);
|
|
108
|
+
if (!r.ok)
|
|
109
|
+
return r;
|
|
110
|
+
}
|
|
103
111
|
const lists = scanChecklists(fences, offset);
|
|
104
112
|
if (!lists.ok)
|
|
105
113
|
return lists;
|
|
@@ -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 }) {
|
|
@@ -27,6 +27,10 @@ export type ArtifactRoutesConfig = {
|
|
|
27
27
|
packFor?: (artifact: ArtifactRecord) => BrandPack | undefined;
|
|
28
28
|
/** The host that serves a 200, e.g. https://artifacts.example.com. No trailing slash. */
|
|
29
29
|
siteUrl: string;
|
|
30
|
+
/** The embed page of this host's Supersuit Session demo (an https URL, e.g.
|
|
31
|
+
* https://example.com/simulator/embed), for ```simulator blocks. Without it a block shows its
|
|
32
|
+
* caption only. No default: this package points at nobody's service. */
|
|
33
|
+
simulatorEmbed?: string;
|
|
30
34
|
/** Read at request time, so a rotated key needs no rebuild. */
|
|
31
35
|
publishKey: () => string | undefined;
|
|
32
36
|
/** Verifies publisher passes (`p1`, see artifacts/publisher.ts): a signed, hour-long stand-in
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -303,7 +303,7 @@ export function createArtifactRoutes(config) {
|
|
|
303
303
|
const title = v.title ?? a.title;
|
|
304
304
|
const summary = v.summary ?? a.summary;
|
|
305
305
|
const toc = showToc(v.markdown, a.toc) ? tocOf(v.markdown) : [];
|
|
306
|
-
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.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: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
|
|
306
|
+
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { "data-nospeak": true, className: `mx-auto max-w-2xl px-6 ${top ? 'pt-20 sm:pt-24' : 'pt-8'}`, children: _jsxs("p", { "data-version-banner": true, role: "status", className: "rounded-lg border px-4 py-3 text-sm", style: { borderColor: brand.accent }, children: [`You are reading version ${n} of ${of}. `, _jsx("a", { href: pagePath(a.id), className: "underline underline-offset-4", style: { color: brand.accent }, children: "Read the current version" })] }) }), _jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: pack.Cover ? (_jsx(pack.Cover, { title: title, subtitle: v.subtitle, summary: summary, to: v.to, markdown: v.markdown, kicker: _jsx(Kicker, { to: v.to, brand: brand }), meta: _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader }), version: n, updatedAt: v.at, createdAt: a.createdAt })) : (_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-10 sm:pt-12' : 'pt-10'} pb-8 text-center`, children: [_jsx(Kicker, { to: v.to, brand: brand }), _jsx("h1", { className: "text-4xl sm:text-5xl", style: { fontFamily: brand.type.display, color: brand.ink }, children: title }), v.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: v.subtitle })) : null, _jsx("p", { className: "mx-auto mt-6 max-w-xl text-lg italic opacity-80", children: summary }), _jsx(VersionLine, { a: a, n: n, at: v.at, history: history, reader: reader })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
|
|
307
307
|
}
|
|
308
308
|
/** The page itself, shared by open and gated pages. */
|
|
309
309
|
async function Body(a, { top, reader }) {
|
|
@@ -328,7 +328,7 @@ export function createArtifactRoutes(config) {
|
|
|
328
328
|
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] }));
|
|
329
329
|
return (_jsx(Framed, { pack: pack, toc: toc.length ? _jsx(TocInline, { items: toc }) : null, cover: _jsxs(_Fragment, { children: [header, a.cover ? (_jsx("div", { className: "mx-auto max-w-2xl px-6 pb-8", children: _jsx("img", { src: a.cover, alt: "", className: "w-full rounded-xl border border-[color:var(--a-line)]" }) })) : null] }), children: _jsx(ArtifactMarkdown, { markdown: a.markdown, definitions: a.definitions, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined,
|
|
330
330
|
// Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
|
|
331
|
-
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 }) }));
|
|
331
|
+
checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined }) }));
|
|
332
332
|
})() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && !!ownerEmail && reader.email === ownerEmail, version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
|
|
333
333
|
2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
|
|
334
334
|
recorded at publish, or there is none. */] }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.25.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",
|