@supersuit/artifacts 0.1.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/LICENSE +21 -0
  3. package/README.md +206 -0
  4. package/fonts/Newsreader-600.ttf +0 -0
  5. package/lib/artifacts/assets.d.ts +27 -0
  6. package/lib/artifacts/assets.js +63 -0
  7. package/lib/artifacts/auth.d.ts +1 -0
  8. package/lib/artifacts/auth.js +8 -0
  9. package/lib/artifacts/confidential.d.ts +47 -0
  10. package/lib/artifacts/confidential.js +39 -0
  11. package/lib/artifacts/door.d.ts +5 -0
  12. package/lib/artifacts/door.js +4 -0
  13. package/lib/artifacts/front-matter.d.ts +34 -0
  14. package/lib/artifacts/front-matter.js +37 -0
  15. package/lib/artifacts/index.d.ts +9 -0
  16. package/lib/artifacts/index.js +9 -0
  17. package/lib/artifacts/narration.d.ts +8 -0
  18. package/lib/artifacts/narration.js +85 -0
  19. package/lib/artifacts/reader.d.ts +54 -0
  20. package/lib/artifacts/reader.js +109 -0
  21. package/lib/artifacts/readers-store.d.ts +90 -0
  22. package/lib/artifacts/readers-store.js +141 -0
  23. package/lib/artifacts/render.d.ts +4 -0
  24. package/lib/artifacts/render.js +90 -0
  25. package/lib/artifacts/store.d.ts +52 -0
  26. package/lib/artifacts/store.js +114 -0
  27. package/lib/artifacts/unlock.d.ts +9 -0
  28. package/lib/artifacts/unlock.js +24 -0
  29. package/lib/brand/default-share.d.ts +5 -0
  30. package/lib/brand/default-share.js +37 -0
  31. package/lib/brand/index.d.ts +3 -0
  32. package/lib/brand/index.js +3 -0
  33. package/lib/brand/pack.d.ts +48 -0
  34. package/lib/brand/pack.js +17 -0
  35. package/lib/brand/share-card.d.ts +13 -0
  36. package/lib/brand/share-card.js +50 -0
  37. package/lib/brand/wrapper.d.ts +12 -0
  38. package/lib/brand/wrapper.js +13 -0
  39. package/lib/gate.d.ts +1 -0
  40. package/lib/gate.js +5 -0
  41. package/lib/index.d.ts +4 -0
  42. package/lib/index.js +4 -0
  43. package/lib/reader/artifact-reader.d.ts +14 -0
  44. package/lib/reader/artifact-reader.js +165 -0
  45. package/lib/reader/reader-watch.d.ts +7 -0
  46. package/lib/reader/reader-watch.js +125 -0
  47. package/lib/routes/artifacts.d.ts +115 -0
  48. package/lib/routes/artifacts.js +345 -0
  49. package/package.json +74 -0
@@ -0,0 +1,114 @@
1
+ // Firestore store for published artifacts. One document per id; the current
2
+ // body lives on the document and every previous body is kept in its `versions`
3
+ // SUBCOLLECTION, one document per version, so a re-publish is an in-place update with
4
+ // history rather than a new URL.
5
+ //
6
+ // History used to be an array ON the page document. Firestore caps a document at 1 MiB, and
7
+ // a 55 KB paper republished about 25 times crossed it: every publish after that failed with a
8
+ // 500 and the live page silently stayed on the old version (the lightpaper, 2026-09-24). The
9
+ // first save of a page still carrying the array moves it into the subcollection.
10
+ import { randomInt } from 'node:crypto';
11
+ import { FieldValue } from 'firebase-admin/firestore';
12
+ // No 0/o, 1/l/i: an id read aloud or retyped from a screenshot must survive it.
13
+ const ALPHABET = 'abcdefghjkmnpqrstuvwxyz23456789';
14
+ export function newArtifactId() {
15
+ let s = '';
16
+ for (let i = 0; i < 8; i++)
17
+ s += ALPHABET[randomInt(ALPHABET.length)];
18
+ return s;
19
+ }
20
+ /** The Firestore-backed store. The instance hands in its own Firestore, so the shell
21
+ * never knows which project it is writing to; a hosted tenant passes a namespaced one. */
22
+ export function createArtifactStore(db, collection = 'artifacts') {
23
+ const col = () => db.collection(collection);
24
+ return {
25
+ get: (id) => getArtifact(col, id),
26
+ save: (input) => saveArtifact(col, input),
27
+ delete: (id) => deleteArtifact(col, id),
28
+ bumpViews: (id) => bumpViews(col, id),
29
+ setAccess: async (id, access) => {
30
+ const ref = col().doc(id);
31
+ if (!(await ref.get()).exists)
32
+ return false;
33
+ await ref.update({ access: access === 'public' ? FieldValue.delete() : access });
34
+ return true;
35
+ },
36
+ };
37
+ }
38
+ async function getArtifact(col, id) {
39
+ const snap = await col().doc(id).get();
40
+ if (!snap.exists)
41
+ return null;
42
+ return snap.data();
43
+ }
44
+ async function saveArtifact(col, input) {
45
+ const now = new Date().toISOString();
46
+ const fields = {
47
+ title: input.meta.title,
48
+ summary: input.meta.summary,
49
+ template: input.meta.template,
50
+ ...(input.meta.subtitle ? { subtitle: input.meta.subtitle } : {}),
51
+ ...(input.meta.audience ? { audience: input.meta.audience } : {}),
52
+ ...(input.meta.cover ? { cover: input.meta.cover } : {}),
53
+ ...(input.meta.voice ? { voice: input.meta.voice } : {}),
54
+ ...(input.meta.narration ? { narration: input.meta.narration } : {}),
55
+ ...(input.meta.timings ? { timings: input.meta.timings } : {}),
56
+ ...(input.meta.narrationHash ? { narrationHash: input.meta.narrationHash } : {}),
57
+ ...(input.meta.password ? { password: input.meta.password } : {}),
58
+ ...(input.meta.access && input.meta.access !== 'public' ? { access: input.meta.access } : {}),
59
+ };
60
+ if (input.id) {
61
+ const existing = await getArtifact(col, input.id);
62
+ if (!existing)
63
+ return { notFound: true };
64
+ const ref = col().doc(input.id);
65
+ const history = ref.collection('versions');
66
+ const legacy = existing.versions ?? [];
67
+ const current = existing.version ?? legacy.length + 1;
68
+ const vid = (n) => String(n).padStart(6, '0');
69
+ // Move any legacy array out first, then file the body being replaced under its own number.
70
+ for (let i = 0; i < legacy.length; i++)
71
+ await history.doc(vid(i + 1)).set({ version: i + 1, ...legacy[i] });
72
+ await history.doc(vid(current)).set({ version: current, markdown: existing.markdown, at: existing.updatedAt });
73
+ const next = current + 1;
74
+ // An update merges, so a password the file no longer carries has to be cleared on purpose:
75
+ // otherwise removing the line from the file would leave the door up on the live page.
76
+ const password = input.meta.password ? {} : { password: FieldValue.delete() };
77
+ // A subtitle the file no longer carries is gone from the page, like any other content.
78
+ const subtitle = input.meta.subtitle ? {} : { subtitle: FieldValue.delete() };
79
+ // Access is the opposite of password on purpose: absent leaves it alone, and only an explicit
80
+ // `access: public` opens the page. Reopening a confidential page must never be a side effect.
81
+ const access = input.meta.access === 'public' ? { access: FieldValue.delete() } : {};
82
+ await ref.update({
83
+ ...fields, ...password, ...subtitle, ...access, markdown: input.markdown, updatedAt: now, version: next,
84
+ ...(existing.versions ? { versions: FieldValue.delete() } : {}),
85
+ });
86
+ return { id: input.id, version: next, created: false };
87
+ }
88
+ const id = newArtifactId();
89
+ const rec = {
90
+ id,
91
+ ...fields,
92
+ markdown: input.markdown,
93
+ createdAt: now,
94
+ updatedAt: now,
95
+ version: 1,
96
+ views: 0,
97
+ };
98
+ await col().doc(id).set(rec);
99
+ return { id, version: 1, created: true };
100
+ }
101
+ async function deleteArtifact(col, id) {
102
+ const ref = col().doc(id);
103
+ const snap = await ref.get();
104
+ if (!snap.exists)
105
+ return false;
106
+ await ref.delete();
107
+ return true;
108
+ }
109
+ async function bumpViews(col, id) {
110
+ await col()
111
+ .doc(id)
112
+ .update({ views: FieldValue.increment(1) })
113
+ .catch(() => { });
114
+ }
@@ -0,0 +1,9 @@
1
+ export declare function keyHash(id: string, password: string): string;
2
+ export declare function unlockCookieName(id: string): string;
3
+ export declare function isUnlocked({ id, password, key, cookie }: {
4
+ id: string;
5
+ password?: string;
6
+ key?: string | null;
7
+ cookie?: string | null;
8
+ }): boolean;
9
+ export declare function unlockedUrl(pageUrl: string, password: string): string;
@@ -0,0 +1,24 @@
1
+ // A page with a `password:` in its front matter is shut until the reader opens it, and it
2
+ // opens two ways: `?key=<password>` in the URL (the link the author sends; the page then sets
3
+ // a cookie so the next visit needs nothing) or that cookie. The cookie holds a hash bound to
4
+ // the page id, so a stolen cookie names no password and opens no other page. Same shape as
5
+ // the wiki family's `?key=` links (share-a-wiki-page), so an operator learns one convention.
6
+ import { createHash } from 'node:crypto';
7
+ export function keyHash(id, password) {
8
+ return createHash('sha256').update(`${id}\n${password}`).digest('hex');
9
+ }
10
+ export function unlockCookieName(id) {
11
+ return `artifact_key_${id}`;
12
+ }
13
+ export function isUnlocked({ id, password, key, cookie }) {
14
+ if (!password)
15
+ return true;
16
+ if (typeof key === 'string' && key === password)
17
+ return true;
18
+ if (typeof cookie === 'string' && cookie === keyHash(id, password))
19
+ return true;
20
+ return false;
21
+ }
22
+ export function unlockedUrl(pageUrl, password) {
23
+ return `${pageUrl}?key=${encodeURIComponent(password)}`;
24
+ }
@@ -0,0 +1,5 @@
1
+ export declare function newsreader(): Promise<{
2
+ name: string;
3
+ data: ArrayBuffer;
4
+ weight: number;
5
+ }>;
@@ -0,0 +1,37 @@
1
+ // The Freedom look's share-card font: Newsreader 600 (OFL, a static instance from Google
2
+ // Fonts), the nearest open face to the Georgia the pack renders pages in. Bundled with the
3
+ // package so an operator with no pack of their own still unfurls as a title card rather than
4
+ // as nothing. It ships at `fonts/Newsreader-600.ttf` in the package root.
5
+ //
6
+ // WHY process.cwd(). A Next app bundles this module, so `import.meta.url` points into `.next/`,
7
+ // not at the package, and cannot find the font. A deployed function runs with the project root
8
+ // as its cwd, so the first candidate is where an installed package's font sits.
9
+ //
10
+ // Next's file tracer does NOT follow this literal into node_modules (measured on Next 16.3 with
11
+ // Turbopack, 2026-09-24), so a host names the font in `outputFileTracingIncludes` (README), and
12
+ // test/fixture does the same so `npm run test:packed` proves the font reaches a build.
13
+ //
14
+ // Only type imports reach this module from anything client-side, so the node imports are safe.
15
+ import { readFile } from 'node:fs/promises';
16
+ import { join } from 'node:path';
17
+ const CANDIDATES = [
18
+ // Installed as a dependency: the normal case for every host.
19
+ join(process.cwd(), 'node_modules/@supersuit/artifacts/fonts/Newsreader-600.ttf'),
20
+ // This package's own checkout, where its tests run.
21
+ join(process.cwd(), 'fonts/Newsreader-600.ttf'),
22
+ ];
23
+ async function firstReadable(paths) {
24
+ for (const p of paths) {
25
+ try {
26
+ return await readFile(p);
27
+ }
28
+ catch {
29
+ // try the next place
30
+ }
31
+ }
32
+ throw new Error(`@supersuit/artifacts: Newsreader-600.ttf not found. Looked in: ${paths.join(', ')}`);
33
+ }
34
+ export async function newsreader() {
35
+ const b = await firstReadable(CANDIDATES);
36
+ return { name: 'Newsreader', data: b.buffer.slice(b.byteOffset, b.byteOffset + b.byteLength), weight: 600 };
37
+ }
@@ -0,0 +1,3 @@
1
+ export { freedomDefault, type BrandPack, type ShareCardAssets } from './pack.js';
2
+ export { BrandGround, BrandMark } from './wrapper.js';
3
+ export { renderShareCard } from './share-card.js';
@@ -0,0 +1,3 @@
1
+ export { freedomDefault } from './pack.js';
2
+ export { BrandGround, BrandMark } from './wrapper.js';
3
+ export { renderShareCard } from './share-card.js';
@@ -0,0 +1,48 @@
1
+ import type { ComponentType, ReactNode } from 'react';
2
+ /**
3
+ * A brand pack is what makes an operator's pages theirs: data plus at most two
4
+ * components. The package ships `freedomDefault`; an instance passes its own. A pack
5
+ * that carries anyone's trademark lives in that instance, never in this package.
6
+ */
7
+ export type BrandPack = {
8
+ id: string;
9
+ name: string;
10
+ /** CSS colours. */
11
+ ground: string;
12
+ ink: string;
13
+ accent: string;
14
+ /** CSS font-family values for headings and body. */
15
+ type: {
16
+ display: string;
17
+ body: string;
18
+ };
19
+ /** The small uppercase line above a title, e.g. "From Sam Rivera". */
20
+ kicker: string;
21
+ /** The label under the play button, by narrator voice id. */
22
+ narratorLabel: (voice?: string) => string;
23
+ /** Optional full-page backdrop. Receives the page as children. */
24
+ Wrapper?: ComponentType<{
25
+ children: ReactNode;
26
+ }>;
27
+ /** Optional mark rendered once above the kicker. */
28
+ Mark?: ComponentType<{
29
+ className?: string;
30
+ }>;
31
+ /** How a page with no cover unfurls: the shell draws its title over these, so a link
32
+ * previews as the page rather than as the brand. Absent, the instance's static default
33
+ * share image is used. Both loaders run once per instance. */
34
+ share?: ShareCardAssets;
35
+ };
36
+ export type ShareCardAssets = {
37
+ /** The display font, as bytes the renderer can embed. Satori needs a real file: a CSS
38
+ * font-family name is not enough. */
39
+ font: () => Promise<{
40
+ name: string;
41
+ data: ArrayBuffer;
42
+ weight: number;
43
+ }>;
44
+ /** A 1200x630 image as a data: URL, drawn full-bleed under the text. Anything that needs
45
+ * a blur or a glow is baked in here; the renderer only sets type. Absent, `ground` fills. */
46
+ backdrop?: () => Promise<string>;
47
+ };
48
+ export declare const freedomDefault: BrandPack;
@@ -0,0 +1,17 @@
1
+ import { newsreader } from './default-share.js';
2
+ export const freedomDefault = {
3
+ id: 'freedom-default',
4
+ name: 'Freedom',
5
+ ground: '#0e0f13',
6
+ ink: '#ece7dc',
7
+ accent: '#c9a96e',
8
+ type: {
9
+ display: 'Georgia, "Iowan Old Style", "Times New Roman", serif',
10
+ body: 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif',
11
+ },
12
+ kicker: 'A page from Freedom',
13
+ narratorLabel: () => 'Read aloud by Freedom',
14
+ // Pages unfurl as their title on the ground colour. A pack with a backdrop of its own
15
+ // overrides this whole field.
16
+ share: { font: newsreader },
17
+ };
@@ -0,0 +1,13 @@
1
+ import type { BrandPack } from './pack.js';
2
+ export declare const SHARE_W = 1200;
3
+ export declare const SHARE_H = 630;
4
+ /** Titles get smaller as they get longer, so three lines always fit above the mark. */
5
+ export declare function titleSize(title: string): number;
6
+ export declare function ShareCard({ pack, title, fontName, backdrop }: {
7
+ pack: BrandPack;
8
+ title: string;
9
+ fontName: string;
10
+ backdrop?: string;
11
+ }): import("react").JSX.Element;
12
+ /** A 1200x630 PNG for one page. Caller has already checked `pack.share` exists. */
13
+ export declare function renderShareCard(pack: BrandPack, title: string): Promise<Response>;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ // The title card a page unfurls as when it has no cover: kicker, title, the pack's backdrop.
3
+ // Drawn with next/og (Satori), which lays out a subset of CSS: flex only, inline styles, no
4
+ // blur or glow. Anything atmospheric is the backdrop's job. The lower-left corner is left
5
+ // clear, because a backdrop may put a small mark there.
6
+ import { ImageResponse } from 'next/og';
7
+ export const SHARE_W = 1200;
8
+ export const SHARE_H = 630;
9
+ const loaded = new WeakMap();
10
+ function assetsFor(pack) {
11
+ let p = loaded.get(pack);
12
+ if (!p) {
13
+ const share = pack.share;
14
+ p = Promise.all([share.font(), share.backdrop?.()]).then(([font, backdrop]) => ({ font, backdrop }));
15
+ loaded.set(pack, p);
16
+ }
17
+ return p;
18
+ }
19
+ /** Titles get smaller as they get longer, so three lines always fit above the mark. */
20
+ export function titleSize(title) {
21
+ if (title.length <= 40)
22
+ return 76;
23
+ if (title.length <= 80)
24
+ return 62;
25
+ return 52;
26
+ }
27
+ export function ShareCard({ pack, title, fontName, backdrop }) {
28
+ const size = titleSize(title);
29
+ return (_jsxs("div", { style: {
30
+ display: 'flex',
31
+ flexDirection: 'column',
32
+ width: SHARE_W,
33
+ height: SHARE_H,
34
+ padding: '96px 96px 0',
35
+ backgroundColor: pack.ground,
36
+ ...(backdrop ? { backgroundImage: `url(${backdrop})`, backgroundSize: `${SHARE_W}px ${SHARE_H}px` } : {}),
37
+ color: pack.ink,
38
+ fontFamily: fontName,
39
+ }, children: [_jsx("div", { style: { display: 'flex', fontSize: 22, letterSpacing: 5, textTransform: 'uppercase', color: pack.accent, fontWeight: 500 }, children: pack.kicker }), _jsx("div", { style: { display: 'flex', marginTop: 28, fontSize: size, lineHeight: 1.18, maxWidth: 980, lineClamp: 3 }, children: title })] }));
40
+ }
41
+ /** A 1200x630 PNG for one page. Caller has already checked `pack.share` exists. */
42
+ export async function renderShareCard(pack, title) {
43
+ const { font, backdrop } = await assetsFor(pack);
44
+ return new ImageResponse(_jsx(ShareCard, { pack: pack, title: title, fontName: font.name, backdrop: backdrop }), {
45
+ width: SHARE_W,
46
+ height: SHARE_H,
47
+ fonts: [{ name: font.name, data: font.data, weight: font.weight, style: 'normal' }],
48
+ headers: { 'cache-control': 'public, max-age=3600, s-maxage=31536000, stale-while-revalidate=86400' },
49
+ });
50
+ }
@@ -0,0 +1,12 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { BrandPack } from './pack.js';
3
+ /** The page backdrop: the pack's own Wrapper when it has one, else a flat ground. */
4
+ export declare function BrandGround({ pack, children }: {
5
+ pack: BrandPack;
6
+ children: ReactNode;
7
+ }): import("react").JSX.Element;
8
+ /** The mark above the kicker: the pack's own, else a small dove. */
9
+ export declare function BrandMark({ pack, className }: {
10
+ pack: BrandPack;
11
+ className?: string;
12
+ }): import("react").JSX.Element;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ /** The page backdrop: the pack's own Wrapper when it has one, else a flat ground. */
3
+ export function BrandGround({ pack, children }) {
4
+ if (pack.Wrapper)
5
+ return _jsx(pack.Wrapper, { children: children });
6
+ return (_jsx("div", { "data-brand-ground": pack.id, className: "min-h-screen", style: { background: pack.ground, color: pack.ink, fontFamily: pack.type.body }, children: children }));
7
+ }
8
+ /** The mark above the kicker: the pack's own, else a small dove. */
9
+ export function BrandMark({ pack, className }) {
10
+ if (pack.Mark)
11
+ return _jsx(pack.Mark, { className: className });
12
+ return (_jsx("span", { "data-brand-mark": pack.id, "aria-hidden": true, className: className, style: { color: pack.accent }, children: "\uD83D\uDD4A" }));
13
+ }
package/lib/gate.d.ts ADDED
@@ -0,0 +1 @@
1
+ export declare const ARTIFACT_PUBLIC_PREFIXES: readonly ["/a/", "/api/artifacts"];
package/lib/gate.js ADDED
@@ -0,0 +1,5 @@
1
+ // Routes every instance serves without its site gate. The trailing slash on '/a/' is
2
+ // load-bearing: a bare '/a' prefix also matched /admin on the pilot (2026-09-11).
3
+ // On a dedicated host every path is public and this list is moot; it exists for an
4
+ // instance that mounts artifacts inside a gated site under the '/a/' prefix.
5
+ export const ARTIFACT_PUBLIC_PREFIXES = ['/a/', '/api/artifacts'];
package/lib/index.d.ts ADDED
@@ -0,0 +1,4 @@
1
+ export * from './artifacts/index.js';
2
+ export * from './brand/index.js';
3
+ export * from './gate.js';
4
+ export { createArtifactRoutes, type ArtifactRoutesConfig } from './routes/artifacts.js';
package/lib/index.js ADDED
@@ -0,0 +1,4 @@
1
+ export * from './artifacts/index.js';
2
+ export * from './brand/index.js';
3
+ export * from './gate.js';
4
+ export { createArtifactRoutes } from './routes/artifacts.js';
@@ -0,0 +1,14 @@
1
+ export type WordTiming = {
2
+ w: string;
3
+ s: number;
4
+ e: number;
5
+ };
6
+ export declare function ArtifactReader({ src, words, rootId, label, accent, ground, }: {
7
+ src: string;
8
+ words: WordTiming[];
9
+ rootId: string;
10
+ /** The line under the play button; the brand pack decides it from the voice. */
11
+ label: string;
12
+ accent: string;
13
+ ground: string;
14
+ }): import("react").JSX.Element;
@@ -0,0 +1,165 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ // Speechify-style read-along for a published artifact. The narration was generated
4
+ // from the same rendered text this page shows (see lib/artifacts/narration.ts), so
5
+ // the highlighter walks the DOM in reading order, wraps each word, and aligns that
6
+ // sequence to the word timings by normalized token, resyncing within a short window
7
+ // when the two disagree. Click a word to seek; the bar at the bottom plays and paces.
8
+ import { useEffect, useRef, useState } from 'react';
9
+ const SKIP = '[data-nospeak], [data-artifact-links], pre, [data-footnotes], sup, img';
10
+ function normalize(w) {
11
+ return w.toLowerCase().replace(/[^\p{L}\p{N}]/gu, '');
12
+ }
13
+ /** Wrap every word inside `root` (except skipped subtrees) in a span; return them in order. */
14
+ function wrapWords(root) {
15
+ const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
16
+ acceptNode: (n) => {
17
+ const el = n.parentElement;
18
+ if (!el || el.closest(SKIP))
19
+ return NodeFilter.FILTER_REJECT;
20
+ return n.nodeValue && n.nodeValue.trim() ? NodeFilter.FILTER_ACCEPT : NodeFilter.FILTER_REJECT;
21
+ },
22
+ });
23
+ const nodes = [];
24
+ for (let n = walker.nextNode(); n; n = walker.nextNode())
25
+ nodes.push(n);
26
+ const spans = [];
27
+ for (const node of nodes) {
28
+ const parts = node.nodeValue.split(/(\s+)/);
29
+ const frag = document.createDocumentFragment();
30
+ for (const part of parts) {
31
+ if (!part)
32
+ continue;
33
+ if (/^\s+$/.test(part)) {
34
+ frag.appendChild(document.createTextNode(part));
35
+ continue;
36
+ }
37
+ const span = document.createElement('span');
38
+ span.textContent = part;
39
+ span.className = 'artifact-word';
40
+ frag.appendChild(span);
41
+ spans.push(span);
42
+ }
43
+ node.parentNode.replaceChild(frag, node);
44
+ }
45
+ return spans;
46
+ }
47
+ /** Map each timing index to a DOM span index, tolerating small mismatches. */
48
+ function align(spans, words) {
49
+ const out = [];
50
+ let j = 0;
51
+ for (let i = 0; i < words.length; i++) {
52
+ const target = normalize(words[i].w);
53
+ if (!target) {
54
+ out.push(null);
55
+ continue;
56
+ }
57
+ let found = -1;
58
+ for (let k = j; k < Math.min(spans.length, j + 6); k++) {
59
+ if (normalize(spans[k].textContent ?? '') === target) {
60
+ found = k;
61
+ break;
62
+ }
63
+ }
64
+ if (found === -1) {
65
+ out.push(null);
66
+ continue;
67
+ }
68
+ out.push(spans[found]);
69
+ j = found + 1;
70
+ }
71
+ return out;
72
+ }
73
+ function fmt(t) {
74
+ const m = Math.floor(t / 60);
75
+ const s = Math.floor(t % 60);
76
+ return `${m}:${s.toString().padStart(2, '0')}`;
77
+ }
78
+ export function ArtifactReader({ src, words, rootId, label, accent, ground, }) {
79
+ const narrator = label;
80
+ const audioRef = useRef(null);
81
+ const mapRef = useRef([]);
82
+ const litRef = useRef(null);
83
+ const [playing, setPlaying] = useState(false);
84
+ const [t, setT] = useState(0);
85
+ const [dur, setDur] = useState(0);
86
+ const [rate, setRate] = useState(1);
87
+ useEffect(() => {
88
+ const root = document.getElementById(rootId);
89
+ if (!root)
90
+ return;
91
+ const spans = wrapWords(root);
92
+ mapRef.current = align(spans, words);
93
+ const onClick = (e) => {
94
+ const el = e.target.closest('.artifact-word');
95
+ if (!el)
96
+ return;
97
+ const idx = mapRef.current.indexOf(el);
98
+ if (idx === -1 || !audioRef.current)
99
+ return;
100
+ audioRef.current.currentTime = words[idx].s;
101
+ void audioRef.current.play();
102
+ };
103
+ root.addEventListener('click', onClick);
104
+ return () => root.removeEventListener('click', onClick);
105
+ }, [rootId, words]);
106
+ useEffect(() => {
107
+ const a = audioRef.current;
108
+ if (!a)
109
+ return;
110
+ let raf = 0;
111
+ const tick = () => {
112
+ const now = a.currentTime;
113
+ setT(now);
114
+ // Binary search the word at `now`.
115
+ let lo = 0;
116
+ let hi = words.length - 1;
117
+ let idx = -1;
118
+ while (lo <= hi) {
119
+ const mid = (lo + hi) >> 1;
120
+ if (words[mid].s <= now) {
121
+ idx = mid;
122
+ lo = mid + 1;
123
+ }
124
+ else
125
+ hi = mid - 1;
126
+ }
127
+ const el = idx >= 0 && now <= words[idx].e + 0.25 ? mapRef.current[idx] : null;
128
+ if (el !== litRef.current) {
129
+ litRef.current?.classList.remove('artifact-word-lit');
130
+ el?.classList.add('artifact-word-lit');
131
+ litRef.current = el;
132
+ if (el && playing) {
133
+ const r = el.getBoundingClientRect();
134
+ if (r.top < 80 || r.bottom > window.innerHeight - 140) {
135
+ el.scrollIntoView({ block: 'center', behavior: 'smooth' });
136
+ }
137
+ }
138
+ }
139
+ raf = requestAnimationFrame(tick);
140
+ };
141
+ raf = requestAnimationFrame(tick);
142
+ return () => cancelAnimationFrame(raf);
143
+ }, [words, playing]);
144
+ const toggle = () => {
145
+ const a = audioRef.current;
146
+ if (!a)
147
+ return;
148
+ if (a.paused)
149
+ void a.play();
150
+ else
151
+ a.pause();
152
+ };
153
+ return (_jsxs(_Fragment, { children: [_jsx("style", { children: `
154
+ .artifact-word { border-radius: 3px; transition: background-color 120ms; }
155
+ .artifact-word:hover { background: rgba(255,255,255,0.08); cursor: pointer; }
156
+ .artifact-word-lit { background: ${accent}59; color: #fff; }
157
+ ` }), _jsx("audio", { ref: audioRef, src: src, preload: "metadata", onPlay: () => setPlaying(true), onPause: () => setPlaying(false), onLoadedMetadata: (e) => setDur(e.target.duration), onRateChange: (e) => setRate(e.target.playbackRate) }), _jsx("div", { className: "fixed inset-x-0 bottom-0 z-40 border-t border-white/10 backdrop-blur", style: { background: `${ground}e6` }, children: _jsxs("div", { className: "mx-auto flex max-w-2xl items-center gap-4 px-6 py-3", children: [_jsx("button", { type: "button", onClick: toggle, "aria-label": playing ? 'Pause narration' : 'Play narration', className: "flex h-11 w-11 shrink-0 items-center justify-center rounded-full", style: { background: accent, color: ground }, children: playing ? (_jsxs("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: [_jsx("rect", { x: "6", y: "5", width: "4", height: "14" }), _jsx("rect", { x: "14", y: "5", width: "4", height: "14" })] })) : (_jsx("svg", { width: "18", height: "18", viewBox: "0 0 24 24", fill: "currentColor", children: _jsx("path", { d: "M8 5v14l11-7z" }) })) }), _jsxs("div", { className: "min-w-0 flex-1", children: [_jsx("div", { className: "text-[11px] uppercase tracking-[0.2em]", style: { color: accent }, children: narrator }), _jsx("input", { type: "range", min: 0, max: dur || 0, step: 0.1, value: Math.min(t, dur || 0), onChange: (e) => {
158
+ if (audioRef.current)
159
+ audioRef.current.currentTime = Number(e.target.value);
160
+ }, "aria-label": "Seek", className: "mt-1 w-full", style: { accentColor: accent } })] }), _jsxs("div", { className: "w-16 shrink-0 text-right text-xs tabular-nums text-zinc-400", children: [fmt(t), " / ", fmt(dur)] }), _jsxs("button", { type: "button", onClick: () => {
161
+ const next = rate >= 1.5 ? 1 : rate + 0.25;
162
+ if (audioRef.current)
163
+ audioRef.current.playbackRate = next;
164
+ }, className: "shrink-0 rounded border border-white/15 px-2 py-1 text-xs text-zinc-300", "aria-label": "Playback speed", children: [rate, "x"] })] }) }), _jsx("div", { className: "h-20", "aria-hidden": true })] }));
165
+ }
@@ -0,0 +1,7 @@
1
+ /** Quoting a sentence is fine; lifting a section is not. */
2
+ export declare const COPY_LIMIT = 280;
3
+ export declare function ReaderWatch({ artifactId, endpoint, accent }: {
4
+ artifactId: string;
5
+ endpoint: string;
6
+ accent: string;
7
+ }): import("react").JSX.Element | null;