@milesroxas/design-hub 0.0.0-stage → 0.1.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 (217) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +80 -2
  3. package/client.d.ts +6 -0
  4. package/configs/biome/biome.json +64 -0
  5. package/configs/tsconfig.project.json +17 -0
  6. package/core/bytes.test.ts +14 -0
  7. package/core/bytes.ts +14 -0
  8. package/core/catalog.test.ts +44 -0
  9. package/core/catalog.ts +43 -0
  10. package/core/checks.test.ts +23 -0
  11. package/core/checks.ts +72 -0
  12. package/core/contacts.test.ts +31 -0
  13. package/core/contacts.ts +38 -0
  14. package/core/doc.ts +4 -0
  15. package/core/feature.ts +6 -0
  16. package/core/fixtures.test.ts +33 -0
  17. package/core/fixtures.ts +69 -0
  18. package/core/frontmatter.ts +15 -0
  19. package/core/html.ts +3 -0
  20. package/core/image.test.ts +22 -0
  21. package/core/image.ts +37 -0
  22. package/core/jev.test.ts +66 -0
  23. package/core/jev.ts +77 -0
  24. package/core/review/model.ts +189 -0
  25. package/core/review/policy.ts +26 -0
  26. package/core/review/service.test.ts +163 -0
  27. package/core/review/service.ts +309 -0
  28. package/core/review/store.ts +51 -0
  29. package/core/review/triage.test.ts +41 -0
  30. package/core/review/triage.ts +58 -0
  31. package/core/states.test.ts +36 -0
  32. package/core/states.ts +34 -0
  33. package/dist/core/bytes.d.ts +5 -0
  34. package/dist/core/bytes.js +9 -0
  35. package/dist/core/catalog.d.ts +17 -0
  36. package/dist/core/catalog.js +24 -0
  37. package/dist/core/checks.d.ts +41 -0
  38. package/dist/core/checks.js +53 -0
  39. package/dist/core/contacts.d.ts +9 -0
  40. package/dist/core/contacts.js +34 -0
  41. package/dist/core/doc.d.ts +11 -0
  42. package/dist/core/doc.js +1 -0
  43. package/dist/core/feature.d.ts +3 -0
  44. package/dist/core/feature.js +4 -0
  45. package/dist/core/fixtures.d.ts +19 -0
  46. package/dist/core/fixtures.js +52 -0
  47. package/dist/core/frontmatter.d.ts +4 -0
  48. package/dist/core/frontmatter.js +15 -0
  49. package/dist/core/html.d.ts +2 -0
  50. package/dist/core/html.js +2 -0
  51. package/dist/core/image.d.ts +6 -0
  52. package/dist/core/image.js +34 -0
  53. package/dist/core/jev.d.ts +54 -0
  54. package/dist/core/jev.js +52 -0
  55. package/dist/core/review/model.d.ts +85 -0
  56. package/dist/core/review/model.js +112 -0
  57. package/dist/core/review/policy.d.ts +3 -0
  58. package/dist/core/review/policy.js +19 -0
  59. package/dist/core/review/service.d.ts +79 -0
  60. package/dist/core/review/service.js +245 -0
  61. package/dist/core/review/store.d.ts +20 -0
  62. package/dist/core/review/store.js +37 -0
  63. package/dist/core/review/triage.d.ts +6 -0
  64. package/dist/core/review/triage.js +45 -0
  65. package/dist/core/states.d.ts +6 -0
  66. package/dist/core/states.js +29 -0
  67. package/dist/scripts/bootstrap.d.ts +51 -0
  68. package/dist/scripts/bootstrap.js +278 -0
  69. package/dist/scripts/calibrate.d.ts +127 -0
  70. package/dist/scripts/calibrate.js +164 -0
  71. package/dist/scripts/check.d.ts +25 -0
  72. package/dist/scripts/check.js +30 -0
  73. package/dist/scripts/cli.d.ts +2 -0
  74. package/dist/scripts/cli.js +73 -0
  75. package/dist/scripts/commands.d.ts +25 -0
  76. package/dist/scripts/commands.js +49 -0
  77. package/dist/scripts/comments.d.ts +63 -0
  78. package/dist/scripts/comments.js +152 -0
  79. package/dist/scripts/dev.d.ts +46 -0
  80. package/dist/scripts/dev.js +84 -0
  81. package/dist/scripts/elyx.d.ts +61 -0
  82. package/dist/scripts/elyx.js +195 -0
  83. package/dist/scripts/fixtures-pull.d.ts +60 -0
  84. package/dist/scripts/fixtures-pull.js +121 -0
  85. package/dist/scripts/guard.d.ts +58 -0
  86. package/dist/scripts/guard.js +201 -0
  87. package/dist/scripts/hook.d.ts +18 -0
  88. package/dist/scripts/hook.js +46 -0
  89. package/dist/scripts/lib/bin.d.ts +4 -0
  90. package/dist/scripts/lib/bin.js +20 -0
  91. package/dist/scripts/lib/catalog.d.ts +7 -0
  92. package/dist/scripts/lib/catalog.js +18 -0
  93. package/dist/scripts/lib/cli.d.ts +75 -0
  94. package/dist/scripts/lib/cli.js +88 -0
  95. package/dist/scripts/lib/css-tokens.d.ts +13 -0
  96. package/dist/scripts/lib/css-tokens.js +67 -0
  97. package/dist/scripts/lib/judge.d.ts +24 -0
  98. package/dist/scripts/lib/judge.js +60 -0
  99. package/dist/scripts/lib/ports.d.ts +35 -0
  100. package/dist/scripts/lib/ports.js +113 -0
  101. package/dist/scripts/lib/review.d.ts +7 -0
  102. package/dist/scripts/lib/review.js +22 -0
  103. package/dist/scripts/lib/rules.d.ts +12 -0
  104. package/dist/scripts/lib/rules.js +20 -0
  105. package/dist/scripts/lib/vercel.d.ts +17 -0
  106. package/dist/scripts/lib/vercel.js +53 -0
  107. package/dist/scripts/new.d.ts +47 -0
  108. package/dist/scripts/new.js +113 -0
  109. package/dist/scripts/people.d.ts +59 -0
  110. package/dist/scripts/people.js +73 -0
  111. package/dist/scripts/preflight.d.ts +35 -0
  112. package/dist/scripts/preflight.js +251 -0
  113. package/dist/scripts/release-check.d.ts +19 -0
  114. package/dist/scripts/release-check.js +39 -0
  115. package/dist/scripts/skill-hint.d.ts +32 -0
  116. package/dist/scripts/skill-hint.js +103 -0
  117. package/dist/scripts/skills.d.ts +13 -0
  118. package/dist/scripts/skills.js +34 -0
  119. package/dist/scripts/visual-match.d.ts +46 -0
  120. package/dist/scripts/visual-match.js +93 -0
  121. package/dist/server/api.d.ts +22 -0
  122. package/dist/server/api.js +143 -0
  123. package/dist/server/checks.d.ts +4 -0
  124. package/dist/server/checks.js +22 -0
  125. package/dist/server/dev.d.ts +2 -0
  126. package/dist/server/dev.js +54 -0
  127. package/dist/server/env.d.ts +8 -0
  128. package/dist/server/env.js +57 -0
  129. package/dist/server/fake-upstash.d.ts +1 -0
  130. package/dist/server/fake-upstash.js +37 -0
  131. package/dist/server/file-kv.d.ts +2 -0
  132. package/dist/server/file-kv.js +24 -0
  133. package/dist/server/local.d.ts +4 -0
  134. package/dist/server/local.js +25 -0
  135. package/dist/server/paths.d.ts +22 -0
  136. package/dist/server/paths.js +81 -0
  137. package/dist/server/session.d.ts +22 -0
  138. package/dist/server/session.js +110 -0
  139. package/dist/server/upstash.d.ts +7 -0
  140. package/dist/server/upstash.js +41 -0
  141. package/dist/vercel/index.d.ts +5 -0
  142. package/dist/vercel/index.js +12 -0
  143. package/dist/vercel/proxy.d.ts +11 -0
  144. package/dist/vercel/proxy.js +238 -0
  145. package/dist/vite/kit.d.ts +4 -0
  146. package/dist/vite/kit.js +35 -0
  147. package/dist/vite/markdown.d.ts +2 -0
  148. package/dist/vite/markdown.js +212 -0
  149. package/dist/vite/plugin.d.ts +15 -0
  150. package/dist/vite/plugin.js +84 -0
  151. package/dist/vite/registry.d.ts +7 -0
  152. package/dist/vite/registry.js +70 -0
  153. package/kit/Icon.tsx +39 -0
  154. package/kit/Space.tsx +25 -0
  155. package/kit/Variants.tsx +134 -0
  156. package/kit/ViewTools.tsx +34 -0
  157. package/kit/base.css +27 -0
  158. package/kit/dials.css +98 -0
  159. package/kit/dials.tsx +322 -0
  160. package/kit/fixtures.ts +3 -0
  161. package/kit/format.ts +13 -0
  162. package/kit/harness.css +99 -0
  163. package/kit/hooks.ts +35 -0
  164. package/kit/icons.ts +329 -0
  165. package/kit/motion.ts +18 -0
  166. package/kit/prefs.ts +111 -0
  167. package/kit/space.css +12 -0
  168. package/kit/ui/Button.tsx +135 -0
  169. package/kit/ui/Menu.tsx +386 -0
  170. package/kit/ui/Overlay.tsx +300 -0
  171. package/kit/ui/Toast.tsx +187 -0
  172. package/kit/ui/index.ts +13 -0
  173. package/kit/ui/layer.ts +51 -0
  174. package/kit/ui/ui.css +238 -0
  175. package/package.json +87 -3
  176. package/shell/Guide.tsx +160 -0
  177. package/shell/Index.tsx +301 -0
  178. package/shell/Notice.tsx +27 -0
  179. package/shell/Prototype.tsx +31 -0
  180. package/shell/a11y.ts +50 -0
  181. package/shell/doc.d.ts +7 -0
  182. package/shell/doc.ts +3 -0
  183. package/shell/guide.css +133 -0
  184. package/shell/index.html +17 -0
  185. package/shell/main.tsx +53 -0
  186. package/shell/public/favicon.svg +1 -0
  187. package/shell/registry.ts +21 -0
  188. package/shell/registry.virtual.d.ts +10 -0
  189. package/shell/review/Review.tsx +989 -0
  190. package/shell/review/anchor.ts +132 -0
  191. package/shell/review/client.ts +165 -0
  192. package/shell/review/mount.tsx +12 -0
  193. package/shell/review/review.css +296 -0
  194. package/shell/shell.css +160 -0
  195. package/shell/system.css +238 -0
  196. package/skills/miles-conductor/SKILL.md +37 -0
  197. package/skills/miles-deploy/SKILL.md +67 -0
  198. package/skills/miles-design-hub/SKILL.md +62 -0
  199. package/skills/miles-get-hostile/SKILL.md +61 -0
  200. package/skills/miles-new-feature/SKILL.md +40 -0
  201. package/skills/miles-paper-to-prototype/SKILL.md +46 -0
  202. package/skills/miles-port/SKILL.md +59 -0
  203. package/skills/miles-port/references/astro.md +35 -0
  204. package/skills/miles-port/references/next.md +36 -0
  205. package/skills/miles-prototype/SKILL.md +83 -0
  206. package/skills/miles-prototype/references/accessibility.md +48 -0
  207. package/skills/miles-prototype/references/motion.md +59 -0
  208. package/skills/miles-prototype/references/performance.md +45 -0
  209. package/skills/miles-review/SKILL.md +39 -0
  210. package/templates/app/README.md +41 -0
  211. package/templates/app/index.tsx +32 -0
  212. package/templates/app/styles.css +1 -0
  213. package/templates/conductor/settings.toml +29 -0
  214. package/templates/guide.md +38 -0
  215. package/templates/space/README.md +37 -0
  216. package/templates/space/index.tsx +43 -0
  217. package/templates/space/styles.css +13 -0
@@ -0,0 +1,79 @@
1
+ import { type Comment, type Person, type Triage } from './model.ts';
2
+ import { type KV } from './store.ts';
3
+ export type ErrorCode = 'unauthorized' | 'forbidden' | 'not_found' | 'invalid';
4
+ export declare class ReviewError extends Error {
5
+ code: ErrorCode;
6
+ problems: string[];
7
+ constructor(code: ErrorCode, message: string, problems?: string[]);
8
+ }
9
+ export type TriageInput = {
10
+ body: string;
11
+ prototype: string;
12
+ url: string;
13
+ states: string[];
14
+ };
15
+ /** Classifies a new comment; null when it cannot (TypeSafe off, slow, unsure). */
16
+ export type Triager = (input: TriageInput) => Promise<Triage | null>;
17
+ export type Deps = {
18
+ now: () => Date;
19
+ random: (bytes: number) => Uint8Array;
20
+ triage: Triager | null;
21
+ };
22
+ export declare function createReview(kv: KV, overrides?: Partial<Deps>): {
23
+ person: (id: string) => Promise<Person | null>;
24
+ /** The person a signed session names, if they still may sign in with it. */
25
+ sessionPerson(id: string, epoch: number): Promise<Person | null>;
26
+ listPeople(actor: Person | null): Promise<Person[]>;
27
+ invite(actor: Person | null, input: unknown): Promise<{
28
+ person: Person;
29
+ token: string;
30
+ }>;
31
+ /** A fresh link for someone; their old link and every session they hold stop working. */
32
+ reissue(actor: Person | null, id: string): Promise<{
33
+ person: Person;
34
+ token: string;
35
+ }>;
36
+ revoke(actor: Person | null, id: string): Promise<Person>;
37
+ /** The person an invite token names, while the invite is current and they are not revoked. */
38
+ acceptInvite(token: string): Promise<Person | null>;
39
+ listComments(actor: Person | null, prototype: string): Promise<Comment[]>;
40
+ /** Every comment on every prototype, for the CLI, which holds the store's own credentials. */
41
+ allComments(): Promise<Comment[]>;
42
+ add(actor: Person | null, input: {
43
+ body?: unknown;
44
+ anchor?: unknown;
45
+ states?: unknown;
46
+ }): Promise<Comment>;
47
+ reply(actor: Person | null, input: {
48
+ prototype: string;
49
+ thread: string;
50
+ body?: unknown;
51
+ }): Promise<Comment>;
52
+ edit(actor: Person | null, input: {
53
+ prototype: string;
54
+ id: string;
55
+ body?: unknown;
56
+ }): Promise<Comment>;
57
+ /** Deletes a comment; deleting a thread's root deletes its replies with it. */
58
+ remove(actor: Person | null, input: {
59
+ prototype: string;
60
+ id: string;
61
+ }): Promise<string[]>;
62
+ resolve(actor: Person | null, input: {
63
+ prototype: string;
64
+ id: string;
65
+ resolved: boolean;
66
+ }): Promise<Comment>;
67
+ retriage(actor: Person | null, input: {
68
+ prototype: string;
69
+ id: string;
70
+ triage?: unknown;
71
+ states?: unknown;
72
+ }): Promise<Comment>;
73
+ /** Where a comment lives, for the CLI, which has an id and no prototype. */
74
+ locate(id: string): Promise<{
75
+ prototype: string;
76
+ comment: Comment;
77
+ } | null>;
78
+ };
79
+ export type Review = ReturnType<typeof createReview>;
@@ -0,0 +1,245 @@
1
+ // The review use cases over the storage port. Every adapter (the Vercel
2
+ // function, the Vite dev server, the CLI) builds one of these and calls it;
3
+ // none of them touches the store directly, so the rules in model.ts and
4
+ // policy.ts hold everywhere. Writes are per record (one hash field per
5
+ // person, per comment), so two people commenting at once never collide;
6
+ // two edits of the same comment at once keep the last.
7
+ import { base64url, hex, sha256 } from '../bytes.js';
8
+ import { authorOf, checkAnchor, checkBody, checkPersonInput, checkStates, checkTriage, INVITE_DAYS, isRoot, } from './model.js';
9
+ import { can } from './policy.js';
10
+ import { KEY } from './store.js';
11
+ export class ReviewError extends Error {
12
+ code;
13
+ problems;
14
+ constructor(code, message, problems = []) {
15
+ super(message);
16
+ this.code = code;
17
+ this.problems = problems;
18
+ }
19
+ }
20
+ const DAY_MS = 86_400_000;
21
+ const defaults = {
22
+ now: () => new Date(),
23
+ random: (n) => crypto.getRandomValues(new Uint8Array(n)),
24
+ triage: null,
25
+ };
26
+ export function createReview(kv, overrides = {}) {
27
+ const deps = { ...defaults, ...overrides };
28
+ const now = () => deps.now().toISOString();
29
+ // Sortable by time, unique without coordination.
30
+ const newId = () => `${deps.now().getTime().toString(36).padStart(9, '0')}${hex(deps.random(5))}`;
31
+ const parse = (json) => (json === null ? null : JSON.parse(json));
32
+ function allow(actor, action, subject) {
33
+ if (!actor)
34
+ throw new ReviewError('unauthorized', 'Sign in with an invite link to do that.');
35
+ if (!can(actor, action, subject))
36
+ throw new ReviewError('forbidden', 'You cannot do that.');
37
+ }
38
+ function valid(checked) {
39
+ if (!checked.ok)
40
+ throw new ReviewError('invalid', 'Some fields are not valid.', checked.problems);
41
+ return checked.value;
42
+ }
43
+ // Every field checked before any throws, so one save names every problem.
44
+ function validAll(a, b) {
45
+ const problems = [...(a.ok ? [] : a.problems), ...(b.ok ? [] : b.problems)];
46
+ if (!a.ok || !b.ok)
47
+ throw new ReviewError('invalid', 'Some fields are not valid.', problems);
48
+ return [a.value, b.value];
49
+ }
50
+ const person = async (id) => parse(await kv.hget(KEY.people, id));
51
+ const savePerson = (p) => kv.hset(KEY.people, p.id, JSON.stringify(p));
52
+ async function issueInvite(p) {
53
+ // The link carries the token; the store keeps only its hash, so a leaked store signs nobody in.
54
+ const token = base64url(deps.random(32));
55
+ const invite = {
56
+ person: p.id,
57
+ expiresAt: new Date(deps.now().getTime() + INVITE_DAYS * DAY_MS).toISOString(),
58
+ };
59
+ await kv.hset(KEY.invites, hex(await sha256(token)), JSON.stringify(invite));
60
+ return token;
61
+ }
62
+ async function dropInvites(personId) {
63
+ const all = await kv.hgetall(KEY.invites);
64
+ for (const [hash, json] of Object.entries(all))
65
+ if (JSON.parse(json).person === personId)
66
+ await kv.hdel(KEY.invites, hash);
67
+ }
68
+ async function needPerson(id) {
69
+ const p = await person(id);
70
+ if (!p)
71
+ throw new ReviewError('not_found', 'No such person.');
72
+ return p;
73
+ }
74
+ const commentsOf = async (prototype) => Object.values(await kv.hgetall(KEY.comments(prototype)))
75
+ .map((json) => JSON.parse(json))
76
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
77
+ const saveComment = (prototype, c) => kv.hset(KEY.comments(prototype), c.id, JSON.stringify(c));
78
+ async function needComment(prototype, id) {
79
+ const c = parse(await kv.hget(KEY.comments(prototype), id));
80
+ if (!c)
81
+ throw new ReviewError('not_found', 'No such comment.');
82
+ return c;
83
+ }
84
+ async function needRoot(prototype, id) {
85
+ const c = await needComment(prototype, id);
86
+ if (!isRoot(c))
87
+ throw new ReviewError('invalid', 'That is a reply; act on its thread.');
88
+ return c;
89
+ }
90
+ return {
91
+ person,
92
+ /** The person a signed session names, if they still may sign in with it. */
93
+ async sessionPerson(id, epoch) {
94
+ const p = await person(id);
95
+ return p && !p.revokedAt && p.epoch === epoch ? p : null;
96
+ },
97
+ async listPeople(actor) {
98
+ allow(actor, 'people');
99
+ const all = Object.values(await kv.hgetall(KEY.people)).map((j) => JSON.parse(j));
100
+ return all.sort((a, b) => a.team.localeCompare(b.team) || a.name.localeCompare(b.name));
101
+ },
102
+ async invite(actor, input) {
103
+ allow(actor, 'people');
104
+ const p = {
105
+ id: newId(),
106
+ ...valid(checkPersonInput(input)),
107
+ createdAt: now(),
108
+ revokedAt: null,
109
+ epoch: 0,
110
+ };
111
+ await savePerson(p);
112
+ return { person: p, token: await issueInvite(p) };
113
+ },
114
+ /** A fresh link for someone; their old link and every session they hold stop working. */
115
+ async reissue(actor, id) {
116
+ allow(actor, 'people');
117
+ const p = await needPerson(id);
118
+ if (p.revokedAt)
119
+ throw new ReviewError('invalid', 'That person is revoked; invite them again.');
120
+ const next = { ...p, epoch: p.epoch + 1 };
121
+ await dropInvites(id);
122
+ await savePerson(next);
123
+ return { person: next, token: await issueInvite(next) };
124
+ },
125
+ async revoke(actor, id) {
126
+ allow(actor, 'people');
127
+ if (actor.id === id)
128
+ throw new ReviewError('invalid', 'You cannot revoke yourself.');
129
+ const p = await needPerson(id);
130
+ const next = { ...p, revokedAt: p.revokedAt ?? now(), epoch: p.epoch + 1 };
131
+ await dropInvites(id);
132
+ await savePerson(next);
133
+ return next;
134
+ },
135
+ /** The person an invite token names, while the invite is current and they are not revoked. */
136
+ async acceptInvite(token) {
137
+ if (!/^[\w-]{20,100}$/.test(token))
138
+ return null;
139
+ const invite = parse(await kv.hget(KEY.invites, hex(await sha256(token))));
140
+ if (!invite || invite.expiresAt <= now())
141
+ return null;
142
+ const p = await person(invite.person);
143
+ return p && !p.revokedAt ? p : null;
144
+ },
145
+ async listComments(actor, prototype) {
146
+ allow(actor, 'read');
147
+ return commentsOf(prototype);
148
+ },
149
+ /** Every comment on every prototype, for the CLI, which holds the store's own credentials. */
150
+ async allComments() {
151
+ const prototypes = await kv.smembers(KEY.prototypes);
152
+ return (await Promise.all(prototypes.map(commentsOf))).flat();
153
+ },
154
+ async add(actor, input) {
155
+ allow(actor, 'comment');
156
+ const [body, anchor] = validAll(checkBody(input.body), checkAnchor(input.anchor));
157
+ const id = newId();
158
+ const triage = deps.triage
159
+ ? await deps
160
+ .triage({ body, prototype: anchor.prototype, url: anchor.url, states: checkStates(input.states) })
161
+ // Triage is a convenience; a triager that throws never costs the comment.
162
+ .catch(() => null)
163
+ : null;
164
+ const c = {
165
+ id,
166
+ thread: id,
167
+ author: authorOf(actor),
168
+ body,
169
+ createdAt: now(),
170
+ editedAt: null,
171
+ anchor,
172
+ status: 'open',
173
+ resolvedBy: null,
174
+ triage,
175
+ };
176
+ await kv.sadd(KEY.prototypes, anchor.prototype);
177
+ await saveComment(anchor.prototype, c);
178
+ return c;
179
+ },
180
+ async reply(actor, input) {
181
+ allow(actor, 'comment');
182
+ const root = await needRoot(input.prototype, input.thread);
183
+ const id = newId();
184
+ const c = {
185
+ id,
186
+ thread: root.id,
187
+ author: authorOf(actor),
188
+ body: valid(checkBody(input.body)),
189
+ createdAt: now(),
190
+ editedAt: null,
191
+ anchor: null,
192
+ status: 'open',
193
+ resolvedBy: null,
194
+ triage: null,
195
+ };
196
+ await saveComment(input.prototype, c);
197
+ return c;
198
+ },
199
+ async edit(actor, input) {
200
+ const c = await needComment(input.prototype, input.id);
201
+ allow(actor, 'edit', c);
202
+ const next = { ...c, body: valid(checkBody(input.body)), editedAt: now() };
203
+ await saveComment(input.prototype, next);
204
+ return next;
205
+ },
206
+ /** Deletes a comment; deleting a thread's root deletes its replies with it. */
207
+ async remove(actor, input) {
208
+ const c = await needComment(input.prototype, input.id);
209
+ allow(actor, 'delete', c);
210
+ const gone = isRoot(c)
211
+ ? (await commentsOf(input.prototype)).filter((x) => x.thread === c.id).map((x) => x.id)
212
+ : [c.id];
213
+ for (const id of gone)
214
+ await kv.hdel(KEY.comments(input.prototype), id);
215
+ return gone;
216
+ },
217
+ async resolve(actor, input) {
218
+ const c = await needRoot(input.prototype, input.id);
219
+ allow(actor, 'resolve', c);
220
+ const next = input.resolved
221
+ ? { ...c, status: 'resolved', resolvedBy: authorOf(actor) }
222
+ : { ...c, status: 'open', resolvedBy: null };
223
+ await saveComment(input.prototype, next);
224
+ return next;
225
+ },
226
+ async retriage(actor, input) {
227
+ allow(actor, 'triage');
228
+ const c = await needRoot(input.prototype, input.id);
229
+ const states = checkStates(input.states);
230
+ const { kind, state } = valid(checkTriage(input.triage, states.length ? states : null));
231
+ const next = { ...c, triage: { kind, state, by: 'person', confidence: null } };
232
+ await saveComment(input.prototype, next);
233
+ return next;
234
+ },
235
+ /** Where a comment lives, for the CLI, which has an id and no prototype. */
236
+ async locate(id) {
237
+ for (const prototype of await kv.smembers(KEY.prototypes)) {
238
+ const c = parse(await kv.hget(KEY.comments(prototype), id));
239
+ if (c)
240
+ return { prototype, comment: c };
241
+ }
242
+ return null;
243
+ },
244
+ };
245
+ }
@@ -0,0 +1,20 @@
1
+ export interface KV {
2
+ hget(key: string, field: string): Promise<string | null>;
3
+ hset(key: string, field: string, value: string): Promise<void>;
4
+ hdel(key: string, field: string): Promise<void>;
5
+ hgetall(key: string): Promise<Record<string, string>>;
6
+ sadd(key: string, member: string): Promise<void>;
7
+ smembers(key: string): Promise<string[]>;
8
+ }
9
+ /** Every key the hub writes starts here, so a shared Redis never mixes it with another app's data. */
10
+ export declare const KEY: {
11
+ readonly people: 'hub:people';
12
+ readonly invites: 'hub:invites';
13
+ readonly prototypes: 'hub:prototypes';
14
+ readonly comments: (prototype: string) => string;
15
+ };
16
+ export type Snapshot = {
17
+ hashes: Record<string, Record<string, string>>;
18
+ sets: Record<string, string[]>;
19
+ };
20
+ export declare function memoryKV(initial?: Snapshot, onChange?: (s: Snapshot) => void): KV;
@@ -0,0 +1,37 @@
1
+ // The one storage port the review service needs: a few Redis-shaped hash
2
+ // and set operations. Production is Upstash Redis over REST
3
+ // (server/upstash.ts); dev and tests use the in-memory store below, which
4
+ // the dev server persists to a file (server/file-kv.ts).
5
+ /** Every key the hub writes starts here, so a shared Redis never mixes it with another app's data. */
6
+ export const KEY = {
7
+ people: 'hub:people',
8
+ invites: 'hub:invites',
9
+ prototypes: 'hub:prototypes',
10
+ comments: (prototype) => `hub:comments:${prototype}`,
11
+ };
12
+ export function memoryKV(initial = { hashes: {}, sets: {} }, onChange) {
13
+ const hashes = new Map(Object.entries(initial.hashes).map(([k, v]) => [k, new Map(Object.entries(v))]));
14
+ const sets = new Map(Object.entries(initial.sets).map(([k, v]) => [k, new Set(v)]));
15
+ const changed = () => onChange?.({
16
+ hashes: Object.fromEntries([...hashes].map(([k, v]) => [k, Object.fromEntries(v)])),
17
+ sets: Object.fromEntries([...sets].map(([k, v]) => [k, [...v]])),
18
+ });
19
+ const hash = (key) => hashes.get(key) ?? hashes.set(key, new Map()).get(key);
20
+ return {
21
+ hget: async (key, field) => hashes.get(key)?.get(field) ?? null,
22
+ hset: async (key, field, value) => {
23
+ hash(key).set(field, value);
24
+ changed();
25
+ },
26
+ hdel: async (key, field) => {
27
+ hashes.get(key)?.delete(field);
28
+ changed();
29
+ },
30
+ hgetall: async (key) => Object.fromEntries(hashes.get(key) ?? []),
31
+ sadd: async (key, member) => {
32
+ (sets.get(key) ?? sets.set(key, new Set()).get(key)).add(member);
33
+ changed();
34
+ },
35
+ smembers: async (key) => [...(sets.get(key) ?? [])],
36
+ };
37
+ }
@@ -0,0 +1,6 @@
1
+ import type { Checks } from '../checks.ts';
2
+ import { askJev } from '../jev.ts';
3
+ import type { Triager } from './service.ts';
4
+ type Ask = typeof askJev;
5
+ export declare function jevTriager(apiKey: string, checks: Checks | null, ask?: Ask): Triager | null;
6
+ export {};
@@ -0,0 +1,45 @@
1
+ import { askJev, choiceOf } from '../jev.js';
2
+ const KIND_CRITERIA = {
3
+ bug: 'Something is broken or wrong: it does not work, shows the wrong thing, or the layout breaks.',
4
+ taste: 'A design opinion about look, feel, motion, spacing or hierarchy, where nothing is broken.',
5
+ copy: 'About the words: a typo, a label, wording or content to change.',
6
+ };
7
+ export function jevTriager(apiKey, checks, ask = askJev) {
8
+ if (!apiKey || !checks)
9
+ return null;
10
+ const { model, timeoutMs } = checks.jev;
11
+ const at = checks.triage.at;
12
+ return async ({ body, prototype, url, states }) => {
13
+ const questions = {
14
+ kind: {
15
+ type: 'choice',
16
+ instructions: 'What kind of review note is `comment`?',
17
+ criteria: KIND_CRITERIA,
18
+ },
19
+ };
20
+ if (states.length)
21
+ questions.state = {
22
+ type: 'choice',
23
+ instructions: 'Which state of the prototype is `comment` about? `url` is where the reviewer was.',
24
+ criteria: Object.fromEntries(states.map((s) => [s, null])),
25
+ };
26
+ const result = await ask(apiKey, { model, state: { comment: body, prototype, url }, questions }, { timeoutMs });
27
+ if (!result.ok)
28
+ return null;
29
+ const pick = (key) => {
30
+ const c = choiceOf(result.answers, key);
31
+ return c && at !== null && c.confidence >= at ? c : null;
32
+ };
33
+ const kind = pick('kind');
34
+ const state = pick('state');
35
+ if (!kind && !state)
36
+ return null;
37
+ const triage = {
38
+ kind: kind?.choice ?? null,
39
+ state: state?.choice ?? null,
40
+ by: 'jev',
41
+ confidence: Math.min(kind?.confidence ?? 1, state?.confidence ?? 1),
42
+ };
43
+ return triage;
44
+ };
45
+ }
@@ -0,0 +1,6 @@
1
+ export type State = {
2
+ name: string;
3
+ reach: string;
4
+ };
5
+ /** The states listed in a README, or null when it has no States section. */
6
+ export declare function parseStates(readme: string): State[] | null;
@@ -0,0 +1,29 @@
1
+ // A prototype README's `## States` table: one row per state the prototype
2
+ // holds, the first cell its name and the second how a reviewer reaches it.
3
+ // The fidelity guard, the pre-flight, comment triage and `miles-port` all
4
+ // read states through this one parser, so they never disagree on the list.
5
+ const HEADING = /^##\s+States\s*$/m;
6
+ const NEXT_HEADING = /^#{1,2}\s/m;
7
+ const SEPARATOR = /^\|\s*:?-{3,}/;
8
+ const cells = (row) => row
9
+ .trim()
10
+ .replace(/^\||\|$/g, '')
11
+ .split('|')
12
+ .map((c) => c.trim());
13
+ /** The states listed in a README, or null when it has no States section. */
14
+ export function parseStates(readme) {
15
+ const start = readme.search(HEADING);
16
+ if (start < 0)
17
+ return null;
18
+ const rest = readme.slice(start).replace(HEADING, '');
19
+ const end = rest.search(NEXT_HEADING);
20
+ const section = end < 0 ? rest : rest.slice(0, end);
21
+ const rows = section.split('\n').filter((l) => l.trim().startsWith('|'));
22
+ // The first row is the header, whatever it says; the separator follows it.
23
+ return rows
24
+ .slice(1)
25
+ .filter((r) => !SEPARATOR.test(r.trim()))
26
+ .map(cells)
27
+ .map(([name = '', reach = '']) => ({ name, reach }))
28
+ .filter((s) => s.name !== '');
29
+ }
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ import { type Args } from './lib/cli.ts';
3
+ export declare const spec: {
4
+ readonly name: 'bootstrap';
5
+ readonly summary: 'Set the project up on this machine, and optionally on Vercel. Safe to run again.';
6
+ readonly flags: {
7
+ readonly port: {
8
+ readonly type: 'number';
9
+ readonly description: 'Use this port. Refused when another process holds it or another project claims it.';
10
+ };
11
+ readonly 'no-install': {
12
+ readonly type: 'boolean';
13
+ readonly description: 'Skip pnpm install.';
14
+ };
15
+ readonly 'typesafe-key': {
16
+ readonly type: 'string';
17
+ readonly description: "Turn TypeSafe's Jev checks on with this API key.";
18
+ };
19
+ readonly conductor: {
20
+ readonly type: 'boolean';
21
+ readonly description: 'Write .conductor/settings.toml for Conductor workspaces.';
22
+ };
23
+ readonly branch: {
24
+ readonly type: 'string';
25
+ readonly default: 'main';
26
+ readonly description: 'The branch Conductor workspaces land on.';
27
+ };
28
+ readonly vercel: {
29
+ readonly type: 'string';
30
+ readonly description: 'Link this Vercel project and set the password gate env on it.';
31
+ };
32
+ readonly 'gate-password': {
33
+ readonly type: 'string';
34
+ readonly description: 'The share password (8+ characters). Given again, it rotates.';
35
+ };
36
+ readonly review: {
37
+ readonly type: 'boolean';
38
+ readonly description: 'Provision the review store (Upstash Redis) on the Vercel project.';
39
+ };
40
+ };
41
+ readonly output: '{ ok, node, port, install, typesafe, conductor?, vercel?, share?, review?, warnings }';
42
+ };
43
+ /** The gate's values: the password given, kept, or made, and the secret kept or made. */
44
+ export declare function gateValues(given: string | undefined, env: Record<string, string>): {
45
+ password: string;
46
+ secret: string;
47
+ rotated: boolean;
48
+ };
49
+ export declare function main({ flags }: Args<typeof spec>): {
50
+ warnings: string[];
51
+ };