@supersuit/artifacts 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,45 @@
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.3.0 (2026-09-25)
7
+
8
+ **Notes, the first widget.** A ```` ```notes ```` block in a page puts a small "note" control
9
+ beside every heading, so readers can leave notes on a section and, on a `shared` page, read each
10
+ other's. Poll, form and checklist follow in a later release.
11
+
12
+ - **Fence:** ```` ```notes ```` with an optional `visibility: private | shared` line. At most one
13
+ per page. It declares slot `notes` of shape `many` itself, merged into `state:` at parse time
14
+ (a page with no `state:` gets `writers: signed-in`, `visibility: private`), so the 0.2.0 state
15
+ API takes notes unchanged. Refused at publish with the file's line number: a second block, a
16
+ name, an unknown key, `tally`, a `notes` slot declared `one`, a visibility that disagrees with
17
+ `state:`. `poll`, `form` and `checklist` fences are refused until they ship.
18
+ - **A note is `{ slug, heading, note }`**, checked by the server on a page with a notes block.
19
+ Headings carry their GitHub-style slug as an `id`; the renderer finds each heading by its source
20
+ line, so the slug on the page and the slug a note stores come from one derivation.
21
+ - **A renamed or removed heading keeps its notes**, shown under "Notes on earlier versions" with
22
+ the heading they were left under, never attached to another section.
23
+ - **Narration skips the widget**: the fence is not spoken and everything drawn is `data-nospeak`.
24
+ - **Password pages with `state:`** now set the answer API's unlock cookie on every open, not only
25
+ on `?key=`. Readers who unlocked such a page before 0.2.0 are no longer refused (the item parked
26
+ in 0.2.0).
27
+ - **The replace-at-cap check** reads one document (`StateStore.hasOne`, optional, implemented by
28
+ both shipped stores) instead of every answer on the page.
29
+ - **DETECTOR:** a page with a notes block published to a host with no state store gets `warning:
30
+ this host keeps no answers` and draws no controls. A notes page whose readers see no "note"
31
+ button beside its headings is on 0.2.0 or earlier.
32
+ - **REMEDY (hosts):** take 0.3.0. No new routes: the notes widget uses the 0.2.0 state routes
33
+ (`app/api/artifacts/[id]/state/route.ts`), which a host must already mount. A host with its own
34
+ `StateStore` may add `hasOne`; without it the old full read is used.
35
+ - **Tests:** `widgets.test.ts` (slugs, fence parsing, one per page, reserved widgets, merge,
36
+ note shape, placement), `front-matter.test.ts` (line numbers in the file, the first real page's
37
+ front matter), `render.test.tsx` (controls beside headings, fence never drawn as code),
38
+ `widgets/notes.test.tsx` (jsdom: shared notes under their heading, earlier versions, posting
39
+ appends `{ slug, heading, note }`, sign-in offer, removal, no-store host), `narration.test.ts`,
40
+ `state.test.tsx` (note shape refused, shared read, `hasOne` path), `artifacts.test.tsx` (API
41
+ cookie on every open, page wiring, publish refusal), `state-store.test.ts` (`hasOne`), and the
42
+ packed fixture (a notes page draws its controls, a note posts and reads back, a malformed one is
43
+ refused, the compiled widget keeps `'use client'`).
44
+
6
45
  ## 0.2.0 (2026-09-24)
7
46
 
8
47
  **Reader answers.** A page can now take input from the people reading it. It declares named
package/README.md CHANGED
@@ -25,7 +25,8 @@ Firestore), and optionally `@google-cloud/storage >= 7` for uploaded files.
25
25
  | `createArtifactAssets(bucket, prefix)` | Uploaded images for a page, in a Cloud Storage bucket |
26
26
  | `freedomDefault`, `BrandPack` | The default look and the type any other look implements |
27
27
  | `BrandGround`, `BrandMark` | The pack's page backdrop and mark, for your own pages (a home, a 404) |
28
- | `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing |
28
+ | `parseArtifactSource` | The front-matter contract, as a parser you can call before publishing (widget fences included) |
29
+ | `headingsOf`, `scanWidgets`, `placeNotes` | The notes widget's pure parts: heading slugs, fence validation, where each note shows |
29
30
  | `mintPass`, `verifyPass` | The reader pass, for the sign-in side (see below) |
30
31
  | `ARTIFACT_PUBLIC_PREFIXES` (`/gate`) | Paths to leave open if you mount artifacts inside a gated site |
31
32
 
@@ -285,6 +286,55 @@ On a proxy other than Vercel's, pass `clientIp` to `createArtifactRoutes` (used
285
286
  rate limit): the default reads the first hop of `x-forwarded-for`, which Vercel overwrites with
286
287
  the real client IP but another proxy may only append to.
287
288
 
289
+ ## Widgets in a page
290
+
291
+ A widget is a fenced block in a page's markdown that gives readers a place to answer, drawn by
292
+ the shell in the page's brand and kept through the state API above. Widgets need a host with a
293
+ state store (`state:` in the config); on a host without one they draw nothing, and the publish
294
+ response carries the no-answers warning.
295
+
296
+ This version draws one widget, **notes**. Poll, form and checklist are coming: a fence named
297
+ `poll`, `form` or `checklist` is refused at publish until then, so a page never ships a code
298
+ block that turns into a live widget on a later update.
299
+
300
+ ### Notes
301
+
302
+ ````markdown
303
+ ## Sales
304
+
305
+ The weekly pipeline review.
306
+
307
+ ```notes
308
+ visibility: shared # optional: private | shared
309
+ ```
310
+ ````
311
+
312
+ - **A small "note" control sits beside every heading.** A reader opens it, writes, and saves. The
313
+ note appears under that heading; with `visibility: shared` every reader sees every note with
314
+ the writer's first name ("a reader" for a signed-out one), with `private` each reader sees only
315
+ their own and the publisher sees all through `/responses`.
316
+ - **At most one notes block per page.** Where it sits is where the page shows "Notes on earlier
317
+ versions" (below).
318
+ - **The block declares its own slot**: `notes`, shape `many`. A page needs no `state:` for it;
319
+ without one the page takes `writers: signed-in` and `visibility: private`. A page may declare
320
+ the slot itself (`slots: { notes: { shape: many } }`) and set `writers:` and `visibility:` as
321
+ usual. Refused at publish, naming the line in the file: a second notes block, a name after
322
+ `notes`, any key but `visibility`, `visibility: tally`, a `notes` slot declared `shape: one`,
323
+ and a block visibility that disagrees with one `state:` sets on the `notes` slot.
324
+ - **A note is `{ slug, heading, note }`**: the slug and the text of the heading it was left under,
325
+ and up to 4,000 characters of text. On a page with a notes block the server refuses any other
326
+ shape in the `notes` slot. Slugs follow GitHub's rule (lowercase, punctuation dropped, spaces
327
+ to dashes, a repeated heading `-1`, `-2`), and each heading carries its slug as its `id`, so
328
+ `#sales` links to it.
329
+ - **Notes survive a changed heading.** A note shows under the heading with its slug; failing
330
+ that, under a heading with exactly its text; otherwise under "Notes on earlier versions", with
331
+ the heading it was left under. It never attaches to a different section.
332
+ - **Narration skips it.** The fence is not prose, and everything the widget draws is marked
333
+ `data-nospeak`, so the narrator and the read-along highlighter read the page as before.
334
+ - On a gated page (`access:`) only signed-in readers the page is open to, after the agreement,
335
+ can leave or read notes; the banner, watermark and print refusal are unchanged. On a public
336
+ page with `writers: signed-in`, the control offers sign-in through `signInOrigin`.
337
+
288
338
  ## Brand packs
289
339
 
290
340
  A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
@@ -3,6 +3,7 @@
3
3
  import matter from 'gray-matter';
4
4
  import { ACCESS_LEVELS } from './reader.js';
5
5
  import { parseStateConfig } from './state.js';
6
+ import { mergeWidgetState, scanWidgets } from './widgets.js';
6
7
  const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state']);
7
8
  export function parseArtifactSource(text) {
8
9
  const { data, content } = matter(text);
@@ -40,5 +41,17 @@ export function parseArtifactSource(text) {
40
41
  return { ok: false, error: s.error };
41
42
  meta.state = s.state;
42
43
  }
44
+ // Widgets declare their own slots, so a notes block is merged into state: here, before the
45
+ // page is stored, and the state API and the republish shape checks see it like any slot.
46
+ // Errors name the line in the FILE: the body's lines are counted after the front matter's.
47
+ const offset = text.endsWith(content) ? (text.slice(0, text.length - content.length).match(/\n/g) ?? []).length : 0;
48
+ const w = scanWidgets(content, offset);
49
+ if (!w.ok)
50
+ return { ok: false, error: w.error };
51
+ const merged = mergeWidgetState(meta.state, w.notes);
52
+ if (!merged.ok)
53
+ return { ok: false, error: merged.error };
54
+ if (merged.state)
55
+ meta.state = merged.state;
43
56
  return { ok: true, meta, body: content };
44
57
  }
@@ -10,3 +10,4 @@ export { createReadersStore, summarize, FLAG_KINDS, type ReadersStore, type Flag
10
10
  export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT, type StateConfig, type SlotDef, type Shape, type Visibility, type Writers } from './state.js';
11
11
  export { createStateStore, createMemoryStateStore, readerKeyFor, type StateStore, type StateEntry, type Writer } from './state-store.js';
12
12
  export { stateView, responsesOf, responsesCsv, type SlotView, type Tally, type SharedEntry, type Response as StateResponse } from './state-view.js';
13
+ export { slugify, headingsOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS, type Heading, type NotesWidget, type NoteValue, type PlacedNote } from './widgets.js';
@@ -10,3 +10,4 @@ export { createReadersStore, summarize, FLAG_KINDS } from './readers-store.js';
10
10
  export { parseStateConfig, effectiveWriters, slotVisibility, checkValue, shapeChanges, SLOT_NAME, MAX_VALUE_BYTES, MAX_MANY_PER_READER, ANON_WRITES_PER_MINUTE, MAX_ENTRIES_PER_SLOT, SHARED_LIMIT } from './state.js';
11
11
  export { createStateStore, createMemoryStateStore, readerKeyFor } from './state-store.js';
12
12
  export { stateView, responsesOf, responsesCsv } from './state-view.js';
13
+ export { slugify, headingsOf, scanWidgets, mergeWidgetState, hasNotesWidget, checkNoteValue, placeNotes, NOTES_SLOT, MAX_NOTE_CHARS } from './widgets.js';
@@ -0,0 +1,27 @@
1
+ export declare const NOTES_SLOT = "notes";
2
+ export declare const MAX_NOTE_CHARS = 4000;
3
+ export type Heading = {
4
+ depth: number;
5
+ line: number;
6
+ text: string;
7
+ slug: string;
8
+ };
9
+ export type NoteValue = {
10
+ slug: string;
11
+ heading: string;
12
+ note: string;
13
+ };
14
+ /** A note as a reader's page holds it: the stored value plus who and when. */
15
+ export type PlacedNote = NoteValue & {
16
+ id: string;
17
+ name: string;
18
+ at: string;
19
+ mine: boolean;
20
+ };
21
+ /** Where each note shows: under the heading with its slug, else under a heading with its exact
22
+ * text (a repeated heading renumbered), else under "notes on earlier versions". Never under a
23
+ * heading that merely looks close: a note on the wrong section is worse than one set aside. */
24
+ export declare function placeNotes(headings: Pick<Heading, 'slug' | 'text'>[], notes: PlacedNote[]): {
25
+ bySlug: Record<string, PlacedNote[]>;
26
+ earlier: PlacedNote[];
27
+ };
@@ -0,0 +1,25 @@
1
+ // Where each note on a page shows, and the few names both sides share. Kept free of the
2
+ // markdown parser on purpose: the notes widget runs in the reader's browser and imports this,
3
+ // and the parser has no business in that bundle.
4
+ export const NOTES_SLOT = 'notes';
5
+ export const MAX_NOTE_CHARS = 4000;
6
+ /** Where each note shows: under the heading with its slug, else under a heading with its exact
7
+ * text (a repeated heading renumbered), else under "notes on earlier versions". Never under a
8
+ * heading that merely looks close: a note on the wrong section is worse than one set aside. */
9
+ export function placeNotes(headings, notes) {
10
+ const bySlug = {};
11
+ const earlier = [];
12
+ const slugs = new Set(headings.map((h) => h.slug));
13
+ const byText = new Map();
14
+ for (const h of headings)
15
+ if (!byText.has(h.text))
16
+ byText.set(h.text, h.slug);
17
+ for (const n of notes) {
18
+ const at = slugs.has(n.slug) ? n.slug : byText.get(n.heading);
19
+ if (at)
20
+ (bySlug[at] ??= []).push(n);
21
+ else
22
+ earlier.push(n);
23
+ }
24
+ return { bySlug, earlier };
25
+ }
@@ -1,4 +1,10 @@
1
1
  export declare function isReachableHref(href: unknown): href is string;
2
- export declare function ArtifactMarkdown({ markdown }: {
2
+ /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
3
+ * page carries a ```notes block. */
4
+ export declare function ArtifactMarkdown({ markdown, notes }: {
3
5
  markdown: string;
6
+ notes?: {
7
+ artifactId: string;
8
+ accent?: string;
9
+ };
4
10
  }): import("react").JSX.Element;
@@ -1,6 +1,8 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import ReactMarkdown from 'react-markdown';
3
3
  import remarkGfm from 'remark-gfm';
4
+ import { hasNotesWidget, headingsOf } from './widgets.js';
5
+ import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
4
6
  const GOLD = '#C2A15C';
5
7
  function hostOf(url) {
6
8
  try {
@@ -61,6 +63,10 @@ const components = {
61
63
  const lang = /language-(\w+)/.exec(className ?? '')?.[1];
62
64
  if (lang === 'links')
63
65
  return _jsx(LinkCards, { source: textOf(children) });
66
+ // A notes fence is the widget's place on the page. Without notes enabled (the host keeps no
67
+ // answers) it draws nothing: its settings are not prose and never shown as code.
68
+ if (lang === 'notes')
69
+ return null;
64
70
  if (!className) {
65
71
  return _jsx("code", { className: "rounded bg-white/10 px-1.5 py-0.5 text-[0.9em] text-zinc-100", children: children });
66
72
  }
@@ -70,7 +76,7 @@ const components = {
70
76
  // A links fence renders its own block; do not wrap it in <pre>.
71
77
  const inner = Array.isArray(children) ? children[0] : children;
72
78
  const cls = inner?.props?.className ?? '';
73
- if (/language-links/.test(cls))
79
+ if (/language-(links|notes)/.test(cls))
74
80
  return _jsx(_Fragment, { children: children });
75
81
  return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-white/10 bg-black/40 p-4 text-sm text-zinc-100", children: children }));
76
82
  },
@@ -85,6 +91,38 @@ const components = {
85
91
  return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: 'rgba(255,255,255,0.03)' }, 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-zinc-100", children: body })] }));
86
92
  },
87
93
  };
88
- export function ArtifactMarkdown({ markdown }) {
89
- return (_jsx("div", { className: "text-[17px] text-zinc-200", children: _jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: components, children: markdown }) }));
94
+ const HEADING_CLASS = {
95
+ 1: 'mt-10 mb-4 font-serif text-3xl text-zinc-50',
96
+ 2: 'mt-10 mb-3 font-serif text-2xl text-zinc-50',
97
+ 3: 'mt-8 mb-2 text-lg font-semibold text-zinc-100',
98
+ 4: 'mt-6 mb-2 font-semibold text-zinc-100',
99
+ 5: 'mt-6 mb-2 font-semibold text-zinc-100',
100
+ 6: 'mt-6 mb-2 font-semibold text-zinc-100',
101
+ };
102
+ /** Components for a page with a notes block: every heading gets its slug as an id, a note
103
+ * control, and its notes after it. The heading is found by its source line, so the slug is the
104
+ * one headingsOf derived, the same one a note stores. */
105
+ function notesComponents(headings) {
106
+ const byLine = new Map(headings.map((h) => [h.line, h]));
107
+ const heading = (depth) => function NotedHeading({ node, children }) {
108
+ const Tag = `h${depth}`;
109
+ const h = byLine.get(node?.position?.start.line ?? -1);
110
+ if (!h)
111
+ return _jsx(Tag, { className: HEADING_CLASS[depth], children: children });
112
+ return (_jsxs(_Fragment, { children: [_jsxs(Tag, { id: h.slug, className: HEADING_CLASS[depth], children: [children, _jsx(NoteToggle, { slug: h.slug })] }), _jsx(HeadingNotes, { slug: h.slug, text: h.text })] }));
113
+ };
114
+ const Code = components.code;
115
+ return {
116
+ ...components,
117
+ h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6),
118
+ code: (props) => (/language-notes/.test(props.className ?? '') ? _jsx(NotesEarlier, {}) : _jsx(Code, { ...props })),
119
+ };
120
+ }
121
+ /** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
122
+ * page carries a ```notes block. */
123
+ export function ArtifactMarkdown({ markdown, notes }) {
124
+ const on = !!notes && hasNotesWidget(markdown);
125
+ const headings = on ? headingsOf(markdown) : [];
126
+ const body = (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: on ? notesComponents(headings) : components, children: markdown }));
127
+ return (_jsx("div", { className: "text-[17px] text-zinc-200", children: on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body }));
90
128
  }
@@ -49,6 +49,9 @@ export interface StateStore {
49
49
  countAnonWrite(artifactId: string, ipHash: string, minute: number): Promise<number>;
50
50
  /** Every reader's entries in one slot on one page, for MAX_ENTRIES_PER_SLOT. */
51
51
  countSlot(artifactId: string, slot: string): Promise<number>;
52
+ /** Does this reader already hold a `one` answer in this slot? One document read. Optional so a
53
+ * host's own store keeps working; without it the route falls back to loading the page. */
54
+ hasOne?(artifactId: string, slot: string, readerKey: string): Promise<boolean>;
52
55
  }
53
56
  export declare const readerKeyFor: {
54
57
  signedIn: (uid: string) => string;
@@ -89,6 +89,7 @@ export function createStateStore(db, base) {
89
89
  async countSlot(artifactId, slot) {
90
90
  return (await col().where('artifactId', '==', artifactId).where('slot', '==', slot).count().get()).data().count;
91
91
  },
92
+ hasOne: async (artifactId, slot, readerKey) => (await col().doc(oneId(artifactId, slot, readerKey)).get()).exists,
92
93
  };
93
94
  return store;
94
95
  }
@@ -147,5 +148,6 @@ export function createMemoryStateStore() {
147
148
  return n;
148
149
  },
149
150
  countSlot: async (artifactId, slot) => of(artifactId).filter((e) => e.slot === slot).length,
151
+ hasOne: async (artifactId, slot, readerKey) => docs.has(oneId(artifactId, slot, readerKey)),
150
152
  };
151
153
  }
@@ -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
+ }
@@ -88,9 +88,15 @@ export function createArtifactRoutes(config) {
88
88
  // The same cookie again on the page's state API: a cookie scoped to the page path is never
89
89
  // sent to /api/artifacts/<id>/state, so without it a password page could not take answers.
90
90
  const unlockLine = (path) => `${unlockCookieName(id)}=${keyHash(id, a.password)}; Path=${path}; Max-Age=31536000; SameSite=Lax; Secure`;
91
- const remember = open && a.password && key !== undefined && cookie !== keyHash(id, a.password)
92
- ? [pagePath(id), `/api/artifacts/${id}`].map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';')
93
- : null;
91
+ // The API copy is set on EVERY open of a page with state:, because the page cannot see it (a
92
+ // cookie scoped to /api/... never reaches the page path): a reader who unlocked the page
93
+ // before its API cookie existed would otherwise be refused every answer until they reopened
94
+ // the ?key= link.
95
+ const paths = !open || !a.password ? [] : [
96
+ ...(key !== undefined && cookie !== keyHash(id, a.password) ? [pagePath(id)] : []),
97
+ ...(a.state ? [`/api/artifacts/${id}`] : key !== undefined && cookie !== keyHash(id, a.password) ? [`/api/artifacts/${id}`] : []),
98
+ ];
99
+ const remember = paths.length ? paths.map((p) => `document.cookie=${JSON.stringify(unlockLine(p))}`).join(';') : null;
94
100
  if (!open) {
95
101
  return (_jsxs(BrandGround, { pack: brand, 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
102
  }
@@ -107,7 +113,7 @@ export function createArtifactRoutes(config) {
107
113
  }
108
114
  }
109
115
  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] }));
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] }));
111
117
  }
112
118
  /** A page with `access:`. The body is rendered only after the reader is known and allowed;
113
119
  * everyone else gets the title, the summary, and a door. */
@@ -146,7 +152,7 @@ export function createArtifactRoutes(config) {
146
152
  }
147
153
  }
148
154
  const when = new Date(a.updatedAt).toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
149
- return (_jsxs(_Fragment, { children: [_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-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] }));
155
+ return (_jsxs(_Fragment, { children: [_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-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] }));
150
156
  }
151
157
  const cookieLine = (value, maxAge) => `${GRANT_COOKIE}=${value}; Path=/; Max-Age=${maxAge}; HttpOnly; Secure; SameSite=Lax`;
152
158
  /** GET /api/reader/enter?pass=<pass>&to=/<id>: where the sign-in authority sends a signed-in
@@ -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' && (await ctx.state.entries(id)).some((e) => e.slot === slot && e.readerKey === writer.key);
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;
@@ -0,0 +1,134 @@
1
+ 'use client';
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ // The notes widget in the reader's browser. One provider per page reads the page's state once
4
+ // (GET /api/artifacts/<id>/state), places every note under the heading it was left under, and
5
+ // sets aside the ones whose heading is gone. A small "note" control sits in each heading; the
6
+ // panel after the heading lists its notes and, when open, takes a new one.
7
+ //
8
+ // Everything drawn here carries data-nospeak: narration reads the prose, never the notes, and
9
+ // the read-along highlighter skips the same nodes. Notes are shown as text, never as markup.
10
+ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
11
+ import { MAX_NOTE_CHARS, NOTES_SLOT, placeNotes } from '../artifacts/notes-place.js';
12
+ const NotesContext = createContext(null);
13
+ const isNote = (v) => !!v && typeof v === 'object' && typeof v.slug === 'string' &&
14
+ typeof v.heading === 'string' && typeof v.note === 'string';
15
+ function notesFrom(body) {
16
+ const slot = body?.slots?.[NOTES_SLOT];
17
+ if (!slot)
18
+ return [];
19
+ const rows = Array.isArray(slot.shared) ? slot.shared : Array.isArray(slot.mine) ? slot.mine.map((e) => ({ ...e, mine: true })) : [];
20
+ const out = [];
21
+ for (const r of rows) {
22
+ if (!isNote(r.value))
23
+ continue;
24
+ out.push({
25
+ id: String(r.id ?? ''), slug: r.value.slug, heading: r.value.heading, note: r.value.note,
26
+ name: r.mine ? 'You' : typeof r.name === 'string' ? r.name : 'a reader', at: String(r.at ?? ''), mine: r.mine === true,
27
+ });
28
+ }
29
+ return out;
30
+ }
31
+ export function NotesProvider({ artifactId, headings, accent, children }) {
32
+ const endpoint = `/api/artifacts/${artifactId}/state`;
33
+ const [notes, setNotes] = useState([]);
34
+ const [off, setOff] = useState(false);
35
+ const [canWrite, setCanWrite] = useState(false);
36
+ const [signIn, setSignIn] = useState(null);
37
+ const [open, setOpen] = useState(null);
38
+ const [error, setError] = useState(null);
39
+ const [busy, setBusy] = useState(false);
40
+ const apply = useCallback((status, body) => {
41
+ if (status === 200) {
42
+ setNotes(notesFrom(body));
43
+ setCanWrite(body.canWrite === true);
44
+ setSignIn(typeof body.signIn === 'string' ? body.signIn : null);
45
+ setError(null);
46
+ return true;
47
+ }
48
+ if (status === 401) {
49
+ setCanWrite(false);
50
+ setSignIn(typeof body.signIn === 'string' ? body.signIn : null);
51
+ return false;
52
+ }
53
+ // No state store, no such page, or no slot: the page takes no notes here, so draw nothing.
54
+ if (status === 404 || status === 501) {
55
+ setOff(true);
56
+ return false;
57
+ }
58
+ setError(typeof body.error === 'string' ? body.error : 'that did not save; try again');
59
+ return false;
60
+ }, []);
61
+ const call = useCallback(async (init) => {
62
+ try {
63
+ const r = await fetch(endpoint, { credentials: 'same-origin', cache: 'no-store', ...init });
64
+ const body = (await r.json().catch(() => ({})));
65
+ return apply(r.status, body);
66
+ }
67
+ catch {
68
+ setError('that did not reach the page; try again');
69
+ return false;
70
+ }
71
+ }, [endpoint, apply]);
72
+ useEffect(() => { void call(); }, [call]);
73
+ const post = useCallback(async (slug, heading, note) => {
74
+ setBusy(true);
75
+ const done = await call({
76
+ method: 'POST', headers: { 'content-type': 'application/json' },
77
+ body: JSON.stringify({ slot: NOTES_SLOT, op: 'append', value: { slug, heading, note } }),
78
+ });
79
+ setBusy(false);
80
+ if (done)
81
+ setOpen(null);
82
+ return done;
83
+ }, [call]);
84
+ const remove = useCallback(async (id) => {
85
+ await call({ method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ slot: NOTES_SLOT, op: 'remove', entry: id }) });
86
+ }, [call]);
87
+ const placed = useMemo(() => placeNotes(headings, notes), [headings, notes]);
88
+ const value = { off, accent: accent ?? 'currentColor', ...placed, canWrite, signIn, open, setOpen, error, busy, post, remove };
89
+ return _jsx(NotesContext.Provider, { value: value, children: children });
90
+ }
91
+ /** The small control inside a heading. */
92
+ export function NoteToggle({ slug }) {
93
+ const c = useContext(NotesContext);
94
+ if (!c || c.off)
95
+ return null;
96
+ const n = c.bySlug[slug]?.length ?? 0;
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
+ }
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-zinc-100", 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
+ }
102
+ function NoteForm({ slug, heading, c }) {
103
+ const [text, setText] = useState('');
104
+ const submit = async (e) => {
105
+ e.preventDefault();
106
+ const note = text.trim();
107
+ if (!note)
108
+ return;
109
+ if (await c.post(slug, heading, note))
110
+ setText('');
111
+ };
112
+ if (!c.canWrite) {
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
+ }
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-white/15 bg-white/[0.04] p-3 text-[15px] text-zinc-100" }), 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: '#111' }, children: "Save note" }), _jsx("button", { type: "button", onClick: () => c.setOpen(null), className: "text-sm opacity-70 hover:opacity-100", children: "Cancel" })] })] }));
116
+ }
117
+ /** After each heading: its notes, and the box when its control is open. */
118
+ export function HeadingNotes({ slug, text }) {
119
+ const c = useContext(NotesContext);
120
+ if (!c || c.off)
121
+ return null;
122
+ const list = c.bySlug[slug] ?? [];
123
+ const isOpen = c.open === slug;
124
+ if (!list.length && !isOpen)
125
+ return null;
126
+ return (_jsxs("div", { "data-nospeak": true, "data-heading-notes": slug, className: "my-3", children: [list.length ? _jsx("ul", { className: "m-0 grid list-none gap-2 p-0", children: list.map((n) => _jsx(NoteItem, { n: n, c: c }, n.id)) }) : null, isOpen ? _jsx(NoteForm, { slug: slug, heading: text, c: c }) : null] }));
127
+ }
128
+ /** Where the ```notes fence sits: a line saying notes are on, and the notes whose heading is gone. */
129
+ export function NotesEarlier() {
130
+ const c = useContext(NotesContext);
131
+ if (!c || c.off)
132
+ return null;
133
+ return (_jsxs("section", { "data-nospeak": true, "data-artifact-notes": true, className: "my-8 text-sm", children: [_jsx("p", { className: "opacity-60", children: "Leave a note beside any heading." }), c.earlier.length ? (_jsxs(_Fragment, { children: [_jsx("p", { className: "mt-4 text-[11px] uppercase tracking-[0.2em]", style: { color: c.accent }, children: "Notes on earlier versions" }), _jsx("ul", { className: "m-0 mt-2 grid list-none gap-2 p-0", children: c.earlier.map((n) => _jsx(NoteItem, { n: n, c: c, under: true }, n.id)) })] })) : null] }));
134
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@supersuit/artifacts",
3
- "version": "0.2.0",
3
+ "version": "0.3.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",