@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,309 @@
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.ts';
8
+ import {
9
+ authorOf,
10
+ type Checked,
11
+ type Comment,
12
+ checkAnchor,
13
+ checkBody,
14
+ checkPersonInput,
15
+ checkStates,
16
+ checkTriage,
17
+ INVITE_DAYS,
18
+ isRoot,
19
+ type Person,
20
+ type Triage,
21
+ } from './model.ts';
22
+ import { type Action, can } from './policy.ts';
23
+ import { KEY, type KV } from './store.ts';
24
+
25
+ export type ErrorCode = 'unauthorized' | 'forbidden' | 'not_found' | 'invalid';
26
+
27
+ export class ReviewError extends Error {
28
+ code: ErrorCode;
29
+ problems: string[];
30
+ constructor(code: ErrorCode, message: string, problems: string[] = []) {
31
+ super(message);
32
+ this.code = code;
33
+ this.problems = problems;
34
+ }
35
+ }
36
+
37
+ export type TriageInput = { body: string; prototype: string; url: string; states: string[] };
38
+ /** Classifies a new comment; null when it cannot (TypeSafe off, slow, unsure). */
39
+ export type Triager = (input: TriageInput) => Promise<Triage | null>;
40
+
41
+ export type Deps = {
42
+ now: () => Date;
43
+ random: (bytes: number) => Uint8Array;
44
+ triage: Triager | null;
45
+ };
46
+
47
+ const DAY_MS = 86_400_000;
48
+
49
+ const defaults: Deps = {
50
+ now: () => new Date(),
51
+ random: (n) => crypto.getRandomValues(new Uint8Array(n)),
52
+ triage: null,
53
+ };
54
+
55
+ type Invite = { person: string; expiresAt: string };
56
+
57
+ export function createReview(kv: KV, overrides: Partial<Deps> = {}) {
58
+ const deps = { ...defaults, ...overrides };
59
+ const now = () => deps.now().toISOString();
60
+ // Sortable by time, unique without coordination.
61
+ const newId = () => `${deps.now().getTime().toString(36).padStart(9, '0')}${hex(deps.random(5))}`;
62
+
63
+ const parse = <T>(json: string | null): T | null => (json === null ? null : (JSON.parse(json) as T));
64
+
65
+ function allow(actor: Person | null, action: Action, subject?: Comment): asserts actor is Person {
66
+ if (!actor) throw new ReviewError('unauthorized', 'Sign in with an invite link to do that.');
67
+ if (!can(actor, action, subject)) throw new ReviewError('forbidden', 'You cannot do that.');
68
+ }
69
+
70
+ function valid<T>(checked: Checked<T>): T {
71
+ if (!checked.ok) throw new ReviewError('invalid', 'Some fields are not valid.', checked.problems);
72
+ return checked.value;
73
+ }
74
+
75
+ // Every field checked before any throws, so one save names every problem.
76
+ function validAll<A, B>(a: Checked<A>, b: Checked<B>): [A, B] {
77
+ const problems = [...(a.ok ? [] : a.problems), ...(b.ok ? [] : b.problems)];
78
+ if (!a.ok || !b.ok) throw new ReviewError('invalid', 'Some fields are not valid.', problems);
79
+ return [a.value, b.value];
80
+ }
81
+
82
+ const person = async (id: string) => parse<Person>(await kv.hget(KEY.people, id));
83
+ const savePerson = (p: Person) => kv.hset(KEY.people, p.id, JSON.stringify(p));
84
+
85
+ async function issueInvite(p: Person): Promise<string> {
86
+ // The link carries the token; the store keeps only its hash, so a leaked store signs nobody in.
87
+ const token = base64url(deps.random(32));
88
+ const invite: Invite = {
89
+ person: p.id,
90
+ expiresAt: new Date(deps.now().getTime() + INVITE_DAYS * DAY_MS).toISOString(),
91
+ };
92
+ await kv.hset(KEY.invites, hex(await sha256(token)), JSON.stringify(invite));
93
+ return token;
94
+ }
95
+
96
+ async function dropInvites(personId: string): Promise<void> {
97
+ const all = await kv.hgetall(KEY.invites);
98
+ for (const [hash, json] of Object.entries(all))
99
+ if ((JSON.parse(json) as Invite).person === personId) await kv.hdel(KEY.invites, hash);
100
+ }
101
+
102
+ async function needPerson(id: string): Promise<Person> {
103
+ const p = await person(id);
104
+ if (!p) throw new ReviewError('not_found', 'No such person.');
105
+ return p;
106
+ }
107
+
108
+ const commentsOf = async (prototype: string) =>
109
+ Object.values(await kv.hgetall(KEY.comments(prototype)))
110
+ .map((json) => JSON.parse(json) as Comment)
111
+ .sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
112
+
113
+ const saveComment = (prototype: string, c: Comment) =>
114
+ kv.hset(KEY.comments(prototype), c.id, JSON.stringify(c));
115
+
116
+ async function needComment(prototype: string, id: string): Promise<Comment> {
117
+ const c = parse<Comment>(await kv.hget(KEY.comments(prototype), id));
118
+ if (!c) throw new ReviewError('not_found', 'No such comment.');
119
+ return c;
120
+ }
121
+
122
+ async function needRoot(prototype: string, id: string): Promise<Comment> {
123
+ const c = await needComment(prototype, id);
124
+ if (!isRoot(c)) throw new ReviewError('invalid', 'That is a reply; act on its thread.');
125
+ return c;
126
+ }
127
+
128
+ return {
129
+ person,
130
+
131
+ /** The person a signed session names, if they still may sign in with it. */
132
+ async sessionPerson(id: string, epoch: number): Promise<Person | null> {
133
+ const p = await person(id);
134
+ return p && !p.revokedAt && p.epoch === epoch ? p : null;
135
+ },
136
+
137
+ async listPeople(actor: Person | null): Promise<Person[]> {
138
+ allow(actor, 'people');
139
+ const all = Object.values(await kv.hgetall(KEY.people)).map((j) => JSON.parse(j) as Person);
140
+ return all.sort((a, b) => a.team.localeCompare(b.team) || a.name.localeCompare(b.name));
141
+ },
142
+
143
+ async invite(actor: Person | null, input: unknown): Promise<{ person: Person; token: string }> {
144
+ allow(actor, 'people');
145
+ const p: Person = {
146
+ id: newId(),
147
+ ...valid(checkPersonInput(input)),
148
+ createdAt: now(),
149
+ revokedAt: null,
150
+ epoch: 0,
151
+ };
152
+ await savePerson(p);
153
+ return { person: p, token: await issueInvite(p) };
154
+ },
155
+
156
+ /** A fresh link for someone; their old link and every session they hold stop working. */
157
+ async reissue(actor: Person | null, id: string): Promise<{ person: Person; token: string }> {
158
+ allow(actor, 'people');
159
+ const p = await needPerson(id);
160
+ if (p.revokedAt) throw new ReviewError('invalid', 'That person is revoked; invite them again.');
161
+ const next = { ...p, epoch: p.epoch + 1 };
162
+ await dropInvites(id);
163
+ await savePerson(next);
164
+ return { person: next, token: await issueInvite(next) };
165
+ },
166
+
167
+ async revoke(actor: Person | null, id: string): Promise<Person> {
168
+ allow(actor, 'people');
169
+ if (actor.id === id) throw new ReviewError('invalid', 'You cannot revoke yourself.');
170
+ const p = await needPerson(id);
171
+ const next = { ...p, revokedAt: p.revokedAt ?? now(), epoch: p.epoch + 1 };
172
+ await dropInvites(id);
173
+ await savePerson(next);
174
+ return next;
175
+ },
176
+
177
+ /** The person an invite token names, while the invite is current and they are not revoked. */
178
+ async acceptInvite(token: string): Promise<Person | null> {
179
+ if (!/^[\w-]{20,100}$/.test(token)) return null;
180
+ const invite = parse<Invite>(await kv.hget(KEY.invites, hex(await sha256(token))));
181
+ if (!invite || invite.expiresAt <= now()) return null;
182
+ const p = await person(invite.person);
183
+ return p && !p.revokedAt ? p : null;
184
+ },
185
+
186
+ async listComments(actor: Person | null, prototype: string): Promise<Comment[]> {
187
+ allow(actor, 'read');
188
+ return commentsOf(prototype);
189
+ },
190
+
191
+ /** Every comment on every prototype, for the CLI, which holds the store's own credentials. */
192
+ async allComments(): Promise<Comment[]> {
193
+ const prototypes = await kv.smembers(KEY.prototypes);
194
+ return (await Promise.all(prototypes.map(commentsOf))).flat();
195
+ },
196
+
197
+ async add(
198
+ actor: Person | null,
199
+ input: { body?: unknown; anchor?: unknown; states?: unknown },
200
+ ): Promise<Comment> {
201
+ allow(actor, 'comment');
202
+ const [body, anchor] = validAll(checkBody(input.body), checkAnchor(input.anchor));
203
+ const id = newId();
204
+ const triage = deps.triage
205
+ ? await deps
206
+ .triage({ body, prototype: anchor.prototype, url: anchor.url, states: checkStates(input.states) })
207
+ // Triage is a convenience; a triager that throws never costs the comment.
208
+ .catch(() => null)
209
+ : null;
210
+ const c: Comment = {
211
+ id,
212
+ thread: id,
213
+ author: authorOf(actor),
214
+ body,
215
+ createdAt: now(),
216
+ editedAt: null,
217
+ anchor,
218
+ status: 'open',
219
+ resolvedBy: null,
220
+ triage,
221
+ };
222
+ await kv.sadd(KEY.prototypes, anchor.prototype);
223
+ await saveComment(anchor.prototype, c);
224
+ return c;
225
+ },
226
+
227
+ async reply(
228
+ actor: Person | null,
229
+ input: { prototype: string; thread: string; body?: unknown },
230
+ ): Promise<Comment> {
231
+ allow(actor, 'comment');
232
+ const root = await needRoot(input.prototype, input.thread);
233
+ const id = newId();
234
+ const c: Comment = {
235
+ id,
236
+ thread: root.id,
237
+ author: authorOf(actor),
238
+ body: valid(checkBody(input.body)),
239
+ createdAt: now(),
240
+ editedAt: null,
241
+ anchor: null,
242
+ status: 'open',
243
+ resolvedBy: null,
244
+ triage: null,
245
+ };
246
+ await saveComment(input.prototype, c);
247
+ return c;
248
+ },
249
+
250
+ async edit(
251
+ actor: Person | null,
252
+ input: { prototype: string; id: string; body?: unknown },
253
+ ): Promise<Comment> {
254
+ const c = await needComment(input.prototype, input.id);
255
+ allow(actor, 'edit', c);
256
+ const next = { ...c, body: valid(checkBody(input.body)), editedAt: now() };
257
+ await saveComment(input.prototype, next);
258
+ return next;
259
+ },
260
+
261
+ /** Deletes a comment; deleting a thread's root deletes its replies with it. */
262
+ async remove(actor: Person | null, input: { prototype: string; id: string }): Promise<string[]> {
263
+ const c = await needComment(input.prototype, input.id);
264
+ allow(actor, 'delete', c);
265
+ const gone = isRoot(c)
266
+ ? (await commentsOf(input.prototype)).filter((x) => x.thread === c.id).map((x) => x.id)
267
+ : [c.id];
268
+ for (const id of gone) await kv.hdel(KEY.comments(input.prototype), id);
269
+ return gone;
270
+ },
271
+
272
+ async resolve(
273
+ actor: Person | null,
274
+ input: { prototype: string; id: string; resolved: boolean },
275
+ ): Promise<Comment> {
276
+ const c = await needRoot(input.prototype, input.id);
277
+ allow(actor, 'resolve', c);
278
+ const next: Comment = input.resolved
279
+ ? { ...c, status: 'resolved', resolvedBy: authorOf(actor) }
280
+ : { ...c, status: 'open', resolvedBy: null };
281
+ await saveComment(input.prototype, next);
282
+ return next;
283
+ },
284
+
285
+ async retriage(
286
+ actor: Person | null,
287
+ input: { prototype: string; id: string; triage?: unknown; states?: unknown },
288
+ ): Promise<Comment> {
289
+ allow(actor, 'triage');
290
+ const c = await needRoot(input.prototype, input.id);
291
+ const states = checkStates(input.states);
292
+ const { kind, state } = valid(checkTriage(input.triage, states.length ? states : null));
293
+ const next: Comment = { ...c, triage: { kind, state, by: 'person', confidence: null } };
294
+ await saveComment(input.prototype, next);
295
+ return next;
296
+ },
297
+
298
+ /** Where a comment lives, for the CLI, which has an id and no prototype. */
299
+ async locate(id: string): Promise<{ prototype: string; comment: Comment } | null> {
300
+ for (const prototype of await kv.smembers(KEY.prototypes)) {
301
+ const c = parse<Comment>(await kv.hget(KEY.comments(prototype), id));
302
+ if (c) return { prototype, comment: c };
303
+ }
304
+ return null;
305
+ },
306
+ };
307
+ }
308
+
309
+ export type Review = ReturnType<typeof createReview>;
@@ -0,0 +1,51 @@
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
+
6
+ export interface KV {
7
+ hget(key: string, field: string): Promise<string | null>;
8
+ hset(key: string, field: string, value: string): Promise<void>;
9
+ hdel(key: string, field: string): Promise<void>;
10
+ hgetall(key: string): Promise<Record<string, string>>;
11
+ sadd(key: string, member: string): Promise<void>;
12
+ smembers(key: string): Promise<string[]>;
13
+ }
14
+
15
+ /** Every key the hub writes starts here, so a shared Redis never mixes it with another app's data. */
16
+ export const KEY = {
17
+ people: 'hub:people',
18
+ invites: 'hub:invites',
19
+ prototypes: 'hub:prototypes',
20
+ comments: (prototype: string) => `hub:comments:${prototype}`,
21
+ } as const;
22
+
23
+ export type Snapshot = { hashes: Record<string, Record<string, string>>; sets: Record<string, string[]> };
24
+
25
+ export function memoryKV(initial: Snapshot = { hashes: {}, sets: {} }, onChange?: (s: Snapshot) => void): KV {
26
+ const hashes = new Map(Object.entries(initial.hashes).map(([k, v]) => [k, new Map(Object.entries(v))]));
27
+ const sets = new Map(Object.entries(initial.sets).map(([k, v]) => [k, new Set(v)]));
28
+ const changed = () =>
29
+ onChange?.({
30
+ hashes: Object.fromEntries([...hashes].map(([k, v]) => [k, Object.fromEntries(v)])),
31
+ sets: Object.fromEntries([...sets].map(([k, v]) => [k, [...v]])),
32
+ });
33
+ const hash = (key: string) => hashes.get(key) ?? hashes.set(key, new Map()).get(key)!;
34
+ return {
35
+ hget: async (key, field) => hashes.get(key)?.get(field) ?? null,
36
+ hset: async (key, field, value) => {
37
+ hash(key).set(field, value);
38
+ changed();
39
+ },
40
+ hdel: async (key, field) => {
41
+ hashes.get(key)?.delete(field);
42
+ changed();
43
+ },
44
+ hgetall: async (key) => Object.fromEntries(hashes.get(key) ?? []),
45
+ sadd: async (key, member) => {
46
+ (sets.get(key) ?? sets.set(key, new Set()).get(key)!).add(member);
47
+ changed();
48
+ },
49
+ smembers: async (key) => [...(sets.get(key) ?? [])],
50
+ };
51
+ }
@@ -0,0 +1,41 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { DEFAULT_CHECKS } from '../checks.ts';
4
+ import type { askJev } from '../jev.ts';
5
+ import { jevTriager } from './triage.ts';
6
+
7
+ const answering = (answers: Record<string, unknown>): typeof askJev =>
8
+ (async () => ({ ok: true, model: 'm', answers, ms: 1 })) as unknown as typeof askJev;
9
+
10
+ const input = { body: 'Typo in the title', prototype: 'p', url: '/p/', states: ['Real', 'Empty'] };
11
+
12
+ test('keeps a confident kind and state', async () => {
13
+ const triage = jevTriager(
14
+ 'key',
15
+ DEFAULT_CHECKS,
16
+ answering({
17
+ kind: { type: 'choice', choice: 'copy', confidence: 0.8, probabilities: {} },
18
+ state: { type: 'choice', choice: 'Real', confidence: 0.6, probabilities: {} },
19
+ }),
20
+ );
21
+ assert.deepEqual(await triage?.(input), { kind: 'copy', state: 'Real', by: 'jev', confidence: 0.6 });
22
+ });
23
+
24
+ test('leaves an unsure field for a person', async () => {
25
+ const triage = jevTriager(
26
+ 'key',
27
+ DEFAULT_CHECKS,
28
+ answering({
29
+ kind: { type: 'choice', choice: 'bug', confidence: 0.2, probabilities: {} },
30
+ state: { type: 'choice', choice: 'Empty', confidence: 0.9, probabilities: {} },
31
+ }),
32
+ );
33
+ assert.deepEqual(await triage?.(input), { kind: null, state: 'Empty', by: 'jev', confidence: 0.9 });
34
+ });
35
+
36
+ test('is off without a key or checks, and null when Jev fails', async () => {
37
+ assert.equal(jevTriager('', DEFAULT_CHECKS), null);
38
+ assert.equal(jevTriager('key', null), null);
39
+ const failing = (async () => ({ ok: false, error: 'x', ms: 1 })) as unknown as typeof askJev;
40
+ assert.equal(await jevTriager('key', DEFAULT_CHECKS, failing)?.(input), null);
41
+ });
@@ -0,0 +1,58 @@
1
+ // Comment triage with Jev: each new comment gets a kind (bug, taste, copy)
2
+ // and the README state it is about, so feedback lands on the state it
3
+ // concerns. Two Choices in one request. Below the project's `triage.at`
4
+ // confidence a field stays null for a person to set; Jev never guesses
5
+ // loudly. TypeSafe off, slow or failing: no triage, and the comment saves.
6
+ import type { Checks } from '../checks.ts';
7
+ import { askJev, choiceOf, type Question } from '../jev.ts';
8
+ import type { Kind, Triage } from './model.ts';
9
+ import type { Triager } from './service.ts';
10
+
11
+ const KIND_CRITERIA: Record<Kind, string> = {
12
+ bug: 'Something is broken or wrong: it does not work, shows the wrong thing, or the layout breaks.',
13
+ taste: 'A design opinion about look, feel, motion, spacing or hierarchy, where nothing is broken.',
14
+ copy: 'About the words: a typo, a label, wording or content to change.',
15
+ };
16
+
17
+ type Ask = typeof askJev;
18
+
19
+ export function jevTriager(apiKey: string, checks: Checks | null, ask: Ask = askJev): Triager | null {
20
+ if (!apiKey || !checks) return null;
21
+ const { model, timeoutMs } = checks.jev;
22
+ const at = checks.triage.at;
23
+ return async ({ body, prototype, url, states }) => {
24
+ const questions: Record<string, Question> = {
25
+ kind: {
26
+ type: 'choice',
27
+ instructions: 'What kind of review note is `comment`?',
28
+ criteria: KIND_CRITERIA,
29
+ },
30
+ };
31
+ if (states.length)
32
+ questions.state = {
33
+ type: 'choice',
34
+ instructions: 'Which state of the prototype is `comment` about? `url` is where the reviewer was.',
35
+ criteria: Object.fromEntries(states.map((s) => [s, null])),
36
+ };
37
+ const result = await ask(
38
+ apiKey,
39
+ { model, state: { comment: body, prototype, url }, questions },
40
+ { timeoutMs },
41
+ );
42
+ if (!result.ok) return null;
43
+ const pick = (key: string) => {
44
+ const c = choiceOf(result.answers, key);
45
+ return c && at !== null && c.confidence >= at ? c : null;
46
+ };
47
+ const kind = pick('kind');
48
+ const state = pick('state');
49
+ if (!kind && !state) return null;
50
+ const triage: Triage = {
51
+ kind: (kind?.choice as Kind | undefined) ?? null,
52
+ state: state?.choice ?? null,
53
+ by: 'jev',
54
+ confidence: Math.min(kind?.confidence ?? 1, state?.confidence ?? 1),
55
+ };
56
+ return triage;
57
+ };
58
+ }
@@ -0,0 +1,36 @@
1
+ import assert from 'node:assert/strict';
2
+ import { test } from 'node:test';
3
+ import { parseStates } from './states.ts';
4
+
5
+ const readme = (table: string) =>
6
+ `# X\n\n## Question\n\nWhy?\n\n## States\n\nIntro line.\n\n${table}\n\n## Fixtures\n\n| real | x |\n`;
7
+
8
+ test('reads every row after the header and separator', () => {
9
+ const md = readme(
10
+ '| State | How to reach it |\n| --- | --- |\n| Real list | Controls → **real** |\n| Empty | Controls → **empty** |',
11
+ );
12
+ assert.deepEqual(parseStates(md), [
13
+ { name: 'Real list', reach: 'Controls → **real**' },
14
+ { name: 'Empty', reach: 'Controls → **empty**' },
15
+ ]);
16
+ });
17
+
18
+ test('stops at the next section, so a later table is not read as states', () => {
19
+ const md = readme('| State | How |\n|:---|---:|\n| Dark | ?theme=dark |');
20
+ assert.deepEqual(
21
+ parseStates(md)?.map((s) => s.name),
22
+ ['Dark'],
23
+ );
24
+ });
25
+
26
+ test('drops the template placeholder row', () => {
27
+ assert.deepEqual(parseStates(readme('| State | How to reach it |\n| --- | --- |\n| | |')), []);
28
+ });
29
+
30
+ test('is null without a States section', () => {
31
+ assert.equal(parseStates('# X\n\n## Question\n'), null);
32
+ });
33
+
34
+ test('reads a States section that ends the file', () => {
35
+ assert.deepEqual(parseStates('## States\n\n| State | How |\n| --- | --- |\n| Open | click |\n')?.length, 1);
36
+ });
package/core/states.ts ADDED
@@ -0,0 +1,34 @@
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
+
6
+ export type State = { name: string; reach: string };
7
+
8
+ const HEADING = /^##\s+States\s*$/m;
9
+ const NEXT_HEADING = /^#{1,2}\s/m;
10
+ const SEPARATOR = /^\|\s*:?-{3,}/;
11
+
12
+ const cells = (row: string) =>
13
+ row
14
+ .trim()
15
+ .replace(/^\||\|$/g, '')
16
+ .split('|')
17
+ .map((c) => c.trim());
18
+
19
+ /** The states listed in a README, or null when it has no States section. */
20
+ export function parseStates(readme: string): State[] | null {
21
+ const start = readme.search(HEADING);
22
+ if (start < 0) return null;
23
+ const rest = readme.slice(start).replace(HEADING, '');
24
+ const end = rest.search(NEXT_HEADING);
25
+ const section = end < 0 ? rest : rest.slice(0, end);
26
+ const rows = section.split('\n').filter((l) => l.trim().startsWith('|'));
27
+ // The first row is the header, whatever it says; the separator follows it.
28
+ return rows
29
+ .slice(1)
30
+ .filter((r) => !SEPARATOR.test(r.trim()))
31
+ .map(cells)
32
+ .map(([name = '', reach = '']) => ({ name, reach }))
33
+ .filter((s) => s.name !== '');
34
+ }
@@ -0,0 +1,5 @@
1
+ export declare function sha256(text: string): Promise<Uint8Array>;
2
+ export declare const hex: (bytes: Uint8Array) => string;
3
+ export declare const base64: (bytes: Uint8Array) => string;
4
+ /** URL-safe base64 without padding: cookies, tokens and links carry it unescaped. */
5
+ export declare const base64url: (bytes: Uint8Array) => string;
@@ -0,0 +1,9 @@
1
+ // Bytes to text, and SHA-256, on Web Crypto only, so the same helpers serve
2
+ // the browser, Node and Vercel's runtime.
3
+ export async function sha256(text) {
4
+ return new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text)));
5
+ }
6
+ export const hex = (bytes) => Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
7
+ export const base64 = (bytes) => btoa(String.fromCharCode(...bytes));
8
+ /** URL-safe base64 without padding: cookies, tokens and links carry it unescaped. */
9
+ export const base64url = (bytes) => base64(bytes).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
@@ -0,0 +1,17 @@
1
+ import { type State } from './states.ts';
2
+ export type Kind = 'sprint' | 'alternate' | 'exploration';
3
+ export type Meta = {
4
+ slug: string;
5
+ title: string;
6
+ summary: string;
7
+ context: string;
8
+ status: string;
9
+ kind: Kind;
10
+ feature: string;
11
+ created: string;
12
+ /** null when the README has no States section. */
13
+ states: State[] | null;
14
+ };
15
+ export declare function readMeta(slug: string, readme: string): Meta;
16
+ export declare const isLive: (m: Pick<Meta, 'status'>) => boolean;
17
+ export declare const byIndexOrder: (a: Meta, b: Meta) => number;
@@ -0,0 +1,24 @@
1
+ // What a prototype's README says about it: the frontmatter the index lists
2
+ // and the States table the checks read. One reader for the browser index
3
+ // (shell/registry.ts) and the Node scripts (scripts/lib/catalog.ts), so
4
+ // they never disagree about what a prototype is.
5
+ import { frontmatter } from './frontmatter.js';
6
+ import { parseStates } from './states.js';
7
+ export function readMeta(slug, readme) {
8
+ const { data } = frontmatter(readme);
9
+ return {
10
+ slug,
11
+ title: data.title || slug,
12
+ summary: data.summary || '',
13
+ context: data.context || 'space',
14
+ status: data.status || 'exploring',
15
+ kind: data.kind === 'sprint' || data.kind === 'alternate' ? data.kind : 'exploration',
16
+ feature: data.feature || '',
17
+ created: data.created || '',
18
+ states: parseStates(readme),
19
+ };
20
+ }
21
+ export const isLive = (m) => m.status !== 'archived';
22
+ // Open questions sit above answered ones; within a status, newest first.
23
+ const rank = (status) => (status === 'exploring' ? 0 : 1);
24
+ export const byIndexOrder = (a, b) => rank(a.status) - rank(b.status) || b.created.localeCompare(a.created) || a.title.localeCompare(b.title);
@@ -0,0 +1,41 @@
1
+ /** A probability threshold, or null for "never". */
2
+ type At = number | null;
3
+ export type Checks = {
4
+ jev: {
5
+ model: string;
6
+ timeoutMs: number;
7
+ };
8
+ fidelity: {
9
+ warnAt: At;
10
+ blockAt: At;
11
+ };
12
+ states: {
13
+ blockAt: At;
14
+ };
15
+ contacts: {
16
+ privateAt: At;
17
+ allow: string[];
18
+ };
19
+ skillHint: {
20
+ gateAt: At;
21
+ fitsAt: At;
22
+ };
23
+ triage: {
24
+ at: At;
25
+ };
26
+ visual: {
27
+ warnAt: At;
28
+ };
29
+ };
30
+ export declare const DEFAULT_CHECKS: Checks;
31
+ /** Every problem with a checks object, by path; empty when it is valid. */
32
+ export declare function validateChecks(value: unknown): string[];
33
+ export type Loaded = {
34
+ checks: Checks | null;
35
+ problems: string[];
36
+ };
37
+ /** The checks a raw value yields: the value when it validates, else null with the problems. */
38
+ export declare function loadChecks(raw: unknown): Loaded;
39
+ /** True when p reaches the threshold; a null threshold never does. */
40
+ export declare const reaches: (p: number, at: At) => boolean;
41
+ export {};