@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
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,73 @@
|
|
|
3
3
|
`@supersuit/artifacts`. One entry per version, newest first. Each entry says what changed, how a
|
|
4
4
|
host can tell whether it is affected (DETECTOR), what a host does about it (REMEDY), and the tests.
|
|
5
5
|
|
|
6
|
+
## 0.4.0 (2026-09-26)
|
|
7
|
+
|
|
8
|
+
**Light and dark modes, a table of contents, and an animated ground.**
|
|
9
|
+
|
|
10
|
+
- **Modes.** `BrandPack` gains `light` (a second palette), `mode` (`light`, `dark` or `system`,
|
|
11
|
+
default `dark`) and `backdrop`. A page may override the mode with `theme:` in its front matter.
|
|
12
|
+
Every colour the page draws is now a CSS variable set by `BrandGround` for the mode, so `system`
|
|
13
|
+
follows the reader's device. `freedomDefault` carries a light palette.
|
|
14
|
+
- **Table of contents.** Built from the page's `##` headings: a sticky rail in the left margin at
|
|
15
|
+
`xl` widths, highlighting the section being read, and a closed "Contents" list above the body
|
|
16
|
+
below that. Both are `data-nospeak`. It appears once a page has four sections; `toc: false`
|
|
17
|
+
removes it and `toc: true` draws it from one. Every heading now carries its slug as an `id`,
|
|
18
|
+
with or without the notes widget.
|
|
19
|
+
- **Bubbles.** `backdrop: { kind: 'bubbles' }` draws drifting colour behind the page in plain CSS,
|
|
20
|
+
held still under `prefers-reduced-motion`. A pack with its own `Wrapper` draws its own ground
|
|
21
|
+
and ignores `backdrop`.
|
|
22
|
+
- **Long words wrap.** The body is `break-words`, so a bare URL in prose no longer pushes a phone
|
|
23
|
+
sideways (a 390px screen scrolled 7px on the first page checked).
|
|
24
|
+
- **DETECTOR:** a page's root carries `data-artifact-theme`; an open page with four sections
|
|
25
|
+
carries `data-artifact-toc`. A host whose pages still show zinc-on-black text in a light mode is
|
|
26
|
+
building from a Tailwind `content` list that does not scan this package's `lib/`.
|
|
27
|
+
- **REMEDY:** none required. A pack with no `mode` keeps its dark look, and `theme`/`toc` are new
|
|
28
|
+
optional keys. A host whose publisher strips unknown front matter keys must forward `theme` and
|
|
29
|
+
`toc` for pages to set them.
|
|
30
|
+
- **Tests:** theme CSS per mode and the fallback light palette; the ground, the wrapper-pack path
|
|
31
|
+
and the backdrop; `theme` and `toc` parsed, refused, stored and cleared on republish; heading ids
|
|
32
|
+
without notes; the route's mode and both contents placements.
|
|
33
|
+
|
|
34
|
+
## 0.3.0 (2026-09-25)
|
|
35
|
+
|
|
36
|
+
**Notes, the first widget.** A ```` ```notes ```` block in a page puts a small "note" control
|
|
37
|
+
beside every heading, so readers can leave notes on a section and, on a `shared` page, read each
|
|
38
|
+
other's. Poll, form and checklist follow in a later release.
|
|
39
|
+
|
|
40
|
+
- **Fence:** ```` ```notes ```` with an optional `visibility: private | shared` line. At most one
|
|
41
|
+
per page. It declares slot `notes` of shape `many` itself, merged into `state:` at parse time
|
|
42
|
+
(a page with no `state:` gets `writers: signed-in`, `visibility: private`), so the 0.2.0 state
|
|
43
|
+
API takes notes unchanged. Refused at publish with the file's line number: a second block, a
|
|
44
|
+
name, an unknown key, `tally`, a `notes` slot declared `one`, a visibility that disagrees with
|
|
45
|
+
`state:`. `poll`, `form` and `checklist` fences are refused until they ship.
|
|
46
|
+
- **A note is `{ slug, heading, note }`**, checked by the server on a page with a notes block.
|
|
47
|
+
Headings carry their GitHub-style slug as an `id`; the renderer finds each heading by its source
|
|
48
|
+
line, so the slug on the page and the slug a note stores come from one derivation.
|
|
49
|
+
- **A renamed or removed heading keeps its notes**, shown under "Notes on earlier versions" with
|
|
50
|
+
the heading they were left under, never attached to another section.
|
|
51
|
+
- **Narration skips the widget**: the fence is not spoken and everything drawn is `data-nospeak`.
|
|
52
|
+
- **Password pages with `state:`** now set the answer API's unlock cookie on every open, not only
|
|
53
|
+
on `?key=`. Readers who unlocked such a page before 0.2.0 are no longer refused (the item parked
|
|
54
|
+
in 0.2.0).
|
|
55
|
+
- **The replace-at-cap check** reads one document (`StateStore.hasOne`, optional, implemented by
|
|
56
|
+
both shipped stores) instead of every answer on the page.
|
|
57
|
+
- **DETECTOR:** a page with a notes block published to a host with no state store gets `warning:
|
|
58
|
+
this host keeps no answers` and draws no controls. A notes page whose readers see no "note"
|
|
59
|
+
button beside its headings is on 0.2.0 or earlier.
|
|
60
|
+
- **REMEDY (hosts):** take 0.3.0. No new routes: the notes widget uses the 0.2.0 state routes
|
|
61
|
+
(`app/api/artifacts/[id]/state/route.ts`), which a host must already mount. A host with its own
|
|
62
|
+
`StateStore` may add `hasOne`; without it the old full read is used.
|
|
63
|
+
- **Tests:** `widgets.test.ts` (slugs, fence parsing, one per page, reserved widgets, merge,
|
|
64
|
+
note shape, placement), `front-matter.test.ts` (line numbers in the file, the first real page's
|
|
65
|
+
front matter), `render.test.tsx` (controls beside headings, fence never drawn as code),
|
|
66
|
+
`widgets/notes.test.tsx` (jsdom: shared notes under their heading, earlier versions, posting
|
|
67
|
+
appends `{ slug, heading, note }`, sign-in offer, removal, no-store host), `narration.test.ts`,
|
|
68
|
+
`state.test.tsx` (note shape refused, shared read, `hasOne` path), `artifacts.test.tsx` (API
|
|
69
|
+
cookie on every open, page wiring, publish refusal), `state-store.test.ts` (`hasOne`), and the
|
|
70
|
+
packed fixture (a notes page draws its controls, a note posts and reads back, a malformed one is
|
|
71
|
+
refused, the compiled widget keeps `'use client'`).
|
|
72
|
+
|
|
6
73
|
## 0.2.0 (2026-09-24)
|
|
7
74
|
|
|
8
75
|
**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
|
|
|
@@ -129,6 +130,8 @@ summary: Three bets, what each costs, and what we stop doing to afford them.
|
|
|
129
130
|
| `voice`, `narration`, `timings`, `narrationHash` | Read-aloud: the audio and word timings a publisher generated. Absent, no player |
|
|
130
131
|
| `password` | Shuts the body behind a door (below) |
|
|
131
132
|
| `access` | `freedom`, `invite` or `public`: a confidential page (below) |
|
|
133
|
+
| `theme` | `light`, `dark` or `system` (follow the reader's device). Overrides the brand pack's mode for this page |
|
|
134
|
+
| `toc` | The table of contents. Absent, it appears once the page has four `##` sections; `false` never; `true` from one |
|
|
132
135
|
| `template` | `document`, the only one so far |
|
|
133
136
|
|
|
134
137
|
An unknown key is refused, so a typo fails loudly instead of being ignored.
|
|
@@ -285,6 +288,55 @@ On a proxy other than Vercel's, pass `clientIp` to `createArtifactRoutes` (used
|
|
|
285
288
|
rate limit): the default reads the first hop of `x-forwarded-for`, which Vercel overwrites with
|
|
286
289
|
the real client IP but another proxy may only append to.
|
|
287
290
|
|
|
291
|
+
## Widgets in a page
|
|
292
|
+
|
|
293
|
+
A widget is a fenced block in a page's markdown that gives readers a place to answer, drawn by
|
|
294
|
+
the shell in the page's brand and kept through the state API above. Widgets need a host with a
|
|
295
|
+
state store (`state:` in the config); on a host without one they draw nothing, and the publish
|
|
296
|
+
response carries the no-answers warning.
|
|
297
|
+
|
|
298
|
+
This version draws one widget, **notes**. Poll, form and checklist are coming: a fence named
|
|
299
|
+
`poll`, `form` or `checklist` is refused at publish until then, so a page never ships a code
|
|
300
|
+
block that turns into a live widget on a later update.
|
|
301
|
+
|
|
302
|
+
### Notes
|
|
303
|
+
|
|
304
|
+
````markdown
|
|
305
|
+
## Sales
|
|
306
|
+
|
|
307
|
+
The weekly pipeline review.
|
|
308
|
+
|
|
309
|
+
```notes
|
|
310
|
+
visibility: shared # optional: private | shared
|
|
311
|
+
```
|
|
312
|
+
````
|
|
313
|
+
|
|
314
|
+
- **A small "note" control sits beside every heading.** A reader opens it, writes, and saves. The
|
|
315
|
+
note appears under that heading; with `visibility: shared` every reader sees every note with
|
|
316
|
+
the writer's first name ("a reader" for a signed-out one), with `private` each reader sees only
|
|
317
|
+
their own and the publisher sees all through `/responses`.
|
|
318
|
+
- **At most one notes block per page.** Where it sits is where the page shows "Notes on earlier
|
|
319
|
+
versions" (below).
|
|
320
|
+
- **The block declares its own slot**: `notes`, shape `many`. A page needs no `state:` for it;
|
|
321
|
+
without one the page takes `writers: signed-in` and `visibility: private`. A page may declare
|
|
322
|
+
the slot itself (`slots: { notes: { shape: many } }`) and set `writers:` and `visibility:` as
|
|
323
|
+
usual. Refused at publish, naming the line in the file: a second notes block, a name after
|
|
324
|
+
`notes`, any key but `visibility`, `visibility: tally`, a `notes` slot declared `shape: one`,
|
|
325
|
+
and a block visibility that disagrees with one `state:` sets on the `notes` slot.
|
|
326
|
+
- **A note is `{ slug, heading, note }`**: the slug and the text of the heading it was left under,
|
|
327
|
+
and up to 4,000 characters of text. On a page with a notes block the server refuses any other
|
|
328
|
+
shape in the `notes` slot. Slugs follow GitHub's rule (lowercase, punctuation dropped, spaces
|
|
329
|
+
to dashes, a repeated heading `-1`, `-2`), and each heading carries its slug as its `id`, so
|
|
330
|
+
`#sales` links to it.
|
|
331
|
+
- **Notes survive a changed heading.** A note shows under the heading with its slug; failing
|
|
332
|
+
that, under a heading with exactly its text; otherwise under "Notes on earlier versions", with
|
|
333
|
+
the heading it was left under. It never attaches to a different section.
|
|
334
|
+
- **Narration skips it.** The fence is not prose, and everything the widget draws is marked
|
|
335
|
+
`data-nospeak`, so the narrator and the read-along highlighter read the page as before.
|
|
336
|
+
- On a gated page (`access:`) only signed-in readers the page is open to, after the agreement,
|
|
337
|
+
can leave or read notes; the banner, watermark and print refusal are unchanged. On a public
|
|
338
|
+
page with `writers: signed-in`, the control offers sign-in through `signInOrigin`.
|
|
339
|
+
|
|
288
340
|
## Brand packs
|
|
289
341
|
|
|
290
342
|
A `BrandPack` is data plus at most two components: colours, type, the kicker line above a title,
|
|
@@ -292,6 +344,20 @@ the narrator label, an optional full-page `Wrapper` and `Mark`, and an optional
|
|
|
292
344
|
(a font loader and a backdrop) that draws the unfurl card. `freedomDefault` is the default look.
|
|
293
345
|
Keep a pack carrying your own trademark in your own app, not in a pull request here.
|
|
294
346
|
|
|
347
|
+
**Light, dark or system.** A pack's `ground`, `ink` and `accent` are its dark palette; `light` is
|
|
348
|
+
the other one (absent, warm paper with the pack's accent), and `mode` is the one a page opens in
|
|
349
|
+
(`dark` by default; a page's `theme:` overrides it). Pages paint with CSS variables
|
|
350
|
+
(`--a-ground`, `--a-ink`, `--a-accent` and tones derived from them), which is what lets `system`
|
|
351
|
+
follow the device.
|
|
352
|
+
|
|
353
|
+
**An animated ground.** `backdrop: { kind: 'bubbles', colors?, veil? }` draws soft colour drifting
|
|
354
|
+
behind the page under a veil of the ground, in plain CSS, still for readers who ask for reduced
|
|
355
|
+
motion. `colors` are `r,g,b` triplets (default `GLOW_PASTELS`); `veil` is 0 to 1 (default 0.4).
|
|
356
|
+
|
|
357
|
+
```ts
|
|
358
|
+
const pack: BrandPack = { ...freedomDefault, mode: 'light', backdrop: { kind: 'bubbles' } }
|
|
359
|
+
```
|
|
360
|
+
|
|
295
361
|
## Environment
|
|
296
362
|
|
|
297
363
|
| Variable | Used for |
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type Access } from './reader.js';
|
|
2
2
|
import { type StateConfig } from './state.js';
|
|
3
|
+
import { type ThemeMode } from '../brand/theme.js';
|
|
3
4
|
export type ArtifactMeta = {
|
|
4
5
|
title: string;
|
|
5
6
|
summary: string;
|
|
@@ -27,6 +28,10 @@ export type ArtifactMeta = {
|
|
|
27
28
|
/** What readers may put into the page. Content, like the body: a republish without it removes
|
|
28
29
|
* the slots from the page and KEEPS the answers already given. */
|
|
29
30
|
state?: StateConfig;
|
|
31
|
+
/** Light, dark, or the reader's device, overriding the brand pack's mode for this page. */
|
|
32
|
+
theme?: ThemeMode;
|
|
33
|
+
/** The table of contents: absent draws it once the page has four sections, false never. */
|
|
34
|
+
toc?: boolean;
|
|
30
35
|
};
|
|
31
36
|
export declare function parseArtifactSource(text: string): {
|
|
32
37
|
ok: true;
|
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
import matter from 'gray-matter';
|
|
4
4
|
import { ACCESS_LEVELS } from './reader.js';
|
|
5
5
|
import { parseStateConfig } from './state.js';
|
|
6
|
-
|
|
6
|
+
import { mergeWidgetState, scanWidgets } from './widgets.js';
|
|
7
|
+
import { THEME_MODES } from '../brand/theme.js';
|
|
8
|
+
const KNOWN = new Set(['title', 'summary', 'subtitle', 'template', 'audience', 'cover', 'id', 'voice', 'narration', 'timings', 'narrationHash', 'password', 'access', 'state', 'theme', 'toc']);
|
|
7
9
|
export function parseArtifactSource(text) {
|
|
8
10
|
const { data, content } = matter(text);
|
|
9
11
|
const d = data;
|
|
@@ -34,11 +36,33 @@ export function parseArtifactSource(text) {
|
|
|
34
36
|
return { ok: false, error: `access must be one of: public, ${ACCESS_LEVELS.join(', ')}` };
|
|
35
37
|
meta.access = d.access;
|
|
36
38
|
}
|
|
39
|
+
if (d.theme !== undefined) {
|
|
40
|
+
if (!THEME_MODES.includes(d.theme))
|
|
41
|
+
return { ok: false, error: `theme must be one of: ${THEME_MODES.join(', ')}` };
|
|
42
|
+
meta.theme = d.theme;
|
|
43
|
+
}
|
|
44
|
+
if (d.toc !== undefined) {
|
|
45
|
+
if (typeof d.toc !== 'boolean')
|
|
46
|
+
return { ok: false, error: 'toc must be true or false' };
|
|
47
|
+
meta.toc = d.toc;
|
|
48
|
+
}
|
|
37
49
|
if (d.state !== undefined) {
|
|
38
50
|
const s = parseStateConfig(d.state);
|
|
39
51
|
if (!s.ok)
|
|
40
52
|
return { ok: false, error: s.error };
|
|
41
53
|
meta.state = s.state;
|
|
42
54
|
}
|
|
55
|
+
// Widgets declare their own slots, so a notes block is merged into state: here, before the
|
|
56
|
+
// page is stored, and the state API and the republish shape checks see it like any slot.
|
|
57
|
+
// Errors name the line in the FILE: the body's lines are counted after the front matter's.
|
|
58
|
+
const offset = text.endsWith(content) ? (text.slice(0, text.length - content.length).match(/\n/g) ?? []).length : 0;
|
|
59
|
+
const w = scanWidgets(content, offset);
|
|
60
|
+
if (!w.ok)
|
|
61
|
+
return { ok: false, error: w.error };
|
|
62
|
+
const merged = mergeWidgetState(meta.state, w.notes);
|
|
63
|
+
if (!merged.ok)
|
|
64
|
+
return { ok: false, error: merged.error };
|
|
65
|
+
if (merged.state)
|
|
66
|
+
meta.state = merged.state;
|
|
43
67
|
return { ok: true, meta, body: content };
|
|
44
68
|
}
|
package/lib/artifacts/index.d.ts
CHANGED
|
@@ -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';
|
package/lib/artifacts/index.js
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/lib/artifacts/render.js
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
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
|
-
|
|
4
|
+
import { hasNotesWidget, headingsOf } from './widgets.js';
|
|
5
|
+
import { HeadingNotes, NoteToggle, NotesEarlier, NotesProvider } from '../widgets/notes.js';
|
|
6
|
+
// Every colour is a theme variable (brand/theme.ts), so a page follows its light, dark or system mode.
|
|
7
|
+
const GOLD = 'var(--a-accent)';
|
|
5
8
|
function hostOf(url) {
|
|
6
9
|
try {
|
|
7
10
|
return new URL(url).host.replace(/^www\./, '');
|
|
@@ -31,7 +34,7 @@ function LinkCards({ source }) {
|
|
|
31
34
|
const [url, ...rest] = l.split('|');
|
|
32
35
|
return { url: url.trim(), note: rest.join('|').trim() };
|
|
33
36
|
});
|
|
34
|
-
return (_jsx("ul", { "data-artifact-links": true, className: "my-6 grid list-none gap-3 p-0", children: rows.map((r) => (_jsx("li", { className: "m-0 p-0", children: _jsxs("a", { href: r.url, target: "_blank", rel: "noopener noreferrer", className: "block rounded-lg border border-
|
|
37
|
+
return (_jsx("ul", { "data-artifact-links": true, className: "my-6 grid list-none gap-3 p-0", children: rows.map((r) => (_jsx("li", { className: "m-0 p-0", children: _jsxs("a", { href: r.url, target: "_blank", rel: "noopener noreferrer", className: "block rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3 no-underline transition-colors hover:border-[color:var(--a-line-strong)]", children: [_jsx("span", { className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: hostOf(r.url) }), _jsx("span", { className: "block text-[color:var(--a-strong)]", children: r.note || r.url })] }) }, r.url))) }));
|
|
35
38
|
}
|
|
36
39
|
const CALLOUT = /^\[!(note|warning)\]\s*/i;
|
|
37
40
|
// A link the reader can actually follow: absolute, a scheme like mailto:, a
|
|
@@ -43,26 +46,27 @@ export function isReachableHref(href) {
|
|
|
43
46
|
return typeof href === 'string' && REACHABLE.test(href);
|
|
44
47
|
}
|
|
45
48
|
const components = {
|
|
46
|
-
h1: ({ children }) => _jsx("h1", { className: "mt-10 mb-4 font-serif text-3xl text-zinc-50", children: children }),
|
|
47
|
-
h2: ({ children }) => _jsx("h2", { className: "mt-10 mb-3 font-serif text-2xl text-zinc-50", children: children }),
|
|
48
|
-
h3: ({ children }) => _jsx("h3", { className: "mt-8 mb-2 text-lg font-semibold text-zinc-100", children: children }),
|
|
49
49
|
p: ({ children }) => _jsx("p", { className: "my-4 leading-relaxed", children: children }),
|
|
50
50
|
ul: ({ children }) => _jsx("ul", { className: "my-4 list-disc space-y-1 pl-6", children: children }),
|
|
51
51
|
ol: ({ children }) => _jsx("ol", { className: "my-4 list-decimal space-y-1 pl-6", children: children }),
|
|
52
|
-
hr: () => _jsx("hr", { className: "my-10 border-
|
|
52
|
+
hr: () => _jsx("hr", { className: "my-10 border-[color:var(--a-line)]" }),
|
|
53
53
|
table: ({ children }) => (_jsx("div", { className: "my-6 overflow-x-auto", children: _jsx("table", { className: "w-full border-collapse text-sm", children: children }) })),
|
|
54
|
-
th: ({ children }) => (_jsx("th", { className: "border-b border-
|
|
55
|
-
td: ({ children }) => _jsx("td", { className: "border-b border-
|
|
54
|
+
th: ({ children }) => (_jsx("th", { className: "border-b border-[color:var(--a-line-strong)] px-3 py-2 text-left font-semibold text-[color:var(--a-strong)]", children: children })),
|
|
55
|
+
td: ({ children }) => _jsx("td", { className: "border-b border-[color:var(--a-line)] px-3 py-2 align-top", children: children }),
|
|
56
56
|
img: ({ src, alt }) => (
|
|
57
57
|
// eslint-disable-next-line @next/next/no-img-element
|
|
58
|
-
_jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-
|
|
59
|
-
a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-
|
|
58
|
+
_jsx("img", { src: typeof src === 'string' ? src : undefined, alt: alt ?? '', className: "my-6 w-full rounded-lg border border-[color:var(--a-line)]" })),
|
|
59
|
+
a: ({ href, children }) => !isReachableHref(href) ? (_jsx("span", { children: children })) : (_jsxs("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: "text-[color:var(--a-strong)] underline decoration-[color:var(--a-line-strong)] underline-offset-4 hover:decoration-[color:var(--a-strong)]", children: [children, href && hostOf(href) ? (_jsxs("span", { "data-nospeak": true, className: "ml-1 text-xs text-[color:var(--a-muted)]", children: ["(", hostOf(href), ")"] })) : null] })),
|
|
60
60
|
code: ({ className, children }) => {
|
|
61
61
|
const lang = /language-(\w+)/.exec(className ?? '')?.[1];
|
|
62
62
|
if (lang === 'links')
|
|
63
63
|
return _jsx(LinkCards, { source: textOf(children) });
|
|
64
|
+
// A notes fence is the widget's place on the page. Without notes enabled (the host keeps no
|
|
65
|
+
// answers) it draws nothing: its settings are not prose and never shown as code.
|
|
66
|
+
if (lang === 'notes')
|
|
67
|
+
return null;
|
|
64
68
|
if (!className) {
|
|
65
|
-
return _jsx("code", { className: "rounded bg-
|
|
69
|
+
return _jsx("code", { className: "rounded bg-[color:var(--a-surface-strong)] px-1.5 py-0.5 text-[0.9em] text-[color:var(--a-strong)]", children: children });
|
|
66
70
|
}
|
|
67
71
|
return _jsx("code", { className: className, children: children });
|
|
68
72
|
},
|
|
@@ -70,21 +74,67 @@ const components = {
|
|
|
70
74
|
// A links fence renders its own block; do not wrap it in <pre>.
|
|
71
75
|
const inner = Array.isArray(children) ? children[0] : children;
|
|
72
76
|
const cls = inner?.props?.className ?? '';
|
|
73
|
-
if (/language-links/.test(cls))
|
|
77
|
+
if (/language-(links|notes)/.test(cls))
|
|
74
78
|
return _jsx(_Fragment, { children: children });
|
|
75
|
-
return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-
|
|
79
|
+
return (_jsx("pre", { "data-artifact-code": true, className: "my-6 overflow-x-auto rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-code)] p-4 text-sm text-[color:var(--a-strong)]", children: children }));
|
|
76
80
|
},
|
|
77
81
|
blockquote: ({ children }) => {
|
|
78
82
|
const t = textOf(children).trim();
|
|
79
83
|
const m = CALLOUT.exec(t);
|
|
80
84
|
if (!m) {
|
|
81
|
-
return (_jsx("blockquote", { className: "my-6 border-l-2 pl-4 italic text-
|
|
85
|
+
return (_jsx("blockquote", { className: "my-6 border-l-2 pl-4 italic text-[color:var(--a-body)]", style: { borderColor: GOLD }, children: children }));
|
|
82
86
|
}
|
|
83
87
|
const kind = m[1].toLowerCase();
|
|
84
88
|
const body = t.replace(CALLOUT, '');
|
|
85
|
-
return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: '
|
|
89
|
+
return (_jsxs("aside", { "data-callout": kind, className: "my-6 rounded-lg border px-4 py-3", style: { borderColor: kind === 'warning' ? '#d97706' : GOLD, background: 'var(--a-surface)' }, children: [_jsx("span", { "data-nospeak": true, className: "block text-[11px] uppercase tracking-[0.2em]", style: { color: GOLD }, children: kind }), _jsx("p", { className: "mt-1 text-[color:var(--a-strong)]", children: body })] }));
|
|
86
90
|
},
|
|
87
91
|
};
|
|
88
|
-
|
|
89
|
-
|
|
92
|
+
// scroll-mt keeps a heading clear of the top of the screen when a contents link jumps to it.
|
|
93
|
+
const HEADING_CLASS = {
|
|
94
|
+
1: 'mt-10 mb-4 scroll-mt-24 font-serif text-3xl text-[color:var(--a-strong)]',
|
|
95
|
+
2: 'mt-10 mb-3 scroll-mt-24 font-serif text-2xl text-[color:var(--a-strong)]',
|
|
96
|
+
3: 'mt-8 mb-2 scroll-mt-24 text-lg font-semibold text-[color:var(--a-strong)]',
|
|
97
|
+
4: 'mt-6 mb-2 scroll-mt-24 font-semibold text-[color:var(--a-strong)]',
|
|
98
|
+
5: 'mt-6 mb-2 scroll-mt-24 font-semibold text-[color:var(--a-strong)]',
|
|
99
|
+
6: 'mt-6 mb-2 scroll-mt-24 font-semibold text-[color:var(--a-strong)]',
|
|
100
|
+
};
|
|
101
|
+
/** Components for any page: every heading gets its slug as an id, the one headingsOf derived,
|
|
102
|
+
* so a contents link and a note both land on it. */
|
|
103
|
+
function pageComponents(headings) {
|
|
104
|
+
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
105
|
+
const heading = (depth) => function Heading({ node, children }) {
|
|
106
|
+
const Tag = `h${depth}`;
|
|
107
|
+
const h = byLine.get(node?.position?.start.line ?? -1);
|
|
108
|
+
return _jsx(Tag, { id: h?.slug, className: HEADING_CLASS[depth], children: children });
|
|
109
|
+
};
|
|
110
|
+
return { ...components, h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6) };
|
|
111
|
+
}
|
|
112
|
+
/** Components for a page with a notes block: every heading gets its slug as an id, a note
|
|
113
|
+
* control, and its notes after it. The heading is found by its source line, so the slug is the
|
|
114
|
+
* one headingsOf derived, the same one a note stores. */
|
|
115
|
+
function notesComponents(headings) {
|
|
116
|
+
const byLine = new Map(headings.map((h) => [h.line, h]));
|
|
117
|
+
const heading = (depth) => function NotedHeading({ node, children }) {
|
|
118
|
+
const Tag = `h${depth}`;
|
|
119
|
+
const h = byLine.get(node?.position?.start.line ?? -1);
|
|
120
|
+
if (!h)
|
|
121
|
+
return _jsx(Tag, { className: HEADING_CLASS[depth], children: children });
|
|
122
|
+
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 })] }));
|
|
123
|
+
};
|
|
124
|
+
const Code = components.code;
|
|
125
|
+
return {
|
|
126
|
+
...components,
|
|
127
|
+
h1: heading(1), h2: heading(2), h3: heading(3), h4: heading(4), h5: heading(5), h6: heading(6),
|
|
128
|
+
code: (props) => (/language-notes/.test(props.className ?? '') ? _jsx(NotesEarlier, {}) : _jsx(Code, { ...props })),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
/** `notes` turns on the notes widget, for a host that keeps answers; it draws only when the
|
|
132
|
+
* page carries a ```notes block. */
|
|
133
|
+
export function ArtifactMarkdown({ markdown, notes }) {
|
|
134
|
+
const on = !!notes && hasNotesWidget(markdown);
|
|
135
|
+
const headings = headingsOf(markdown);
|
|
136
|
+
const body = (_jsx(ReactMarkdown, { remarkPlugins: [remarkGfm], components: on ? notesComponents(headings) : pageComponents(headings), children: markdown }));
|
|
137
|
+
return (
|
|
138
|
+
// break-words: a bare URL in prose is one unbreakable word and pushed phones 7px sideways (2026-09-26).
|
|
139
|
+
_jsx("div", { className: "break-words text-[17px] text-[color:var(--a-body)]", children: on ? (_jsx(NotesProvider, { artifactId: notes.artifactId, headings: headings.map(({ slug, text }) => ({ slug, text })), accent: notes.accent, children: body })) : body }));
|
|
90
140
|
}
|
|
@@ -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
|
}
|
package/lib/artifacts/store.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { type Firestore } from 'firebase-admin/firestore';
|
|
|
2
2
|
import type { ArtifactMeta } from './front-matter.js';
|
|
3
3
|
import type { Access } from './reader.js';
|
|
4
4
|
import type { StateConfig } from './state.js';
|
|
5
|
+
import type { ThemeMode } from '../brand/theme.js';
|
|
5
6
|
export type ArtifactRecord = {
|
|
6
7
|
id: string;
|
|
7
8
|
title: string;
|
|
@@ -17,6 +18,8 @@ export type ArtifactRecord = {
|
|
|
17
18
|
password?: string;
|
|
18
19
|
access?: Access;
|
|
19
20
|
state?: StateConfig;
|
|
21
|
+
theme?: ThemeMode;
|
|
22
|
+
toc?: boolean;
|
|
20
23
|
markdown: string;
|
|
21
24
|
createdAt: string;
|
|
22
25
|
updatedAt: string;
|
package/lib/artifacts/store.js
CHANGED
|
@@ -57,6 +57,8 @@ async function saveArtifact(col, input) {
|
|
|
57
57
|
...(input.meta.password ? { password: input.meta.password } : {}),
|
|
58
58
|
...(input.meta.access && input.meta.access !== 'public' ? { access: input.meta.access } : {}),
|
|
59
59
|
...(input.meta.state ? { state: input.meta.state } : {}),
|
|
60
|
+
...(input.meta.theme ? { theme: input.meta.theme } : {}),
|
|
61
|
+
...(input.meta.toc !== undefined ? { toc: input.meta.toc } : {}),
|
|
60
62
|
};
|
|
61
63
|
if (input.id) {
|
|
62
64
|
const existing = await getArtifact(col, input.id);
|
|
@@ -82,8 +84,13 @@ async function saveArtifact(col, input) {
|
|
|
82
84
|
const access = input.meta.access === 'public' ? { access: FieldValue.delete() } : {};
|
|
83
85
|
// Content, like the body: a republish that no longer declares state removes the slots.
|
|
84
86
|
const state = input.meta.state ? {} : { state: FieldValue.delete() };
|
|
87
|
+
// How the page looks is content too: a line the file no longer carries goes back to the default.
|
|
88
|
+
const look = {
|
|
89
|
+
...(input.meta.theme ? {} : { theme: FieldValue.delete() }),
|
|
90
|
+
...(input.meta.toc !== undefined ? {} : { toc: FieldValue.delete() }),
|
|
91
|
+
};
|
|
85
92
|
await ref.update({
|
|
86
|
-
...fields, ...password, ...subtitle, ...access, ...state, markdown: input.markdown, updatedAt: now, version: next,
|
|
93
|
+
...fields, ...password, ...subtitle, ...access, ...state, ...look, markdown: input.markdown, updatedAt: now, version: next,
|
|
87
94
|
...(existing.versions ? { versions: FieldValue.delete() } : {}),
|
|
88
95
|
});
|
|
89
96
|
return { id: input.id, version: next, created: false };
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
// Marks the contents entry for the section being read. Renders nothing: it only sets
|
|
3
|
+
// aria-current on the links the server drew, which is also what styles them.
|
|
4
|
+
import { useEffect } from 'react';
|
|
5
|
+
export function TocSpy({ slugs }) {
|
|
6
|
+
const key = slugs.join('|');
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const ids = key.split('|');
|
|
9
|
+
let frame = 0;
|
|
10
|
+
const mark = () => {
|
|
11
|
+
frame = 0;
|
|
12
|
+
let current = ids[0];
|
|
13
|
+
for (const id of ids) {
|
|
14
|
+
const el = document.getElementById(id);
|
|
15
|
+
if (el && el.getBoundingClientRect().top <= 140)
|
|
16
|
+
current = id;
|
|
17
|
+
}
|
|
18
|
+
document.querySelectorAll('a[data-toc-slug]').forEach((a) => {
|
|
19
|
+
if (a.dataset.tocSlug === current)
|
|
20
|
+
a.setAttribute('aria-current', 'location');
|
|
21
|
+
else
|
|
22
|
+
a.removeAttribute('aria-current');
|
|
23
|
+
});
|
|
24
|
+
};
|
|
25
|
+
const onScroll = () => {
|
|
26
|
+
if (!frame)
|
|
27
|
+
frame = requestAnimationFrame(mark);
|
|
28
|
+
};
|
|
29
|
+
mark();
|
|
30
|
+
window.addEventListener('scroll', onScroll, { passive: true });
|
|
31
|
+
window.addEventListener('resize', onScroll);
|
|
32
|
+
return () => {
|
|
33
|
+
window.removeEventListener('scroll', onScroll);
|
|
34
|
+
window.removeEventListener('resize', onScroll);
|
|
35
|
+
if (frame)
|
|
36
|
+
cancelAnimationFrame(frame);
|
|
37
|
+
};
|
|
38
|
+
}, [key]);
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export type TocItem = {
|
|
2
|
+
slug: string;
|
|
3
|
+
text: string;
|
|
4
|
+
};
|
|
5
|
+
export declare function tocOf(markdown: string): TocItem[];
|
|
6
|
+
/** `flag` is the page's `toc:`. Absent, the list appears once there are enough sections. */
|
|
7
|
+
export declare function showToc(markdown: string, flag?: boolean): boolean;
|
|
8
|
+
/** Wide screens: fixed in the left margin, so the reading column stays centred. */
|
|
9
|
+
export declare function TocRail({ items }: {
|
|
10
|
+
items: TocItem[];
|
|
11
|
+
}): import("react").JSX.Element;
|
|
12
|
+
/** Narrow screens: a closed list above the body. */
|
|
13
|
+
export declare function TocInline({ items }: {
|
|
14
|
+
items: TocItem[];
|
|
15
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
// The table of contents: the page's sections, as a sticky rail on a wide screen and a
|
|
3
|
+
// collapsible list above the body on a narrow one. Both are marked data-nospeak so the
|
|
4
|
+
// read-along never narrates them, and both link to the ids the renderer puts on headings,
|
|
5
|
+
// which come from the same headingsOf the notes widget uses.
|
|
6
|
+
import { headingsOf } from './widgets.js';
|
|
7
|
+
import { TocSpy } from './toc-spy.js';
|
|
8
|
+
/** Sections below this count read fine without a contents list. */
|
|
9
|
+
const AUTO_MIN = 4;
|
|
10
|
+
export function tocOf(markdown) {
|
|
11
|
+
return headingsOf(markdown).filter((h) => h.depth === 2).map(({ slug, text }) => ({ slug, text }));
|
|
12
|
+
}
|
|
13
|
+
/** `flag` is the page's `toc:`. Absent, the list appears once there are enough sections. */
|
|
14
|
+
export function showToc(markdown, flag) {
|
|
15
|
+
if (flag === false)
|
|
16
|
+
return false;
|
|
17
|
+
const n = tocOf(markdown).length;
|
|
18
|
+
return flag === true ? n > 0 : n >= AUTO_MIN;
|
|
19
|
+
}
|
|
20
|
+
const LINK = 'block border-l-2 border-transparent py-1 pl-3 no-underline transition-colors text-[color:var(--a-muted)] hover:text-[color:var(--a-strong)] aria-[current=location]:border-[color:var(--a-accent)] aria-[current=location]:text-[color:var(--a-strong)]';
|
|
21
|
+
function List({ items }) {
|
|
22
|
+
return (_jsx("ol", { className: "m-0 list-none space-y-0.5 p-0 text-sm leading-snug", children: items.map((i) => (_jsx("li", { className: "m-0 p-0", children: _jsx("a", { href: `#${i.slug}`, "data-toc-slug": i.slug, className: LINK, children: i.text }) }, i.slug))) }));
|
|
23
|
+
}
|
|
24
|
+
/** Wide screens: fixed in the left margin, so the reading column stays centred. */
|
|
25
|
+
export function TocRail({ items }) {
|
|
26
|
+
return (_jsxs("nav", { "data-nospeak": true, "data-artifact-toc": "rail", "aria-label": "Contents", className: "fixed top-28 left-6 z-10 hidden max-h-[calc(100vh-10rem)] w-56 overflow-y-auto xl:block 2xl:left-12 2xl:w-64", children: [_jsx("p", { className: "mb-3 pl-3 text-[11px] font-medium uppercase tracking-[0.25em] text-[color:var(--a-accent)]", children: "Contents" }), _jsx(List, { items: items }), _jsx(TocSpy, { slugs: items.map((i) => i.slug) })] }));
|
|
27
|
+
}
|
|
28
|
+
/** Narrow screens: a closed list above the body. */
|
|
29
|
+
export function TocInline({ items }) {
|
|
30
|
+
return (_jsxs("details", { "data-nospeak": true, "data-artifact-toc": "inline", className: "mb-8 rounded-lg border border-[color:var(--a-line)] bg-[color:var(--a-surface)] px-4 py-3 xl:hidden", children: [_jsx("summary", { className: "cursor-pointer text-[11px] font-medium uppercase tracking-[0.25em] text-[color:var(--a-accent)]", children: "Contents" }), _jsx("div", { className: "mt-3", children: _jsx(List, { items: items }) })] }));
|
|
31
|
+
}
|