@supersuit/artifacts 0.2.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 +67 -0
- package/README.md +67 -1
- package/lib/artifacts/front-matter.d.ts +5 -0
- package/lib/artifacts/front-matter.js +25 -1
- package/lib/artifacts/index.d.ts +1 -0
- package/lib/artifacts/index.js +1 -0
- package/lib/artifacts/notes-place.d.ts +27 -0
- package/lib/artifacts/notes-place.js +25 -0
- package/lib/artifacts/render.d.ts +7 -1
- package/lib/artifacts/render.js +67 -17
- package/lib/artifacts/state-store.d.ts +3 -0
- package/lib/artifacts/state-store.js +2 -0
- 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/artifacts/widgets.d.ts +38 -0
- package/lib/artifacts/widgets.js +141 -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 +24 -23
- package/lib/routes/state-routes.js +5 -2
- package/lib/widgets/notes.d.ts +21 -0
- package/lib/widgets/notes.js +134 -0
- package/package.json +1 -1
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { StateConfig, Visibility } from './state.js';
|
|
2
|
+
import { type Heading } from './notes-place.js';
|
|
3
|
+
export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes, type Heading, type NoteValue, type PlacedNote } from './notes-place.js';
|
|
4
|
+
export type NotesWidget = {
|
|
5
|
+
line: number;
|
|
6
|
+
visibility?: Exclude<Visibility, 'tally'>;
|
|
7
|
+
};
|
|
8
|
+
/** GitHub's rule: lowercase, drop everything but letters, digits, spaces, dashes and
|
|
9
|
+
* underscores, spaces to dashes. A heading of only punctuation is `section`. */
|
|
10
|
+
export declare function slugify(text: string): string;
|
|
11
|
+
/** Every heading in source order, with the line it starts on and a slug unique on the page
|
|
12
|
+
* (a repeated heading gets `-1`, `-2`). The renderer finds a heading here by its line, so the
|
|
13
|
+
* slug a note stores is exactly the slug the page draws. */
|
|
14
|
+
export declare function headingsOf(markdown: string): Heading[];
|
|
15
|
+
/** Finds the widget fences in a body. `offset` is how many lines of the file sit above the body
|
|
16
|
+
* (the front matter), so every error names the line the author sees in their editor. */
|
|
17
|
+
export declare function scanWidgets(body: string, offset: number): {
|
|
18
|
+
ok: true;
|
|
19
|
+
notes: NotesWidget | null;
|
|
20
|
+
} | {
|
|
21
|
+
ok: false;
|
|
22
|
+
error: string;
|
|
23
|
+
};
|
|
24
|
+
/** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
|
|
25
|
+
* `state:` (creating `state:` with its defaults when the page had none), so the state API
|
|
26
|
+
* takes notes with no change and a republish's shape checks cover the slot. */
|
|
27
|
+
export declare function mergeWidgetState(state: StateConfig | undefined, notes: NotesWidget | null): {
|
|
28
|
+
ok: true;
|
|
29
|
+
state: StateConfig | undefined;
|
|
30
|
+
} | {
|
|
31
|
+
ok: false;
|
|
32
|
+
error: string;
|
|
33
|
+
};
|
|
34
|
+
/** Does this page's body carry a notes block? The state route asks, to check a note's shape. */
|
|
35
|
+
export declare function hasNotesWidget(markdown: string): boolean;
|
|
36
|
+
/** The server's check on a note, on a page with a notes block. The browser checks too; this is
|
|
37
|
+
* the one that counts. */
|
|
38
|
+
export declare function checkNoteValue(v: unknown): string | null;
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Widgets: fenced blocks in a document's markdown that give readers a place to answer, drawn by
|
|
2
|
+
// the shell in the page's brand and written through the state API (state.ts, state-routes.ts).
|
|
3
|
+
//
|
|
4
|
+
// This version draws ONE widget, `notes`: a small note control beside every heading, kept in the
|
|
5
|
+
// `many` slot `notes`. A note stores the heading it was left under, both its slug and its text,
|
|
6
|
+
// so a republish that renames or removes the heading keeps the note and shows it under "notes
|
|
7
|
+
// on earlier versions" instead of losing it or attaching it to the wrong section.
|
|
8
|
+
//
|
|
9
|
+
// Everything here is pure and server-safe: parsing, publish-time validation, the heading slugs
|
|
10
|
+
// the renderer and the stored notes agree on, and where each note is placed. The drawing lives
|
|
11
|
+
// in ../widgets/notes.tsx.
|
|
12
|
+
import { unified } from 'unified';
|
|
13
|
+
import remarkParse from 'remark-parse';
|
|
14
|
+
import remarkGfm from 'remark-gfm';
|
|
15
|
+
import { MAX_NOTE_CHARS, NOTES_SLOT } from './notes-place.js';
|
|
16
|
+
export { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from './notes-place.js';
|
|
17
|
+
/** Widgets the design names and this version does not draw yet. A fence using one is refused at
|
|
18
|
+
* publish, so a page never ships a code block that turns into a live widget on a later update. */
|
|
19
|
+
const NOT_YET = ['poll', 'form', 'checklist'];
|
|
20
|
+
const MAX_HEADING_CHARS = 300;
|
|
21
|
+
const parse = (markdown) => unified().use(remarkParse).use(remarkGfm).parse(markdown);
|
|
22
|
+
function inline(nodes) {
|
|
23
|
+
let out = '';
|
|
24
|
+
for (const n of nodes) {
|
|
25
|
+
if (n.type === 'text' || n.type === 'inlineCode')
|
|
26
|
+
out += n.value;
|
|
27
|
+
else if (n.type === 'break')
|
|
28
|
+
out += ' ';
|
|
29
|
+
else if ('children' in n)
|
|
30
|
+
out += inline(n.children);
|
|
31
|
+
}
|
|
32
|
+
return out;
|
|
33
|
+
}
|
|
34
|
+
function walk(nodes, visit) {
|
|
35
|
+
for (const n of nodes) {
|
|
36
|
+
visit(n);
|
|
37
|
+
if ('children' in n && n.type !== 'heading' && n.type !== 'paragraph')
|
|
38
|
+
walk(n.children, visit);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** GitHub's rule: lowercase, drop everything but letters, digits, spaces, dashes and
|
|
42
|
+
* underscores, spaces to dashes. A heading of only punctuation is `section`. */
|
|
43
|
+
export function slugify(text) {
|
|
44
|
+
const s = text.trim().toLowerCase().replace(/[^\p{L}\p{N}\s_-]/gu, '').replace(/\s/g, '-');
|
|
45
|
+
return s || 'section';
|
|
46
|
+
}
|
|
47
|
+
/** Every heading in source order, with the line it starts on and a slug unique on the page
|
|
48
|
+
* (a repeated heading gets `-1`, `-2`). The renderer finds a heading here by its line, so the
|
|
49
|
+
* slug a note stores is exactly the slug the page draws. */
|
|
50
|
+
export function headingsOf(markdown) {
|
|
51
|
+
const out = [];
|
|
52
|
+
const seen = new Map();
|
|
53
|
+
walk(parse(markdown).children, (n) => {
|
|
54
|
+
if (n.type !== 'heading')
|
|
55
|
+
return;
|
|
56
|
+
const text = inline(n.children).replace(/\s+/g, ' ').trim();
|
|
57
|
+
const base = slugify(text);
|
|
58
|
+
const k = seen.get(base) ?? 0;
|
|
59
|
+
seen.set(base, k + 1);
|
|
60
|
+
out.push({ depth: n.depth, line: n.position?.start.line ?? 0, text, slug: k ? `${base}-${k}` : base });
|
|
61
|
+
});
|
|
62
|
+
return out;
|
|
63
|
+
}
|
|
64
|
+
/** Finds the widget fences in a body. `offset` is how many lines of the file sit above the body
|
|
65
|
+
* (the front matter), so every error names the line the author sees in their editor. */
|
|
66
|
+
export function scanWidgets(body, offset) {
|
|
67
|
+
const fences = [];
|
|
68
|
+
walk(parse(body).children, (n) => { if (n.type === 'code')
|
|
69
|
+
fences.push(n); });
|
|
70
|
+
let notes = null;
|
|
71
|
+
for (const f of fences) {
|
|
72
|
+
const line = (f.position?.start.line ?? 1) + offset;
|
|
73
|
+
if (f.lang && NOT_YET.includes(f.lang))
|
|
74
|
+
return { ok: false, error: `line ${line}: the ${f.lang} widget is not available in this version of the artifacts package` };
|
|
75
|
+
if (f.lang !== NOTES_SLOT)
|
|
76
|
+
continue;
|
|
77
|
+
if (notes)
|
|
78
|
+
return { ok: false, error: `line ${line}: a page takes at most one notes block` };
|
|
79
|
+
if (f.meta?.trim())
|
|
80
|
+
return { ok: false, error: `line ${line}: the notes block takes no name; it always writes to slot "notes"` };
|
|
81
|
+
const w = { line };
|
|
82
|
+
const rows = f.value ? f.value.split('\n') : [];
|
|
83
|
+
for (let i = 0; i < rows.length; i++) {
|
|
84
|
+
const raw = rows[i].replace(/#.*$/, '').trim();
|
|
85
|
+
if (!raw)
|
|
86
|
+
continue;
|
|
87
|
+
const at = line + 1 + i;
|
|
88
|
+
const m = /^([A-Za-z_-]+)\s*:\s*(.*)$/.exec(raw);
|
|
89
|
+
if (!m)
|
|
90
|
+
return { ok: false, error: `line ${at}: the notes block takes lines like "visibility: shared"` };
|
|
91
|
+
if (m[1] !== 'visibility')
|
|
92
|
+
return { ok: false, error: `line ${at}: the notes block has an unknown key: ${m[1]}` };
|
|
93
|
+
if (m[2] !== 'private' && m[2] !== 'shared')
|
|
94
|
+
return { ok: false, error: `line ${at}: notes visibility must be private or shared` };
|
|
95
|
+
w.visibility = m[2];
|
|
96
|
+
}
|
|
97
|
+
notes = w;
|
|
98
|
+
}
|
|
99
|
+
return { ok: true, notes };
|
|
100
|
+
}
|
|
101
|
+
/** A widget declares its own slot. The notes block adds `notes: { shape: many }` to the page's
|
|
102
|
+
* `state:` (creating `state:` with its defaults when the page had none), so the state API
|
|
103
|
+
* takes notes with no change and a republish's shape checks cover the slot. */
|
|
104
|
+
export function mergeWidgetState(state, notes) {
|
|
105
|
+
if (!notes)
|
|
106
|
+
return { ok: true, state };
|
|
107
|
+
const had = state?.slots[NOTES_SLOT];
|
|
108
|
+
if (had && had.shape !== 'many')
|
|
109
|
+
return { ok: false, error: `line ${notes.line}: the notes block writes to slot "notes" as shape many, and state: declares it shape ${had.shape}; rename that slot` };
|
|
110
|
+
if (had?.visibility && notes.visibility && had.visibility !== notes.visibility)
|
|
111
|
+
return { ok: false, error: `line ${notes.line}: the notes block says visibility ${notes.visibility} and state: says ${had.visibility} for slot "notes"; say it once` };
|
|
112
|
+
const slot = had
|
|
113
|
+
? (notes.visibility && !had.visibility ? { ...had, visibility: notes.visibility } : had)
|
|
114
|
+
: { shape: 'many', ...(notes.visibility ? { visibility: notes.visibility } : {}) };
|
|
115
|
+
const base = state ?? { writers: 'signed-in', visibility: 'private', slots: {} };
|
|
116
|
+
if (slot === had)
|
|
117
|
+
return { ok: true, state: base };
|
|
118
|
+
return { ok: true, state: { ...base, slots: { ...base.slots, [NOTES_SLOT]: slot } } };
|
|
119
|
+
}
|
|
120
|
+
/** Does this page's body carry a notes block? The state route asks, to check a note's shape. */
|
|
121
|
+
export function hasNotesWidget(markdown) {
|
|
122
|
+
const r = scanWidgets(markdown, 0);
|
|
123
|
+
return r.ok && r.notes !== null;
|
|
124
|
+
}
|
|
125
|
+
const SLUG = /^[\p{Ll}\p{Lo}\p{Lm}\p{N}_-]{1,120}$/u;
|
|
126
|
+
const isMap = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
127
|
+
/** The server's check on a note, on a page with a notes block. The browser checks too; this is
|
|
128
|
+
* the one that counts. */
|
|
129
|
+
export function checkNoteValue(v) {
|
|
130
|
+
if (!isMap(v) || Object.keys(v).some((k) => !['slug', 'heading', 'note'].includes(k)))
|
|
131
|
+
return 'a note is { slug, heading, note }';
|
|
132
|
+
if (typeof v.slug !== 'string' || !SLUG.test(v.slug))
|
|
133
|
+
return 'a note needs the slug of the heading it is under';
|
|
134
|
+
if (typeof v.heading !== 'string' || v.heading.length > MAX_HEADING_CHARS)
|
|
135
|
+
return 'a note needs the heading it is under';
|
|
136
|
+
if (typeof v.note !== 'string' || !v.note.trim())
|
|
137
|
+
return 'a note needs some text';
|
|
138
|
+
if (v.note.length > MAX_NOTE_CHARS)
|
|
139
|
+
return `a note is at most ${MAX_NOTE_CHARS} characters`;
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
@@ -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}`;
|
|
@@ -88,26 +94,20 @@ export function createArtifactRoutes(config) {
|
|
|
88
94
|
// The same cookie again on the page's state API: a cookie scoped to the page path is never
|
|
89
95
|
// sent to /api/artifacts/<id>/state, so without it a password page could not take answers.
|
|
90
96
|
const unlockLine = (path) => `${unlockCookieName(id)}=${keyHash(id, a.password)}; Path=${path}; Max-Age=31536000; SameSite=Lax; Secure`;
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
97
|
+
// The API copy is set on EVERY open of a page with state:, because the page cannot see it (a
|
|
98
|
+
// cookie scoped to /api/... never reaches the page path): a reader who unlocked the page
|
|
99
|
+
// before its API cookie existed would otherwise be refused every answer until they reopened
|
|
100
|
+
// the ?key= link.
|
|
101
|
+
const paths = !open || !a.password ? [] : [
|
|
102
|
+
...(key !== undefined && cookie !== keyHash(id, a.password) ? [pagePath(id)] : []),
|
|
103
|
+
...(a.state ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
|
|
104
|
+
];
|
|
105
|
+
const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
|
|
94
106
|
if (!open) {
|
|
95
|
-
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 })] }));
|
|
96
108
|
}
|
|
97
109
|
void store.bumpViews(id);
|
|
98
|
-
|
|
99
|
-
if (a.narration && a.timings) {
|
|
100
|
-
try {
|
|
101
|
-
const r = await fetch(a.timings, { next: { revalidate: 3600 } });
|
|
102
|
-
if (r.ok)
|
|
103
|
-
words = (await r.json()).words ?? [];
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
words = [];
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
110
|
-
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 }) })] }), 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 })] }));
|
|
111
111
|
}
|
|
112
112
|
/** A page with `access:`. The body is rendered only after the reader is known and allowed;
|
|
113
113
|
* everyone else gets the title, the summary, and a door. */
|
|
@@ -120,17 +120,17 @@ export function createArtifactRoutes(config) {
|
|
|
120
120
|
if (!d.open) {
|
|
121
121
|
if (d.why === 'not-allowed' && config.readers)
|
|
122
122
|
void config.readers.flag({ artifactId: id, reader: d.reader, kind: 'refused' }).catch(() => { });
|
|
123
|
-
return (_jsxs(BrandGround, { pack:
|
|
123
|
+
return (_jsxs(BrandGround, { pack: pack, mode: a.theme, children: [header, d.why === 'not-allowed'
|
|
124
124
|
? _jsx(NotAllowedDoor, { brand: brand, reader: d.reader, signOutUrl: signOutUrl(id) })
|
|
125
125
|
: _jsx(SignInDoor, { brand: brand, href: signInOrigin ? signInUrl(signInOrigin, pageUrl(id)) : undefined })] }));
|
|
126
126
|
}
|
|
127
127
|
const r = reader;
|
|
128
128
|
// The agreement comes before the body, every reader, once per page. No agreement, no body.
|
|
129
129
|
if (config.readers && !(await config.readers.acknowledged(id, r.email))) {
|
|
130
|
-
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) })] }));
|
|
131
131
|
}
|
|
132
132
|
void store.bumpViews(id);
|
|
133
|
-
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 })] }));
|
|
134
134
|
}
|
|
135
135
|
/** The page itself, shared by open and gated pages. */
|
|
136
136
|
async function Body(a, { top }) {
|
|
@@ -146,7 +146,8 @@ export function createArtifactRoutes(config) {
|
|
|
146
146
|
}
|
|
147
147
|
}
|
|
148
148
|
const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
|
|
149
|
-
|
|
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] }));
|
|
150
151
|
}
|
|
151
152
|
const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
|
|
152
153
|
/** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
|
|
@@ -366,7 +367,7 @@ export function createArtifactRoutes(config) {
|
|
|
366
367
|
const a = ARTIFACT_ID.test(id) ? await store.get(id) : null;
|
|
367
368
|
if (!a)
|
|
368
369
|
return new NextResponse(`no artifact with id ${id}`, { status: 404 });
|
|
369
|
-
return renderShareCard(
|
|
370
|
+
return renderShareCard(pack, a.title);
|
|
370
371
|
}
|
|
371
372
|
async function DELETE(request, { params }) {
|
|
372
373
|
if (!isPublishAuthed(request, config.publishKey()))
|
|
@@ -16,6 +16,7 @@ import { GRANT_COOKIE, decide, firstName, signInUrl, verifyGrant } from '../arti
|
|
|
16
16
|
import { isUnlocked, unlockCookieName } from '../artifacts/unlock.js';
|
|
17
17
|
import { ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, checkValue, effectiveWriters } from '../artifacts/state.js';
|
|
18
18
|
import { readerKeyFor } from '../artifacts/state-store.js';
|
|
19
|
+
import { NOTES_SLOT, checkNoteValue, hasNotesWidget } from '../artifacts/widgets.js';
|
|
19
20
|
import { responsesCsv, responsesOf, stateView } from '../artifacts/state-view.js';
|
|
20
21
|
export const ANON_COOKIE = 'artifact_anon';
|
|
21
22
|
const ANON_ID = /^[A-Za-z0-9]{24}$/;
|
|
@@ -168,13 +169,15 @@ export function createStateRoutes(ctx) {
|
|
|
168
169
|
await ctx.state.remove({ artifactId: id, slot, readerKey: writer.key, entryId: typeof b.entry === 'string' ? b.entry : undefined });
|
|
169
170
|
}
|
|
170
171
|
else {
|
|
171
|
-
const bad = checkValue(b.value);
|
|
172
|
+
const bad = checkValue(b.value) ?? (slot === NOTES_SLOT && hasNotesWidget(a.markdown) ? checkNoteValue(b.value) : null);
|
|
172
173
|
if (bad)
|
|
173
174
|
return json({ error: bad }, 400);
|
|
174
175
|
// The page-wide cap per slot. Counted before the write and not atomically with it, so a
|
|
175
176
|
// burst can overshoot by the writes in flight; it bounds the slot, it is not a quota.
|
|
176
177
|
if ((await ctx.state.countSlot(id, slot)) >= MAX_ENTRIES_PER_SLOT) {
|
|
177
|
-
const replacing = op === 'set' && (
|
|
178
|
+
const replacing = op === 'set' && (ctx.state.hasOne
|
|
179
|
+
? await ctx.state.hasOne(id, slot, writer.key)
|
|
180
|
+
: (await ctx.state.entries(id)).some((e) => e.slot === slot && e.readerKey === writer.key));
|
|
178
181
|
if (!replacing)
|
|
179
182
|
return json({ error: 'this page is not taking more answers here' }, 409);
|
|
180
183
|
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
export declare function NotesProvider({ artifactId, headings, accent, children }: {
|
|
3
|
+
artifactId: string;
|
|
4
|
+
headings: {
|
|
5
|
+
slug: string;
|
|
6
|
+
text: string;
|
|
7
|
+
}[];
|
|
8
|
+
accent?: string;
|
|
9
|
+
children: ReactNode;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
11
|
+
/** The small control inside a heading. */
|
|
12
|
+
export declare function NoteToggle({ slug }: {
|
|
13
|
+
slug: string;
|
|
14
|
+
}): import("react").JSX.Element | null;
|
|
15
|
+
/** After each heading: its notes, and the box when its control is open. */
|
|
16
|
+
export declare function HeadingNotes({ slug, text }: {
|
|
17
|
+
slug: string;
|
|
18
|
+
text: string;
|
|
19
|
+
}): import("react").JSX.Element | null;
|
|
20
|
+
/** Where the ```notes fence sits: a line saying notes are on, and the notes whose heading is gone. */
|
|
21
|
+
export declare function NotesEarlier(): import("react").JSX.Element | null;
|