@cfbender/cesium 0.6.2 → 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 (36) hide show
  1. package/CHANGELOG.md +82 -1
  2. package/README.md +1 -1
  3. package/package.json +1 -1
  4. package/src/index.ts +2 -0
  5. package/src/prompt/system-fragment.md +68 -8
  6. package/src/render/annotate-frozen.ts +90 -0
  7. package/src/render/blocks/render.ts +20 -0
  8. package/src/render/blocks/renderers/callout.ts +3 -2
  9. package/src/render/blocks/renderers/code.ts +17 -2
  10. package/src/render/blocks/renderers/compare-table.ts +3 -2
  11. package/src/render/blocks/renderers/diagram.ts +3 -2
  12. package/src/render/blocks/renderers/diff.ts +23 -9
  13. package/src/render/blocks/renderers/hero.ts +3 -2
  14. package/src/render/blocks/renderers/kv.ts +3 -2
  15. package/src/render/blocks/renderers/list.ts +5 -4
  16. package/src/render/blocks/renderers/pill-row.ts +3 -2
  17. package/src/render/blocks/renderers/prose.ts +8 -2
  18. package/src/render/blocks/renderers/raw-html.ts +8 -2
  19. package/src/render/blocks/renderers/risk-table.ts +3 -2
  20. package/src/render/blocks/renderers/section.ts +4 -2
  21. package/src/render/blocks/renderers/timeline.ts +3 -2
  22. package/src/render/blocks/renderers/tldr.ts +3 -2
  23. package/src/render/client-js.ts +803 -6
  24. package/src/render/critique.ts +5 -335
  25. package/src/render/theme.ts +455 -6
  26. package/src/render/validate.ts +353 -97
  27. package/src/render/wrap.ts +67 -9
  28. package/src/server/api.ts +162 -3
  29. package/src/storage/index-gen.ts +4 -2
  30. package/src/storage/mutate.ts +433 -27
  31. package/src/tools/annotate.ts +336 -0
  32. package/src/tools/ask.ts +2 -6
  33. package/src/tools/critique.ts +15 -45
  34. package/src/tools/publish.ts +16 -56
  35. package/src/tools/styleguide.ts +7 -1
  36. package/src/tools/wait.ts +77 -24
@@ -1,14 +1,9 @@
1
1
  // Validates cesium_publish and cesium_ask tool input before any write occurs.
2
2
 
3
- import { parseFragment, defaultTreeAdapter as ta } from "parse5";
4
- import type { DefaultTreeAdapterTypes } from "parse5";
5
3
  import type { Block } from "./blocks/types.ts";
6
4
  import { blockCatalog } from "./blocks/catalog.ts";
7
5
  import { deepValidateBlock } from "./blocks/validate-block.ts";
8
6
 
9
- type ChildNode = DefaultTreeAdapterTypes.ChildNode;
10
- type Element = DefaultTreeAdapterTypes.Element;
11
-
12
7
  export interface ValidationOk<T> {
13
8
  ok: true;
14
9
  value: T;
@@ -29,7 +24,8 @@ export type PublishKind =
29
24
  | "audit"
30
25
  | "rfc"
31
26
  | "other"
32
- | "ask";
27
+ | "ask"
28
+ | "annotate";
33
29
 
34
30
  export const PUBLISH_KINDS: readonly PublishKind[] = [
35
31
  "plan",
@@ -42,10 +38,23 @@ export const PUBLISH_KINDS: readonly PublishKind[] = [
42
38
  "rfc",
43
39
  "other",
44
40
  "ask",
41
+ "annotate",
45
42
  ];
46
43
 
47
44
  // ─── Interactive artifact types ────────────────────────────────────────────────
48
45
 
46
+ export type Comment = {
47
+ id: string; // server-assigned nanoid, opaque to client
48
+ anchor: string; // matches /^block-\d+(\.line-\d+)?$/
49
+ selectedText: string; // captured at submit time, ≤ 4096 chars
50
+ comment: string; // ≤ 16384 chars, non-empty after trim
51
+ createdAt: string; // ISO 8601
52
+ };
53
+
54
+ export type Verdict = "approve" | "request_changes" | "comment";
55
+
56
+ export type VerdictMode = "approve" | "approve-or-reject" | "full";
57
+
49
58
  export type Option = {
50
59
  id: string;
51
60
  label: string;
@@ -114,7 +123,8 @@ export type AnswerValue =
114
123
  | { type: "slider"; value: number }
115
124
  | { type: "react"; decision: string; comment?: string };
116
125
 
117
- export type InteractiveData = {
126
+ export type InteractiveAskData = {
127
+ kind: "ask";
118
128
  status: "open" | "complete" | "expired" | "cancelled";
119
129
  requireAll: boolean;
120
130
  expiresAt: string;
@@ -123,6 +133,20 @@ export type InteractiveData = {
123
133
  completedAt?: string;
124
134
  };
125
135
 
136
+ export type InteractiveAnnotateData = {
137
+ kind: "annotate";
138
+ status: "open" | "complete" | "expired" | "cancelled";
139
+ expiresAt: string;
140
+ verdictMode: VerdictMode;
141
+ requireVerdict: boolean;
142
+ perLineFor: ("diff" | "code")[];
143
+ comments: Comment[];
144
+ verdict: { value: Verdict; decidedAt: string } | null;
145
+ completedAt?: string;
146
+ };
147
+
148
+ export type InteractiveData = InteractiveAskData | InteractiveAnnotateData;
149
+
126
150
  // ─── Question validation ───────────────────────────────────────────────────────
127
151
 
128
152
  function isNonEmptyString(v: unknown): v is string {
@@ -410,27 +434,309 @@ export function validateAskInput(input: unknown): AskValidationResult {
410
434
  return { ok: true, value: result };
411
435
  }
412
436
 
413
- // ─── PublishInput supports html XOR blocks ──────────────────────────────────
437
+ // ─── AnnotateInput validation ──────────────────────────────────────────────────
414
438
 
415
- export type PublishInput =
416
- | {
417
- title: string;
418
- kind: PublishKind;
419
- html: string;
420
- blocks?: never;
421
- summary?: string;
422
- tags?: string[];
423
- supersedes?: string;
439
+ // annotate is blocks-only by design — no html/body escape valve.
440
+ export interface AnnotateInput {
441
+ title: string;
442
+ blocks: Block[];
443
+ verdictMode?: VerdictMode;
444
+ perLineFor?: ("diff" | "code")[];
445
+ requireVerdict?: boolean;
446
+ summary?: string;
447
+ tags?: string[];
448
+ expiresAt?: string;
449
+ }
450
+
451
+ const VERDICT_MODES: readonly VerdictMode[] = ["approve", "approve-or-reject", "full"];
452
+
453
+ function isVerdictMode(v: unknown): v is VerdictMode {
454
+ return typeof v === "string" && (VERDICT_MODES as readonly string[]).includes(v);
455
+ }
456
+
457
+ export function validateAnnotateInput(input: unknown): ValidationResult<AnnotateInput> {
458
+ if (input === null || typeof input !== "object") {
459
+ return { ok: false, error: "input must be an object" };
460
+ }
461
+ const raw = input as Record<string, unknown>;
462
+
463
+ // title — required, non-empty, ≤ 200 chars
464
+ if (!("title" in raw) || typeof raw["title"] !== "string" || raw["title"].trim() === "") {
465
+ return { ok: false, error: "title is required and must be a non-empty string" };
466
+ }
467
+ if (raw["title"].length > 200) {
468
+ return { ok: false, error: "title must be 200 characters or fewer" };
469
+ }
470
+ const title = raw["title"];
471
+
472
+ // annotate is blocks-only — stray `body` is silently ignored, not an error.
473
+
474
+ // blocks — required, non-empty array
475
+ if (!("blocks" in raw) || !Array.isArray(raw["blocks"]) || raw["blocks"].length === 0) {
476
+ return { ok: false, error: "blocks must be a non-empty array" };
477
+ }
478
+ const blocksResult = validateBlocksArray(raw["blocks"]);
479
+ if (!blocksResult.ok) {
480
+ const errorMessages = blocksResult.errors.map((e) => `${e.path}: ${e.message}`).join("; ");
481
+ return { ok: false, error: `blocks validation failed — ${errorMessages}` };
482
+ }
483
+
484
+ // verdictMode — optional, must be valid enum value
485
+ if ("verdictMode" in raw && raw["verdictMode"] !== undefined) {
486
+ if (!isVerdictMode(raw["verdictMode"])) {
487
+ return {
488
+ ok: false,
489
+ error: `verdictMode must be one of: ${VERDICT_MODES.join(", ")}`,
490
+ };
424
491
  }
425
- | {
426
- title: string;
427
- kind: PublishKind;
428
- blocks: Block[];
429
- html?: never;
430
- summary?: string;
431
- tags?: string[];
432
- supersedes?: string;
492
+ }
493
+
494
+ // perLineFor — optional array, only "diff" and "code", no duplicates
495
+ if ("perLineFor" in raw && raw["perLineFor"] !== undefined) {
496
+ if (!Array.isArray(raw["perLineFor"])) {
497
+ return { ok: false, error: "perLineFor must be an array" };
498
+ }
499
+ const seen = new Set<string>();
500
+ for (const item of raw["perLineFor"] as unknown[]) {
501
+ if (item !== "diff" && item !== "code") {
502
+ return {
503
+ ok: false,
504
+ error: `perLineFor items must be "diff" or "code", got "${String(item)}"`,
505
+ };
506
+ }
507
+ if (seen.has(item)) {
508
+ return {
509
+ ok: false,
510
+ error: `perLineFor must not contain duplicates, got duplicate "${item}"`,
511
+ };
512
+ }
513
+ seen.add(item);
514
+ }
515
+ }
516
+
517
+ // requireVerdict — optional boolean
518
+ if ("requireVerdict" in raw && raw["requireVerdict"] !== undefined) {
519
+ if (typeof raw["requireVerdict"] !== "boolean") {
520
+ return { ok: false, error: "requireVerdict must be a boolean" };
521
+ }
522
+ }
523
+
524
+ // summary — optional string
525
+ if ("summary" in raw && raw["summary"] !== undefined) {
526
+ if (typeof raw["summary"] !== "string") {
527
+ return { ok: false, error: "summary must be a string" };
528
+ }
529
+ }
530
+
531
+ // tags — optional array of strings
532
+ if ("tags" in raw && raw["tags"] !== undefined) {
533
+ if (!Array.isArray(raw["tags"])) {
534
+ return { ok: false, error: "tags must be an array of strings" };
535
+ }
536
+ for (const tag of raw["tags"]) {
537
+ if (typeof tag !== "string") {
538
+ return { ok: false, error: "tags must be an array of strings" };
539
+ }
540
+ }
541
+ }
542
+
543
+ // expiresAt — optional, must be valid ISO date string
544
+ if ("expiresAt" in raw && raw["expiresAt"] !== undefined) {
545
+ if (typeof raw["expiresAt"] !== "string") {
546
+ return { ok: false, error: "expiresAt must be a string" };
547
+ }
548
+ const d = new Date(raw["expiresAt"]);
549
+ if (isNaN(d.getTime())) {
550
+ return { ok: false, error: "expiresAt must be a valid ISO date string" };
551
+ }
552
+ }
553
+
554
+ const result: AnnotateInput = { title, blocks: blocksResult.blocks };
555
+ if (isVerdictMode(raw["verdictMode"])) result.verdictMode = raw["verdictMode"];
556
+ if (Array.isArray(raw["perLineFor"]))
557
+ result.perLineFor = raw["perLineFor"] as ("diff" | "code")[];
558
+ if (typeof raw["requireVerdict"] === "boolean") result.requireVerdict = raw["requireVerdict"];
559
+ if (typeof raw["summary"] === "string") result.summary = raw["summary"];
560
+ if (Array.isArray(raw["tags"])) result.tags = raw["tags"] as string[];
561
+ if (typeof raw["expiresAt"] === "string") result.expiresAt = raw["expiresAt"];
562
+
563
+ return { ok: true, value: result };
564
+ }
565
+
566
+ // ─── Comment value validation ──────────────────────────────────────────────────
567
+
568
+ const ANCHOR_RE = /^block-\d+(\.line-\d+)?$/;
569
+
570
+ export function validateCommentValue(
571
+ input: unknown,
572
+ ): ValidationResult<{ anchor: string; selectedText: string; comment: string }> {
573
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
574
+ return { ok: false, error: "input must be an object" };
575
+ }
576
+ const raw = input as Record<string, unknown>;
577
+
578
+ // anchor
579
+ if (typeof raw["anchor"] !== "string") {
580
+ return { ok: false, error: "anchor must be a string" };
581
+ }
582
+ if (!ANCHOR_RE.test(raw["anchor"])) {
583
+ return {
584
+ ok: false,
585
+ error: `anchor must match /^block-\\d+(\\.line-\\d+)?$/, got "${raw["anchor"]}"`,
586
+ };
587
+ }
588
+ const anchor = raw["anchor"];
589
+
590
+ // selectedText — required, can be empty, ≤ 4096 chars
591
+ if (typeof raw["selectedText"] !== "string") {
592
+ return { ok: false, error: "selectedText must be a string" };
593
+ }
594
+ if (raw["selectedText"].length > 4096) {
595
+ return { ok: false, error: "selectedText must be 4096 characters or fewer" };
596
+ }
597
+ const selectedText = raw["selectedText"];
598
+
599
+ // comment — required, non-empty after trim, ≤ 16384 chars
600
+ if (typeof raw["comment"] !== "string") {
601
+ return { ok: false, error: "comment must be a string" };
602
+ }
603
+ if (raw["comment"].trim() === "") {
604
+ return { ok: false, error: "comment must be non-empty" };
605
+ }
606
+ if (raw["comment"].length > 16384) {
607
+ return { ok: false, error: "comment must be 16384 characters or fewer" };
608
+ }
609
+ const comment = raw["comment"];
610
+
611
+ return { ok: true, value: { anchor, selectedText, comment } };
612
+ }
613
+
614
+ // ─── Verdict value validation ──────────────────────────────────────────────────
615
+
616
+ const VERDICTS_BY_MODE: Record<VerdictMode, readonly Verdict[]> = {
617
+ approve: ["approve"],
618
+ "approve-or-reject": ["approve", "request_changes"],
619
+ full: ["approve", "request_changes", "comment"],
620
+ };
621
+
622
+ export function validateVerdictValue(input: unknown, mode: VerdictMode): ValidationResult<Verdict> {
623
+ if (input === null || typeof input !== "object" || Array.isArray(input)) {
624
+ return { ok: false, error: "input must be an object" };
625
+ }
626
+ const raw = input as Record<string, unknown>;
627
+
628
+ if (typeof raw["verdict"] !== "string") {
629
+ return { ok: false, error: "verdict must be a string" };
630
+ }
631
+ const v = raw["verdict"];
632
+ const allowed = VERDICTS_BY_MODE[mode];
633
+ if (!(allowed as readonly string[]).includes(v)) {
634
+ return {
635
+ ok: false,
636
+ error: `verdict "${v}" is not allowed in "${mode}" mode; allowed: ${allowed.join(", ")}`,
433
637
  };
638
+ }
639
+
640
+ return { ok: true, value: v as Verdict };
641
+ }
642
+
643
+ // ─── coerceInteractiveData ─────────────────────────────────────────────────────
644
+
645
+ function isValidStatus(v: unknown): v is InteractiveData["status"] {
646
+ return v === "open" || v === "complete" || v === "expired" || v === "cancelled";
647
+ }
648
+
649
+ function coerceAsAskData(raw: Record<string, unknown>): InteractiveAskData | null {
650
+ if (!isValidStatus(raw["status"])) return null;
651
+ if (!Array.isArray(raw["questions"])) return null;
652
+ if (typeof raw["requireAll"] !== "boolean") return null;
653
+ if (typeof raw["expiresAt"] !== "string") return null;
654
+ const answers =
655
+ raw["answers"] !== null && typeof raw["answers"] === "object" && !Array.isArray(raw["answers"])
656
+ ? (raw["answers"] as Record<string, { value: AnswerValue; answeredAt: string }>)
657
+ : null;
658
+ if (answers === null) return null;
659
+
660
+ const result: InteractiveAskData = {
661
+ kind: "ask",
662
+ status: raw["status"],
663
+ requireAll: raw["requireAll"],
664
+ expiresAt: raw["expiresAt"],
665
+ questions: raw["questions"] as Question[],
666
+ answers,
667
+ };
668
+ if (typeof raw["completedAt"] === "string") result.completedAt = raw["completedAt"];
669
+ return result;
670
+ }
671
+
672
+ function coerceAsAnnotateData(raw: Record<string, unknown>): InteractiveAnnotateData | null {
673
+ if (!isValidStatus(raw["status"])) return null;
674
+ if (typeof raw["expiresAt"] !== "string") return null;
675
+ if (!isVerdictMode(raw["verdictMode"])) return null;
676
+ if (typeof raw["requireVerdict"] !== "boolean") return null;
677
+ if (!Array.isArray(raw["perLineFor"])) return null;
678
+ if (!Array.isArray(raw["comments"])) return null;
679
+ // verdict can be null or an object
680
+ const rawVerdict = raw["verdict"];
681
+ const verdict =
682
+ rawVerdict === null
683
+ ? null
684
+ : rawVerdict !== null &&
685
+ typeof rawVerdict === "object" &&
686
+ !Array.isArray(rawVerdict) &&
687
+ typeof (rawVerdict as Record<string, unknown>)["value"] === "string" &&
688
+ typeof (rawVerdict as Record<string, unknown>)["decidedAt"] === "string"
689
+ ? (rawVerdict as { value: Verdict; decidedAt: string })
690
+ : undefined;
691
+ if (verdict === undefined) return null;
692
+
693
+ const result: InteractiveAnnotateData = {
694
+ kind: "annotate",
695
+ status: raw["status"],
696
+ expiresAt: raw["expiresAt"],
697
+ verdictMode: raw["verdictMode"],
698
+ requireVerdict: raw["requireVerdict"],
699
+ perLineFor: raw["perLineFor"] as ("diff" | "code")[],
700
+ comments: raw["comments"] as Comment[],
701
+ verdict,
702
+ };
703
+ if (typeof raw["completedAt"] === "string") result.completedAt = raw["completedAt"];
704
+ return result;
705
+ }
706
+
707
+ /**
708
+ * Tolerant reader for embedded cesium-meta.interactive JSON.
709
+ *
710
+ * - Returns null for non-object input or unrecognized shapes.
711
+ * - Legacy ask artifacts (without a `kind` field) are coerced to InteractiveAskData
712
+ * with `kind: "ask"` injected.
713
+ */
714
+ export function coerceInteractiveData(raw: unknown): InteractiveData | null {
715
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw)) return null;
716
+ const obj = raw as Record<string, unknown>;
717
+
718
+ if (obj["kind"] === "annotate") {
719
+ return coerceAsAnnotateData(obj);
720
+ }
721
+
722
+ if (obj["kind"] === "ask" || obj["kind"] === undefined || !("kind" in obj)) {
723
+ return coerceAsAskData(obj);
724
+ }
725
+
726
+ // Unknown kind
727
+ return null;
728
+ }
729
+
730
+ // ─── PublishInput — blocks-only ──────────────────────────────────────────────
731
+
732
+ export interface PublishInput {
733
+ title: string;
734
+ kind: PublishKind;
735
+ blocks: Block[];
736
+ summary?: string;
737
+ tags?: string[];
738
+ supersedes?: string;
739
+ }
434
740
 
435
741
  function isPublishKind(val: unknown): val is PublishKind {
436
742
  return typeof val === "string" && (PUBLISH_KINDS as readonly string[]).includes(val);
@@ -645,7 +951,7 @@ function validateBlock(raw: unknown, path: string, depth: number): BlockValidati
645
951
  return errors;
646
952
  }
647
953
 
648
- function validateBlocksArray(raw: unknown): BlockValidationResult {
954
+ export function validateBlocksArray(raw: unknown): BlockValidationResult {
649
955
  if (!Array.isArray(raw)) {
650
956
  return { ok: false, errors: [{ path: "blocks", message: "blocks must be an array" }] };
651
957
  }
@@ -725,15 +1031,18 @@ export function validatePublishInput(input: unknown): ValidationResult<PublishIn
725
1031
  }
726
1032
  const kind = raw["kind"];
727
1033
 
728
- // XOR: exactly one of html or blocks
729
- const hasHtml = "html" in raw && raw["html"] !== undefined;
730
- const hasBlocks = "blocks" in raw && raw["blocks"] !== undefined;
731
-
732
- if (hasHtml && hasBlocks) {
733
- return { ok: false, error: "provide exactly one of html or blocks, not both" };
734
- }
735
- if (!hasHtml && !hasBlocks) {
736
- return { ok: false, error: "provide exactly one of html or blocks" };
1034
+ // blocks required, non-empty
1035
+ if (
1036
+ !("blocks" in raw) ||
1037
+ raw["blocks"] === undefined ||
1038
+ !Array.isArray(raw["blocks"]) ||
1039
+ raw["blocks"].length === 0
1040
+ ) {
1041
+ return {
1042
+ ok: false,
1043
+ error:
1044
+ "cesium_publish requires a non-empty `blocks` array. Call `cesium_styleguide` for the block catalog.",
1045
+ };
737
1046
  }
738
1047
 
739
1048
  // summary (optional)
@@ -765,69 +1074,16 @@ export function validatePublishInput(input: unknown): ValidationResult<PublishIn
765
1074
  }
766
1075
  }
767
1076
 
768
- const commonFields = {
769
- title,
770
- kind,
771
- ...(typeof raw["summary"] === "string" ? { summary: raw["summary"] } : {}),
772
- ...(Array.isArray(raw["tags"]) ? { tags: raw["tags"] as string[] } : {}),
773
- ...(typeof raw["supersedes"] === "string" ? { supersedes: raw["supersedes"] } : {}),
774
- };
775
-
776
- if (hasHtml) {
777
- // html branch
778
- if (typeof raw["html"] !== "string" || raw["html"].trim() === "") {
779
- return { ok: false, error: "html is required and must be a non-empty string" };
780
- }
781
- return {
782
- ok: true,
783
- value: { ...commonFields, html: raw["html"] },
784
- };
785
- } else {
786
- // blocks branch
787
- const blocksResult = validateBlocksArray(raw["blocks"]);
788
- if (!blocksResult.ok) {
789
- const errorMessages = blocksResult.errors.map((e) => `${e.path}: ${e.message}`).join("; ");
790
- return { ok: false, error: `blocks validation failed — ${errorMessages}` };
791
- }
792
- return {
793
- ok: true,
794
- value: { ...commonFields, blocks: blocksResult.blocks },
795
- };
796
- }
797
- }
798
-
799
- const HEADING_TAGS = new Set(["h1", "h2", "h3", "h4", "h5", "h6"]);
800
-
801
- function walkNodes(nodes: ChildNode[], visitor: (node: ChildNode) => void): void {
802
- for (const node of nodes) {
803
- visitor(node);
804
- if (ta.isElementNode(node)) {
805
- walkNodes(ta.getChildNodes(node as Element) as ChildNode[], visitor);
806
- }
1077
+ const blocksResult = validateBlocksArray(raw["blocks"]);
1078
+ if (!blocksResult.ok) {
1079
+ const errorMessages = blocksResult.errors.map((e) => `${e.path}: ${e.message}`).join("; ");
1080
+ return { ok: false, error: `blocks validation failed ${errorMessages}` };
807
1081
  }
808
- }
809
1082
 
810
- export function htmlBodyWarnings(htmlBody: string): string[] {
811
- try {
812
- const warnings: string[] = [];
813
- const fragment = parseFragment(htmlBody);
814
- const children = ta.getChildNodes(fragment) as ChildNode[];
815
- let hasHeading = false;
1083
+ const value: PublishInput = { title, kind, blocks: blocksResult.blocks };
1084
+ if (typeof raw["summary"] === "string") value.summary = raw["summary"];
1085
+ if (Array.isArray(raw["tags"])) value.tags = raw["tags"] as string[];
1086
+ if (typeof raw["supersedes"] === "string") value.supersedes = raw["supersedes"];
816
1087
 
817
- walkNodes(children, (node) => {
818
- if (ta.isElementNode(node)) {
819
- const el = node as Element;
820
- const tag = ta.getTagName(el);
821
- if (HEADING_TAGS.has(tag)) hasHeading = true;
822
- }
823
- });
824
-
825
- if (!hasHeading) {
826
- warnings.push("no headings found — consider adding an <h1> or <h2>");
827
- }
828
-
829
- return warnings;
830
- } catch {
831
- return [];
832
- }
1088
+ return { ok: true, value };
833
1089
  }
@@ -5,7 +5,13 @@ import { buildThemeCss } from "../storage/theme-write.ts";
5
5
  import { renderControl, renderAnswered } from "./controls.ts";
6
6
  import { getClientJs } from "./client-js.ts";
7
7
  import { faviconLinkTag } from "./favicon.ts";
8
- import type { InteractiveData, Question } from "./validate.ts";
8
+ import type {
9
+ InteractiveData,
10
+ InteractiveAskData,
11
+ InteractiveAnnotateData,
12
+ Question,
13
+ VerdictMode,
14
+ } from "./validate.ts";
9
15
 
10
16
  export interface ArtifactMeta {
11
17
  id: string;
@@ -25,7 +31,6 @@ export interface ArtifactMeta {
25
31
  supersedes: string | null;
26
32
  supersededBy: string | null;
27
33
  contentSha256: string;
28
- inputMode: "html" | "blocks";
29
34
  }
30
35
 
31
36
  export interface WrapOptions {
@@ -80,7 +85,7 @@ const BACK_LINK_STYLE = "color: var(--muted); text-decoration: none;";
80
85
 
81
86
  // ─── Interactive rendering ─────────────────────────────────────────────────────
82
87
 
83
- function renderQuestionSection(q: Question, interactive: InteractiveData): string {
88
+ function renderQuestionSection(q: Question, interactive: InteractiveAskData): string {
84
89
  const answered = interactive.answers[q.id];
85
90
 
86
91
  if (answered !== undefined) {
@@ -90,11 +95,62 @@ function renderQuestionSection(q: Question, interactive: InteractiveData): strin
90
95
  return renderControl(q);
91
96
  }
92
97
 
98
+ function renderVerdictButtons(verdictMode: VerdictMode, isOpen: boolean): string {
99
+ // When open, render buttons WITHOUT disabled — client JS gates them based on
100
+ // comment count and session status. When not open, mark all disabled.
101
+ const disabled = isOpen ? "" : ' disabled aria-disabled="true"';
102
+ const buttons: string[] = [];
103
+
104
+ buttons.push(
105
+ `<button type="button" class="cs-verdict-btn cs-verdict-approve" data-verdict="approve"${disabled}>Approve</button>`,
106
+ );
107
+
108
+ if (verdictMode === "approve-or-reject" || verdictMode === "full") {
109
+ buttons.push(
110
+ `<button type="button" class="cs-verdict-btn cs-verdict-request_changes" data-verdict="request_changes"${disabled}>Request changes</button>`,
111
+ );
112
+ }
113
+
114
+ if (verdictMode === "full") {
115
+ buttons.push(
116
+ `<button type="button" class="cs-verdict-btn cs-verdict-comment" data-verdict="comment"${disabled}>Comment</button>`,
117
+ );
118
+ }
119
+
120
+ return buttons.join("\n ");
121
+ }
122
+
123
+ function renderAnnotateScaffold(interactive: InteractiveAnnotateData): string {
124
+ const isOpen = interactive.status === "open";
125
+ const verdictButtons = renderVerdictButtons(interactive.verdictMode, isOpen);
126
+
127
+ return `<section class="cs-annotate-scaffold" data-cesium-annotate-scaffold data-cesium-verdict-mode="${interactive.verdictMode}" data-cesium-status="${interactive.status}">
128
+ <template id="cs-annotate-comment-popup">
129
+ <div class="cs-comment-popup" role="dialog" aria-label="Add a comment">
130
+ <textarea class="cs-comment-input" placeholder="Add a comment\u2026"></textarea>
131
+ <div class="cs-comment-actions">
132
+ <button type="button" class="cs-comment-save" disabled>Save</button>
133
+ <button type="button" class="cs-comment-cancel">Cancel</button>
134
+ </div>
135
+ </div>
136
+ </template>
137
+ <aside class="cs-comment-rail" data-cesium-comment-rail aria-label="Review comments"></aside>
138
+ <footer class="cs-verdict-footer">
139
+ <span class="cs-comment-count" data-cesium-comment-count>0 comments</span>
140
+ ${verdictButtons}
141
+ </footer>
142
+ </section>`;
143
+ }
144
+
93
145
  function renderInteractive(interactive: InteractiveData): string {
94
- const sections = interactive.questions
95
- .map((q) => renderQuestionSection(q, interactive))
96
- .join("\n");
97
- return `\n<section class="cs-questions">\n${sections}\n</section>`;
146
+ if (interactive.kind === "ask") {
147
+ const sections = interactive.questions
148
+ .map((q) => renderQuestionSection(q, interactive))
149
+ .join("\n");
150
+ return `\n<section class="cs-questions">\n${sections}\n</section>`;
151
+ }
152
+ // interactive.kind === "annotate"
153
+ return `\n${renderAnnotateScaffold(interactive)}`;
98
154
  }
99
155
 
100
156
  function renderBackNav(meta: ArtifactMeta): string {
@@ -148,8 +204,10 @@ export function wrapDocument(opts: WrapOptions): string {
148
204
  // cascade order — so theme upgrades retroactively apply to served artifacts
149
205
  // while standalone copies retain their generation-time look.
150
206
  const themeCss = buildThemeCss(theme);
151
- // Embed interactive into the cesium-meta JSON block when present
152
- const metaPayload: Record<string, unknown> = { ...meta };
207
+ // Embed interactive into the cesium-meta JSON block when present.
208
+ // inputMode is frozen at "blocks" on-disk for stability; the field is kept
209
+ // on emitted metadata so older readers/tools that look for it still see a value.
210
+ const metaPayload: Record<string, unknown> = { ...meta, inputMode: "blocks" };
153
211
  if (interactive !== undefined) {
154
212
  metaPayload["interactive"] = interactive;
155
213
  }