@supersuit/artifacts 0.3.0 → 0.4.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 +51 -0
- package/README.md +16 -0
- package/lib/artifacts/assets.d.ts +16 -0
- package/lib/artifacts/assets.js +38 -2
- package/lib/artifacts/front-matter.d.ts +5 -0
- package/lib/artifacts/front-matter.js +12 -1
- package/lib/artifacts/index.d.ts +1 -1
- package/lib/artifacts/index.js +1 -1
- package/lib/artifacts/render.js +35 -23
- package/lib/artifacts/store.d.ts +3 -0
- package/lib/artifacts/store.js +8 -1
- package/lib/artifacts/toc-spy.d.ts +3 -0
- package/lib/artifacts/toc-spy.js +40 -0
- package/lib/artifacts/toc.d.ts +15 -0
- package/lib/artifacts/toc.js +31 -0
- package/lib/brand/backdrop.d.ts +7 -0
- package/lib/brand/backdrop.js +34 -0
- package/lib/brand/index.d.ts +3 -1
- package/lib/brand/index.js +2 -0
- package/lib/brand/pack.d.ts +21 -1
- package/lib/brand/pack.js +1 -0
- package/lib/brand/theme.d.ts +9 -0
- package/lib/brand/theme.js +30 -0
- package/lib/brand/wrapper.d.ts +6 -2
- package/lib/brand/wrapper.js +16 -6
- package/lib/reader/artifact-reader.js +5 -5
- package/lib/reader/reader-watch.js +1 -1
- package/lib/routes/artifacts.d.ts +12 -0
- package/lib/routes/artifacts.js +51 -22
- package/lib/widgets/notes.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,57 @@
|
|
|
3
3
|
`@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
|
|
4
4
|
host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
|
|
5
5
|
|
|
6
|
+
## 0.4.1 (2026-09-26)
|
|
7
|
+
|
|
8
|
+
**Big files upload straight to the bucket.**
|
|
9
|
+
|
|
10
|
+
- A route handler on Vercel refuses a body over 4.5 MB (`FUNCTION_PAYLOAD_TOO_LARGE`), about five
|
|
11
|
+
minutes of narration, so a long page's audio could never publish; `PUT_ASSET`'s own 16 MiB cap
|
|
12
|
+
was never reached. New route `UPLOAD` (`POST /api/artifacts/<id>/uploads/<name>`, publish key,
|
|
13
|
+
JSON `{ digest }`) returns a v4 signed URL and the headers to send; the publisher PUTs the bytes
|
|
14
|
+
there, then calls again with `{ digest, done: true }`, which makes the object readable and
|
|
15
|
+
returns its URL (409 if nothing arrived). The key is the same hashed name `put()` writes, with
|
|
16
|
+
the same `immutable` cache header. `ArtifactAssets` gains optional `signUpload` and
|
|
17
|
+
`finishUpload`; `createArtifactAssets` implements both. `storedName`, `hashedName` and
|
|
18
|
+
`ASSET_DIGEST` are exported.
|
|
19
|
+
- **DETECTOR:** publishing a page whose narration is longer than about five minutes fails with
|
|
20
|
+
`narration upload failed 413: ... FUNCTION_PAYLOAD_TOO_LARGE`.
|
|
21
|
+
- **REMEDY:** add `app/api/artifacts/[id]/uploads/[name]/route.ts` exporting `POST` that calls
|
|
22
|
+
the routes' `UPLOAD`, the same shape as the assets route. The bucket's service account needs no
|
|
23
|
+
new permission when the host signs with a service-account key. Until the route exists the
|
|
24
|
+
publisher gets a 404 and falls back to `PUT_ASSET`.
|
|
25
|
+
- **Tests:** signing targets put()'s key with its cache header; finishing refuses a missing
|
|
26
|
+
object; the route's refusals match `PUT_ASSET`'s, a bad digest is 400, and a host whose assets
|
|
27
|
+
cannot sign answers 501.
|
|
28
|
+
|
|
29
|
+
## 0.4.0 (2026-09-26)
|
|
30
|
+
|
|
31
|
+
**Light and dark modes, a table of contents, and an animated ground.**
|
|
32
|
+
|
|
33
|
+
- **Modes.** `BrandPack` gains `light` (a second palette), `mode` (`light`, `dark` or `system`,
|
|
34
|
+
default `dark`) and `backdrop`. A page may override the mode with `theme:` in its front matter.
|
|
35
|
+
Every colour the page draws is now a CSS variable set by `BrandGround` for the mode, so `system`
|
|
36
|
+
follows the reader's device. `freedomDefault` carries a light palette.
|
|
37
|
+
- **Table of contents.** Built from the page's `##` headings: a sticky rail in the left margin at
|
|
38
|
+
`xl` widths, highlighting the section being read, and a closed "Contents" list above the body
|
|
39
|
+
below that. Both are `data-nospeak`. It appears once a page has four sections; `toc: false`
|
|
40
|
+
removes it and `toc: true` draws it from one. Every heading now carries its slug as an `id`,
|
|
41
|
+
with or without the notes widget.
|
|
42
|
+
- **Bubbles.** `backdrop: { kind: 'bubbles' }` draws drifting colour behind the page in plain CSS,
|
|
43
|
+
held still under `prefers-reduced-motion`. A pack with its own `Wrapper` draws its own ground
|
|
44
|
+
and ignores `backdrop`.
|
|
45
|
+
- **Long words wrap.** The body is `break-words`, so a bare URL in prose no longer pushes a phone
|
|
46
|
+
sideways (a 390px screen scrolled 7px on the first page checked).
|
|
47
|
+
- **DETECTOR:** a page's root carries `data-artifact-theme`; an open page with four sections
|
|
48
|
+
carries `data-artifact-toc`. A host whose pages still show zinc-on-black text in a light mode is
|
|
49
|
+
building from a Tailwind `content` list that does not scan this package's `lib/`.
|
|
50
|
+
- **REMEDY:** none required. A pack with no `mode` keeps its dark look, and `theme`/`toc` are new
|
|
51
|
+
optional keys. A host whose publisher strips unknown front matter keys must forward `theme` and
|
|
52
|
+
`toc` for pages to set them.
|
|
53
|
+
- **Tests:** theme CSS per mode and the fallback light palette; the ground, the wrapper-pack path
|
|
54
|
+
and the backdrop; `theme` and `toc` parsed, refused, stored and cleared on republish; heading ids
|
|
55
|
+
without notes; the route's mode and both contents placements.
|
|
56
|
+
|
|
6
57
|
## 0.3.0 (2026-09-25)
|
|
7
58
|
|
|
8
59
|
**Notes, the first widget.** A ```` ```notes ```` block in a page puts a small "note" control
|
package/README.md
CHANGED
|
@@ -130,6 +130,8 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
130
130
|
| `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent, no player |
|
|
131
131
|
| `password` | Shuts the body behind a door (below) |
|
|
132
132
|
| `access` | `freedom`, `invite` or `public`: a confidential page (below) |
|
|
133
|
+
| `theme` | `light`, `dark` or `system` (follow the reader's device). Overrides the brand pack's mode for this page |
|
|
134
|
+
| `toc` | The table of contents. Absent, it appears once the page has four `##` sections; `false` never; `true` from one |
|
|
133
135
|
| `template` | `document`, the only one so far |
|
|
134
136
|
|
|
135
137
|
An unknown key is refused, so a typo fails loudly instead of being ignored.
|
|
@@ -342,6 +344,20 @@ the narrator label, an optional full-page `Wrapper` and `Mark`, and an optional
|
|
|
342
344
|
(a font loader and a backdrop) that draws the unfurl card. `freedomDefault` is the default look.
|
|
343
345
|
Keep a pack carrying your own trademark in your own app, not in a pull request here.
|
|
344
346
|
|
|
347
|
+
**Light, dark or system.** A pack's `ground`, `ink` and `accent` are its dark palette; `light` is
|
|
348
|
+
the other one (absent, warm paper with the pack's accent), and `mode` is the one a page opens in
|
|
349
|
+
(`dark` by default; a page's `theme:` overrides it). Pages paint with CSS variables
|
|
350
|
+
(`--a-ground`, `--a-ink`, `--a-accent` and tones derived from them), which is what lets `system`
|
|
351
|
+
follow the device.
|
|
352
|
+
|
|
353
|
+
**An animated ground.** `backdrop: { kind: 'bubbles', colors?, veil? }` draws soft colour drifting
|
|
354
|
+
behind the page under a veil of the ground, in plain CSS, still for readers who ask for reduced
|
|
355
|
+
motion. `colors` are `r,g,b` triplets (default `GLOW_PASTELS`); `veil` is 0 to 1 (default 0.4).
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
const pack: BrandPack = { ...freedomDefault, mode: 'light', backdrop: { kind: 'bubbles' } }
|
|
359
|
+
```
|
|
360
|
+
|
|
345
361
|
## Environment
|
|
346
362
|
|
|
347
363
|
| Variable | Used for |
|
|
@@ -2,7 +2,21 @@ import type { Bucket } from '@google-cloud/storage';
|
|
|
2
2
|
export interface ArtifactAssets {
|
|
3
3
|
/** Store `bytes` for artifact `id` under `name`; return the public URL. */
|
|
4
4
|
put(id: string, name: string, bytes: Buffer, contentType: string): Promise<string>;
|
|
5
|
+
/**
|
|
6
|
+
* A signed URL the publisher PUTs a big file to directly, skipping the route handler.
|
|
7
|
+
* `digest` is the first 8 hex of the bytes' sha256, so the key is the one put() would use.
|
|
8
|
+
* Optional: a host without it answers 501 and the publisher falls back to put().
|
|
9
|
+
*/
|
|
10
|
+
signUpload?(id: string, name: string, digest: string, contentType: string): Promise<{
|
|
11
|
+
uploadUrl: string;
|
|
12
|
+
headers: Record<string, string>;
|
|
13
|
+
url: string;
|
|
14
|
+
}>;
|
|
15
|
+
/** After a signed upload: make it readable and return its URL, or null if nothing arrived. */
|
|
16
|
+
finishUpload?(id: string, name: string, digest: string): Promise<string | null>;
|
|
5
17
|
}
|
|
18
|
+
/** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
|
|
19
|
+
export declare const ASSET_DIGEST: RegExp;
|
|
6
20
|
/** Names are one path segment: letters, digits, dot, dash, underscore. */
|
|
7
21
|
export declare const ASSET_NAME: RegExp;
|
|
8
22
|
export declare const ASSET_TYPES: Record<string, string>;
|
|
@@ -24,4 +38,6 @@ export declare function contentTypeFor(name: string): string | undefined;
|
|
|
24
38
|
* URL and stay cached, which is the whole benefit.
|
|
25
39
|
*/
|
|
26
40
|
export declare function hashedName(name: string, bytes: Buffer): string;
|
|
41
|
+
/** `name` with `digest` spliced in before the extension, the way hashedName stores it. */
|
|
42
|
+
export declare function storedName(name: string, digest: string): string;
|
|
27
43
|
export declare function createArtifactAssets(bucket: Bucket, prefix: string): ArtifactAssets;
|
package/lib/artifacts/assets.js
CHANGED
|
@@ -3,6 +3,9 @@
|
|
|
3
3
|
// publisher shelled out to gsutil against the maintainer's bucket, which no other operator
|
|
4
4
|
// could do, so every operator gets the same door: PUT bytes with their key, get a URL back.
|
|
5
5
|
import { createHash } from 'node:crypto';
|
|
6
|
+
/** The digest half of a stored name: the first 8 hex of the bytes' sha256. */
|
|
7
|
+
export const ASSET_DIGEST = /^[0-9a-f]{8}$/;
|
|
8
|
+
const CACHE_CONTROL = 'public, max-age=31536000, immutable';
|
|
6
9
|
/** Names are one path segment: letters, digits, dot, dash, underscore. */
|
|
7
10
|
export const ASSET_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]{0,120}$/;
|
|
8
11
|
export const ASSET_TYPES = {
|
|
@@ -35,7 +38,10 @@ export function contentTypeFor(name) {
|
|
|
35
38
|
* URL and stay cached, which is the whole benefit.
|
|
36
39
|
*/
|
|
37
40
|
export function hashedName(name, bytes) {
|
|
38
|
-
|
|
41
|
+
return storedName(name, createHash('sha256').update(bytes).digest('hex').slice(0, 8));
|
|
42
|
+
}
|
|
43
|
+
/** `name` with `digest` spliced in before the extension, the way hashedName stores it. */
|
|
44
|
+
export function storedName(name, digest) {
|
|
39
45
|
const dot = name.lastIndexOf('.');
|
|
40
46
|
// A name with no extension is legal here, so append rather than assume there is a dot.
|
|
41
47
|
return dot <= 0 ? `${name}.${digest}` : `${name.slice(0, dot)}.${digest}${name.slice(dot)}`;
|
|
@@ -49,7 +55,7 @@ export function createArtifactAssets(bucket, prefix) {
|
|
|
49
55
|
await file.save(bytes, {
|
|
50
56
|
contentType,
|
|
51
57
|
resumable: false,
|
|
52
|
-
metadata: { cacheControl:
|
|
58
|
+
metadata: { cacheControl: CACHE_CONTROL },
|
|
53
59
|
});
|
|
54
60
|
try {
|
|
55
61
|
await file.makePublic();
|
|
@@ -59,5 +65,35 @@ export function createArtifactAssets(bucket, prefix) {
|
|
|
59
65
|
}
|
|
60
66
|
return `https://storage.googleapis.com/${bucket.name}/${key}`;
|
|
61
67
|
},
|
|
68
|
+
// A route handler on Vercel refuses a body over 4.5 MB (FUNCTION_PAYLOAD_TOO_LARGE), which is
|
|
69
|
+
// about five minutes of narration. An hour-long paper's audio (~55 MB) could never publish.
|
|
70
|
+
// So a big file goes to the bucket directly, on a URL signed with the host's own service
|
|
71
|
+
// account, to the same hashed key and with the same cache header put() writes.
|
|
72
|
+
async signUpload(id, name, digest, contentType) {
|
|
73
|
+
const key = `${clean}/${id}/${storedName(name, digest)}`;
|
|
74
|
+
const headers = { 'content-type': contentType, 'cache-control': CACHE_CONTROL };
|
|
75
|
+
const [uploadUrl] = await bucket.file(key).getSignedUrl({
|
|
76
|
+
version: 'v4',
|
|
77
|
+
action: 'write',
|
|
78
|
+
expires: Date.now() + 15 * 60 * 1000,
|
|
79
|
+
contentType,
|
|
80
|
+
extensionHeaders: { 'cache-control': CACHE_CONTROL },
|
|
81
|
+
});
|
|
82
|
+
return { uploadUrl, headers, url: `https://storage.googleapis.com/${bucket.name}/${key}` };
|
|
83
|
+
},
|
|
84
|
+
async finishUpload(id, name, digest) {
|
|
85
|
+
const key = `${clean}/${id}/${storedName(name, digest)}`;
|
|
86
|
+
const file = bucket.file(key);
|
|
87
|
+
const [there] = await file.exists();
|
|
88
|
+
if (!there)
|
|
89
|
+
return null;
|
|
90
|
+
try {
|
|
91
|
+
await file.makePublic();
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// uniform bucket access: already public
|
|
95
|
+
}
|
|
96
|
+
return `https://storage.googleapis.com/${bucket.name}/${key}`;
|
|
97
|
+
},
|
|
62
98
|
};
|
|
63
99
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Access } from './reader.js';
|
|
2
2
|
import { type StateConfig } from './state.js';
|
|
3
|
+
import { type ThemeMode } from '../brand/theme.js';
|
|
3
4
|
export type ArtifactMeta = {
|
|
4
5
|
title: string;
|
|
5
6
|
summary: string;
|
|
@@ -27,6 +28,10 @@ export type ArtifactMeta = {
|
|
|
27
28
|
/** What readers may put into the page. Content, like the body: a republish without it removes
|
|
28
29
|
* the slots from the page and KEEPS the answers already given. */
|
|
29
30
|
state?: StateConfig;
|
|
31
|
+
/** Light, dark, or the reader's device, overriding the brand pack's mode for this page. */
|
|
32
|
+
theme?: ThemeMode;
|
|
33
|
+
/** The table of contents: absent draws it once the page has four sections, false never. */
|
|
34
|
+
toc?: boolean;
|
|
30
35
|
};
|
|
31
36
|
export declare function parseArtifactSource(text: string): {
|
|
32
37
|
ok: true;
|
|
@@ -4,7 +4,8 @@ import matter from 'gray-matter';
|
|
|
4
4
|
import { ACCESS_LEVELS } from './reader.js';
|
|
5
5
|
import { parseStateConfig } from './state.js';
|
|
6
6
|
import { mergeWidgetState, scanWidgets } from './widgets.js';
|
|
7
|
-
|
|
7
|
+
import { THEME_MODES } from '../brand/theme.js';
|
|
8
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc']);
|
|
8
9
|
export function parseArtifactSource(text) {
|
|
9
10
|
const { data, content } = matter(text);
|
|
10
11
|
const d = data;
|
|
@@ -35,6 +36,16 @@ export function parseArtifactSource(text) {
|
|
|
35
36
|
return { ok: false, error: `access must be one of: public, ${ACCESS_LEVELS.join(', ')}` };
|
|
36
37
|
meta.access = d.access;
|
|
37
38
|
}
|
|
39
|
+
if (d.theme !== undefined) {
|
|
40
|
+
if (!THEME_MODES.includes(d.theme))
|
|
41
|
+
return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
|
|
42
|
+
meta.theme = d.theme;
|
|
43
|
+
}
|
|
44
|
+
if (d.toc !== undefined) {
|
|
45
|
+
if (typeof d.toc !== 'boolean')
|
|
46
|
+
return { ok: false, error: 'toc must be true or false' };
|
|
47
|
+
meta.toc = d.toc;
|
|
48
|
+
}
|
|
38
49
|
if (d.state !== undefined) {
|
|
39
50
|
const s = parseStateConfig(d.state);
|
|
40
51
|
if (!s.ok)
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { ArtifactMarkdown } from './render.js';
|
|
|
3
3
|
export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
|
|
4
4
|
export { createArtifactStore, newArtifactId, type ArtifactStore, type ArtifactRecord, type SaveResult } from './store.js';
|
|
5
5
|
export { isPublishAuthed } from './auth.js';
|
|
6
|
-
export { createArtifactAssets, contentTypeFor, ASSET_NAME, type ArtifactAssets } from './assets.js';
|
|
6
|
+
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST, type ArtifactAssets } from './assets.js';
|
|
7
7
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
8
8
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS, type Access, type Reader, type AllowEntry, type Decision } from './reader.js';
|
|
9
9
|
export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type FlagKind, type SessionDoc, type FlagDoc } from './readers-store.js';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -3,7 +3,7 @@ export { ArtifactMarkdown } from './render.js';
|
|
|
3
3
|
export { narrationText, narrationTextFromMarkdown, normalizeWord } from './narration.js';
|
|
4
4
|
export { createArtifactStore, newArtifactId } from './store.js';
|
|
5
5
|
export { isPublishAuthed } from './auth.js';
|
|
6
|
-
export { createArtifactAssets, contentTypeFor, ASSET_NAME } from './assets.js';
|
|
6
|
+
export { createArtifactAssets, contentTypeFor, storedName, hashedName, ASSET_NAME, ASSET_DIGEST } from './assets.js';
|
|
7
7
|
export { isUnlocked, keyHash, unlockCookieName, unlockedUrl } from './unlock.js';
|
|
8
8
|
export { verifyPass, mintPass, verifyGrant, mintGrant, decide, firstName, signInUrl, safeReturnPath, GRANT_COOKIE, ACCESS_LEVELS } from './reader.js';
|
|
9
9
|
export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
|
package/lib/artifacts/render.js
CHANGED
|
@@ -3,7 +3,8 @@ import ReactMarkdown from 'react-markdown';
|
|
|
3
3
|
import remarkGfm from 'remark-gfm';
|
|
4
4
|
import { hasNotesWidget, headingsOf } from './widgets.js';
|
|
5
5
|
import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
|
|
6
|
-
|
|
6
|
+
// Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
|
|
7
|
+
const GOLD = 'var(--a-accent)';
|
|
7
8
|
function hostOf(url) {
|
|
8
9
|
try {
|
|
9
10
|
return new URL(url).host.replace(/^www\./, '');
|
|
@@ -33,7 +34,7 @@ function LinkCards({ source }) {
|
|
|
33
34
|
const [url, ...rest] = l.split('|');
|
|
34
35
|
return { url: url.trim(), note: rest.join('|').trim() };
|
|
35
36
|
});
|
|
36
|
-
return (_jsx("ul", { "data-artifact-links": true, className: "my-6 grid list-none gap-3 p-0", children: rows.map((r) => (_jsx("li", { className: "m-0 p-0", children: _jsxs("a", { href: r.url, target: "_blank", rel: "noopener noreferrer", className: "block rounded-lg border border-
|
|
37
|
+
return (_jsx("ul", { "data-artifact-links": true, className: "my-6 grid list-none gap-3 p-0", children: rows.map((r) => (_jsx("li", { className: "m-0 p-0", children: _jsxs("a", { href: r.url, target: "_blank", rel: "noopener noreferrer", className: "block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3 no-underline transition-colors hover:border-[color:var(--a-line-strong)]", children: [_jsx("span", { className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: hostOf(r.url) }), _jsx("span", { className: "block text-[color:var(--a-strong)]", children: r.note || r.url })] }) }, r.url))) }));
|
|
37
38
|
}
|
|
38
39
|
const CALLOUT = /^\[!(note|warning)\]\s*/i;
|
|
39
40
|
// A link the reader can actually follow: absolute, a scheme like mailto:, a
|
|
@@ -45,20 +46,17 @@ export function isReachableHref(href) {
|
|
|
45
46
|
return typeof href === 'string' && REACHABLE.test(href);
|
|
46
47
|
}
|
|
47
48
|
const components = {
|
|
48
|
-
h1: ({ children }) => _jsx("h1", { className: "mt-10 mb-4 font-serif text-3xl text-zinc-50", children: children }),
|
|
49
|
-
h2: ({ children }) => _jsx("h2", { className: "mt-10 mb-3 font-serif text-2xl text-zinc-50", children: children }),
|
|
50
|
-
h3: ({ children }) => _jsx("h3", { className: "mt-8 mb-2 text-lg font-semibold text-zinc-100", children: children }),
|
|
51
49
|
p: ({ children }) => _jsx("p", { className: "my-4 leading-relaxed", children: children }),
|
|
52
50
|
ul: ({ children }) => _jsx("ul", { className: "my-4 list-disc space-y-1 pl-6", children: children }),
|
|
53
51
|
ol: ({ children }) => _jsx("ol", { className: "my-4 list-decimal space-y-1 pl-6", children: children }),
|
|
54
|
-
hr: () => _jsx("hr", { className: "my-10 border-
|
|
52
|
+
hr: () => _jsx("hr", { className: "my-10 border-[color:var(--a-line)]" }),
|
|
55
53
|
table: ({ children }) => (_jsx("div", { className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
|
|
56
|
-
th: ({ children }) => (_jsx("th", { className: "border-b border-
|
|
57
|
-
td: ({ children }) => _jsx("td", { className: "border-b border-
|
|
54
|
+
th: ({ children }) => (_jsx("th", { className: "border-b border-[color:var(--a-line-strong)] px-3 py-2 text-left font-semibold text-[color:var(--a-strong)]", children: children })),
|
|
55
|
+
td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
|
|
58
56
|
img: ({ src, alt }) => (
|
|
59
57
|
// eslint-disable-next-line @next/next/no-img-element
|
|
60
|
-
_jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-
|
|
61
|
-
a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-
|
|
58
|
+
_jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" })),
|
|
59
|
+
a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] })),
|
|
62
60
|
code: ({ className, children }) => {
|
|
63
61
|
const lang = /language-(\w+)/.exec(className ?? '')?.[1];
|
|
64
62
|
if (lang === 'links')
|
|
@@ -68,7 +66,7 @@ const components = {
|
|
|
68
66
|
if (lang === 'notes')
|
|
69
67
|
return null;
|
|
70
68
|
if (!className) {
|
|
71
|
-
return _jsx("code", { className: "rounded bg-
|
|
69
|
+
return _jsx("code", { className: "rounded bg-[color:var(--a-surface-strong)] px-1.5 py-0.5 text-[0.9em] text-[color:var(--a-strong)]", children: children });
|
|
72
70
|
}
|
|
73
71
|
return _jsx("code", { className: className, children: children });
|
|
74
72
|
},
|
|
@@ -78,27 +76,39 @@ const components = {
|
|
|
78
76
|
const cls = inner?.props?.className ?? '';
|
|
79
77
|
if (/language-(links|notes)/.test(cls))
|
|
80
78
|
return _jsx(_Fragment, { children: children });
|
|
81
|
-
return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-
|
|
79
|
+
return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-code)] p-4 text-sm text-[color:var(--a-strong)]", children: children }));
|
|
82
80
|
},
|
|
83
81
|
blockquote: ({ children }) => {
|
|
84
82
|
const t = textOf(children).trim();
|
|
85
83
|
const m = CALLOUT.exec(t);
|
|
86
84
|
if (!m) {
|
|
87
|
-
return (_jsx("blockquote", { className: "my-6 border-l-2 pl-4 italic text-
|
|
85
|
+
return (_jsx("blockquote", { className: "my-6 border-l-2 pl-4 italic text-[color:var(--a-body)]", style: { borderColor: GOLD }, children: children }));
|
|
88
86
|
}
|
|
89
87
|
const kind = m[1].toLowerCase();
|
|
90
88
|
const body = t.replace(CALLOUT, '');
|
|
91
|
-
return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: '
|
|
89
|
+
return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: 'var(--a-surface)' }, children: [_jsx("span", { "data-nospeak": true, className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: kind }), _jsx("p", { className: "mt-1 text-[color:var(--a-strong)]", children: body })] }));
|
|
92
90
|
},
|
|
93
91
|
};
|
|
92
|
+
// scroll-mt keeps a heading clear of the top of the screen when a contents link jumps to it.
|
|
94
93
|
const HEADING_CLASS = {
|
|
95
|
-
1: 'mt-10 mb-4 font-serif text-3xl text-
|
|
96
|
-
2: 'mt-10 mb-3 font-serif text-2xl text-
|
|
97
|
-
3: 'mt-8 mb-2 text-lg font-semibold text-
|
|
98
|
-
4: 'mt-6 mb-2 font-semibold text-
|
|
99
|
-
5: 'mt-6 mb-2 font-semibold text-
|
|
100
|
-
6: 'mt-6 mb-2 font-semibold text-
|
|
94
|
+
1: 'mt-10 mb-4 scroll-mt-24 font-serif text-3xl text-[color:var(--a-strong)]',
|
|
95
|
+
2: 'mt-10 mb-3 scroll-mt-24 font-serif text-2xl text-[color:var(--a-strong)]',
|
|
96
|
+
3: 'mt-8 mb-2 scroll-mt-24 text-lg font-semibold text-[color:var(--a-strong)]',
|
|
97
|
+
4: 'mt-6 mb-2 scroll-mt-24 font-semibold text-[color:var(--a-strong)]',
|
|
98
|
+
5: 'mt-6 mb-2 scroll-mt-24 font-semibold text-[color:var(--a-strong)]',
|
|
99
|
+
6: 'mt-6 mb-2 scroll-mt-24 font-semibold text-[color:var(--a-strong)]',
|
|
101
100
|
};
|
|
101
|
+
/** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
|
|
102
|
+
* so a contents link and a note both land on it. */
|
|
103
|
+
function pageComponents(headings) {
|
|
104
|
+
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
105
|
+
const heading = (depth) => function Heading({ node, children }) {
|
|
106
|
+
const Tag = `h${depth}`;
|
|
107
|
+
const h = byLine.get(node?.position?.start.line ?? -1);
|
|
108
|
+
return _jsx(Tag, { id: h?.slug, className: HEADING_CLASS[depth], children: children });
|
|
109
|
+
};
|
|
110
|
+
return { ...components, h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
|
|
111
|
+
}
|
|
102
112
|
/** Components for a page with a notes block: every heading gets its slug as an id, a note
|
|
103
113
|
* control, and its notes after it. The heading is found by its source line, so the slug is the
|
|
104
114
|
* one headingsOf derived, the same one a note stores. */
|
|
@@ -122,7 +132,9 @@ function notesComponents(headings) {
|
|
|
122
132
|
* page carries a ```notes block. */
|
|
123
133
|
export function ArtifactMarkdown({ markdown, notes }) {
|
|
124
134
|
const on = !!notes && hasNotesWidget(markdown);
|
|
125
|
-
const headings =
|
|
126
|
-
const body = (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: on ? notesComponents(headings) :
|
|
127
|
-
return (
|
|
135
|
+
const headings = headingsOf(markdown);
|
|
136
|
+
const body = (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: on ? notesComponents(headings) : pageComponents(headings), children: markdown }));
|
|
137
|
+
return (
|
|
138
|
+
// break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
|
|
139
|
+
_jsx("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body }));
|
|
128
140
|
}
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Firestore } from 'firebase-admin/firestore';
|
|
|
2
2
|
import type { ArtifactMeta } from './front-matter.js';
|
|
3
3
|
import type { Access } from './reader.js';
|
|
4
4
|
import type { StateConfig } from './state.js';
|
|
5
|
+
import type { ThemeMode } from '../brand/theme.js';
|
|
5
6
|
export type ArtifactRecord = {
|
|
6
7
|
id: string;
|
|
7
8
|
title: string;
|
|
@@ -17,6 +18,8 @@ export type ArtifactRecord = {
|
|
|
17
18
|
password?: string;
|
|
18
19
|
access?: Access;
|
|
19
20
|
state?: StateConfig;
|
|
21
|
+
theme?: ThemeMode;
|
|
22
|
+
toc?: boolean;
|
|
20
23
|
markdown: string;
|
|
21
24
|
createdAt: string;
|
|
22
25
|
updatedAt: string;
|
package/lib/artifacts/store.js
CHANGED
|
@@ -57,6 +57,8 @@ async function saveArtifact(col, input) {
|
|
|
57
57
|
...(input.meta.password ? { password: input.meta.password } : {}),
|
|
58
58
|
...(input.meta.access && input.meta.access !== 'public' ? { access: input.meta.access } : {}),
|
|
59
59
|
...(input.meta.state ? { state: input.meta.state } : {}),
|
|
60
|
+
...(input.meta.theme ? { theme: input.meta.theme } : {}),
|
|
61
|
+
...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
|
|
60
62
|
};
|
|
61
63
|
if (input.id) {
|
|
62
64
|
const existing = await getArtifact(col, input.id);
|
|
@@ -82,8 +84,13 @@ async function saveArtifact(col, input) {
|
|
|
82
84
|
const access = input.meta.access === 'public' ? { access: FieldValue.delete() } : {};
|
|
83
85
|
// Content, like the body: a republish that no longer declares state removes the slots.
|
|
84
86
|
const state = input.meta.state ? {} : { state: FieldValue.delete() };
|
|
87
|
+
// How the page looks is content too: a line the file no longer carries goes back to the default.
|
|
88
|
+
const look = {
|
|
89
|
+
...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
|
|
90
|
+
...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
|
|
91
|
+
};
|
|
85
92
|
await ref.update({
|
|
86
|
-
...fields, ...password, ...subtitle, ...access, ...state, markdown: input.markdown, updatedAt: now, version: next,
|
|
93
|
+
...fields, ...password, ...subtitle, ...access, ...state, ...look, markdown: input.markdown, updatedAt: now, version: next,
|
|
87
94
|
...(existing.versions ? { versions: FieldValue.delete() } : {}),
|
|
88
95
|
});
|
|
89
96
|
return { id: input.id, version: next, created: false };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// Marks the contents entry for the section being read. Renders nothing: it only sets
|
|
3
|
+
// aria-current on the links the server drew, which is also what styles them.
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
export function TocSpy({ slugs }) {
|
|
6
|
+
const key = slugs.join('|');
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const ids = key.split('|');
|
|
9
|
+
let frame = 0;
|
|
10
|
+
const mark = () => {
|
|
11
|
+
frame = 0;
|
|
12
|
+
let current = ids[0];
|
|
13
|
+
for (const id of ids) {
|
|
14
|
+
const el = document.getElementById(id);
|
|
15
|
+
if (el && el.getBoundingClientRect().top <= 140)
|
|
16
|
+
current = id;
|
|
17
|
+
}
|
|
18
|
+
document.querySelectorAll('a[data-toc-slug]').forEach((a) => {
|
|
19
|
+
if (a.dataset.tocSlug === current)
|
|
20
|
+
a.setAttribute('aria-current', 'location');
|
|
21
|
+
else
|
|
22
|
+
a.removeAttribute('aria-current');
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
const onScroll = () => {
|
|
26
|
+
if (!frame)
|
|
27
|
+
frame = requestAnimationFrame(mark);
|
|
28
|
+
};
|
|
29
|
+
mark();
|
|
30
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
31
|
+
window.addEventListener('resize', onScroll);
|
|
32
|
+
return () => {
|
|
33
|
+
window.removeEventListener('scroll', onScroll);
|
|
34
|
+
window.removeEventListener('resize', onScroll);
|
|
35
|
+
if (frame)
|
|
36
|
+
cancelAnimationFrame(frame);
|
|
37
|
+
};
|
|
38
|
+
}, [key]);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type TocItem = {
|
|
2
|
+
slug: string;
|
|
3
|
+
text: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function tocOf(markdown: string): TocItem[];
|
|
6
|
+
/** `flag` is the page's `toc:`. Absent, the list appears once there are enough sections. */
|
|
7
|
+
export declare function showToc(markdown: string, flag?: boolean): boolean;
|
|
8
|
+
/** Wide screens: fixed in the left margin, so the reading column stays centred. */
|
|
9
|
+
export declare function TocRail({ items }: {
|
|
10
|
+
items: TocItem[];
|
|
11
|
+
}): import("react").JSX.Element;
|
|
12
|
+
/** Narrow screens: a closed list above the body. */
|
|
13
|
+
export declare function TocInline({ items }: {
|
|
14
|
+
items: TocItem[];
|
|
15
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The table of contents: the page's sections, as a sticky rail on a wide screen and a
|
|
3
|
+
// collapsible list above the body on a narrow one. Both are marked data-nospeak so the
|
|
4
|
+
// read-along never narrates them, and both link to the ids the renderer puts on headings,
|
|
5
|
+
// which come from the same headingsOf the notes widget uses.
|
|
6
|
+
import { headingsOf } from './widgets.js';
|
|
7
|
+
import { TocSpy } from './toc-spy.js';
|
|
8
|
+
/** Sections below this count read fine without a contents list. */
|
|
9
|
+
const AUTO_MIN = 4;
|
|
10
|
+
export function tocOf(markdown) {
|
|
11
|
+
return headingsOf(markdown).filter((h) => h.depth === 2).map(({ slug, text }) => ({ slug, text }));
|
|
12
|
+
}
|
|
13
|
+
/** `flag` is the page's `toc:`. Absent, the list appears once there are enough sections. */
|
|
14
|
+
export function showToc(markdown, flag) {
|
|
15
|
+
if (flag === false)
|
|
16
|
+
return false;
|
|
17
|
+
const n = tocOf(markdown).length;
|
|
18
|
+
return flag === true ? n > 0 : n >= AUTO_MIN;
|
|
19
|
+
}
|
|
20
|
+
const LINK = 'block border-l-2 border-transparent py-1 pl-3 no-underline transition-colors text-[color:var(--a-muted)] hover:text-[color:var(--a-strong)] aria-[current=location]:border-[color:var(--a-accent)] aria-[current=location]:text-[color:var(--a-strong)]';
|
|
21
|
+
function List({ items }) {
|
|
22
|
+
return (_jsx("ol", { className: "m-0 list-none space-y-0.5 p-0 text-sm leading-snug", children: items.map((i) => (_jsx("li", { className: "m-0 p-0", children: _jsx("a", { href: `#${i.slug}`, "data-toc-slug": i.slug, className: LINK, children: i.text }) }, i.slug))) }));
|
|
23
|
+
}
|
|
24
|
+
/** Wide screens: fixed in the left margin, so the reading column stays centred. */
|
|
25
|
+
export function TocRail({ items }) {
|
|
26
|
+
return (_jsxs("nav", { "data-nospeak": true, "data-artifact-toc": "rail", "aria-label": "Contents", className: "fixed top-28 left-6 z-10 hidden max-h-[calc(100vh-10rem)] w-56 overflow-y-auto xl:block 2xl:left-12 2xl:w-64", children: [_jsx("p", { className: "mb-3 pl-3 text-[11px] font-medium uppercase tracking-[0.25em] text-[color:var(--a-accent)]", children: "Contents" }), _jsx(List, { items: items }), _jsx(TocSpy, { slugs: items.map((i) => i.slug) })] }));
|
|
27
|
+
}
|
|
28
|
+
/** Narrow screens: a closed list above the body. */
|
|
29
|
+
export function TocInline({ items }) {
|
|
30
|
+
return (_jsxs("details", { "data-nospeak": true, "data-artifact-toc": "inline", className: "mb-8 rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3 xl:hidden", children: [_jsx("summary", { className: "cursor-pointer text-[11px] font-medium uppercase tracking-[0.25em] text-[color:var(--a-accent)]", children: "Contents" }), _jsx("div", { className: "mt-3", children: _jsx(List, { items: items }) })] }));
|
|
31
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Backdrop } from './pack.js';
|
|
2
|
+
/** The Freedom Glow's colours (a blue core and the spectrum it separates into), mixed most of
|
|
3
|
+
* the way to cream so a light page still reads as light. */
|
|
4
|
+
export declare const GLOW_PASTELS: string[];
|
|
5
|
+
export declare function Bubbles({ backdrop }: {
|
|
6
|
+
backdrop: Backdrop;
|
|
7
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/** The Freedom Glow's colours (a blue core and the spectrum it separates into), mixed most of
|
|
3
|
+
* the way to cream so a light page still reads as light. */
|
|
4
|
+
export const GLOW_PASTELS = ['150,190,255', '215,185,240', '160,225,220', '250,195,180', '250,215,160', '190,210,255'];
|
|
5
|
+
// [animation, duration s, transform-origin] per bubble, after the original's six.
|
|
6
|
+
const MOTION = [
|
|
7
|
+
['a-bubble-v', 30, 'center center'],
|
|
8
|
+
['a-bubble-c', 20, 'calc(50% - 400px) center'],
|
|
9
|
+
['a-bubble-c', 40, 'calc(50% + 400px) center'],
|
|
10
|
+
['a-bubble-h', 40, 'calc(50% - 200px) center'],
|
|
11
|
+
['a-bubble-c', 20, 'calc(50% - 800px) calc(50% + 200px)'],
|
|
12
|
+
['a-bubble-v', 35, 'calc(50% + 300px) calc(50% - 200px)'],
|
|
13
|
+
];
|
|
14
|
+
const CSS = `
|
|
15
|
+
@keyframes a-bubble-v{0%,100%{transform:translateY(-50%)}50%{transform:translateY(50%)}}
|
|
16
|
+
@keyframes a-bubble-c{0%{transform:rotate(0deg)}50%{transform:rotate(180deg)}100%{transform:rotate(360deg)}}
|
|
17
|
+
@keyframes a-bubble-h{0%,100%{transform:translateX(-50%) translateY(-10%)}50%{transform:translateX(50%) translateY(10%)}}
|
|
18
|
+
[data-artifact-backdrop] .a-bubble{position:absolute;width:80%;height:80%;top:10%;left:10%;border-radius:9999px;mix-blend-mode:hard-light;animation-timing-function:ease;animation-iteration-count:infinite}
|
|
19
|
+
@media (prefers-reduced-motion: reduce){[data-artifact-backdrop] .a-bubble{animation:none!important}}
|
|
20
|
+
`;
|
|
21
|
+
export function Bubbles({ backdrop }) {
|
|
22
|
+
const colors = backdrop.colors?.length ? backdrop.colors : GLOW_PASTELS;
|
|
23
|
+
const veil = Math.round((backdrop.veil ?? 0.4) * 100);
|
|
24
|
+
return (_jsxs("div", { "data-artifact-backdrop": "bubbles", "aria-hidden": true, style: { position: 'fixed', inset: 0, zIndex: -1, overflow: 'hidden', background: 'var(--a-ground)', pointerEvents: 'none' }, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: CSS } }), _jsx("div", { style: { position: 'absolute', inset: 0, filter: 'blur(40px)' }, children: colors.map((c, i) => {
|
|
25
|
+
const [name, secs, origin] = MOTION[i % MOTION.length];
|
|
26
|
+
return (_jsx("div", { className: "a-bubble", style: {
|
|
27
|
+
background: `radial-gradient(circle at center, rgba(${c},0.8) 0, rgba(${c},0) 50%)`,
|
|
28
|
+
animationName: name,
|
|
29
|
+
animationDuration: `${secs}s`,
|
|
30
|
+
animationDirection: i % 2 ? 'reverse' : 'normal',
|
|
31
|
+
transformOrigin: origin,
|
|
32
|
+
} }, i));
|
|
33
|
+
}) }), _jsx("div", { style: { position: 'absolute', inset: 0, background: `color-mix(in srgb, var(--a-ground) ${veil}%, transparent)` } })] }));
|
|
34
|
+
}
|
package/lib/brand/index.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
-
export { freedomDefault, type BrandPack, type ShareCardAssets } from './pack.js';
|
|
1
|
+
export { freedomDefault, type BrandPack, type ShareCardAssets, type Palette, type Backdrop } from './pack.js';
|
|
2
2
|
export { BrandGround, BrandMark } from './wrapper.js';
|
|
3
3
|
export { renderShareCard } from './share-card.js';
|
|
4
|
+
export { THEME_MODES, type ThemeMode, themeCss, themedPack, resolveMode } from './theme.js';
|
|
5
|
+
export { GLOW_PASTELS } from './backdrop.js';
|
package/lib/brand/index.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
1
|
export { freedomDefault } from './pack.js';
|
|
2
2
|
export { BrandGround, BrandMark } from './wrapper.js';
|
|
3
3
|
export { renderShareCard } from './share-card.js';
|
|
4
|
+
export { THEME_MODES, themeCss, themedPack, resolveMode } from './theme.js';
|
|
5
|
+
export { GLOW_PASTELS } from './backdrop.js';
|
package/lib/brand/pack.d.ts
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
1
|
import type { ComponentType, ReactNode } from 'react';
|
|
2
|
+
import type { ThemeMode } from './theme.js';
|
|
3
|
+
/** One set of page colours. */
|
|
4
|
+
export type Palette = {
|
|
5
|
+
ground: string;
|
|
6
|
+
ink: string;
|
|
7
|
+
accent: string;
|
|
8
|
+
};
|
|
9
|
+
/** The animated ground behind a page. `bubbles` is soft colour drifting under a veil of the
|
|
10
|
+
* ground colour. Colours are `r,g,b` triplets. */
|
|
11
|
+
export type Backdrop = {
|
|
12
|
+
kind: 'bubbles';
|
|
13
|
+
colors?: string[];
|
|
14
|
+
veil?: number;
|
|
15
|
+
};
|
|
2
16
|
/**
|
|
3
17
|
* A brand pack is what makes an operator's pages theirs: data plus at most two
|
|
4
18
|
* components. The package ships `freedomDefault`; an instance passes its own. A pack
|
|
@@ -7,10 +21,16 @@ import type { ComponentType, ReactNode } from 'react';
|
|
|
7
21
|
export type BrandPack = {
|
|
8
22
|
id: string;
|
|
9
23
|
name: string;
|
|
10
|
-
/** CSS colours. */
|
|
24
|
+
/** CSS colours: the DARK palette, which is every pack's original one. */
|
|
11
25
|
ground: string;
|
|
12
26
|
ink: string;
|
|
13
27
|
accent: string;
|
|
28
|
+
/** The light palette. Absent, a warm paper palette with this pack's accent is used. */
|
|
29
|
+
light?: Palette;
|
|
30
|
+
/** The mode a page opens in unless its front matter says otherwise. Default `dark`. */
|
|
31
|
+
mode?: ThemeMode;
|
|
32
|
+
/** An animated ground drawn behind the page. Ignored when the pack has its own Wrapper. */
|
|
33
|
+
backdrop?: Backdrop;
|
|
14
34
|
/** CSS font-family values for headings and body. */
|
|
15
35
|
type: {
|
|
16
36
|
display: string;
|
package/lib/brand/pack.js
CHANGED
|
@@ -5,6 +5,7 @@ export const freedomDefault = {
|
|
|
5
5
|
ground: '#0e0f13',
|
|
6
6
|
ink: '#ece7dc',
|
|
7
7
|
accent: '#c9a96e',
|
|
8
|
+
light: { ground: '#f6f1e7', ink: '#1c1a17', accent: '#8a6a2c' },
|
|
8
9
|
type: {
|
|
9
10
|
display: 'Georgia, "Iowan Old Style", "Times New Roman", serif',
|
|
10
11
|
body: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { BrandPack } from './pack.js';
|
|
2
|
+
export declare const THEME_MODES: readonly ["light", "dark", "system"];
|
|
3
|
+
export type ThemeMode = (typeof THEME_MODES)[number];
|
|
4
|
+
export declare function resolveMode(pack: BrandPack, page?: ThemeMode): ThemeMode;
|
|
5
|
+
/** The stylesheet for one pack: its two palettes, the tones derived from them, and the
|
|
6
|
+
* device switch for `system`. */
|
|
7
|
+
export declare function themeCss(pack: BrandPack): string;
|
|
8
|
+
/** The pack as the page renders it: every colour is the variable the mode sets. */
|
|
9
|
+
export declare function themedPack(pack: BrandPack): BrandPack;
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export const THEME_MODES = ['light', 'dark', 'system'];
|
|
2
|
+
/** The light palette a pack gets when it declares none: warm paper, near-black ink, and its
|
|
3
|
+
* own accent, which every shipped accent is dark enough to read on. */
|
|
4
|
+
const FALLBACK_LIGHT = { ground: '#f7f3ea', ink: '#1d1b17' };
|
|
5
|
+
export function resolveMode(pack, page) {
|
|
6
|
+
return page ?? pack.mode ?? 'dark';
|
|
7
|
+
}
|
|
8
|
+
function vars(p, scheme) {
|
|
9
|
+
return `--a-ground:${p.ground};--a-ink:${p.ink};--a-accent:${p.accent};color-scheme:${scheme}`;
|
|
10
|
+
}
|
|
11
|
+
/** The stylesheet for one pack: its two palettes, the tones derived from them, and the
|
|
12
|
+
* device switch for `system`. */
|
|
13
|
+
export function themeCss(pack) {
|
|
14
|
+
const dark = { ground: pack.ground, ink: pack.ink, accent: pack.accent };
|
|
15
|
+
const light = pack.light ?? { ...FALLBACK_LIGHT, accent: pack.accent };
|
|
16
|
+
const mix = (n) => `color-mix(in srgb,var(--a-ink) ${n}%,var(--a-ground))`;
|
|
17
|
+
return [
|
|
18
|
+
`[data-artifact-theme]{--a-strong:var(--a-ink);--a-body:${mix(86)};--a-muted:${mix(55)};` +
|
|
19
|
+
`--a-line:${mix(12)};--a-line-strong:${mix(28)};--a-surface:${mix(4)};--a-surface-strong:${mix(9)};` +
|
|
20
|
+
`--a-code:${mix(6)};--a-on-accent:var(--a-ground)}`,
|
|
21
|
+
`[data-artifact-theme="dark"]{${vars(dark, 'dark')}}`,
|
|
22
|
+
`[data-artifact-theme="light"]{${vars(light, 'light')}}`,
|
|
23
|
+
`[data-artifact-theme="system"]{${vars(dark, 'dark')}}`,
|
|
24
|
+
`@media (prefers-color-scheme: light){[data-artifact-theme="system"]{${vars(light, 'light')}}}`,
|
|
25
|
+
].join('\n');
|
|
26
|
+
}
|
|
27
|
+
/** The pack as the page renders it: every colour is the variable the mode sets. */
|
|
28
|
+
export function themedPack(pack) {
|
|
29
|
+
return { ...pack, ground: 'var(--a-ground)', ink: 'var(--a-ink)', accent: 'var(--a-accent)' };
|
|
30
|
+
}
|
package/lib/brand/wrapper.d.ts
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import type { ReactNode } from 'react';
|
|
2
2
|
import type { BrandPack } from './pack.js';
|
|
3
|
-
|
|
4
|
-
|
|
3
|
+
import { type ThemeMode } from './theme.js';
|
|
4
|
+
/** The page backdrop: the pack's own Wrapper when it has one, else its ground (and its animated
|
|
5
|
+
* backdrop, when it declares one). Either way the colour variables for the mode are set here,
|
|
6
|
+
* so everything inside paints with them. `mode` is the page's override of the pack's. */
|
|
7
|
+
export declare function BrandGround({ pack, mode, children }: {
|
|
5
8
|
pack: BrandPack;
|
|
9
|
+
mode?: ThemeMode;
|
|
6
10
|
children: ReactNode;
|
|
7
11
|
}): import("react").JSX.Element;
|
|
8
12
|
/** The mark above the kicker: the pack's own, else a small dove. */
|
package/lib/brand/wrapper.js
CHANGED
|
@@ -1,9 +1,19 @@
|
|
|
1
|
-
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { Bubbles } from './backdrop.js';
|
|
3
|
+
import { resolveMode, themeCss } from './theme.js';
|
|
4
|
+
/** The page backdrop: the pack's own Wrapper when it has one, else its ground (and its animated
|
|
5
|
+
* backdrop, when it declares one). Either way the colour variables for the mode are set here,
|
|
6
|
+
* so everything inside paints with them. `mode` is the page's override of the pack's. */
|
|
7
|
+
export function BrandGround({ pack, mode, children }) {
|
|
8
|
+
const m = resolveMode(pack, mode);
|
|
9
|
+
const css = _jsx("style", { dangerouslySetInnerHTML: { __html: themeCss(pack) } });
|
|
10
|
+
if (pack.Wrapper) {
|
|
11
|
+
// display:contents keeps the wrapper's own layout; custom properties still inherit through it.
|
|
12
|
+
return (_jsxs("div", { "data-artifact-theme": m, style: { display: 'contents' }, children: [css, _jsx(pack.Wrapper, { children: children })] }));
|
|
13
|
+
}
|
|
14
|
+
return (_jsxs("div", { "data-brand-ground": pack.id, "data-artifact-theme": m, className: "min-h-screen",
|
|
15
|
+
// isolation puts the fixed backdrop (z-index -1) above this ground and below the page.
|
|
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] }));
|
|
7
17
|
}
|
|
8
18
|
/** The mark above the kicker: the pack's own, else a small dove. */
|
|
9
19
|
export function BrandMark({ pack, className }) {
|
|
@@ -152,14 +152,14 @@ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
|
|
|
152
152
|
};
|
|
153
153
|
return (_jsxs(_Fragment, { children: [_jsx("style", { children: `
|
|
154
154
|
.artifact-word { border-radius: 3px; transition: background-color 120ms; }
|
|
155
|
-
.artifact-word:hover { background: rgba(255,255,255,0.08); cursor: pointer; }
|
|
156
|
-
.artifact-word-lit { background: ${accent}
|
|
157
|
-
` }), _jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => setPlaying(true), onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx("div", { className: "fixed inset-x-0 bottom-0 z-40 border-t border-
|
|
155
|
+
.artifact-word:hover { background: var(--a-surface-strong, rgba(255,255,255,0.08)); cursor: pointer; }
|
|
156
|
+
.artifact-word-lit { background: color-mix(in srgb, ${accent} 35%, transparent); color: var(--a-strong, #fff); }
|
|
157
|
+
` }), _jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => setPlaying(true), onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx("div", { className: "fixed inset-x-0 bottom-0 z-40 border-t border-[color:var(--a-line)] backdrop-blur", style: { background: `color-mix(in srgb, ${ground} 90%, transparent)` }, children: _jsxs("div", { className: "mx-auto flex max-w-2xl items-center gap-4 px-6 py-3", children: [_jsx("button", { type: "button", onClick: toggle, "aria-label": playing ? 'Pause narration' : 'Play narration', className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-full", style: { background: accent, color: ground }, children: playing ? (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("rect", { x: "6", y: "5", width: "4", height: "14" }), _jsx("rect", { x: "14", y: "5", width: "4", height: "14" })] })) : (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M8 5v14l11-7z" }) })) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: narrator }), _jsx("input", { type: "range", min: 0, max: dur || 0, step: 0.1, value: Math.min(t, dur || 0), onChange: (e) => {
|
|
158
158
|
if (audioRef.current)
|
|
159
159
|
audioRef.current.currentTime = Number(e.target.value);
|
|
160
|
-
}, "aria-label": "Seek", className: "mt-1 w-full", style: { accentColor: accent } })] }), _jsxs("div", { className: "w-16 shrink-0 text-right text-xs tabular-nums text-
|
|
160
|
+
}, "aria-label": "Seek", className: "mt-1 w-full", style: { accentColor: accent } })] }), _jsxs("div", { className: "w-16 shrink-0 text-right text-xs tabular-nums text-[color:var(--a-muted)]", children: [fmt(t), " / ", fmt(dur)] }), _jsxs("button", { type: "button", onClick: () => {
|
|
161
161
|
const next = rate >= 1.5 ? 1 : rate + 0.25;
|
|
162
162
|
if (audioRef.current)
|
|
163
163
|
audioRef.current.playbackRate = next;
|
|
164
|
-
}, className: "shrink-0 rounded border border-
|
|
164
|
+
}, className: "shrink-0 rounded border border-[color:var(--a-line-strong)] px-2 py-1 text-xs text-[color:var(--a-body)]", "aria-label": "Playback speed", children: [rate, "x"] })] }) }), _jsx("div", { className: "h-20", "aria-hidden": true })] }));
|
|
165
165
|
}
|
|
@@ -121,5 +121,5 @@ export function ReaderWatch({ artifactId, endpoint, accent }) {
|
|
|
121
121
|
window.removeEventListener(ev, active);
|
|
122
122
|
};
|
|
123
123
|
}, [artifactId, endpoint]);
|
|
124
|
-
return notice ? (_jsx("div", { role: "status", "data-nospeak": true, className: "fixed inset-x-0 bottom-6 z-50 mx-auto w-fit max-w-[90vw] rounded-lg px-4 py-3 text-sm shadow-lg", style: { background: '#111', color: '#fff', border: `1px solid ${accent}` }, children: notice })) : null;
|
|
124
|
+
return notice ? (_jsx("div", { role: "status", "data-nospeak": true, className: "fixed inset-x-0 bottom-6 z-50 mx-auto w-fit max-w-[90vw] rounded-lg px-4 py-3 text-sm shadow-lg", style: { background: 'var(--a-ground, #111)', color: 'var(--a-ink, #fff)', border: `1px solid ${accent}` }, children: notice })) : null;
|
|
125
125
|
}
|
|
@@ -102,6 +102,18 @@ export declare function createArtifactRoutes(config: ArtifactRoutesConfig): {
|
|
|
102
102
|
name: string;
|
|
103
103
|
url: string;
|
|
104
104
|
}>>;
|
|
105
|
+
UPLOAD: (request: NextRequest, { params }: {
|
|
106
|
+
params: Promise<{
|
|
107
|
+
id: string;
|
|
108
|
+
name: string;
|
|
109
|
+
}>;
|
|
110
|
+
}) => Promise<NextResponse<{
|
|
111
|
+
error: string;
|
|
112
|
+
}> | NextResponse<{
|
|
113
|
+
id: string;
|
|
114
|
+
name: string;
|
|
115
|
+
url: string;
|
|
116
|
+
}>>;
|
|
105
117
|
SHARE_IMAGE: (_request: NextRequest, { params }: Params) => Promise<Response>;
|
|
106
118
|
ENTER: (request: NextRequest) => Promise<NextResponse<unknown>>;
|
|
107
119
|
LEAVE: (request: NextRequest) => Promise<NextResponse<unknown>>;
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -9,8 +9,10 @@ import { ArtifactMarkdown } from '../artifacts/render.js';
|
|
|
9
9
|
import { ArtifactDoor } from '../artifacts/door.js';
|
|
10
10
|
import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
|
|
11
11
|
import { shapeChanges } from '../artifacts/state.js';
|
|
12
|
-
import { ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
|
|
12
|
+
import { ASSET_DIGEST, ASSET_NAME, contentTypeFor } from '../artifacts/assets.js';
|
|
13
13
|
import { BrandGround } from '../brand/wrapper.js';
|
|
14
|
+
import { themedPack } from '../brand/theme.js';
|
|
15
|
+
import { showToc, tocOf, TocInline, TocRail } from '../artifacts/toc.js';
|
|
14
16
|
import { renderShareCard } from '../brand/share-card.js';
|
|
15
17
|
import { ArtifactReader } from '../reader/artifact-reader.js';
|
|
16
18
|
import { ReaderWatch } from '../reader/reader-watch.js';
|
|
@@ -26,7 +28,11 @@ async function defaultReadCookie(name) {
|
|
|
26
28
|
return (await cookies()).get(name)?.value;
|
|
27
29
|
}
|
|
28
30
|
export function createArtifactRoutes(config) {
|
|
29
|
-
const { store,
|
|
31
|
+
const { store, siteUrl } = config;
|
|
32
|
+
// The pack as configured draws the share card; the page paints with its theme variables, so a
|
|
33
|
+
// page follows its light, dark or system mode (brand/theme.ts).
|
|
34
|
+
const pack = config.brand;
|
|
35
|
+
const brand = themedPack(pack);
|
|
30
36
|
const prefix = config.pagePrefix ?? '/';
|
|
31
37
|
const pageUrl = (id) => `${siteUrl}${prefix}${id}`;
|
|
32
38
|
const pagePath = (id) => `${prefix}${id}`;
|
|
@@ -98,22 +104,10 @@ export function createArtifactRoutes(config) {
|
|
|
98
104
|
];
|
|
99
105
|
const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
|
|
100
106
|
if (!open) {
|
|
101
|
-
return (_jsxs(BrandGround, { pack:
|
|
107
|
+
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("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 })] }));
|
|
102
108
|
}
|
|
103
109
|
void store.bumpViews(id);
|
|
104
|
-
|
|
105
|
-
if (a.narration && a.timings) {
|
|
106
|
-
try {
|
|
107
|
-
const r = await fetch(a.timings, { next: { revalidate: 3600 } });
|
|
108
|
-
if (r.ok)
|
|
109
|
-
words = (await r.json()).words ?? [];
|
|
110
|
-
}
|
|
111
|
-
catch {
|
|
112
|
-
words = [];
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
116
|
-
return (_jsxs(BrandGround, { pack: brand, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: "mx-auto max-w-2xl px-6 pt-24 pb-8 text-center sm:pt-28", children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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-white/10" }) })) : null, _jsx("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: _jsx(ArtifactMarkdown, { markdown: a.markdown, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined }) })] }), 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] }));
|
|
110
|
+
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [remember ? _jsx("script", { dangerouslySetInnerHTML: { __html: remember } }) : null, await Body(a, { top: true })] }));
|
|
117
111
|
}
|
|
118
112
|
/** A page with `access:`. The body is rendered only after the reader is known and allowed;
|
|
119
113
|
* everyone else gets the title, the summary, and a door. */
|
|
@@ -126,17 +120,17 @@ export function createArtifactRoutes(config) {
|
|
|
126
120
|
if (!d.open) {
|
|
127
121
|
if (d.why === 'not-allowed' && config.readers)
|
|
128
122
|
void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
|
|
129
|
-
return (_jsxs(BrandGround, { pack:
|
|
123
|
+
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
|
|
130
124
|
? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: signOutUrl(id) })
|
|
131
125
|
: _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, pageUrl(id)) : undefined })] }));
|
|
132
126
|
}
|
|
133
127
|
const r = reader;
|
|
134
128
|
// The agreement comes before the body, every reader, once per page. No agreement, no body.
|
|
135
129
|
if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
|
|
136
|
-
return (_jsxs(BrandGround, { pack:
|
|
130
|
+
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: signOutUrl(id) })] }));
|
|
137
131
|
}
|
|
138
132
|
void store.bumpViews(id);
|
|
139
|
-
return (_jsxs(BrandGround, { pack:
|
|
133
|
+
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [_jsx("style", { dangerouslySetInnerHTML: { __html: NO_PRINT_CSS } }), _jsx(Watermark, { email: r.email }), _jsx("div", { className: "px-6 pt-20 sm:pt-24", children: _jsx(ConfidentialBanner, { name: firstName(r, allow), email: r.email, reason: d.reason, owner: owner, brand: brand, signOutUrl: signOutUrl(id) }) }), await Body(a, { top: false }), _jsx(ReaderWatch, { artifactId: id, endpoint: "/api/reader/track", accent: brand.accent })] }));
|
|
140
134
|
}
|
|
141
135
|
/** The page itself, shared by open and gated pages. */
|
|
142
136
|
async function Body(a, { top }) {
|
|
@@ -152,7 +146,8 @@ export function createArtifactRoutes(config) {
|
|
|
152
146
|
}
|
|
153
147
|
}
|
|
154
148
|
const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
155
|
-
|
|
149
|
+
const toc = showToc(a.markdown, a.toc) ? tocOf(a.markdown) : [];
|
|
150
|
+
return (_jsxs(_Fragment, { children: [toc.length ? _jsx(TocRail, { items: toc }) : null, _jsxs("div", { id: "artifact-narration-root", children: [_jsxs("div", { className: `mx-auto max-w-2xl px-6 ${top ? 'pt-24 sm:pt-28' : 'pt-12'} pb-8 text-center`, children: [_jsx("p", { "data-nospeak": true, className: "mb-4 text-[11px] font-medium uppercase tracking-[0.3em]", style: { color: brand.accent }, children: brand.kicker }), _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 }), _jsxs("p", { "data-nospeak": true, className: "mt-4 text-xs opacity-50", children: ["Updated ", when] })] }), 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, _jsxs("article", { className: "mx-auto max-w-2xl px-6 pb-24", children: [toc.length ? _jsx(TocInline, { items: toc }) : null, _jsx(ArtifactMarkdown, { markdown: a.markdown, notes: config.state && a.state ? { artifactId: a.id, accent: brand.accent } : undefined })] })] }), 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] }));
|
|
156
151
|
}
|
|
157
152
|
const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
158
153
|
/** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
|
|
@@ -363,6 +358,40 @@ export function createArtifactRoutes(config) {
|
|
|
363
358
|
const url = await config.assets.put(id, name, bytes, type);
|
|
364
359
|
return NextResponse.json({ id, name, url }, { status: 201 });
|
|
365
360
|
}
|
|
361
|
+
/**
|
|
362
|
+
* POST /api/artifacts/<id>/uploads/<name> with `{ digest }`: a signed URL to PUT a big file to
|
|
363
|
+
* directly, because a route handler cannot take one (Vercel caps a body at 4.5 MB). Then the
|
|
364
|
+
* same call with `{ digest, done: true }` makes it readable and returns its URL; 409 when the
|
|
365
|
+
* bytes never arrived. 501 on a host whose assets cannot sign, so the publisher falls back.
|
|
366
|
+
*/
|
|
367
|
+
async function UPLOAD(request, { params }) {
|
|
368
|
+
if (!isPublishAuthed(request, config.publishKey()))
|
|
369
|
+
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
|
370
|
+
const assets = config.assets;
|
|
371
|
+
if (!assets?.signUpload || !assets.finishUpload)
|
|
372
|
+
return NextResponse.json({ error: 'this host does not take direct uploads' }, { status: 501 });
|
|
373
|
+
const { id, name } = await params;
|
|
374
|
+
if (!ARTIFACT_ID.test(id))
|
|
375
|
+
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
376
|
+
if (!(await store.get(id)))
|
|
377
|
+
return NextResponse.json({ error: `no artifact with id ${id}` }, { status: 404 });
|
|
378
|
+
if (!ASSET_NAME.test(name))
|
|
379
|
+
return NextResponse.json({ error: 'asset name must be one path segment: letters, digits, dot, dash, underscore' }, { status: 400 });
|
|
380
|
+
const type = contentTypeFor(name);
|
|
381
|
+
if (!type)
|
|
382
|
+
return NextResponse.json({ error: 'asset type not allowed; use webp, png, jpg, gif, mp3 or json' }, { status: 415 });
|
|
383
|
+
const body = (await request.json().catch(() => ({})));
|
|
384
|
+
const digest = typeof body.digest === 'string' ? body.digest : '';
|
|
385
|
+
if (!ASSET_DIGEST.test(digest))
|
|
386
|
+
return NextResponse.json({ error: 'digest must be the first 8 hex of the sha256 of the bytes' }, { status: 400 });
|
|
387
|
+
if (body.done === true) {
|
|
388
|
+
const url = await assets.finishUpload(id, name, digest);
|
|
389
|
+
if (!url)
|
|
390
|
+
return NextResponse.json({ error: 'nothing was uploaded to that url' }, { status: 409 });
|
|
391
|
+
return NextResponse.json({ id, name, url }, { status: 201 });
|
|
392
|
+
}
|
|
393
|
+
return NextResponse.json({ id, name, ...(await assets.signUpload(id, name, digest, type)) }, { status: 201 });
|
|
394
|
+
}
|
|
366
395
|
/** GET /<id>/share.png: the page's title card. 404 when the pack draws none, so a
|
|
367
396
|
* tenant on the static default never serves a half-styled card. */
|
|
368
397
|
async function SHARE_IMAGE(_request, { params }) {
|
|
@@ -372,7 +401,7 @@ export function createArtifactRoutes(config) {
|
|
|
372
401
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
373
402
|
if (!a)
|
|
374
403
|
return new NextResponse(`no artifact with id ${id}`, { status: 404 });
|
|
375
|
-
return renderShareCard(
|
|
404
|
+
return renderShareCard(pack, a.title);
|
|
376
405
|
}
|
|
377
406
|
async function DELETE(request, { params }) {
|
|
378
407
|
if (!isPublishAuthed(request, config.publishKey()))
|
|
@@ -384,5 +413,5 @@ export function createArtifactRoutes(config) {
|
|
|
384
413
|
revalidatePath(pagePath(id));
|
|
385
414
|
return NextResponse.json({ deleted: true });
|
|
386
415
|
}
|
|
387
|
-
return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
416
|
+
return { Page, generateMetadata, POST, GET, DELETE, PUT_ASSET, UPLOAD, SHARE_IMAGE, ENTER, LEAVE, TRACK, ACK, ACCESS, READS, ...stateRoutes, dynamic: 'force-dynamic', maxDuration: 30 };
|
|
388
417
|
}
|
package/lib/widgets/notes.js
CHANGED
|
@@ -97,7 +97,7 @@ export function NoteToggle({ slug }) {
|
|
|
97
97
|
return (_jsx("button", { type: "button", "data-nospeak": true, "data-note-toggle": slug, "aria-expanded": c.open === slug, onClick: () => c.setOpen(c.open === slug ? null : slug), className: "ml-3 inline-block rounded-full border px-2 py-0.5 align-middle font-sans text-[11px] font-medium uppercase tracking-[0.15em] opacity-70 transition-opacity hover:opacity-100", style: { borderColor: c.accent, color: c.accent }, children: n ? `note · ${n}` : 'note' }));
|
|
98
98
|
}
|
|
99
99
|
function NoteItem({ n, c, under }) {
|
|
100
|
-
return (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: c.accent }, children: [_jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-
|
|
100
|
+
return (_jsxs("li", { className: "m-0 border-l-2 py-1 pl-3", style: { borderColor: c.accent }, children: [_jsx("span", { className: "block whitespace-pre-wrap text-[15px] text-[color:var(--a-strong)]", children: n.note }), _jsxs("span", { className: "block text-xs opacity-60", children: [n.name, under ? _jsxs(_Fragment, { children: [" \u00B7 under \u201C", n.heading, "\u201D"] }) : null, n.mine && n.id ? (_jsx("button", { type: "button", "data-note-remove": n.id, onClick: () => void c.remove(n.id), className: "ml-2 underline opacity-80 hover:opacity-100", children: "remove" })) : null] })] }));
|
|
101
101
|
}
|
|
102
102
|
function NoteForm({ slug, heading, c }) {
|
|
103
103
|
const [text, setText] = useState('');
|
|
@@ -112,7 +112,7 @@ function NoteForm({ slug, heading, c }) {
|
|
|
112
112
|
if (!c.canWrite) {
|
|
113
113
|
return c.signIn ? (_jsx("p", { className: "my-2 text-sm", children: _jsx("a", { href: c.signIn, className: "underline", style: { color: c.accent }, children: "Sign in to leave a note" }) })) : (_jsx("p", { className: "my-2 text-sm opacity-70", children: "Notes here are open to signed-in readers." }));
|
|
114
114
|
}
|
|
115
|
-
return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-
|
|
115
|
+
return (_jsxs("form", { onSubmit: submit, className: "my-2", children: [_jsx("textarea", { value: text, onChange: (e) => setText(e.target.value), maxLength: MAX_NOTE_CHARS, rows: 3, "aria-label": `A note on ${heading}`, placeholder: `A note on “${heading}”`, className: "w-full rounded-lg border border-[color:var(--a-line-strong)] bg-[color:var(--a-surface)] p-3 text-[15px] text-[color:var(--a-strong)]" }), c.error ? _jsx("p", { className: "mt-1 text-xs text-amber-500", children: c.error }) : null, _jsxs("div", { className: "mt-2 flex gap-3", children: [_jsx("button", { type: "submit", disabled: c.busy || !text.trim(), className: "rounded-full px-4 py-1 text-sm font-medium disabled:opacity-40", style: { background: c.accent, color: 'var(--a-on-accent, #111)' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
|
|
116
116
|
}
|
|
117
117
|
/** After each heading: its notes, and the box when its control is open. */
|
|
118
118
|
export function HeadingNotes({ slug, text }) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@supersuit/artifacts",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.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",
|