bottleship 0.1.0-prototype.0 → 0.1.0-prototype.2

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 (167) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +118 -113
  3. package/dist/src/boxes.d.ts +41 -39
  4. package/dist/src/boxes.js +181 -179
  5. package/dist/src/cli.js +298 -83
  6. package/dist/src/config.d.ts +3 -6
  7. package/dist/src/config.js +19 -85
  8. package/dist/src/init.d.ts +3 -4
  9. package/dist/src/init.js +120 -205
  10. package/dist/src/local.d.ts +8 -0
  11. package/dist/src/local.js +24 -0
  12. package/dist/src/onboard.d.ts +5 -0
  13. package/dist/src/onboard.js +25 -0
  14. package/dist/src/paths.d.ts +0 -1
  15. package/dist/src/paths.js +0 -1
  16. package/dist/src/provider.d.ts +16 -0
  17. package/dist/src/provider.js +81 -0
  18. package/dist/src/repo.d.ts +21 -0
  19. package/dist/src/repo.js +75 -0
  20. package/dist/src/review/comments.d.ts +66 -0
  21. package/dist/src/review/comments.js +344 -0
  22. package/dist/src/review/contract.d.ts +82 -0
  23. package/dist/src/review/contract.js +189 -0
  24. package/dist/src/review/github.d.ts +120 -0
  25. package/dist/src/review/github.js +160 -0
  26. package/dist/src/review/local.d.ts +14 -0
  27. package/dist/src/review/local.js +70 -0
  28. package/dist/src/review/loop.d.ts +12 -0
  29. package/dist/src/review/loop.js +119 -0
  30. package/dist/src/review/pr.d.ts +14 -0
  31. package/dist/src/review/pr.js +68 -0
  32. package/dist/src/review/prompt.d.ts +15 -0
  33. package/dist/src/review/prompt.js +48 -0
  34. package/dist/src/review/serve.d.ts +1 -0
  35. package/dist/src/review/serve.js +8 -0
  36. package/dist/src/review/server.d.ts +14 -0
  37. package/dist/src/review/server.js +484 -0
  38. package/dist/src/review/setting-up.d.ts +4 -0
  39. package/dist/src/review/setting-up.js +74 -0
  40. package/dist/src/review/start.d.ts +32 -0
  41. package/dist/src/review/start.js +140 -0
  42. package/dist/src/review/target.d.ts +20 -0
  43. package/dist/src/review/target.js +66 -0
  44. package/dist/src/review/template.d.ts +3 -0
  45. package/dist/src/review/template.js +13 -0
  46. package/dist/src/review/terminal.d.ts +4 -0
  47. package/dist/src/review/terminal.js +91 -0
  48. package/dist/src/secrets.d.ts +1 -0
  49. package/dist/src/secrets.js +11 -1
  50. package/dist/src/types.d.ts +1 -71
  51. package/dist/src/ui.d.ts +6 -1
  52. package/dist/src/ui.js +22 -23
  53. package/dist/src/util.d.ts +11 -0
  54. package/dist/src/util.js +40 -0
  55. package/package.json +7 -14
  56. package/presets/agents/claude.json +1 -5
  57. package/presets/agents/codex.json +1 -10
  58. package/presets/agents/cursor.json +1 -9
  59. package/presets/agents/opencode.json +1 -14
  60. package/presets/providers/docker/create +12 -0
  61. package/presets/providers/docker/destroy +2 -0
  62. package/presets/providers/docker/exec +2 -0
  63. package/presets/providers/docker/list +4 -0
  64. package/presets/providers/docker/status +2 -0
  65. package/presets/providers/docker/url +4 -0
  66. package/review-app/KIT.md +106 -0
  67. package/review-app/WRITING.md +93 -0
  68. package/review-app/examples/rply-16.tsx +98 -0
  69. package/review-app/index.html +15 -0
  70. package/review-app/package-lock.json +2539 -0
  71. package/review-app/package.json +24 -0
  72. package/review-app/playground/fixtures/excalidraw-12602/data/box.json +1 -0
  73. package/review-app/playground/fixtures/excalidraw-12602/data/pr.diff +91 -0
  74. package/review-app/playground/fixtures/excalidraw-12602/data/pr.json +62 -0
  75. package/review-app/playground/fixtures/excalidraw-12602/data/review-comments.json +44 -0
  76. package/review-app/playground/fixtures/excalidraw-12602/events.json +1 -0
  77. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-ar-zoom.png +0 -0
  78. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-en-US-zoom.png +0 -0
  79. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-IL-zoom.png +0 -0
  80. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-dark.png +0 -0
  81. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-hover.png +0 -0
  82. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-page.png +0 -0
  83. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-tip.png +0 -0
  84. package/review-app/playground/fixtures/excalidraw-12602/files/shots/base-he-zen.png +0 -0
  85. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-ar-zoom.png +0 -0
  86. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-en-US-zoom.png +0 -0
  87. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-IL-zoom.png +0 -0
  88. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-dark.png +0 -0
  89. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-hover.png +0 -0
  90. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-page.png +0 -0
  91. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-tip.png +0 -0
  92. package/review-app/playground/fixtures/excalidraw-12602/files/shots/head-he-zen.png +0 -0
  93. package/review-app/playground/fixtures/excalidraw-12602/instances.json +1 -0
  94. package/review-app/playground/fixtures/excalidraw-12602/review.tsx +103 -0
  95. package/review-app/playground/fixtures/rply-16/data/box.json +1 -0
  96. package/review-app/playground/fixtures/rply-16/data/hooks.diff +28 -0
  97. package/review-app/playground/fixtures/rply-16/data/pr.diff +114 -0
  98. package/review-app/playground/fixtures/rply-16/data/pr.json +68 -0
  99. package/review-app/playground/fixtures/rply-16/data/review-comments.json +1 -0
  100. package/review-app/playground/fixtures/rply-16/events.json +156 -0
  101. package/review-app/playground/fixtures/rply-16/instances.json +1 -0
  102. package/review-app/playground/fixtures/rply-16/review.tsx +98 -0
  103. package/review-app/playground/gallery.tsx +127 -0
  104. package/review-app/playground/index.html +15 -0
  105. package/review-app/playground/main.tsx +26 -0
  106. package/review-app/playground/mock.ts +215 -0
  107. package/review-app/playground/playground.css +18 -0
  108. package/review-app/playground/vite.config.ts +32 -0
  109. package/review-app/scripts/check.cjs +47 -0
  110. package/review-app/scripts/clip.cjs +104 -0
  111. package/review-app/scripts/shot.cjs +66 -0
  112. package/review-app/src/App.tsx +132 -0
  113. package/review-app/src/data/box.json +1 -0
  114. package/review-app/src/data/hooks.diff +28 -0
  115. package/review-app/src/data/pr.diff +114 -0
  116. package/review-app/src/data/pr.json +68 -0
  117. package/review-app/src/data/review-comments.json +1 -0
  118. package/review-app/src/kit/agent.tsx +287 -0
  119. package/review-app/src/kit/chapter.tsx +178 -0
  120. package/review-app/src/kit/code.tsx +217 -0
  121. package/review-app/src/kit/comments.tsx +377 -0
  122. package/review-app/src/kit/finish.tsx +274 -0
  123. package/review-app/src/kit/flow.tsx +184 -0
  124. package/review-app/src/kit/index.ts +10 -0
  125. package/review-app/src/kit/kit.css +696 -0
  126. package/review-app/src/kit/live.ts +91 -0
  127. package/review-app/src/kit/media.tsx +348 -0
  128. package/review-app/src/kit/overview.tsx +161 -0
  129. package/review-app/src/kit/pr.ts +100 -0
  130. package/review-app/src/kit/pullrequest.tsx +298 -0
  131. package/review-app/src/kit/review.ts +74 -0
  132. package/review-app/src/kit/theme.ts +30 -0
  133. package/review-app/src/kit/ui.tsx +127 -0
  134. package/review-app/src/main.tsx +6 -0
  135. package/review-app/src/review.tsx +22 -0
  136. package/review-app/tsconfig.json +17 -0
  137. package/review-app/vite.config.ts +12 -0
  138. package/dist/presets/providers/command.d.ts +0 -3
  139. package/dist/presets/providers/command.js +0 -39
  140. package/dist/presets/providers/daytona.d.ts +0 -3
  141. package/dist/presets/providers/daytona.js +0 -173
  142. package/dist/presets/providers/docker.d.ts +0 -3
  143. package/dist/presets/providers/docker.js +0 -31
  144. package/dist/presets/providers/e2b.d.ts +0 -3
  145. package/dist/presets/providers/e2b.js +0 -124
  146. package/dist/presets/providers/exe.d.ts +0 -3
  147. package/dist/presets/providers/exe.js +0 -28
  148. package/dist/presets/providers/http.example.d.ts +0 -3
  149. package/dist/presets/providers/http.example.js +0 -47
  150. package/dist/presets/providers/local.d.ts +0 -3
  151. package/dist/presets/providers/local.js +0 -52
  152. package/dist/presets/providers/modal.d.ts +0 -3
  153. package/dist/presets/providers/modal.js +0 -117
  154. package/dist/presets/providers/ssh.d.ts +0 -3
  155. package/dist/presets/providers/ssh.js +0 -49
  156. package/dist/presets/providers/util.d.ts +0 -13
  157. package/dist/presets/providers/util.js +0 -102
  158. package/presets/providers/command.ts +0 -43
  159. package/presets/providers/daytona.ts +0 -181
  160. package/presets/providers/docker.ts +0 -38
  161. package/presets/providers/e2b.ts +0 -130
  162. package/presets/providers/exe.ts +0 -35
  163. package/presets/providers/http.example.ts +0 -64
  164. package/presets/providers/local.ts +0 -56
  165. package/presets/providers/modal.ts +0 -121
  166. package/presets/providers/ssh.ts +0 -54
  167. package/presets/providers/util.ts +0 -112
@@ -0,0 +1,156 @@
1
+ {
2
+ "mapped-loopback": {
3
+ "status": {
4
+ "id": "mapped-loopback",
5
+ "status": "done"
6
+ },
7
+ "ms": 4000,
8
+ "events": [
9
+ {
10
+ "instance": "base",
11
+ "kind": "request",
12
+ "text": "GET /v1/unfurl?url=http://[::ffff:7f00:1]:9199/",
13
+ "flow": {
14
+ "id": "ssrf",
15
+ "edge": [
16
+ "route",
17
+ "guard"
18
+ ]
19
+ }
20
+ },
21
+ {
22
+ "instance": "head",
23
+ "kind": "request",
24
+ "text": "GET /v1/unfurl?url=http://[::ffff:7f00:1]:9199/",
25
+ "flow": {
26
+ "id": "ssrf",
27
+ "edge": [
28
+ "route",
29
+ "guard"
30
+ ]
31
+ }
32
+ },
33
+ {
34
+ "instance": "base",
35
+ "kind": "hook",
36
+ "text": "unfurl.ts:76 ::ffff:7f00:1 is public",
37
+ "flow": {
38
+ "id": "ssrf",
39
+ "node": "guard",
40
+ "status": "fail",
41
+ "label": "public"
42
+ }
43
+ },
44
+ {
45
+ "instance": "head",
46
+ "kind": "hook",
47
+ "text": "unfurl.ts:76 ::ffff:7f00:1 is private",
48
+ "flow": {
49
+ "id": "ssrf",
50
+ "node": "guard",
51
+ "status": "ok",
52
+ "label": "blocked"
53
+ }
54
+ },
55
+ {
56
+ "instance": "base",
57
+ "kind": "hook",
58
+ "text": "fetch http://127.0.0.1:9199/ 200",
59
+ "flow": {
60
+ "id": "ssrf",
61
+ "edge": [
62
+ "guard",
63
+ "internal"
64
+ ],
65
+ "node": "internal",
66
+ "status": "fail",
67
+ "label": "fetched"
68
+ }
69
+ },
70
+ {
71
+ "instance": "base",
72
+ "kind": "fail",
73
+ "text": "Leaked the internal page",
74
+ "overlay": {
75
+ "title": "Leaked “internal”",
76
+ "body": "The loopback page came back as a preview.",
77
+ "status": "fail"
78
+ }
79
+ },
80
+ {
81
+ "instance": "head",
82
+ "kind": "ok",
83
+ "text": "Blocked before any fetch",
84
+ "overlay": {
85
+ "title": "Blocked, nothing fetched",
86
+ "body": "The preview is empty.",
87
+ "status": "ok"
88
+ }
89
+ }
90
+ ]
91
+ },
92
+ "other-forms": {
93
+ "status": {
94
+ "id": "other-forms",
95
+ "status": "done"
96
+ },
97
+ "ms": 3000,
98
+ "events": [
99
+ {
100
+ "instance": "base",
101
+ "kind": "hook",
102
+ "text": "::ffff:a9fe:a9fe is public",
103
+ "flow": {
104
+ "id": "ssrf",
105
+ "node": "guard",
106
+ "status": "fail",
107
+ "label": "4 of 6 pass"
108
+ }
109
+ },
110
+ {
111
+ "instance": "head",
112
+ "kind": "hook",
113
+ "text": "::ffff:a9fe:a9fe is private",
114
+ "flow": {
115
+ "id": "ssrf",
116
+ "node": "guard",
117
+ "status": "ok",
118
+ "label": "6 of 6 blocked"
119
+ }
120
+ },
121
+ {
122
+ "instance": "base",
123
+ "kind": "fail",
124
+ "text": "4 of 6 spellings pass the guard",
125
+ "overlay": {
126
+ "title": "4 of 6 spellings pass",
127
+ "status": "fail"
128
+ }
129
+ },
130
+ {
131
+ "instance": "head",
132
+ "kind": "ok",
133
+ "text": "All 6 blocked",
134
+ "overlay": {
135
+ "title": "All 6 blocked",
136
+ "status": "ok"
137
+ }
138
+ }
139
+ ]
140
+ },
141
+ "unit-tests": {
142
+ "status": {
143
+ "id": "unit-tests",
144
+ "status": "failed",
145
+ "output": "not ok 4 - unfurl refuses an IPv4-mapped loopback host\n Error: connect ECONNREFUSED 127.0.0.1:5432"
146
+ },
147
+ "ms": 2500,
148
+ "events": [
149
+ {
150
+ "instance": "head",
151
+ "kind": "request",
152
+ "text": "node --test test/unfurl.test.ts"
153
+ }
154
+ ]
155
+ }
156
+ }
@@ -0,0 +1,98 @@
1
+ // A complete review, for joshuaKnauber/rply#16: a backend fix with no visible UI, so the evidence is flows and overlays.
2
+ // To try it with the sample data in src/data, copy it over src/review.tsx and change the imports to "./kit".
3
+ import { Attention, BeforeAfter, Card, Chapter, defineReview, Flow, Lead, Outcome, Overview, type FlowDef } from "../../../src/kit";
4
+
5
+ const UNFURL = "packages/server/src/routes/unfurl.ts";
6
+
7
+ const ssrf: FlowDef = {
8
+ id: "ssrf",
9
+ title: "Link preview request path",
10
+ nodes: [
11
+ { id: "route", title: "GET /v1/unfurl", sub: "authenticated member", col: 0, row: 0 },
12
+ { id: "guard", title: "assertPublic", sub: "isPrivate(address)", col: 1, row: 0 },
13
+ { id: "internal", title: "Loopback / metadata", sub: "127.0.0.1:9199", col: 2, row: 0 },
14
+ ],
15
+ edges: [["route", "guard"], ["guard", "internal"]],
16
+ changed: ["guard"],
17
+ };
18
+
19
+ export default defineReview({
20
+ instances: [
21
+ { id: "base", label: "Base", detail: "main", port: 7781 },
22
+ { id: "head", label: "Head", detail: "this PR", port: 8080 },
23
+ ],
24
+
25
+ overview: () => (
26
+ <Overview>
27
+ <Lead>Link previews can no longer reach the server's own network through IPv6 spellings of private addresses.</Lead>
28
+ <BeforeAfter
29
+ before={<>The guard only stripped a dotted <code>::ffff:</code> prefix. URL parsing turns <code>[::ffff:127.0.0.1]</code> into <code>[::ffff:7f00:1]</code>, which looked public, so the server fetched its own loopback and returned the page.</>}
30
+ after="The guard reads every address as eight 16-bit groups, judges IPv6 that embeds IPv4 as that IPv4, and blocks private ranges before any fetch. Addresses it cannot parse are blocked too."
31
+ />
32
+ <Card caption="Dashed: changed in this PR. The tour lights it up on base and head.">
33
+ <Flow def={ssrf} />
34
+ </Card>
35
+ <Attention
36
+ items={[
37
+ { text: "Addresses the parser cannot read now count as private", detail: "Failing closed is right for a guard, but any odd resolver output now blocks a preview.", to: { group: "core" } },
38
+ { claim: { source: "PR description", quote: "The guard now parses each address and rejects the full loopback, link-local, unique-local, multicast, mapped, and NAT64 ranges before it fetches.", status: "neutral", label: "Mostly true" }, detail: "True for those ranges, but 6to4 (2002::/16), SIIT and 2001:db8::/32 are still treated as public, so \u201creserved\u201d in the title is broader than what is checked. None reach loopback on a normal host.", to: { chapter: "other-forms" } },
39
+ { text: "DNS rebinding still bypasses the guard", detail: "assertPublic and fetch resolve the host separately. The PR says so and leaves it for later." },
40
+ ]}
41
+ held={[
42
+ { source: "PR description", quote: "A URL whose host is an IPv4-mapped IPv6 address such as ::ffff:7f00:1 passed the private-address check, so the server connected to loopback.", to: { chapter: "mapped-loopback" } },
43
+ { source: "PR description", quote: "Parsing the address into groups and checking the full ranges classifies all 15 private test addresses as private and keeps the 5 public ones public.", to: { chapter: "unit-tests" } },
44
+ ]}
45
+ />
46
+ </Overview>
47
+ ),
48
+
49
+ chapters: [
50
+ {
51
+ id: "mapped-loopback",
52
+ title: "A loopback page through ::ffff:7f00:1",
53
+ page: () => (
54
+ <Chapter
55
+ title="A loopback page through ::ffff:7f00:1"
56
+ step="mapped-loopback"
57
+ expect={{ base: "Returns the internal page, titled “internal”.", head: "Returns no title. The guard blocks before any fetch." }}
58
+ flow={ssrf}
59
+ here="guard"
60
+ group="core"
61
+ >
62
+ Signs in as the demo user on each instance and asks for a preview of <code>http://[::ffff:7f00:1]:9199/</code>, a page only reachable on the server's loopback.
63
+ </Chapter>
64
+ ),
65
+ },
66
+ {
67
+ id: "other-forms",
68
+ title: "Other spellings of private addresses",
69
+ page: () => (
70
+ <Chapter
71
+ title="Other spellings of private addresses"
72
+ step="other-forms"
73
+ expect={{ base: "The mapped and NAT64 spellings pass the guard.", head: "Every spelling is blocked." }}
74
+ flow={ssrf}
75
+ here="guard"
76
+ group="parse"
77
+ >
78
+ Tries the metadata address in mapped hex form, dotted-mapped, the long zero form, <code>fe90::</code>, multicast <code>ff02::</code> and NAT64.
79
+ </Chapter>
80
+ ),
81
+ },
82
+ {
83
+ id: "unit-tests",
84
+ title: "The PR's own tests",
85
+ page: () => (
86
+ <Chapter title="The PR's own tests" step="unit-tests" expect={{ head: "15 private and 5 public addresses classified right, and the mapped-loopback test passes." }} group="tests" evidence={<Outcome empty="Press Run: the PR's test file runs against head." />}>
87
+ Runs the PR's test file against head: the address table and an end-to-end test against a real server on loopback.
88
+ </Chapter>
89
+ ),
90
+ },
91
+ ],
92
+
93
+ groups: [
94
+ { id: "core", title: "Embedded IPv4 is judged as IPv4", why: "The fix itself. isPrivate first asks for the IPv4 inside an IPv6 address, then checks the IPv6 ranges.", chapter: "mapped-loopback", file: UNFURL, lines: [[79, 107]] },
95
+ { id: "parse", title: "Addresses become numbers", why: "Prefix string checks are replaced by parsing into eight groups, so every spelling of one address is the same address.", chapter: "other-forms", file: UNFURL, lines: [[109, 128]] },
96
+ { id: "tests", title: "Tests", why: "An address table, and an end-to-end test against a real server on loopback.", chapter: "unit-tests", file: "packages/server/test/unfurl.test.ts", lines: [[1, 8], [49, 76]] },
97
+ ],
98
+ });
@@ -0,0 +1,127 @@
1
+ // Every kit component in its states, on one page, to work on the look. Uses the fixture's review for navigation.
2
+ import type { ReactNode } from "react";
3
+ import { NavContext, type Review } from "../src/kit/review";
4
+ import { FlowCompare } from "../src/kit/flow";
5
+ import { Attention, BeforeAfter, Card, Compare, Diff, FileLine, Flow, Icon, Label, Lead, Live, Outcome, Row, Rows, Shot, Stat, pr, type FlowDef, type ReviewEvent } from "../src/kit";
6
+ import { ChapterContext } from "../src/kit/review";
7
+
8
+ const flow: FlowDef = {
9
+ id: "demo",
10
+ title: "Link preview request path",
11
+ nodes: [
12
+ { id: "route", title: "GET /v1/unfurl", sub: "authenticated member", col: 0, row: 0 },
13
+ { id: "guard", title: "assertPublic", sub: "isPrivate(address)", col: 1, row: 0 },
14
+ { id: "internal", title: "Loopback / metadata", sub: "127.0.0.1:9199", col: 2, row: 0 },
15
+ { id: "cache", title: "Preview cache", sub: "kv", col: 1, row: 1 },
16
+ ],
17
+ edges: [["route", "guard"], ["guard", "internal"], ["guard", "cache"]],
18
+ changed: ["guard"],
19
+ };
20
+ let seq = 0;
21
+ const ev = (instance: string, kind: ReviewEvent["kind"], text: string, f?: ReviewEvent["flow"], overlay?: ReviewEvent["overlay"]): ReviewEvent => ({ seq: ++seq, instance, kind, text, flow: f, overlay });
22
+ const failing = [ev("base", "hook", "x", { id: "demo", edge: ["route", "guard"] }), ev("base", "hook", "x", { id: "demo", node: "guard", status: "fail", label: "public" }), ev("base", "hook", "x", { id: "demo", edge: ["guard", "internal"], node: "internal", status: "fail", label: "fetched" })];
23
+ const passing = [ev("head", "hook", "x", { id: "demo", edge: ["route", "guard"] }), ev("head", "hook", "x", { id: "demo", node: "guard", status: "ok", label: "blocked" })];
24
+ const overlays = [ev("base", "fail", "Order is reversed", undefined, { title: "Leaked “internal”", body: "The loopback page came back as a preview.", status: "fail" }), ev("head", "ok", "Order is − 100% +", undefined, { title: "Blocked, nothing fetched", body: "The preview is empty.", status: "ok" })];
25
+
26
+ const email = [ev("base", "ok", "No email", undefined, { title: "No email", status: "neutral" }), ev("head", "ok", "You have 2 unread messages in rply", undefined, { title: "You have 2 unread messages in rply", body: "To: quiet-agent@example.com\nSubject: You have 2 unread messages in rply\n\nYour agents are waiting on you:\n\n- Cron job: 2 unread\n http://localhost:8080/c/rx2v49azvt1fs2vd\n\nTurn on notifications in rply to get these as push notifications instead of emails.", status: "ok" })];
27
+
28
+ const SECTIONS = ["Foundations", "Buttons", "Rows", "Overview", "Flow", "Screenshots", "Live instances", "Code"];
29
+
30
+ function Sec({ title, children, note }: { title: string; children: ReactNode; note?: ReactNode }) {
31
+ return (
32
+ <section className="g-sec" id={`g-${title}`}>
33
+ <div className="g-head"><h2>{title}</h2>{note && <p>{note}</p>}</div>
34
+ <div className="g-body">{children}</div>
35
+ </section>
36
+ );
37
+ }
38
+ const Spec = ({ name, children }: { name: string; children: ReactNode }) => <div className="g-spec"><div className="g-name">{name}</div><div>{children}</div></div>;
39
+
40
+ const TOKENS = ["--bg", "--panel", "--card", "--raised", "--line", "--line-2", "--text", "--text-2", "--text-3", "--green", "--red", "--amber", "--green-bg", "--red-bg", "--amber-bg", "--add", "--del"];
41
+
42
+ export function Gallery({ review }: { review: Review }) {
43
+ const first = pr.files[0]?.path;
44
+ const visual = pr.repo === "excalidraw";
45
+ return (
46
+ <NavContext.Provider value={{ review, go: (t) => (location.hash = typeof t === "string" ? (t === "overview" ? "#/" : "#/changes") : "chapter" in t ? `#/chapter/${t.chapter}` : `#/changes/${t.group}`) }}>
47
+ <nav className="rail">
48
+ <div className="pr"><span className="r">{Icon.pr} Review kit</span><b>Gallery</b></div>
49
+ {SECTIONS.map((s) => <a key={s} className="g-link" href={`#/gallery`} onClick={(e) => { e.preventDefault(); document.getElementById(`g-${s}`)?.scrollIntoView({ behavior: "smooth" }); }}>{s}</a>)}
50
+ <div className="gap" />
51
+ <a className="g-link" href="#/">Open the review</a>
52
+ </nav>
53
+ <main className="gallery-main">
54
+ <Sec title="Foundations" note="Tokens follow the system theme. Color is only for status.">
55
+ <div className="g-swatches">{TOKENS.map((t) => <div key={t}><i style={{ background: `var(${t})` }} /><code>{t}</code></div>)}</div>
56
+ <Spec name="title"><h1 className="title">Preserve zoom controls order in RTL locales</h1></Spec>
57
+ <Spec name="lead"><Lead>In right-to-left languages the zoom controls keep their − 100% + order.</Lead></Spec>
58
+ <Spec name="prose"><p className="prose">Opens each instance in Hebrew and Arabic and sorts the three zoom buttons by their position on screen.</p></Spec>
59
+ <Spec name="label"><Label count={3}>Worth a close look</Label></Spec>
60
+ <Spec name="stat"><Stat add={46} del={7} /></Spec>
61
+ </Sec>
62
+
63
+ <Sec title="Buttons">
64
+ <div className="g-row">
65
+ <button className="btn primary">{Icon.play} Run</button>
66
+ <button className="btn">{Icon.code} Show the code</button>
67
+ <button className="btn ghost">{Icon.back} Overview</button>
68
+ <button className="btn sm">{Icon.play} Chapter 1</button>
69
+ <button className="btn primary" disabled>{Icon.play} Running</button>
70
+ </div>
71
+ </Sec>
72
+
73
+ <Sec title="Rows">
74
+ <Spec name="file">{first && <Rows><Row><FileLine path={first} add={pr.files[0].add} del={pr.files[0].del} /></Row><Row to="changes" toLabel="Code"><FileLine path="packages/excalidraw/components/a/very/deeply/nested/folder/structure/ZoomActions.module.scss" add={12} del={0} /></Row></Rows>}</Spec>
75
+ <Spec name="attention">
76
+ <Attention items={[{ text: "Direction is set twice", detail: "Both the .zoom-actions rule and an inline style set direction: ltr; one is redundant.", to: { group: review.groups[0]?.id ?? "x" } }, { text: "A finding with no link", detail: "Some findings have nothing to point at." }]} />
77
+ </Spec>
78
+ <Spec name="claims">
79
+ <Attention
80
+ items={[
81
+ { claim: { source: "coderabbitai[bot]", quote: "Zen mode is unaffected by this change.", status: "fail" }, detail: "The zoom percentage still sits on the wrong side in zen mode.", to: { chapter: review.chapters[0]?.id ?? "x" } },
82
+ { claim: { source: "PR description", quote: "That separate weakness is recorded and is not addressed here.", status: "neutral", label: "Known, out of scope" }, detail: "assertPublic and fetch still resolve the host separately." },
83
+ ]}
84
+ held={[{ source: "PR description", quote: "A URL whose host is an IPv4-mapped IPv6 address passed the private-address check.", to: { chapter: review.chapters[0]?.id ?? "x" } }]}
85
+ />
86
+ </Spec>
87
+ </Sec>
88
+
89
+ <Sec title="Overview">
90
+ <BeforeAfter before={<>The row followed the page direction, so in RTL it read <code>+ 100% −</code>.</>} after={<>The row is forced to <code>direction: ltr</code>, so it reads <code>− 100% +</code> everywhere.</>} />
91
+ <Card caption="Dashed: changed in this PR. The tour lights it up on base and head."><Flow def={flow} /></Card>
92
+ </Sec>
93
+
94
+ <Sec title="Flow" note="The diagram with a failing run, and the comparison a chapter shows beside its text.">
95
+ <Spec name="default, failing"><Card><Flow def={flow} here="guard" events={failing} /></Card></Spec>
96
+ <Spec name="beside a chapter, base failing and head passing"><div style={{ maxWidth: 336 }}><FlowCompare def={flow} here="guard" instances={review.instances} events={[...failing.map((e) => ({ ...e, instance: "base" })), ...passing.map((e) => ({ ...e, instance: "head" }))]} /></div></Spec>
97
+ </Sec>
98
+
99
+ <Sec title="Screenshots" note={visual ? undefined : "Run the playground with the excalidraw fixture to see real screenshots."}>
100
+ {visual && <>
101
+ <Spec name="shot"><Shot src="shots/head-he-hover.png" title="Head, Hebrew, hovering +" caption="The tooltip sits on the right side of the button." /></Spec>
102
+ <Spec name="compare, side by side"><Compare title="The zoom controls in Hebrew" base="shots/base-he-IL-zoom.png" head="shots/head-he-IL-zoom.png" caption="Base mirrors the controls; head keeps − 100% +." /></Spec>
103
+ <Spec name="compare, slider"><Compare base="shots/base-he-dark.png" head="shots/head-he-dark.png" mode="slider" /></Spec>
104
+ <Spec name="compare, difference"><Compare base="shots/base-en-US-zoom.png" head="shots/head-en-US-zoom.png" mode="diff" caption="English: no pixel changes." /></Spec>
105
+ <Spec name="compare, whole page"><Compare base="shots/base-he-page.png" head="shots/head-he-page.png" /></Spec>
106
+ </>}
107
+ </Sec>
108
+
109
+ <Sec title="Live instances" note="Frames with an outcome overlay, as a step leaves them.">
110
+ <ChapterContext.Provider value={{ events: overlays, instances: review.instances }}>
111
+ <div className="g-frames"><Live /></div>
112
+ <Spec name="outcome"><Outcome /></Spec>
113
+ </ChapterContext.Provider>
114
+ <ChapterContext.Provider value={{ events: email, instances: review.instances }}>
115
+ <Spec name="outcome, a long result and none"><Outcome /></Spec>
116
+ </ChapterContext.Provider>
117
+ <Spec name="outcome, not run"><Outcome /></Spec>
118
+ </Sec>
119
+
120
+ <Sec title="Code">
121
+ {review.groups[0] && <Spec name="a group's lines, the rest folded"><div className="file"><div className="fh"><FileLine path={review.groups[0].file} /></div><Diff file={review.groups[0].file} lines={review.groups[0].lines} /></div></Spec>}
122
+ {first && <Spec name="a whole file"><div className="file"><div className="fh"><FileLine path={first} /></div><Diff file={first} /></div></Spec>}
123
+ </Sec>
124
+ </main>
125
+ </NavContext.Provider>
126
+ );
127
+ }
@@ -0,0 +1,15 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>Review playground</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Geist+Mono:wght@400;500&display=swap" rel="stylesheet">
10
+ </head>
11
+ <body>
12
+ <div id="root"></div>
13
+ <script type="module" src="./main.tsx"></script>
14
+ </body>
15
+ </html>
@@ -0,0 +1,26 @@
1
+ // The playground: the fixture's review in the real frame, or the gallery at #/gallery.
2
+ // ?still turns off animation, for screenshots.
3
+ import { useEffect, useState } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import review from "@review";
6
+ import { App } from "../src/App";
7
+ import { Gallery } from "./gallery";
8
+ import "../src/kit/kit.css";
9
+ import "./playground.css";
10
+
11
+ const params = new URLSearchParams(location.search);
12
+ if (params.has("still")) document.documentElement.classList.add("still");
13
+ // ?run presses Run once the chapter is up, for screenshots of results
14
+ if (params.has("run")) { const t = setInterval(() => { const b = document.getElementById("run"); if (b) { clearInterval(t); setTimeout(() => b.click(), 300); } }, 100); }
15
+
16
+ function Root() {
17
+ const [gallery, setGallery] = useState(location.hash.startsWith("#/gallery"));
18
+ useEffect(() => {
19
+ const on = () => setGallery(location.hash.startsWith("#/gallery"));
20
+ addEventListener("hashchange", on);
21
+ return () => removeEventListener("hashchange", on);
22
+ }, []);
23
+ return gallery ? <Gallery review={review} /> : <App review={review} />;
24
+ }
25
+
26
+ createRoot(document.getElementById("root")!).render(<Root />);
@@ -0,0 +1,215 @@
1
+ // The review server's API, played from a fixture: recorded events replay when a step runs, files and instance
2
+ // stand-ins come from the fixture directory. Lets the kit run without a box.
3
+ import { existsSync, readFileSync, statSync } from "node:fs";
4
+ import { extname, join, resolve, sep } from "node:path";
5
+ import type { IncomingMessage, ServerResponse } from "node:http";
6
+ import type { Plugin } from "vite";
7
+ import { validateAnswer, validateComment, validateVerdict, type CommentInput, type ReviewComment, type Verdict } from "../../src/review/contract";
8
+
9
+ interface Recorded { status: { id: string; status: "done" | "failed"; output?: string }; ms: number; events: Record<string, unknown>[] }
10
+
11
+ const TYPES: Record<string, string> = { ".png": "image/png", ".jpg": "image/jpeg", ".webp": "image/webp", ".webm": "video/webm", ".svg": "image/svg+xml" };
12
+
13
+ // local: the review is of local changes, so comments go to the developer's agent, played here too
14
+ export function mockReviewServer(fixture: string, local = false): Plugin {
15
+ const recorded: Record<string, Recorded> = existsSync(join(fixture, "events.json")) ? JSON.parse(readFileSync(join(fixture, "events.json"), "utf8")) : {};
16
+ // box port -> a screenshot standing in for the live app
17
+ const instances: Record<string, string> = existsSync(join(fixture, "instances.json")) ? JSON.parse(readFileSync(join(fixture, "instances.json"), "utf8")) : {};
18
+ let events: Record<string, unknown>[] = [];
19
+ let steps: Record<string, unknown> = {};
20
+ let running: string | undefined;
21
+ // comments live as long as the playground runs; GitHub and the agents only pretend
22
+ let comments: ReviewComment[] = [];
23
+ let nextComment = 1;
24
+ let verdict: Verdict = {};
25
+ const updates: { n: number; at: string; note: string; refreshed?: string }[] = [];
26
+ // the PR as GitHub would report it, for the pull request page
27
+ const checkRun = (name: string, status: string, workflow: string, secs: number) => ({ name, status, workflow, url: "https://github.com/excalidraw/excalidraw/actions", started: "2026-11-15T09:00:00Z", ...(status !== "pending" && { finished: new Date(Date.parse("2026-11-15T09:00:00Z") + secs * 1000).toISOString() }) });
28
+ let prStatus: Record<string, unknown> = {
29
+ number: 12602, title: "fix: preserve zoom controls order in RTL locales (#12375)", url: "https://github.com/excalidraw/excalidraw/pull/12602", body: "Fixes #12375.\n\nIn RTL locales the zoom row reversed, so **+** and **−** swapped sides. This pins the row to `direction: ltr`:\n\n- `Actions.tsx`: inline style on the row\n- `Actions.scss`: the same on `.zoom-actions`\n- a test for both locales\n\nZen mode keeps its RTL mirroring.", state: "OPEN", draft: false, author: "yashash1111",
30
+ head: { ref: "fix/zoom-buttons-rtl", oid: "abc123" }, base: "master", additions: 20, deletions: 13, files: 4,
31
+ mergeable: "MERGEABLE", mergeState: "UNSTABLE", reviewDecision: "REVIEW_REQUIRED",
32
+ reviews: [{ by: "dwelle", state: "COMMENTED", at: "2026-11-12T10:00:00Z" }, { by: "mtolmacs", state: "APPROVED", at: "2026-11-14T16:20:00Z" }],
33
+ requested: ["dwelle"],
34
+ checks: [checkRun("test", "pass", "Tests", 214), checkRun("lint", "pass", "Lint", 48), checkRun("size-limit", "fail", "Size", 95), checkRun("e2e (chromium)", "pending", "E2E", 0), { name: "Vercel – excalidraw-package-example", status: "pass", url: "https://vercel.com", started: "2026-11-15T09:01:00Z", finished: "2026-11-15T09:02:30Z" }],
35
+ canMerge: true, methods: ["SQUASH", "MERGE"], defaultMethod: "SQUASH",
36
+ };
37
+ const clients = new Set<ServerResponse>();
38
+ const send = (res: ServerResponse, ev: string, data: unknown) => res.write(`event: ${ev}\ndata: ${JSON.stringify(data)}\n\n`);
39
+ const broadcast = (ev: string, data: unknown) => clients.forEach((c) => send(c, ev, data));
40
+ const json = (res: ServerResponse, code: number, body: unknown) => res.writeHead(code, { "content-type": "application/json" }).end(JSON.stringify(body));
41
+
42
+ function run(id: string) {
43
+ const rec = recorded[id];
44
+ running = id;
45
+ steps[id] = { id, status: "running" };
46
+ broadcast("step", steps[id]);
47
+ const gap = Math.max(250, Math.min(900, rec.ms / Math.max(1, rec.events.length + 1)));
48
+ rec.events.forEach((e, i) =>
49
+ setTimeout(() => {
50
+ const ev = { ...e, step: id, seq: events.length + 1 };
51
+ events.push(ev);
52
+ broadcast("event", ev);
53
+ }, gap * (i + 1)),
54
+ );
55
+ setTimeout(() => {
56
+ running = undefined;
57
+ steps[id] = rec.status;
58
+ broadcast("step", rec.status);
59
+ }, gap * (rec.events.length + 1));
60
+ }
61
+
62
+ const read = (req: IncomingMessage) => new Promise<string>((ok) => { let b = ""; req.on("data", (c) => (b += c)); req.on("end", () => ok(b)); });
63
+
64
+ async function handle(req: IncomingMessage, res: ServerResponse, next: () => void) {
65
+ const u = new URL(req.url ?? "/", "http://playground");
66
+ const p = u.pathname;
67
+ if (p === "/api/status") return json(res, 200, { app: true });
68
+ if (p === "/api/urls") {
69
+ const host = `http://${req.headers.host}`;
70
+ return json(res, 200, Object.fromEntries(Object.keys(instances).map((port) => [port, `${host}/__instance/${port}/`])));
71
+ }
72
+ if (p === "/api/events" && req.method === "GET") {
73
+ res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-store", connection: "keep-alive" });
74
+ send(res, "state", { steps });
75
+ send(res, "comments", { comments });
76
+ send(res, "verdict", verdict);
77
+ send(res, "agent", { local, listening: local });
78
+ send(res, "updates", { updates });
79
+ events.forEach((e) => send(res, "event", e));
80
+ send(res, "ready", {});
81
+ clients.add(res);
82
+ req.on("close", () => clients.delete(res));
83
+ return;
84
+ }
85
+ const step = req.method === "POST" && p.match(/^\/api\/steps\/([^/]+)\/run$/);
86
+ if (step) {
87
+ const id = decodeURIComponent(step[1]);
88
+ if (!recorded[id]) return json(res, 404, { errors: [`no recorded step "${id}"`] });
89
+ if (running) return json(res, 409, { errors: [`step "${running}" is still running`] });
90
+ run(id);
91
+ return json(res, 202, { id, status: "running" });
92
+ }
93
+ if (p === "/api/reset" && req.method === "POST") {
94
+ events = [];
95
+ steps = {};
96
+ broadcast("reset", {});
97
+ return res.writeHead(204).end();
98
+ }
99
+ if (p === "/api/comments" && req.method === "GET") return json(res, 200, { comments });
100
+ if (p === "/api/verdict" && req.method === "GET") return json(res, 200, verdict);
101
+ // a moment's wait, as GitHub takes, so the page shows its skeleton first
102
+ if (p === "/api/pr" && req.method === "GET") return local ? json(res, 409, { errors: ["these are local changes with no PR"] }) : setTimeout(() => json(res, 200, prStatus), 900);
103
+ if (req.method !== "POST" && req.method !== "PATCH" && req.method !== "DELETE") return handleRest();
104
+ const body = JSON.parse((await read(req)) || "{}");
105
+ const changed = () => broadcast("comments", { comments });
106
+ if (p === "/api/verdict") {
107
+ const errors = validateVerdict(body);
108
+ if (errors.length) return json(res, 400, { errors });
109
+ verdict = body;
110
+ broadcast("verdict", verdict);
111
+ return json(res, 200, verdict);
112
+ }
113
+ if (p === "/api/comments" && req.method === "POST") {
114
+ const errors = validateComment(body);
115
+ if (errors.length) return json(res, 400, { errors });
116
+ const { mode, ...input } = body as CommentInput;
117
+ if (input.to === "github") {
118
+ if (local) return json(res, 409, { errors: ["these are local changes with no PR: comments go to your agent"] });
119
+ if (mode === "single" && comments.some((c) => c.github?.state === "pending")) return json(res, 502, { errors: ["you have a review in progress on GitHub: add the comment to it"] });
120
+ }
121
+ const id = nextComment++;
122
+ const c: ReviewComment = {
123
+ ...input,
124
+ id: `c${id}`,
125
+ at: new Date().toISOString(),
126
+ by: "reviewer",
127
+ ...(input.to === "github" ? { github: { state: mode === "single" ? "posted" : "pending", node: `C_${id}`, id: 90000 + id, url: `https://github.com/excalidraw/excalidraw/pull/12602#discussion_r${90000 + id}` } } : { status: "sent" }),
128
+ } as ReviewComment;
129
+ comments.push(c);
130
+ changed();
131
+ if (c.to === "agent") agentWorks([c]);
132
+ return setTimeout(() => json(res, 201, c), 250);
133
+ }
134
+ const one = p.match(/^\/api\/comments\/(c\d+)(\/answer)?$/);
135
+ if (one?.[2]) {
136
+ const errors = validateAnswer(body);
137
+ if (errors.length) return json(res, 400, { errors });
138
+ return json(res, 201, answer(one[1], body.body, body.commit));
139
+ }
140
+ if (one) {
141
+ const c = comments.find((x) => x.id === one[1]);
142
+ if (!c) return json(res, 404, { errors: [`no comment ${one[1]} of yours`] });
143
+ if (c.to === "agent" && c.status !== "sent") return json(res, 409, { errors: ["the agent is already working on it"] });
144
+ if (req.method === "DELETE") comments = comments.filter((x) => x !== c);
145
+ else c.body = body.body;
146
+ changed();
147
+ return req.method === "DELETE" ? res.writeHead(204).end() : json(res, 200, c);
148
+ }
149
+ if (p === "/api/finish") return res.writeHead(204).end();
150
+ if (p === "/api/pr/merge") {
151
+ if (!["MERGE", "SQUASH", "REBASE"].includes(body.method)) return json(res, 400, { errors: ["method: expected one of MERGE, SQUASH, REBASE"] });
152
+ prStatus = { ...prStatus, state: "MERGED", merged: { at: new Date().toISOString(), by: "you" } };
153
+ return setTimeout(() => json(res, 200, prStatus), 700);
154
+ }
155
+ if (p === "/api/submit") {
156
+ if (!verdict.event) return json(res, 400, { errors: ["choose a verdict before submitting"] });
157
+ comments.forEach((c) => c.github?.state === "pending" && (c.github.state = "posted"));
158
+ verdict = {};
159
+ changed();
160
+ broadcast("verdict", verdict);
161
+ return setTimeout(() => json(res, 200, { url: "https://github.com/excalidraw/excalidraw/pull/12602#pullrequestreview-1" }), 600);
162
+ }
163
+ return handleRest();
164
+
165
+ function handleRest() {
166
+ return rest(req, res, p, next);
167
+ }
168
+ }
169
+
170
+ function answer(id: string, body: string, commit?: string) {
171
+ const c = comments.find((x) => x.id === id)!;
172
+ c.status = "done";
173
+ const a: ReviewComment = { id: `c${nextComment++}`, at: new Date().toISOString(), by: "agent", to: "agent", replyTo: id, body, ...(commit && { commit }) };
174
+ comments.push(a);
175
+ broadcast("comments", { comments });
176
+ return a;
177
+ }
178
+
179
+ // the agent picks up what was sent, works a while, answers each, and the tour notes the update
180
+ function agentWorks(sent: ReviewComment[]) {
181
+ if (!sent.length) return;
182
+ setTimeout(() => {
183
+ sent.forEach((c) => (c.status = "working"));
184
+ broadcast("comments", { comments });
185
+ }, 900);
186
+ setTimeout(() => {
187
+ sent.forEach((c) => answer(c.id, `On ${c.topic!.label}, checked in the box:\n\n- Base renders the zoom buttons in DOM order, so in RTL the flex row **reverses** them.\n- Head pins the row to \`direction: ltr\`, which is why chapter 1 shows *− 100% +* in both locales.\n\n| Locale | Base | Head |\n| --- | --- | --- |\n| he-IL | + 100% − | − 100% + |\n| en-US | − 100% + | − 100% + |\n\n> The class on the wrapper is redundant with the inline style.\n\nNothing was changed.`));
188
+ }, 3500);
189
+ }
190
+
191
+ function rest(req: IncomingMessage, res: ServerResponse, p: string, next: () => void) {
192
+ if (p.startsWith("/files/")) {
193
+ const root = resolve(fixture, "files");
194
+ const file = resolve(root, "." + sep + decodeURIComponent(p.slice(7)));
195
+ if (!file.startsWith(root + sep) || !existsSync(file) || !statSync(file).isFile()) return res.writeHead(404).end();
196
+ return res.writeHead(200, { "content-type": TYPES[extname(file)] ?? "application/octet-stream" }).end(readFileSync(file));
197
+ }
198
+ const inst = p.match(/^\/__instance\/(\d+)\/?$/);
199
+ if (inst) {
200
+ const shot = instances[inst[1]];
201
+ const body = shot
202
+ ? `<img src="/files/${shot}" style="display:block;width:100%;height:100vh;object-fit:cover;object-position:top">`
203
+ : `<div style="height:100vh;display:grid;place-items:center;font:14px -apple-system,sans-serif;color:#888">Live app on port ${inst[1]}</div>`;
204
+ return res.writeHead(200, { "content-type": "text/html; charset=utf-8" }).end(`<!doctype html><body style="margin:0;background:#fff">${body}</body>`);
205
+ }
206
+ next();
207
+ }
208
+
209
+ return {
210
+ name: "mock-review-server",
211
+ configureServer(server) {
212
+ server.middlewares.use((req, res, next) => void handle(req, res, next));
213
+ },
214
+ };
215
+ }