@shivaedev/work-board 0.6.1 → 0.7.1

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 (75) hide show
  1. package/CHANGELOG.md +35 -2
  2. package/README.md +19 -0
  3. package/assets/native.js +1 -1
  4. package/dist/board.d.ts.map +1 -1
  5. package/dist/board.js +6 -0
  6. package/dist/board.js.map +1 -1
  7. package/dist/cli.js +1 -1
  8. package/dist/http/page.d.ts.map +1 -1
  9. package/dist/http/page.js +4 -0
  10. package/dist/http/page.js.map +1 -1
  11. package/dist/http/start.d.ts +4 -0
  12. package/dist/http/start.d.ts.map +1 -0
  13. package/dist/http/start.js +12 -0
  14. package/dist/http/start.js.map +1 -0
  15. package/dist/page/navigationScript.js +1 -1
  16. package/dist/page/shell.d.ts +1 -1
  17. package/dist/page/shell.d.ts.map +1 -1
  18. package/dist/page/shell.js +1 -1
  19. package/dist/page/shell.js.map +1 -1
  20. package/dist/page/startHtml.d.ts +2 -0
  21. package/dist/page/startHtml.d.ts.map +1 -0
  22. package/dist/page/startHtml.js +10 -0
  23. package/dist/page/startHtml.js.map +1 -0
  24. package/dist/page/startTemplates.d.ts +14 -0
  25. package/dist/page/startTemplates.d.ts.map +1 -0
  26. package/dist/page/startTemplates.js +104 -0
  27. package/dist/page/startTemplates.js.map +1 -0
  28. package/dist/page/style.d.ts.map +1 -1
  29. package/dist/page/style.js +37 -1
  30. package/dist/page/style.js.map +1 -1
  31. package/dist/render/board.d.ts.map +1 -1
  32. package/dist/render/board.js +5 -2
  33. package/dist/render/board.js.map +1 -1
  34. package/dist/render/callouts.d.ts +3 -0
  35. package/dist/render/callouts.d.ts.map +1 -0
  36. package/dist/render/callouts.js +27 -0
  37. package/dist/render/callouts.js.map +1 -0
  38. package/dist/render/markdown.d.ts.map +1 -1
  39. package/dist/render/markdown.js +4 -1
  40. package/dist/render/markdown.js.map +1 -1
  41. package/dist/render/visual.d.ts +3 -0
  42. package/dist/render/visual.d.ts.map +1 -0
  43. package/dist/render/visual.js +90 -0
  44. package/dist/render/visual.js.map +1 -0
  45. package/dist/render/visualDocuments.d.ts +5 -0
  46. package/dist/render/visualDocuments.d.ts.map +1 -0
  47. package/dist/render/visualDocuments.js +57 -0
  48. package/dist/render/visualDocuments.js.map +1 -0
  49. package/dist/render/visualFields.d.ts +12 -0
  50. package/dist/render/visualFields.d.ts.map +1 -0
  51. package/dist/render/visualFields.js +13 -0
  52. package/dist/render/visualFields.js.map +1 -0
  53. package/dist/rpc/contract.js +1 -1
  54. package/dist/rpc/contract.js.map +1 -1
  55. package/dist/search/entries.d.ts.map +1 -1
  56. package/dist/search/entries.js +4 -1
  57. package/dist/search/entries.js.map +1 -1
  58. package/package.json +4 -2
  59. package/src/board.ts +6 -0
  60. package/src/cli.ts +1 -1
  61. package/src/http/page.ts +4 -0
  62. package/src/http/start.ts +16 -0
  63. package/src/page/navigationScript.ts +1 -1
  64. package/src/page/shell.ts +2 -2
  65. package/src/page/startHtml.ts +10 -0
  66. package/src/page/startTemplates.ts +103 -0
  67. package/src/page/style.ts +38 -1
  68. package/src/render/board.ts +5 -2
  69. package/src/render/callouts.ts +29 -0
  70. package/src/render/markdown.ts +4 -1
  71. package/src/render/visual.ts +100 -0
  72. package/src/render/visualDocuments.ts +68 -0
  73. package/src/render/visualFields.ts +17 -0
  74. package/src/rpc/contract.ts +1 -1
  75. package/src/search/entries.ts +4 -1
@@ -0,0 +1,103 @@
1
+ export const startTemplates = [
2
+ {
3
+ file: "project.md",
4
+ label: "Project",
5
+ source: `---
6
+ id: example.project
7
+ kind: project
8
+ criteria:
9
+ - id: readable-result
10
+ text: A reviewer can trace the result to its reasoning and recorded evidence.
11
+ ---
12
+ # Project
13
+
14
+ ## Goal
15
+ Describe the outcome and who needs it.
16
+
17
+ ## Scope and constraints
18
+ Name what belongs here and what must remain outside this work.
19
+
20
+ ## Reading path
21
+ - [Investigation](investigation.md)
22
+ - [Agent result](result.md)
23
+
24
+ ## Acceptance
25
+ The criterion above is a target, not a recorded pass.
26
+ Record what was checked, the source revision, and remaining limitations.
27
+ `,
28
+ },
29
+ {
30
+ file: "investigation.md",
31
+ label: "Investigation",
32
+ source: `---
33
+ id: example.investigation
34
+ kind: investigation
35
+ relationships:
36
+ - kind: informs
37
+ target: example.project
38
+ criteria:
39
+ - id: explained-choice
40
+ text: Options, evidence, tradeoffs and the recommendation are explicit.
41
+ ---
42
+ # Investigation
43
+
44
+ ## Question and background
45
+ Explain the decision in context; do not require the reader to reconstruct it.
46
+
47
+ ## Options and tradeoffs
48
+ | Option | Benefit | Cost | Evidence |
49
+ | --- | --- | --- | --- |
50
+ | First option | Not recorded | Not recorded | Not recorded |
51
+ | Second option | Not recorded | Not recorded | Not recorded |
52
+
53
+ ## Recommendation
54
+ State your take and what remains uncertain.
55
+
56
+ ## Response needed
57
+ Name the exact question and the choices. Do not imply that a response was recorded.
58
+
59
+ [Project and acceptance target](project.md)
60
+ `,
61
+ },
62
+ {
63
+ file: "result.md",
64
+ label: "Agent result",
65
+ source: `---
66
+ id: example.result
67
+ kind: result
68
+ relationships:
69
+ - kind: implements
70
+ target: example.investigation
71
+ - kind: relates_to
72
+ target: example.project
73
+ # Add evidence only after a real observation; replace every placeholder:
74
+ # evidence:
75
+ # - criterion: example.project#readable-result
76
+ # source: ./evidence/reading-check.md
77
+ # method: Describe the actual check.
78
+ # outcome: Describe the observed result and its limits.
79
+ # observed_at: Use the actual ISO timestamp with timezone.
80
+ # checked_revision: Use the actual full source commit SHA.
81
+ ---
82
+ # Agent result
83
+
84
+ ## Outcome and changes
85
+ Explain what changed and why. Link the relevant source files or patch.
86
+
87
+ ## Reasoning
88
+ [Investigation and alternatives](investigation.md)
89
+
90
+ ## Checks and evidence
91
+ Not recorded. Replace this with actual commands, observations and local evidence links.
92
+ Recorded evidence does not by itself establish verified acceptance.
93
+
94
+ ## Limits and unresolved questions
95
+ Name failures, skipped checks, uncertainty and any response needed.
96
+
97
+ ## Next action
98
+ State the next action and who needs to respond, if known.
99
+
100
+ [Project and acceptance target](project.md)
101
+ `,
102
+ },
103
+ ] as const;
package/src/page/style.ts CHANGED
@@ -58,5 +58,42 @@ figure.diagram[data-state="failed"] .diagram-source { display: block; }
58
58
  @media (prefers-reduced-motion: reduce) { * { transition: none !important; animation: none !important; } }
59
59
  `;
60
60
 
61
+ const documents = `
62
+ .start-open { display: block; padding: 0 12px 18px; color: var(--sidebar-text); }
63
+ .onboarding { max-width: 76ch; }
64
+ .start-template textarea { display: block; box-sizing: border-box; width: 100%; margin: 12px 0; padding: 12px; font-family: var(--mono); font-size: 13px; color: var(--text); background: var(--surface); border: 1px solid var(--border); border-radius: 6px; }
65
+ @media print {
66
+ .sidebar, .bar, .preferences, .reading-tools, .visual-tools { display: none !important; }
67
+ .workspace { display: block !important; }
68
+ .page { width: 100%; }
69
+ #doc { padding: 0; }
70
+ .doc pre, .doc table { white-space: pre-wrap; overflow: visible; }
71
+ figure.diagram[data-state="pending"] .diagram-source, figure.diagram[data-state="failed"] .diagram-source { display: block; }
72
+ }
73
+
74
+ .visual-document, .visual-callout { border: 1px solid var(--border); border-radius: 12px; padding: 1em 1.2em; margin: 1.2em 0; overflow-wrap: anywhere; }
75
+ .visual-document > :last-child, .visual-callout > :last-child { margin-bottom: 0; }
76
+ .visual-label { font-weight: 650; margin-top: 0; }
77
+ .visual-value { font-size: 1.35em; font-variant-numeric: tabular-nums; }
78
+ .visual-document progress { display: block; width: 100%; height: 1em; accent-color: var(--link); }
79
+ .visual-timeline-entries { border-left: 2px solid var(--border); padding-left: 1.6em; }
80
+ .visual-timeline-entries > li { padding: .3em 0 .7em .2em; }
81
+ .visual-callout { border-left: 4px solid var(--link); background: var(--surface); }
82
+ .visual-warning, .visual-caution { border-left-color: #b97720; }
83
+ .visual-diagnostic { font-weight: 600; color: var(--muted); }
84
+ @media print { .visual-document, .visual-callout { break-inside: avoid; } }
85
+ `;
86
+
61
87
  export const style =
62
- theme + layout + prose + board + diagrams + readingStyles() + searchStyles + metadataStyles + workStyle() + attentionStyle() + visualStyles;
88
+ theme
89
+ + layout
90
+ + prose
91
+ + board
92
+ + diagrams
93
+ + readingStyles()
94
+ + searchStyles
95
+ + metadataStyles
96
+ + workStyle()
97
+ + attentionStyle()
98
+ + visualStyles
99
+ + documents;
@@ -1,5 +1,8 @@
1
1
  import type { RootContent } from "mdast";
2
- import { markdownToMdast } from "satteri";
2
+ import remarkDirective from "remark-directive";
3
+ import remarkGfm from "remark-gfm";
4
+ import remarkParse from "remark-parse";
5
+ import { unified } from "unified";
3
6
 
4
7
  export interface Section {
5
8
  readonly heading: string;
@@ -58,7 +61,7 @@ const joined = (blocks: readonly string[]): string => blocks.join("\n\n");
58
61
 
59
62
  export const boardOf = (source: string): Board => {
60
63
  const draft: Draft = { definitions: [], footer: undefined, intro: [], sections: [], title: "" };
61
- const root = markdownToMdast(source);
64
+ const root = unified().use(remarkParse).use(remarkGfm).use(remarkDirective).parse(source);
62
65
  const nodes = root.type === "root" ? root.children : [];
63
66
  const lastHeading = nodes.findLastIndex((node) => node.type === "heading");
64
67
  nodes.forEach((node, index) => {
@@ -0,0 +1,29 @@
1
+ import type { Root } from "mdast";
2
+ import { visit as visitNodes } from "unist-util-visit";
3
+
4
+ const alerts = new Set(["NOTE", "TIP", "IMPORTANT", "WARNING", "CAUTION"]);
5
+
6
+ export function callouts() {
7
+ return function transform(tree: Root) {
8
+ visitNodes(tree, "blockquote", (node) => {
9
+ const [first] = node.children;
10
+ const text = first?.type === "paragraph" ? first.children.at(0) : undefined;
11
+ if (text?.type !== "text") {
12
+ return;
13
+ }
14
+ const match = /^\[!(?<kind>[A-Z]+)\](?:\n|$)/u.exec(text.value);
15
+ const named = match?.groups?.kind;
16
+ if (!(named && alerts.has(named))) {
17
+ return;
18
+ }
19
+ const kind = named.toLowerCase();
20
+ text.value = text.value.slice(match?.[0].length ?? 0);
21
+ node.children.unshift({
22
+ children: [{ type: "text", value: named[0] + kind.slice(1) }],
23
+ data: { hProperties: { className: ["visual-label"] } },
24
+ type: "paragraph",
25
+ });
26
+ node.data = { ...node.data, hName: "aside", hProperties: { className: ["visual-callout", `visual-${kind}`], role: "note" } };
27
+ });
28
+ };
29
+ }
@@ -3,8 +3,11 @@ import { createElement } from "react";
3
3
  import { renderToStaticMarkup } from "react-dom/server";
4
4
  import { MarkdownAsync } from "react-markdown";
5
5
  import rehypeRaw from "rehype-raw";
6
+ import remarkDirective from "remark-directive";
6
7
  import remarkGfm from "remark-gfm";
7
8
  import type { PluggableList } from "unified";
9
+ import { callouts } from "#render/callouts.ts";
10
+ import { visualDocuments } from "#render/visualDocuments.ts";
8
11
  import { codeBlocks } from "./code.ts";
9
12
  import type { documentHeadings } from "./documentHeadings.ts";
10
13
  import { documentLinks } from "./documentLinks.ts";
@@ -38,7 +41,7 @@ export const renderMarkdown = Effect.fn("WorkBoard.renderMarkdown")(function* (s
38
41
  children: source,
39
42
  components: { img: ({ node: _node, ...props }) => createElement("img", { ...props, loading: "lazy" }) },
40
43
  rehypePlugins: plugins,
41
- remarkPlugins: [remarkGfm],
44
+ remarkPlugins: [remarkGfm, remarkDirective, visualDocuments, callouts],
42
45
  }),
43
46
  ),
44
47
  });
@@ -0,0 +1,100 @@
1
+ import { Option, Schema } from "effect";
2
+ import type { Paragraph, RootContent } from "mdast";
3
+ import type { ContainerDirective } from "mdast-util-directive";
4
+ import { MetricFields, ProgressFields } from "./visualFields.ts";
5
+
6
+ function textOf(node: RootContent): string {
7
+ if ("value" in node) {
8
+ return node.value;
9
+ }
10
+ return "children" in node ? node.children.map((child) => textOf(child)).join("") : "";
11
+ }
12
+
13
+ function paragraph(value: string, className?: string): Paragraph {
14
+ return { children: [{ type: "text", value }], data: { hProperties: { className: className ? [className] : [] } }, type: "paragraph" };
15
+ }
16
+
17
+ function hasLink(node: RootContent): boolean {
18
+ if (node.type === "link") {
19
+ return node.url.trim().length > 0;
20
+ }
21
+ if (node.type === "linkReference") {
22
+ return node.identifier.trim().length > 0;
23
+ }
24
+ return "children" in node && node.children.some((child) => hasLink(child));
25
+ }
26
+
27
+ function provenance(node: ContainerDirective): boolean {
28
+ return node.children.some((child) => child.type === "paragraph" && textOf(child).toLowerCase().startsWith("source:") && hasLink(child));
29
+ }
30
+
31
+ function fields<A>(schema: Schema.ConstraintDecoder<A, never>, node: ContainerDirective): A {
32
+ const parsed = Schema.decodeUnknownOption(schema, { onExcessProperty: "error" })(node.attributes ?? {});
33
+ if (Option.isNone(parsed)) {
34
+ throw new Error("Invalid or unknown attributes.");
35
+ }
36
+ return parsed.value;
37
+ }
38
+
39
+ function metric(node: ContainerDirective) {
40
+ const { unit, value } = fields(MetricFields, node);
41
+ if (!provenance(node)) {
42
+ throw new Error("Name a source with a Source: Markdown link.");
43
+ }
44
+ node.children.splice(1, 0, paragraph(value === undefined || value === "unknown" ? "Not recorded" : `${value} ${unit}`, "visual-value"));
45
+ }
46
+
47
+ function progress(node: ContainerDirective, label: string) {
48
+ const { completed, total } = fields(ProgressFields, node);
49
+ if (!provenance(node)) {
50
+ throw new Error("Name a source with a Source: Markdown link.");
51
+ }
52
+ const known = typeof completed === "number" && typeof total === "number";
53
+ if (known && completed > total) {
54
+ throw new Error("Completed count exceeds total.");
55
+ }
56
+ function display(value: number | "unknown" | undefined) {
57
+ return typeof value === "number" ? String(value) : "Not recorded";
58
+ }
59
+ const tally = `Recorded tally: ${display(completed)} of ${display(total)}`;
60
+ node.children.splice(1, 0, paragraph(tally + (known && total > 0 ? "" : ". Percentage unavailable."), "visual-value"));
61
+ if (known && total > 0) {
62
+ node.children.splice(2, 0, {
63
+ children: [{ type: "text", value: tally }],
64
+ data: { hName: "progress", hProperties: { ariaLabel: `${label}: ${tally}`, max: String(total), value: String(completed) } },
65
+ type: "paragraph",
66
+ });
67
+ }
68
+ }
69
+
70
+ function timeline(node: ContainerDirective) {
71
+ fields(Schema.Struct({}), node);
72
+ const lists = node.children.filter((child) => child.type === "list");
73
+ const [list] = lists;
74
+ if (lists.length !== 1 || !list?.ordered || list.children.length === 0 || list.children.length > 100) {
75
+ throw new Error("Use one ordered list with 1–100 entries.");
76
+ }
77
+ if (list.children.some((item) => !hasLink(item) || textOf(item).trim().length === 0)) {
78
+ throw new Error("Each timeline entry needs text and a source link.");
79
+ }
80
+ list.data = { ...list.data, hProperties: { className: ["visual-timeline-entries"] } };
81
+ }
82
+
83
+ export function renderVisual(node: ContainerDirective) {
84
+ const [first] = node.children;
85
+ if (first?.type !== "paragraph" || textOf(first).trim().length === 0 || textOf(first).toLowerCase().startsWith("source:")) {
86
+ throw new Error("Start with a Markdown label paragraph.");
87
+ }
88
+ const label = textOf(first).trim();
89
+ if (node.name === "metric") {
90
+ metric(node);
91
+ }
92
+ if (node.name === "progress") {
93
+ progress(node, label);
94
+ }
95
+ if (node.name === "timeline") {
96
+ timeline(node);
97
+ }
98
+ first.data = { ...first.data, hProperties: { className: ["visual-label"] } };
99
+ node.data = { ...node.data, hName: "section", hProperties: { ariaLabel: label, className: ["visual-document", `visual-${node.name}`] } };
100
+ }
@@ -0,0 +1,68 @@
1
+ import type { Root, RootContent } from "mdast";
2
+ import type { ContainerDirective, LeafDirective, TextDirective } from "mdast-util-directive";
3
+ import { SKIP, visit as visitNodes } from "unist-util-visit";
4
+ import { renderVisual } from "./visual.ts";
5
+
6
+ type Directive = ContainerDirective | LeafDirective | TextDirective;
7
+ const names = new Set(["metric", "progress", "timeline"]);
8
+
9
+ function isDirective(node: { readonly type: string }): node is Directive {
10
+ return node.type === "containerDirective" || node.type === "leafDirective" || node.type === "textDirective";
11
+ }
12
+
13
+ function nested(node: RootContent): boolean {
14
+ return isDirective(node) || ("children" in node && node.children.some((child) => nested(child)));
15
+ }
16
+
17
+ function failureOf(node: Directive, source: string): string | undefined {
18
+ try {
19
+ if (!names.has(node.name)) {
20
+ throw new Error(`Unknown visual directive ${node.name}.`);
21
+ }
22
+ if (node.type !== "containerDirective") {
23
+ throw new Error("Use a ::: container directive.");
24
+ }
25
+ const length = new TextEncoder().encode(source.slice(node.position?.start.offset, node.position?.end.offset)).byteLength;
26
+ if (length > 65_536) {
27
+ throw new Error("Visual directive exceeds 64 KiB.");
28
+ }
29
+ if (node.children.some((child) => nested(child))) {
30
+ throw new Error("Nested visual directives are not supported.");
31
+ }
32
+ renderVisual(node);
33
+ return undefined;
34
+ } catch (error) {
35
+ return error instanceof Error ? error.message : String(error);
36
+ }
37
+ }
38
+
39
+ export function visualDocuments() {
40
+ return function transform(tree: Root, file: { readonly value: unknown }) {
41
+ const source = String(file.value);
42
+ visitNodes(tree, ["containerDirective", "leafDirective", "textDirective"], (directive, index, parent) => {
43
+ if (!isDirective(directive) || index === undefined || parent === undefined) {
44
+ return;
45
+ }
46
+ const failure = failureOf(directive, source);
47
+ if (failure === undefined) {
48
+ return;
49
+ }
50
+ const original = source.slice(directive.position?.start.offset, directive.position?.end.offset);
51
+ if (directive.type === "textDirective") {
52
+ parent.children.splice(index, 1, { type: "text", value: original });
53
+ return [SKIP, index + 1] as const;
54
+ }
55
+ parent.children.splice(
56
+ index,
57
+ 1,
58
+ {
59
+ children: [{ type: "text", value: `Visual component could not be rendered: ${failure}` }],
60
+ data: { hProperties: { className: ["visual-diagnostic"] } },
61
+ type: "paragraph",
62
+ },
63
+ { type: "code", value: original },
64
+ );
65
+ return [SKIP, index + 2] as const;
66
+ });
67
+ };
68
+ }
@@ -0,0 +1,17 @@
1
+ import { Schema, SchemaTransformation } from "effect";
2
+
3
+ const NumberValue = Schema.String.check(Schema.isPattern(/\S/u)).pipe(Schema.decodeTo(Schema.Finite, SchemaTransformation.numberFromString));
4
+ const Count = NumberValue.check(Schema.makeFilter((value) => (Number.isSafeInteger(value) && value >= 0) || "Expected a nonnegative safe integer"));
5
+ const UnknownValue = Schema.Literal("unknown");
6
+
7
+ export const MetricFields = Schema.Struct({
8
+ unit: Schema.String.check(Schema.isPattern(/\S/u)),
9
+ value: Schema.optional(Schema.Union([NumberValue, UnknownValue])),
10
+ });
11
+ export const ProgressFields = Schema.Struct({
12
+ completed: Schema.optional(Schema.Union([Count, UnknownValue])),
13
+ total: Schema.optional(Schema.Union([Count, UnknownValue])),
14
+ });
15
+
16
+ export type MetricFields = typeof MetricFields.Type;
17
+ export type ProgressFields = typeof ProgressFields.Type;
@@ -18,7 +18,7 @@ const PageUrl = Schema.String.check(
18
18
  const path = new URL(value, "http://127.0.0.1").pathname;
19
19
  return (
20
20
  path === "/"
21
- || ["/_board/work", "/_board/overview", "/_board/changes"].includes(path)
21
+ || ["/_board/work", "/_board/overview", "/_board/changes", "/_board/start"].includes(path)
22
22
  || /^\/_board\/item\/[^/]+\/$/u.exec(path) !== null
23
23
  || /\.md$/iu.exec(path) !== null
24
24
  || "Expected a local reading URL"
@@ -1,9 +1,12 @@
1
1
  import { MarkdownAsync } from "react-markdown";
2
2
  import rehypeRaw from "rehype-raw";
3
+ import remarkDirective from "remark-directive";
3
4
  import remarkGfm from "remark-gfm";
4
5
  import { fileUrl } from "#files/url.ts";
5
6
  import { boardOf } from "#render/board.ts";
7
+ import { callouts } from "#render/callouts.ts";
6
8
  import { documentHeadings } from "#render/documentHeadings.ts";
9
+ import { visualDocuments } from "#render/visualDocuments.ts";
7
10
  import { searchCollector } from "./collector.ts";
8
11
  import { linkCollector } from "./linkCollector.ts";
9
12
 
@@ -46,7 +49,7 @@ export async function entriesOf(source: string, file: string, home: boolean, bod
46
49
  await MarkdownAsync({
47
50
  children: board?.definitions ? `${fragment}\n\n${board.definitions}` : fragment,
48
51
  rehypePlugins: [rehypeRaw, headings.plugin, plugin, linkCollector(links)],
49
- remarkPlugins: [remarkGfm],
52
+ remarkPlugins: [remarkGfm, remarkDirective, visualDocuments, callouts],
50
53
  });
51
54
  }
52
55
  }