@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.
- package/LICENSE +21 -0
- package/README.md +80 -2
- package/client.d.ts +6 -0
- package/configs/biome/biome.json +64 -0
- package/configs/tsconfig.project.json +17 -0
- package/core/bytes.test.ts +14 -0
- package/core/bytes.ts +14 -0
- package/core/catalog.test.ts +44 -0
- package/core/catalog.ts +43 -0
- package/core/checks.test.ts +23 -0
- package/core/checks.ts +72 -0
- package/core/contacts.test.ts +31 -0
- package/core/contacts.ts +38 -0
- package/core/doc.ts +4 -0
- package/core/feature.ts +6 -0
- package/core/fixtures.test.ts +33 -0
- package/core/fixtures.ts +69 -0
- package/core/frontmatter.ts +15 -0
- package/core/html.ts +3 -0
- package/core/image.test.ts +22 -0
- package/core/image.ts +37 -0
- package/core/jev.test.ts +66 -0
- package/core/jev.ts +77 -0
- package/core/review/model.ts +189 -0
- package/core/review/policy.ts +26 -0
- package/core/review/service.test.ts +163 -0
- package/core/review/service.ts +309 -0
- package/core/review/store.ts +51 -0
- package/core/review/triage.test.ts +41 -0
- package/core/review/triage.ts +58 -0
- package/core/states.test.ts +36 -0
- package/core/states.ts +34 -0
- package/dist/core/bytes.d.ts +5 -0
- package/dist/core/bytes.js +9 -0
- package/dist/core/catalog.d.ts +17 -0
- package/dist/core/catalog.js +24 -0
- package/dist/core/checks.d.ts +41 -0
- package/dist/core/checks.js +53 -0
- package/dist/core/contacts.d.ts +9 -0
- package/dist/core/contacts.js +34 -0
- package/dist/core/doc.d.ts +11 -0
- package/dist/core/doc.js +1 -0
- package/dist/core/feature.d.ts +3 -0
- package/dist/core/feature.js +4 -0
- package/dist/core/fixtures.d.ts +19 -0
- package/dist/core/fixtures.js +52 -0
- package/dist/core/frontmatter.d.ts +4 -0
- package/dist/core/frontmatter.js +15 -0
- package/dist/core/html.d.ts +2 -0
- package/dist/core/html.js +2 -0
- package/dist/core/image.d.ts +6 -0
- package/dist/core/image.js +34 -0
- package/dist/core/jev.d.ts +54 -0
- package/dist/core/jev.js +52 -0
- package/dist/core/review/model.d.ts +85 -0
- package/dist/core/review/model.js +112 -0
- package/dist/core/review/policy.d.ts +3 -0
- package/dist/core/review/policy.js +19 -0
- package/dist/core/review/service.d.ts +79 -0
- package/dist/core/review/service.js +245 -0
- package/dist/core/review/store.d.ts +20 -0
- package/dist/core/review/store.js +37 -0
- package/dist/core/review/triage.d.ts +6 -0
- package/dist/core/review/triage.js +45 -0
- package/dist/core/states.d.ts +6 -0
- package/dist/core/states.js +29 -0
- package/dist/scripts/bootstrap.d.ts +51 -0
- package/dist/scripts/bootstrap.js +278 -0
- package/dist/scripts/calibrate.d.ts +127 -0
- package/dist/scripts/calibrate.js +164 -0
- package/dist/scripts/check.d.ts +25 -0
- package/dist/scripts/check.js +30 -0
- package/dist/scripts/cli.d.ts +2 -0
- package/dist/scripts/cli.js +73 -0
- package/dist/scripts/commands.d.ts +25 -0
- package/dist/scripts/commands.js +49 -0
- package/dist/scripts/comments.d.ts +63 -0
- package/dist/scripts/comments.js +152 -0
- package/dist/scripts/dev.d.ts +46 -0
- package/dist/scripts/dev.js +84 -0
- package/dist/scripts/elyx.d.ts +61 -0
- package/dist/scripts/elyx.js +195 -0
- package/dist/scripts/fixtures-pull.d.ts +60 -0
- package/dist/scripts/fixtures-pull.js +121 -0
- package/dist/scripts/guard.d.ts +58 -0
- package/dist/scripts/guard.js +201 -0
- package/dist/scripts/hook.d.ts +18 -0
- package/dist/scripts/hook.js +46 -0
- package/dist/scripts/lib/bin.d.ts +4 -0
- package/dist/scripts/lib/bin.js +20 -0
- package/dist/scripts/lib/catalog.d.ts +7 -0
- package/dist/scripts/lib/catalog.js +18 -0
- package/dist/scripts/lib/cli.d.ts +75 -0
- package/dist/scripts/lib/cli.js +88 -0
- package/dist/scripts/lib/css-tokens.d.ts +13 -0
- package/dist/scripts/lib/css-tokens.js +67 -0
- package/dist/scripts/lib/judge.d.ts +24 -0
- package/dist/scripts/lib/judge.js +60 -0
- package/dist/scripts/lib/ports.d.ts +35 -0
- package/dist/scripts/lib/ports.js +113 -0
- package/dist/scripts/lib/review.d.ts +7 -0
- package/dist/scripts/lib/review.js +22 -0
- package/dist/scripts/lib/rules.d.ts +12 -0
- package/dist/scripts/lib/rules.js +20 -0
- package/dist/scripts/lib/vercel.d.ts +17 -0
- package/dist/scripts/lib/vercel.js +53 -0
- package/dist/scripts/new.d.ts +47 -0
- package/dist/scripts/new.js +113 -0
- package/dist/scripts/people.d.ts +59 -0
- package/dist/scripts/people.js +73 -0
- package/dist/scripts/preflight.d.ts +35 -0
- package/dist/scripts/preflight.js +251 -0
- package/dist/scripts/release-check.d.ts +19 -0
- package/dist/scripts/release-check.js +39 -0
- package/dist/scripts/skill-hint.d.ts +32 -0
- package/dist/scripts/skill-hint.js +103 -0
- package/dist/scripts/skills.d.ts +13 -0
- package/dist/scripts/skills.js +34 -0
- package/dist/scripts/visual-match.d.ts +46 -0
- package/dist/scripts/visual-match.js +93 -0
- package/dist/server/api.d.ts +22 -0
- package/dist/server/api.js +143 -0
- package/dist/server/checks.d.ts +4 -0
- package/dist/server/checks.js +22 -0
- package/dist/server/dev.d.ts +2 -0
- package/dist/server/dev.js +54 -0
- package/dist/server/env.d.ts +8 -0
- package/dist/server/env.js +57 -0
- package/dist/server/fake-upstash.d.ts +1 -0
- package/dist/server/fake-upstash.js +37 -0
- package/dist/server/file-kv.d.ts +2 -0
- package/dist/server/file-kv.js +24 -0
- package/dist/server/local.d.ts +4 -0
- package/dist/server/local.js +25 -0
- package/dist/server/paths.d.ts +22 -0
- package/dist/server/paths.js +81 -0
- package/dist/server/session.d.ts +22 -0
- package/dist/server/session.js +110 -0
- package/dist/server/upstash.d.ts +7 -0
- package/dist/server/upstash.js +41 -0
- package/dist/vercel/index.d.ts +5 -0
- package/dist/vercel/index.js +12 -0
- package/dist/vercel/proxy.d.ts +11 -0
- package/dist/vercel/proxy.js +238 -0
- package/dist/vite/kit.d.ts +4 -0
- package/dist/vite/kit.js +35 -0
- package/dist/vite/markdown.d.ts +2 -0
- package/dist/vite/markdown.js +212 -0
- package/dist/vite/plugin.d.ts +15 -0
- package/dist/vite/plugin.js +84 -0
- package/dist/vite/registry.d.ts +7 -0
- package/dist/vite/registry.js +70 -0
- package/kit/Icon.tsx +39 -0
- package/kit/Space.tsx +25 -0
- package/kit/Variants.tsx +134 -0
- package/kit/ViewTools.tsx +34 -0
- package/kit/base.css +27 -0
- package/kit/dials.css +98 -0
- package/kit/dials.tsx +322 -0
- package/kit/fixtures.ts +3 -0
- package/kit/format.ts +13 -0
- package/kit/harness.css +99 -0
- package/kit/hooks.ts +35 -0
- package/kit/icons.ts +329 -0
- package/kit/motion.ts +18 -0
- package/kit/prefs.ts +111 -0
- package/kit/space.css +12 -0
- package/kit/ui/Button.tsx +135 -0
- package/kit/ui/Menu.tsx +386 -0
- package/kit/ui/Overlay.tsx +300 -0
- package/kit/ui/Toast.tsx +187 -0
- package/kit/ui/index.ts +13 -0
- package/kit/ui/layer.ts +51 -0
- package/kit/ui/ui.css +238 -0
- package/package.json +87 -3
- package/shell/Guide.tsx +160 -0
- package/shell/Index.tsx +301 -0
- package/shell/Notice.tsx +27 -0
- package/shell/Prototype.tsx +31 -0
- package/shell/a11y.ts +50 -0
- package/shell/doc.d.ts +7 -0
- package/shell/doc.ts +3 -0
- package/shell/guide.css +133 -0
- package/shell/index.html +17 -0
- package/shell/main.tsx +53 -0
- package/shell/public/favicon.svg +1 -0
- package/shell/registry.ts +21 -0
- package/shell/registry.virtual.d.ts +10 -0
- package/shell/review/Review.tsx +989 -0
- package/shell/review/anchor.ts +132 -0
- package/shell/review/client.ts +165 -0
- package/shell/review/mount.tsx +12 -0
- package/shell/review/review.css +296 -0
- package/shell/shell.css +160 -0
- package/shell/system.css +238 -0
- package/skills/miles-conductor/SKILL.md +37 -0
- package/skills/miles-deploy/SKILL.md +67 -0
- package/skills/miles-design-hub/SKILL.md +62 -0
- package/skills/miles-get-hostile/SKILL.md +61 -0
- package/skills/miles-new-feature/SKILL.md +40 -0
- package/skills/miles-paper-to-prototype/SKILL.md +46 -0
- package/skills/miles-port/SKILL.md +59 -0
- package/skills/miles-port/references/astro.md +35 -0
- package/skills/miles-port/references/next.md +36 -0
- package/skills/miles-prototype/SKILL.md +83 -0
- package/skills/miles-prototype/references/accessibility.md +48 -0
- package/skills/miles-prototype/references/motion.md +59 -0
- package/skills/miles-prototype/references/performance.md +45 -0
- package/skills/miles-review/SKILL.md +39 -0
- package/templates/app/README.md +41 -0
- package/templates/app/index.tsx +32 -0
- package/templates/app/styles.css +1 -0
- package/templates/conductor/settings.toml +29 -0
- package/templates/guide.md +38 -0
- package/templates/space/README.md +37 -0
- package/templates/space/index.tsx +43 -0
- package/templates/space/styles.css +13 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { imageSize } from './image.ts';
|
|
4
|
+
|
|
5
|
+
test('reads a PNG header', () => {
|
|
6
|
+
const png = new Uint8Array(33);
|
|
7
|
+
png.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
|
|
8
|
+
png.set([0, 0, 3, 0x20, 0, 0, 2, 0x58], 16);
|
|
9
|
+
assert.deepEqual(imageSize(png), { width: 800, height: 600, format: 'png' });
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
test('reads a JPEG frame header past a segment, a restart marker and a fill byte', () => {
|
|
13
|
+
const app0 = [0xff, 0xe0, 0x00, 0x04, 0x00, 0x00];
|
|
14
|
+
const restart = [0xff, 0xd3];
|
|
15
|
+
const sof0 = [0xff, 0xc0, 0x00, 0x11, 0x08, 0x02, 0x58, 0x03, 0x20, 0x03, 0, 0, 0, 0, 0, 0, 0, 0, 0];
|
|
16
|
+
const jpg = new Uint8Array([0xff, 0xd8, ...app0, ...restart, 0xff, ...sof0]);
|
|
17
|
+
assert.deepEqual(imageSize(jpg), { width: 800, height: 600, format: 'jpg' });
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test('is null for anything else', () => {
|
|
21
|
+
assert.equal(imageSize(new TextEncoder().encode('GIF89a........................')), null);
|
|
22
|
+
});
|
package/core/image.ts
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// Pixel size from an image's header, without decoding it: PNG from its IHDR
|
|
2
|
+
// chunk, JPEG from its first frame header. The walkthrough compiler sizes
|
|
3
|
+
// figures with it, and the visual match checks TypeSafe's image limits.
|
|
4
|
+
|
|
5
|
+
export type ImageSize = { width: number; height: number; format: 'png' | 'jpg' };
|
|
6
|
+
|
|
7
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
8
|
+
|
|
9
|
+
const u16 = (b: Uint8Array, i: number) => (b[i] << 8) | b[i + 1];
|
|
10
|
+
const u32 = (b: Uint8Array, i: number) =>
|
|
11
|
+
((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
|
12
|
+
|
|
13
|
+
function pngSize(b: Uint8Array): ImageSize | null {
|
|
14
|
+
if (b.length < 24 || PNG_SIGNATURE.some((v, i) => b[i] !== v)) return null;
|
|
15
|
+
return { width: u32(b, 16), height: u32(b, 20), format: 'png' };
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Markers with no length field: start of image, TEM, and the restart markers.
|
|
19
|
+
const standalone = (m: number) => m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7);
|
|
20
|
+
// SOF0 to SOF15 carry the frame size, except DHT (C4), JPG (C8) and DAC (CC).
|
|
21
|
+
const frame = (m: number) => m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc;
|
|
22
|
+
|
|
23
|
+
function jpegSize(b: Uint8Array): ImageSize | null {
|
|
24
|
+
if (b[0] !== 0xff || b[1] !== 0xd8) return null;
|
|
25
|
+
let i = 2;
|
|
26
|
+
while (i + 9 < b.length) {
|
|
27
|
+
const marker = b[i + 1];
|
|
28
|
+
// A byte that is not a marker, or 0xFF fill before one, moves on by one.
|
|
29
|
+
if (b[i] !== 0xff || marker === 0xff) i += 1;
|
|
30
|
+
else if (standalone(marker)) i += 2;
|
|
31
|
+
else if (frame(marker)) return { height: u16(b, i + 5), width: u16(b, i + 7), format: 'jpg' };
|
|
32
|
+
else i += 2 + u16(b, i + 2);
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const imageSize = (bytes: Uint8Array): ImageSize | null => pngSize(bytes) ?? jpegSize(bytes);
|
package/core/jev.test.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { test } from 'node:test';
|
|
3
|
+
import { askJev, choiceOf, noulOf } from './jev.ts';
|
|
4
|
+
|
|
5
|
+
const request = {
|
|
6
|
+
model: 'jev-1.14.0',
|
|
7
|
+
state: { a: 1 },
|
|
8
|
+
questions: { q: { type: 'noul', instructions: 'Is a 1?' } },
|
|
9
|
+
} as const;
|
|
10
|
+
const reply = (status: number, body: unknown, headers: Record<string, string> = {}) =>
|
|
11
|
+
(async () => new Response(JSON.stringify(body), { status, headers })) as unknown as typeof fetch;
|
|
12
|
+
|
|
13
|
+
test('returns the answers and the model that gave them', async () => {
|
|
14
|
+
const r = await askJev('k', request, {
|
|
15
|
+
timeoutMs: 1000,
|
|
16
|
+
fetch: reply(200, { model: 'jev-1.14.0', answers: { q: { type: 'noul', noul: 0.9 } } }),
|
|
17
|
+
});
|
|
18
|
+
assert.ok(r.ok);
|
|
19
|
+
assert.equal(noulOf(r.answers, 'q'), 0.9);
|
|
20
|
+
assert.equal(r.model, 'jev-1.14.0');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('fails open on an HTTP error, naming the status and request id', async () => {
|
|
24
|
+
const r = await askJev('bad', request, {
|
|
25
|
+
timeoutMs: 1000,
|
|
26
|
+
fetch: reply(401, { detail: 'nope' }, { 'x-typesafe-request-id': 'req_1' }),
|
|
27
|
+
});
|
|
28
|
+
assert.equal(r.ok, false);
|
|
29
|
+
assert.match(!r.ok ? r.error : '', /^401 \(req_1\)/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('fails open on a timeout', async () => {
|
|
33
|
+
const hang = ((_: unknown, init: RequestInit) =>
|
|
34
|
+
new Promise((_, reject) =>
|
|
35
|
+
init.signal?.addEventListener('abort', () => reject(init.signal?.reason)),
|
|
36
|
+
)) as typeof fetch;
|
|
37
|
+
const r = await askJev('k', request, { timeoutMs: 20, fetch: hang });
|
|
38
|
+
assert.deepEqual(r.ok ? null : r.error, 'timeout after 20ms');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test('sends the images header only when there are assets', async () => {
|
|
42
|
+
let sent: Headers | undefined;
|
|
43
|
+
const capture = (async (_: unknown, init: RequestInit) => {
|
|
44
|
+
sent = new Headers(init.headers);
|
|
45
|
+
return new Response(JSON.stringify({ answers: {} }));
|
|
46
|
+
}) as typeof fetch;
|
|
47
|
+
await askJev('k', request, { timeoutMs: 1000, fetch: capture });
|
|
48
|
+
assert.equal(sent?.get('typesafe-danger-zone-images'), null);
|
|
49
|
+
await askJev(
|
|
50
|
+
'k',
|
|
51
|
+
{ ...request, assets: { img: { type: 'image', format: 'png', data_base64: 'AA==' } } },
|
|
52
|
+
{
|
|
53
|
+
timeoutMs: 1000,
|
|
54
|
+
fetch: capture,
|
|
55
|
+
},
|
|
56
|
+
);
|
|
57
|
+
assert.equal(sent?.get('typesafe-danger-zone-images'), 'true');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('reads answers by type and ignores the wrong type', () => {
|
|
61
|
+
const answers = {
|
|
62
|
+
c: { type: 'choice', choice: 'bug', confidence: 0.5, probabilities: { bug: 0.7 } },
|
|
63
|
+
} as const;
|
|
64
|
+
assert.equal(choiceOf(answers, 'c')?.choice, 'bug');
|
|
65
|
+
assert.equal(noulOf(answers, 'c'), null);
|
|
66
|
+
});
|
package/core/jev.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// One client for TypeSafe's Jev (System One), on plain fetch, so the same
|
|
2
|
+
// code runs in the scripts and in the Vercel server code with no SDK to
|
|
3
|
+
// install. API: https://docs.typesafe.ai/api/system-one.md
|
|
4
|
+
//
|
|
5
|
+
// It never throws and never retries. Every caller treats a failure the same
|
|
6
|
+
// way, fail open: a check is never the reason the user is blocked, and a
|
|
7
|
+
// retry would only spend the latency budget the caller set.
|
|
8
|
+
|
|
9
|
+
const JEV_URL = 'https://api.typesafe.ai/v1/systemone';
|
|
10
|
+
|
|
11
|
+
export type Question =
|
|
12
|
+
| { type: 'noul'; instructions: string }
|
|
13
|
+
| { type: 'choice'; instructions: string; criteria: Record<string, string | null> };
|
|
14
|
+
|
|
15
|
+
export type Answer =
|
|
16
|
+
| { type: 'noul'; noul: number }
|
|
17
|
+
| { type: 'choice'; choice: string; confidence: number; probabilities: Record<string, number> };
|
|
18
|
+
|
|
19
|
+
/** An image the state or a question refers to by its key. Experimental on TypeSafe's side. */
|
|
20
|
+
export type ImageAsset = { type: 'image'; format: 'png' | 'jpg' | 'jpeg'; data_base64: string };
|
|
21
|
+
|
|
22
|
+
export type JevRequest = {
|
|
23
|
+
model: string;
|
|
24
|
+
state: unknown;
|
|
25
|
+
questions: Record<string, Question>;
|
|
26
|
+
assets?: Record<string, ImageAsset>;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export type JevResult =
|
|
30
|
+
| { ok: true; model: string; answers: Record<string, Answer>; ms: number }
|
|
31
|
+
| { ok: false; error: string; ms: number };
|
|
32
|
+
|
|
33
|
+
type Options = { timeoutMs: number; fetch?: typeof fetch };
|
|
34
|
+
|
|
35
|
+
export async function askJev(apiKey: string, request: JevRequest, options: Options): Promise<JevResult> {
|
|
36
|
+
const started = Date.now();
|
|
37
|
+
const ms = () => Date.now() - started;
|
|
38
|
+
const headers: Record<string, string> = {
|
|
39
|
+
authorization: `Bearer ${apiKey}`,
|
|
40
|
+
'content-type': 'application/json',
|
|
41
|
+
accept: 'application/json',
|
|
42
|
+
};
|
|
43
|
+
// Images sit behind TypeSafe's "danger zone" header; without it the request is a 400.
|
|
44
|
+
if (request.assets && Object.keys(request.assets).length) headers['typesafe-danger-zone-images'] = 'true';
|
|
45
|
+
try {
|
|
46
|
+
const response = await (options.fetch ?? fetch)(JEV_URL, {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
headers,
|
|
49
|
+
body: JSON.stringify(request),
|
|
50
|
+
signal: AbortSignal.timeout(options.timeoutMs),
|
|
51
|
+
});
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
const id = response.headers.get('x-typesafe-request-id');
|
|
54
|
+
const body = (await response.text()).slice(0, 300);
|
|
55
|
+
return { ok: false, error: `${response.status}${id ? ` (${id})` : ''}: ${body}`, ms: ms() };
|
|
56
|
+
}
|
|
57
|
+
const json = (await response.json()) as { model?: string; answers?: Record<string, Answer> };
|
|
58
|
+
if (!json.answers) return { ok: false, error: 'response had no answers', ms: ms() };
|
|
59
|
+
return { ok: true, model: json.model ?? request.model, answers: json.answers, ms: ms() };
|
|
60
|
+
} catch (error) {
|
|
61
|
+
const name = error instanceof Error ? error.name : '';
|
|
62
|
+
const error_ = name === 'TimeoutError' ? `timeout after ${options.timeoutMs}ms` : String(error);
|
|
63
|
+
return { ok: false, error: error_, ms: ms() };
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The yes-probability of a Noul answer, or null when it is missing or another type. */
|
|
68
|
+
export const noulOf = (answers: Record<string, Answer>, key: string): number | null => {
|
|
69
|
+
const a = answers[key];
|
|
70
|
+
return a?.type === 'noul' && typeof a.noul === 'number' ? a.noul : null;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/** A Choice answer, or null when it is missing or another type. */
|
|
74
|
+
export const choiceOf = (answers: Record<string, Answer>, key: string) => {
|
|
75
|
+
const a = answers[key];
|
|
76
|
+
return a?.type === 'choice' ? a : null;
|
|
77
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
// The review domain: people who may comment on the deployed prototypes, and
|
|
2
|
+
// the comments they leave, pinned to a prototype, its state and an element.
|
|
3
|
+
// Pure: no I/O, no environment. Every limit and rule lives here once and is
|
|
4
|
+
// enforced by the service, whichever adapter (Vercel, Vite dev, the CLI)
|
|
5
|
+
// calls it.
|
|
6
|
+
|
|
7
|
+
import { SLUG } from '../feature.ts';
|
|
8
|
+
|
|
9
|
+
export const ROLES = ['editor', 'reviewer'] as const;
|
|
10
|
+
export type Role = (typeof ROLES)[number];
|
|
11
|
+
|
|
12
|
+
/** How a comment is triaged: something broken, a design opinion, or the words. */
|
|
13
|
+
export const KINDS = ['bug', 'taste', 'copy'] as const;
|
|
14
|
+
export type Kind = (typeof KINDS)[number];
|
|
15
|
+
|
|
16
|
+
/** How long an invite link signs its person in before an editor must issue a new one. */
|
|
17
|
+
export const INVITE_DAYS = 14;
|
|
18
|
+
|
|
19
|
+
export const LIMITS = {
|
|
20
|
+
name: 80,
|
|
21
|
+
team: 80,
|
|
22
|
+
body: 4000,
|
|
23
|
+
url: 2000,
|
|
24
|
+
target: 500,
|
|
25
|
+
dialsChars: 8000,
|
|
26
|
+
states: 50,
|
|
27
|
+
stateName: 120,
|
|
28
|
+
} as const;
|
|
29
|
+
|
|
30
|
+
export type Person = {
|
|
31
|
+
id: string;
|
|
32
|
+
name: string;
|
|
33
|
+
/** The group a person belongs to, shown beside their name: the studio, or the client's company. */
|
|
34
|
+
team: string;
|
|
35
|
+
role: Role;
|
|
36
|
+
createdAt: string;
|
|
37
|
+
revokedAt: string | null;
|
|
38
|
+
/** Bumped on revoke and on a new invite, which signs out every session signed with the old one. */
|
|
39
|
+
epoch: number;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/** A comment's author as it was when they wrote it, so a revoked person's comments still read. */
|
|
43
|
+
export type Author = Pick<Person, 'id' | 'name' | 'team'>;
|
|
44
|
+
|
|
45
|
+
export type Anchor = {
|
|
46
|
+
prototype: string;
|
|
47
|
+
feature: string;
|
|
48
|
+
/** Path, query and hash when the comment was written: reopening it reopens the state. */
|
|
49
|
+
url: string;
|
|
50
|
+
/** Every dial panel's values by panel name, so the state includes what the dials were set to. */
|
|
51
|
+
dials: Record<string, Record<string, unknown>>;
|
|
52
|
+
/** A CSS selector for the element the pin sits on, or null for the page as a whole. */
|
|
53
|
+
target: string | null;
|
|
54
|
+
/** Where on the target, as fractions of its box. */
|
|
55
|
+
offset: { x: number; y: number } | null;
|
|
56
|
+
viewport: { width: number; height: number };
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
export type Triage = {
|
|
60
|
+
kind: Kind | null;
|
|
61
|
+
/** A row of the prototype README's States table. */
|
|
62
|
+
state: string | null;
|
|
63
|
+
by: 'jev' | 'person';
|
|
64
|
+
confidence: number | null;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type Comment = {
|
|
68
|
+
id: string;
|
|
69
|
+
/** The root comment's id; a root's thread is its own id. */
|
|
70
|
+
thread: string;
|
|
71
|
+
author: Author;
|
|
72
|
+
body: string;
|
|
73
|
+
createdAt: string;
|
|
74
|
+
editedAt: string | null;
|
|
75
|
+
/** Roots only. */
|
|
76
|
+
anchor: Anchor | null;
|
|
77
|
+
status: 'open' | 'resolved';
|
|
78
|
+
resolvedBy: Author | null;
|
|
79
|
+
triage: Triage | null;
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
export const authorOf = (p: Person): Author => ({ id: p.id, name: p.name, team: p.team });
|
|
83
|
+
export const isRoot = (c: Comment) => c.thread === c.id;
|
|
84
|
+
|
|
85
|
+
// Every input from outside goes through one of these checks.
|
|
86
|
+
|
|
87
|
+
export type Checked<T> = { ok: true; value: T } | { ok: false; problems: string[] };
|
|
88
|
+
|
|
89
|
+
const text = (v: unknown, path: string, max: number, problems: string[]): string => {
|
|
90
|
+
if (typeof v !== 'string' || !v.trim()) {
|
|
91
|
+
problems.push(`${path}: required`);
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
const s = v.trim();
|
|
95
|
+
if (s.length > max) problems.push(`${path}: ${max} characters at most`);
|
|
96
|
+
return s;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const fraction = (v: unknown) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1;
|
|
100
|
+
const size = (v: unknown) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v < 100_000;
|
|
101
|
+
|
|
102
|
+
export function checkPersonInput(input: unknown): Checked<Pick<Person, 'name' | 'team' | 'role'>> {
|
|
103
|
+
const i = (input ?? {}) as Record<string, unknown>;
|
|
104
|
+
const problems: string[] = [];
|
|
105
|
+
const name = text(i.name, 'name', LIMITS.name, problems);
|
|
106
|
+
const team = text(i.team, 'team', LIMITS.team, problems);
|
|
107
|
+
const role = i.role ?? 'reviewer';
|
|
108
|
+
if (!ROLES.includes(role as Role)) problems.push(`role: one of ${ROLES.join(', ')}`);
|
|
109
|
+
return problems.length ? { ok: false, problems } : { ok: true, value: { name, team, role: role as Role } };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function checkBody(body: unknown): Checked<string> {
|
|
113
|
+
const problems: string[] = [];
|
|
114
|
+
const value = text(body, 'body', LIMITS.body, problems);
|
|
115
|
+
return problems.length ? { ok: false, problems } : { ok: true, value };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const isSlug = (v: unknown) => typeof v === 'string' && SLUG.test(v);
|
|
119
|
+
const isObject = (v: unknown): v is Record<string, unknown> =>
|
|
120
|
+
!!v && typeof v === 'object' && !Array.isArray(v);
|
|
121
|
+
|
|
122
|
+
/** One rule per field: the field, whether a value passes, and what the field must be. */
|
|
123
|
+
const ANCHOR_RULES: [keyof Anchor, (v: unknown) => boolean, string][] = [
|
|
124
|
+
['prototype', isSlug, 'a prototype slug'],
|
|
125
|
+
['feature', (v) => v === '' || isSlug(v), 'a feature slug or empty'],
|
|
126
|
+
[
|
|
127
|
+
'url',
|
|
128
|
+
(v) => typeof v === 'string' && v.startsWith('/') && v.length <= LIMITS.url,
|
|
129
|
+
`a path starting with /, ${LIMITS.url} characters at most`,
|
|
130
|
+
],
|
|
131
|
+
[
|
|
132
|
+
'dials',
|
|
133
|
+
(v) => isObject(v) && JSON.stringify(v).length <= LIMITS.dialsChars,
|
|
134
|
+
`an object of panels, ${LIMITS.dialsChars} characters at most`,
|
|
135
|
+
],
|
|
136
|
+
[
|
|
137
|
+
'target',
|
|
138
|
+
(v) => v === null || (typeof v === 'string' && v.length <= LIMITS.target),
|
|
139
|
+
`a selector, ${LIMITS.target} characters at most, or null`,
|
|
140
|
+
],
|
|
141
|
+
[
|
|
142
|
+
'offset',
|
|
143
|
+
(v) => v === null || (isObject(v) && fraction(v.x) && fraction(v.y)),
|
|
144
|
+
'x and y from 0 to 1, or null',
|
|
145
|
+
],
|
|
146
|
+
['viewport', (v) => isObject(v) && size(v.width) && size(v.height), 'width and height in pixels'],
|
|
147
|
+
];
|
|
148
|
+
|
|
149
|
+
export function checkAnchor(input: unknown): Checked<Anchor> {
|
|
150
|
+
const given = isObject(input) ? input : {};
|
|
151
|
+
// Absent optional fields take their empty form before the rules run.
|
|
152
|
+
const a: Record<string, unknown> = { dials: {}, target: null, offset: null, ...given };
|
|
153
|
+
const problems = ANCHOR_RULES.filter(([key, passes]) => !passes(a[key])).map(
|
|
154
|
+
([key, , must]) => `anchor.${key}: ${must}`,
|
|
155
|
+
);
|
|
156
|
+
if (problems.length) return { ok: false, problems };
|
|
157
|
+
// Every rule passed, so each field holds its Anchor type.
|
|
158
|
+
const { prototype, feature, url, dials, target, offset, viewport } = a as unknown as Anchor;
|
|
159
|
+
return { ok: true, value: { prototype, feature, url, dials, target, offset, viewport } };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** The README states a client offers for triage: trimmed, capped, de-duplicated. */
|
|
163
|
+
export function checkStates(input: unknown): string[] {
|
|
164
|
+
if (!Array.isArray(input)) return [];
|
|
165
|
+
const names = input
|
|
166
|
+
.filter((s): s is string => typeof s === 'string')
|
|
167
|
+
.map((s) => s.trim().slice(0, LIMITS.stateName));
|
|
168
|
+
return [...new Set(names.filter(Boolean))].slice(0, LIMITS.states);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function checkTriage(
|
|
172
|
+
input: unknown,
|
|
173
|
+
states: string[] | null,
|
|
174
|
+
): Checked<Pick<Triage, 'kind' | 'state'>> {
|
|
175
|
+
const t = (input ?? {}) as Record<string, unknown>;
|
|
176
|
+
const problems: string[] = [];
|
|
177
|
+
const kind = t.kind ?? null;
|
|
178
|
+
if (kind !== null && !KINDS.includes(kind as Kind))
|
|
179
|
+
problems.push(`triage.kind: one of ${KINDS.join(', ')}, or null`);
|
|
180
|
+
const state = t.state ?? null;
|
|
181
|
+
if (
|
|
182
|
+
state !== null &&
|
|
183
|
+
(typeof state !== 'string' || state.length > LIMITS.stateName || (states && !states.includes(state)))
|
|
184
|
+
)
|
|
185
|
+
problems.push('triage.state: a row of the States table, or null');
|
|
186
|
+
return problems.length
|
|
187
|
+
? { ok: false, problems }
|
|
188
|
+
: { ok: true, value: { kind: kind as Kind | null, state: state as string | null } };
|
|
189
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Who may do what. Editors run the review: they invite and revoke people,
|
|
2
|
+
// resolve any thread and correct triage. Reviewers comment, reply, and edit
|
|
3
|
+
// or delete what they wrote, and resolve threads they started. A revoked
|
|
4
|
+
// person, or a visitor with only the shared password, may do nothing here.
|
|
5
|
+
import type { Comment, Person } from './model.ts';
|
|
6
|
+
|
|
7
|
+
export type Action = 'read' | 'comment' | 'edit' | 'delete' | 'resolve' | 'triage' | 'people';
|
|
8
|
+
|
|
9
|
+
export function can(actor: Person | null, action: Action, subject?: Comment): boolean {
|
|
10
|
+
if (!actor || actor.revokedAt) return false;
|
|
11
|
+
const editor = actor.role === 'editor';
|
|
12
|
+
const own = subject?.author.id === actor.id;
|
|
13
|
+
switch (action) {
|
|
14
|
+
case 'read':
|
|
15
|
+
case 'comment':
|
|
16
|
+
return true;
|
|
17
|
+
case 'edit':
|
|
18
|
+
return own;
|
|
19
|
+
case 'delete':
|
|
20
|
+
case 'resolve':
|
|
21
|
+
return own || editor;
|
|
22
|
+
case 'triage':
|
|
23
|
+
case 'people':
|
|
24
|
+
return editor;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import { beforeEach, test } from 'node:test';
|
|
3
|
+
import type { Anchor, Person } from './model.ts';
|
|
4
|
+
import { createReview, type Review, ReviewError } from './service.ts';
|
|
5
|
+
import { memoryKV } from './store.ts';
|
|
6
|
+
|
|
7
|
+
const anchor: Anchor = {
|
|
8
|
+
prototype: 'sample-list',
|
|
9
|
+
feature: 'sample',
|
|
10
|
+
url: '/sample-list/#/items',
|
|
11
|
+
dials: { Review: { fixtures: 'empty' } },
|
|
12
|
+
target: 'main > ul > li:nth-child(2)',
|
|
13
|
+
offset: { x: 0.5, y: 0.25 },
|
|
14
|
+
viewport: { width: 1440, height: 900 },
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
let clock = 0;
|
|
18
|
+
let review: Review;
|
|
19
|
+
let editor: Person;
|
|
20
|
+
let reviewer: Person;
|
|
21
|
+
let reviewerToken: string;
|
|
22
|
+
|
|
23
|
+
const rejects = (p: Promise<unknown>, code: string) =>
|
|
24
|
+
assert.rejects(p, (e: unknown) => e instanceof ReviewError && e.code === code);
|
|
25
|
+
|
|
26
|
+
beforeEach(async () => {
|
|
27
|
+
clock = Date.parse('2030-01-01T00:00:00Z');
|
|
28
|
+
review = createReview(memoryKV(), {
|
|
29
|
+
now: () => {
|
|
30
|
+
clock += 1000;
|
|
31
|
+
return new Date(clock);
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
const root = {
|
|
35
|
+
id: 'cli',
|
|
36
|
+
name: 'CLI',
|
|
37
|
+
team: 'Studio',
|
|
38
|
+
role: 'editor',
|
|
39
|
+
createdAt: '',
|
|
40
|
+
revokedAt: null,
|
|
41
|
+
epoch: 0,
|
|
42
|
+
} as Person;
|
|
43
|
+
editor = (await review.invite(root, { name: 'Ed', team: 'Studio', role: 'editor' })).person;
|
|
44
|
+
const r = await review.invite(editor, { name: 'Rae', team: 'Acme' });
|
|
45
|
+
reviewer = r.person;
|
|
46
|
+
reviewerToken = r.token;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
test('an invite signs its person in until it is reissued or revoked', async () => {
|
|
50
|
+
assert.equal((await review.acceptInvite(reviewerToken))?.id, reviewer.id);
|
|
51
|
+
const { token } = await review.reissue(editor, reviewer.id);
|
|
52
|
+
assert.equal(await review.acceptInvite(reviewerToken), null);
|
|
53
|
+
assert.equal((await review.acceptInvite(token))?.epoch, 1);
|
|
54
|
+
await review.revoke(editor, reviewer.id);
|
|
55
|
+
assert.equal(await review.acceptInvite(token), null);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('a reissue or revoke ends every session signed with the old epoch', async () => {
|
|
59
|
+
assert.ok(await review.sessionPerson(reviewer.id, 0));
|
|
60
|
+
await review.reissue(editor, reviewer.id);
|
|
61
|
+
assert.equal(await review.sessionPerson(reviewer.id, 0), null);
|
|
62
|
+
assert.ok(await review.sessionPerson(reviewer.id, 1));
|
|
63
|
+
await review.revoke(editor, reviewer.id);
|
|
64
|
+
assert.equal(await review.sessionPerson(reviewer.id, 2), null);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('an invite expires', async () => {
|
|
68
|
+
clock += 15 * 86_400_000;
|
|
69
|
+
assert.equal(await review.acceptInvite(reviewerToken), null);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('only editors manage people, and nobody revokes themselves', async () => {
|
|
73
|
+
await rejects(review.invite(reviewer, { name: 'X', team: 'Acme' }), 'forbidden');
|
|
74
|
+
await rejects(review.listPeople(null), 'unauthorized');
|
|
75
|
+
await rejects(review.revoke(editor, editor.id), 'invalid');
|
|
76
|
+
assert.deepEqual(
|
|
77
|
+
(await review.listPeople(editor)).map((p) => p.name),
|
|
78
|
+
['Rae', 'Ed'],
|
|
79
|
+
);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
test('a comment, its replies, resolving and deleting the thread', async () => {
|
|
83
|
+
const root = await review.add(reviewer, { body: ' Row height jumps on empty ', anchor });
|
|
84
|
+
assert.equal(root.body, 'Row height jumps on empty');
|
|
85
|
+
await review.reply(editor, { prototype: 'sample-list', thread: root.id, body: 'Fixed in v2' });
|
|
86
|
+
assert.equal((await review.listComments(reviewer, 'sample-list')).length, 2);
|
|
87
|
+
const done = await review.resolve(reviewer, { prototype: 'sample-list', id: root.id, resolved: true });
|
|
88
|
+
assert.equal(done.resolvedBy?.id, reviewer.id);
|
|
89
|
+
assert.deepEqual((await review.remove(reviewer, { prototype: 'sample-list', id: root.id })).length, 2);
|
|
90
|
+
assert.deepEqual(await review.listComments(editor, 'sample-list'), []);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test('people edit only their own words; editors resolve and triage anything', async () => {
|
|
94
|
+
const c = await review.add(reviewer, { body: 'Typo in header', anchor });
|
|
95
|
+
await rejects(review.edit(editor, { prototype: 'sample-list', id: c.id, body: 'x' }), 'forbidden');
|
|
96
|
+
const other = (await review.invite(editor, { name: 'Oz', team: 'Acme' })).person;
|
|
97
|
+
await rejects(review.resolve(other, { prototype: 'sample-list', id: c.id, resolved: true }), 'forbidden');
|
|
98
|
+
await rejects(
|
|
99
|
+
review.retriage(reviewer, { prototype: 'sample-list', id: c.id, triage: { kind: 'copy' } }),
|
|
100
|
+
'forbidden',
|
|
101
|
+
);
|
|
102
|
+
const t = await review.retriage(editor, {
|
|
103
|
+
prototype: 'sample-list',
|
|
104
|
+
id: c.id,
|
|
105
|
+
triage: { kind: 'copy', state: 'Empty' },
|
|
106
|
+
states: ['Real list', 'Empty'],
|
|
107
|
+
});
|
|
108
|
+
assert.deepEqual(t.triage, { kind: 'copy', state: 'Empty', by: 'person', confidence: null });
|
|
109
|
+
await rejects(
|
|
110
|
+
review.retriage(editor, {
|
|
111
|
+
prototype: 'sample-list',
|
|
112
|
+
id: c.id,
|
|
113
|
+
triage: { state: 'Nope' },
|
|
114
|
+
states: ['Empty'],
|
|
115
|
+
}),
|
|
116
|
+
'invalid',
|
|
117
|
+
);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('a revoked person and a password-only visitor can do nothing', async () => {
|
|
121
|
+
const gone = await review.revoke(editor, reviewer.id);
|
|
122
|
+
await rejects(review.add(gone, { body: 'x', anchor }), 'forbidden');
|
|
123
|
+
await rejects(review.add(null, { body: 'x', anchor }), 'unauthorized');
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('bad input names every problem', async () => {
|
|
127
|
+
await assert.rejects(
|
|
128
|
+
review.add(reviewer, { body: '', anchor: { ...anchor, url: 'x', offset: { x: 2, y: 0 } } }),
|
|
129
|
+
(e: unknown) => {
|
|
130
|
+
assert.ok(e instanceof ReviewError);
|
|
131
|
+
assert.deepEqual(
|
|
132
|
+
e.problems.map((p) => p.split(':')[0]),
|
|
133
|
+
['body', 'anchor.url', 'anchor.offset'],
|
|
134
|
+
);
|
|
135
|
+
return true;
|
|
136
|
+
},
|
|
137
|
+
);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test('triage runs on add, and a failing triager never loses the comment', async () => {
|
|
141
|
+
const seen: string[][] = [];
|
|
142
|
+
const triaged = createReview(memoryKV(), {
|
|
143
|
+
triage: async ({ states }) => {
|
|
144
|
+
seen.push(states);
|
|
145
|
+
return { kind: 'bug', state: states[0] ?? null, by: 'jev', confidence: 0.9 };
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const c = await triaged.add(reviewer, {
|
|
149
|
+
body: 'Broken',
|
|
150
|
+
anchor,
|
|
151
|
+
states: ['Empty', 'Empty', ' Real list '],
|
|
152
|
+
});
|
|
153
|
+
assert.deepEqual(seen, [['Empty', 'Real list']]);
|
|
154
|
+
assert.equal(c.triage?.kind, 'bug');
|
|
155
|
+
const failing = createReview(memoryKV(), { triage: async () => Promise.reject(new Error('down')) });
|
|
156
|
+
assert.equal((await failing.add(reviewer, { body: 'Still saved', anchor })).triage, null);
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('locate finds a comment by id alone', async () => {
|
|
160
|
+
const c = await review.add(reviewer, { body: 'Here', anchor });
|
|
161
|
+
assert.equal((await review.locate(c.id))?.prototype, 'sample-list');
|
|
162
|
+
assert.equal(await review.locate('nope'), null);
|
|
163
|
+
});
|