@supersuit/artifacts 0.47.0 → 0.49.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,50 @@
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.49.0 (2026-10-16)
7
+
8
+ **The default look is Supersuit's, the Maison.** The product the default pack is named for became
9
+ Supersuit on 2026-10-14, and its maker's house look, the Maison, became the brand on 2026-10-15.
10
+ `freedomDefault` (the export keeps its name, so no host breaks) now reads "A page from Supersuit"
11
+ and names Supersuit in metadata (`siteName`); paints night `#17171a` with mist `#d8d1c4` and bronze
12
+ `#a8834f` by default and travertine `#e8e0d2` with ink `#1c1a17` and bronze deepened with ink in
13
+ light mode, each text and accent pairing at 4.5:1 or better; and sets DM Serif Display over DM Sans
14
+ by name (Georgia and the system face if a host does not load them; README, "The default look's
15
+ faces"). The default mark (`BrandMark` with no `Mark`) is a short rule in the accent instead of a
16
+ dove: the dove stood for the agent, and no figure stands in for her until her look is designed.
17
+ What a reader reads moves with the name: the confidential door's default line ("the Google account
18
+ your Supersuit account uses"), the member reason ("you are a Supersuit user"), and the share
19
+ sheet's "Any Supersuit account" and "Supersuit accounts get in without being on the list." The
20
+ `freedom` access level keeps its identifier. A pack of a host's own is untouched, and the share
21
+ card keeps the bundled Newsreader so no tracing config changes.
22
+
23
+ - DETECTOR: a host whose operators have no pack of their own (`freedomDefault`, or a spread of it
24
+ with only the kicker changed) renders on the night ground with bronze, not the old near-black and
25
+ gold, and its kicker says Supersuit.
26
+ - REMEDY: nothing required. To draw the default look in its own faces, load DM Serif Display and
27
+ DM Sans in the host's root layout (README).
28
+ - Tests: pack.test.tsx (the default look: tokens only, 4.5:1 pairings, the house pair, no dove, no
29
+ Freedom), the default-pages golden regenerated on purpose (palette, faces and kicker only), and
30
+ the kicker, metadata and door expectations in artifacts, to, extension-points and gated.
31
+
32
+ ## 0.48.0
33
+
34
+ **Unpublishing keeps the history.** `POST /api/artifacts?id=<id>&unpublish=1` takes a page off the
35
+ web and keeps everything that happened on it: the record, every version, every comment, every
36
+ file. Every reader-facing path (the page, its versions, its share card, comments, answers, per-reader
37
+ links) reads through a view of the store that does not see an unpublished page, so each answers
38
+ 404; the publisher's own routes (publish, the narration GET, uploads, DELETE) still reach it. A
39
+ republish to the id puts the page back at the same link. Until now the only way to take a page
40
+ down was DELETE, which destroys every version and every comment for good. DELETE is unchanged,
41
+ and is now the explicit "destroy" rather than the only "take down". The new store method is
42
+ `setUnpublished(id, at | null)`; the Firestore store has it, and a store without it answers 501.
43
+ On the publish route rather than a new one, so no host has a route file to add, and a host older
44
+ than this answers the empty body with a 400 and changes nothing.
45
+
46
+ - DETECTOR: a publisher that sends `?unpublish=1` and gets a 400 is talking to a host before 0.48.0.
47
+ - REMEDY: update the dependency. Nothing to configure.
48
+ - Tests: unpublish.test.tsx.
49
+
6
50
  ## 0.47.0
7
51
 
8
52
  **Figures and Helm scenes play inline, as two new embed fences.** A ```` ```figure ```` block
package/README.md CHANGED
@@ -5,7 +5,7 @@ site shell behind that: the store, the route handlers, the read-along reader, br
5
5
  password pages, confidential pages with a signed-in reader list, and the share card a link
6
6
  unfurls as. You mount it in a Next.js App Router app with a handful of one-line route files.
7
7
 
8
- It is the shell that serves every Freedom operator's `artifacts.<theirname>` page, published
8
+ It is the shell that serves every Supersuit operator's `artifacts.<theirname>` page, published
9
9
  openly because none of it is secret and all of it is better with more eyes on it.
10
10
 
11
11
  ```bash
@@ -23,7 +23,7 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
23
23
  | `createArtifactStore(db, path)` | Firestore store for pages and their version history, under any collection path |
24
24
  | `createReadersStore(db, path)` | The record for confidential pages: allowlist, agreements, reading sessions, flags |
25
25
  | `createArtifactAssets(bucket, prefix, { privateBucket? })` | Uploaded images for a page, in a Cloud Storage bucket; private media in a separate private bucket |
26
- | `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
26
+ | `freedomDefault`, `BrandPack` | The default look (Supersuit's, the Maison) and the type any other look implements |
27
27
  | `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
28
28
  | `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
29
29
  | `headingsOf`, `scanWidgets`, `placeNotes` | The notes widget's pure parts: heading slugs, fence validation, where each note shows |
@@ -116,6 +116,13 @@ hostname and pick by the `host` header.
116
116
  body. The response carries the page's id and URL. Posting a file whose front matter carries
117
117
  that `id:` republishes it in place, keeping every earlier version.
118
118
 
119
+ **Taking a page down keeps its history (0.48.0).** `POST /api/artifacts?id=<id>&unpublish=1`
120
+ (publish key, empty body) takes the page off the web: every reader path answers 404, and the
121
+ record, every version, every comment and every file are kept. Republishing to that id puts it back
122
+ at the same link. `DELETE /api/artifacts/<id>` is the separate act that destroys all of it. A host
123
+ older than 0.48.0 answers the unpublish with a 400 and changes nothing, so a publisher can never
124
+ mistake it for a delete; a store without `setUnpublished` answers 501, likewise changing nothing.
125
+
119
126
  ## The page menu
120
127
 
121
128
  Every page, past version and door carries one small round **⋯** button in the top right corner
@@ -157,7 +164,7 @@ anyone else. `PageMenu`, `roleLine` and `menuItems` are exported from `/reader`.
157
164
  The owner and the hub's admins change a page's settings from the menu, with no republish. Each item
158
165
  opens a sheet (from the bottom on a phone, under the button on a desk):
159
166
 
160
- - **Share settings.** Who can open it: anyone with the link, any Freedom account (plus the list),
167
+ - **Share settings.** Who can open it: anyone with the link, any Supersuit account (plus the list),
161
168
  or only the list. The list itself: add or remove an email. On a page open to anyone, a password
162
169
  on or off (on makes a 12-character key). The link to send, with a Copy button: on a password page
163
170
  it carries the key. Under them, read-only lines: what a stranger with the bare URL sees and what
@@ -307,7 +314,7 @@ their change alone.
307
314
 
308
315
  ## A confidential page (`access:`)
309
316
 
310
- `access: freedom` opens the page only to a signed-in reader with an active Freedom account or
317
+ `access: freedom` opens the page only to a signed-in reader with an active Supersuit account or
311
318
  on the page's list; `access: invite` opens it only to the list. Everyone else gets the title,
312
319
  the summary and a sign-in door; the body never reaches them.
313
320
 
@@ -1174,6 +1181,16 @@ an optional full-page `Wrapper` and `Mark`, and an optional `share` block
1174
1181
  (a font loader and a backdrop) that draws the unfurl card. `freedomDefault` is the default look.
1175
1182
  Keep a pack carrying your own trademark in your own app, not in a pull request here.
1176
1183
 
1184
+ **The default look's faces.** Since 0.49.0 `freedomDefault` is Supersuit's own look, the Maison:
1185
+ night with mist type and bronze by default, travertine with ink behind the light toggle, every
1186
+ colour one of seven stone tokens or a mix of two, and the mark above the kicker a short rule in
1187
+ the accent (no dove). It sets DM Serif Display for headings and DM Sans for reading, by name. The
1188
+ package does not fetch them, so a host that wants them loads them itself, for example
1189
+ `import '@fontsource/dm-serif-display/400.css'` and `import '@fontsource/dm-sans/400.css'` (plus
1190
+ 500 and 600) in its root layout, which serves the files from the host's own origin. A host that
1191
+ does not falls back to Georgia and the system face. The share card still sets its title in the
1192
+ bundled Newsreader, so no host's `outputFileTracingIncludes` has to change.
1193
+
1177
1194
  **Light, dark or system.** A pack's `ground`, `ink` and `accent` are its dark palette; `light` is
1178
1195
  the other one (absent, warm paper with the pack's accent), and `mode` is the one a page opens in
1179
1196
  (`dark` by default; a page's `theme:` overrides it). Pages paint with CSS variables
@@ -25,9 +25,10 @@ export declare function Watermark({ email }: {
25
25
  }): import("react").JSX.Element;
26
26
  /** Printing shows this and nothing else, whichever way the print dialog was reached. */
27
27
  export declare const NO_PRINT_CSS = "@media print { body * { visibility: hidden !important; } body::after { content: \"This page is confidential and cannot be printed.\"; visibility: visible; position: fixed; top: 40%; left: 0; right: 0; text-align: center; font: 16px sans-serif; color: #000; } }";
28
- /** The door's default line, for a host whose readers sign in with a Freedom account. A host
28
+ /** The door's default line, for a host whose readers sign in with a Supersuit account (the product
29
+ * called Freedom until 2026-10-14; the access level keeps its `freedom` identifier). A host
29
30
  * that signs readers in some other way passes `signInNote` to say so in its own words. */
30
- export declare const DEFAULT_SIGN_IN_NOTE = "This page is confidential. Sign in with the Google account your Freedom account uses, or the address it was shared with.";
31
+ export declare const DEFAULT_SIGN_IN_NOTE = "This page is confidential. Sign in with the Google account your Supersuit account uses, or the address it was shared with.";
31
32
  export declare function SignInDoor({ brand, href, note }: {
32
33
  brand: BrandPack;
33
34
  href?: string;
@@ -19,9 +19,10 @@ export function Watermark({ email }) {
19
19
  }
20
20
  /** Printing shows this and nothing else, whichever way the print dialog was reached. */
21
21
  export const NO_PRINT_CSS = `@media print { body * { visibility: hidden !important; } body::after { content: "This page is confidential and cannot be printed."; visibility: visible; position: fixed; top: 40%; left: 0; right: 0; text-align: center; font: 16px sans-serif; color: #000; } }`;
22
- /** The door's default line, for a host whose readers sign in with a Freedom account. A host
22
+ /** The door's default line, for a host whose readers sign in with a Supersuit account (the product
23
+ * called Freedom until 2026-10-14; the access level keeps its `freedom` identifier). A host
23
24
  * that signs readers in some other way passes `signInNote` to say so in its own words. */
24
- export const DEFAULT_SIGN_IN_NOTE = 'This page is confidential. Sign in with the Google account your Freedom account uses, or the address it was shared with.';
25
+ export const DEFAULT_SIGN_IN_NOTE = 'This page is confidential. Sign in with the Google account your Supersuit account uses, or the address it was shared with.';
25
26
  export function SignInDoor({ brand, href, note = DEFAULT_SIGN_IN_NOTE }) {
26
27
  // No href means the host configured no sign-in authority: the door stays, with no way through.
27
28
  if (!href) {
@@ -50,7 +50,7 @@ export type Decision = {
50
50
  why: 'not-allowed';
51
51
  reader: Reader;
52
52
  };
53
- export declare const MEMBER_REASON = "you are a Freedom user";
53
+ export declare const MEMBER_REASON = "you are a Supersuit user";
54
54
  export declare const LISTED_REASON = "you were given access to it by name";
55
55
  export declare const STAFF_REASON = "you are its owner or an admin of this hub";
56
56
  /** Whether this reader may open a page with this access level. The allowlist opens both
@@ -90,7 +90,7 @@ export function mintGrant(secret, reader, now = Date.now()) {
90
90
  export function verifyGrant(secret, grant, now = Date.now()) {
91
91
  return decode(secret, 'artifact-grant', 'g1', grant, now);
92
92
  }
93
- export const MEMBER_REASON = 'you are a Freedom user';
93
+ export const MEMBER_REASON = 'you are a Supersuit user';
94
94
  export const LISTED_REASON = 'you were given access to it by name';
95
95
  export const STAFF_REASON = 'you are its owner or an admin of this hub';
96
96
  /** Whether this reader may open a page with this access level. The allowlist opens both
@@ -57,6 +57,10 @@ export type ArtifactRecord = {
57
57
  * given: a version is never described by anything its author did not write. */
58
58
  note?: string;
59
59
  views: number;
60
+ /** When the page was taken off the web (0.48.0). The record, every version, every comment and
61
+ * every file are KEPT: an unpublished page is served to no reader, and a republish to its id
62
+ * puts it back. DELETE is the separate act that destroys. */
63
+ unpublishedAt?: string;
60
64
  };
61
65
  /** One line of a page's history. `at` is when that version went live. */
62
66
  export type VersionEntry = {
@@ -130,6 +134,9 @@ export interface ArtifactStore {
130
134
  version?(id: string, n: number): Promise<VersionRecord | null>;
131
135
  /** Write (or, with null, clear) the change note on one version after the fact. */
132
136
  setNote?(id: string, n: number, note: string | null): Promise<boolean>;
137
+ /** Take a page off the web keeping everything (an ISO time), or put it back (null). False when
138
+ * no such page. A store without it can only destroy, and the host refuses to unpublish. */
139
+ setUnpublished?(id: string, at: string | null): Promise<boolean>;
133
140
  /** Every page of one kind, newest first, without bodies (0.37.0): the owner's documents and the
134
141
  * owner's broadcasts are two lists. */
135
142
  list?(kind: ArtifactKind): Promise<ArtifactListItem[]>;
@@ -69,6 +69,13 @@ export function createArtifactStore(db, collection = 'artifacts') {
69
69
  history: (id) => historyOf(col, id),
70
70
  version: (id, n) => versionOf(col, id, n),
71
71
  setNote: (id, n, note) => setNote(col, id, n, note),
72
+ setUnpublished: async (id, at) => {
73
+ const ref = col().doc(id);
74
+ if (!(await ref.get()).exists)
75
+ return false;
76
+ await ref.update({ unpublishedAt: at ?? FieldValue.delete() });
77
+ return true;
78
+ },
72
79
  list: async (kind) => {
73
80
  // `select` keeps every body out of the listing; a tenant's pages number in the hundreds.
74
81
  const snap = await col().select('title', 'summary', 'template', 'createdAt', 'updatedAt', 'views').get();
@@ -231,6 +238,8 @@ async function saveArtifact(col, input) {
231
238
  // `updatedAt` is when this version went live; finishing its own publish does not move it.
232
239
  ...(fresh || input.markdown !== existing.markdown ? { updatedAt: now } : {}),
233
240
  note: nextNote ?? FieldValue.delete(),
241
+ // A republish is the page going back up: an unpublished page is live again at the same link.
242
+ unpublishedAt: FieldValue.delete(),
234
243
  ...(existing.versions ? { versions: FieldValue.delete() } : {}),
235
244
  });
236
245
  return { id: input.id, version: next, created: false };
@@ -38,7 +38,7 @@ export type BrandPack = {
38
38
  };
39
39
  /** The small uppercase line above a title, e.g. "From Sam Rivera". */
40
40
  kicker: string;
41
- /** Unused since 0.33.1: the player bar names no narrator (it read "Read aloud by Freedom").
41
+ /** Unused since 0.33.1: the player bar names no narrator.
42
42
  * Optional so a pack that still sets it compiles; nothing reads it. */
43
43
  narratorLabel?: (voice?: string) => string;
44
44
  /** Optional full-page backdrop. Receives the page as children. */
@@ -140,4 +140,19 @@ export type ShareCardAssets = {
140
140
  * a blur or a glow is baked in here; the renderer only sets type. Absent, `ground` fills. */
141
141
  backdrop?: () => Promise<string>;
142
142
  };
143
+ /**
144
+ * THE DEFAULT LOOK: Supersuit's own, for every operator with no pack of their own. Named
145
+ * `freedomDefault` because the product was Freedom until 2026-10-14; the export keeps its name so
146
+ * no host breaks, and everything a reader sees says Supersuit.
147
+ *
148
+ * Since 0.49.0 it is the Maison, the house look of Supersuit's maker: stone, and colour only for
149
+ * context. Night (#17171a) with mist type and bronze by default; travertine with ink one tap away,
150
+ * its accent bronze deepened with ink so a link reads at 4.5:1. Every value is one of the house's
151
+ * seven tokens or a mix of two (night, walnut, travertine, marble, bronze, ink, mist). The dark
152
+ * palette stays plain hex because the share card draws with it and its renderer has no color-mix().
153
+ *
154
+ * Type is the house pair: DM Serif Display to inscribe, DM Sans to read. Both are named, not
155
+ * fetched: a host loads them (README, "The default look's faces"), and one that does not falls back
156
+ * to Georgia and the system face rather than asking a font service for them on every page view.
157
+ */
143
158
  export declare const freedomDefault: BrandPack;
package/lib/brand/pack.js CHANGED
@@ -1,16 +1,31 @@
1
1
  import { newsreader } from './default-share.js';
2
+ /**
3
+ * THE DEFAULT LOOK: Supersuit's own, for every operator with no pack of their own. Named
4
+ * `freedomDefault` because the product was Freedom until 2026-10-14; the export keeps its name so
5
+ * no host breaks, and everything a reader sees says Supersuit.
6
+ *
7
+ * Since 0.49.0 it is the Maison, the house look of Supersuit's maker: stone, and colour only for
8
+ * context. Night (#17171a) with mist type and bronze by default; travertine with ink one tap away,
9
+ * its accent bronze deepened with ink so a link reads at 4.5:1. Every value is one of the house's
10
+ * seven tokens or a mix of two (night, walnut, travertine, marble, bronze, ink, mist). The dark
11
+ * palette stays plain hex because the share card draws with it and its renderer has no color-mix().
12
+ *
13
+ * Type is the house pair: DM Serif Display to inscribe, DM Sans to read. Both are named, not
14
+ * fetched: a host loads them (README, "The default look's faces"), and one that does not falls back
15
+ * to Georgia and the system face rather than asking a font service for them on every page view.
16
+ */
2
17
  export const freedomDefault = {
3
18
  id: 'freedom-default',
4
- name: 'Freedom',
5
- ground: '#0e0f13',
6
- ink: '#ece7dc',
7
- accent: '#c9a96e',
8
- light: { ground: '#f6f1e7', ink: '#1c1a17', accent: '#8a6a2c' },
19
+ name: 'Supersuit',
20
+ ground: '#17171a',
21
+ ink: '#d8d1c4',
22
+ accent: '#a8834f',
23
+ light: { ground: '#e8e0d2', ink: '#1c1a17', accent: 'color-mix(in srgb,#a8834f 60%,#1c1a17)' },
9
24
  type: {
10
- display: 'Georgia, "Iowan Old Style", "Times New Roman", serif',
11
- body: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
25
+ display: '"DM Serif Display", Georgia, "Times New Roman", serif',
26
+ body: '"DM Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
12
27
  },
13
- kicker: 'A page from Freedom',
28
+ kicker: 'A page from Supersuit',
14
29
  // Pages unfurl as their title on the ground colour. A pack with a backdrop of its own
15
30
  // overrides this whole field.
16
31
  share: { font: newsreader },
@@ -9,7 +9,8 @@ export declare function BrandGround({ pack, mode, children }: {
9
9
  mode?: ThemeMode;
10
10
  children: ReactNode;
11
11
  }): import("react").JSX.Element;
12
- /** The mark above the kicker: the pack's own, else a small dove. */
12
+ /** The mark above the kicker: the pack's own, else a short rule in the accent. Not a dove: the dove
13
+ * stood for the agent, and no bird or figure stands in for her until her own look is designed. */
13
14
  export declare function BrandMark({ pack, className }: {
14
15
  pack: BrandPack;
15
16
  className?: string;
@@ -15,9 +15,10 @@ export function BrandGround({ pack, mode, children }) {
15
15
  // isolation puts the fixed backdrop (z-index -1) above this ground and below the page.
16
16
  style: { background: 'var(--a-ground)', color: 'var(--a-ink)', fontFamily: pack.type.body, position: 'relative', isolation: 'isolate' }, children: [css, pack.backdrop?.kind === 'bubbles' ? _jsx(Bubbles, { backdrop: pack.backdrop }) : null, children] }));
17
17
  }
18
- /** The mark above the kicker: the pack's own, else a small dove. */
18
+ /** The mark above the kicker: the pack's own, else a short rule in the accent. Not a dove: the dove
19
+ * stood for the agent, and no bird or figure stands in for her until her own look is designed. */
19
20
  export function BrandMark({ pack, className }) {
20
21
  if (pack.Mark)
21
22
  return _jsx(pack.Mark, { className: className });
22
- return (_jsx("span", { "data-brand-mark": pack.id, "aria-hidden": true, className: className, style: { color: pack.accent }, children: "\uD83D\uDD4A" }));
23
+ return (_jsx("span", { "data-brand-mark": pack.id, "aria-hidden": true, className: className, style: { display: 'inline-block', width: 28, height: 1, verticalAlign: 'middle', background: pack.accent } }));
23
24
  }
@@ -13,7 +13,7 @@ import { useEffect, useRef, useState } from 'react';
13
13
  import { NO_ZOOM_FONT } from './no-zoom.js';
14
14
  export const ACCESS_LABEL = {
15
15
  public: 'Anyone with the link',
16
- freedom: 'Any Freedom account',
16
+ freedom: 'Any Supersuit account',
17
17
  invite: 'Only the list',
18
18
  };
19
19
  export const COMMENTS_LABEL = { anyone: 'Anyone', 'signed-in': 'Signed-in readers', off: 'Nobody' };
@@ -132,7 +132,7 @@ export function SettingsSheet({ kind, settings, phone, vars, onClose, onCopy, sa
132
132
  const primary = { ...btn, background: 'var(--a-accent, #111)', borderColor: 'var(--a-accent, #111)', color: 'var(--a-ground, #fff)', fontWeight: 600 };
133
133
  const muted = { margin: '4px 0 0', fontSize: 12, opacity: 0.65 };
134
134
  const radio = (name, value, current, set, label, note) => (_jsxs("label", { style: choice, "data-settings-choice": `${name}:${value}`, children: [_jsx("input", { type: "radio", name: name, value: value, checked: current === value, onChange: () => set(value), style: { width: 18, height: 18, margin: 0, accentColor: 'var(--a-accent, #111)' } }), _jsxs("span", { children: [label, note ? _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: note }) : null] })] }, value));
135
- const share = (_jsxs(_Fragment, { children: [_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can open it" }), s.levels.map((l) => radio('access', l, access, setAccess, ACCESS_LABEL[l], l === 'freedom' ? 'Plus anyone on the list' : undefined))] }), access === 'public' ? (_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Password" }), _jsxs("label", { style: choice, "data-settings-choice": "password", children: [_jsx("input", { type: "checkbox", checked: password, onChange: (e) => setPassword(e.target.checked), style: { width: 18, height: 18, margin: 0, accentColor: 'var(--a-accent, #111)' } }), _jsxs("span", { children: ["Require a password", _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: "The link to send carries the key" })] })] })] })) : null, dirty || busy ? (_jsxs("div", { style: { display: 'flex', gap: 8, margin: '0 0 14px' }, children: [_jsx("button", { type: "button", "data-settings-save": true, onClick: () => void save(), disabled: busy, style: primary, children: busy ? 'Saving' : 'Save' }), _jsx("button", { type: "button", onClick: () => { setAccess(s.access); setPassword(s.password); }, disabled: busy, style: btn, children: "Undo" })] })) : null, s.list && s.access !== 'public' ? (_jsxs("fieldset", { style: fieldset, "data-settings-list": true, children: [_jsx("legend", { style: legend, children: `The list (${s.list.length})` }), s.list.length === 0 ? _jsx("p", { style: { ...muted, margin: '0 0 6px' }, children: "Nobody yet." }) : null, _jsx("ul", { style: { listStyle: 'none', margin: '0 0 8px', padding: 0 }, children: s.list.map((e) => (_jsxs("li", { "data-settings-list-entry": e.email, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', borderBottom: '1px solid var(--a-line, rgba(127,127,127,0.2))' }, children: [_jsx("span", { style: { flex: 1, minWidth: 0, overflowWrap: 'anywhere' }, children: e.name ? _jsxs(_Fragment, { children: [e.name, _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: e.email })] }) : e.email }), _jsx("button", { type: "button", "aria-label": `Remove ${e.email}`, onClick: () => void listChange({ remove: [e.email] }), disabled: busy, style: btn, children: "Remove" })] }, e.email))) }), _jsxs("form", { onSubmit: onAdd, style: { display: 'flex', gap: 8 }, children: [_jsx("input", { type: "email", inputMode: "email", autoComplete: "off", placeholder: "name@example.com", "aria-label": "Email to add", value: email, onChange: (e) => setEmail(e.target.value), style: { flex: 1, minWidth: 0, font: 'inherit', ...NO_ZOOM_FONT, padding: phone ? '8px 10px' : '6px 8px', borderRadius: 8, border: '1px solid var(--a-line, rgba(127,127,127,0.4))', background: 'transparent', color: 'inherit' } }), _jsx("button", { type: "submit", disabled: busy, style: btn, children: "Add" })] }), s.access === 'invite' ? null : _jsx("p", { style: muted, children: "Freedom accounts get in without being on the list." })] })) : null, _jsxs("div", { style: { margin: '0 0 14px' }, "data-settings-link": true, children: [_jsx("p", { style: legend, children: "The link to send" }), _jsxs("div", { style: { display: 'flex', gap: 8, alignItems: 'center' }, children: [_jsx("code", { style: { flex: 1, minWidth: 0, fontSize: 13, overflowWrap: 'anywhere' }, children: s.link }), _jsx("button", { type: "button", onClick: () => void copy(), style: btn, children: "Copy" })] })] }), s.facts.length ? (_jsx("dl", { style: { display: 'grid', gap: 8, margin: 0 }, "data-settings-facts": true, children: s.facts.map((f) => (_jsxs("div", { children: [_jsx("dt", { style: { fontSize: 12, fontWeight: 600, opacity: 0.7 }, children: f.label }), _jsx("dd", { style: { margin: 0, fontSize: 13, opacity: 0.85 }, children: f.text })] }, f.label))) })) : null] }));
135
+ const share = (_jsxs(_Fragment, { children: [_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can open it" }), s.levels.map((l) => radio('access', l, access, setAccess, ACCESS_LABEL[l], l === 'freedom' ? 'Plus anyone on the list' : undefined))] }), access === 'public' ? (_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Password" }), _jsxs("label", { style: choice, "data-settings-choice": "password", children: [_jsx("input", { type: "checkbox", checked: password, onChange: (e) => setPassword(e.target.checked), style: { width: 18, height: 18, margin: 0, accentColor: 'var(--a-accent, #111)' } }), _jsxs("span", { children: ["Require a password", _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: "The link to send carries the key" })] })] })] })) : null, dirty || busy ? (_jsxs("div", { style: { display: 'flex', gap: 8, margin: '0 0 14px' }, children: [_jsx("button", { type: "button", "data-settings-save": true, onClick: () => void save(), disabled: busy, style: primary, children: busy ? 'Saving' : 'Save' }), _jsx("button", { type: "button", onClick: () => { setAccess(s.access); setPassword(s.password); }, disabled: busy, style: btn, children: "Undo" })] })) : null, s.list && s.access !== 'public' ? (_jsxs("fieldset", { style: fieldset, "data-settings-list": true, children: [_jsx("legend", { style: legend, children: `The list (${s.list.length})` }), s.list.length === 0 ? _jsx("p", { style: { ...muted, margin: '0 0 6px' }, children: "Nobody yet." }) : null, _jsx("ul", { style: { listStyle: 'none', margin: '0 0 8px', padding: 0 }, children: s.list.map((e) => (_jsxs("li", { "data-settings-list-entry": e.email, style: { display: 'flex', alignItems: 'center', gap: 8, padding: '4px 0', borderBottom: '1px solid var(--a-line, rgba(127,127,127,0.2))' }, children: [_jsx("span", { style: { flex: 1, minWidth: 0, overflowWrap: 'anywhere' }, children: e.name ? _jsxs(_Fragment, { children: [e.name, _jsx("span", { style: { display: 'block', fontSize: 12, opacity: 0.6 }, children: e.email })] }) : e.email }), _jsx("button", { type: "button", "aria-label": `Remove ${e.email}`, onClick: () => void listChange({ remove: [e.email] }), disabled: busy, style: btn, children: "Remove" })] }, e.email))) }), _jsxs("form", { onSubmit: onAdd, style: { display: 'flex', gap: 8 }, children: [_jsx("input", { type: "email", inputMode: "email", autoComplete: "off", placeholder: "name@example.com", "aria-label": "Email to add", value: email, onChange: (e) => setEmail(e.target.value), style: { flex: 1, minWidth: 0, font: 'inherit', ...NO_ZOOM_FONT, padding: phone ? '8px 10px' : '6px 8px', borderRadius: 8, border: '1px solid var(--a-line, rgba(127,127,127,0.4))', background: 'transparent', color: 'inherit' } }), _jsx("button", { type: "submit", disabled: busy, style: btn, children: "Add" })] }), s.access === 'invite' ? null : _jsx("p", { style: muted, children: "Supersuit accounts get in without being on the list." })] })) : null, _jsxs("div", { style: { margin: '0 0 14px' }, "data-settings-link": true, children: [_jsx("p", { style: legend, children: "The link to send" }), _jsxs("div", { style: { display: 'flex', gap: 8, alignItems: 'center' }, children: [_jsx("code", { style: { flex: 1, minWidth: 0, fontSize: 13, overflowWrap: 'anywhere' }, children: s.link }), _jsx("button", { type: "button", onClick: () => void copy(), style: btn, children: "Copy" })] })] }), s.facts.length ? (_jsx("dl", { style: { display: 'grid', gap: 8, margin: 0 }, "data-settings-facts": true, children: s.facts.map((f) => (_jsxs("div", { children: [_jsx("dt", { style: { fontSize: 12, fontWeight: 600, opacity: 0.7 }, children: f.label }), _jsx("dd", { style: { margin: 0, fontSize: 13, opacity: 0.85 }, children: f.text })] }, f.label))) })) : null] }));
136
136
  const commentSheet = (_jsxs(_Fragment, { children: [_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can comment" }), ['anyone', 'signed-in', 'off'].map((m) => radio('comments', m, comments, setComments, COMMENTS_LABEL[m], m === 'anyone' && s.gated ? 'This page is private, so only signed-in readers it is open to can' : undefined))] }), comments !== 'off' ? (_jsxs("fieldset", { style: fieldset, children: [_jsx("legend", { style: legend, children: "Who can read the comments" }), ['owner', 'readers'].map((v) => radio('visible', v, visible, setVisible, VISIBLE_LABEL[v], v === 'owner' ? 'Each commenter also sees their own' : undefined))] })) : null, dirty || busy ? (_jsxs("div", { style: { display: 'flex', gap: 8 }, children: [_jsx("button", { type: "button", "data-settings-save": true, onClick: () => void save(), disabled: busy, style: primary, children: busy ? 'Saving' : 'Save' }), _jsx("button", { type: "button", onClick: () => { setComments(s.comments); setVisible(s.commentsVisible); }, disabled: busy, style: btn, children: "Undo" })] })) : null] }));
137
137
  return (_jsxs(_Fragment, { children: [_jsx("div", { "aria-hidden": true, "data-settings-scrim": true, onClick: onClose, style: { position: 'fixed', inset: 0, zIndex: 60, background: phone ? 'rgba(0,0,0,0.28)' : 'transparent' } }), _jsxs("div", { ref: panel, role: "dialog", "aria-modal": "true", "aria-label": kind === 'share' ? 'Share settings' : 'Comment settings', tabIndex: -1, "data-settings-sheet": kind, "data-nospeak": true, "data-comment-ui": true, style: style, children: [_jsxs("div", { style: { display: 'flex', alignItems: 'baseline', justifyContent: 'space-between', gap: 8 }, children: [_jsx("h2", { style: h, children: kind === 'share' ? 'Share settings' : 'Comment settings' }), _jsx("button", { type: "button", onClick: onClose, "aria-label": "Close", style: { ...btn, border: 0, padding: '2px 6px', fontSize: 20, lineHeight: 1 }, children: "\u00D7" })] }), kind === 'share' ? share : commentSheet, _jsx("p", { role: "status", "aria-live": "polite", "data-settings-said": true, style: { margin: said ? '10px 0 0' : 0, fontSize: 13, minHeight: 0 }, children: said ?? '' })] })] }));
138
138
  }
@@ -229,6 +229,10 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
229
229
  }>>;
230
230
  POST: (request: NextRequest) => Promise<NextResponse<{
231
231
  error: string;
232
+ }> | NextResponse<{
233
+ id: string;
234
+ unpublished: boolean;
235
+ at: string;
232
236
  }> | NextResponse<{
233
237
  warning?: string | undefined;
234
238
  expireAt?: string | null | undefined;
@@ -71,7 +71,11 @@ function ViewingAsReader({ back }) {
71
71
  return (_jsx("div", { "data-viewing-as-reader": true, "data-nospeak": true, className: "fixed inset-x-0 top-3 z-50 flex justify-center px-3", children: _jsxs("p", { className: "m-0 rounded-full border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] px-4 py-1.5 font-sans text-xs text-[color:var(--a-strong)] shadow-lg", children: ["Viewing as a reader \u00B7 ", _jsx("a", { href: back, className: "underline", children: "Back to your view" })] }) }));
72
72
  }
73
73
  export function createArtifactRoutes(config) {
74
- const { store, siteUrl } = config;
74
+ const { store: kept, siteUrl } = config;
75
+ // An unpublished page (0.48.0) is KEPT, versions, comments and files all, and served to nobody:
76
+ // every reader-facing path reads through this view, which does not see it. The publisher's own
77
+ // routes (POST, GET, DELETE, the uploads) read `kept`, so the page can be put back or destroyed.
78
+ const store = { ...kept, get: async (id) => { const a = await kept.get(id); return a?.unpublishedAt ? null : a; } };
75
79
  // The pack as configured draws the share card; the page paints with its theme variables, so a
76
80
  // page follows its light, dark or system mode (brand/theme.ts).
77
81
  const pack = config.brand;
@@ -771,6 +775,21 @@ export function createArtifactRoutes(config) {
771
775
  async function POST(request) {
772
776
  if (!authed(request))
773
777
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
778
+ // `?unpublish=1`: take the page off the web and keep everything. On the publish route rather
779
+ // than a route of its own so no host has a file to add, and so a host older than 0.48.0 refuses
780
+ // the empty body with a 400 instead of doing anything at all: it can never be mistaken for a delete.
781
+ if (request.nextUrl.searchParams.get('unpublish') === '1') {
782
+ const id = request.nextUrl.searchParams.get('id');
783
+ if (!id || !ARTIFACT_ID.test(id))
784
+ return NextResponse.json({ error: 'unpublish needs ?id=<page id>' }, { status: 400 });
785
+ if (!kept.setUnpublished)
786
+ return NextResponse.json({ error: 'this store cannot keep a page unpublished; nothing was changed' }, { status: 501 });
787
+ const at = new Date().toISOString();
788
+ if (!(await kept.get(id)) || !(await kept.setUnpublished(id, at)))
789
+ return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
790
+ revalidatePath(pagePath(id));
791
+ return NextResponse.json({ id, unpublished: true, at });
792
+ }
774
793
  const text = await request.text();
775
794
  let parsed = parseArtifactSource(text);
776
795
  if (!parsed.ok)
@@ -779,7 +798,7 @@ export function createArtifactRoutes(config) {
779
798
  // The kind is sticky, like `links:`: a republish of a broadcast that leaves `template:` out is
780
799
  // still a broadcast, and one that says `document` is refused rather than quietly changed.
781
800
  if (id && parsed.meta.template === 'document') {
782
- const stored = await store.get(id);
801
+ const stored = await kept.get(id);
783
802
  if (stored && kindOf(stored) === 'broadcast') {
784
803
  const again = parseArtifactSource(text, { defaultKind: 'broadcast' });
785
804
  if (!again.ok)
@@ -792,10 +811,10 @@ export function createArtifactRoutes(config) {
792
811
  // Fail closed: private media needs a private store. Without one the media would have to sit in
793
812
  // the public bucket, which may carry a bucket-wide public read grant, so nothing is saved.
794
813
  // `media:` is sticky, so a stored page that already keeps private media counts too.
795
- if (!keepsPrivateMedia() && (parsed.meta.privateMedia || (id && (await store.get(id))?.privateMedia)))
814
+ if (!keepsPrivateMedia() && (parsed.meta.privateMedia || (id && (await kept.get(id))?.privateMedia)))
796
815
  return NextResponse.json({ error: NO_PRIVATE_STORE }, { status: 501 });
797
816
  if (id && parsed.meta.state) {
798
- const existing = await store.get(id);
817
+ const existing = await kept.get(id);
799
818
  const changed = shapeChanges(existing?.state, parsed.meta.state);
800
819
  if (changed.length)
801
820
  return NextResponse.json({ error: changed.join('; ') }, { status: 400 });
@@ -822,7 +841,7 @@ export function createArtifactRoutes(config) {
822
841
  // `?amend=1` is the publisher finishing the publish it started (image URLs, then narration).
823
842
  const note = cleanNote(request.nextUrl.searchParams.get('note')) ?? parsed.meta.change;
824
843
  const amend = request.nextUrl.searchParams.get('amend') === '1';
825
- const result = await store.save({ id, meta: parsed.meta, markdown: parsed.body, note, amend });
844
+ const result = await kept.save({ id, meta: parsed.meta, markdown: parsed.body, note, amend });
826
845
  if ('notFound' in result)
827
846
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
828
847
  // A broadcast with no date gets one: 90 days from now. A date already set (by the sender, or
@@ -850,7 +869,7 @@ export function createArtifactRoutes(config) {
850
869
  if (!authed(request))
851
870
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
852
871
  const { id } = await params;
853
- const a = await store.get(id);
872
+ const a = await kept.get(id);
854
873
  if (!a)
855
874
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
856
875
  return NextResponse.json({ id, text: narrationText({ title: a.title, subtitle: a.subtitle, summary: a.summary, markdown: a.markdown }) });
@@ -867,7 +886,7 @@ export function createArtifactRoutes(config) {
867
886
  const { id, name } = await params;
868
887
  if (!ARTIFACT_ID.test(id))
869
888
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
870
- if (!(await store.get(id)))
889
+ if (!(await kept.get(id)))
871
890
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
872
891
  if (!ASSET_NAME.test(name))
873
892
  return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
@@ -917,7 +936,7 @@ export function createArtifactRoutes(config) {
917
936
  const { id, name } = await params;
918
937
  if (!ARTIFACT_ID.test(id))
919
938
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
920
- if (!(await store.get(id)))
939
+ if (!(await kept.get(id)))
921
940
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
922
941
  if (!ASSET_NAME.test(name))
923
942
  return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
@@ -977,7 +996,7 @@ export function createArtifactRoutes(config) {
977
996
  if (!authed(request))
978
997
  return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
979
998
  const { id } = await params;
980
- if (!ARTIFACT_ID.test(id) || !(await store.get(id)))
999
+ if (!ARTIFACT_ID.test(id) || !(await kept.get(id)))
981
1000
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
982
1001
  let files = 0;
983
1002
  if (config.assets?.deletePage) {
@@ -995,7 +1014,7 @@ export function createArtifactRoutes(config) {
995
1014
  catch (e) {
996
1015
  return NextResponse.json({ error: `the page's readers could not be forgotten, so the page was kept; delete it again: ${e instanceof Error ? e.message : String(e)}` }, { status: 502 });
997
1016
  }
998
- const ok = await store.delete(id);
1017
+ const ok = await kept.delete(id);
999
1018
  if (!ok)
1000
1019
  return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
1001
1020
  revalidatePath(pagePath(id));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.47.0",
3
+ "version": "0.49.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",