@supersuit/artifacts 0.29.0 → 0.30.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 CHANGED
@@ -3,6 +3,54 @@
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.30.1
7
+
8
+ **Play from here is one compact pill that stays on screen.** A word tapped near the right edge put
9
+ the bar half off the screen, so its label wrapped to three lines inside a pill inside a pill, with
10
+ an emoji play glyph that iOS draws as a picture.
11
+
12
+ - One pill in the page's accent color, the UI font, 13px, never wrapping, a drawn play triangle, and
13
+ the close button inside the same pill.
14
+ - Placed under the tapped word and clamped inside the screen.
15
+ - DETECTOR: on a phone, pause and tap a word near the right edge. A three-line bubble means 0.30.0.
16
+ - REMEDY: update the dependency.
17
+ - Tests: `artifact-reader-controls.test.tsx` (no emoji, no wrapping, kept inside a 390px screen).
18
+
19
+ ## 0.30.0
20
+
21
+ **Every page has a page menu: one small "⋯" button, top right, whose items depend on who you are
22
+ to the page.** Release 1 of the approved design. Things that floated around the page on their own
23
+ (the History link, the comments on/off switch, sign in) now also have one always-findable home.
24
+
25
+ - A 36px round button fixed top right on every page, past version and door, themed with the
26
+ page's variables, labelled "Page menu". It opens a sheet on a phone and a popover on a desk;
27
+ a tap outside or Escape closes it.
28
+ - First line, the role: "Not signed in", "Signed in as Dana", "… · not on this page's list" at a
29
+ gated door, "… · owner of this page" (`ownerEmail`), "… · admin of this hub" (`admins`).
30
+ - Items, absent (never greyed) when a role cannot use them: **Share** (the phone's share sheet,
31
+ else copy with "Link copied"; on `invite` pages with "Only people on the list can open it."; on
32
+ password pages for the owner and admins only, the link carrying `?key=`), **Show / Hide
33
+ comments** (only where shared comments are on, kept in sync with the comment panel's own toggle),
34
+ **Sign in** (back to the exact address, hash included) / **Sign out**, **Version history**
35
+ (whoever `history` allows, admins by default), **Copy the markdown** (owner and admins: the
36
+ published front matter and body, ready to republish).
37
+ - The History control left the "Version N · Updated" line, which stays for everyone; the version
38
+ list stays in the server markup, hidden, for readers with scripts off.
39
+ - The server passes the menu only what this reader may use: the markdown source, a keyed share
40
+ link and the version list never reach a page rendered for anyone else.
41
+ - New exports from `/reader`: `PageMenu`, `roleLine`, `menuItems`, `HistoryPanel`, `VersionList`,
42
+ `VersionHistory`, and the page bridge (`requestPins`, `announcePins`, `signInBack`).
43
+ - DETECTOR: open any page. No round "⋯" button in the top right means 0.29.0 or earlier.
44
+ - REMEDY: update the dependency. No config change; set `ownerEmail` and `admins` for the owner and
45
+ admin items, `signInOrigin` for Sign in.
46
+ - Tests: `reader/page-menu.test.tsx` (role line per role, items per role, share copies on a desk
47
+ and uses the sheet on a phone, a refused or hung share/clipboard still answers, invite note,
48
+ copy markdown, sign-in returns with the hash, history panel, closing, two-way sync with the
49
+ comment layer), `routes/page-menu.test.tsx` (role and items per role and access level, password
50
+ share only for admins, the markdown source and version list never handed to non-admins). The
51
+ 0.15.0 golden was regenerated on purpose: the History control moved into the menu marker.
52
+ `test:packed` green, with the menu marker checked in the built fixture.
53
+
6
54
  ## 0.29.0
7
55
 
8
56
  **The "comment can't be shown" notice is a readable card with a way in.** 0.28.0 drew it as a pill
package/README.md CHANGED
@@ -114,10 +114,46 @@ hostname and pick by the `host` header.
114
114
  body. The response carries the page's id and URL. Posting a file whose front matter carries
115
115
  that `id:` republishes it in place, keeping every earlier version.
116
116
 
117
+ ## The page menu
118
+
119
+ Every page, past version and door carries one small round **⋯** button in the top right corner
120
+ (`aria-label="Page menu"`), the same on every host. Tapping it opens a short menu: a sheet from
121
+ the bottom on a phone, a popover under the button on a desk. A tap outside, Escape, or choosing an
122
+ item closes it. It paints with the page's own theme variables, so it follows the pack and the
123
+ light or dark mode.
124
+
125
+ The first line says who the reader is to this page, and the items depend on it. **An item a role
126
+ cannot use is absent, never greyed out.**
127
+
128
+ | Role | Role line | Items |
129
+ |---|---|---|
130
+ | Visitor (signed out) | Not signed in | Share, Show / Hide comments, Sign in |
131
+ | Reader (signed in) | Signed in as Dana | Share, Show / Hide comments, Sign out |
132
+ | Shut out (at a gated page's door) | Signed in as Dana · not on this page's list | Share, Sign out |
133
+ | Owner (`ownerEmail`) | Signed in as Robin · owner of this page | Share, Show / Hide comments, Version history, Copy the markdown, Sign out |
134
+ | Admin (`admins`) | Signed in as Casey · admin of this hub | the owner's items |
135
+
136
+ - **Share** opens the phone's own share sheet where there is one, and otherwise copies the link and
137
+ says "Link copied". Public and `access: freedom` pages show it to everyone; `access: invite` pages
138
+ show it with the line "Only people on the list can open it."; a password page shows it to the
139
+ owner and admins only, with the password in the link (`?key=`), since only they hold it.
140
+ - **Show / Hide comments** appears only where shared comments are on, and is the same switch as
141
+ the comment panel's own toggle: moving either moves both.
142
+ - **Sign in** needs `signInOrigin` and returns to the exact address, hash included. **Sign out** is
143
+ this host's `/api/reader/leave` (the `LEAVE` handler).
144
+ - **Version history** is for whoever `history` lets see past versions (the owner and admins by
145
+ default) and opens the panel described below.
146
+ - **Copy the markdown** copies the page as published (an `id`, `title`, `summary`, `subtitle` and
147
+ `to` front matter, then the body), ready to paste into a session and republish onto the same page.
148
+
149
+ The server decides every item and hands the browser only what this reader may use: the markdown
150
+ source, a share link carrying a password, and the version list are never in a page rendered for
151
+ anyone else. `PageMenu`, `roleLine` and `menuItems` are exported from `/reader`.
152
+
117
153
  ## Version history
118
154
 
119
- Every page names its version under the summary, "Version 4 · Updated <minute> · History", and
120
- History opens a panel listing every version newest first: its number, the minute it went live in
155
+ Every page names its version under the summary, "Version 4 · Updated <minute>", and the page
156
+ menu's **Version history** opens a panel listing every version newest first: its number, the minute it went live in
121
157
  the reader's own time zone, and its change note. The panel is drawn over the page (a sheet on a
122
158
  phone, a side panel on a desk), so opening it moves no text. Each past version is readable at
123
159
  `/<id>/v/<n>`, read-only, under a banner naming it and linking the current page, behind exactly
@@ -125,7 +161,7 @@ the same password or confidential door as the page itself. A version that does n
125
161
  404; on a shut page the door comes first, so it never says which versions exist.
126
162
 
127
163
  **Past versions are for admins by default (0.21.0).** An earlier draft can hold what the current
128
- one was edited to remove, so only the page's admins see the History control or open
164
+ one was edited to remove, so only the page's admins see Version history in the page menu or open
129
165
  `/<id>/v/<n>`: the reader signed in as `ownerEmail` or any address in `admins` (case does not
130
166
  matter). Everyone else still sees "Version N · Updated <minute>", and a past version is a 404 to
131
167
  them. With neither configured, nobody does. `history: 'everyone'` in the config restores the old
@@ -144,6 +144,8 @@ export function isUserScrollKey(key, target) {
144
144
  return false;
145
145
  return true;
146
146
  }
147
+ /** The Play from here bar's width, so it can be kept inside the screen. */
148
+ const OFFER_WIDTH = 168;
147
149
  export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
148
150
  const narrator = label;
149
151
  const audioRef = useRef(null);
@@ -184,7 +186,9 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
184
186
  return;
185
187
  }
186
188
  const r = el.getBoundingClientRect();
187
- setOffer({ idx, top: r.bottom + window.scrollY + 6, left: Math.max(8, r.left + window.scrollX) });
189
+ // Kept on screen: a word near the right edge would push the bar off it and wrap its label.
190
+ const vw = window.innerWidth || document.documentElement.clientWidth || 1024;
191
+ setOffer({ idx, top: r.bottom + window.scrollY + 8, left: Math.max(8, Math.min(r.left, vw - OFFER_WIDTH - 8)) + window.scrollX });
188
192
  };
189
193
  root.addEventListener('click', onClick);
190
194
  return () => root.removeEventListener('click', onClick);
@@ -264,7 +268,7 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
264
268
  follow(true);
265
269
  }, onSeeking: () => {
266
270
  startedRef.current = true;
267
- }, onPause: () => { playingRef.current = false; setPlaying(false); }, onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), offer ? (_jsxs("div", { "data-nospeak": true, className: "absolute z-50 flex items-center gap-1 rounded-full border px-1 py-1 text-sm shadow-lg", style: { top: offer.top, left: offer.left, background: ground, borderColor: accent }, children: [_jsx("button", { type: "button", "data-play-from-here": true, onClick: () => playFrom(offer.idx), className: "rounded-full px-3 py-1 font-medium", style: { background: accent, color: ground }, children: "\u25B6 Play from here" }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setOffer(null), className: "px-2 opacity-70", children: "\u00D7" })] })) : null, playing && !following ? (_jsx("button", { type: "button", "data-follow-voice": true, "data-nospeak": true, onClick: backToVoice, className: "fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium shadow-lg", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)', background: accent, color: ground }, children: "Back to the voice" })) : null, _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: narrator, accent: accent, ground: ground, value: t, max: dur, step: 0.1, onSeek: (v) => {
271
+ }, onPause: () => { playingRef.current = false; setPlaying(false); }, onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), offer ? (_jsxs("div", { "data-nospeak": true, "data-play-offer": true, className: "absolute z-50 flex items-center rounded-full font-sans text-[13px] leading-none shadow-lg", style: { top: offer.top, left: offer.left, width: OFFER_WIDTH, whiteSpace: 'nowrap', background: accent, color: ground }, children: [_jsxs("button", { type: "button", "data-play-from-here": true, onClick: () => playFrom(offer.idx), className: "flex flex-1 items-center gap-1.5 rounded-l-full py-2 pl-3 pr-1 font-medium", children: [_jsx("svg", { "aria-hidden": "true", width: "10", height: "11", viewBox: "0 0 10 11", children: _jsx("path", { d: "M1 1l8 4.5L1 10z", fill: "currentColor" }) }), "Play from here"] }), _jsx("button", { type: "button", "aria-label": "Close", onClick: () => setOffer(null), className: "rounded-r-full py-2 pl-1 pr-3 text-base leading-none opacity-70", children: "\u00D7" })] })) : null, playing && !following ? (_jsx("button", { type: "button", "data-follow-voice": true, "data-nospeak": true, onClick: backToVoice, className: "fixed left-1/2 z-50 -translate-x-1/2 rounded-full px-4 py-2 text-sm font-medium shadow-lg", style: { bottom: 'calc(env(safe-area-inset-bottom, 0px) + 5.5rem)', background: accent, color: ground }, children: "Back to the voice" })) : null, _jsx(PlayerBar, { playing: playing, onToggle: toggle, label: narrator, accent: accent, ground: ground, value: t, max: dur, step: 0.1, onSeek: (v) => {
268
272
  if (audioRef.current)
269
273
  audioRef.current.currentTime = v;
270
274
  }, progress: `${fmt(t)} / ${fmt(dur)}`, rate: rate, onRate: () => {
@@ -24,6 +24,7 @@ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-run
24
24
  // overlay is portalled onto document.body so its coordinates are page coordinates.
25
25
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
26
26
  import { createPortal } from 'react-dom';
27
+ import { PINS_SET, announcePins, signInBack } from './page-bridge.js';
27
28
  import { MAX_QUOTE_CHARS, textAnchorFrom } from '../artifacts/anchor.js';
28
29
  import { COMMENTS_SLOT } from '../artifacts/comments.js';
29
30
  import { CommentCard } from './comment-card.js';
@@ -37,18 +38,6 @@ import { boxOf, headingAt, indexText, offsetAt, placeAnchor, regionFrom } from '
37
38
  const isValue = (v) => !!v && typeof v === 'object' && typeof v.body === 'string' && !!v.anchor && typeof v.anchor === 'object';
38
39
  const LONG_PRESS_MS = 400;
39
40
  const HASH = /^#comment-([A-Za-z0-9_-]{1,128})$/;
40
- /** The sign-in link, returning to this exact address, hash included, so a reader who signs in to
41
- * see a linked comment lands on it. */
42
- function signInBack(signIn) {
43
- try {
44
- const u = new URL(signIn);
45
- u.searchParams.set('to', window.location.href);
46
- return u.toString();
47
- }
48
- catch {
49
- return signIn;
50
- }
51
- }
52
41
  /** The entry id a `#comment-<id>` hash names, or null. */
53
42
  const commentHashId = (hash) => HASH.exec(hash)?.[1] ?? null;
54
43
  const DRAG_PX = 8;
@@ -360,6 +349,14 @@ export function CommentLayer({ artifactId, rootId, ownerName, comments, canShare
360
349
  return { placed, earlier };
361
350
  // `tick` re-measures after a resize.
362
351
  }, [mounted, rootId, tops, tick]); // eslint-disable-line react-hooks/exhaustive-deps
352
+ // The page menu's Show / Hide comments drives the same switch as the panel's toggle, and hears
353
+ // back whichever one moved it, so the two never disagree.
354
+ useEffect(() => {
355
+ const onSet = (e) => setShowPins(e.detail === true);
356
+ window.addEventListener(PINS_SET, onSet);
357
+ return () => window.removeEventListener(PINS_SET, onSet);
358
+ }, []);
359
+ useEffect(() => { announcePins(showPins); }, [showPins]);
363
360
  // Show the comment the address names once it has loaded: a reply is shown in its parent's thread.
364
361
  // Held until the comments arrive; an id that is not among them (deleted, or not this reader's to
365
362
  // see) says so in a line, rather than leaving the reader wondering why nothing happened.
@@ -1,6 +1,9 @@
1
1
  export { ArtifactReader, wrapWords, seekableWord, litWordIndex, type WordTiming } from './artifact-reader.js';
2
2
  export { PlayerBar } from './player-bar.js';
3
3
  export { CommentLayer } from './comment-layer.js';
4
+ export { PageMenu, roleLine, menuItems, type PageMenuProps, type PageRole } from './page-menu.js';
5
+ export { VersionHistory, HistoryPanel, VersionList, type HistoryItem } from './version-history.js';
6
+ export { requestPins, announcePins, signInBack, PINS_SET, PINS_STATE } from './page-bridge.js';
4
7
  export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
5
8
  export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
6
9
  export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom, type TextIndex, type Placement } from './comment-place.js';
@@ -3,6 +3,9 @@
3
3
  export { ArtifactReader, wrapWords, seekableWord, litWordIndex } from './artifact-reader.js';
4
4
  export { PlayerBar } from './player-bar.js';
5
5
  export { CommentLayer } from './comment-layer.js';
6
+ export { PageMenu, roleLine, menuItems } from './page-menu.js';
7
+ export { VersionHistory, HistoryPanel, VersionList } from './version-history.js';
8
+ export { requestPins, announcePins, signInBack, PINS_SET, PINS_STATE } from './page-bridge.js';
6
9
  export { CommentCard, warningLine, DEVICE_LINE } from './comment-card.js';
7
10
  export { deviceNotes, deviceNotesKey, addDeviceNote, replaceDeviceNote, removeDeviceNote } from './device-notes.js';
8
11
  export { indexText, offsetAt, rangeOf, headingAt, placeAnchor, boxOf, regionFrom } from './comment-place.js';
@@ -0,0 +1,9 @@
1
+ /** The menu asks the comment layer to show (true) or hide (false) its pins. */
2
+ export declare const PINS_SET = "artifact:pins-set";
3
+ /** The comment layer says whether its pins are showing, on mount and on every change. */
4
+ export declare const PINS_STATE = "artifact:pins-state";
5
+ export declare function requestPins(show: boolean): void;
6
+ export declare function announcePins(show: boolean): void;
7
+ /** The sign-in link, returning to this exact address, hash included, so a reader who signs in
8
+ * lands where they were (a linked comment, a section). */
9
+ export declare function signInBack(signIn: string): string;
@@ -0,0 +1,24 @@
1
+ // Small bridges between the page menu and the parts of the page it drives, so neither has to
2
+ // import the other. Both run in the same window; a window event is the whole wire.
3
+ /** The menu asks the comment layer to show (true) or hide (false) its pins. */
4
+ export const PINS_SET = 'artifact:pins-set';
5
+ /** The comment layer says whether its pins are showing, on mount and on every change. */
6
+ export const PINS_STATE = 'artifact:pins-state';
7
+ export function requestPins(show) {
8
+ window.dispatchEvent(new CustomEvent(PINS_SET, { detail: show }));
9
+ }
10
+ export function announcePins(show) {
11
+ window.dispatchEvent(new CustomEvent(PINS_STATE, { detail: show }));
12
+ }
13
+ /** The sign-in link, returning to this exact address, hash included, so a reader who signs in
14
+ * lands where they were (a linked comment, a section). */
15
+ export function signInBack(signIn) {
16
+ try {
17
+ const u = new URL(signIn);
18
+ u.searchParams.set('to', window.location.href);
19
+ return u.toString();
20
+ }
21
+ catch {
22
+ return signIn;
23
+ }
24
+ }
@@ -0,0 +1,34 @@
1
+ import { type HistoryItem } from './version-history.js';
2
+ export type PageRole = 'visitor' | 'reader' | 'owner' | 'admin';
3
+ export type PageMenuProps = {
4
+ role: PageRole;
5
+ /** What to call the reader in the role line: a first name, else their email. Null signed out. */
6
+ name: string | null;
7
+ /** The page is gated and this reader is not let in (they see its door). */
8
+ shutOut?: boolean;
9
+ /** The link to share, or null when this reader cannot share this page. `note` sits under it. */
10
+ share: {
11
+ url: string;
12
+ title: string;
13
+ note?: string;
14
+ } | null;
15
+ /** Shared comments are on here, so the pins can be shown or hidden. */
16
+ comments: boolean;
17
+ /** The sign-in authority's link for this page, when signed out on a host that signs readers in. */
18
+ signIn: string | null;
19
+ /** This host's sign-out link, when signed in. */
20
+ signOut: string | null;
21
+ /** Every version, when this reader may see past versions and there is more than one. */
22
+ history: {
23
+ items: HistoryItem[];
24
+ base: string;
25
+ viewing: number;
26
+ } | null;
27
+ /** The published source, for the owner and admins only. */
28
+ markdown: string | null;
29
+ };
30
+ /** The menu's first line: who this reader is to this page. */
31
+ export declare function roleLine(role: PageRole, name: string | null, shutOut?: boolean): string;
32
+ /** The items this reader gets, in order. Exported so the server markup and tests can name them. */
33
+ export declare function menuItems(p: Pick<PageMenuProps, 'share' | 'comments' | 'signIn' | 'signOut' | 'history' | 'markdown'>): string[];
34
+ export declare function PageMenu(props: PageMenuProps): import("react").JSX.Element;
@@ -0,0 +1,181 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // The page menu: one small round "⋯" button fixed in the top right of every artifact page, the
4
+ // same on every page and every host. Tapping it opens a short menu whose first line says who the
5
+ // reader is to this page ("Signed in as Robin · admin of this hub"), and whose items depend on
6
+ // that role. Items a role cannot use are absent, never greyed out.
7
+ //
8
+ // Release 1 (0.30.0): Share, Show / Hide comments, Sign in / Sign out, Version history and Copy
9
+ // the markdown. The server decides every item (routes/artifacts.tsx) and passes only what this
10
+ // reader may have: the markdown source and the history list never reach a reader who may not use
11
+ // them, so nothing here has to hide anything.
12
+ //
13
+ // Rendered as a hidden marker on the server and on the first client render, so the markup always
14
+ // hydrates; after mount the button and its menu are portalled onto document.body carrying the
15
+ // page's theme variables, like the comment layer, so nothing on the page can paint over them. On
16
+ // a phone the menu is a sheet from the bottom; on a desk a popover under the button. A tap
17
+ // outside, Escape, or choosing an item closes it.
18
+ import { useCallback, useEffect, useRef, useState } from 'react';
19
+ import { createPortal } from 'react-dom';
20
+ import { themeVarsAround } from './theme-vars.js';
21
+ import { HistoryPanel, VersionList } from './version-history.js';
22
+ import { PINS_STATE, requestPins, signInBack } from './page-bridge.js';
23
+ /** The menu's first line: who this reader is to this page. */
24
+ export function roleLine(role, name, shutOut = false) {
25
+ if (role === 'visitor' || !name)
26
+ return 'Not signed in';
27
+ if (role === 'admin')
28
+ return `Signed in as ${name} · admin of this hub`;
29
+ if (role === 'owner')
30
+ return `Signed in as ${name} · owner of this page`;
31
+ return shutOut ? `Signed in as ${name} · not on this page's list` : `Signed in as ${name}`;
32
+ }
33
+ /** The items this reader gets, in order. Exported so the server markup and tests can name them. */
34
+ export function menuItems(p) {
35
+ return [
36
+ p.share ? 'share' : null,
37
+ p.comments ? 'comments' : null,
38
+ p.history ? 'history' : null,
39
+ p.markdown !== null ? 'markdown' : null,
40
+ p.signOut ? 'sign-out' : p.signIn ? 'sign-in' : null,
41
+ ].filter((x) => !!x);
42
+ }
43
+ const PHONE = '(max-width: 639px)';
44
+ const isPhone = () => (typeof window.matchMedia === 'function' ? window.matchMedia(PHONE).matches : window.innerWidth < 640);
45
+ const coarse = () => typeof window.matchMedia === 'function' && window.matchMedia('(pointer: coarse)').matches;
46
+ /** Copy text, by the clipboard API where it exists, else the old textarea way. */
47
+ async function copyText(text) {
48
+ try {
49
+ if (navigator.clipboard?.writeText) {
50
+ // Bounded: a browser waiting on a permission it never asks for leaves the promise pending
51
+ // (seen in headless Chrome), and the reader would get no answer at all.
52
+ const done = await Promise.race([
53
+ navigator.clipboard.writeText(text).then(() => true, () => false),
54
+ new Promise((r) => setTimeout(() => r(false), 1200)),
55
+ ]);
56
+ if (done)
57
+ return true;
58
+ }
59
+ }
60
+ catch { /* fall through */ }
61
+ try {
62
+ const t = document.createElement('textarea');
63
+ t.value = text;
64
+ t.setAttribute('readonly', '');
65
+ t.style.position = 'fixed';
66
+ t.style.opacity = '0';
67
+ document.body.appendChild(t);
68
+ t.select();
69
+ const ok = document.execCommand?.('copy') ?? false;
70
+ t.remove();
71
+ return ok;
72
+ }
73
+ catch {
74
+ return false;
75
+ }
76
+ }
77
+ export function PageMenu(props) {
78
+ const { role, name, shutOut, share, comments, signIn, signOut, history, markdown } = props;
79
+ const items = menuItems(props);
80
+ const [mounted, setMounted] = useState(false);
81
+ const [open, setOpen] = useState(false);
82
+ const [phone, setPhone] = useState(false);
83
+ const [vars, setVars] = useState({});
84
+ const [pins, setPins] = useState(true);
85
+ const [said, setSaid] = useState(null);
86
+ const [historyOpen, setHistoryOpen] = useState(false);
87
+ const marker = useRef(null);
88
+ const button = useRef(null);
89
+ const menu = useRef(null);
90
+ useEffect(() => {
91
+ setVars(themeVarsAround(marker.current));
92
+ setMounted(true);
93
+ const onPins = (e) => setPins(e.detail === true);
94
+ window.addEventListener(PINS_STATE, onPins);
95
+ return () => window.removeEventListener(PINS_STATE, onPins);
96
+ }, []);
97
+ const close = useCallback((focus = true) => {
98
+ setOpen(false);
99
+ setSaid(null);
100
+ if (focus)
101
+ button.current?.focus();
102
+ }, []);
103
+ useEffect(() => {
104
+ if (!open)
105
+ return;
106
+ setPhone(isPhone());
107
+ setVars(themeVarsAround(marker.current));
108
+ menu.current?.querySelector('[role="menuitem"]')?.focus();
109
+ const onKey = (e) => { if (e.key === 'Escape')
110
+ close(); };
111
+ const onDown = (e) => {
112
+ const t = e.target;
113
+ if (menu.current?.contains(t) || button.current?.contains(t))
114
+ return;
115
+ close(false);
116
+ };
117
+ document.addEventListener('keydown', onKey);
118
+ document.addEventListener('pointerdown', onDown);
119
+ return () => {
120
+ document.removeEventListener('keydown', onKey);
121
+ document.removeEventListener('pointerdown', onDown);
122
+ };
123
+ }, [open, close]);
124
+ const onShare = async () => {
125
+ if (!share)
126
+ return;
127
+ // A phone hands the link to its own share sheet (Messages, Mail, AirDrop); a desk copies it.
128
+ if (coarse() && typeof navigator.share === 'function') {
129
+ try {
130
+ await navigator.share({ title: share.title, url: share.url });
131
+ close();
132
+ return;
133
+ }
134
+ catch (e) {
135
+ // The reader backed out of the sheet: leave the menu as it was. Any other refusal (no
136
+ // permission, an unsupported page) falls through to copying, so Share always answers.
137
+ if (e?.name === 'AbortError')
138
+ return;
139
+ }
140
+ }
141
+ setSaid((await copyText(share.url)) ? 'Link copied' : 'Could not copy the link');
142
+ };
143
+ const onMarkdown = async () => {
144
+ if (markdown === null)
145
+ return;
146
+ setSaid((await copyText(markdown)) ? 'Markdown copied' : 'Could not copy the markdown');
147
+ };
148
+ const onPins = () => {
149
+ requestPins(!pins);
150
+ setPins(!pins);
151
+ close();
152
+ };
153
+ const onHistory = () => {
154
+ setOpen(false);
155
+ setHistoryOpen(true);
156
+ };
157
+ const line = roleLine(role, name, shutOut);
158
+ const itemStyle = {
159
+ display: 'block', width: '100%', boxSizing: 'border-box', textAlign: 'left', font: 'inherit', fontSize: phone ? 16 : 14,
160
+ color: 'inherit', background: 'none', border: 0, borderRadius: 8, padding: phone ? '12px 12px' : '8px 10px',
161
+ cursor: 'pointer', textDecoration: 'none', lineHeight: 1.3,
162
+ };
163
+ const sub = { display: 'block', marginTop: 2, fontSize: 12, opacity: 0.6 };
164
+ const panelStyle = {
165
+ ...vars,
166
+ position: 'fixed', zIndex: 60, boxSizing: 'border-box', background: 'var(--a-surface, var(--a-ground, #fff))', color: 'var(--a-strong, var(--a-ink, #111))',
167
+ border: '1px solid var(--a-line, rgba(127,127,127,0.3))', boxShadow: '0 12px 32px rgba(0,0,0,0.22)', fontFamily: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
168
+ fontStyle: 'normal', fontWeight: 400, letterSpacing: 'normal', textTransform: 'none', outline: 'none',
169
+ ...(phone
170
+ ? { left: 8, right: 8, bottom: 'calc(env(safe-area-inset-bottom, 0px) + 8px)', borderRadius: 16, padding: '6px 6px 8px', maxHeight: '70vh', overflowY: 'auto' }
171
+ : { top: 'calc(env(safe-area-inset-top, 0px) + 56px)', right: 12, width: 264, borderRadius: 12, padding: 6 }),
172
+ };
173
+ const ui = mounted ? (_jsxs("div", { "data-page-menu-ui": true, "data-nospeak": true, "data-comment-ui": true, style: vars, children: [_jsx("button", { ref: button, type: "button", "data-page-menu-button": true, "aria-label": "Page menu", "aria-haspopup": "menu", "aria-expanded": open, onClick: () => (open ? close() : (setSaid(null), setOpen(true))), style: {
174
+ position: 'fixed', zIndex: 45, top: 'calc(env(safe-area-inset-top, 0px) + 12px)', right: 12, width: 36, height: 36,
175
+ display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 0, margin: 0, borderRadius: 999, cursor: 'pointer',
176
+ color: 'var(--a-ink, #111)', background: 'color-mix(in srgb, var(--a-ground, #fff) 82%, transparent)',
177
+ border: '1px solid var(--a-line, rgba(127,127,127,0.3))', boxShadow: '0 1px 4px rgba(0,0,0,0.12)',
178
+ backdropFilter: 'blur(8px)', WebkitBackdropFilter: 'blur(8px)',
179
+ }, children: _jsxs("svg", { width: "18", height: "18", viewBox: "0 0 18 18", "aria-hidden": true, focusable: "false", children: [_jsx("circle", { cx: "3.5", cy: "9", r: "1.6", fill: "currentColor" }), _jsx("circle", { cx: "9", cy: "9", r: "1.6", fill: "currentColor" }), _jsx("circle", { cx: "14.5", cy: "9", r: "1.6", fill: "currentColor" })] }) }), open && phone ? _jsx("div", { "aria-hidden": true, "data-page-menu-scrim": true, style: { position: 'fixed', inset: 0, zIndex: 59, background: 'rgba(0,0,0,0.28)' } }) : null, open ? (_jsxs("div", { ref: menu, "data-page-menu": true, role: "menu", "aria-label": "Page menu", style: panelStyle, children: [_jsx("p", { "data-page-menu-role": true, style: { margin: 0, padding: phone ? '10px 12px 8px' : '8px 10px 6px', fontSize: 12, lineHeight: 1.35, opacity: 0.7, overflowWrap: 'anywhere' }, children: line }), items.length ? _jsx("div", { "aria-hidden": true, style: { height: 1, margin: '0 6px 4px', background: 'var(--a-line, rgba(127,127,127,0.3))' } }) : null, share ? (_jsxs("button", { type: "button", role: "menuitem", "data-page-menu-item": "share", onClick: () => void onShare(), style: itemStyle, children: [said === 'Link copied' || said === 'Could not copy the link' ? said : 'Share', share.note ? _jsx("span", { "data-page-menu-note": true, style: sub, children: share.note }) : null] })) : null, comments ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "comments", onClick: onPins, style: itemStyle, children: pins ? 'Hide comments' : 'Show comments' })) : null, history ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "history", "aria-haspopup": "dialog", onClick: onHistory, style: itemStyle, children: "Version history" })) : null, markdown !== null ? (_jsx("button", { type: "button", role: "menuitem", "data-page-menu-item": "markdown", onClick: () => void onMarkdown(), style: itemStyle, children: said === 'Markdown copied' || said === 'Could not copy the markdown' ? said : 'Copy the markdown' })) : null, signOut ? (_jsx("a", { role: "menuitem", "data-page-menu-item": "sign-out", href: signOut, style: itemStyle, children: "Sign out" })) : signIn ? (_jsx("a", { role: "menuitem", "data-page-menu-item": "sign-in", href: signIn, onClick: (e) => { e.currentTarget.href = signInBack(signIn); }, style: itemStyle, children: "Sign in" })) : null, _jsx("span", { role: "status", "aria-live": "polite", style: { position: 'absolute', width: 1, height: 1, overflow: 'hidden', clip: 'rect(0 0 0 0)' }, children: said ?? '' })] })) : null, historyOpen && history ? (_jsx(HistoryPanel, { items: history.items, base: history.base, viewing: history.viewing, anchor: button, onClose: () => { setHistoryOpen(false); button.current?.focus(); }, onOutside: () => setHistoryOpen(false) })) : null] })) : null;
180
+ return (_jsxs(_Fragment, { children: [_jsx("span", { ref: marker, hidden: true, "data-page-menu-for": role, "data-page-menu-items": items.join(' '), children: history ? _jsx(VersionList, { items: history.items, base: history.base, marker: true }) : null }), ui ? createPortal(ui, document.body) : null] }));
181
+ }
@@ -12,3 +12,23 @@ export declare function VersionHistory({ items, base, viewing }: {
12
12
  /** The version this page shows. */
13
13
  viewing: number;
14
14
  }): import("react").JSX.Element;
15
+ /** Every version as plain links, hidden: server markup a reader with scripts off can still use. */
16
+ export declare function VersionList({ items, base, marker }: {
17
+ items: HistoryItem[];
18
+ base: string;
19
+ marker?: boolean;
20
+ }): import("react").JSX.Element;
21
+ /** The open list, portalled onto document.body. `anchor` is the control that opened it: its theme
22
+ * variables are carried onto the panel, and a tap on it is not a tap outside. */
23
+ export declare function HistoryPanel({ items, base, viewing, anchor, onClose, onOutside }: {
24
+ items: HistoryItem[];
25
+ base: string;
26
+ viewing: number;
27
+ anchor: {
28
+ current: HTMLElement | null;
29
+ };
30
+ /** Escape or the close button: closes and hands focus back. */
31
+ onClose: () => void;
32
+ /** A tap outside the panel and the anchor. */
33
+ onOutside: () => void;
34
+ }): import("react").ReactPortal | null;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
- import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
- // The page's history, opened from the "Version N" line under the summary. The list renders
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The page's history, opened from the page menu ("Version history"), or from the standalone
4
+ // History control a host may still place itself. The list renders
4
5
  // through a portal on document.body, OUTSIDE the text flow, like the definition layer, so opening
5
6
  // it cannot reflow a word of the page: on a phone it is a sheet pinned to the bottom, above the
6
7
  // narration bar; on a desk it is a panel pinned to the right of the window. Escape, the close
@@ -17,44 +18,51 @@ const MARGIN = 16;
17
18
  const VARS = ['--a-ground', '--a-ink', '--a-accent', '--a-line'];
18
19
  export function VersionHistory({ items, base, viewing }) {
19
20
  const [open, setOpen] = useState(false);
21
+ const button = useRef(null);
22
+ const close = useCallback(() => {
23
+ setOpen(false);
24
+ button.current?.focus();
25
+ }, []);
26
+ return (_jsxs(_Fragment, { children: [_jsx("button", { ref: button, type: "button", "data-version-history": true, "aria-haspopup": "dialog", "aria-expanded": open, onClick: () => setOpen((o) => !o), style: { font: 'inherit', color: 'inherit', background: 'none', border: 0, padding: 0, cursor: 'pointer', textDecoration: 'underline', textUnderlineOffset: '0.2em' }, children: "History" }), _jsx(VersionList, { items: items, base: base }), open ? _jsx(HistoryPanel, { items: items, base: base, viewing: viewing, anchor: button, onClose: close, onOutside: () => setOpen(false) }) : null] }));
27
+ }
28
+ /** Every version as plain links, hidden: server markup a reader with scripts off can still use. */
29
+ export function VersionList({ items, base, marker }) {
30
+ return (_jsx("span", { hidden: true, "data-version-list": true, ...(marker ? { 'data-version-history': '' } : {}), children: items.map((v) => (_jsx("a", { href: v.current ? base : `${base}/v/${v.version}`, children: `Version ${v.version}${v.note ? `: ${v.note}` : ''}` }, v.version))) }));
31
+ }
32
+ /** The open list, portalled onto document.body. `anchor` is the control that opened it: its theme
33
+ * variables are carried onto the panel, and a tap on it is not a tap outside. */
34
+ export function HistoryPanel({ items, base, viewing, anchor, onClose, onOutside }) {
20
35
  const [phone, setPhone] = useState(false);
21
36
  const [bottom, setBottom] = useState(MARGIN);
22
37
  const [vars, setVars] = useState({});
23
38
  const [zone, setZone] = useState('UTC');
24
- const button = useRef(null);
25
39
  const panel = useRef(null);
26
40
  const measure = useCallback(() => {
27
- const el = button.current;
28
- if (!el)
29
- return;
30
- const cs = getComputedStyle(el);
31
- setVars({ ...Object.fromEntries(VARS.map((v) => [v, cs.getPropertyValue(v).trim()]).filter(([, x]) => x)), fontFamily: cs.fontFamily });
41
+ const el = anchor.current;
42
+ if (el) {
43
+ const cs = getComputedStyle(el);
44
+ setVars({ ...Object.fromEntries(VARS.map((v) => [v, cs.getPropertyValue(v).trim()]).filter(([, x]) => x)), fontFamily: cs.fontFamily });
45
+ }
32
46
  const isPhone = window.matchMedia ? window.matchMedia(PHONE).matches : window.innerWidth < 640;
33
47
  setPhone(isPhone);
34
48
  const bar = document.querySelector('[data-artifact-player]')?.getBoundingClientRect();
35
49
  setBottom((bar && bar.top < window.innerHeight ? window.innerHeight - bar.top : 0) + MARGIN);
36
- }, []);
37
- const close = useCallback(() => {
38
- setOpen(false);
39
- button.current?.focus();
40
- }, []);
50
+ }, [anchor]);
41
51
  useEffect(() => {
42
52
  setZone(Intl.DateTimeFormat().resolvedOptions().timeZone);
43
53
  }, []);
44
54
  useEffect(() => {
45
- if (!open)
46
- return;
47
55
  measure();
48
56
  panel.current?.focus();
49
57
  const onKey = (e) => {
50
58
  if (e.key === 'Escape')
51
- close();
59
+ onClose();
52
60
  };
53
61
  const onDown = (e) => {
54
62
  const t = e.target;
55
- if (panel.current?.contains(t) || button.current?.contains(t))
63
+ if (panel.current?.contains(t) || anchor.current?.contains(t))
56
64
  return;
57
- setOpen(false);
65
+ onOutside();
58
66
  };
59
67
  document.addEventListener('keydown', onKey);
60
68
  document.addEventListener('pointerdown', onDown);
@@ -64,7 +72,7 @@ export function VersionHistory({ items, base, viewing }) {
64
72
  document.removeEventListener('pointerdown', onDown);
65
73
  window.removeEventListener('resize', measure);
66
74
  };
67
- }, [open, measure, close]);
75
+ }, [measure, onClose, onOutside, anchor]);
68
76
  const style = {
69
77
  ...vars,
70
78
  zIndex: 60,
@@ -85,13 +93,13 @@ export function VersionHistory({ items, base, viewing }) {
85
93
  ? { position: 'fixed', left: MARGIN, right: MARGIN, bottom: `calc(${bottom}px + env(safe-area-inset-bottom, 0px))`, maxHeight: '65vh' }
86
94
  : { position: 'fixed', top: 80, right: 24, width: 380, maxHeight: 'calc(100vh - 120px)' }),
87
95
  };
88
- return (_jsxs(_Fragment, { children: [_jsx("button", { ref: button, type: "button", "data-version-history": true, "aria-haspopup": "dialog", "aria-expanded": open, onClick: () => setOpen((o) => !o), style: { font: 'inherit', color: 'inherit', background: 'none', border: 0, padding: 0, cursor: 'pointer', textDecoration: 'underline', textUnderlineOffset: '0.2em' }, children: "History" }), _jsx("span", { hidden: true, "data-version-list": true, children: items.map((v) => (_jsx("a", { href: v.current ? base : `${base}/v/${v.version}`, children: `Version ${v.version}${v.note ? `: ${v.note}` : ''}` }, v.version))) }), open && typeof document !== 'undefined'
89
- ? createPortal(_jsxs("div", { ref: panel, "data-version-panel": true, role: "dialog", "aria-label": "Version history", tabIndex: -1, style: style, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px 8px' }, children: [_jsx("span", { style: { fontSize: 11, fontWeight: 500, letterSpacing: '0.2em', textTransform: 'uppercase', opacity: 0.6 }, children: "Version history" }), _jsx("button", { type: "button", "aria-label": "Close", onClick: close, style: { font: 'inherit', fontSize: 20, lineHeight: 1, color: 'inherit', background: 'none', border: 0, padding: 4, cursor: 'pointer', opacity: 0.6 }, children: "\u00D7" })] }), _jsx("ol", { style: { listStyle: 'none', margin: 0, padding: '0 8px 10px' }, children: items.map((v) => {
90
- const here = v.version === viewing;
91
- return (_jsx("li", { children: _jsxs("a", { href: v.current ? base : `${base}/v/${v.version}`, "aria-current": here ? 'page' : undefined, style: {
92
- display: 'block', padding: '10px 10px', borderRadius: 8, color: 'inherit', textDecoration: 'none',
93
- background: here ? 'color-mix(in srgb, var(--a-accent, #888) 14%, transparent)' : undefined,
94
- }, children: [_jsxs("span", { style: { display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }, children: [_jsx("strong", { style: { fontWeight: 600 }, children: `Version ${v.version}` }), v.current ? _jsx("span", { style: { fontSize: 12, color: 'var(--a-accent)' }, children: "current" }) : null, _jsx("time", { dateTime: v.at, style: { fontSize: 12, opacity: 0.6 }, children: formatUpdated(v.at, zone) })] }), v.note ? _jsx("span", { style: { display: 'block', marginTop: 2, fontSize: 14, opacity: 0.85 }, children: v.note }) : null] }) }, v.version));
95
- }) })] }), document.body)
96
- : null] }));
96
+ if (typeof document === 'undefined')
97
+ return null;
98
+ return createPortal(_jsxs("div", { ref: panel, "data-version-panel": true, role: "dialog", "aria-label": "Version history", tabIndex: -1, style: style, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '14px 18px 8px' }, children: [_jsx("span", { style: { fontSize: 11, fontWeight: 500, letterSpacing: '0.2em', textTransform: 'uppercase', opacity: 0.6 }, children: "Version history" }), _jsx("button", { type: "button", "aria-label": "Close", onClick: onClose, style: { font: 'inherit', fontSize: 20, lineHeight: 1, color: 'inherit', background: 'none', border: 0, padding: 4, cursor: 'pointer', opacity: 0.6 }, children: "\u00D7" })] }), _jsx("ol", { style: { listStyle: 'none', margin: 0, padding: '0 8px 10px' }, children: items.map((v) => {
99
+ const here = v.version === viewing;
100
+ return (_jsx("li", { children: _jsxs("a", { href: v.current ? base : `${base}/v/${v.version}`, "aria-current": here ? 'page' : undefined, style: {
101
+ display: 'block', padding: '10px 10px', borderRadius: 8, color: 'inherit', textDecoration: 'none',
102
+ background: here ? 'color-mix(in srgb, var(--a-accent, #888) 14%, transparent)' : undefined,
103
+ }, children: [_jsxs("span", { style: { display: 'flex', gap: 8, alignItems: 'baseline', flexWrap: 'wrap' }, children: [_jsx("strong", { style: { fontWeight: 600 }, children: `Version ${v.version}` }), v.current ? _jsx("span", { style: { fontSize: 12, color: 'var(--a-accent)' }, children: "current" }) : null, _jsx("time", { dateTime: v.at, style: { fontSize: 12, opacity: 0.6 }, children: formatUpdated(v.at, zone) })] }), v.note ? _jsx("span", { style: { display: 'block', marginTop: 2, fontSize: 14, opacity: 0.85 }, children: v.note }) : null] }) }, v.version));
104
+ }) })] }), document.body);
97
105
  }
@@ -4,7 +4,7 @@ import { type ArtifactRecord, type ArtifactStore, type VersionEntry } from '../a
4
4
  import type { StateStore } from '../artifacts/state-store.js';
5
5
  import { type ArtifactAssets } from '../artifacts/assets.js';
6
6
  import type { BrandPack } from '../brand/pack.js';
7
- import { type Access } from '../artifacts/reader.js';
7
+ import { type Access, type AllowEntry } from '../artifacts/reader.js';
8
8
  import { type ReadersStore } from '../artifacts/readers-store.js';
9
9
  import { type ReaderEvent } from '../artifacts/read-alerts.js';
10
10
  import type { PersonalStore } from '../artifacts/personal-store.js';
@@ -206,7 +206,7 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
206
206
  }> | NextResponse<{
207
207
  id: string;
208
208
  access: Access | null;
209
- readers: import("../index.js").AllowEntry[];
209
+ readers: AllowEntry[];
210
210
  }>>;
211
211
  READS: (request: NextRequest, { params }: Params) => Promise<NextResponse<{
212
212
  error: string;
@@ -21,7 +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 { VersionHistory } from '../reader/version-history.js';
24
+ import { PageMenu } from '../reader/page-menu.js';
25
25
  import { ACCESS_LEVELS, GRANT_COOKIE, GRANT_TTL_SECONDS, decide, firstName, mintGrant, safeReturnPath, signInUrl, verifyGrant, verifyPass, } from '../artifacts/reader.js';
26
26
  import { FLAG_KINDS, summarize } from '../artifacts/readers-store.js';
27
27
  import { readSecondsFor, wordCount } from '../artifacts/read-alerts.js';
@@ -65,6 +65,14 @@ export function createArtifactRoutes(config) {
65
65
  /** Whether this reader may see a page's past versions (config `history`). */
66
66
  const seesHistory = (reader) => config.history === 'everyone' || (!!reader && admins.has(reader.email.trim().toLowerCase()));
67
67
  const readerSecret = () => config.readerSecret?.();
68
+ /** Who this reader is to this host's pages: the page menu's role. Owner and admins share a menu. */
69
+ const roleOf = (reader) => {
70
+ if (!reader)
71
+ return 'visitor';
72
+ const email = reader.email.trim().toLowerCase();
73
+ return email === ownerEmail ? 'owner' : admins.has(email) ? 'admin' : 'reader';
74
+ };
75
+ const isAdmin = (reader) => roleOf(reader) === 'owner' || roleOf(reader) === 'admin';
68
76
  const signOutUrl = (id) => `/api/reader/leave?to=${encodeURIComponent(pagePath(id))}`;
69
77
  // Signing out alone cannot change the account: the authority still holds the Google session
70
78
  // and vouches for it again on the next sign-in. This leaves AND sends them to choose one.
@@ -137,6 +145,25 @@ export function createArtifactRoutes(config) {
137
145
  }
138
146
  return { pages, readers: count };
139
147
  }
148
+ /** The page menu for one reader on one page. Everything an item needs is decided here, and only
149
+ * what this reader may use is passed: the markdown source and the history list never reach a
150
+ * reader who may not have them, so the browser has nothing to hide.
151
+ * - Share: public and `freedom` pages for everyone; `invite` pages too, with the line that only
152
+ * the list can open it; a password page only for the owner and admins, with the key in the
153
+ * link, since only they hold it.
154
+ * - Version history: whoever `history` lets see past versions (admins by default).
155
+ * - Copy the markdown: the owner and admins. */
156
+ function Menu({ a, reader, shutOut, allow, comments, history, viewing, source }) {
157
+ const role = roleOf(reader);
158
+ const admin = isAdmin(reader);
159
+ const name = reader ? (firstName(reader, allow ?? []) ?? reader.email) : null;
160
+ const share = a.access
161
+ ? { url: pageUrl(a.id), title: a.title, ...(a.access === 'invite' ? { note: 'Only people on the list can open it.' } : {}) }
162
+ : a.password
163
+ ? (admin ? { url: `${pageUrl(a.id)}?key=${encodeURIComponent(a.password)}`, title: a.title, note: 'The link carries the password.' } : null)
164
+ : { url: pageUrl(a.id), title: a.title };
165
+ return (_jsx(PageMenu, { role: role, name: name, shutOut: shutOut, share: share, comments: !!comments, signIn: !reader && signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null, signOut: reader ? signOutUrl(a.id) : null, history: history && history.length > 1 && seesHistory(reader) && viewing ? { items: history, base: pagePath(a.id), viewing } : null, markdown: admin && source ? source : null }));
166
+ }
140
167
  /** The kicker over every title: the pack's line, then who the page is for. Never spoken: the
141
168
  * whole paragraph is data-nospeak, and narrationText has no `to` to read. */
142
169
  function Kicker({ to, brand }) {
@@ -224,12 +251,12 @@ export function createArtifactRoutes(config) {
224
251
  ...(a.state || config.personal ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
225
252
  ];
226
253
  const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
254
+ // Who is reading, when they happen to be signed in: their comments follow them, and the menu
255
+ // says who they are. Only a host that signs readers in can know one; the read never fails the page.
256
+ const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
227
257
  if (!open) {
228
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", 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 })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
258
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx(Menu, { a: a, reader: reader }), _jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", 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 })] }), _jsx(ArtifactDoor, { brand: brand, wrongKey: key !== undefined })] }));
229
259
  }
230
- // Who is reading an open page, when they happen to be signed in: their comments follow them.
231
- // Only a host that signs readers in can know one; the read never fails the page.
232
- const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
233
260
  const page = await render(true, reader);
234
261
  if (n === null)
235
262
  void store.bumpViews(id);
@@ -255,14 +282,14 @@ export function createArtifactRoutes(config) {
255
282
  else
256
283
  void flagged;
257
284
  }
258
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
285
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx(Menu, { a: a, reader: reader, shutOut: d.why === 'not-allowed' }), header, d.why === 'not-allowed'
259
286
  ? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: switchUrl(id) })
260
287
  : _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, here) : undefined, note: config.signInNote })] }));
261
288
  }
262
289
  const r = reader;
263
290
  // The agreement comes before the body, every reader, once per page. No agreement, no body.
264
291
  if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
265
- return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: switchUrl(id) })] }));
292
+ return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx(Menu, { a: a, reader: r, allow: allow }), header, _jsx(AckDoor, { brand: brand, name: firstName(r, allow), email: r.email, owner: owner, pageId: id, signOutUrl: switchUrl(id) })] }));
266
293
  }
267
294
  const page = await render(false, r);
268
295
  if (n === null)
@@ -276,9 +303,18 @@ export function createArtifactRoutes(config) {
276
303
  return null;
277
304
  return store.history(id).catch(() => null);
278
305
  }
279
- /** "Version N · Updated <minute> · History": the line under the summary. */
280
- function VersionLine({ a, n, at, history, reader }) {
281
- return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at }), history && history.length > 1 && seesHistory(reader) ? (_jsxs(_Fragment, { children: [' · ', _jsx(VersionHistory, { items: history, base: pagePath(a.id), viewing: n })] })) : null] }));
306
+ /** "Version N · Updated <minute>": the line under the summary. The way into the history is the
307
+ * page menu's Version history (0.30.0), for whoever `history` lets see it. */
308
+ function VersionLine({ n, at }) {
309
+ return (_jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-60", children: [`Version ${n} · Updated `, _jsx(UpdatedTime, { iso: at })] }));
310
+ }
311
+ /** The source of one version as published: its front matter's identifying lines, then the body,
312
+ * so it pastes into a session and republishes onto the same page. Quoted as JSON strings, which
313
+ * are valid YAML. */
314
+ function sourceOf(id, m, markdown) {
315
+ const lines = [`id: ${id}`, `title: ${JSON.stringify(m.title)}`, ...(m.subtitle ? [`subtitle: ${JSON.stringify(m.subtitle)}`] : []),
316
+ `summary: ${JSON.stringify(m.summary)}`, ...(m.to ? [`to: ${JSON.stringify(m.to)}`] : [])];
317
+ return `---\n${lines.join('\n')}\n---\n\n${markdown}`;
282
318
  }
283
319
  /** The title block and the body, in the pack's Article when it has one. Without one this is
284
320
  * exactly the markup every page had before packs could frame it. */
@@ -303,7 +339,7 @@ export function createArtifactRoutes(config) {
303
339
  const title = v.title ?? a.title;
304
340
  const summary = v.summary ?? a.summary;
305
341
  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, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
342
+ return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, history: history, viewing: n, source: isAdmin(reader) ? sourceOf(a.id, { title, subtitle: v.subtitle, summary, to: v.to }, v.markdown) : null }), 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, { n: n, at: v.at }), 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, { n: n, at: v.at })] })), children: _jsx(ArtifactMarkdown, { markdown: v.markdown, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined, definitions: a.definitions, checklist: { artifactId: a.id, accent: brand.accent }, Section: pack.Section, figure: pack.figure }) })] }));
307
343
  }
308
344
  /** The page itself, shared by open and gated pages. */
309
345
  async function Body(a, { top, reader }) {
@@ -322,14 +358,16 @@ export function createArtifactRoutes(config) {
322
358
  const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
323
359
  // Shared comments need the page to take them AND a host that keeps answers; personal notes need neither.
324
360
  const mode = config.state && a.state && a.comments && a.comments !== 'off' && a.state.slots[COMMENTS_SLOT] ? a.comments : 'off';
325
- return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
361
+ const history = await historyOf(a.id);
362
+ const current = a.version ?? (a.versions?.length ?? 0) + 1;
363
+ return (_jsxs(_Fragment, { children: [_jsx(Menu, { a: a, reader: reader, comments: mode !== 'off', history: history, viewing: current, source: isAdmin(reader) ? sourceOf(a.id, a, a.markdown) : null }), toc.length ? _jsx(TocRail, { items: toc }) : null, _jsx("div", { id: "artifact-narration-root", children: await (async () => {
326
364
  const n = a.version ?? (a.versions?.length ?? 0) + 1;
327
- const meta = (_jsxs(_Fragment, { children: [_jsx(VersionLine, { a: a, n: n, at: a.updatedAt, history: await historyOf(a.id), reader: reader }), 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] }));
365
+ 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] }));
328
366
  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
367
  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
368
  // Ticks are kept in the reader's browser on any host; Send needs one that keeps answers.
331
369
  checklist: { artifactId: a.id, send: !!config.state && !!a.state && Object.hasOwn(a.state.slots, CHECKLIST_SLOT), owner, accent: brand.accent }, Section: pack.Section, figure: pack.figure, simulator: config.simulatorEmbed ? { embed: config.simulatorEmbed } : undefined, messages: config.messagesEmbed ? { embed: config.messagesEmbed } : undefined }) }));
332
- })() }), _jsx(CommentLayer, { artifactId: a.id, rootId: "artifact-narration-root", ownerName: owner, comments: mode, canShare: mode !== 'off' && (reader !== null || effectiveWriters(a.state, a.access, COMMENTS_SLOT) === 'anyone'), signedIn: reader !== null, isOwner: !!reader && ((!!ownerEmail && reader.email.trim().toLowerCase() === ownerEmail.trim().toLowerCase()) || (config.admins ?? []).some((e) => e.trim().toLowerCase() === reader.email.trim().toLowerCase())), version: a.version ?? (a.versions?.length ?? 0) + 1, accent: brand.accent, ground: brand.ground, signIn: signInOrigin ? signInUrl(signInOrigin, pageUrl(a.id)) : null }), a.narration && words.length > 0 ? (_jsx(ArtifactReader, { src: a.narration, words: words, rootId: "artifact-narration-root", label: brand.narratorLabel(a.voice), accent: brand.accent, ground: brand.ground })) : null /* No recorded narration: no player. Never the browser's own voice (the operator,
370
+ })() }), _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 }), 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
371
  2026-09-29: "NEVER PUT DEFAULT SAFARI VOICE DICTATION"); the audio is Kokoro or ElevenLabs,
334
372
  recorded at publish, or there is none. */] }));
335
373
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.29.0",
3
+ "version": "0.30.1",
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",