@supersuit/artifacts 0.3.0 → 0.4.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 +28 -0
- package/README.md +16 -0
- package/lib/artifacts/front-matter.d.ts +5 -0
- package/lib/artifacts/front-matter.js +12 -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.js +15 -20
- package/lib/widgets/notes.js +2 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,34 @@
|
|
|
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.0 (2026-09-26)
|
|
7
|
+
|
|
8
|
+
**Light and dark modes, a table of contents, and an animated ground.**
|
|
9
|
+
|
|
10
|
+
- **Modes.** `BrandPack` gains `light` (a second palette), `mode` (`light`, `dark` or `system`,
|
|
11
|
+
default `dark`) and `backdrop`. A page may override the mode with `theme:` in its front matter.
|
|
12
|
+
Every colour the page draws is now a CSS variable set by `BrandGround` for the mode, so `system`
|
|
13
|
+
follows the reader's device. `freedomDefault` carries a light palette.
|
|
14
|
+
- **Table of contents.** Built from the page's `##` headings: a sticky rail in the left margin at
|
|
15
|
+
`xl` widths, highlighting the section being read, and a closed "Contents" list above the body
|
|
16
|
+
below that. Both are `data-nospeak`. It appears once a page has four sections; `toc: false`
|
|
17
|
+
removes it and `toc: true` draws it from one. Every heading now carries its slug as an `id`,
|
|
18
|
+
with or without the notes widget.
|
|
19
|
+
- **Bubbles.** `backdrop: { kind: 'bubbles' }` draws drifting colour behind the page in plain CSS,
|
|
20
|
+
held still under `prefers-reduced-motion`. A pack with its own `Wrapper` draws its own ground
|
|
21
|
+
and ignores `backdrop`.
|
|
22
|
+
- **Long words wrap.** The body is `break-words`, so a bare URL in prose no longer pushes a phone
|
|
23
|
+
sideways (a 390px screen scrolled 7px on the first page checked).
|
|
24
|
+
- **DETECTOR:** a page's root carries `data-artifact-theme`; an open page with four sections
|
|
25
|
+
carries `data-artifact-toc`. A host whose pages still show zinc-on-black text in a light mode is
|
|
26
|
+
building from a Tailwind `content` list that does not scan this package's `lib/`.
|
|
27
|
+
- **REMEDY:** none required. A pack with no `mode` keeps its dark look, and `theme`/`toc` are new
|
|
28
|
+
optional keys. A host whose publisher strips unknown front matter keys must forward `theme` and
|
|
29
|
+
`toc` for pages to set them.
|
|
30
|
+
- **Tests:** theme CSS per mode and the fallback light palette; the ground, the wrapper-pack path
|
|
31
|
+
and the backdrop; `theme` and `toc` parsed, refused, stored and cleared on republish; heading ids
|
|
32
|
+
without notes; the route's mode and both contents placements.
|
|
33
|
+
|
|
6
34
|
## 0.3.0 (2026-09-25)
|
|
7
35
|
|
|
8
36
|
**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 |
|
|
@@ -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/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
|
}
|
package/lib/routes/artifacts.js
CHANGED
|
@@ -11,6 +11,8 @@ import { isUnlocked, keyHash, unlockCookieName } from '../artifacts/unlock.js';
|
|
|
11
11
|
import { shapeChanges } from '../artifacts/state.js';
|
|
12
12
|
import { 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
|
|
@@ -372,7 +367,7 @@ export function createArtifactRoutes(config) {
|
|
|
372
367
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
373
368
|
if (!a)
|
|
374
369
|
return new NextResponse(`no artifact with id ${id}`, { status: 404 });
|
|
375
|
-
return renderShareCard(
|
|
370
|
+
return renderShareCard(pack, a.title);
|
|
376
371
|
}
|
|
377
372
|
async function DELETE(request, { params }) {
|
|
378
373
|
if (!isPublishAuthed(request, config.publishKey()))
|
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.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",
|