@supersuit/artifacts 0.31.0 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,39 @@
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.32.0
7
+
8
+ **The page's owner and the hub's admins always get past a locked page's door** (Gary, 2026-10-11).
9
+ Before, an admin who set a page to "only the list" without being on it landed on the door at their
10
+ next reload. `decide()` takes a fourth argument, `staff`, which opens the page with
11
+ `why: 'staff'`; the page, the agreement, the reading heartbeat and the state API all pass it.
12
+
13
+ **Play from here stays inside the page's text column.** 0.31.1 bounded the bar by clipping
14
+ ancestors, but the company host's card does not clip that way, so the pill still ran 2px past the
15
+ card's edge at iPhone width. It is now also bounded by the narration root, the page as the reader
16
+ sees it.
17
+
18
+ - DETECTOR: as a hub admin not on a list-only page's list, open it: the door means 0.31.1. At iPhone
19
+ width on the company host, tap a word at the right edge: a pill past the card's edge means 0.31.1.
20
+ - REMEDY: update the dependency. A host passing `admins` or `ownerEmail` needs nothing else.
21
+ - Tests: `reader.test.ts` (staff opens signed in, never signed out), `gated.test.tsx` (an admin and
22
+ the owner read a list-only page they are not on; anyone else is still refused),
23
+ `artifact-reader-controls.test.tsx` (the bar stays 8px inside the text column).
24
+
25
+ ## 0.31.1
26
+
27
+ **Play from here stays inside the page card, not only the screen.** 0.30.2 kept the bar inside
28
+ the screen, but the company host draws the page in a rounded card that clips its content, so the
29
+ bar's right end was still cut off at the card's edge.
30
+
31
+ - The bar is bounded by every ancestor that clips (any overflow other than visible) as well as the
32
+ screen.
33
+ - DETECTOR: at iPhone width on the company host, pause and tap a word at the right edge. A pill
34
+ whose right end is cut off means 0.30.2.
35
+ - REMEDY: update the dependency.
36
+ - Tests: `artifact-reader-controls.test.tsx` (a clipping card 10 to 380px keeps the bar's right edge
37
+ at 372px or less).
38
+
6
39
  ## 0.31.0
7
40
 
8
41
  **The owner and admins change a page's share and comment settings from the page menu, and a later
@@ -38,6 +38,10 @@ export type Decision = {
38
38
  open: true;
39
39
  why: 'member';
40
40
  reason: string;
41
+ } | {
42
+ open: true;
43
+ why: 'staff';
44
+ reason: string;
41
45
  } | {
42
46
  open: false;
43
47
  why: 'signed-out';
@@ -48,10 +52,13 @@ export type Decision = {
48
52
  };
49
53
  export declare const MEMBER_REASON = "you are a Freedom user";
50
54
  export declare const LISTED_REASON = "you were given access to it by name";
55
+ export declare const STAFF_REASON = "you are its owner or an admin of this hub";
51
56
  /** Whether this reader may open a page with this access level. The allowlist opens both
52
57
  * levels; `freedom` also opens to any active Freedom account. A listed person's own reason
53
58
  * wins over the membership one, because it is the truer account of why they are reading. */
54
- export declare function decide(access: Access, reader: Reader | null, allow: AllowEntry[]): Decision;
59
+ /** `staff`: the reader is the page's owner or one of the hub's admins, who always get in, so an
60
+ * admin who narrows a page to its list can never lock themselves out of it. */
61
+ export declare function decide(access: Access, reader: Reader | null, allow: AllowEntry[], staff?: boolean): Decision;
55
62
  /** The name the banner addresses: the allowlist's name, then the account's, first word only. */
56
63
  export declare function firstName(reader: Reader, allow: AllowEntry[]): string | null;
57
64
  /** Where a reader goes to sign in, carrying the page they asked for. `switchAccount` tells the
@@ -92,12 +92,17 @@ export function verifyGrant(secret, grant, now = Date.now()) {
92
92
  }
93
93
  export const MEMBER_REASON = 'you are a Freedom user';
94
94
  export const LISTED_REASON = 'you were given access to it by name';
95
+ export const STAFF_REASON = 'you are its owner or an admin of this hub';
95
96
  /** Whether this reader may open a page with this access level. The allowlist opens both
96
97
  * levels; `freedom` also opens to any active Freedom account. A listed person's own reason
97
98
  * wins over the membership one, because it is the truer account of why they are reading. */
98
- export function decide(access, reader, allow) {
99
+ /** `staff`: the reader is the page's owner or one of the hub's admins, who always get in, so an
100
+ * admin who narrows a page to its list can never lock themselves out of it. */
101
+ export function decide(access, reader, allow, staff = false) {
99
102
  if (!reader)
100
103
  return { open: false, why: 'signed-out' };
104
+ if (staff)
105
+ return { open: true, why: 'staff', reason: STAFF_REASON };
101
106
  const listed = allow.find((a) => a.email.trim().toLowerCase() === reader.email);
102
107
  if (listed)
103
108
  return { open: true, why: 'listed', reason: listed.reason?.trim() || LISTED_REASON };
@@ -173,13 +173,32 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
173
173
  const el = offerRef.current;
174
174
  if (!offer || !el || offer.nudged)
175
175
  return;
176
- const vw = document.documentElement.clientWidth || window.innerWidth;
176
+ // Inside the screen AND inside whatever box clips the bar (a host's rounded page card).
177
+ let lo = 0;
178
+ let hi = document.documentElement.clientWidth || window.innerWidth;
179
+ for (let p = el.parentElement; p && p !== document.body; p = p.parentElement) {
180
+ const o = getComputedStyle(p);
181
+ if (o.overflowX !== 'visible' || o.overflow !== 'visible') {
182
+ const b = p.getBoundingClientRect();
183
+ if (b.width > 0) {
184
+ lo = Math.max(lo, b.left);
185
+ hi = Math.min(hi, b.right);
186
+ }
187
+ }
188
+ }
189
+ // And inside the page's own text column, which is what a reader sees as the page.
190
+ const col = document.getElementById(rootId)?.getBoundingClientRect();
191
+ if (col && col.width > 0) {
192
+ lo = Math.max(lo, col.left);
193
+ hi = Math.min(hi, col.right);
194
+ }
195
+ const vw = hi;
177
196
  const r = el.getBoundingClientRect();
178
197
  // No layout to measure (a server render, a test DOM): leave the placement as computed.
179
- const shift = r.width === 0 ? 0 : r.right > vw - 8 ? vw - 8 - r.right : r.left < 8 ? 8 - r.left : 0;
198
+ const shift = r.width === 0 ? 0 : r.right > vw - 8 ? vw - 8 - r.right : r.left < lo + 8 ? lo + 8 - r.left : 0;
180
199
  // Once per tap: measured again, an element with no layout would move forever.
181
200
  setOffer({ ...offer, left: offer.left + (Math.abs(shift) >= 1 ? shift : 0), nudged: true });
182
- }, [offer]);
201
+ }, [offer, rootId]);
183
202
  useEffect(() => {
184
203
  const root = document.getElementById(rootId);
185
204
  if (!root)
@@ -314,7 +314,7 @@ export function createArtifactRoutes(config) {
314
314
  // Only a host that signs readers in can know one; the read never fails the page.
315
315
  const reader = readerSecret() ? verifyGrant(readerSecret(), await readCookie(GRANT_COOKIE).catch(() => undefined)) : null;
316
316
  const allow = config.readers && reader ? await config.readers.allowList(id) : [];
317
- const d = config.readers ? decide(a.access, reader, allow) : { open: false, why: 'signed-out' };
317
+ const d = config.readers ? decide(a.access, reader, allow, isAdmin(reader)) : { open: false, why: 'signed-out' };
318
318
  const header = (_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 })] }));
319
319
  if (!d.open) {
320
320
  if (d.why === 'not-allowed' && config.readers) {
@@ -455,7 +455,7 @@ export function createArtifactRoutes(config) {
455
455
  const a = await store.get(id);
456
456
  if (!reader || !a?.access)
457
457
  return back;
458
- if (!decide(a.access, reader, await config.readers.allowList(id)).open)
458
+ if (!decide(a.access, reader, await config.readers.allowList(id), isAdmin(reader)).open)
459
459
  return back;
460
460
  await config.readers.acknowledge({ artifactId: id, reader, text: ackText(owner), country: request.headers.get('x-vercel-ip-country') ?? undefined });
461
461
  return back;
@@ -487,7 +487,7 @@ export function createArtifactRoutes(config) {
487
487
  if (!a?.access)
488
488
  return new NextResponse(null, { status: 404 });
489
489
  const allow = await config.readers.allowList(id);
490
- if (!decide(a.access, reader, allow).open)
490
+ if (!decide(a.access, reader, allow, isAdmin(reader)).open)
491
491
  return new NextResponse(null, { status: 403 });
492
492
  const country = request.headers.get('x-vercel-ip-country') ?? undefined;
493
493
  if (b.kind === 'beat') {
@@ -73,7 +73,7 @@ export function createStateRoutes(ctx) {
73
73
  return { error: json({ error: 'this page is closed' }, 403) };
74
74
  if (!reader)
75
75
  return { error: json({ error: 'sign in to answer', signIn }, 401) };
76
- if (!decide(a.access, reader, await ctx.readers.allowList(id)).open)
76
+ if (!decide(a.access, reader, await ctx.readers.allowList(id), isOwner(reader)).open)
77
77
  return { error: json({ error: 'this page is not open to you' }, 403) };
78
78
  // The page shows its body only after the agreement, so its answers wait for it too.
79
79
  if (!(await ctx.readers.acknowledged(id, reader.email)))
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.31.0",
3
+ "version": "0.32.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",