@shivaedev/work-board 0.2.1 → 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.
Files changed (316) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +141 -3
  3. package/dist/board.d.ts.map +1 -1
  4. package/dist/board.js +9 -1
  5. package/dist/board.js.map +1 -1
  6. package/dist/cli.js +1 -1
  7. package/dist/files/changes.d.ts +1 -0
  8. package/dist/files/changes.d.ts.map +1 -1
  9. package/dist/files/changes.js +1 -1
  10. package/dist/files/changes.js.map +1 -1
  11. package/dist/files/url.d.ts +2 -0
  12. package/dist/files/url.d.ts.map +1 -0
  13. package/dist/files/url.js +4 -0
  14. package/dist/files/url.js.map +1 -0
  15. package/dist/http/assets.d.ts.map +1 -1
  16. package/dist/http/assets.js +16 -1
  17. package/dist/http/assets.js.map +1 -1
  18. package/dist/http/identity.d.ts +7 -0
  19. package/dist/http/identity.d.ts.map +1 -0
  20. package/dist/http/identity.js +38 -0
  21. package/dist/http/identity.js.map +1 -0
  22. package/dist/http/page.d.ts +2 -1
  23. package/dist/http/page.d.ts.map +1 -1
  24. package/dist/http/page.js +29 -7
  25. package/dist/http/page.js.map +1 -1
  26. package/dist/http/search.d.ts +5 -0
  27. package/dist/http/search.d.ts.map +1 -0
  28. package/dist/http/search.js +11 -0
  29. package/dist/http/search.js.map +1 -0
  30. package/dist/http/work.d.ts +6 -0
  31. package/dist/http/work.d.ts.map +1 -0
  32. package/dist/http/work.js +22 -0
  33. package/dist/http/work.js.map +1 -0
  34. package/dist/metadata/backlinks.d.ts +8 -0
  35. package/dist/metadata/backlinks.d.ts.map +1 -0
  36. package/dist/metadata/backlinks.js +29 -0
  37. package/dist/metadata/backlinks.js.map +1 -0
  38. package/dist/metadata/fields.d.ts +29 -0
  39. package/dist/metadata/fields.d.ts.map +1 -0
  40. package/dist/metadata/fields.js +25 -0
  41. package/dist/metadata/fields.js.map +1 -0
  42. package/dist/metadata/links.d.ts +4 -0
  43. package/dist/metadata/links.d.ts.map +1 -0
  44. package/dist/metadata/links.js +18 -0
  45. package/dist/metadata/links.js.map +1 -0
  46. package/dist/metadata/locations.d.ts +3 -0
  47. package/dist/metadata/locations.d.ts.map +1 -0
  48. package/dist/metadata/locations.js +35 -0
  49. package/dist/metadata/locations.js.map +1 -0
  50. package/dist/metadata/model.d.ts +15 -0
  51. package/dist/metadata/model.d.ts.map +1 -0
  52. package/dist/metadata/model.js +13 -0
  53. package/dist/metadata/model.js.map +1 -0
  54. package/dist/metadata/parse.d.ts +16 -0
  55. package/dist/metadata/parse.d.ts.map +1 -0
  56. package/dist/metadata/parse.js +48 -0
  57. package/dist/metadata/parse.js.map +1 -0
  58. package/dist/metadata/problems.d.ts +4 -0
  59. package/dist/metadata/problems.d.ts.map +1 -0
  60. package/dist/metadata/problems.js +57 -0
  61. package/dist/metadata/problems.js.map +1 -0
  62. package/dist/metadata/relationshipLabel.d.ts +2 -0
  63. package/dist/metadata/relationshipLabel.d.ts.map +1 -0
  64. package/dist/metadata/relationshipLabel.js +13 -0
  65. package/dist/metadata/relationshipLabel.js.map +1 -0
  66. package/dist/metadata/schema.d.ts +34 -0
  67. package/dist/metadata/schema.d.ts.map +1 -0
  68. package/dist/metadata/schema.js +27 -0
  69. package/dist/metadata/schema.js.map +1 -0
  70. package/dist/metadata/sourceLinks.d.ts +5 -0
  71. package/dist/metadata/sourceLinks.d.ts.map +1 -0
  72. package/dist/metadata/sourceLinks.js +44 -0
  73. package/dist/metadata/sourceLinks.js.map +1 -0
  74. package/dist/page/backlinksHtml.d.ts +3 -0
  75. package/dist/page/backlinksHtml.d.ts.map +1 -0
  76. package/dist/page/backlinksHtml.js +16 -0
  77. package/dist/page/backlinksHtml.js.map +1 -0
  78. package/dist/page/board.d.ts +1 -1
  79. package/dist/page/board.d.ts.map +1 -1
  80. package/dist/page/board.js +14 -4
  81. package/dist/page/board.js.map +1 -1
  82. package/dist/page/client.d.ts +2 -1
  83. package/dist/page/client.d.ts.map +1 -1
  84. package/dist/page/client.js +65 -7
  85. package/dist/page/client.js.map +1 -1
  86. package/dist/page/criteriaEvidence.d.ts +4 -0
  87. package/dist/page/criteriaEvidence.d.ts.map +1 -0
  88. package/dist/page/criteriaEvidence.js +27 -0
  89. package/dist/page/criteriaEvidence.js.map +1 -0
  90. package/dist/page/diagrams.d.ts +1 -1
  91. package/dist/page/diagrams.d.ts.map +1 -1
  92. package/dist/page/diagrams.js +12 -4
  93. package/dist/page/diagrams.js.map +1 -1
  94. package/dist/page/document.d.ts +1 -1
  95. package/dist/page/document.d.ts.map +1 -1
  96. package/dist/page/document.js +6 -3
  97. package/dist/page/document.js.map +1 -1
  98. package/dist/page/libraryScript.d.ts +2 -0
  99. package/dist/page/libraryScript.d.ts.map +1 -0
  100. package/dist/page/libraryScript.js +73 -0
  101. package/dist/page/libraryScript.js.map +1 -0
  102. package/dist/page/metadata.d.ts +5 -0
  103. package/dist/page/metadata.d.ts.map +1 -0
  104. package/dist/page/metadata.js +34 -0
  105. package/dist/page/metadata.js.map +1 -0
  106. package/dist/page/metadataDetails.d.ts +4 -0
  107. package/dist/page/metadataDetails.d.ts.map +1 -0
  108. package/dist/page/metadataDetails.js +21 -0
  109. package/dist/page/metadataDetails.js.map +1 -0
  110. package/dist/page/metadataLinks.d.ts +6 -0
  111. package/dist/page/metadataLinks.d.ts.map +1 -0
  112. package/dist/page/metadataLinks.js +31 -0
  113. package/dist/page/metadataLinks.js.map +1 -0
  114. package/dist/page/nav.d.ts.map +1 -1
  115. package/dist/page/nav.js +3 -3
  116. package/dist/page/nav.js.map +1 -1
  117. package/dist/page/navigationScript.d.ts +2 -0
  118. package/dist/page/navigationScript.d.ts.map +1 -0
  119. package/dist/page/navigationScript.js +104 -0
  120. package/dist/page/navigationScript.js.map +1 -0
  121. package/dist/page/readingStateScript.d.ts +2 -0
  122. package/dist/page/readingStateScript.d.ts.map +1 -0
  123. package/dist/page/readingStateScript.js +62 -0
  124. package/dist/page/readingStateScript.js.map +1 -0
  125. package/dist/page/readingStyles.d.ts +2 -0
  126. package/dist/page/readingStyles.d.ts.map +1 -0
  127. package/dist/page/readingStyles.js +29 -0
  128. package/dist/page/readingStyles.js.map +1 -0
  129. package/dist/page/readingTools.d.ts +3 -0
  130. package/dist/page/readingTools.d.ts.map +1 -0
  131. package/dist/page/readingTools.js +10 -0
  132. package/dist/page/readingTools.js.map +1 -0
  133. package/dist/page/recordedEvidence.d.ts +4 -0
  134. package/dist/page/recordedEvidence.d.ts.map +1 -0
  135. package/dist/page/recordedEvidence.js +10 -0
  136. package/dist/page/recordedEvidence.js.map +1 -0
  137. package/dist/page/searchDialog.d.ts +3 -0
  138. package/dist/page/searchDialog.d.ts.map +1 -0
  139. package/dist/page/searchDialog.js +16 -0
  140. package/dist/page/searchDialog.js.map +1 -0
  141. package/dist/page/searchResultsScript.d.ts +2 -0
  142. package/dist/page/searchResultsScript.d.ts.map +1 -0
  143. package/dist/page/searchResultsScript.js +64 -0
  144. package/dist/page/searchResultsScript.js.map +1 -0
  145. package/dist/page/searchScript.d.ts +2 -0
  146. package/dist/page/searchScript.d.ts.map +1 -0
  147. package/dist/page/searchScript.js +73 -0
  148. package/dist/page/searchScript.js.map +1 -0
  149. package/dist/page/shell.d.ts +1 -1
  150. package/dist/page/shell.d.ts.map +1 -1
  151. package/dist/page/shell.js +23 -5
  152. package/dist/page/shell.js.map +1 -1
  153. package/dist/page/stateScript.d.ts +2 -0
  154. package/dist/page/stateScript.d.ts.map +1 -0
  155. package/dist/page/stateScript.js +49 -0
  156. package/dist/page/stateScript.js.map +1 -0
  157. package/dist/page/style.d.ts.map +1 -1
  158. package/dist/page/style.js +14 -34
  159. package/dist/page/style.js.map +1 -1
  160. package/dist/page/swap.d.ts +1 -1
  161. package/dist/page/swap.d.ts.map +1 -1
  162. package/dist/page/swap.js +4 -1
  163. package/dist/page/swap.js.map +1 -1
  164. package/dist/page/workspaceStyles.d.ts +3 -0
  165. package/dist/page/workspaceStyles.d.ts.map +1 -0
  166. package/dist/page/workspaceStyles.js +79 -0
  167. package/dist/page/workspaceStyles.js.map +1 -0
  168. package/dist/render/documentHeadings.d.ts +667 -0
  169. package/dist/render/documentHeadings.d.ts.map +1 -0
  170. package/dist/render/documentHeadings.js +45 -0
  171. package/dist/render/documentHeadings.js.map +1 -0
  172. package/dist/render/documentLinks.d.ts +659 -0
  173. package/dist/render/documentLinks.d.ts.map +1 -0
  174. package/dist/render/documentLinks.js +19 -0
  175. package/dist/render/documentLinks.js.map +1 -0
  176. package/dist/render/markdown.d.ts +7 -1
  177. package/dist/render/markdown.d.ts.map +1 -1
  178. package/dist/render/markdown.js +13 -2
  179. package/dist/render/markdown.js.map +1 -1
  180. package/dist/search/collector.d.ts +13 -0
  181. package/dist/search/collector.d.ts.map +1 -0
  182. package/dist/search/collector.js +40 -0
  183. package/dist/search/collector.js.map +1 -0
  184. package/dist/search/entries.d.ts +10 -0
  185. package/dist/search/entries.d.ts.map +1 -0
  186. package/dist/search/entries.js +37 -0
  187. package/dist/search/entries.js.map +1 -0
  188. package/dist/search/linkCollector.d.ts +8 -0
  189. package/dist/search/linkCollector.d.ts.map +1 -0
  190. package/dist/search/linkCollector.js +16 -0
  191. package/dist/search/linkCollector.js.map +1 -0
  192. package/dist/search/match.d.ts +14 -0
  193. package/dist/search/match.d.ts.map +1 -0
  194. package/dist/search/match.js +26 -0
  195. package/dist/search/match.js.map +1 -0
  196. package/dist/search/metadataEntries.d.ts +4 -0
  197. package/dist/search/metadataEntries.d.ts.map +1 -0
  198. package/dist/search/metadataEntries.js +40 -0
  199. package/dist/search/metadataEntries.js.map +1 -0
  200. package/dist/search/snapshot.d.ts +13 -0
  201. package/dist/search/snapshot.d.ts.map +1 -0
  202. package/dist/search/snapshot.js +50 -0
  203. package/dist/search/snapshot.js.map +1 -0
  204. package/dist/views/items.d.ts +11 -0
  205. package/dist/views/items.d.ts.map +1 -0
  206. package/dist/views/items.js +48 -0
  207. package/dist/views/items.js.map +1 -0
  208. package/dist/views/projection.d.ts +15 -0
  209. package/dist/views/projection.d.ts.map +1 -0
  210. package/dist/views/projection.js +47 -0
  211. package/dist/views/projection.js.map +1 -0
  212. package/dist/views/savedScript.d.ts +2 -0
  213. package/dist/views/savedScript.d.ts.map +1 -0
  214. package/dist/views/savedScript.js +89 -0
  215. package/dist/views/savedScript.js.map +1 -0
  216. package/dist/views/state.d.ts +15 -0
  217. package/dist/views/state.d.ts.map +1 -0
  218. package/dist/views/state.js +45 -0
  219. package/dist/views/state.js.map +1 -0
  220. package/dist/views/tools.d.ts +3 -0
  221. package/dist/views/tools.d.ts.map +1 -0
  222. package/dist/views/tools.js +11 -0
  223. package/dist/views/tools.js.map +1 -0
  224. package/dist/views/workCards.d.ts +4 -0
  225. package/dist/views/workCards.d.ts.map +1 -0
  226. package/dist/views/workCards.js +23 -0
  227. package/dist/views/workCards.js.map +1 -0
  228. package/dist/views/workControls.d.ts +4 -0
  229. package/dist/views/workControls.d.ts.map +1 -0
  230. package/dist/views/workControls.js +32 -0
  231. package/dist/views/workControls.js.map +1 -0
  232. package/dist/views/workDetail.d.ts +13 -0
  233. package/dist/views/workDetail.d.ts.map +1 -0
  234. package/dist/views/workDetail.js +26 -0
  235. package/dist/views/workDetail.js.map +1 -0
  236. package/dist/views/workHtml.d.ts +15 -0
  237. package/dist/views/workHtml.d.ts.map +1 -0
  238. package/dist/views/workHtml.js +35 -0
  239. package/dist/views/workHtml.js.map +1 -0
  240. package/dist/views/workStyle.d.ts +2 -0
  241. package/dist/views/workStyle.d.ts.map +1 -0
  242. package/dist/views/workStyle.js +18 -0
  243. package/dist/views/workStyle.js.map +1 -0
  244. package/dist/views/workTable.d.ts +4 -0
  245. package/dist/views/workTable.d.ts.map +1 -0
  246. package/dist/views/workTable.js +19 -0
  247. package/dist/views/workTable.js.map +1 -0
  248. package/package.json +11 -8
  249. package/src/board.ts +9 -1
  250. package/src/cli.ts +1 -1
  251. package/src/files/changes.ts +2 -1
  252. package/src/files/url.ts +3 -0
  253. package/src/http/assets.ts +16 -1
  254. package/src/http/identity.ts +62 -0
  255. package/src/http/page.ts +46 -9
  256. package/src/http/search.ts +13 -0
  257. package/src/http/work.ts +41 -0
  258. package/src/metadata/backlinks.ts +39 -0
  259. package/src/metadata/fields.ts +26 -0
  260. package/src/metadata/links.ts +19 -0
  261. package/src/metadata/locations.ts +34 -0
  262. package/src/metadata/model.ts +27 -0
  263. package/src/metadata/parse.ts +62 -0
  264. package/src/metadata/problems.ts +62 -0
  265. package/src/metadata/relationshipLabel.ts +13 -0
  266. package/src/metadata/schema.ts +36 -0
  267. package/src/metadata/sourceLinks.ts +46 -0
  268. package/src/page/backlinksHtml.ts +18 -0
  269. package/src/page/board.ts +14 -4
  270. package/src/page/client.ts +65 -7
  271. package/src/page/criteriaEvidence.ts +36 -0
  272. package/src/page/diagrams.ts +12 -4
  273. package/src/page/document.ts +6 -3
  274. package/src/page/libraryScript.ts +72 -0
  275. package/src/page/metadata.ts +40 -0
  276. package/src/page/metadataDetails.ts +24 -0
  277. package/src/page/metadataLinks.ts +35 -0
  278. package/src/page/nav.ts +5 -4
  279. package/src/page/navigationScript.ts +103 -0
  280. package/src/page/readingStateScript.ts +61 -0
  281. package/src/page/readingStyles.ts +28 -0
  282. package/src/page/readingTools.ts +12 -0
  283. package/src/page/recordedEvidence.ts +12 -0
  284. package/src/page/searchDialog.ts +16 -0
  285. package/src/page/searchResultsScript.ts +63 -0
  286. package/src/page/searchScript.ts +72 -0
  287. package/src/page/shell.ts +24 -5
  288. package/src/page/stateScript.ts +48 -0
  289. package/src/page/style.ts +14 -35
  290. package/src/page/swap.ts +4 -1
  291. package/src/page/workspaceStyles.ts +79 -0
  292. package/src/render/documentHeadings.ts +52 -0
  293. package/src/render/documentLinks.ts +19 -0
  294. package/src/render/markdown.ts +21 -4
  295. package/src/search/collector.ts +44 -0
  296. package/src/search/entries.ts +51 -0
  297. package/src/search/linkCollector.ts +16 -0
  298. package/src/search/match.ts +28 -0
  299. package/src/search/metadataEntries.ts +42 -0
  300. package/src/search/snapshot.ts +60 -0
  301. package/src/views/items.ts +55 -0
  302. package/src/views/projection.ts +59 -0
  303. package/src/views/savedScript.ts +88 -0
  304. package/src/views/state.ts +47 -0
  305. package/src/views/tools.ts +11 -0
  306. package/src/views/workCards.ts +26 -0
  307. package/src/views/workControls.ts +38 -0
  308. package/src/views/workDetail.ts +28 -0
  309. package/src/views/workHtml.ts +39 -0
  310. package/src/views/workStyle.ts +17 -0
  311. package/src/views/workTable.ts +21 -0
  312. package/dist/index.d.ts +0 -2
  313. package/dist/index.d.ts.map +0 -1
  314. package/dist/index.js +0 -2
  315. package/dist/index.js.map +0 -1
  316. package/src/index.ts +0 -1
package/src/page/board.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { Effect } from "effect";
2
2
  import { type Board, countsOf, type Section } from "#render/board.ts";
3
+ import { documentHeadings } from "#render/documentHeadings.ts";
3
4
  import { renderMarkdown } from "#render/markdown.ts";
4
5
  import { escapeHtml } from "./escape.ts";
6
+ import { readingTools } from "./readingTools.ts";
5
7
  import { updated } from "./updated.ts";
6
8
 
7
9
  const counts = (board: Board): string =>
@@ -12,22 +14,30 @@ const counts = (board: Board): string =>
12
14
  type Render = (fragment: string) => ReturnType<typeof renderMarkdown>;
13
15
 
14
16
  const section = Effect.fn("WorkBoard.section")(function* ({ heading, items, notes }: Section, render: Render) {
15
- const rendered = yield* Effect.forEach(items, (item) => Effect.map(render(item), (html) => `<article class="item">${html}</article>`));
17
+ const title = yield* render(heading);
16
18
  const note = notes === "" ? "" : `<div class="notes">${yield* render(notes)}</div>`;
17
- return `<section>${yield* render(heading)}${note}${rendered.join("")}</section>`;
19
+ const rendered = yield* Effect.forEach(items, (item) => Effect.map(render(item), (html) => `<article class="item">${html}</article>`));
20
+ return `<section>${title}${note}${rendered.join("")}</section>`;
18
21
  });
19
22
 
20
- export const boardHtml = Effect.fn("WorkBoard.boardHtml")(function* (board: Board, fallbackTitle: string, modified: number) {
23
+ export const boardHtml = Effect.fn("WorkBoard.boardHtml")(function* (board: Board, fallbackTitle: string, modified: number, metadata = "") {
21
24
  let fragments = 0;
25
+ const headings = documentHeadings();
22
26
  const render: Render = (fragment) => {
23
27
  fragments += 1;
24
- return renderMarkdown(board.definitions === "" ? fragment : `${fragment}\n\n${board.definitions}`, `part-${fragments}-`);
28
+ return renderMarkdown(board.definitions === "" ? fragment : `${fragment}\n\n${board.definitions}`, {
29
+ file: fallbackTitle,
30
+ headings,
31
+ idPrefix: `part-${fragments}-`,
32
+ });
25
33
  };
26
34
  const title = board.title === "" ? `<h1>${escapeHtml(fallbackTitle)}</h1>` : yield* render(board.title);
27
35
  const intro = board.intro === "" ? "" : `<div class="intro">${yield* render(board.intro)}</div>`;
28
36
  const sections = yield* Effect.forEach(board.sections, (each) => section(each, render));
29
37
  const footer = board.footer === "" ? "" : `<footer>${yield* render(board.footer)}</footer>`;
30
38
  return [
39
+ readingTools(headings.entries),
40
+ metadata,
31
41
  `<header class="board-head">${title}${intro}`,
32
42
  `<p class="counts">${counts(board)}</p>`,
33
43
  `<p class="meta">${updated(modified)}</p></header>`,
@@ -1,6 +1,11 @@
1
1
  export const client = `
2
+ import "/_board/saved-views.js";
3
+ import "/_board/preferences.js";
4
+ import "/_board/search.js";
2
5
  import { renderDiagrams } from "/_board/diagrams.js";
3
- import { remember, swap } from "/_board/swap.js";
6
+ import { applyPage } from "/_board/page-state.js";
7
+ import { pageVersion } from "/_board/navigation.js";
8
+ import { remember } from "/_board/swap.js";
4
9
 
5
10
  const since = (modified) => {
6
11
  const seconds = Math.max(0, Math.round((Date.now() - modified) / 1000));
@@ -32,22 +37,23 @@ const show = () => {
32
37
  };
33
38
 
34
39
  const load = async () => {
40
+ const version = pageVersion();
41
+ const path = location.pathname + location.search;
35
42
  try {
36
- const response = await fetch(location.pathname, { cache: "no-store" });
43
+ const response = await fetch(path, { cache: "no-store" });
37
44
  const next = new DOMParser().parseFromString(await response.text(), "text/html");
38
45
  const files = next.getElementById("files");
39
46
  const doc = next.getElementById("doc");
47
+ if (version !== pageVersion()) return;
40
48
  if (files === null || doc === null) {
41
49
  failure = "refresh failed (" + response.status + ")";
42
50
  return;
43
51
  }
44
52
  failure = "";
45
- swap(document.getElementById("files"), files);
46
- swap(document.getElementById("doc"), doc);
47
- document.title = next.title;
53
+ applyPage(next, true);
48
54
  tick();
49
- renderDiagrams(document);
50
55
  } catch {
56
+ if (version !== pageVersion()) return;
51
57
  failure = "refresh failed";
52
58
  }
53
59
  };
@@ -71,6 +77,7 @@ const refresh = async () => {
71
77
  const events = new EventSource("/events");
72
78
  events.addEventListener("ready", () => {
73
79
  connected = true;
80
+ document.dispatchEvent(new Event("board-index-change"));
74
81
  show();
75
82
  if (dropped) refresh();
76
83
  dropped = false;
@@ -80,11 +87,62 @@ const down = () => {
80
87
  dropped = true;
81
88
  show();
82
89
  };
83
- events.addEventListener("change", refresh);
90
+ events.addEventListener("change", () => { refresh(); document.dispatchEvent(new Event("board-index-change")); });
84
91
  events.addEventListener("down", down);
85
92
  events.addEventListener("error", down);
86
93
 
94
+ document.addEventListener("board-page", tick);
87
95
  tick();
88
96
  setInterval(tick, 5000);
89
97
  renderDiagrams(document);
90
98
  `;
99
+ export const preferences = `
100
+ const root = document.documentElement;
101
+ const key = "work-board:appearance:" + root.dataset.workspace;
102
+ const theme = document.getElementById("theme");
103
+ const density = document.getElementById("density");
104
+ const sidebar = document.getElementById("sidebar");
105
+ const toggle = document.getElementById("sidebar-toggle");
106
+ const status = document.getElementById("preference-status");
107
+ const system = matchMedia("(prefers-color-scheme: dark)");
108
+ let saved = {};
109
+ try {
110
+ const value = JSON.parse(localStorage.getItem(key) ?? "{}");
111
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) saved = value;
112
+ } catch {
113
+ status.textContent = "Preferences apply to this page only.";
114
+ }
115
+
116
+ theme.value = ["light", "dark"].includes(saved.theme) ? saved.theme : "system";
117
+ density.value = saved.density === "compact" ? "compact" : "comfortable";
118
+ let closed = typeof saved.closed === "boolean" ? saved.closed : matchMedia("(max-width: 760px)").matches;
119
+
120
+ const appearance = () => {
121
+ root.dataset.theme = theme.value;
122
+ root.dataset.scheme = theme.value === "system" ? (system.matches ? "dark" : "light") : theme.value;
123
+ root.dataset.density = density.value;
124
+ root.dataset.sidebar = closed ? "closed" : "open";
125
+ sidebar.hidden = closed;
126
+ toggle.setAttribute("aria-expanded", String(!closed));
127
+ document.dispatchEvent(new Event("board-theme"));
128
+ };
129
+
130
+ const save = () => {
131
+ appearance();
132
+ try {
133
+ localStorage.setItem(key, JSON.stringify({ theme: theme.value, density: density.value, closed }));
134
+ status.textContent = "";
135
+ } catch {
136
+ status.textContent = "Preferences apply to this page only.";
137
+ }
138
+ };
139
+
140
+ theme.addEventListener("change", save);
141
+ density.addEventListener("change", save);
142
+ toggle.addEventListener("click", () => {
143
+ closed = !closed;
144
+ save();
145
+ });
146
+ system.addEventListener("change", appearance);
147
+ appearance();
148
+ `;
@@ -0,0 +1,36 @@
1
+ import { referenceTarget } from "#metadata/links.ts";
2
+ import type { MetadataModel } from "#metadata/model.ts";
3
+ import type { ParsedMetadata } from "#metadata/parse.ts";
4
+ import { escapeHtml } from "./escape.ts";
5
+ import { documentReference } from "./metadataLinks.ts";
6
+
7
+ function evidenceFor(reference: string, model: MetadataModel): string {
8
+ const records = model.documents.flatMap(
9
+ (document) =>
10
+ document.parsed.fields.evidence?.flatMap((evidence, index) =>
11
+ evidence.criterion === reference ? [{ file: document.file, index, line: document.parsed.lines[`evidence.${index}.source`] }] : [],
12
+ ) ?? [],
13
+ );
14
+ if (records.length === 0) {
15
+ return "<p>No recorded evidence naming this criterion. Acceptance is not established.</p>";
16
+ }
17
+ return `<p>${records.length} recorded ${records.length === 1 ? "claim" : "claims"}, not verified acceptance:</p><ul>${records.map((record) => `<li><a href="${escapeHtml(documentReference(record.file, model, `#recorded-evidence-${record.index}`))}">${escapeHtml(record.file)}${record.line === undefined ? "" : `:${record.line}`} — open recorded evidence</a></li>`).join("")}</ul>`;
18
+ }
19
+
20
+ export function criteriaEvidence(parsed: ParsedMetadata, model: MetadataModel): string {
21
+ const criteria = parsed.fields.criteria ?? [];
22
+ if (criteria.length === 0) {
23
+ return "";
24
+ }
25
+ return `<h3>Criteria and recorded evidence</h3><ul>${criteria
26
+ .map((criterion) => {
27
+ const unique = criteria.filter((candidate) => candidate.id === criterion.id).length === 1;
28
+ const reference = parsed.fields.id ? `${parsed.fields.id}#${criterion.id}` : "";
29
+ const evidence =
30
+ reference && referenceTarget(reference, model)
31
+ ? evidenceFor(reference, model)
32
+ : "<p>Evidence association requires unique item/criterion IDs and a readable workspace. Acceptance is not established.</p>";
33
+ return `<li${unique ? ` id="criterion-${escapeHtml(criterion.id)}" tabindex="-1"` : ""}><b>${escapeHtml(criterion.id)}</b>: ${escapeHtml(criterion.text)}${evidence}</li>`;
34
+ })
35
+ .join("")}</ul>`;
36
+ }
@@ -13,7 +13,7 @@ const load = async () => {
13
13
  securityLevel: "strict",
14
14
  theme: "base",
15
15
  themeVariables: {
16
- darkMode: dark.matches,
16
+ darkMode: schemeNow() === "dark",
17
17
  background: token("--bg"),
18
18
  primaryColor: token("--surface"),
19
19
  primaryTextColor: token("--text"),
@@ -26,7 +26,8 @@ const load = async () => {
26
26
  return mermaid;
27
27
  };
28
28
 
29
- const schemeNow = () => (dark.matches ? "dark" : "light");
29
+ const schemeNow = () => document.documentElement.dataset.scheme ?? (dark.matches ? "dark" : "light");
30
+ let lastScheme = schemeNow();
30
31
 
31
32
  const draw = async (figure) => {
32
33
  const source = figure.querySelector(".diagram-source").textContent;
@@ -65,8 +66,15 @@ export const renderDiagrams = (root, selector = "figure.diagram:not([data-state=
65
66
  return Promise.all([...root.querySelectorAll(selector)].map(draw));
66
67
  };
67
68
 
68
- dark.addEventListener("change", () => {
69
+ const themeChanged = () => {
70
+ const scheme = schemeNow();
71
+ if (lastScheme === scheme) return;
72
+ lastScheme = scheme;
73
+ const doc = document.getElementById("doc");
74
+ if (doc) doc.dataset.scheme = scheme;
69
75
  mermaid = undefined;
70
76
  renderDiagrams(document, "figure.diagram");
71
- });
77
+ };
78
+ document.addEventListener("board-theme", themeChanged);
79
+ dark.addEventListener("change", themeChanged);
72
80
  `;
@@ -1,10 +1,13 @@
1
1
  import { Effect } from "effect";
2
2
  import type { MarkdownFile } from "#files/list.ts";
3
+ import { documentHeadings } from "#render/documentHeadings.ts";
3
4
  import { renderMarkdown } from "#render/markdown.ts";
4
5
  import { escapeHtml } from "./escape.ts";
6
+ import { readingTools } from "./readingTools.ts";
5
7
  import { updated } from "./updated.ts";
6
8
 
7
- export const documentHtml = Effect.fn("WorkBoard.documentHtml")(function* (source: string, file: MarkdownFile) {
8
- const html = yield* renderMarkdown(source);
9
- return `<p class="meta">${escapeHtml(file.path)} · ${updated(file.modified)}</p><article class="doc">${html}</article>`;
9
+ export const documentHtml = Effect.fn("WorkBoard.documentHtml")(function* (source: string, file: MarkdownFile, metadata = "") {
10
+ const headings = documentHeadings();
11
+ const html = yield* renderMarkdown(source, { file: file.path, headings });
12
+ return `${readingTools(headings.entries)}<p class="meta">${escapeHtml(file.path)} · ${updated(file.modified)}</p>${metadata}<article class="doc">${html}</article>`;
10
13
  });
@@ -0,0 +1,72 @@
1
+ export function libraryScript(): string {
2
+ return `
3
+ const root = document.documentElement;
4
+ const key = "work-board:library:" + root.dataset.workspace;
5
+ const library = document.getElementById("reading-library");
6
+ let favorites = [];
7
+ let recents = [];
8
+ let notice = "";
9
+ const valid = (items) => Array.isArray(items) ? items.filter((item) => item && typeof item.file === "string" && item.file !== "" && typeof item.title === "string") : [];
10
+ try {
11
+ const saved = JSON.parse(localStorage.getItem(key) ?? "{}");
12
+ favorites = valid(saved?.favorites);
13
+ recents = valid(saved?.recents);
14
+ } catch { notice = "Reading history applies to this page only."; }
15
+ const save = () => {
16
+ try { localStorage.setItem(key, JSON.stringify({ favorites, recents })); notice = ""; }
17
+ catch { notice = "Reading history applies to this page only."; }
18
+ };
19
+ const hrefOf = (file) => "/" + file.split("/").map(encodeURIComponent).join("/");
20
+ const list = (title, items) => {
21
+ const heading = document.createElement("h2");
22
+ heading.className = "nav-heading";
23
+ heading.textContent = title;
24
+ const ul = document.createElement("ul");
25
+ const available = new Set([...document.querySelectorAll("#files a")].map((a) => new URL(a.href).pathname));
26
+ for (const item of items) {
27
+ const li = document.createElement("li");
28
+ const a = document.createElement("a");
29
+ a.href = hrefOf(item.file);
30
+ a.textContent = item.title;
31
+ if (item.file === document.getElementById("doc").dataset.file) a.setAttribute("aria-current", "page");
32
+ if (!available.has(a.pathname)) {
33
+ a.textContent += " (missing)";
34
+ a.className = "missing";
35
+ }
36
+ li.append(a); ul.append(li);
37
+ }
38
+ return [heading, ul];
39
+ };
40
+ export const refreshLibrary = (visit = false) => {
41
+ const doc = document.getElementById("doc");
42
+ const title = doc.querySelector("h1")?.textContent.trim() || doc.dataset.file;
43
+ document.title = title ? title + " · Work Board" : "Work Board";
44
+ const active = document.querySelector('#files a[aria-current="page"]');
45
+ if (active) active.title = title;
46
+ const toggle = document.getElementById("favorite-toggle");
47
+ if (toggle) {
48
+ const saved = favorites.some((item) => item.file === doc.dataset.file);
49
+ toggle.disabled = false;
50
+ toggle.setAttribute("aria-pressed", String(saved));
51
+ toggle.textContent = saved ? "Remove favorite" : "Save favorite";
52
+ if (visit) {
53
+ recents = [{ file: doc.dataset.file, title }, ...recents.filter((item) => item.file !== doc.dataset.file)].slice(0, 10);
54
+ save();
55
+ }
56
+ }
57
+ const status = document.createElement("p");
58
+ status.className = "library-status";
59
+ status.setAttribute("role", "status");
60
+ status.textContent = notice;
61
+ library.replaceChildren(...(favorites.length ? list("Favorites", favorites) : []), ...(recents.length ? list("Recent documents", recents) : []), ...(notice ? [status] : []));
62
+ library.hidden = favorites.length === 0 && recents.length === 0 && notice === "";
63
+ };
64
+ document.addEventListener("click", (event) => {
65
+ if (!event.target.closest?.("#favorite-toggle")) return;
66
+ const doc = document.getElementById("doc");
67
+ const file = doc.dataset.file;
68
+ favorites = favorites.some((item) => item.file === file) ? favorites.filter((item) => item.file !== file) : [...favorites, { file, title: doc.querySelector("h1")?.textContent.trim() || file }];
69
+ save(); refreshLibrary();
70
+ });
71
+ `;
72
+ }
@@ -0,0 +1,40 @@
1
+ import { identityUrl } from "#metadata/links.ts";
2
+ import type { MetadataModel } from "#metadata/model.ts";
3
+ import type { ParsedMetadata } from "#metadata/parse.ts";
4
+ import { stateOf, viewUrl } from "#views/state.ts";
5
+ import { backlinksHtml } from "./backlinksHtml.ts";
6
+ import { escapeHtml } from "./escape.ts";
7
+ import { metadataDetails } from "./metadataDetails.ts";
8
+
9
+ export function metadataHtml(parsed: ParsedMetadata, file: string, model: MetadataModel): string {
10
+ const backlinks = backlinksHtml(file, model);
11
+ const diagnostics = model.diagnostics.get(file) ?? parsed.diagnostics;
12
+ if (parsed.raw === undefined && diagnostics.length === 0) {
13
+ return backlinks;
14
+ }
15
+ const fields = parsed.fields;
16
+ const unique = model.unavailable.length === 0 && fields.id !== undefined && model.ids.get(fields.id)?.length === 1;
17
+ const problems =
18
+ diagnostics.length > 0
19
+ ? `<div class="metadata-problems" role="status"><b>Source diagnostics</b><ul>${diagnostics.map((problem) => `<li>${escapeHtml(file)}:${problem.line} — ${escapeHtml(problem.message)}</li>`).join("")}</ul></div>`
20
+ : "";
21
+ const identity =
22
+ unique && fields.id
23
+ ? `<a href="${identityUrl(fields.id)}">Item link: ${escapeHtml(fields.id)}</a>`
24
+ : `ID: ${escapeHtml(fields.id ?? "Not recorded")}`;
25
+ const details = metadataDetails(parsed, file, model);
26
+ const boardLink =
27
+ unique && fields.kind === "board" && fields.id
28
+ ? `<p><a href="${escapeHtml(viewUrl(stateOf("/"), { board: fields.id }))}">View this board</a></p>`
29
+ : "";
30
+ return `<section class="work-meta" aria-label="Recorded work"><p class="metadata-summary">Recorded work · ${identity}${fields.status ? ` · ${escapeHtml(fields.status)}` : ""}${fields.owner ? ` · ${escapeHtml(fields.owner)}` : ""}</p>${fields.nextAction ? `<p>Next action: ${escapeHtml(fields.nextAction)}</p>` : ""}${boardLink}${problems}<details data-key="work-metadata"><summary>Work details and source</summary>${details}<p>Source: ${escapeHtml(file)}:${parsed.lines.id ?? 1}; prose starts at line ${parsed.bodyLine}.</p>${parsed.raw ? `<details data-key="frontmatter-source"><summary>Original frontmatter</summary><pre>${escapeHtml(parsed.raw)}</pre></details>` : ""}</details></section>${backlinks}`;
31
+ }
32
+
33
+ export const metadataStyles = `
34
+ .work-meta{font-size:.9rem;margin:16px 0 24px;padding:12px 16px;border:1px solid var(--border);border-radius:8px;background:var(--surface);overflow-wrap:anywhere}
35
+ .metadata-summary{font-weight:600;margin:0}.work-meta>p{margin-bottom:8px}.work-meta details{margin:0}.work-meta summary{color:var(--link)}
36
+ .work-meta dl{display:grid;grid-template-columns:minmax(80px,auto) minmax(0,1fr);gap:4px 12px;margin:12px 0}.work-meta dt{color:var(--muted)}.work-meta dd{margin:0}
37
+ .work-meta pre{max-height:300px;overflow:auto;margin-top:12px}.metadata-problems{color:var(--danger);margin:12px 0}.metadata-problems ul{margin:4px 0 12px;padding-left:20px}
38
+ .work-context{font-size:.9rem;margin:12px 0 24px;padding:12px 16px;border:1px solid var(--border);border-radius:8px;overflow-wrap:anywhere}.work-context details{margin:0}.work-context ul{margin:12px 0}.work-context p{font-size:13px;color:var(--muted);margin:8px 0 0}
39
+ [id^="recorded-evidence-"],[id^="criterion-"]{scroll-margin-top:20px}
40
+ `;
@@ -0,0 +1,24 @@
1
+ import type { MetadataModel } from "#metadata/model.ts";
2
+ import type { ParsedMetadata } from "#metadata/parse.ts";
3
+ import { relationshipLabel } from "#metadata/relationshipLabel.ts";
4
+ import { criteriaEvidence } from "./criteriaEvidence.ts";
5
+ import { escapeHtml } from "./escape.ts";
6
+ import { referenceHtml, valueHtml } from "./metadataLinks.ts";
7
+ import { recordedEvidence } from "./recordedEvidence.ts";
8
+
9
+ export function metadataDetails(parsed: ParsedMetadata, file: string, model: MetadataModel): string {
10
+ const fields = parsed.fields;
11
+ const details = [
12
+ `<dl><dt>Kind</dt><dd>${valueHtml(fields.kind)}</dd><dt>Status</dt><dd>${valueHtml(fields.status)}</dd><dt>Owner</dt><dd>${valueHtml(fields.owner)}</dd><dt>Next action</dt><dd>${valueHtml(fields.nextAction)}</dd></dl>`,
13
+ fields.relationships?.length
14
+ ? `<h3>Relationships</h3><ul>${fields.relationships.map((link) => `<li>${escapeHtml(relationshipLabel(link.kind))}: ${referenceHtml(link.target, link.target, model)}</li>`).join("")}</ul>`
15
+ : "",
16
+ fields.items?.length
17
+ ? `<h3>Declared board membership</h3><ul>${fields.items.map((id) => `<li>${referenceHtml(id, id, model)}</li>`).join("")}</ul>`
18
+ : "",
19
+
20
+ criteriaEvidence(parsed, model),
21
+ recordedEvidence(parsed, file, model),
22
+ ].join("");
23
+ return details;
24
+ }
@@ -0,0 +1,35 @@
1
+ import { fileUrl } from "#files/url.ts";
2
+ import { referenceTarget } from "#metadata/links.ts";
3
+ import type { MetadataModel } from "#metadata/model.ts";
4
+ import { sourceFile, sourceHref } from "#metadata/sourceLinks.ts";
5
+ import { escapeHtml } from "./escape.ts";
6
+
7
+ export function referenceHtml(label: string, target: string, model: MetadataModel): string {
8
+ const href = referenceTarget(target, model);
9
+ return href
10
+ ? `<a href="${escapeHtml(href)}">${escapeHtml(label)}</a>`
11
+ : `<span>${escapeHtml(label)} (unresolved, ambiguous, or unavailable)</span>`;
12
+ }
13
+
14
+ export function sourceHtml(source: string, file: string, model: MetadataModel): string {
15
+ const href = sourceHref(source, file);
16
+ if (!href) {
17
+ return escapeHtml(source);
18
+ }
19
+ const target = sourceFile(source, file, model);
20
+ const missing = target && !model.documents.some((document) => document.file === target);
21
+ let problem = "";
22
+ if (missing) {
23
+ problem = model.unavailable.includes(target) ? " (source unavailable)" : " (missing Markdown source)";
24
+ }
25
+ return `<a href="${escapeHtml(href)}" rel="noreferrer">${escapeHtml(source)}</a>${problem}`;
26
+ }
27
+
28
+ export function documentReference(file: string, model: MetadataModel, hash = ""): string {
29
+ const id = model.documents.find((document) => document.file === file)?.parsed.fields.id;
30
+ return ((id ? referenceTarget(id, model) : undefined) ?? fileUrl(file)) + hash;
31
+ }
32
+
33
+ export function valueHtml(text: string | undefined): string {
34
+ return escapeHtml(text ?? "Not recorded");
35
+ }
package/src/page/nav.ts CHANGED
@@ -1,16 +1,15 @@
1
1
  import type { MarkdownFile } from "#files/list.ts";
2
+ import { fileUrl } from "#files/url.ts";
2
3
  import { escapeHtml } from "./escape.ts";
3
4
 
4
5
  const nameOf = (path: string): string => path.slice(path.lastIndexOf("/") + 1).replace(/\.md$/u, "");
5
6
 
6
7
  const folderOf = (path: string): string => path.slice(0, Math.max(0, path.lastIndexOf("/")));
7
8
 
8
- const hrefOf = (path: string): string => `/${path.split("/").map(encodeURIComponent).join("/")}`;
9
-
10
9
  const link = (file: MarkdownFile, current: string): string => {
11
10
  const here = file.path === current ? ' aria-current="page"' : "";
12
11
  const age = `<span class="age short" data-modified="${file.modified}"></span>`;
13
- return `<a href="${hrefOf(file.path)}"${here}>${escapeHtml(nameOf(file.path))}</a>${age}`;
12
+ return `<a href="${fileUrl(file.path)}"${here}>${escapeHtml(nameOf(file.path))}</a>${age}`;
14
13
  };
15
14
 
16
15
  const folder = (name: string, files: readonly MarkdownFile[], current: string): string => {
@@ -23,6 +22,8 @@ export const navHtml = (files: readonly MarkdownFile[], current: string, home: s
23
22
  const ordered = [...files.filter((file) => file.path === home), ...files.filter((file) => file.path !== home)];
24
23
  const folders = Map.groupBy(ordered, (file) => (file.path === home ? "" : folderOf(file.path)));
25
24
  return [...folders]
26
- .map(([name, grouped]) => (name === "" ? grouped.map((file) => `<span>${link(file, current)}</span>`).join("") : folder(name, grouped, current)))
25
+ .map(([name, grouped]) =>
26
+ name === "" ? `<ul>${grouped.map((file) => `<li>${link(file, current)}</li>`).join("")}</ul>` : folder(name, grouped, current),
27
+ )
27
28
  .join("");
28
29
  };
@@ -0,0 +1,103 @@
1
+ export function navigationScript(): string {
2
+ return `
3
+ import { applyPage, capturePage } from "/_board/page-state.js";
4
+ import { reportMissingPassage, restoreReading, scrollToPassage } from "/_board/reading-state.js";
5
+ import { refreshLibrary } from "/_board/library.js";
6
+
7
+ const cache = new Map();
8
+ const readingKey = "work-board:reading:" + document.documentElement.dataset.workspace;
9
+ let savedReading = {};
10
+ try {
11
+ const saved = JSON.parse(sessionStorage.getItem(readingKey) ?? "{}");
12
+ if (saved && typeof saved === "object" && !Array.isArray(saved)) savedReading = saved;
13
+ } catch {}
14
+ let count = 0;
15
+ const newId = () => Date.now() + "-" + ++count;
16
+ let displayedId = history.state?.workBoard?.id ?? newId();
17
+ let version = 0;
18
+ let controller;
19
+ export const pageVersion = () => version;
20
+ const stateWith = (id, reading = null) => ({ ...history.state, workBoard: { id, reading } });
21
+ history.replaceState(stateWith(displayedId, history.state?.workBoard?.reading), "");
22
+ history.scrollRestoration = "manual";
23
+ document.getElementById("doc").dataset.scheme = document.documentElement.dataset.scheme;
24
+ refreshLibrary(true);
25
+ const initialReading = savedReading[displayedId] ?? history.state.workBoard.reading;
26
+ requestAnimationFrame(() => {
27
+ if (initialReading) restoreReading(initialReading);
28
+ else scrollToPassage(location.hash);
29
+ reportMissingPassage();
30
+ });
31
+
32
+ const saveCurrent = (write = true) => {
33
+ const snapshot = capturePage();
34
+ cache.delete(displayedId);
35
+ cache.set(displayedId, snapshot);
36
+ delete savedReading[displayedId];
37
+ savedReading[displayedId] = snapshot.reading;
38
+ savedReading = Object.fromEntries(Object.entries(savedReading).slice(-30));
39
+ try { sessionStorage.setItem(readingKey, JSON.stringify(savedReading)); } catch {}
40
+ if (cache.size > 30) cache.delete(cache.keys().next().value);
41
+ if (write && history.state?.workBoard?.id === displayedId) history.replaceState(stateWith(displayedId, snapshot.reading), "");
42
+ };
43
+ window.addEventListener("pagehide", () => saveCurrent(false));
44
+
45
+ const navigate = async (target, entry) => {
46
+ const sameDocument = document.getElementById("favorite-toggle") !== null && displayedId === history.state?.workBoard?.id && target.pathname === location.pathname && target.search === location.search;
47
+ saveCurrent(!entry);
48
+ const id = entry?.id ?? newId();
49
+ if (!entry) history.pushState(stateWith(id), "", target);
50
+ else if (history.state?.workBoard?.id !== id) history.replaceState(stateWith(id, entry.reading), "");
51
+ const mine = ++version;
52
+ controller?.abort();
53
+ controller = new AbortController();
54
+ const status = document.getElementById("navigation-status");
55
+ status.textContent = "";
56
+ if (!entry && sameDocument) {
57
+ displayedId = id;
58
+ scrollToPassage(target.hash);
59
+ return;
60
+ }
61
+ const cached = cache.get(id);
62
+ if (cached) {
63
+ applyPage(cached.page);
64
+ displayedId = id;
65
+ restoreReading(cached.reading);
66
+ } else status.textContent = "Opening document…";
67
+ try {
68
+ const response = await fetch(target.pathname + target.search, { cache: "no-store", signal: controller.signal });
69
+ const page = new DOMParser().parseFromString(await response.text(), "text/html");
70
+ if (mine !== version) return;
71
+ if (!applyPage(page, Boolean(cached))) throw new Error("Invalid page");
72
+ displayedId = id;
73
+ status.textContent = response.ok ? "" : response.status === 404 ? "This document is missing or was renamed." : "This document could not be opened.";
74
+ if (response.ok) reportMissingPassage();
75
+ if (!cached) {
76
+ document.getElementById("doc").focus({ preventScroll: true });
77
+ if (savedReading[id] ?? entry?.reading) restoreReading(savedReading[id] ?? entry.reading);
78
+ else scrollToPassage(target.hash);
79
+ }
80
+ } catch (error) {
81
+ if (mine !== version || error.name === "AbortError") return;
82
+ status.textContent = "Could not open this document. Retry the link or reload the page.";
83
+ }
84
+ };
85
+ document.addEventListener("click", (event) => {
86
+ const link = event.target.closest?.("a[href]");
87
+ if (!link || event.defaultPrevented || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || link.hasAttribute("download") || (link.target && link.target !== "_self")) return;
88
+ const target = new URL(link.href, location.href);
89
+ if (target.origin !== location.origin || !(target.pathname === "/" || (target.pathname === "/_board/work" || target.pathname.startsWith("/_board/item/")) || /\\.md$/i.test(target.pathname))) return;
90
+ event.preventDefault();
91
+ navigate(target);
92
+ });
93
+ document.addEventListener("submit", (event) => {
94
+ const form = event.target;
95
+ if (!form.matches?.("#work-filters")) return;
96
+ event.preventDefault();
97
+ const target = new URL(form.action, location.href);
98
+ target.search = new URLSearchParams([...new FormData(form)].map(([key, value]) => [key, String(value)])).toString();
99
+ navigate(target);
100
+ });
101
+ window.addEventListener("popstate", (event) => navigate(new URL(location.href), event.state?.workBoard ?? { id: newId() }));
102
+ `;
103
+ }
@@ -0,0 +1,61 @@
1
+ export function readingStateScript(): string {
2
+ return `
3
+ const pathOf = (node, root) => {
4
+ const path = [];
5
+ for (; node !== root; node = node.parentNode) {
6
+ if (!node?.parentNode) return null;
7
+ path.unshift([...node.parentNode.childNodes].indexOf(node));
8
+ }
9
+ return path;
10
+ };
11
+ const nodeAt = (path, root) => path.reduce((node, index) => node?.childNodes[index], root);
12
+
13
+ export const captureReading = () => {
14
+ const root = document.getElementById("doc");
15
+ const selection = document.getSelection();
16
+ const anchor = selection?.anchorNode && pathOf(selection.anchorNode, root);
17
+ const focus = selection?.focusNode && pathOf(selection.focusNode, root);
18
+ return {
19
+ scroll: [scrollX, scrollY],
20
+ sidebar: document.getElementById("sidebar").scrollTop,
21
+ details: [...root.querySelectorAll("details")].map((detail) => detail.open),
22
+ selection: anchor && focus ? { anchor, focus, a: selection.anchorOffset, f: selection.focusOffset } : null,
23
+ };
24
+ };
25
+
26
+ export const restoreReading = (state, restoreDetails = true) => {
27
+ if (!state || !Array.isArray(state.scroll) || state.scroll.length !== 2 || !state.scroll.every(Number.isFinite)) return;
28
+ const root = document.getElementById("doc");
29
+ [...root.querySelectorAll("details")].forEach((detail, index) => {
30
+ if (restoreDetails && typeof state.details?.[index] === "boolean") detail.open = state.details[index];
31
+ });
32
+ document.getElementById("sidebar").scrollTop = state.sidebar ?? 0;
33
+ const saved = state.selection;
34
+ if (saved && Array.isArray(saved.anchor) && Array.isArray(saved.focus) && Number.isInteger(saved.a) && Number.isInteger(saved.f) && saved.a >= 0 && saved.f >= 0) {
35
+ const anchor = nodeAt(saved.anchor, root);
36
+ const focus = nodeAt(saved.focus, root);
37
+ const limit = (node) => node.nodeType === 3 ? node.textContent.length : node.childNodes.length;
38
+ if (anchor && focus) document.getSelection()?.setBaseAndExtent(anchor, Math.min(saved.a, limit(anchor)), focus, Math.min(saved.f, limit(focus)));
39
+ }
40
+ scrollTo(...state.scroll);
41
+ };
42
+
43
+ const passageAt = (hash) => {
44
+ try { return document.getElementById(decodeURIComponent(hash.slice(1))); } catch { return null; }
45
+ };
46
+ export const reportMissingPassage = () => {
47
+ if (!location.hash || !document.getElementById("favorite-toggle")) return;
48
+ const status = document.getElementById("navigation-status");
49
+ if (!passageAt(location.hash)) status.textContent = "This passage is no longer in the document.";
50
+ else if (status.textContent === "This passage is no longer in the document.") status.textContent = "";
51
+ };
52
+ export const scrollToPassage = (hash) => {
53
+ if (!hash) { scrollTo(0, 0); return; }
54
+ const target = passageAt(hash);
55
+ if (!target) { reportMissingPassage(); return; }
56
+ for (let parent = target.parentElement; parent; parent = parent.parentElement) if (parent.tagName === "DETAILS") parent.open = true;
57
+ target.scrollIntoView();
58
+ target.focus({ preventScroll: true });
59
+ };
60
+ `;
61
+ }
@@ -0,0 +1,28 @@
1
+ export function readingStyles(): string {
2
+ return `
3
+ .reading-tools { display: flex; align-items: start; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; font-size: 13px; }
4
+ .reading-tools button[aria-pressed="true"] { background: var(--surface); border-color: var(--link); }
5
+ .outline { margin: 0; min-width: 180px; max-width: 100%; }
6
+ .outline > summary { padding: 6px 10px; border: 1px solid var(--border); border-radius: 6px; color: var(--text); }
7
+ .outline nav { margin-top: 12px; padding: 0 12px; max-height: 40vh; overflow-y: auto; }
8
+ .outline ol { padding-left: 18px; }
9
+ .outline li { overflow-wrap: anywhere; }
10
+ .outline li[data-depth="3"], .outline li[data-depth="4"] { margin-left: 12px; }
11
+ .outline li[data-depth="5"], .outline li[data-depth="6"] { margin-left: 24px; }
12
+ .heading-anchor { display: inline-block; margin-left: 8px; font-size: 0.7em; text-decoration: none; opacity: 0.35; }
13
+ .heading-anchor::after { content: "#"; }
14
+ .heading-anchor:hover, .heading-anchor:focus-visible { opacity: 1; }
15
+ [id^="heading-"] { scroll-margin-top: 20px; }
16
+ .reading-library { font-size: 13px; margin-bottom: 24px; }
17
+ .reading-library[hidden] { display: none; }
18
+ .reading-library ul { list-style: none; padding: 0 12px; margin: 0 0 20px; }
19
+ .reading-library a { color: var(--sidebar-text); overflow-wrap: anywhere; }
20
+ .reading-library a[aria-current="page"] { font-weight: 600; }
21
+ .reading-library .missing { color: var(--sidebar-muted); }
22
+ .library-status { padding: 0 12px; color: var(--sidebar-muted); }
23
+ #navigation-status { color: var(--danger); overflow-wrap: anywhere; }
24
+ #navigation-status:empty { display: none; }
25
+ .bar:has(#navigation-status:not(:empty)) { flex-wrap: wrap; }
26
+ #navigation-status:not(:empty) { flex-basis: 100%; }
27
+ `;
28
+ }