@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
@@ -0,0 +1,51 @@
1
+ import { markdownToHtml } from "satteri";
2
+ import { fileUrl } from "#files/url.ts";
3
+ import { boardOf } from "#render/board.ts";
4
+ import { documentHeadings } from "#render/documentHeadings.ts";
5
+ import { searchCollector } from "./collector.ts";
6
+ import { linkCollector } from "./linkCollector.ts";
7
+
8
+ export interface Entry {
9
+ readonly file: string;
10
+ readonly href: string;
11
+ readonly kind: "document" | "heading" | "passage";
12
+ readonly line?: number;
13
+ readonly text: string;
14
+ readonly title: string;
15
+ }
16
+
17
+ function fragmentLocation(source: string, fragment: string, bodyLine: number, seen: Map<string, number>): number | undefined {
18
+ const start = source.indexOf(fragment, seen.get(fragment) ?? 0);
19
+ if (start < 0) {
20
+ return undefined;
21
+ }
22
+ seen.set(fragment, start + fragment.length);
23
+ return bodyLine + source.slice(0, start).split("\n").length - 1;
24
+ }
25
+
26
+ export async function entriesOf(source: string, file: string, home: boolean, bodyLine = 1, links = new Set<string>()): Promise<readonly Entry[]> {
27
+ const headings = documentHeadings();
28
+ let fragmentLine: number | undefined = bodyLine;
29
+ let fragmentLength = 0;
30
+ const seenFragments = new Map<string, number>();
31
+ const { entries, plugin } = searchCollector(
32
+ file,
33
+ () => fragmentLine,
34
+ () => fragmentLength,
35
+ );
36
+ const board = home ? boardOf(source) : undefined;
37
+ const fragments = board
38
+ ? [board.title, board.intro, ...board.sections.flatMap((section) => [section.heading, section.notes, ...section.items]), board.footer]
39
+ : [source];
40
+ for (const fragment of fragments) {
41
+ if (fragment) {
42
+ fragmentLength = fragment.split("\n").length;
43
+ fragmentLine = board ? fragmentLocation(source, fragment, bodyLine, seenFragments) : bodyLine;
44
+ await markdownToHtml(board?.definitions ? `${fragment}\n\n${board.definitions}` : fragment, {
45
+ hastPlugins: [headings.plugin, plugin, linkCollector(links)],
46
+ });
47
+ }
48
+ }
49
+ const title = headings.entries.find((heading) => heading.depth === 1)?.title || file;
50
+ return [{ file, href: fileUrl(file), kind: "document", line: bodyLine, text: `${file} ${title}`, title }, ...entries];
51
+ }
@@ -0,0 +1,16 @@
1
+ import { defineHastPlugin } from "satteri";
2
+
3
+ export function linkCollector(links: Set<string>) {
4
+ return defineHastPlugin({
5
+ element: {
6
+ filter: ["a"],
7
+ visit: (element) => {
8
+ const href = element.properties.href;
9
+ if (typeof href === "string" && !href.startsWith("#")) {
10
+ links.add(href);
11
+ }
12
+ },
13
+ },
14
+ name: "work-board-link-collector",
15
+ });
16
+ }
@@ -0,0 +1,28 @@
1
+ import type { Entry } from "./entries.ts";
2
+
3
+ function normalize(text: string): string {
4
+ return text.normalize("NFKC").toLowerCase().replace(/\s+/gu, " ").trim();
5
+ }
6
+
7
+ export function searchMatch(entries: readonly Entry[], query: string) {
8
+ const phrase = normalize(query.slice(0, 200));
9
+ const terms = phrase.split(" ").filter(Boolean).slice(0, 8);
10
+ if (terms.length === 0) {
11
+ return { results: [], total: 0 };
12
+ }
13
+ const matches = entries
14
+ .flatMap((entry, order) => {
15
+ const text = normalize(entry.text);
16
+ if (!terms.every((term) => text.includes(term))) {
17
+ return [];
18
+ }
19
+ const at = Math.max(0, entry.text.toLowerCase().indexOf(terms[0] ?? ""));
20
+ const start = Math.max(0, at - 50);
21
+ const snippet = (start ? "…" : "") + entry.text.slice(start, start + 180) + (entry.text.length > start + 180 ? "…" : "");
22
+ const ranks = { document: 0, heading: 2, passage: 4 };
23
+ const rank = ranks[entry.kind] + (text.includes(phrase) ? 0 : 1);
24
+ return [{ entry: { ...entry, snippet }, order, rank }];
25
+ })
26
+ .sort((left, right) => left.rank - right.rank || left.order - right.order);
27
+ return { results: matches.slice(0, 40).map((match) => match.entry), total: matches.length };
28
+ }
@@ -0,0 +1,42 @@
1
+ import { fileUrl } from "#files/url.ts";
2
+ import { identityUrl } from "#metadata/links.ts";
3
+ import type { MetadataDocument, MetadataModel } from "#metadata/model.ts";
4
+ import type { Entry } from "./entries.ts";
5
+
6
+ function itemUrl(document: MetadataDocument, model: MetadataModel): string {
7
+ const id = document.parsed.fields.id;
8
+ return model.unavailable.length === 0 && id && model.ids.get(id)?.length === 1 ? identityUrl(id) : fileUrl(document.file);
9
+ }
10
+ export function metadataEntries(entries: readonly Entry[], documents: readonly MetadataDocument[], model: MetadataModel): readonly Entry[] {
11
+ const sources = new Map(documents.map((document) => [document.file, document]));
12
+ const enriched = entries.map((entry) => {
13
+ const document = sources.get(entry.file);
14
+ if (entry.kind !== "document" || !document) {
15
+ return entry;
16
+ }
17
+ const fields = document.parsed.fields;
18
+ return {
19
+ ...entry,
20
+ href: itemUrl(document, model),
21
+ text: [entry.text, fields.id, fields.kind, fields.status, fields.owner, fields.nextAction].filter(Boolean).join(" "),
22
+ };
23
+ });
24
+ for (const document of documents) {
25
+ const criteria = document.parsed.fields.criteria ?? [];
26
+ for (const [index, criterion] of criteria.entries()) {
27
+ if (criteria.filter((item) => item.id === criterion.id).length !== 1) {
28
+ continue;
29
+ }
30
+ const line = document.parsed.lines[`criteria.${index}`] ?? document.parsed.lines.criteria;
31
+ enriched.push({
32
+ file: document.file,
33
+ href: `${itemUrl(document, model)}#criterion-${encodeURIComponent(criterion.id)}`,
34
+ kind: "passage",
35
+ ...(line === undefined ? {} : { line }),
36
+ text: criterion.text,
37
+ title: criterion.text,
38
+ });
39
+ }
40
+ }
41
+ return enriched;
42
+ }
@@ -0,0 +1,60 @@
1
+ import { Effect, FileSystem, Option, Path, Ref, Semaphore } from "effect";
2
+ import type { Changes } from "#files/changes.ts";
3
+ import { within } from "#files/list.ts";
4
+ import { type MetadataDocument, metadataModel } from "#metadata/model.ts";
5
+ import { metadataParse } from "#metadata/parse.ts";
6
+ import { type Entry, entriesOf } from "./entries.ts";
7
+ import { metadataEntries } from "./metadataEntries.ts";
8
+
9
+ export interface Snapshot {
10
+ readonly documents: readonly MetadataDocument[];
11
+ readonly entries: readonly Entry[];
12
+ readonly model: ReturnType<typeof metadataModel>;
13
+ readonly revision: number;
14
+ readonly unavailable: readonly string[];
15
+ }
16
+
17
+ export const searchSnapshot = Effect.fn("WorkBoard.searchSnapshot")(function* (root: string, home: string | undefined, changes: Changes) {
18
+ const cached = yield* Ref.make<Snapshot | undefined>(undefined);
19
+ const semaphore = yield* Semaphore.make(1);
20
+ const read = Effect.gen(function* () {
21
+ const revision = yield* changes.revision;
22
+ const hit = yield* Ref.get(cached);
23
+ if (hit?.revision === revision && (yield* Ref.get(changes.watching))) {
24
+ return hit;
25
+ }
26
+ const fs = yield* FileSystem.FileSystem;
27
+ const path = yield* Path.Path;
28
+ const files = yield* changes.files;
29
+ const entries: Entry[] = [];
30
+ const documents: MetadataDocument[] = [];
31
+ const unavailable: string[] = [];
32
+ for (const file of files) {
33
+ const found = yield* Effect.option(
34
+ Effect.gen(function* () {
35
+ const real = yield* fs.realPath(path.join(root, file.path));
36
+ if (!within(changes.realRoot, path.sep, real)) {
37
+ return undefined;
38
+ }
39
+ const source = yield* fs.readFileString(real);
40
+ const parsed = metadataParse(source);
41
+ const links = new Set<string>();
42
+ const rendered = yield* Effect.tryPromise(() => entriesOf(parsed.body, file.path, file.path === home, parsed.bodyLine, links));
43
+ return { links: [...links], parsed, rendered };
44
+ }),
45
+ );
46
+ if (Option.isSome(found) && found.value) {
47
+ documents.push({ file: file.path, links: found.value.links, parsed: found.value.parsed });
48
+ entries.push(...found.value.rendered);
49
+ } else {
50
+ unavailable.push(file.path);
51
+ }
52
+ }
53
+ const model = metadataModel(documents, unavailable, home ?? files[0]?.path);
54
+ const enriched = metadataEntries(entries, documents, model);
55
+ const snapshot = { documents, entries: enriched, model, revision, unavailable };
56
+ yield* Ref.set(cached, unavailable.length > 0 ? undefined : snapshot);
57
+ return snapshot;
58
+ });
59
+ return semaphore.withPermit(read);
60
+ });
@@ -0,0 +1,55 @@
1
+ import type { MetadataDocument } from "#metadata/model.ts";
2
+ import type { Snapshot } from "#search/snapshot.ts";
3
+ import { fieldFilter, type State } from "./state.ts";
4
+
5
+ export interface Item {
6
+ readonly document: MetadataDocument;
7
+ readonly id: string;
8
+ readonly title: string;
9
+ }
10
+ export function itemsOf(snapshot: Snapshot): readonly Item[] {
11
+ const titles = new Map(snapshot.entries.filter((entry) => entry.kind === "document").map((entry) => [entry.file, entry.title]));
12
+ return [...snapshot.model.ids].flatMap(([id, documents]) => {
13
+ const document = documents.length === 1 ? documents[0] : undefined;
14
+ return document ? [{ document, id, title: titles.get(document.file) ?? document.file }] : [];
15
+ });
16
+ }
17
+ function matches(item: Item, state: State): boolean {
18
+ const fields = item.document.parsed.fields;
19
+ if (state.owner && fieldFilter(fields.owner) !== state.owner) {
20
+ return false;
21
+ }
22
+ if (state.status && fieldFilter(fields.status) !== state.status) {
23
+ return false;
24
+ }
25
+ const text = [item.id, item.title, fields.kind, fields.status, fields.owner, fields.nextAction]
26
+ .filter(Boolean)
27
+ .join(" ")
28
+ .normalize("NFKC")
29
+ .toLowerCase();
30
+ return state.query
31
+ .normalize("NFKC")
32
+ .toLowerCase()
33
+ .split(/\s+/u)
34
+ .filter(Boolean)
35
+ .every((term) => text.includes(term));
36
+ }
37
+ function order(item: Item, sort: State["sort"]): string | undefined {
38
+ return sort === "title" ? item.title : item.document.parsed.fields[sort];
39
+ }
40
+ const collator = new Intl.Collator("en", { numeric: true, sensitivity: "base" });
41
+ export function filterItems(items: readonly Item[], state: State): readonly Item[] {
42
+ return items
43
+ .filter((item) => matches(item, state))
44
+ .sort((a, b) => {
45
+ const left = order(a, state.sort);
46
+ const right = order(b, state.sort);
47
+ if (left === undefined) {
48
+ return right === undefined ? a.id.localeCompare(b.id) : 1;
49
+ }
50
+ if (right === undefined) {
51
+ return -1;
52
+ }
53
+ return collator.compare(left, right) || a.id.localeCompare(b.id);
54
+ });
55
+ }
@@ -0,0 +1,59 @@
1
+ import type { Snapshot } from "#search/snapshot.ts";
2
+ import { filterItems, type Item, itemsOf } from "./items.ts";
3
+ import type { State } from "./state.ts";
4
+
5
+ export interface Projection {
6
+ readonly boards: readonly Item[];
7
+ readonly issues: readonly string[];
8
+ readonly items: readonly Item[];
9
+ readonly rows: readonly Item[];
10
+ readonly selected: Item | undefined;
11
+ readonly selectedOutside: boolean;
12
+ readonly title: string;
13
+ readonly validBoard: boolean;
14
+ }
15
+ function boardItems(board: Item, items: readonly Item[], snapshot: Snapshot) {
16
+ const references = board.document.parsed.fields.items ?? [];
17
+ const byId = new Map(items.map((item) => [item.id, item]));
18
+ const issues: string[] = [];
19
+ const found: Item[] = [];
20
+ for (const id of new Set(references)) {
21
+ const item = byId.get(id);
22
+ if (item) {
23
+ found.push(item);
24
+ } else {
25
+ issues.push(`${id}: ${(snapshot.model.ids.get(id)?.length ?? 0) > 1 ? "ambiguous ID" : "missing or invalid ID"}`);
26
+ }
27
+ if (references.filter((reference) => reference === id).length > 1) {
28
+ issues.push(`${id}: repeated membership, shown once`);
29
+ }
30
+ }
31
+ return { found, issues };
32
+ }
33
+ export function projectItems(snapshot: Snapshot, state: State): Projection {
34
+ const known = itemsOf(snapshot);
35
+ const boards = known.filter((item) => item.document.parsed.fields.kind === "board");
36
+ const board = boards.find((item) => item.id === state.board);
37
+ const membership = board ? boardItems(board, known, snapshot) : undefined;
38
+ const items = membership?.found ?? known.filter((item) => item.document.parsed.fields.kind !== "board");
39
+ const rows = filterItems(items, state);
40
+ const selected = known.find((item) => item.id === state.item);
41
+ const issues =
42
+ membership?.issues
43
+ ?? [...snapshot.model.ids]
44
+ .filter(([, documents]) => documents.length > 1)
45
+ .map(([id]) => `${id}: ambiguous ID; open its source files to correct the declaration`);
46
+ const sourceIssues = board
47
+ ? (snapshot.model.diagnostics.get(board.document.file) ?? []).map((problem) => `${board.document.file}:${problem.line}: ${problem.message}`)
48
+ : [];
49
+ return {
50
+ boards,
51
+ issues: [...issues, ...sourceIssues],
52
+ items,
53
+ rows,
54
+ selected,
55
+ selectedOutside: selected !== undefined && !rows.some((item) => item.id === selected.id),
56
+ title: board?.title ?? "All work",
57
+ validBoard: !state.board || board !== undefined,
58
+ };
59
+ }
@@ -0,0 +1,88 @@
1
+ export function savedScript(): string {
2
+ return `
3
+ const key = "work-board:views:" + document.documentElement.dataset.workspace;
4
+ let views = [];
5
+ let notice = "";
6
+ let selectedName = "";
7
+ const localUrl = (value) => {
8
+ if (typeof value !== "string" || value.length > 8192) return null;
9
+ try {
10
+ const url = new URL(value, location.href);
11
+ if (url.origin !== location.origin || url.pathname !== "/_board/work") return null;
12
+ for (const name of [...url.searchParams.keys()]) {
13
+ if (!["view", "board", "owner", "status", "q", "sort"].includes(name)) url.searchParams.delete(name);
14
+ }
15
+ return url.pathname + url.search;
16
+ } catch { return null; }
17
+ };
18
+ try {
19
+ const saved = JSON.parse(localStorage.getItem(key) ?? "[]");
20
+ if (Array.isArray(saved)) {
21
+ for (const entry of saved) {
22
+ const url = localUrl(entry?.url);
23
+ if (!url || typeof entry.name !== "string" || !entry.name.trim() || entry.name.length > 60 || views.some((view) => view.name === entry.name)) continue;
24
+ views.push({ name: entry.name, url });
25
+ if (views.length === 10) break;
26
+ }
27
+ }
28
+ } catch { notice = "Saved views could not be restored; new views apply to this page until saved."; }
29
+ const persist = () => {
30
+ try { localStorage.setItem(key, JSON.stringify(views)); notice = ""; }
31
+ catch { notice = "Saved views apply to this page only; browser storage is unavailable."; }
32
+ };
33
+ const selectedView = () => views.find((view) => view.name === document.getElementById("saved-view")?.value);
34
+ const showSelection = () => {
35
+ const open = document.getElementById("open-saved-view");
36
+ if (!open) return;
37
+ const selected = selectedView();
38
+ open.hidden = !selected;
39
+ if (selected) open.href = selected.url; else open.removeAttribute("href");
40
+ document.getElementById("remove-saved-view").disabled = !selected;
41
+ };
42
+ const option = (label, value) => {
43
+ const node = document.createElement("option");
44
+ node.textContent = label; node.value = value;
45
+ return node;
46
+ };
47
+ const refresh = (chosen) => {
48
+ const tools = document.getElementById("saved-work-views");
49
+ if (!tools) return;
50
+ const picker = document.getElementById("saved-view");
51
+ const previous = chosen ?? selectedName;
52
+ const labels = views.map((view) => view.name);
53
+ if (JSON.stringify([...picker.options].slice(1).map((option) => option.value)) !== JSON.stringify(labels)) {
54
+ picker.replaceChildren(option(views.length ? "Choose a saved view" : "No saved views", ""), ...views.map((view) => option(view.name, view.name)));
55
+ }
56
+ picker.value = views.some((view) => view.name === previous) ? previous : "";
57
+ selectedName = picker.value;
58
+ picker.disabled = views.length === 0;
59
+ tools.querySelector('#save-work-view button').disabled = false;
60
+ document.getElementById("clear-saved-views").disabled = views.length === 0;
61
+ document.getElementById("saved-view-status").textContent = notice || "Save board, filters, sort, and layout locally. Up to 10 named views; saving the same name updates it. Item selection stays in the URL.";
62
+ showSelection();
63
+ };
64
+ document.addEventListener("submit", (event) => {
65
+ if (!event.target.matches?.("#save-work-view")) return;
66
+ event.preventDefault();
67
+ const name = document.getElementById("view-name").value.trim().slice(0, 60);
68
+ const url = localUrl(document.getElementById("saved-work-views").dataset.url);
69
+ if (!name || !url) return;
70
+ if (views.length === 10 && !views.some((view) => view.name === name)) {
71
+ notice = "You have 10 saved views. Remove one or update an existing name."; refresh(); return;
72
+ }
73
+ views = [{ name, url }, ...views.filter((view) => view.name !== name)];
74
+ persist(); refresh(name);
75
+ });
76
+ document.addEventListener("change", (event) => {
77
+ if (event.target.id === "saved-view") { selectedName = event.target.value; showSelection(); }
78
+ });
79
+ document.addEventListener("click", (event) => {
80
+ if (event.target.id === "remove-saved-view") views = views.filter((view) => view.name !== selectedView()?.name);
81
+ else if (event.target.id === "clear-saved-views") views = [];
82
+ else return;
83
+ persist(); refresh();
84
+ });
85
+ document.addEventListener("board-page", () => refresh());
86
+ refresh();
87
+ `;
88
+ }
@@ -0,0 +1,47 @@
1
+ import { Option, Schema } from "effect";
2
+
3
+ const View = Schema.Literals(["board", "table"]);
4
+ const Sort = Schema.Literals(["title", "owner", "status"]);
5
+ export const State = Schema.Struct({
6
+ board: Schema.String,
7
+ item: Schema.String,
8
+ owner: Schema.String,
9
+ query: Schema.String,
10
+ sort: Sort,
11
+ status: Schema.String,
12
+ view: View,
13
+ });
14
+ export type State = typeof State.Type;
15
+
16
+ export function stateOf(url: string): State {
17
+ const params = new URL(url, "http://127.0.0.1").searchParams;
18
+ return {
19
+ board: params.get("board") ?? "",
20
+ item: params.get("item") ?? "",
21
+ owner: params.get("owner") ?? "",
22
+ query: (params.get("q") ?? "").slice(0, 200),
23
+ sort: Option.getOrElse(Schema.decodeUnknownOption(Sort)(params.get("sort")), () => "title"),
24
+ status: params.get("status") ?? "",
25
+ view: Option.getOrElse(Schema.decodeUnknownOption(View)(params.get("view")), () => "board"),
26
+ };
27
+ }
28
+ export function viewUrl(state: State, changes: Partial<State> = {}, hash = ""): string {
29
+ const next = { ...state, ...changes };
30
+ const params = new URLSearchParams({ view: next.view });
31
+ for (const [key, value] of Object.entries({
32
+ board: next.board,
33
+ item: next.item,
34
+ owner: next.owner,
35
+ q: next.query,
36
+ sort: next.sort,
37
+ status: next.status,
38
+ })) {
39
+ if (value) {
40
+ params.set(key, value);
41
+ }
42
+ }
43
+ return `/_board/work?${params}${hash ? `#${hash}` : ""}`;
44
+ }
45
+ export function fieldFilter(value: string | undefined): string {
46
+ return value === undefined ? "missing" : `value:${value}`;
47
+ }
@@ -0,0 +1,11 @@
1
+ import { escapeHtml } from "#page/escape.ts";
2
+ import { type State, viewUrl } from "./state.ts";
3
+
4
+ export function viewTools(state: State): string {
5
+ const layouts = ["board", "table"] as const;
6
+ return `<nav class="view-layouts" aria-label="Work layout">${layouts.map((view) => `<a href="${escapeHtml(viewUrl(state, { view }, "work-results"))}"${state.view === view ? ' aria-current="page"' : ""}>${view === "board" ? "Board" : "Table"}</a>`).join("")}</nav>
7
+ <section id="saved-work-views" class="saved-work-views" aria-label="Saved local views" data-url="${escapeHtml(viewUrl(state, { item: "" }))}">
8
+ <form id="save-work-view"><label for="view-name">View name</label><input id="view-name" maxlength="60" placeholder="Name this view" required><button type="submit" disabled>Save view</button></form>
9
+ <div class="view-picker"><label for="saved-view">Saved views</label><select id="saved-view" disabled><option value="">No saved views</option></select><a id="open-saved-view" hidden>Open</a><button id="remove-saved-view" type="button" disabled>Remove</button><button id="clear-saved-views" type="button" disabled>Clear all</button></div>
10
+ <p id="saved-view-status" role="status">Save board, filters, sort, and layout locally. Item selection stays in the URL. Saving requires JavaScript.</p></section>`;
11
+ }
@@ -0,0 +1,26 @@
1
+ import { escapeHtml } from "#page/escape.ts";
2
+ import type { Item } from "./items.ts";
3
+ import { type State, viewUrl } from "./state.ts";
4
+
5
+ function card(item: Item, state: State): string {
6
+ const fields = item.document.parsed.fields;
7
+ const selected = item.id === state.item;
8
+ return `<article class="work-card" data-key="item:${escapeHtml(item.id)}" data-item="${escapeHtml(item.id)}"${selected ? ' data-selected="true"' : ""}>
9
+ <p class="work-kind">${escapeHtml(fields.kind ?? "Kind not recorded")}</p>
10
+ <h3><a href="${escapeHtml(viewUrl(state, { item: item.id }, "work-detail"))}"${selected ? ' aria-current="true"' : ""}>${escapeHtml(item.title)}</a></h3>
11
+ <p class="work-id">${escapeHtml(item.id)}</p><p class="work-owner">Owner: ${escapeHtml(fields.owner ?? "Not recorded")}</p>
12
+ ${fields.nextAction ? `<p class="work-next">Next: ${escapeHtml(fields.nextAction)}</p>` : ""}</article>`;
13
+ }
14
+ export function workCards(items: readonly Item[], state: State): string {
15
+ if (items.length === 0) {
16
+ return '<p class="empty">No work matches these filters.</p>';
17
+ }
18
+ const columns = Map.groupBy(items, (item) => item.document.parsed.fields.status);
19
+ return `<div class="work-columns">${[...columns]
20
+ .map(
21
+ ([status, rows]) => `<section class="work-column" data-key="status:${escapeHtml(status ?? "")}"
22
+ aria-label="${escapeHtml(status ?? "Status not recorded")}"><h2>${escapeHtml(status ?? "Status not recorded")} <span class="work-count">${rows.length}</span></h2>
23
+ ${rows.map((item) => card(item, state)).join("")}</section>`,
24
+ )
25
+ .join("")}</div>`;
26
+ }
@@ -0,0 +1,38 @@
1
+ import { escapeHtml } from "#page/escape.ts";
2
+ import type { Item } from "./items.ts";
3
+ import type { Projection } from "./projection.ts";
4
+ import { fieldFilter, type State, viewUrl } from "./state.ts";
5
+
6
+ function option(value: string, title: string, current: string): string {
7
+ return `<option value="${escapeHtml(value)}"${value === current ? " selected" : ""}>${escapeHtml(title)}</option>`;
8
+ }
9
+ function fieldOptions(items: readonly Item[], field: "owner" | "status", current: string): string {
10
+ const values = new Map(
11
+ items.map((item) => {
12
+ const value = item.document.parsed.fields[field];
13
+ return [fieldFilter(value), value ?? `${field === "owner" ? "Owner" : "Status"} not recorded`];
14
+ }),
15
+ );
16
+ if (current && !values.has(current)) {
17
+ values.set(current, `${current.startsWith("value:") ? current.slice(6) : current} (no current matches)`);
18
+ }
19
+ return (
20
+ option("", `All ${field === "owner" ? "owners" : "statuses"}`, current)
21
+ + [...values]
22
+ .sort((a, b) => a[1].localeCompare(b[1], "en"))
23
+ .map(([value, label]) => option(value, label, current))
24
+ .join("")
25
+ );
26
+ }
27
+ export function workControls(projection: Projection, state: State): string {
28
+ const boards = option("", "All work", state.board) + projection.boards.map((board) => option(board.id, board.title, state.board)).join("");
29
+ const unavailable = state.board && !projection.validBoard ? option(state.board, `${state.board} (unavailable)`, state.board) : "";
30
+ return `<form id="work-filters" class="work-filters" action="/_board/work#work-results" method="get">
31
+ <input type="hidden" name="view" value="${state.view}"><input type="hidden" name="item" value="${escapeHtml(state.item)}">
32
+ <label><span>Board</span><select name="board" id="work-board">${boards}${unavailable}</select></label>
33
+ <label><span>Status</span><select name="status" id="work-status">${fieldOptions(projection.items, "status", state.status)}</select></label>
34
+ <label><span>Owner</span><select name="owner" id="work-owner">${fieldOptions(projection.items, "owner", state.owner)}</select></label>
35
+ <label><span>Find work</span><input name="q" id="work-query" type="search" value="${escapeHtml(state.query)}" maxlength="200"></label>
36
+ <label><span>Sort by</span><select name="sort" id="work-sort">${["title", "owner", "status"].map((sort) => option(sort, sort.charAt(0).toUpperCase() + sort.slice(1), state.sort)).join("")}</select></label>
37
+ <button type="submit">Apply</button><a href="${escapeHtml(viewUrl(state, { owner: "", query: "", status: "" }, "work-results"))}">Clear filters</a></form>`;
38
+ }
@@ -0,0 +1,28 @@
1
+ import { Effect } from "effect";
2
+ import { identityUrl } from "#metadata/links.ts";
3
+ import { escapeHtml } from "#page/escape.ts";
4
+ import { metadataHtml } from "#page/metadata.ts";
5
+ import { documentHeadings } from "#render/documentHeadings.ts";
6
+ import { renderMarkdown } from "#render/markdown.ts";
7
+ import type { Snapshot } from "#search/snapshot.ts";
8
+ import type { Projection } from "./projection.ts";
9
+ import { type State, viewUrl } from "./state.ts";
10
+
11
+ export const workDetail = Effect.fn("WorkBoard.workDetail")(function* (projection: Projection, state: State, snapshot: Snapshot) {
12
+ if (!state.item) {
13
+ return "";
14
+ }
15
+ const close = `<a class="detail-close" href="${escapeHtml(viewUrl(state, { item: "" }, "work-results"))}">Close detail</a>`;
16
+ const selected = projection.selected;
17
+ if (!selected) {
18
+ const ambiguous = (snapshot.model.ids.get(state.item)?.length ?? 0) > 1;
19
+ return `<aside id="work-detail" class="work-detail" tabindex="-1" aria-label="Selected work">${close}<h2>Item unavailable</h2><p>${escapeHtml(state.item)} ${ambiguous ? "has a duplicate ID" : "is missing or its identity changed"}. No source was selected.</p><a href="${escapeHtml(identityUrl(state.item))}">Inspect item identity</a></aside>`;
20
+ }
21
+ const { document } = selected;
22
+ const prose = yield* renderMarkdown(document.parsed.body, { file: document.file, headings: documentHeadings() });
23
+ return `<aside id="work-detail" class="work-detail" data-key="detail:${escapeHtml(selected.id)}" tabindex="-1" aria-label="Selected work">
24
+ <header>${close}<a href="${escapeHtml(identityUrl(selected.id))}">Open document</a></header>
25
+ ${projection.selectedOutside ? '<p class="detail-notice" role="status">This selected item is outside the current board or filters.</p>' : ""}
26
+ <p class="meta">Source: ${escapeHtml(document.file)}</p>${metadataHtml(document.parsed, document.file, snapshot.model)}
27
+ <article class="doc">${prose}</article></aside>`;
28
+ });
@@ -0,0 +1,39 @@
1
+ import { Effect } from "effect";
2
+ import { identityUrl } from "#metadata/links.ts";
3
+ import { escapeHtml } from "#page/escape.ts";
4
+ import type { Snapshot } from "#search/snapshot.ts";
5
+ import { projectItems } from "./projection.ts";
6
+ import type { State } from "./state.ts";
7
+ import { viewTools } from "./tools.ts";
8
+ import { workCards } from "./workCards.ts";
9
+ import { workControls } from "./workControls.ts";
10
+ import { workDetail } from "./workDetail.ts";
11
+ import { workTable } from "./workTable.ts";
12
+
13
+ export const workHtml = Effect.fn("WorkBoard.workHtml")(function* (snapshot: Snapshot, state: State) {
14
+ const projection = projectItems(snapshot, state);
15
+ const controls = workControls(projection, state);
16
+ const tools = viewTools(state);
17
+ if (!projection.validBoard) {
18
+ return {
19
+ html: `<h1>Board unavailable</h1>${tools}${controls}<p class="empty">${escapeHtml(state.board)} is missing, has a duplicate ID, or is not a declared board. Choose another board or open its <a href="${escapeHtml(identityUrl(state.board))}">source identity</a>.</p>`,
20
+ status: 404,
21
+ };
22
+ }
23
+ const issues =
24
+ projection.issues.length > 0
25
+ ? `<details class="view-issues"><summary>Source issues (${projection.issues.length})</summary><ul>${projection.issues.map((issue) => `<li>${escapeHtml(issue)}</li>`).join("")}</ul></details>`
26
+ : "";
27
+ const source = state.board ? `<a href="${escapeHtml(identityUrl(state.board))}">Board source</a>` : "";
28
+ const empty =
29
+ projection.items.length === 0
30
+ ? '<p class="empty">No identified work is declared here. Keep reading ordinary files, or add item IDs and explicit board membership in their Markdown sources.</p>'
31
+ : "";
32
+ const detail = yield* workDetail(projection, state, snapshot);
33
+ return {
34
+ html: `<header class="work-head"><p class="work-eyebrow">WORK · ${state.view.toUpperCase()} VIEW</p><h1>${escapeHtml(projection.title)}</h1><p>Views use recorded fields. A section heading does not set an item's status.</p>${source}</header>
35
+ ${tools}${controls}${issues}<p class="work-total" role="status">${projection.rows.length} shown · ${projection.items.length} items in this view</p>
36
+ <div class="work-layout${state.item ? " has-detail" : ""}"><section id="work-results" tabindex="-1" aria-label="Work items">${empty}${state.view === "table" ? workTable(projection.rows, state) : workCards(projection.rows, state)}</section>${detail}</div>`,
37
+ status: 200,
38
+ };
39
+ });
@@ -0,0 +1,17 @@
1
+ export function workStyle(): string {
2
+ return `
3
+ .work-head h1{margin:0 0 8px}.work-eyebrow{font:11px var(--mono);letter-spacing:.12em;color:var(--muted);margin:0 0 12px}.work-head>p:not(.work-eyebrow){color:var(--muted);font-size:.9rem;margin:0 0 12px}
4
+ .work-filters{display:flex;flex-wrap:wrap;gap:12px;align-items:end;margin:24px 0 16px;padding:16px;border:1px solid var(--border);border-radius:10px;background:var(--surface)}
5
+ .work-filters label{display:grid;gap:5px;font-size:12px;color:var(--muted);flex:1 1 130px;min-width:0}.work-filters input,.work-filters select{width:100%;min-width:0;font:inherit;font-size:14px;color:var(--text);background:var(--card);border:1px solid var(--border);border-radius:5px;padding:8px}
6
+ .work-filters button{font:inherit;color:var(--text);background:var(--card);border:1px solid var(--border);border-radius:5px;padding:8px 12px}.work-filters>a{font-size:13px;padding:8px 0}.work-total{font-size:13px;color:var(--muted);margin:0 0 20px}
7
+ .work-layout{display:grid;grid-template-columns:minmax(0,1fr);gap:24px;align-items:start}.work-layout.has-detail{grid-template-columns:minmax(0,1fr) minmax(300px,420px)}
8
+ .work-columns{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,220px),1fr));gap:16px}.work-column{min-width:0}.work-column>h2{display:flex;justify-content:space-between;gap:12px;margin:0 0 12px;font-size:14px;overflow-wrap:anywhere}.work-count{font:12px var(--mono);color:var(--muted)}
9
+ .work-card{padding:var(--card-space);margin-bottom:12px;border:1px solid var(--border);border-radius:10px;background:var(--card);overflow-wrap:anywhere}.work-card[data-selected="true"]{border-color:var(--link);box-shadow:0 0 0 1px var(--link)}.work-card h3{font-size:1rem;margin:6px 0}.work-card h3 a{text-decoration:none}.work-kind{font-size:11px;text-transform:uppercase;letter-spacing:.04em;color:var(--muted);margin:0}.work-id{font:11px var(--mono);color:var(--muted);margin:0 0 12px}.work-owner,.work-next{font-size:13px;margin:0 0 8px}.work-card>:last-child{margin-bottom:0}
10
+ .work-detail{position:sticky;top:24px;max-height:calc(100vh - 48px);overflow:auto;min-width:0;padding:20px;border:1px solid var(--border);border-radius:12px;background:var(--card);scroll-margin-top:20px}.work-detail>header{display:flex;justify-content:space-between;gap:16px;font-size:13px;margin:0 0 16px}.work-detail .work-meta{padding:12px;font-size:13px}.work-detail .doc{font-size:.9rem}.work-detail .doc h1{font-size:1.5rem}.detail-notice{padding:10px;background:var(--surface);font-size:13px}.view-issues{margin:0 0 16px;padding:12px;background:var(--surface);border-radius:8px;font-size:13px;overflow-wrap:anywhere}
11
+ .work-open{font-size:13px;white-space:nowrap}.work-open[aria-current="page"]{font-weight:700;text-decoration:underline}#work-results{scroll-margin-top:20px}
12
+ .view-layouts{display:flex;gap:6px;margin:20px 0 12px}.view-layouts a{padding:5px 14px;border:1px solid var(--border);border-radius:6px;text-decoration:none;font-size:13px}.view-layouts a[aria-current="page"]{background:var(--surface);font-weight:600}
13
+ .saved-work-views{display:flex;flex-wrap:wrap;gap:12px 24px;align-items:center;font-size:12px}.saved-work-views form,.view-picker{display:flex;align-items:center;flex-wrap:wrap;gap:8px;min-width:0}.saved-work-views input,.saved-work-views select{min-width:0;max-width:100%;padding:6px 10px;font:inherit;border:1px solid var(--border);border-radius:6px;background:var(--card);color:var(--text)}.saved-work-views>p{flex-basis:100%;margin:0;color:var(--muted)}
14
+ .work-table-wrap{max-width:100%;overflow:auto}.work-table{display:table;width:100%;margin:0;font-size:13px;background:var(--card)}.work-table th,.work-table td{padding:calc(var(--card-space)/2);overflow-wrap:anywhere;min-width:100px}.work-table th{font-weight:500}.work-table tbody th{min-width:160px}.work-table .work-id{display:block;margin:6px 0 0}.work-table tr[data-selected="true"]>*{background:var(--surface);box-shadow:inset 0 -2px var(--link)}
15
+ @media(max-width:1000px){.work-layout.has-detail{grid-template-columns:minmax(0,1fr)}.work-detail{position:static;max-height:none;grid-row:1}.work-filters{padding:12px;gap:10px}}
16
+ `;
17
+ }
@@ -0,0 +1,21 @@
1
+ import { escapeHtml } from "#page/escape.ts";
2
+ import type { Item } from "./items.ts";
3
+ import { type State, viewUrl } from "./state.ts";
4
+
5
+ function row(item: Item, state: State): string {
6
+ const fields = item.document.parsed.fields;
7
+ const selected = item.id === state.item;
8
+ const values = [fields.kind, fields.status, fields.owner, fields.nextAction];
9
+ return `<tr data-item="${escapeHtml(item.id)}"${selected ? ' data-selected="true"' : ""}>
10
+ <th scope="row"><a href="${escapeHtml(viewUrl(state, { item: item.id }, "work-detail"))}"${selected ? ' aria-current="true"' : ""}>${escapeHtml(item.title)}</a><span class="work-id">${escapeHtml(item.id)}</span></th>
11
+ ${values.map((value) => `<td>${escapeHtml(value ?? "Not recorded")}</td>`).join("")}</tr>`;
12
+ }
13
+
14
+ export function workTable(items: readonly Item[], state: State): string {
15
+ if (items.length === 0) {
16
+ return '<p class="empty">No work matches these filters.</p>';
17
+ }
18
+ return `<div class="work-table-wrap"><table class="work-table"><caption class="sr-only">Identified work, sorted by ${escapeHtml(state.sort)}</caption>
19
+ <thead><tr>${["Work", "Kind", "Status", "Owner", "Next action"].map((title) => `<th scope="col">${title}</th>`).join("")}</tr></thead>
20
+ <tbody>${items.map((item) => row(item, state)).join("")}</tbody></table></div>`;
21
+ }