@shivaedev/work-board 0.8.0 → 0.9.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 (166) hide show
  1. package/CHANGELOG.md +20 -0
  2. package/README.md +18 -1
  3. package/assets/agent.js +14 -14
  4. package/assets/native.js +49 -27
  5. package/dist/browser/responses/Prompt.d.ts +10 -0
  6. package/dist/browser/responses/Prompt.d.ts.map +1 -0
  7. package/dist/browser/responses/Prompt.js +2 -0
  8. package/dist/browser/responses/Prompt.js.map +1 -0
  9. package/dist/browser/responses/answerBody.d.ts +4 -0
  10. package/dist/browser/responses/answerBody.d.ts.map +1 -0
  11. package/dist/browser/responses/answerBody.js +9 -0
  12. package/dist/browser/responses/answerBody.js.map +1 -0
  13. package/dist/browser/responses/draftSession.d.ts.map +1 -1
  14. package/dist/browser/responses/draftSession.js +23 -10
  15. package/dist/browser/responses/draftSession.js.map +1 -1
  16. package/dist/browser/responses/drafts.d.ts +5 -0
  17. package/dist/browser/responses/drafts.d.ts.map +1 -1
  18. package/dist/browser/responses/drafts.js +20 -1
  19. package/dist/browser/responses/drafts.js.map +1 -1
  20. package/dist/browser/responses/preview.d.ts +2 -1
  21. package/dist/browser/responses/preview.d.ts.map +1 -1
  22. package/dist/browser/responses/preview.js +3 -2
  23. package/dist/browser/responses/preview.js.map +1 -1
  24. package/dist/browser/responses/questionnaireControls.d.ts +10 -0
  25. package/dist/browser/responses/questionnaireControls.d.ts.map +1 -0
  26. package/dist/browser/responses/questionnaireControls.js +68 -0
  27. package/dist/browser/responses/questionnaireControls.js.map +1 -0
  28. package/dist/browser/responses/schema.d.ts +41 -0
  29. package/dist/browser/responses/schema.d.ts.map +1 -1
  30. package/dist/browser/responses/schema.js +11 -1
  31. package/dist/browser/responses/schema.js.map +1 -1
  32. package/dist/browser/responses/setup.d.ts.map +1 -1
  33. package/dist/browser/responses/setup.js +34 -4
  34. package/dist/browser/responses/setup.js.map +1 -1
  35. package/dist/browser/responses/submissionFields.d.ts +15 -0
  36. package/dist/browser/responses/submissionFields.d.ts.map +1 -0
  37. package/dist/browser/responses/submissionFields.js +14 -0
  38. package/dist/browser/responses/submissionFields.js.map +1 -0
  39. package/dist/browser/responses/submit.d.ts.map +1 -1
  40. package/dist/browser/responses/submit.js +3 -9
  41. package/dist/browser/responses/submit.js.map +1 -1
  42. package/dist/metadata/backlinks.d.ts.map +1 -1
  43. package/dist/metadata/backlinks.js +3 -0
  44. package/dist/metadata/backlinks.js.map +1 -1
  45. package/dist/metadata/fields.d.ts +6 -0
  46. package/dist/metadata/fields.d.ts.map +1 -1
  47. package/dist/metadata/problems.d.ts.map +1 -1
  48. package/dist/metadata/problems.js +1 -0
  49. package/dist/metadata/problems.js.map +1 -1
  50. package/dist/metadata/schema.d.ts +6 -0
  51. package/dist/metadata/schema.d.ts.map +1 -1
  52. package/dist/page/metadataDetails.js +1 -1
  53. package/dist/page/metadataDetails.js.map +1 -1
  54. package/dist/page/style.d.ts.map +1 -1
  55. package/dist/page/style.js +1 -0
  56. package/dist/page/style.js.map +1 -1
  57. package/dist/render/markdown.d.ts +1 -0
  58. package/dist/render/markdown.d.ts.map +1 -1
  59. package/dist/render/markdown.js +4 -2
  60. package/dist/render/markdown.js.map +1 -1
  61. package/dist/render/questionnaireDocuments.d.ts +3 -0
  62. package/dist/render/questionnaireDocuments.d.ts.map +1 -0
  63. package/dist/render/questionnaireDocuments.js +11 -0
  64. package/dist/render/questionnaireDocuments.js.map +1 -0
  65. package/dist/render/visualDocuments.d.ts.map +1 -1
  66. package/dist/render/visualDocuments.js +3 -0
  67. package/dist/render/visualDocuments.js.map +1 -1
  68. package/dist/responses/agent.d.ts +12 -0
  69. package/dist/responses/agent.d.ts.map +1 -1
  70. package/dist/responses/await.d.ts +6 -0
  71. package/dist/responses/await.d.ts.map +1 -1
  72. package/dist/responses/composerHtml.d.ts +27 -0
  73. package/dist/responses/composerHtml.d.ts.map +1 -0
  74. package/dist/responses/composerHtml.js +14 -0
  75. package/dist/responses/composerHtml.js.map +1 -0
  76. package/dist/responses/history.d.ts +20 -0
  77. package/dist/responses/history.d.ts.map +1 -0
  78. package/dist/responses/history.js +16 -0
  79. package/dist/responses/history.js.map +1 -0
  80. package/dist/responses/locate.d.ts.map +1 -1
  81. package/dist/responses/locate.js +5 -0
  82. package/dist/responses/locate.js.map +1 -1
  83. package/dist/responses/make.d.ts +3 -0
  84. package/dist/responses/make.d.ts.map +1 -0
  85. package/dist/responses/make.js +16 -0
  86. package/dist/responses/make.js.map +1 -0
  87. package/dist/responses/outcome.d.ts +6 -0
  88. package/dist/responses/outcome.d.ts.map +1 -1
  89. package/dist/responses/page.d.ts +1 -1
  90. package/dist/responses/page.d.ts.map +1 -1
  91. package/dist/responses/page.js +7 -10
  92. package/dist/responses/page.js.map +1 -1
  93. package/dist/responses/read.d.ts +6 -0
  94. package/dist/responses/read.d.ts.map +1 -1
  95. package/dist/responses/records.d.ts.map +1 -1
  96. package/dist/responses/records.js +2 -0
  97. package/dist/responses/records.js.map +1 -1
  98. package/dist/responses/retry.d.ts +12 -0
  99. package/dist/responses/retry.d.ts.map +1 -1
  100. package/dist/responses/service.d.ts +37 -1
  101. package/dist/responses/service.d.ts.map +1 -1
  102. package/dist/responses/service.js +19 -14
  103. package/dist/responses/service.js.map +1 -1
  104. package/dist/responses/template.d.ts +7 -0
  105. package/dist/responses/template.d.ts.map +1 -0
  106. package/dist/responses/template.js +58 -0
  107. package/dist/responses/template.js.map +1 -0
  108. package/dist/responses/templateHtml.d.ts +7 -0
  109. package/dist/responses/templateHtml.d.ts.map +1 -0
  110. package/dist/responses/templateHtml.js +18 -0
  111. package/dist/responses/templateHtml.js.map +1 -0
  112. package/dist/responses/templateLinks.d.ts +3 -0
  113. package/dist/responses/templateLinks.d.ts.map +1 -0
  114. package/dist/responses/templateLinks.js +45 -0
  115. package/dist/responses/templateLinks.js.map +1 -0
  116. package/dist/responses/templateNodes.d.ts +17 -0
  117. package/dist/responses/templateNodes.d.ts.map +1 -0
  118. package/dist/responses/templateNodes.js +47 -0
  119. package/dist/responses/templateNodes.js.map +1 -0
  120. package/dist/responses/validateSupersedes.d.ts +5 -0
  121. package/dist/responses/validateSupersedes.d.ts.map +1 -0
  122. package/dist/responses/validateSupersedes.js +21 -0
  123. package/dist/responses/validateSupersedes.js.map +1 -0
  124. package/dist/responses/validatedInput.d.ts +3 -0
  125. package/dist/responses/validatedInput.d.ts.map +1 -0
  126. package/dist/responses/validatedInput.js +43 -0
  127. package/dist/responses/validatedInput.js.map +1 -0
  128. package/dist/responses/waitFor.d.ts +6 -0
  129. package/dist/responses/waitFor.d.ts.map +1 -1
  130. package/dist/rpc/contract.d.ts +24 -0
  131. package/dist/rpc/contract.d.ts.map +1 -1
  132. package/dist/rpc/responseContract.d.ts +48 -0
  133. package/dist/rpc/responseContract.d.ts.map +1 -1
  134. package/dist/serve-cli.js +1 -1
  135. package/package.json +2 -2
  136. package/src/browser/responses/Prompt.ts +6 -0
  137. package/src/browser/responses/answerBody.ts +11 -0
  138. package/src/browser/responses/draftSession.ts +23 -10
  139. package/src/browser/responses/drafts.ts +25 -1
  140. package/src/browser/responses/preview.ts +4 -2
  141. package/src/browser/responses/questionnaireControls.ts +74 -0
  142. package/src/browser/responses/schema.ts +12 -1
  143. package/src/browser/responses/setup.ts +34 -4
  144. package/src/browser/responses/submissionFields.ts +15 -0
  145. package/src/browser/responses/submit.ts +3 -11
  146. package/src/metadata/backlinks.ts +3 -0
  147. package/src/metadata/problems.ts +1 -0
  148. package/src/page/metadataDetails.ts +1 -1
  149. package/src/page/style.ts +1 -0
  150. package/src/render/markdown.ts +5 -2
  151. package/src/render/questionnaireDocuments.ts +12 -0
  152. package/src/render/visualDocuments.ts +3 -0
  153. package/src/responses/composerHtml.ts +28 -0
  154. package/src/responses/history.ts +18 -0
  155. package/src/responses/locate.ts +5 -0
  156. package/src/responses/make.ts +17 -0
  157. package/src/responses/page.ts +7 -18
  158. package/src/responses/records.ts +2 -0
  159. package/src/responses/service.ts +26 -15
  160. package/src/responses/template.ts +72 -0
  161. package/src/responses/templateHtml.ts +26 -0
  162. package/src/responses/templateLinks.ts +43 -0
  163. package/src/responses/templateNodes.ts +55 -0
  164. package/src/responses/validateSupersedes.ts +24 -0
  165. package/src/responses/validatedInput.ts +45 -0
  166. package/src/serve-cli.ts +1 -1
@@ -0,0 +1,74 @@
1
+ import type { Draft } from "./drafts.ts";
2
+ import type { Prompt } from "./Prompt.ts";
3
+ import type { PromptAnswer } from "./schema.ts";
4
+
5
+ function restoreAnswers(fields: readonly HTMLFieldSetElement[], draft: Draft, same: boolean) {
6
+ for (const field of fields) {
7
+ const answer = draft.answers?.find((entry) => entry.prompt === field.dataset.prompt);
8
+ for (const input of field.querySelectorAll<HTMLInputElement>("input")) {
9
+ input.checked = same && (answer?.selected.includes(input.value) ?? false);
10
+ }
11
+ const text = field.querySelector<HTMLTextAreaElement>("textarea");
12
+ if (text) {
13
+ text.value = answer?.text ?? "";
14
+ }
15
+ }
16
+ }
17
+ export function questionnaireControls(form: HTMLFormElement) {
18
+ function modeOf(field: HTMLFieldSetElement) {
19
+ if (field.dataset.mode === "text") {
20
+ return "text";
21
+ }
22
+ return field.dataset.mode === "many" ? "many" : "one";
23
+ }
24
+ const fields = [...form.querySelectorAll<HTMLFieldSetElement>("fieldset[data-prompt]")];
25
+ const models: Prompt[] = fields.map((field) => ({
26
+ id: field.dataset.prompt ?? "",
27
+ mode: modeOf(field),
28
+ options: [...field.querySelectorAll<HTMLInputElement>("input[data-option-source]")].map((input) => ({
29
+ id: input.value,
30
+ source: input.dataset.optionSource ?? "",
31
+ })),
32
+ source: field.dataset.source ?? "",
33
+ }));
34
+ function read(): readonly PromptAnswer[] | undefined {
35
+ return fields.length === 0
36
+ ? undefined
37
+ : fields.map((field) => ({
38
+ prompt: field.dataset.prompt ?? "",
39
+ selected: [...field.querySelectorAll<HTMLInputElement>("input:checked")].map((input) => input.value),
40
+ text: field.querySelector<HTMLTextAreaElement>("textarea")?.value ?? "",
41
+ }));
42
+ }
43
+ function restore(draft: Draft, revision: string) {
44
+ const same = draft.revision === revision && (draft.question === undefined || draft.question === form.dataset.question);
45
+ restoreAnswers(fields, draft, same);
46
+ const recovery = form.querySelector<HTMLPreElement>("#response-draft-recovery");
47
+ const retained =
48
+ !same && draft.answers
49
+ ? `Earlier template answers (selections cleared; review before choosing again):\n${JSON.stringify(draft.answers, null, 2)}\n${draft.recovery ?? ""}`
50
+ : draft.recovery;
51
+ if (recovery) {
52
+ recovery.textContent = retained ?? "";
53
+ recovery.hidden = !retained;
54
+ }
55
+ return retained;
56
+ }
57
+ function validate(type: string) {
58
+ for (const field of fields) {
59
+ const text = field.querySelector<HTMLTextAreaElement>("textarea");
60
+ text?.setCustomValidity(
61
+ type === "answer" && !text.value.trim() && !field.querySelector("input:checked") ? "Choose an option or answer in your own words." : "",
62
+ );
63
+ }
64
+ }
65
+ for (const field of fields) {
66
+ field.querySelector("[data-clear-choices]")?.addEventListener("click", () => {
67
+ for (const input of field.querySelectorAll<HTMLInputElement>("input")) {
68
+ input.checked = false;
69
+ }
70
+ form.dispatchEvent(new Event("input", { bubbles: true }));
71
+ });
72
+ }
73
+ return { models, read, restore, validate };
74
+ }
@@ -3,6 +3,13 @@ import { Identity } from "#path/Identity.ts";
3
3
 
4
4
  export const Revision = Schema.String.check(Schema.isPattern(/^[a-f\d]{64}$/u));
5
5
  export const ResponseKind = Schema.Literals(["answer", "clarify", "not_now"]);
6
+ export const PromptAnswer = Schema.Struct({
7
+ prompt: Identity.check(Schema.isMaxLength(128)),
8
+ selected: Schema.Array(Identity.check(Schema.isMaxLength(128))).check(Schema.isMaxLength(32)),
9
+ text: Schema.String.check(Schema.isMaxLength(8192)),
10
+ });
11
+ export const Answers = Schema.Array(PromptAnswer).check(Schema.isMaxLength(32));
12
+ export type PromptAnswer = typeof PromptAnswer.Type;
6
13
  export const QuestionRecord = Schema.Struct({
7
14
  deadline: Schema.Number.check(Schema.isFinite(), Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(8_640_000_000_000_000)),
8
15
  item: Identity,
@@ -13,10 +20,12 @@ export const QuestionRecord = Schema.Struct({
13
20
  source: Schema.String,
14
21
  });
15
22
  export const ResponseRecord = Schema.Struct({
23
+ answers: Schema.optional(Answers),
16
24
  author: Schema.String.check(Schema.isPattern(/\S/u), Schema.isMaxLength(200)),
17
25
  question: Identity,
18
26
  recordedAt: Schema.Number.check(Schema.isFinite(), Schema.isGreaterThanOrEqualTo(0), Schema.isLessThanOrEqualTo(8_640_000_000_000_000)),
19
27
  reviewedRevision: Revision,
28
+ supersedes: Schema.optional(Identity),
20
29
  type: ResponseKind,
21
30
  });
22
31
  export const QuestionPreview = Schema.Struct({
@@ -40,10 +49,12 @@ export class ResponseFailed extends Schema.TaggedError<ResponseFailed>()("Respon
40
49
  message: Schema.String,
41
50
  }) {}
42
51
  export const DraftInput = Schema.Struct({
52
+ answers: Schema.optional(Answers),
43
53
  author: ResponseRecord.fields.author,
44
- body: Schema.String.check(Schema.isPattern(/\S/u), Schema.isMaxLength(32_768)),
54
+ body: Schema.String.check(Schema.isMaxLength(32_768)),
45
55
  id: Identity,
46
56
  question: Identity,
57
+ supersedes: Schema.optional(Identity),
47
58
  type: ResponseKind,
48
59
  });
49
60
  export type Question = typeof Question.Type;
@@ -1,5 +1,6 @@
1
1
  import { draftSession } from "./draftSession.ts";
2
2
  import { responsePreview } from "./preview.ts";
3
+ import { questionnaireControls } from "./questionnaireControls.ts";
3
4
  import { submitResponse } from "./submit.ts";
4
5
 
5
6
  function attach() {
@@ -16,6 +17,7 @@ function attach() {
16
17
  const author = form.elements.namedItem("author");
17
18
  const body = form.elements.namedItem("body");
18
19
  const type = form.elements.namedItem("type");
20
+ const supersedes = form.elements.namedItem("supersedes");
19
21
  const submit = form.querySelector<HTMLButtonElement>("#response-submit");
20
22
  const preview = form.querySelector<HTMLPreElement>("#response-preview-content");
21
23
  const clear = form.querySelector<HTMLButtonElement>("#response-clear");
@@ -40,6 +42,7 @@ function attach() {
40
42
  const item = form.dataset.item ?? "";
41
43
  const request = form.dataset.request ?? "";
42
44
  const revision = form.dataset.revision ?? "";
45
+ const questionId = form.dataset.question ?? "";
43
46
  const key = `${item}/${request}`;
44
47
  const storage = `work-board:drafts:${document.documentElement.dataset.workspace}`;
45
48
  let storageApi: Storage | undefined;
@@ -52,12 +55,29 @@ function attach() {
52
55
  status.textContent = message;
53
56
  });
54
57
  let draft = drafts.current();
58
+ const controls = questionnaireControls(form);
59
+ let recovery = controls.restore(draft, revision);
60
+ if (draft.question && draft.question !== questionId && draft.revision === revision) {
61
+ status.textContent = "Source moved since this draft. Text is retained and selections cleared; review the source and preview before responding.";
62
+ }
63
+ if (supersedes instanceof HTMLSelectElement) {
64
+ supersedes.value = draft.supersedes ?? "";
65
+ }
55
66
  author.value = draft.author;
56
67
  body.value = draft.body;
57
68
  type.value = draft.type;
58
69
  const fields = { author, body, type };
59
70
  function save() {
60
- draft = drafts.update({ author: fields.author.value, body: fields.body.value, revision: draft.revision, type: fields.type.value });
71
+ draft = drafts.update({
72
+ answers: controls.read(),
73
+ author: fields.author.value,
74
+ body: fields.body.value,
75
+ question: draft.question ?? questionId,
76
+ recovery,
77
+ revision: draft.revision,
78
+ supersedes: supersedes instanceof HTMLSelectElement ? supersedes.value || undefined : undefined,
79
+ type: fields.type.value,
80
+ });
61
81
  }
62
82
  form.addEventListener("input", () => {
63
83
  save();
@@ -67,29 +87,39 @@ function attach() {
67
87
  previewButton.disabled = false;
68
88
  clear.disabled = false;
69
89
  previewButton.addEventListener("click", () => {
90
+ controls.validate(type.value);
70
91
  draft = drafts.update({
92
+ answers: controls.read(),
71
93
  author: author.value,
72
94
  body: body.value,
73
- id: draft.revision === revision ? drafts.current().id : `response.${crypto.randomUUID()}`,
95
+ id: draft.revision === revision && (!draft.question || draft.question === questionId) ? drafts.current().id : `response.${crypto.randomUUID()}`,
96
+ question: questionId,
97
+ recovery,
74
98
  revision,
99
+ supersedes: supersedes instanceof HTMLSelectElement ? supersedes.value || undefined : undefined,
75
100
  type: type.value,
76
101
  });
77
- preview.textContent = responsePreview(draft, form.dataset.question ?? "");
102
+ preview.textContent = responsePreview(draft, questionId ?? "", controls.models);
78
103
  preview.hidden = false;
79
104
  submit.disabled = form.dataset.writable !== "true" || !form.checkValidity();
80
105
  });
81
106
  clear.addEventListener("click", drafts.clear);
82
107
  form.addEventListener("submit", async (event) => {
83
108
  event.preventDefault();
84
- if (submit.disabled || draft.revision !== revision || !form.reportValidity()) {
109
+ if (submit.disabled || draft.revision !== revision || draft.question !== questionId || !form.reportValidity()) {
85
110
  return;
86
111
  }
87
112
  submit.disabled = true;
88
113
  previewButton.disabled = true;
89
114
  if (await submitResponse(form, status, drafts, draft)) {
90
115
  draft = drafts.current();
116
+ recovery = draft.recovery;
91
117
  if (form.isConnected) {
92
118
  body.value = "";
119
+ controls.restore(draft, revision);
120
+ if (supersedes instanceof HTMLSelectElement) {
121
+ supersedes.value = "";
122
+ }
93
123
  }
94
124
  }
95
125
  previewButton.disabled = false;
@@ -0,0 +1,15 @@
1
+ import { Schema } from "effect";
2
+ import type { Draft } from "./drafts.ts";
3
+ import { ResponseKind } from "./schema.ts";
4
+
5
+ export function submissionFields(pending: Draft, question: string) {
6
+ return {
7
+ ...(pending.answers === undefined ? {} : { answers: pending.answers }),
8
+ ...(pending.supersedes === undefined ? {} : { supersedes: pending.supersedes }),
9
+ author: pending.author,
10
+ body: pending.body,
11
+ id: pending.id,
12
+ question,
13
+ type: Schema.decodeUnknownSync(ResponseKind)(pending.type),
14
+ };
15
+ }
@@ -1,8 +1,8 @@
1
- import { Schema } from "effect";
2
1
  import { session } from "#browser/session.ts";
3
2
  import type { draftSession } from "./draftSession.ts";
4
3
  import type { Draft } from "./drafts.ts";
5
- import { ResponseFailed, ResponseKind } from "./schema.ts";
4
+ import { ResponseFailed } from "./schema.ts";
5
+ import { submissionFields } from "./submissionFields.ts";
6
6
 
7
7
  export async function submitResponse(form: HTMLFormElement, status: HTMLParagraphElement, drafts: ReturnType<typeof draftSession>, pending: Draft) {
8
8
  status.dataset.outcome = "pending";
@@ -12,15 +12,7 @@ export async function submitResponse(form: HTMLFormElement, status: HTMLParagrap
12
12
  const question = await native.mutate(
13
13
  native.responses.registerQuestion.run({ item: form.dataset.item ?? "", request: form.dataset.request ?? "", revision: pending.revision }),
14
14
  );
15
- await native.mutate(
16
- native.responses.recordResponse.run({
17
- author: pending.author,
18
- body: pending.body,
19
- id: pending.id,
20
- question: question.id,
21
- type: Schema.decodeUnknownSync(ResponseKind)(pending.type),
22
- }),
23
- );
15
+ await native.mutate(native.responses.recordResponse.run(submissionFields(pending, question.id)));
24
16
  if (form.isConnected) {
25
17
  status.dataset.outcome = "saved";
26
18
  status.textContent = `Saved: responses/${pending.id}.md. This records feedback, not acceptance.`;
@@ -12,6 +12,9 @@ function references(document: MetadataDocument, model: MetadataModel) {
12
12
  return [
13
13
  ...(fields.question ? [{ kind: "reviewed question", line: lines.question, target: referenceFile(fields.question.item, model) }] : []),
14
14
  ...(fields.response ? [{ kind: "recorded response", line: lines.response, target: referenceFile(fields.response.question, model) }] : []),
15
+ ...(fields.response?.supersedes
16
+ ? [{ kind: "superseding response", line: lines.response, target: referenceFile(fields.response.supersedes, model) }]
17
+ : []),
15
18
  ...(fields.attention?.flatMap((request, index) =>
16
19
  request.unblocks.map((target, targetIndex) => ({
17
20
  kind: "attention target",
@@ -29,6 +29,7 @@ function referenceProblems(document: MetadataDocument, ids: ReadonlyMap<string,
29
29
  const references = [
30
30
  ...(fields.question ? [{ field: "question", key: "question.item", target: fields.question.item }] : []),
31
31
  ...(fields.response ? [{ field: "response", key: "response.question", target: fields.response.question }] : []),
32
+ ...(fields.response?.supersedes ? [{ field: "response", key: "response.supersedes", target: fields.response.supersedes }] : []),
32
33
  ...(fields.attention?.flatMap((request, index) =>
33
34
  request.unblocks.map((target, targetIndex) => ({ field: "attention", key: `attention.${index}.unblocks.${targetIndex}`, target })),
34
35
  ) ?? []),
@@ -22,7 +22,7 @@ export function metadataDetails(parsed: ParsedMetadata, file: string, model: Met
22
22
  ? `<h3>Reviewed question</h3><p>${referenceHtml(fields.question.item, fields.question.item, model)} / ${escapeHtml(fields.question.request)} · ${escapeHtml(fields.question.reason)}</p><p>Reviewed SHA-256: <code>${fields.question.reviewedRevision}</code>. Registered: ${new Date(fields.question.registeredAt).toISOString()}. Unanswered deadline: ${new Date(fields.question.deadline).toISOString()}.</p>`
23
23
  : "",
24
24
  fields.response
25
- ? `<h3>Recorded response</h3><p>${referenceHtml(fields.response.question, fields.response.question, model)} · ${escapeHtml(fields.response.author)} · ${escapeHtml(fields.response.type)}</p><p>Reviewed SHA-256: <code>${fields.response.reviewedRevision}</code>. This is authored feedback, not verified acceptance.</p>`
25
+ ? `<h3>Recorded response</h3><p>${referenceHtml(fields.response.question, fields.response.question, model)} · ${escapeHtml(fields.response.author)} · ${escapeHtml(fields.response.type)}</p><p>Reviewed SHA-256: <code>${fields.response.reviewedRevision}</code>. This is authored feedback, not verified acceptance.</p>${fields.response.supersedes ? `<p>Explicitly supersedes ${referenceHtml(fields.response.supersedes, fields.response.supersedes, model)}.</p>` : ""}`
26
26
  : "",
27
27
  criteriaEvidence(parsed, model),
28
28
  attentionDetails(parsed, file, model),
package/src/page/style.ts CHANGED
@@ -97,4 +97,5 @@ export const style =
97
97
  + attentionStyle()
98
98
  + visualStyles
99
99
  + documents
100
+ + ".response-panel fieldset, .questionnaire-reading { min-width: 0; border: 1px solid var(--border); border-radius: 12px; padding: 1rem; margin: 1rem 0; } .response-panel legend { font-weight: 650; } .response-panel .question-option { display: grid; grid-template-columns: 1.5rem minmax(0, 1fr); gap: .75rem; padding: 1rem; margin: .5rem 0; border: 1px solid var(--border); border-radius: 8px; } .response-panel .question-option:has(input:checked) { border-color: var(--link); background: var(--surface); } .response-panel .question-option input { width: 1.2rem; height: 1.2rem; margin-top: .2rem; accent-color: var(--link); } .response-panel .question-option label { font-weight: 650; cursor: pointer; } .response-panel .question-option p { margin-top: .5rem; } .response-panel #response-history article { border-top: 1px solid var(--border); margin-top: 1.5rem; padding-top: 1rem; } .response-context, .question-option, .response-panel fieldset { overflow-wrap: anywhere; }"
100
101
  + ".response-panel { max-width: 850px; margin: auto; padding: 1.5rem; } .response-panel form { display: grid; gap: 1rem; } .response-panel label { display: grid; gap: .5rem; } .response-panel input, .response-panel textarea, .response-panel select { width: 100%; box-sizing: border-box; font: inherit; background: var(--surface); color: inherit; padding: .75rem; border: 1px solid var(--border); } .response-panel pre, .response-panel code { white-space: pre-wrap; overflow-wrap: anywhere; }";
@@ -14,16 +14,18 @@ import { documentLinks } from "./documentLinks.ts";
14
14
  import { RenderFailed } from "./failed.ts";
15
15
  import { footnoteIds } from "./footnotes.ts";
16
16
  import { Highlighter } from "./highlighter.ts";
17
+ import { questionnaireDocuments } from "./questionnaireDocuments.ts";
17
18
 
18
19
  export interface RenderOptions {
19
20
  readonly file?: string;
20
21
  readonly headings?: ReturnType<typeof documentHeadings>;
21
22
  readonly idPrefix?: string;
23
+ readonly safe?: boolean;
22
24
  }
23
25
 
24
26
  export const renderMarkdown = Effect.fn("WorkBoard.renderMarkdown")(function* (source: string, options: RenderOptions = {}) {
25
27
  const highlighter = yield* Highlighter;
26
- const plugins: PluggableList = [rehypeRaw, codeBlocks(highlighter)];
28
+ const plugins: PluggableList = [...(options.safe ? [] : [rehypeRaw]), codeBlocks(highlighter)];
27
29
  if (options.idPrefix !== undefined) {
28
30
  plugins.push(footnoteIds(options.idPrefix));
29
31
  }
@@ -41,7 +43,8 @@ export const renderMarkdown = Effect.fn("WorkBoard.renderMarkdown")(function* (s
41
43
  children: source,
42
44
  components: { img: ({ node: _node, ...props }) => createElement("img", { ...props, loading: "lazy" }) },
43
45
  rehypePlugins: plugins,
44
- remarkPlugins: [remarkGfm, remarkDirective, visualDocuments, callouts],
46
+ remarkPlugins: [remarkGfm, remarkDirective, questionnaireDocuments, visualDocuments, callouts],
47
+ skipHtml: options.safe,
45
48
  }),
46
49
  ),
47
50
  });
@@ -0,0 +1,12 @@
1
+ import type { Root } from "mdast";
2
+ import { visit } from "unist-util-visit";
3
+
4
+ export function questionnaireDocuments() {
5
+ return (tree: Root) => {
6
+ visit(tree, "containerDirective", (node) => {
7
+ if (node.name === "question" || node.name === "option") {
8
+ node.data = { hName: "section", hProperties: { className: ["questionnaire-reading", `questionnaire-${node.name}`] } };
9
+ }
10
+ });
11
+ };
12
+ }
@@ -43,6 +43,9 @@ export function visualDocuments() {
43
43
  if (!isDirective(directive) || index === undefined || parent === undefined) {
44
44
  return;
45
45
  }
46
+ if (directive.type === "containerDirective" && ["question", "option"].includes(directive.name)) {
47
+ return;
48
+ }
46
49
  const failure = failureOf(directive, source);
47
50
  if (failure === undefined) {
48
51
  return;
@@ -0,0 +1,28 @@
1
+ import { Effect } from "effect";
2
+ import type { QuestionPreview, RecordedResponse } from "#browser/responses/schema.ts";
3
+ import { fileUrl } from "#files/url.ts";
4
+ import { escapeHtml } from "#page/escape.ts";
5
+ import { renderMarkdown } from "#render/markdown.ts";
6
+ import { templateHtml } from "./templateHtml.ts";
7
+
8
+ export const composerHtml = Effect.fn("WorkBoard.composerHtml")(function* (
9
+ q: QuestionPreview,
10
+ records: readonly RecordedResponse[],
11
+ enabled: boolean,
12
+ ) {
13
+ const template = yield* templateHtml(q.context, q.request, q.source);
14
+ const history = yield* Effect.forEach(records, (record) =>
15
+ Effect.map(
16
+ renderMarkdown(record.body, { file: `responses/${record.id}.md`, safe: true }),
17
+ (html) =>
18
+ `<article id="${escapeHtml(record.id)}"><h3><a href="/responses/${record.id}.md">${escapeHtml(record.response.author)} · ${record.response.type}</a></h3><p>${record.response.question === q.id ? "This reviewed revision" : "Earlier reviewed context — does not approve the current source"} · <code>${record.response.reviewedRevision}</code></p>${record.response.supersedes ? `<p>Explicitly supersedes <a href="/responses/${escapeHtml(record.response.supersedes)}.md">${escapeHtml(record.response.supersedes)}</a>.</p>` : ""}${html}</article>`,
19
+ ),
20
+ );
21
+ const prior = records
22
+ .map(
23
+ (record) =>
24
+ `<option value="${escapeHtml(record.id)}">${escapeHtml(record.id)} · ${escapeHtml(record.response.author)} · ${record.response.reviewedRevision.slice(0, 12)}</option>`,
25
+ )
26
+ .join("");
27
+ return `<section class="response-panel"><h1>Respond to ${escapeHtml(q.item)} / ${escapeHtml(q.request)}</h1><p>${escapeHtml(q.reason)}</p><p><a href="${fileUrl(q.source)}">Source: ${escapeHtml(q.source)}</a> · Reviewed SHA-256: <code>${q.reviewedRevision}</code></p><p>Saved replies are authored feedback. They do not close the request or establish acceptance.</p><section class="response-context">${template.background}</section><details><summary>Exact source being reviewed</summary><pre>${escapeHtml(q.context)}</pre></details><form id="response-form" data-question="${q.id}" data-writable="${enabled}" data-item="${escapeHtml(q.item)}" data-request="${escapeHtml(q.request)}" data-revision="${q.reviewedRevision}"><label>Local author label <input name="author" maxlength="200" required></label><label>Response kind <select name="type"><option value="answer">Answer (including no)</option><option value="clarify">Ask back / clarify</option><option value="not_now">Not now</option></select></label>${template.controls}<label>${template.hasPrompts ? "Overall response / next action (optional)" : "Your response"}<textarea name="body" rows="8" maxlength="32768" ${template.hasPrompts ? "" : "required"}></textarea></label><label>Supersedes an earlier response (optional)<select name="supersedes"><option value="">Adds feedback; does not supersede</option>${prior}</select></label><pre id="response-draft-recovery" hidden></pre><p>Submit the whole packet together. Drafts stay in this browser for 30 days after the last edit, with a 2 MiB workspace limit. Saved responses are Markdown files in responses/.</p><button id="response-preview" type="button" disabled>Preview Markdown</button><pre id="response-preview-content" hidden></pre><button id="response-submit" type="submit" disabled>Record response</button><button id="response-clear" type="button" disabled>Clear workspace drafts</button><p id="response-status" role="status">${enabled ? "JavaScript is required to record a response." : "Read-only mode. Restart with --responses to explicitly enable local writes."}</p></form><section id="response-history"><h2>Recorded responses</h2>${history.join("") || "<p>No response is recorded for this source revision.</p>"}</section></section>`;
28
+ });
@@ -0,0 +1,18 @@
1
+ import type { QuestionPreview } from "#browser/responses/schema.ts";
2
+ import type { Snapshot } from "#search/snapshot.ts";
3
+ import { malformedResponse, questionFrom, responsesFor } from "./records.ts";
4
+
5
+ export function responseHistory(data: Snapshot, preview: QuestionPreview) {
6
+ if (data.unavailable.length > 0 || data.documents.some(malformedResponse)) {
7
+ throw new Error("Response history is incomplete or malformed.");
8
+ }
9
+ const questions = data.documents.flatMap((document) => {
10
+ const question = questionFrom(document);
11
+ return question && question.question.item === preview.item && question.question.request === preview.request ? [question] : [];
12
+ });
13
+ const responses = questions.flatMap((question) => responsesFor(data.documents, question).responses);
14
+ if ([...questions, ...responses].some((record) => data.model.ids.get(record.id)?.length !== 1)) {
15
+ throw new Error("Response history includes ambiguous identities.");
16
+ }
17
+ return responses.sort((a, b) => b.response.recordedAt - a.response.recordedAt);
18
+ }
@@ -6,6 +6,7 @@ import { metadataModel } from "#metadata/model.ts";
6
6
  import { metadataParse } from "#metadata/parse.ts";
7
7
  import type { Snapshot } from "#search/snapshot.ts";
8
8
  import { questionId, revisionOf } from "./records.ts";
9
+ import { questionTemplate } from "./template.ts";
9
10
 
10
11
  function reject(code: ResponseFailed["code"], message: string) {
11
12
  return Effect.fail(new ResponseFailed({ code, message }));
@@ -41,6 +42,10 @@ export function locate(root: string, realRoot: string, snapshot: Effect.Effect<S
41
42
  return yield* reject("Stale", "The current request or its references are invalid. Review the source diagnostics before registering it.");
42
43
  }
43
44
  const revision = revisionOf(source);
45
+ yield* Effect.try({
46
+ catch: (cause) => new ResponseFailed({ code: "Unsupported", message: `Invalid response template: ${String(cause)}` }),
47
+ try: () => questionTemplate(source, request),
48
+ });
44
49
  const question: QuestionPreview = {
45
50
  context: source,
46
51
  id: questionId(item, request, revision, entry.document.file),
@@ -0,0 +1,17 @@
1
+ import type { DraftInput, Question, RecordedResponse } from "#browser/responses/schema.ts";
2
+
3
+ export function makeResponse(input: DraftInput, question: Question, recordedAt: number): RecordedResponse {
4
+ return {
5
+ body: input.body,
6
+ id: input.id,
7
+ response: {
8
+ ...(input.answers === undefined ? {} : { answers: input.answers }),
9
+ ...(input.supersedes === undefined ? {} : { supersedes: input.supersedes }),
10
+ author: input.author,
11
+ question: input.question,
12
+ recordedAt,
13
+ reviewedRevision: question.question.reviewedRevision,
14
+ type: input.type,
15
+ },
16
+ };
17
+ }
@@ -1,11 +1,11 @@
1
1
  import { Effect } from "effect";
2
2
  import type { HttpServerRequest } from "effect/unstable/http";
3
3
  import type { Changes } from "#files/changes.ts";
4
- import { fileUrl } from "#files/url.ts";
5
4
  import { respond } from "#http/respond.ts";
6
5
  import { escapeHtml } from "#page/escape.ts";
7
6
  import { navHtml } from "#page/nav.ts";
8
7
  import { shell } from "#page/shell.ts";
8
+ import { composerHtml } from "./composerHtml.ts";
9
9
  import type { responseService } from "./service.ts";
10
10
 
11
11
  export function responsePage(
@@ -16,26 +16,15 @@ export function responsePage(
16
16
  ) {
17
17
  return Effect.fn("WorkBoard.responsePage")(function* (request: HttpServerRequest.HttpServerRequest) {
18
18
  const params = new URL(request.url, "http://127.0.0.1").searchParams;
19
- const item = params.get("item") ?? "";
20
- const requestId = params.get("request") ?? "";
21
19
  const files = yield* changes.files;
22
20
  function layout(body: string, status = 200) {
23
21
  return respond(shell("Respond", navHtml(files, "", home), body, changes.realRoot, false, undefined, "response"), "text/html", status);
24
22
  }
25
- return yield* service.locate(item, requestId).pipe(
26
- Effect.flatMap((question) =>
27
- Effect.map(
28
- service
29
- .read(question.id)
30
- .pipe(Effect.catch((error) => (error.code === "Missing" ? Effect.succeed({ responses: [] }) : Effect.fail(error)))),
31
- (recorded) => {
32
- const q = question;
33
- const body = `<section class="response-panel"><h1>Respond to ${escapeHtml(item)} / ${escapeHtml(requestId)}</h1><p>${escapeHtml(q.reason)}</p><p><a href="${fileUrl(q.source)}">Source: ${escapeHtml(q.source)}</a> · Reviewed SHA-256: <code>${q.reviewedRevision}</code></p><p>Saved replies are authored feedback. They do not close the request or establish acceptance.</p><details><summary>Exact source being reviewed</summary><pre>${escapeHtml(question.context)}</pre></details><form id="response-form" data-question="${question.id}" data-writable="${enabled}" data-item="${escapeHtml(item)}" data-request="${escapeHtml(requestId)}" data-revision="${q.reviewedRevision}"><label>Local author label <input name="author" maxlength="200" required></label><label>Response kind <select name="type"><option value="answer">Answer (including no)</option><option value="clarify">Ask back / clarify</option><option value="not_now">Not now</option></select></label><label>Your response <textarea name="body" rows="8" maxlength="32768" required></textarea></label><p>Drafts stay in this browser for 30 days after the last edit, with a 2 MiB workspace limit. Saved responses are Markdown files in responses/.</p><button id="response-preview" type="button" disabled>Preview Markdown</button><pre id="response-preview-content" hidden></pre><button id="response-submit" type="submit" disabled>Record response</button><button id="response-clear" type="button" disabled>Clear workspace drafts</button><p id="response-status" role="status">${enabled ? "JavaScript is required to record a response." : "Read-only mode. Restart with --responses to explicitly enable local writes."}</p></form><section id="response-history"><h2>Recorded responses</h2>${recorded.responses.map((record) => `<article><p><a href="/responses/${record.id}.md">${escapeHtml(record.response.author)} · ${escapeHtml(record.response.type)}</a></p><pre>${escapeHtml(record.body)}</pre></article>`).join("") || "<p>No response is recorded for this source revision.</p>"}</section></section>`;
34
- return layout(body);
35
- },
36
- ),
37
- ),
38
- Effect.catch((error) => Effect.succeed(layout(`<h1>Response unavailable</h1><p>${escapeHtml(error.message)}</p>`, 409))),
39
- );
23
+ return yield* Effect.gen(function* () {
24
+ const question = yield* service.locate(params.get("item") ?? "", params.get("request") ?? "");
25
+ yield* service.read(question.id).pipe(Effect.catch((error) => (error.code === "Missing" ? Effect.void : Effect.fail(error))));
26
+ const records = yield* service.history(question);
27
+ return layout(yield* composerHtml(question, records, enabled));
28
+ }).pipe(Effect.catch((error) => Effect.succeed(layout(`<h1>Response unavailable</h1><p>${escapeHtml(String(error))}</p>`, 409))));
40
29
  });
41
30
  }
@@ -41,6 +41,8 @@ export function responsesFor(documents: readonly MetadataDocument[], question: Q
41
41
  export function matchesResponse(record: RecordedResponse, input: DraftInput) {
42
42
  return (
43
43
  record.body === input.body
44
+ && JSON.stringify(record.response.answers) === JSON.stringify(input.answers)
45
+ && record.response.supersedes === input.supersedes
44
46
  && record.response.author === input.author
45
47
  && record.response.type === input.type
46
48
  && record.response.question === input.question
@@ -1,13 +1,17 @@
1
1
  import { Clock, Effect, FileSystem, Semaphore } from "effect";
2
- import { type DraftInput, type Question, type RecordedResponse, ResponseFailed } from "#browser/responses/schema.ts";
2
+ import { type DraftInput, type Question, ResponseFailed } from "#browser/responses/schema.ts";
3
3
  import type { Changes } from "#files/changes.ts";
4
4
  import type { Snapshot } from "#search/snapshot.ts";
5
5
  import { awaitResponse } from "./await.ts";
6
+ import { responseHistory } from "./history.ts";
6
7
  import { locate as locateQuestion } from "./locate.ts";
8
+ import { makeResponse } from "./make.ts";
7
9
  import { publish } from "./publish.ts";
8
10
  import { readResponses } from "./read.ts";
9
11
  import { questionMarkdown, responseMarkdown } from "./records.ts";
10
12
  import { retryResponse } from "./retry.ts";
13
+ import { validatedInput } from "./validatedInput.ts";
14
+ import { validateSupersedes } from "./validateSupersedes.ts";
11
15
 
12
16
  export interface ResponseOptions {
13
17
  readonly changes: Changes;
@@ -65,11 +69,15 @@ export const responseService = Effect.fn("WorkBoard.responseService")(function*
65
69
  }),
66
70
  );
67
71
  }
68
- function record(input: DraftInput) {
72
+ function record(draft: DraftInput) {
69
73
  return lock.withPermit(
70
74
  Effect.gen(function* () {
71
75
  yield* enabled;
72
- const result = yield* read(input.question);
76
+ const result = yield* read(draft.question);
77
+ const input = yield* Effect.try({
78
+ catch: (cause) => new ResponseFailed({ code: "Conflict", message: String(cause) }),
79
+ try: () => validatedInput(draft, result.question.context, result.question.question.request, result.question.question.source),
80
+ });
73
81
  const existing = result.responses.find((candidate) => candidate.id === input.id);
74
82
  if (existing) {
75
83
  yield* retryResponse(existing, input, snapshot, options);
@@ -81,20 +89,15 @@ export const responseService = Effect.fn("WorkBoard.responseService")(function*
81
89
  return yield* reject("Stale", "Source changed or moved. Keep the draft and review the new revision before responding.");
82
90
  }
83
91
  const data = yield* snapshot;
92
+ yield* validateSupersedes(input, question, data);
84
93
  if (data.model.ids.has(input.id)) {
85
94
  return yield* reject("Conflict", "This response identity is already used.");
86
95
  }
87
- const response: RecordedResponse = {
88
- body: input.body,
89
- id: input.id,
90
- response: {
91
- author: input.author,
92
- question: input.question,
93
- recordedAt: result.responses.reduce((time, saved) => Math.max(time, saved.response.recordedAt + 1), yield* Clock.currentTimeMillis),
94
- reviewedRevision: question.question.reviewedRevision,
95
- type: input.type,
96
- },
97
- };
96
+ const response = makeResponse(
97
+ input,
98
+ question,
99
+ result.responses.reduce((time, saved) => Math.max(time, saved.response.recordedAt + 1), yield* Clock.currentTimeMillis),
100
+ );
98
101
  yield* publish(options.root, options.changes.realRoot, response.id, responseMarkdown(response)).pipe(
99
102
  Effect.ensuring(options.changes.refresh ?? Effect.void),
100
103
  );
@@ -102,5 +105,13 @@ export const responseService = Effect.fn("WorkBoard.responseService")(function*
102
105
  }),
103
106
  );
104
107
  }
105
- return { awaitResponse: awaitResponse(read), locate, read, record, register };
108
+ function history(preview: Parameters<typeof responseHistory>[1]) {
109
+ return Effect.flatMap(snapshot, (data) =>
110
+ Effect.try({
111
+ catch: (cause) => new ResponseFailed({ code: "Unavailable", message: String(cause) }),
112
+ try: () => responseHistory(data, preview),
113
+ }),
114
+ );
115
+ }
116
+ return { awaitResponse: awaitResponse(read), history, locate, read, record, register };
106
117
  });