@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,39 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ // The gate before a repo is published: no tracked file may contain a
4
+ // term from the list, case-insensitive (a client's name, a person's name).
5
+ // Tracked means `git ls-files`, so an untracked plan never trips it and a
6
+ // gitignored secret never hides a hit.
7
+ import { execFileSync } from 'node:child_process';
8
+ import { readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { ROOT } from '../server/paths.js';
11
+ import { CliError } from './lib/cli.js';
12
+ export const spec = {
13
+ name: 'release-check',
14
+ summary: 'Fail when any tracked file contains one of the given terms.',
15
+ flags: { term: { type: 'list', description: 'A term to refuse, case-insensitive. Repeat for more.' } },
16
+ output: '{ ok, terms, files, hits: { <file>: [terms] } }',
17
+ };
18
+ export function main({ flags }) {
19
+ const terms = flags.term.map((t) => t.toLowerCase()).filter(Boolean);
20
+ if (!terms.length)
21
+ throw new CliError('Give at least one --term.');
22
+ const files = execFileSync('git', ['-C', ROOT, 'ls-files', '-z'], { encoding: 'utf8' })
23
+ .split('\0')
24
+ .filter(Boolean);
25
+ const hits = {};
26
+ for (const file of files) {
27
+ let text;
28
+ try {
29
+ text = readFileSync(join(ROOT, file), 'utf8').toLowerCase();
30
+ }
31
+ catch {
32
+ continue;
33
+ }
34
+ const found = terms.filter((t) => text.includes(t));
35
+ if (found.length)
36
+ hits[file] = found;
37
+ }
38
+ return { ok: Object.keys(hits).length === 0, terms, files: files.length, hits };
39
+ }
@@ -0,0 +1,32 @@
1
+ #!/usr/bin/env node
2
+ import type { Args } from './lib/cli.ts';
3
+ import { judge } from './lib/judge.ts';
4
+ export declare const spec: {
5
+ readonly name: 'skill-hint';
6
+ readonly summary: 'Suggest the miles-* skill for a prompt. Normally runs as the UserPromptSubmit hook.';
7
+ readonly positionals: [{
8
+ readonly name: 'prompt';
9
+ readonly description: 'The prompt to judge';
10
+ readonly required: true;
11
+ }];
12
+ readonly flags: {};
13
+ readonly output: '{ ok, skill: string | null, gate, fits }';
14
+ };
15
+ export type Skill = {
16
+ name: string;
17
+ description: string;
18
+ };
19
+ export declare function roster(dir?: string): Skill[];
20
+ export type Hint = {
21
+ skill: string | null;
22
+ gate: number | null;
23
+ fits: number | null;
24
+ };
25
+ /** `ask` is Jev through the log (lib/judge.ts); tests pass their own. */
26
+ export declare function suggest(prompt: string, skills?: Skill[], ask?: typeof judge): Promise<Hint>;
27
+ export declare function skillHintHook(): Promise<void>;
28
+ export declare const main: ({ positionals }: Args<typeof spec>) => Promise<{
29
+ skill: string | null;
30
+ gate: number | null;
31
+ fits: number | null;
32
+ }>;
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ // The skill hint: a UserPromptSubmit hook (`design-hub hook skill-hint`) that, with TypeSafe on,
4
+ // names the miles-* skill the prompt most likely needs, in one line of
5
+ // context. Off without TYPESAFE_API_KEY, silent on any failure.
6
+ //
7
+ // It follows TypeSafe's skill-suggestion cookbook in one request: a Choice
8
+ // over the skills says which; three gate Nouls about whether the prompt asks
9
+ // for action at all say whether to speak; one `fits` Noul per skill confirms
10
+ // the winner. A Choice has no reliable "none" option, which is why the gates
11
+ // exist. Thresholds: checks.json `skillHint`.
12
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import { reaches } from '../core/checks.js';
15
+ import { frontmatter } from '../core/frontmatter.js';
16
+ import { choiceOf, noulOf } from '../core/jev.js';
17
+ import { checks } from '../server/checks.js';
18
+ import { SKILLS } from '../server/paths.js';
19
+ import { judge } from './lib/judge.js';
20
+ export const spec = {
21
+ name: 'skill-hint',
22
+ summary: 'Suggest the miles-* skill for a prompt. Normally runs as the UserPromptSubmit hook.',
23
+ positionals: [{ name: 'prompt', description: 'The prompt to judge', required: true }],
24
+ flags: {},
25
+ output: '{ ok, skill: string | null, gate, fits }',
26
+ };
27
+ // miles-design-hub starts a project from outside one; inside a project it is never the answer.
28
+ const GLOBAL_ONLY = new Set(['miles-design-hub']);
29
+ const MAX_PROMPT_CHARS = 4000;
30
+ const GATES = {
31
+ acts: "Is the assistant being asked to act on the user's files, project or online services, rather than only to explain or advise?",
32
+ procedure: 'Would a careful expert answering `request` consult a specific documented procedure or set of commands, rather than answer from general understanding?',
33
+ prose: "Could a knowledgeable generalist fully satisfy `request` in prose, with no tools, no documentation and no access to the user's files?",
34
+ };
35
+ export function roster(dir = SKILLS) {
36
+ if (!existsSync(dir))
37
+ return [];
38
+ return readdirSync(dir)
39
+ .filter((name) => name.startsWith('miles-') && !GLOBAL_ONLY.has(name))
40
+ .map((name) => join(dir, name, 'SKILL.md'))
41
+ .filter(existsSync)
42
+ .map((file) => frontmatter(readFileSync(file, 'utf8')).data)
43
+ .filter((d) => d.name && d.description)
44
+ .map((d) => ({ name: d.name, description: d.description }));
45
+ }
46
+ /** `ask` is Jev through the log (lib/judge.ts); tests pass their own. */
47
+ export async function suggest(prompt, skills = roster(), ask = judge) {
48
+ const none = { skill: null, gate: null, fits: null };
49
+ if (!checks || !skills.length || !prompt.trim() || prompt.trimStart().startsWith('/'))
50
+ return none;
51
+ // A prompt that already names a skill needs no hint.
52
+ if (skills.some((s) => prompt.includes(s.name)))
53
+ return none;
54
+ const questions = {
55
+ which: {
56
+ type: 'choice',
57
+ instructions: 'Which of these skills is the right one to load to help with `request`?',
58
+ criteria: Object.fromEntries(skills.map((s) => [s.name, s.description])),
59
+ },
60
+ ...Object.fromEntries(Object.entries(GATES).map(([k, q]) => [k, { type: 'noul', instructions: q }])),
61
+ ...Object.fromEntries(skills.map((s) => [
62
+ `fits:${s.name}`,
63
+ {
64
+ type: 'noul',
65
+ instructions: `Does the skill '${s.name}' do the specific thing \`request\` asks for? It is described as: ${s.description}`,
66
+ },
67
+ ])),
68
+ };
69
+ const judged = await ask({
70
+ check: 'skillHint',
71
+ subject: 'prompt',
72
+ state: { request: prompt.slice(0, MAX_PROMPT_CHARS) },
73
+ questions,
74
+ excerpt: prompt.slice(0, 500),
75
+ });
76
+ if (!judged)
77
+ return none;
78
+ const [acts, procedure, prose] = ['acts', 'procedure', 'prose'].map((k) => noulOf(judged.answers, k));
79
+ const which = choiceOf(judged.answers, 'which');
80
+ if (acts === null || procedure === null || prose === null || !which)
81
+ return none;
82
+ const gate = (acts + procedure + (1 - prose)) / 3;
83
+ const fits = noulOf(judged.answers, `fits:${which.choice}`);
84
+ const speak = reaches(gate, checks.skillHint.gateAt) && fits !== null && reaches(fits, checks.skillHint.fitsAt);
85
+ return { skill: speak ? which.choice : null, gate, fits };
86
+ }
87
+ export async function skillHintHook() {
88
+ let prompt = '';
89
+ try {
90
+ prompt = JSON.parse(readFileSync(0, 'utf8')).prompt ?? '';
91
+ }
92
+ catch {
93
+ return;
94
+ }
95
+ const { skill, fits } = await suggest(prompt);
96
+ if (!skill || fits === null)
97
+ return;
98
+ const text = `Skill hint: ${skill} looks like the skill for this request (fit ${fits.toFixed(2)}). Load it if its trigger matches; ignore this line if it does not.`;
99
+ console.log(JSON.stringify({ hookSpecificOutput: { hookEventName: 'UserPromptSubmit', additionalContext: text } }));
100
+ }
101
+ export const main = async ({ positionals }) => ({
102
+ ...(await suggest(positionals.join(' '))),
103
+ });
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env node
2
+ import type { Args } from './lib/cli.ts';
3
+ export declare const spec: {
4
+ readonly name: 'skills';
5
+ readonly summary: "Install or update the project's miles-* skills from this package into .claude/skills/.";
6
+ readonly flags: {};
7
+ readonly output: '{ ok, installed: [name], into }';
8
+ };
9
+ export declare function installSkills(from?: string, into?: string): string[];
10
+ export declare function main(_args: Args<typeof spec>): {
11
+ installed: string[];
12
+ into: string;
13
+ };
@@ -0,0 +1,34 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ // `design-hub skills`: the project-facing miles-* skills from this package
4
+ // into the project's .claude/skills/, replacing what is there. The skills
5
+ // ship with the package so a version bump updates them; a project never
6
+ // edits its copies. `miles-design-hub` stays out: it starts a project from
7
+ // outside one and is installed globally.
8
+ import { cpSync, existsSync, mkdirSync, readdirSync, rmSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+ import { PACKAGE_SKILLS, SKILLS } from '../server/paths.js';
11
+ export const spec = {
12
+ name: 'skills',
13
+ summary: "Install or update the project's miles-* skills from this package into .claude/skills/.",
14
+ flags: {},
15
+ output: '{ ok, installed: [name], into }',
16
+ };
17
+ const GLOBAL_ONLY = new Set(['miles-design-hub']);
18
+ export function installSkills(from = PACKAGE_SKILLS, into = SKILLS) {
19
+ const names = readdirSync(from, { withFileTypes: true })
20
+ .filter((d) => d.isDirectory() && d.name.startsWith('miles-') && !GLOBAL_ONLY.has(d.name))
21
+ .map((d) => d.name)
22
+ .sort();
23
+ mkdirSync(into, { recursive: true });
24
+ for (const name of names) {
25
+ const dest = join(into, name);
26
+ if (existsSync(dest))
27
+ rmSync(dest, { recursive: true, force: true });
28
+ cpSync(join(from, name), dest, { recursive: true });
29
+ }
30
+ return names;
31
+ }
32
+ export function main(_args) {
33
+ return { installed: installSkills(), into: SKILLS };
34
+ }
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+ import { type ImageAsset } from '../core/jev.ts';
3
+ import { type Args } from './lib/cli.ts';
4
+ export declare const spec: {
5
+ readonly name: 'visual-match';
6
+ readonly summary: 'Experimental: ask Jev whether a prototype screenshot is missing, adds or reorders elements of its Paper comp.';
7
+ readonly positionals: [{
8
+ readonly name: 'prototype';
9
+ readonly description: 'The prototype slug, for the log';
10
+ readonly required: true;
11
+ }];
12
+ readonly flags: {
13
+ readonly comp: {
14
+ readonly type: 'string';
15
+ readonly required: true;
16
+ readonly description: 'The Paper frame export (PNG or JPEG).';
17
+ };
18
+ readonly shot: {
19
+ readonly type: 'string';
20
+ readonly required: true;
21
+ readonly description: 'The prototype screenshot in the same state (PNG or JPEG).';
22
+ };
23
+ };
24
+ readonly output: '{ ok, experimental, findings: [{ question, p, id }] }';
25
+ };
26
+ export declare const IMAGE_LIMITS: {
27
+ readonly side: readonly [280, 1568];
28
+ readonly pixels: readonly [78400, 2458624];
29
+ readonly bytes: number;
30
+ };
31
+ export declare function asset(path: string, flag: string): ImageAsset;
32
+ export declare function main({ positionals: [prototype], flags }: Args<typeof spec>): Promise<{
33
+ ok: boolean;
34
+ error: string;
35
+ experimental: boolean;
36
+ findings?: undefined;
37
+ } | {
38
+ ok?: undefined;
39
+ error?: undefined;
40
+ experimental: boolean;
41
+ findings: {
42
+ question: "Are the elements in the image `prototype` in a different order or arrangement than in the image `comp`?" | "Does the image `prototype` show an element that the image `comp` does not have?" | "Is any element that is visible in the image `comp` missing from the image `prototype`?";
43
+ p: number;
44
+ id: string;
45
+ }[];
46
+ }>;
@@ -0,0 +1,93 @@
1
+ #!/usr/bin/env node
2
+ /// <reference types="node" />
3
+ // Experimental, TypeSafe on: compares a Paper frame's export with a
4
+ // screenshot of the prototype in the same state and asks Jev three narrow
5
+ // questions: is an element of the comp missing, is there one the comp does
6
+ // not have, is the order different. It never judges sizes or colours; those
7
+ // come from get_computed_styles, because Jev is weak on numbers and a
8
+ // screenshot drifts by pixels. TypeSafe marks image input as subject to
9
+ // change, so this warns and never blocks. Threshold: checks.json visual.warnAt.
10
+ import { randomUUID } from 'node:crypto';
11
+ import { existsSync, readFileSync, statSync } from 'node:fs';
12
+ import { extname, resolve } from 'node:path';
13
+ import { reaches } from '../core/checks.js';
14
+ import { imageSize } from '../core/image.js';
15
+ import { noulOf } from '../core/jev.js';
16
+ import { checks } from '../server/checks.js';
17
+ import { CliError } from './lib/cli.js';
18
+ import { judge, typesafeKey } from './lib/judge.js';
19
+ export const spec = {
20
+ name: 'visual-match',
21
+ summary: 'Experimental: ask Jev whether a prototype screenshot is missing, adds or reorders elements of its Paper comp.',
22
+ positionals: [{ name: 'prototype', description: 'The prototype slug, for the log', required: true }],
23
+ flags: {
24
+ comp: { type: 'string', required: true, description: 'The Paper frame export (PNG or JPEG).' },
25
+ shot: {
26
+ type: 'string',
27
+ required: true,
28
+ description: 'The prototype screenshot in the same state (PNG or JPEG).',
29
+ },
30
+ },
31
+ output: '{ ok, experimental, findings: [{ question, p, id }] }',
32
+ };
33
+ // TypeSafe's image limits: each side 280 to 1568 px, 78,400 to 2,458,624 pixels, 16 MiB a file.
34
+ export const IMAGE_LIMITS = {
35
+ side: [280, 1568],
36
+ pixels: [78_400, 2_458_624],
37
+ bytes: 16 * 1024 * 1024,
38
+ };
39
+ const QUESTIONS = {
40
+ missing: 'Is any element that is visible in the image `comp` missing from the image `prototype`?',
41
+ extra: 'Does the image `prototype` show an element that the image `comp` does not have?',
42
+ order: 'Are the elements in the image `prototype` in a different order or arrangement than in the image `comp`?',
43
+ };
44
+ export function asset(path, flag) {
45
+ const file = resolve(path);
46
+ if (!existsSync(file))
47
+ throw new CliError(`${flag} ${path} does not exist.`);
48
+ if (statSync(file).size > IMAGE_LIMITS.bytes)
49
+ throw new CliError(`${flag} is over 16 MiB.`);
50
+ const data = readFileSync(file);
51
+ const size = imageSize(data);
52
+ if (!size || !['.png', '.jpg', '.jpeg'].includes(extname(file).toLowerCase()))
53
+ throw new CliError(`${flag} must be a PNG or JPEG.`, { got: path });
54
+ const { width, height } = size;
55
+ const [minSide, maxSide] = IMAGE_LIMITS.side;
56
+ const [minPx, maxPx] = IMAGE_LIMITS.pixels;
57
+ if (width < minSide ||
58
+ height < minSide ||
59
+ width > maxSide ||
60
+ height > maxSide ||
61
+ width * height < minPx ||
62
+ width * height > maxPx)
63
+ throw new CliError(`${flag} is ${width}×${height}; TypeSafe takes 280 to 1568 px a side and rejects anything else.`, {
64
+ fix: `sips -Z ${maxSide} "${path}" on macOS shrinks the long side to ${maxSide} px.`,
65
+ });
66
+ return { type: 'image', format: size.format, data_base64: data.toString('base64') };
67
+ }
68
+ export async function main({ positionals: [prototype], flags }) {
69
+ if (!typesafeKey())
70
+ throw new CliError('TypeSafe is off.', { fix: 'design-hub bootstrap --typesafe-key <key>' });
71
+ if (!checks)
72
+ throw new CliError('checks.json is invalid; every Jev check is off.', {
73
+ fix: 'design-hub preflight names the problem.',
74
+ });
75
+ // Opaque references, as TypeSafe asks: the image travels once in `assets` and is named where it is meant.
76
+ const comp = randomUUID();
77
+ const shot = randomUUID();
78
+ const judged = await judge({
79
+ check: 'visual',
80
+ subject: prototype,
81
+ state: { comp, prototype: shot },
82
+ assets: { [comp]: asset(flags.comp, '--comp'), [shot]: asset(flags.shot, '--shot') },
83
+ questions: Object.fromEntries(Object.entries(QUESTIONS).map(([k, q]) => [k, { type: 'noul', instructions: q }])),
84
+ });
85
+ if (!judged)
86
+ return { ok: false, error: 'Jev did not answer; the log has the reason.', experimental: true };
87
+ const { warnAt } = checks.visual;
88
+ const findings = Object.keys(QUESTIONS).flatMap((q) => {
89
+ const p = noulOf(judged.answers, q);
90
+ return p !== null && reaches(p, warnAt) ? [{ question: QUESTIONS[q], p, id: judged.ids[q] }] : [];
91
+ });
92
+ return { experimental: true, findings };
93
+ }
@@ -0,0 +1,22 @@
1
+ import type { Checks } from '../core/checks.ts';
2
+ import type { Person } from '../core/review/model.ts';
3
+ import { type Review } from '../core/review/service.ts';
4
+ import { type Gate } from './session.ts';
5
+ export declare const API_PATH = "/api/review";
6
+ export type ApiContext = {
7
+ review: Review;
8
+ actor: Person | null;
9
+ };
10
+ export declare function handleReview(request: Request, ctx: ApiContext): Promise<Response>;
11
+ /** The person a request's signed cookie names, while they may still sign in. */
12
+ export declare function actorFrom(request: Request, review: Review, gate: Gate): Promise<Person | null>;
13
+ export type Deployed = {
14
+ review: Review;
15
+ gate: Gate;
16
+ } | {
17
+ error: string;
18
+ };
19
+ /** The review service as deployed: Upstash for the store, Jev for triage when its key is set and `checks` is given. */
20
+ export declare function deployedReview(env: Record<string, string | undefined>, checks?: Checks | null): Deployed;
21
+ /** The Vercel function's whole body; `checks` is the project's checks.json, bundled by its api/review.ts. */
22
+ export declare function deployedHandler(request: Request, checks: Checks | null): Promise<Response>;
@@ -0,0 +1,143 @@
1
+ import { SLUG } from '../core/feature.js';
2
+ import { createReview, ReviewError } from '../core/review/service.js';
3
+ import { jevTriager } from '../core/review/triage.js';
4
+ import { PERSON_COOKIE, readCookie, readGate, readPersonToken, sameOrigin } from './session.js';
5
+ import { storeConfig, upstashKV } from './upstash.js';
6
+ export const API_PATH = '/api/review';
7
+ const MAX_BODY_BYTES = 64 * 1024;
8
+ const STATUS = {
9
+ unauthorized: 401,
10
+ forbidden: 403,
11
+ not_found: 404,
12
+ invalid: 422,
13
+ };
14
+ const json = (status, body) => new Response(JSON.stringify(body), {
15
+ status,
16
+ headers: { 'content-type': 'application/json; charset=utf-8', 'cache-control': 'no-store' },
17
+ });
18
+ /** What the browser may see of a person: everything but the session epoch. */
19
+ const visible = ({ epoch: _epoch, ...p }) => p;
20
+ const str = (v) => (typeof v === 'string' ? v : '');
21
+ function prototypeOf(v) {
22
+ const s = str(v);
23
+ if (!SLUG.test(s))
24
+ throw new ReviewError('invalid', 'Name the prototype.', ['prototype: a prototype slug']);
25
+ return s;
26
+ }
27
+ async function query(op, params, { review, actor }) {
28
+ switch (op) {
29
+ case 'me':
30
+ return { person: actor ? visible(actor) : null };
31
+ case 'comments':
32
+ return { comments: await review.listComments(actor, prototypeOf(params.get('prototype'))) };
33
+ case 'people':
34
+ return { people: (await review.listPeople(actor)).map(visible) };
35
+ default:
36
+ return undefined;
37
+ }
38
+ }
39
+ async function command(op, b, { review, actor }) {
40
+ const at = () => ({ prototype: prototypeOf(b.prototype), id: str(b.id) });
41
+ const link = (r) => ({
42
+ person: visible(r.person),
43
+ link: `/invite/${r.token}`,
44
+ });
45
+ switch (op) {
46
+ case 'comment.add':
47
+ return { comment: await review.add(actor, { body: b.body, anchor: b.anchor, states: b.states }) };
48
+ case 'comment.reply':
49
+ return {
50
+ comment: await review.reply(actor, {
51
+ prototype: prototypeOf(b.prototype),
52
+ thread: str(b.thread),
53
+ body: b.body,
54
+ }),
55
+ };
56
+ case 'comment.edit':
57
+ return { comment: await review.edit(actor, { ...at(), body: b.body }) };
58
+ case 'comment.remove':
59
+ return { removed: await review.remove(actor, at()) };
60
+ case 'comment.resolve':
61
+ return { comment: await review.resolve(actor, { ...at(), resolved: b.resolved === true }) };
62
+ case 'comment.triage':
63
+ return { comment: await review.retriage(actor, { ...at(), triage: b.triage, states: b.states }) };
64
+ case 'people.invite':
65
+ return link(await review.invite(actor, { name: b.name, team: b.team, role: b.role }));
66
+ case 'people.reissue':
67
+ return link(await review.reissue(actor, str(b.id)));
68
+ case 'people.revoke':
69
+ return { person: visible(await review.revoke(actor, str(b.id))) };
70
+ default:
71
+ return undefined;
72
+ }
73
+ }
74
+ async function readJson(request) {
75
+ if (!(request.headers.get('content-type') ?? '').startsWith('application/json'))
76
+ return null;
77
+ const text = await request.text();
78
+ if (new TextEncoder().encode(text).length > MAX_BODY_BYTES)
79
+ return null;
80
+ try {
81
+ const value = JSON.parse(text);
82
+ return value && typeof value === 'object' && !Array.isArray(value)
83
+ ? value
84
+ : null;
85
+ }
86
+ catch {
87
+ return null;
88
+ }
89
+ }
90
+ export async function handleReview(request, ctx) {
91
+ try {
92
+ if (request.method === 'GET') {
93
+ const params = new URL(request.url).searchParams;
94
+ const result = await query(params.get('op') ?? '', params, ctx);
95
+ return result === undefined ? json(400, { error: 'Unknown query.' }) : json(200, result);
96
+ }
97
+ if (request.method === 'POST') {
98
+ // JSON only and same origin: a cross-site form cannot send JSON without a CORS preflight, which this never answers.
99
+ if (!sameOrigin(request))
100
+ return json(403, { error: 'Cross-origin request refused.' });
101
+ const body = await readJson(request);
102
+ if (!body)
103
+ return json(400, { error: `Send a JSON object of ${MAX_BODY_BYTES / 1024} KB or less.` });
104
+ const result = await command(str(body.op), body, ctx);
105
+ return result === undefined ? json(400, { error: 'Unknown command.' }) : json(200, result);
106
+ }
107
+ return new Response('Method not allowed', { status: 405, headers: { allow: 'GET, POST' } });
108
+ }
109
+ catch (error) {
110
+ if (error instanceof ReviewError)
111
+ return json(STATUS[error.code], { error: error.message, problems: error.problems });
112
+ console.error('[review]', error);
113
+ return json(500, { error: 'The review store did not answer. Try again.' });
114
+ }
115
+ }
116
+ /** The person a request's signed cookie names, while they may still sign in. */
117
+ export async function actorFrom(request, review, gate) {
118
+ const claim = await readPersonToken(gate, readCookie(request, PERSON_COOKIE));
119
+ return claim ? review.sessionPerson(claim.id, claim.epoch) : null;
120
+ }
121
+ /** The review service as deployed: Upstash for the store, Jev for triage when its key is set and `checks` is given. */
122
+ export function deployedReview(env, checks = null) {
123
+ const gate = readGate(env);
124
+ if (!gate)
125
+ return { error: 'The prototype gate is not configured.' };
126
+ const store = storeConfig(env);
127
+ if (!store)
128
+ return { error: 'Review is not set up on this site: pnpm bootstrap --vercel <project> --review.' };
129
+ return {
130
+ gate,
131
+ review: createReview(upstashKV(store), { triage: jevTriager(env.TYPESAFE_API_KEY ?? '', checks) }),
132
+ };
133
+ }
134
+ /** The Vercel function's whole body; `checks` is the project's checks.json, bundled by its api/review.ts. */
135
+ export async function deployedHandler(request, checks) {
136
+ const setup = deployedReview(process.env, checks);
137
+ if ('error' in setup)
138
+ return json(503, { error: setup.error });
139
+ return handleReview(request, {
140
+ review: setup.review,
141
+ actor: await actorFrom(request, setup.review, setup.gate),
142
+ });
143
+ }
@@ -0,0 +1,4 @@
1
+ import { type Checks } from '../core/checks.ts';
2
+ export declare const checkProblems: string[];
3
+ /** The validated thresholds, or null when checks.json is invalid (every Jev check is then off). */
4
+ export declare const checks: Checks | null;
@@ -0,0 +1,22 @@
1
+ /// <reference types="node" />
2
+ // The project's checks, read once: .design-hub/checks.json when the project
3
+ // has one, the package defaults when it does not. The scripts and the dev
4
+ // server import `checks` from here; the Vercel function gets its copy bundled
5
+ // by the project's api/review.ts.
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { DEFAULT_CHECKS, loadChecks } from '../core/checks.js';
8
+ import { CHECKS_FILE } from './paths.js';
9
+ function readChecks(file = CHECKS_FILE) {
10
+ if (!existsSync(file))
11
+ return { checks: DEFAULT_CHECKS, problems: [] };
12
+ try {
13
+ return loadChecks(JSON.parse(readFileSync(file, 'utf8')));
14
+ }
15
+ catch (error) {
16
+ return { checks: null, problems: [`${file}: ${error instanceof Error ? error.message : String(error)}`] };
17
+ }
18
+ }
19
+ const loaded = readChecks();
20
+ export const checkProblems = loaded.problems;
21
+ /** The validated thresholds, or null when checks.json is invalid (every Jev check is then off). */
22
+ export const checks = loaded.checks;
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from 'vite';
2
+ export declare function devReview(): Plugin;
@@ -0,0 +1,54 @@
1
+ import { createReview } from '../core/review/service.js';
2
+ import { KEY } from '../core/review/store.js';
3
+ import { jevTriager } from '../core/review/triage.js';
4
+ import { API_PATH, handleReview } from './api.js';
5
+ import { checks } from './checks.js';
6
+ import { readEnv } from './env.js';
7
+ import { fileKV } from './file-kv.js';
8
+ import { LOCAL_STORE, localEditor } from './local.js';
9
+ import { ROOT } from './paths.js';
10
+ // Connect strips the mount path from req.url; originalUrl keeps the query the handler reads.
11
+ async function toRequest(req) {
12
+ const chunks = [];
13
+ for await (const chunk of req)
14
+ chunks.push(chunk);
15
+ const headers = new Headers();
16
+ for (const [k, v] of Object.entries(req.headers))
17
+ if (typeof v === 'string')
18
+ headers.set(k, v);
19
+ const body = chunks.length ? Buffer.concat(chunks) : undefined;
20
+ return new Request(`http://${req.headers.host ?? 'localhost'}${req.originalUrl ?? req.url}`, {
21
+ method: req.method,
22
+ headers,
23
+ body: req.method === 'GET' || req.method === 'HEAD' ? undefined : body,
24
+ });
25
+ }
26
+ async function send(res, response) {
27
+ res.statusCode = response.status;
28
+ response.headers.forEach((value, key) => {
29
+ res.setHeader(key, value);
30
+ });
31
+ res.end(Buffer.from(await response.arrayBuffer()));
32
+ }
33
+ export function devReview() {
34
+ return {
35
+ name: 'dev-review',
36
+ apply: 'serve',
37
+ configureServer(server) {
38
+ const kv = fileKV(LOCAL_STORE);
39
+ // The project's .env only, never the shell: TypeSafe is on only where the project opted in.
40
+ const review = createReview(kv, {
41
+ triage: jevTriager(readEnv(ROOT).TYPESAFE_API_KEY ?? '', checks),
42
+ });
43
+ const actor = localEditor();
44
+ // The local editor is in the people list like anyone else, so the people panel shows them.
45
+ void kv.hset(KEY.people, actor.id, JSON.stringify(actor));
46
+ server.middlewares.use(API_PATH, (req, res, nextMiddleware) => {
47
+ toRequest(req)
48
+ .then((request) => handleReview(request, { review, actor }))
49
+ .then((response) => send(res, response))
50
+ .catch(nextMiddleware);
51
+ });
52
+ },
53
+ };
54
+ }
@@ -0,0 +1,8 @@
1
+ export declare function readEnv(dir: string): Record<string, string>;
2
+ export declare function writeEnv(dir: string, updates: Record<string, string>): void;
3
+ /**
4
+ * The dev server's port. Inside a Conductor workspace, Conductor's own port
5
+ * wins, because its Open button always points there; everywhere else it is
6
+ * the PORT that `pnpm bootstrap` wrote to .env.
7
+ */
8
+ export declare function devPort(fileEnv: Record<string, string>, processEnv?: Record<string, string | undefined>): number | undefined;