@milesroxas/design-hub 0.0.0-stage → 0.1.0
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 +63 -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,53 @@
|
|
|
1
|
+
// The model and thresholds every Jev check reads, from one file in the
|
|
2
|
+
// project: .design-hub/checks.json. `design-hub calibrate --write` rewrites it
|
|
3
|
+
// from labelled judgments, so thresholds come from data, never from a
|
|
4
|
+
// constant in a script. The model is pinned, not an alias: an alias moves on
|
|
5
|
+
// a release, and the thresholds were set against one model. A file that does
|
|
6
|
+
// not validate turns every Jev check off (and `design-hub preflight` reports
|
|
7
|
+
// it), because a check must never block on a config typo. A project without
|
|
8
|
+
// the file runs on DEFAULT_CHECKS.
|
|
9
|
+
export const DEFAULT_CHECKS = {
|
|
10
|
+
jev: { model: 'jev-1.14.0', timeoutMs: 2000 },
|
|
11
|
+
fidelity: { warnAt: 0.7, blockAt: 0.9 },
|
|
12
|
+
states: { blockAt: 0.8 },
|
|
13
|
+
contacts: { privateAt: 0.5, allow: [] },
|
|
14
|
+
skillHint: { gateAt: 0.3, fitsAt: 0.3 },
|
|
15
|
+
triage: { at: 0.5 },
|
|
16
|
+
visual: { warnAt: 0.7 },
|
|
17
|
+
};
|
|
18
|
+
const isAt = (v) => v === null || (typeof v === 'number' && v >= 0 && v <= 1);
|
|
19
|
+
const obj = (v) => v && typeof v === 'object' && !Array.isArray(v) ? v : {};
|
|
20
|
+
/** Every problem with a checks object, by path; empty when it is valid. */
|
|
21
|
+
export function validateChecks(value) {
|
|
22
|
+
const c = obj(value);
|
|
23
|
+
const problems = [];
|
|
24
|
+
const jev = obj(c.jev);
|
|
25
|
+
if (typeof jev.model !== 'string' || !jev.model)
|
|
26
|
+
problems.push('jev.model: a model name');
|
|
27
|
+
if (typeof jev.timeoutMs !== 'number' || jev.timeoutMs <= 0)
|
|
28
|
+
problems.push('jev.timeoutMs: milliseconds > 0');
|
|
29
|
+
const thresholds = [
|
|
30
|
+
['fidelity', 'warnAt'],
|
|
31
|
+
['fidelity', 'blockAt'],
|
|
32
|
+
['states', 'blockAt'],
|
|
33
|
+
['contacts', 'privateAt'],
|
|
34
|
+
['skillHint', 'gateAt'],
|
|
35
|
+
['skillHint', 'fitsAt'],
|
|
36
|
+
['triage', 'at'],
|
|
37
|
+
['visual', 'warnAt'],
|
|
38
|
+
];
|
|
39
|
+
for (const [group, key] of thresholds)
|
|
40
|
+
if (!isAt(obj(c[group])[key]))
|
|
41
|
+
problems.push(`${group}.${key}: a probability from 0 to 1, or null`);
|
|
42
|
+
const allow = obj(c.contacts).allow;
|
|
43
|
+
if (!Array.isArray(allow) || !allow.every((a) => typeof a === 'string'))
|
|
44
|
+
problems.push('contacts.allow: a list of strings');
|
|
45
|
+
return problems;
|
|
46
|
+
}
|
|
47
|
+
/** The checks a raw value yields: the value when it validates, else null with the problems. */
|
|
48
|
+
export function loadChecks(raw) {
|
|
49
|
+
const problems = validateChecks(raw);
|
|
50
|
+
return { checks: problems.length ? null : raw, problems };
|
|
51
|
+
}
|
|
52
|
+
/** True when p reaches the threshold; a null threshold never does. */
|
|
53
|
+
export const reaches = (p, at) => at !== null && p >= at;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
export type Contact = {
|
|
2
|
+
kind: 'email' | 'phone';
|
|
3
|
+
value: string;
|
|
4
|
+
path: string;
|
|
5
|
+
};
|
|
6
|
+
/** Every contact in a string, in order of appearance. */
|
|
7
|
+
export declare function contactsIn(text: string, path?: string): Contact[];
|
|
8
|
+
/** Every contact in any string of a JSON value, with the path to it (`real[2].owner`). */
|
|
9
|
+
export declare function contactsInJson(value: unknown, path?: string): Contact[];
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Emails and phone numbers in fixture data. A CMS pull can put a real
|
|
2
|
+
// person's details behind a share link a client forwards, so the pre-flight
|
|
3
|
+
// stops on every match until it is confirmed as a business contact. This is
|
|
4
|
+
// the code half of the contact check; Jev, when on, only judges whether a
|
|
5
|
+
// match is a private person or a public business.
|
|
6
|
+
const EMAIL = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g;
|
|
7
|
+
// A run that starts and ends with a digit, may hold spaces, dots, dashes and
|
|
8
|
+
// brackets, and is not glued to letters or digits on either side.
|
|
9
|
+
const PHONE = /(?<![\w.+-])\+?\d[\d\s().-]{6,}\d(?![\w])/g;
|
|
10
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}/;
|
|
11
|
+
function phones(text) {
|
|
12
|
+
return [...text.matchAll(PHONE)]
|
|
13
|
+
.map((m) => m[0].trim())
|
|
14
|
+
.filter((v) => {
|
|
15
|
+
const digits = v.replace(/\D/g, '').length;
|
|
16
|
+
return digits >= 9 && digits <= 15 && !ISO_DATE.test(v);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Every contact in a string, in order of appearance. */
|
|
20
|
+
export function contactsIn(text, path = '') {
|
|
21
|
+
const emails = [...text.matchAll(EMAIL)].map((m) => ({ kind: 'email', value: m[0], path }));
|
|
22
|
+
const withoutEmails = text.replace(EMAIL, ' ');
|
|
23
|
+
return [...emails, ...phones(withoutEmails).map((value) => ({ kind: 'phone', value, path }))];
|
|
24
|
+
}
|
|
25
|
+
/** Every contact in any string of a JSON value, with the path to it (`real[2].owner`). */
|
|
26
|
+
export function contactsInJson(value, path = '') {
|
|
27
|
+
if (typeof value === 'string')
|
|
28
|
+
return contactsIn(value, path);
|
|
29
|
+
if (Array.isArray(value))
|
|
30
|
+
return value.flatMap((v, i) => contactsInJson(v, `${path}[${i}]`));
|
|
31
|
+
if (value && typeof value === 'object')
|
|
32
|
+
return Object.entries(value).flatMap(([k, v]) => contactsInJson(v, path ? `${path}.${k}` : k));
|
|
33
|
+
return [];
|
|
34
|
+
}
|
package/dist/core/doc.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
// The feature slug names docs/sprints/<feature>/ and a prototype's `feature:`. One rule for its shape and its display name.
|
|
2
|
+
export const SLUG = /^[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
3
|
+
export const featureTitle = (slug) => slug.replace(/-/g, ' ').replace(/^\w/, (c) => c.toUpperCase());
|
|
4
|
+
export const guideHref = (feature) => `/guide/${feature}/`;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export declare const SET_NAMES: readonly ['real', 'longest', 'empty', 'max', 'missing'];
|
|
2
|
+
export type SetName = (typeof SET_NAMES)[number];
|
|
3
|
+
export type FixtureSets<T> = Record<SetName, T[]>;
|
|
4
|
+
type Source<T> = {
|
|
5
|
+
real: T[];
|
|
6
|
+
missing: T[];
|
|
7
|
+
} & Partial<Record<SetName, T[]>>;
|
|
8
|
+
/** The keys of T whose values are strings: the only fields `longest` can stretch. */
|
|
9
|
+
type TextKey<T> = {
|
|
10
|
+
[K in keyof T & string]: T[K] extends string | null | undefined ? K : never;
|
|
11
|
+
}[keyof T & string];
|
|
12
|
+
type Options<T> = {
|
|
13
|
+
/** The design's maximum count, for `max`. */
|
|
14
|
+
max: number;
|
|
15
|
+
/** The free-text fields a reviewer reads (a name, a title), for `longest`. */
|
|
16
|
+
text: readonly TextKey<T>[];
|
|
17
|
+
};
|
|
18
|
+
export declare function fixtureSets<T extends Record<string, unknown>>(source: Source<T>, { max, text }: Options<T>): FixtureSets<T>;
|
|
19
|
+
export {};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Hostile fixture sets, derived from the real ones so Claude never writes
|
|
2
|
+
// them by hand. A feature's fixtures.json holds `real` (pulled from the CMS
|
|
3
|
+
// or copied from the comp) and `missing` (the item with no image, no link,
|
|
4
|
+
// hand-written because no rule can derive it). This file fills in the rest:
|
|
5
|
+
//
|
|
6
|
+
// longest the first item carries, in each free-text field the component
|
|
7
|
+
// names (`text`), the longest real value for that field doubled,
|
|
8
|
+
// because the CMS will hold a longer title than anything in it
|
|
9
|
+
// today; the other items stay real, so the monster row is judged
|
|
10
|
+
// beside normal ones. Only named fields stretch: a doubled date,
|
|
11
|
+
// status or URL is not a longer value, it is a broken one
|
|
12
|
+
// empty nothing
|
|
13
|
+
// max the real items repeated up to the design's maximum count, ids
|
|
14
|
+
// made unique so React keys hold
|
|
15
|
+
//
|
|
16
|
+
// A set written in the JSON by hand wins over the derived one, so a feature
|
|
17
|
+
// can replace any of them when its component needs something the rule
|
|
18
|
+
// cannot know.
|
|
19
|
+
export const SET_NAMES = ['real', 'longest', 'empty', 'max', 'missing'];
|
|
20
|
+
export function fixtureSets(source, { max, text }) {
|
|
21
|
+
return {
|
|
22
|
+
real: source.real,
|
|
23
|
+
longest: source.longest ?? longest(source.real, text),
|
|
24
|
+
empty: source.empty ?? [],
|
|
25
|
+
max: source.max ?? repeat(source.real, max),
|
|
26
|
+
missing: source.missing,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function longest(real, text) {
|
|
30
|
+
const [first, ...rest] = real;
|
|
31
|
+
if (!first)
|
|
32
|
+
return [];
|
|
33
|
+
const hostile = { ...first };
|
|
34
|
+
for (const key of text) {
|
|
35
|
+
const widest = real
|
|
36
|
+
.map((item) => item[key])
|
|
37
|
+
.filter((v) => typeof v === 'string')
|
|
38
|
+
.reduce((a, b) => (b.length > a.length ? b : a), '');
|
|
39
|
+
if (widest)
|
|
40
|
+
hostile[key] = `${widest} ${widest}`;
|
|
41
|
+
}
|
|
42
|
+
return [hostile, ...rest];
|
|
43
|
+
}
|
|
44
|
+
function repeat(real, count) {
|
|
45
|
+
if (!real.length)
|
|
46
|
+
return [];
|
|
47
|
+
return Array.from({ length: count }, (_, i) => {
|
|
48
|
+
const item = real[i % real.length];
|
|
49
|
+
const round = Math.floor(i / real.length);
|
|
50
|
+
return round === 0 || !('id' in item) ? item : { ...item, id: `${String(item.id)}-${round}` };
|
|
51
|
+
});
|
|
52
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
// The YAML block at the top of a Markdown file, read as flat `key: value`
|
|
2
|
+
// pairs. Shared by the index (in the browser) and the ?doc plugin (in Node).
|
|
3
|
+
export function frontmatter(md) {
|
|
4
|
+
const match = md.match(/^---\n([\s\S]*?)\n---\n?/);
|
|
5
|
+
const data = {};
|
|
6
|
+
for (const line of (match?.[1] ?? '').split('\n')) {
|
|
7
|
+
const at = line.indexOf(':');
|
|
8
|
+
if (at > 0)
|
|
9
|
+
data[line.slice(0, at).trim()] = line
|
|
10
|
+
.slice(at + 1)
|
|
11
|
+
.trim()
|
|
12
|
+
.replace(/^["']|["']$/g, '');
|
|
13
|
+
}
|
|
14
|
+
return { data, body: match ? md.slice(match[0].length) : md };
|
|
15
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
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
|
+
const PNG_SIGNATURE = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
|
5
|
+
const u16 = (b, i) => (b[i] << 8) | b[i + 1];
|
|
6
|
+
const u32 = (b, i) => ((b[i] << 24) | (b[i + 1] << 16) | (b[i + 2] << 8) | b[i + 3]) >>> 0;
|
|
7
|
+
function pngSize(b) {
|
|
8
|
+
if (b.length < 24 || PNG_SIGNATURE.some((v, i) => b[i] !== v))
|
|
9
|
+
return null;
|
|
10
|
+
return { width: u32(b, 16), height: u32(b, 20), format: 'png' };
|
|
11
|
+
}
|
|
12
|
+
// Markers with no length field: start of image, TEM, and the restart markers.
|
|
13
|
+
const standalone = (m) => m === 0xd8 || m === 0x01 || (m >= 0xd0 && m <= 0xd7);
|
|
14
|
+
// SOF0 to SOF15 carry the frame size, except DHT (C4), JPG (C8) and DAC (CC).
|
|
15
|
+
const frame = (m) => m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc;
|
|
16
|
+
function jpegSize(b) {
|
|
17
|
+
if (b[0] !== 0xff || b[1] !== 0xd8)
|
|
18
|
+
return null;
|
|
19
|
+
let i = 2;
|
|
20
|
+
while (i + 9 < b.length) {
|
|
21
|
+
const marker = b[i + 1];
|
|
22
|
+
// A byte that is not a marker, or 0xFF fill before one, moves on by one.
|
|
23
|
+
if (b[i] !== 0xff || marker === 0xff)
|
|
24
|
+
i += 1;
|
|
25
|
+
else if (standalone(marker))
|
|
26
|
+
i += 2;
|
|
27
|
+
else if (frame(marker))
|
|
28
|
+
return { height: u16(b, i + 5), width: u16(b, i + 7), format: 'jpg' };
|
|
29
|
+
else
|
|
30
|
+
i += 2 + u16(b, i + 2);
|
|
31
|
+
}
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
export const imageSize = (bytes) => pngSize(bytes) ?? jpegSize(bytes);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
export type Question = {
|
|
2
|
+
type: 'noul';
|
|
3
|
+
instructions: string;
|
|
4
|
+
} | {
|
|
5
|
+
type: 'choice';
|
|
6
|
+
instructions: string;
|
|
7
|
+
criteria: Record<string, string | null>;
|
|
8
|
+
};
|
|
9
|
+
export type Answer = {
|
|
10
|
+
type: 'noul';
|
|
11
|
+
noul: number;
|
|
12
|
+
} | {
|
|
13
|
+
type: 'choice';
|
|
14
|
+
choice: string;
|
|
15
|
+
confidence: number;
|
|
16
|
+
probabilities: Record<string, number>;
|
|
17
|
+
};
|
|
18
|
+
/** An image the state or a question refers to by its key. Experimental on TypeSafe's side. */
|
|
19
|
+
export type ImageAsset = {
|
|
20
|
+
type: 'image';
|
|
21
|
+
format: 'png' | 'jpg' | 'jpeg';
|
|
22
|
+
data_base64: string;
|
|
23
|
+
};
|
|
24
|
+
export type JevRequest = {
|
|
25
|
+
model: string;
|
|
26
|
+
state: unknown;
|
|
27
|
+
questions: Record<string, Question>;
|
|
28
|
+
assets?: Record<string, ImageAsset>;
|
|
29
|
+
};
|
|
30
|
+
export type JevResult = {
|
|
31
|
+
ok: true;
|
|
32
|
+
model: string;
|
|
33
|
+
answers: Record<string, Answer>;
|
|
34
|
+
ms: number;
|
|
35
|
+
} | {
|
|
36
|
+
ok: false;
|
|
37
|
+
error: string;
|
|
38
|
+
ms: number;
|
|
39
|
+
};
|
|
40
|
+
type Options = {
|
|
41
|
+
timeoutMs: number;
|
|
42
|
+
fetch?: typeof fetch;
|
|
43
|
+
};
|
|
44
|
+
export declare function askJev(apiKey: string, request: JevRequest, options: Options): Promise<JevResult>;
|
|
45
|
+
/** The yes-probability of a Noul answer, or null when it is missing or another type. */
|
|
46
|
+
export declare const noulOf: (answers: Record<string, Answer>, key: string) => number | null;
|
|
47
|
+
/** A Choice answer, or null when it is missing or another type. */
|
|
48
|
+
export declare const choiceOf: (answers: Record<string, Answer>, key: string) => {
|
|
49
|
+
type: 'choice';
|
|
50
|
+
choice: string;
|
|
51
|
+
confidence: number;
|
|
52
|
+
probabilities: Record<string, number>;
|
|
53
|
+
} | null;
|
|
54
|
+
export {};
|
package/dist/core/jev.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
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
|
+
const JEV_URL = 'https://api.typesafe.ai/v1/systemone';
|
|
9
|
+
export async function askJev(apiKey, request, options) {
|
|
10
|
+
const started = Date.now();
|
|
11
|
+
const ms = () => Date.now() - started;
|
|
12
|
+
const headers = {
|
|
13
|
+
authorization: `Bearer ${apiKey}`,
|
|
14
|
+
'content-type': 'application/json',
|
|
15
|
+
accept: 'application/json',
|
|
16
|
+
};
|
|
17
|
+
// Images sit behind TypeSafe's "danger zone" header; without it the request is a 400.
|
|
18
|
+
if (request.assets && Object.keys(request.assets).length)
|
|
19
|
+
headers['typesafe-danger-zone-images'] = 'true';
|
|
20
|
+
try {
|
|
21
|
+
const response = await (options.fetch ?? fetch)(JEV_URL, {
|
|
22
|
+
method: 'POST',
|
|
23
|
+
headers,
|
|
24
|
+
body: JSON.stringify(request),
|
|
25
|
+
signal: AbortSignal.timeout(options.timeoutMs),
|
|
26
|
+
});
|
|
27
|
+
if (!response.ok) {
|
|
28
|
+
const id = response.headers.get('x-typesafe-request-id');
|
|
29
|
+
const body = (await response.text()).slice(0, 300);
|
|
30
|
+
return { ok: false, error: `${response.status}${id ? ` (${id})` : ''}: ${body}`, ms: ms() };
|
|
31
|
+
}
|
|
32
|
+
const json = (await response.json());
|
|
33
|
+
if (!json.answers)
|
|
34
|
+
return { ok: false, error: 'response had no answers', ms: ms() };
|
|
35
|
+
return { ok: true, model: json.model ?? request.model, answers: json.answers, ms: ms() };
|
|
36
|
+
}
|
|
37
|
+
catch (error) {
|
|
38
|
+
const name = error instanceof Error ? error.name : '';
|
|
39
|
+
const error_ = name === 'TimeoutError' ? `timeout after ${options.timeoutMs}ms` : String(error);
|
|
40
|
+
return { ok: false, error: error_, ms: ms() };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** The yes-probability of a Noul answer, or null when it is missing or another type. */
|
|
44
|
+
export const noulOf = (answers, key) => {
|
|
45
|
+
const a = answers[key];
|
|
46
|
+
return a?.type === 'noul' && typeof a.noul === 'number' ? a.noul : null;
|
|
47
|
+
};
|
|
48
|
+
/** A Choice answer, or null when it is missing or another type. */
|
|
49
|
+
export const choiceOf = (answers, key) => {
|
|
50
|
+
const a = answers[key];
|
|
51
|
+
return a?.type === 'choice' ? a : null;
|
|
52
|
+
};
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
export declare const ROLES: readonly ['editor', 'reviewer'];
|
|
2
|
+
export type Role = (typeof ROLES)[number];
|
|
3
|
+
/** How a comment is triaged: something broken, a design opinion, or the words. */
|
|
4
|
+
export declare const KINDS: readonly ['bug', 'taste', 'copy'];
|
|
5
|
+
export type Kind = (typeof KINDS)[number];
|
|
6
|
+
/** How long an invite link signs its person in before an editor must issue a new one. */
|
|
7
|
+
export declare const INVITE_DAYS = 14;
|
|
8
|
+
export declare const LIMITS: {
|
|
9
|
+
readonly name: 80;
|
|
10
|
+
readonly team: 80;
|
|
11
|
+
readonly body: 4000;
|
|
12
|
+
readonly url: 2000;
|
|
13
|
+
readonly target: 500;
|
|
14
|
+
readonly dialsChars: 8000;
|
|
15
|
+
readonly states: 50;
|
|
16
|
+
readonly stateName: 120;
|
|
17
|
+
};
|
|
18
|
+
export type Person = {
|
|
19
|
+
id: string;
|
|
20
|
+
name: string;
|
|
21
|
+
/** The group a person belongs to, shown beside their name: the studio, or the client's company. */
|
|
22
|
+
team: string;
|
|
23
|
+
role: Role;
|
|
24
|
+
createdAt: string;
|
|
25
|
+
revokedAt: string | null;
|
|
26
|
+
/** Bumped on revoke and on a new invite, which signs out every session signed with the old one. */
|
|
27
|
+
epoch: number;
|
|
28
|
+
};
|
|
29
|
+
/** A comment's author as it was when they wrote it, so a revoked person's comments still read. */
|
|
30
|
+
export type Author = Pick<Person, 'id' | 'name' | 'team'>;
|
|
31
|
+
export type Anchor = {
|
|
32
|
+
prototype: string;
|
|
33
|
+
feature: string;
|
|
34
|
+
/** Path, query and hash when the comment was written: reopening it reopens the state. */
|
|
35
|
+
url: string;
|
|
36
|
+
/** Every dial panel's values by panel name, so the state includes what the dials were set to. */
|
|
37
|
+
dials: Record<string, Record<string, unknown>>;
|
|
38
|
+
/** A CSS selector for the element the pin sits on, or null for the page as a whole. */
|
|
39
|
+
target: string | null;
|
|
40
|
+
/** Where on the target, as fractions of its box. */
|
|
41
|
+
offset: {
|
|
42
|
+
x: number;
|
|
43
|
+
y: number;
|
|
44
|
+
} | null;
|
|
45
|
+
viewport: {
|
|
46
|
+
width: number;
|
|
47
|
+
height: number;
|
|
48
|
+
};
|
|
49
|
+
};
|
|
50
|
+
export type Triage = {
|
|
51
|
+
kind: Kind | null;
|
|
52
|
+
/** A row of the prototype README's States table. */
|
|
53
|
+
state: string | null;
|
|
54
|
+
by: 'jev' | 'person';
|
|
55
|
+
confidence: number | null;
|
|
56
|
+
};
|
|
57
|
+
export type Comment = {
|
|
58
|
+
id: string;
|
|
59
|
+
/** The root comment's id; a root's thread is its own id. */
|
|
60
|
+
thread: string;
|
|
61
|
+
author: Author;
|
|
62
|
+
body: string;
|
|
63
|
+
createdAt: string;
|
|
64
|
+
editedAt: string | null;
|
|
65
|
+
/** Roots only. */
|
|
66
|
+
anchor: Anchor | null;
|
|
67
|
+
status: 'open' | 'resolved';
|
|
68
|
+
resolvedBy: Author | null;
|
|
69
|
+
triage: Triage | null;
|
|
70
|
+
};
|
|
71
|
+
export declare const authorOf: (p: Person) => Author;
|
|
72
|
+
export declare const isRoot: (c: Comment) => boolean;
|
|
73
|
+
export type Checked<T> = {
|
|
74
|
+
ok: true;
|
|
75
|
+
value: T;
|
|
76
|
+
} | {
|
|
77
|
+
ok: false;
|
|
78
|
+
problems: string[];
|
|
79
|
+
};
|
|
80
|
+
export declare function checkPersonInput(input: unknown): Checked<Pick<Person, 'name' | 'team' | 'role'>>;
|
|
81
|
+
export declare function checkBody(body: unknown): Checked<string>;
|
|
82
|
+
export declare function checkAnchor(input: unknown): Checked<Anchor>;
|
|
83
|
+
/** The README states a client offers for triage: trimmed, capped, de-duplicated. */
|
|
84
|
+
export declare function checkStates(input: unknown): string[];
|
|
85
|
+
export declare function checkTriage(input: unknown, states: string[] | null): Checked<Pick<Triage, 'kind' | 'state'>>;
|
|
@@ -0,0 +1,112 @@
|
|
|
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
|
+
import { SLUG } from '../feature.js';
|
|
7
|
+
export const ROLES = ['editor', 'reviewer'];
|
|
8
|
+
/** How a comment is triaged: something broken, a design opinion, or the words. */
|
|
9
|
+
export const KINDS = ['bug', 'taste', 'copy'];
|
|
10
|
+
/** How long an invite link signs its person in before an editor must issue a new one. */
|
|
11
|
+
export const INVITE_DAYS = 14;
|
|
12
|
+
export const LIMITS = {
|
|
13
|
+
name: 80,
|
|
14
|
+
team: 80,
|
|
15
|
+
body: 4000,
|
|
16
|
+
url: 2000,
|
|
17
|
+
target: 500,
|
|
18
|
+
dialsChars: 8000,
|
|
19
|
+
states: 50,
|
|
20
|
+
stateName: 120,
|
|
21
|
+
};
|
|
22
|
+
export const authorOf = (p) => ({ id: p.id, name: p.name, team: p.team });
|
|
23
|
+
export const isRoot = (c) => c.thread === c.id;
|
|
24
|
+
const text = (v, path, max, problems) => {
|
|
25
|
+
if (typeof v !== 'string' || !v.trim()) {
|
|
26
|
+
problems.push(`${path}: required`);
|
|
27
|
+
return '';
|
|
28
|
+
}
|
|
29
|
+
const s = v.trim();
|
|
30
|
+
if (s.length > max)
|
|
31
|
+
problems.push(`${path}: ${max} characters at most`);
|
|
32
|
+
return s;
|
|
33
|
+
};
|
|
34
|
+
const fraction = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v <= 1;
|
|
35
|
+
const size = (v) => typeof v === 'number' && Number.isFinite(v) && v >= 0 && v < 100_000;
|
|
36
|
+
export function checkPersonInput(input) {
|
|
37
|
+
const i = (input ?? {});
|
|
38
|
+
const problems = [];
|
|
39
|
+
const name = text(i.name, 'name', LIMITS.name, problems);
|
|
40
|
+
const team = text(i.team, 'team', LIMITS.team, problems);
|
|
41
|
+
const role = i.role ?? 'reviewer';
|
|
42
|
+
if (!ROLES.includes(role))
|
|
43
|
+
problems.push(`role: one of ${ROLES.join(', ')}`);
|
|
44
|
+
return problems.length ? { ok: false, problems } : { ok: true, value: { name, team, role: role } };
|
|
45
|
+
}
|
|
46
|
+
export function checkBody(body) {
|
|
47
|
+
const problems = [];
|
|
48
|
+
const value = text(body, 'body', LIMITS.body, problems);
|
|
49
|
+
return problems.length ? { ok: false, problems } : { ok: true, value };
|
|
50
|
+
}
|
|
51
|
+
const isSlug = (v) => typeof v === 'string' && SLUG.test(v);
|
|
52
|
+
const isObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
53
|
+
/** One rule per field: the field, whether a value passes, and what the field must be. */
|
|
54
|
+
const ANCHOR_RULES = [
|
|
55
|
+
['prototype', isSlug, 'a prototype slug'],
|
|
56
|
+
['feature', (v) => v === '' || isSlug(v), 'a feature slug or empty'],
|
|
57
|
+
[
|
|
58
|
+
'url',
|
|
59
|
+
(v) => typeof v === 'string' && v.startsWith('/') && v.length <= LIMITS.url,
|
|
60
|
+
`a path starting with /, ${LIMITS.url} characters at most`,
|
|
61
|
+
],
|
|
62
|
+
[
|
|
63
|
+
'dials',
|
|
64
|
+
(v) => isObject(v) && JSON.stringify(v).length <= LIMITS.dialsChars,
|
|
65
|
+
`an object of panels, ${LIMITS.dialsChars} characters at most`,
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
'target',
|
|
69
|
+
(v) => v === null || (typeof v === 'string' && v.length <= LIMITS.target),
|
|
70
|
+
`a selector, ${LIMITS.target} characters at most, or null`,
|
|
71
|
+
],
|
|
72
|
+
[
|
|
73
|
+
'offset',
|
|
74
|
+
(v) => v === null || (isObject(v) && fraction(v.x) && fraction(v.y)),
|
|
75
|
+
'x and y from 0 to 1, or null',
|
|
76
|
+
],
|
|
77
|
+
['viewport', (v) => isObject(v) && size(v.width) && size(v.height), 'width and height in pixels'],
|
|
78
|
+
];
|
|
79
|
+
export function checkAnchor(input) {
|
|
80
|
+
const given = isObject(input) ? input : {};
|
|
81
|
+
// Absent optional fields take their empty form before the rules run.
|
|
82
|
+
const a = { dials: {}, target: null, offset: null, ...given };
|
|
83
|
+
const problems = ANCHOR_RULES.filter(([key, passes]) => !passes(a[key])).map(([key, , must]) => `anchor.${key}: ${must}`);
|
|
84
|
+
if (problems.length)
|
|
85
|
+
return { ok: false, problems };
|
|
86
|
+
// Every rule passed, so each field holds its Anchor type.
|
|
87
|
+
const { prototype, feature, url, dials, target, offset, viewport } = a;
|
|
88
|
+
return { ok: true, value: { prototype, feature, url, dials, target, offset, viewport } };
|
|
89
|
+
}
|
|
90
|
+
/** The README states a client offers for triage: trimmed, capped, de-duplicated. */
|
|
91
|
+
export function checkStates(input) {
|
|
92
|
+
if (!Array.isArray(input))
|
|
93
|
+
return [];
|
|
94
|
+
const names = input
|
|
95
|
+
.filter((s) => typeof s === 'string')
|
|
96
|
+
.map((s) => s.trim().slice(0, LIMITS.stateName));
|
|
97
|
+
return [...new Set(names.filter(Boolean))].slice(0, LIMITS.states);
|
|
98
|
+
}
|
|
99
|
+
export function checkTriage(input, states) {
|
|
100
|
+
const t = (input ?? {});
|
|
101
|
+
const problems = [];
|
|
102
|
+
const kind = t.kind ?? null;
|
|
103
|
+
if (kind !== null && !KINDS.includes(kind))
|
|
104
|
+
problems.push(`triage.kind: one of ${KINDS.join(', ')}, or null`);
|
|
105
|
+
const state = t.state ?? null;
|
|
106
|
+
if (state !== null &&
|
|
107
|
+
(typeof state !== 'string' || state.length > LIMITS.stateName || (states && !states.includes(state))))
|
|
108
|
+
problems.push('triage.state: a row of the States table, or null');
|
|
109
|
+
return problems.length
|
|
110
|
+
? { ok: false, problems }
|
|
111
|
+
: { ok: true, value: { kind: kind, state: state } };
|
|
112
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export function can(actor, action, subject) {
|
|
2
|
+
if (!actor || actor.revokedAt)
|
|
3
|
+
return false;
|
|
4
|
+
const editor = actor.role === 'editor';
|
|
5
|
+
const own = subject?.author.id === actor.id;
|
|
6
|
+
switch (action) {
|
|
7
|
+
case 'read':
|
|
8
|
+
case 'comment':
|
|
9
|
+
return true;
|
|
10
|
+
case 'edit':
|
|
11
|
+
return own;
|
|
12
|
+
case 'delete':
|
|
13
|
+
case 'resolve':
|
|
14
|
+
return own || editor;
|
|
15
|
+
case 'triage':
|
|
16
|
+
case 'people':
|
|
17
|
+
return editor;
|
|
18
|
+
}
|
|
19
|
+
}
|