@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,238 @@
1
+ // Vercel Routing Middleware (the project's proxy.ts, named in vercel.json as
2
+ // proxy.entrypoint): the gate in front of every path of the deployed
3
+ // prototypes, the review API included.
4
+ // Two ways in, both signed cookies (server/session.ts):
5
+ //
6
+ // the shared password view only; the form posts back to the same URL and
7
+ // keeps the hash through the redirect
8
+ // an invite link /invite/<token> signs one person in, to comment as
9
+ // themselves; a revoke or reissue ends it on their
10
+ // next page load
11
+ //
12
+ // With GATE_PASSWORD or GATE_SECRET missing the gate fails closed (503).
13
+ import { next } from '@vercel/functions';
14
+ import { base64, sha256 } from '../core/bytes.js';
15
+ import { escapeHtml } from '../core/html.js';
16
+ import { deployedReview } from '../server/api.js';
17
+ import { clearedCookie, GATE_COOKIE, gateTokenIsValid, issueGateToken, issuePersonToken, PERSON_COOKIE, passwordMatches, readCookie, readGate, readPersonToken, sameOrigin, sessionCookie, } from '../server/session.js';
18
+ const DEFAULT_SITE_NAME = 'Prototypes';
19
+ const WRONG_PASSWORD_DELAY_MS = 800;
20
+ const MAX_PASSWORD_LENGTH = 256;
21
+ const INVITE_PATH = /^\/invite\/([\w-]{20,100})\/?$/;
22
+ // What the gate page itself loads, served before sign-in: the hub's face
23
+ // (the Vite plugin emits it at this fixed path) and the favicon. Nothing else.
24
+ export const GATE_FONT = '/_gate/geist.woff2';
25
+ const GATE_ASSETS = new Set([GATE_FONT, '/favicon.svg']);
26
+ // The gate page. CSS and JS are constants so their CSP hashes can be computed
27
+ // once and the page carries no 'unsafe-inline'. The gate is hub chrome, so it
28
+ // wears the hub's neutral system look (shell/system.css) inlined, never the
29
+ // project's brand. Geist (latin, variable) is the one font, preloaded so the
30
+ // first paint is already in it.
31
+ const CSS = `
32
+ @font-face{font-family:Geist;src:url(${GATE_FONT}) format("woff2");font-weight:100 900;font-style:normal;font-display:swap}
33
+ :root{color-scheme:light;--bg:oklch(.985 0 0);--surface:oklch(1 0 0);--ink:oklch(.205 0 0);--ink-2:oklch(.371 0 0);--ink-3:oklch(.46 0 0);--ink-4:oklch(.54 0 0);--line:oklch(.87 0 0);--fill-hover:oklch(.97 0 0);--brand:oklch(.205 0 0);--brand-hover:oklch(.3 0 0);--on-brand:oklch(.985 0 0);--error:oklch(.505 .213 27.518);--error-line:oklch(.637 .237 25.331);--ring:0 0 0 2px oklch(1 0 0),0 0 0 4px oklch(.556 0 0);--ring-error:0 0 0 2px oklch(1 0 0),0 0 0 4px oklch(.637 .237 25.331);--shadow:0 1px 2px 0 oklch(0 0 0/.05);--raised:0 0 0 1px oklch(0 0 0/.06),0 1px 2px oklch(0 0 0/.05),0 12px 24px -8px oklch(0 0 0/.1);--ease-out:cubic-bezier(.23,1,.32,1);--ease-press:cubic-bezier(.33,1,.68,1);--ease-hover:cubic-bezier(.25,.46,.45,.94)}
34
+ @media(prefers-color-scheme:dark){:root{color-scheme:dark;--bg:oklch(.145 0 0);--surface:oklch(.175 0 0);--ink:oklch(.985 0 0);--ink-2:oklch(.87 0 0);--ink-3:oklch(.72 0 0);--ink-4:oklch(.66 0 0);--line:oklch(.32 0 0);--fill-hover:oklch(.215 0 0);--brand:oklch(.985 0 0);--brand-hover:oklch(.87 0 0);--on-brand:oklch(.205 0 0);--error:oklch(.704 .191 22.216);--error-line:oklch(.704 .191 22.216);--ring:0 0 0 2px oklch(.175 0 0),0 0 0 4px oklch(.708 0 0);--ring-error:0 0 0 2px oklch(.175 0 0),0 0 0 4px oklch(.704 .191 22.216);--shadow:0 0 #0000;--raised:0 0 0 1px oklch(1 0 0/.09)}}
35
+ *{box-sizing:border-box}
36
+ html{height:100%;-webkit-text-size-adjust:100%;-webkit-tap-highlight-color:transparent}
37
+ body{margin:0;min-height:100%;display:grid;place-items:center;padding:max(24px,env(safe-area-inset-top)) max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left));background:var(--bg);color:var(--ink);font:14px/20px Geist,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif;font-optical-sizing:auto;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;text-rendering:optimizeLegibility}
38
+ [hidden]{display:none!important}
39
+ .gate{width:100%;max-width:360px;padding:32px;background:var(--surface);border-radius:12px;box-shadow:var(--raised);transition:opacity 240ms var(--ease-out),transform 240ms var(--ease-out)}
40
+ @starting-style{.gate{opacity:0;transform:translateY(6px)}}
41
+ h1{margin:0 0 4px;font-size:20px;line-height:28px;font-weight:600;letter-spacing:-.02em;text-wrap:balance}
42
+ .hint{margin:0 0 24px;color:var(--ink-3);text-wrap:pretty}
43
+ .hint strong{font-weight:500;color:var(--ink)}
44
+ label{display:block;margin-bottom:6px;font-weight:500;color:var(--ink-2)}
45
+ .field{position:relative}
46
+ input{display:block;width:100%;height:40px;padding:0 44px 0 12px;border:1px solid var(--line);border-radius:6px;background:var(--surface);color:var(--ink);box-shadow:var(--shadow);font:inherit;transition:border-color 120ms var(--ease-hover),box-shadow 120ms var(--ease-hover)}
47
+ input:focus-visible{outline:2px solid transparent;outline-offset:2px;box-shadow:var(--ring),var(--shadow)}
48
+ input[aria-invalid=true]{border-color:var(--error-line)}
49
+ input[aria-invalid=true]:focus-visible{box-shadow:var(--ring-error),var(--shadow)}
50
+ .toggle{position:absolute;top:4px;right:4px;width:32px;height:32px;display:grid;place-items:center;margin:0;padding:0;border:0;border-radius:2px;background:transparent;color:var(--ink-4);cursor:pointer;touch-action:manipulation;-webkit-user-select:none;user-select:none;transition:color 120ms var(--ease-hover),background-color 120ms var(--ease-hover)}
51
+ .toggle::before{content:"";position:absolute;inset:-4px}
52
+ .toggle svg{display:block}
53
+ .toggle:focus-visible{outline:2px solid transparent;box-shadow:var(--ring)}
54
+ .toggle:active{color:var(--ink-2)}
55
+ .toggle[aria-pressed=true] .show,.toggle[aria-pressed=false] .hide{display:none}
56
+ @media(hover:hover) and (pointer:fine){.toggle:hover{color:var(--ink-2);background:var(--fill-hover)}}
57
+ .note{margin:8px 0 0;color:var(--ink-3)}
58
+ .error{color:var(--error)}
59
+ .go{display:block;width:100%;height:44px;margin-top:20px;border:0;border-radius:6px;background:var(--brand);color:var(--on-brand);font:inherit;font-weight:500;cursor:pointer;touch-action:manipulation;-webkit-user-select:none;user-select:none;box-shadow:var(--shadow);transition:background-color 120ms var(--ease-hover),transform 160ms var(--ease-press),opacity 120ms var(--ease-hover)}
60
+ .go:focus-visible{outline:2px solid transparent;outline-offset:2px;box-shadow:var(--ring)}
61
+ .go:active{transform:scale(.97)}
62
+ form[data-busy] .go{opacity:.7;pointer-events:none}
63
+ @media(hover:hover) and (pointer:fine){.go:hover{background:var(--brand-hover)}}
64
+ @media(pointer:coarse){input{height:44px;font-size:16px}.toggle{width:36px;height:36px}}
65
+ @media(max-width:400px){.gate{padding:24px}}
66
+ @media(prefers-reduced-motion:reduce){.gate{transition:opacity 240ms var(--ease-out)}@starting-style{.gate{transform:none}}.go:active{transform:none}}
67
+ `;
68
+ // Keeps the hash route the visitor asked for (a form posts to its action
69
+ // without the fragment), shows the password on request, and says when Caps
70
+ // Lock is on. The toggle swallows pointerdown so the caret stays in the field.
71
+ const JS = `var f=document.forms[0],p=document.getElementById('password'),t=document.getElementById('toggle'),c=document.getElementById('caps');
72
+ if(location.hash){f.action=location.pathname+location.search+location.hash}
73
+ t.addEventListener('pointerdown',function(e){e.preventDefault()});
74
+ t.addEventListener('click',function(){var s=p.type==='password';p.type=s?'text':'password';t.setAttribute('aria-pressed',s);t.setAttribute('aria-label',s?'Hide password':'Show password')});
75
+ function caps(e){if(e.getModifierState){c.hidden=!e.getModifierState('CapsLock')}}
76
+ p.addEventListener('keydown',caps);p.addEventListener('keyup',caps);p.addEventListener('blur',function(){c.hidden=true});
77
+ f.addEventListener('submit',function(){f.setAttribute('data-busy','')});
78
+ addEventListener('pageshow',function(){f.removeAttribute('data-busy')});`;
79
+ const EYE = '<svg class="show" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2.42 12.713c-.136-.215-.204-.323-.242-.49a1.173 1.173 0 0 1 0-.446c.038-.167.106-.274.242-.49C3.546 9.505 6.895 5 12 5s8.455 4.505 9.58 6.287c.137.215.205.323.243.49.029.125.029.322 0 .446-.038.167-.106.274-.242.49C20.454 14.495 17.105 19 12 19S3.545 14.495 2.42 12.713Z"/><path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Z"/></svg>' +
80
+ '<svg class="hide" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.743 5.092C11.149 5.032 11.569 5 12 5c5.105 0 8.455 4.505 9.58 6.287.137.215.205.323.243.49.029.125.029.322 0 .446-.038.167-.107.275-.244.492-.3.474-.757 1.141-1.363 1.865M6.724 6.715c-2.162 1.467-3.63 3.504-4.303 4.57-.137.216-.205.324-.243.49a1.173 1.173 0 0 0 0 .446c.038.167.106.274.242.49C3.546 14.495 6.895 19 12 19c2.059 0 3.832-.732 5.289-1.723M3 3l18 18M9.879 9.879a3 3 0 1 0 4.243 4.243"/></svg>';
81
+ function shell(siteName, body, script) {
82
+ return `<!doctype html>
83
+ <html lang="en">
84
+ <head>
85
+ <meta charset="utf-8">
86
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
87
+ <meta name="robots" content="noindex, nofollow">
88
+ <meta name="color-scheme" content="light dark">
89
+ <title>${escapeHtml(siteName)}</title>
90
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
91
+ <link rel="preload" href="${GATE_FONT}" as="font" type="font/woff2" crossorigin>
92
+ <style>${CSS}</style>
93
+ </head>
94
+ <body>
95
+ <main class="gate">
96
+ <h1>${escapeHtml(siteName)}</h1>
97
+ ${body}
98
+ </main>
99
+ ${script ? `<script>${JS}</script>` : ''}
100
+ </body>
101
+ </html>
102
+ `;
103
+ }
104
+ function passwordPage(siteName, failed) {
105
+ const error = failed
106
+ ? `<p id="err" class="note error" role="alert">That password didn’t match. Try again.</p>`
107
+ : '';
108
+ const invalid = failed ? ' aria-invalid="true" aria-describedby="err"' : '';
109
+ return shell(siteName, `<p class="hint">Use the password from the message that shared this link.</p>
110
+ <form method="post" novalidate>
111
+ <label for="password">Password</label>
112
+ <div class="field">
113
+ <input id="password" name="password" type="password" autocomplete="current-password" autocapitalize="off" autocorrect="off" spellcheck="false" required autofocus maxlength="${MAX_PASSWORD_LENGTH}"${invalid}>
114
+ <button id="toggle" class="toggle" type="button" aria-pressed="false" aria-label="Show password" aria-controls="password">${EYE}</button>
115
+ </div>
116
+ <p id="caps" class="note" aria-live="polite" hidden>Caps Lock is on.</p>
117
+ ${error}
118
+ <button class="go" type="submit">Open prototypes</button>
119
+ </form>`, true);
120
+ }
121
+ // A GET shows who the link is for and a POST signs them in, so a mail
122
+ // scanner that opens every link never uses the invite.
123
+ function invitePage(siteName, person) {
124
+ if (!person)
125
+ return shell(siteName, `<p class="hint">This invite link has expired or was revoked. Ask whoever sent it for a new one.</p>`, false);
126
+ return shell(siteName, `<p class="hint">You’re invited to review as <strong>${escapeHtml(person.name)}</strong>, ${escapeHtml(person.team)}.</p>
127
+ <form method="post"><button class="go" type="submit" autofocus>Continue</button></form>`, false);
128
+ }
129
+ // `design-hub dev` has no gate; the Vite plugin serves this at /_gate/ so the page can be reviewed.
130
+ export async function gatePreview(failed, siteName = DEFAULT_SITE_NAME) {
131
+ return { html: passwordPage(siteName, failed), csp: (await csp()).withScript };
132
+ }
133
+ let cspPromise;
134
+ function csp() {
135
+ cspPromise ??= Promise.all([sha256(CSS), sha256(JS)]).then(([css, js]) => {
136
+ const base = `default-src 'none'; style-src 'sha256-${base64(css)}'; font-src 'self'; img-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'`;
137
+ return {
138
+ withScript: `${base}; script-src 'sha256-${base64(js)}'`,
139
+ noScript: `${base}; script-src 'none'`,
140
+ };
141
+ });
142
+ return cspPromise;
143
+ }
144
+ const NO_STORE = 'no-store, max-age=0';
145
+ function wantsHtml(request) {
146
+ const dest = request.headers.get('sec-fetch-dest');
147
+ if (dest)
148
+ return dest === 'document' || dest === 'iframe';
149
+ return (request.headers.get('accept') ?? '').includes('text/html');
150
+ }
151
+ async function htmlPage(request, status, html, script, cookie) {
152
+ const headers = new Headers({ 'cache-control': NO_STORE, 'x-robots-tag': 'noindex, nofollow' });
153
+ if (cookie)
154
+ headers.append('set-cookie', cookie);
155
+ if (!wantsHtml(request)) {
156
+ headers.set('content-type', 'text/plain; charset=utf-8');
157
+ return new Response(request.method === 'HEAD' ? null : 'Password required.', { status, headers });
158
+ }
159
+ const policy = await csp();
160
+ headers.set('content-type', 'text/html; charset=utf-8');
161
+ headers.set('content-security-policy', script ? policy.withScript : policy.noScript);
162
+ headers.set('referrer-policy', 'no-referrer');
163
+ return new Response(request.method === 'HEAD' ? null : html, { status, headers });
164
+ }
165
+ const plain = (status, text, extra = {}) => new Response(text, {
166
+ status,
167
+ headers: { 'cache-control': NO_STORE, 'content-type': 'text/plain; charset=utf-8', ...extra },
168
+ });
169
+ const redirect = (location, cookie) => new Response(null, { status: 303, headers: { location, 'cache-control': NO_STORE, 'set-cookie': cookie } });
170
+ async function readPassword(request) {
171
+ if (!(request.headers.get('content-type') ?? '').startsWith('application/x-www-form-urlencoded'))
172
+ return null;
173
+ try {
174
+ const value = (await request.formData()).get('password');
175
+ return typeof value === 'string' && value.length <= MAX_PASSWORD_LENGTH ? value : null;
176
+ }
177
+ catch {
178
+ return null;
179
+ }
180
+ }
181
+ async function invite(siteName, request, token) {
182
+ const setup = deployedReview(process.env);
183
+ if ('error' in setup)
184
+ return plain(503, setup.error);
185
+ if (request.method === 'POST' && !sameOrigin(request))
186
+ return plain(403, 'Forbidden');
187
+ const person = await setup.review.acceptInvite(token);
188
+ if (request.method !== 'POST' || !person)
189
+ return htmlPage(request, person ? 200 : 404, invitePage(siteName, person), false);
190
+ const cookie = sessionCookie(PERSON_COOKIE, await issuePersonToken(setup.gate, { id: person.id, epoch: person.epoch }));
191
+ return redirect('/', cookie);
192
+ }
193
+ const pass = () => next({ headers: { 'cache-control': 'private, no-store' } });
194
+ // The signature alone admits assets; a page load also asks the store, so a
195
+ // revoke lands on the person's next navigation without a store read per asset.
196
+ async function personSession(siteName, request, claim) {
197
+ if (!wantsHtml(request))
198
+ return pass();
199
+ const setup = deployedReview(process.env);
200
+ const current = 'error' in setup ? null : await setup.review.sessionPerson(claim.id, claim.epoch).catch(() => null);
201
+ if (current)
202
+ return pass();
203
+ return htmlPage(request, 401, passwordPage(siteName, false), true, clearedCookie(PERSON_COOKIE));
204
+ }
205
+ async function passwordPost(siteName, request, gate, url) {
206
+ if (!sameOrigin(request))
207
+ return plain(403, 'Forbidden');
208
+ const submitted = await readPassword(request);
209
+ if (submitted === null || !(await passwordMatches(gate, submitted))) {
210
+ await new Promise((resolve) => setTimeout(resolve, WRONG_PASSWORD_DELAY_MS));
211
+ return htmlPage(request, 401, passwordPage(siteName, true), true);
212
+ }
213
+ return redirect(url.pathname + url.search, sessionCookie(GATE_COOKIE, await issueGateToken(gate)));
214
+ }
215
+ /** The middleware a project's proxy.ts default-exports. */
216
+ export function createProxy({ siteName = DEFAULT_SITE_NAME } = {}) {
217
+ return async function proxy(request) {
218
+ const gate = readGate(process.env);
219
+ if (!gate)
220
+ return plain(503, 'The prototype gate is not configured.');
221
+ const url = new URL(request.url);
222
+ const invited = url.pathname.match(INVITE_PATH);
223
+ if (invited && ['GET', 'HEAD', 'POST'].includes(request.method))
224
+ return invite(siteName, request, invited[1]);
225
+ if (GATE_ASSETS.has(url.pathname) && (request.method === 'GET' || request.method === 'HEAD'))
226
+ return next();
227
+ if (await gateTokenIsValid(gate, readCookie(request, GATE_COOKIE)))
228
+ return pass();
229
+ const claim = await readPersonToken(gate, readCookie(request, PERSON_COOKIE));
230
+ if (claim)
231
+ return personSession(siteName, request, claim);
232
+ if (request.method === 'GET' || request.method === 'HEAD')
233
+ return htmlPage(request, 401, passwordPage(siteName, false), true);
234
+ if (request.method === 'POST')
235
+ return passwordPost(siteName, request, gate, url);
236
+ return plain(405, 'Method not allowed', { allow: 'GET, HEAD, POST' });
237
+ };
238
+ }
@@ -0,0 +1,4 @@
1
+ import type { Plugin } from 'vite';
2
+ /** The file `@kit/<rest>` names in the first of `dirs` that has it, or null. */
3
+ export declare function findKitFile(dirs: string[], rest: string): string | null;
4
+ export declare function kitResolver(projectKit: string, packageKit: string): Plugin;
@@ -0,0 +1,35 @@
1
+ /// <reference types="node" />
2
+ // `@kit/<name>` is the project's kit/ first and this package's kit/ second,
3
+ // so a project forks a kit piece by copying the file into kit/ under the same
4
+ // name and changing the copy: every import of it, the hub's included, then
5
+ // takes the project's. `@milesroxas/design-hub/kit/<name>` always means the package's
6
+ // copy, for a project file that extends it (kit/icons.ts adds glyphs to the base set).
7
+ import { existsSync, statSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ const KIT_PREFIX = '@kit/';
10
+ const PACKAGE_PREFIX = '@milesroxas/design-hub/kit/';
11
+ const CANDIDATES = ['', '.ts', '.tsx', '.css', '.json', '/index.ts', '/index.tsx'];
12
+ const isFile = (path) => existsSync(path) && statSync(path).isFile();
13
+ /** The file `@kit/<rest>` names in the first of `dirs` that has it, or null. */
14
+ export function findKitFile(dirs, rest) {
15
+ for (const dir of dirs)
16
+ for (const ext of CANDIDATES) {
17
+ const path = join(dir, rest + ext);
18
+ if (isFile(path))
19
+ return path;
20
+ }
21
+ return null;
22
+ }
23
+ export function kitResolver(projectKit, packageKit) {
24
+ return {
25
+ name: 'design-hub-kit',
26
+ enforce: 'pre',
27
+ resolveId(id) {
28
+ if (id.startsWith(KIT_PREFIX))
29
+ return findKitFile([projectKit, packageKit], id.slice(KIT_PREFIX.length));
30
+ if (id.startsWith(PACKAGE_PREFIX))
31
+ return findKitFile([packageKit], id.slice(PACKAGE_PREFIX.length));
32
+ return null;
33
+ },
34
+ };
35
+ }
@@ -0,0 +1,2 @@
1
+ import type { Plugin } from 'vite';
2
+ export declare function markdownDocs(): Plugin;
@@ -0,0 +1,212 @@
1
+ /// <reference types="node" />
2
+ import { readFileSync } from 'node:fs';
3
+ import { dirname, resolve } from 'node:path';
4
+ import { Marked } from 'marked';
5
+ import { frontmatter } from '../core/frontmatter.js';
6
+ import { escapeHtml } from '../core/html.js';
7
+ import { imageSize } from '../core/image.js';
8
+ // `import { html, title, toc, meta } from './x.md?doc'` compiles a Markdown
9
+ // document at build time: the frontmatter, the first H1 as the title, a table
10
+ // of contents, and the body as HTML. Every image becomes a Vite import, so it
11
+ // ships hashed and carries its pixel size, and GitHub alerts become callouts.
12
+ // Links to other Markdown files lose their href: nothing serves them here.
13
+ // A paragraph that starts with `Controls → …` is a click path: each piece is
14
+ // one press in the prototype panel, in order, and the last is the one you set.
15
+ const SUFFIX = '?doc';
16
+ const ALERT = /^\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*/i;
17
+ export function markdownDocs() {
18
+ return {
19
+ name: 'markdown-docs',
20
+ enforce: 'pre',
21
+ load(id) {
22
+ if (!id.endsWith(SUFFIX))
23
+ return null;
24
+ const file = id.slice(0, -SUFFIX.length);
25
+ this.addWatchFile(file);
26
+ return compile(readFileSync(file, 'utf8'), file);
27
+ },
28
+ };
29
+ }
30
+ const IMG = '\u0000';
31
+ function compile(source, file) {
32
+ const { data, body } = frontmatter(source);
33
+ const ids = new Map();
34
+ const slug = (text) => {
35
+ const base = text
36
+ .toLowerCase()
37
+ .replace(/[^\p{L}\p{N}\s-]/gu, '')
38
+ .trim()
39
+ .replace(/\s+/g, '-') || 'section';
40
+ const n = ids.get(base) ?? 0;
41
+ ids.set(base, n + 1);
42
+ return n ? `${base}-${n}` : base;
43
+ };
44
+ const toc = [];
45
+ const images = [];
46
+ let title = '';
47
+ const md = new Marked({
48
+ gfm: true,
49
+ renderer: {
50
+ heading({ tokens, depth, text }) {
51
+ const id = slug(text);
52
+ if (depth === 2 || depth === 3)
53
+ toc.push({ id, text, depth });
54
+ return `<h${depth} id="${id}">${this.parser.parseInline(tokens)}</h${depth}>\n`;
55
+ },
56
+ paragraph({ tokens }) {
57
+ const path = clickPath(tokens, (t) => this.parser.parseInline(t));
58
+ if (path)
59
+ return path;
60
+ if (tokens.length === 1 && tokens[0].type === 'image')
61
+ return this.parser.parseInline(tokens);
62
+ return `<p>${this.parser.parseInline(tokens)}</p>\n`;
63
+ },
64
+ tablecell(token) {
65
+ const path = clickPath(token.tokens, (t) => this.parser.parseInline(t));
66
+ const tag = token.header ? 'th' : 'td';
67
+ const open = token.align ? `<${tag} align="${token.align}">` : `<${tag}>`;
68
+ const inner = path ?? this.parser.parseInline(token.tokens);
69
+ return `${open}${inner}</${tag}>`;
70
+ },
71
+ image({ href, text }) {
72
+ const n = images.push(href) - 1;
73
+ const size = imageSizeOf(resolve(dirname(file), href));
74
+ const dims = size ? ` width="${size.width}" height="${size.height}"` : '';
75
+ const loading = n === 0 ? 'eager' : 'lazy';
76
+ return (`<figure class="doc-figure"><img src="${IMG}${n}${IMG}" alt=""${dims} loading="${loading}" decoding="async">` +
77
+ `<figcaption>${escapeHtml(text)} <a href="${IMG}${n}${IMG}" target="_blank" rel="noreferrer">Full size</a></figcaption></figure>\n`);
78
+ },
79
+ link({ href, tokens }) {
80
+ const inner = this.parser.parseInline(tokens);
81
+ if (/\.md(#|$)/.test(href))
82
+ return inner;
83
+ const external = /^https?:/.test(href) ? ' target="_blank" rel="noreferrer"' : '';
84
+ return `<a href="${escapeHtml(href)}"${external}>${inner}</a>`;
85
+ },
86
+ blockquote(token) {
87
+ const kind = token.alert;
88
+ const inner = this.parser.parse(token.tokens);
89
+ if (!kind)
90
+ return `<blockquote>${inner}</blockquote>\n`;
91
+ const label = kind[0].toUpperCase() + kind.slice(1);
92
+ return `<aside class="doc-callout" data-kind="${kind}"><p class="doc-callout-kind">${label}</p>${inner}</aside>\n`;
93
+ },
94
+ },
95
+ });
96
+ const tokens = md.lexer(body);
97
+ const at = tokens.findIndex((t) => t.type === 'heading' && t.depth === 1);
98
+ if (at >= 0) {
99
+ title = tokens[at].text;
100
+ tokens.splice(at, 1);
101
+ }
102
+ for (const t of tokens)
103
+ if (t.type === 'blockquote')
104
+ markAlert(t);
105
+ const html = md
106
+ .parser(tokens)
107
+ .replace(/<table>/g, '<div class="doc-table"><table>')
108
+ .replace(/<\/table>/g, '</table></div>');
109
+ const imports = images.map((href, n) => `import img${n} from ${JSON.stringify(relative(href))};`);
110
+ const parts = html.split(IMG).map((part, i) => (i % 2 ? `img${part}` : JSON.stringify(part)));
111
+ return [
112
+ ...imports,
113
+ `export const meta = ${JSON.stringify(data)};`,
114
+ `export const title = ${JSON.stringify(title)};`,
115
+ `export const toc = ${JSON.stringify(toc)};`,
116
+ `export const html = ${parts.join(' + ')};`,
117
+ ].join('\n');
118
+ }
119
+ const relative = (href) => (href.startsWith('.') || href.startsWith('/') ? href : `./${href}`);
120
+ // `Controls → Review → **Reset to the comps**` on its own line. A sentence may
121
+ // follow the last step after `. `. Anything that does not start at Controls
122
+ // stays prose, so an arrow in a sentence is not a path. The first step is the
123
+ // closed panel's button: DialKit's sliders glyph, not the word.
124
+ const PATH_TOKEN = new Set(['text', 'strong', 'em', 'codespan', 'escape']);
125
+ /** The inline tokens split at each ` → `, one list per step, and how many arrows there were. */
126
+ function splitSteps(tokens) {
127
+ const steps = [[]];
128
+ let arrows = 0;
129
+ for (const token of tokens) {
130
+ if (token.type !== 'text') {
131
+ steps.at(-1)?.push(token);
132
+ continue;
133
+ }
134
+ const parts = token.text.split(' → ');
135
+ arrows += parts.length - 1;
136
+ parts.forEach((part, i) => {
137
+ if (i > 0)
138
+ steps.push([]);
139
+ if (part)
140
+ steps.at(-1)?.push({ type: 'text', raw: part, text: part });
141
+ });
142
+ }
143
+ return { steps, arrows };
144
+ }
145
+ /** A sentence after the last step (`… → **dark**. Then reload.`) comes off the step and reads as prose. */
146
+ function peelFollow(last) {
147
+ const tail = last.at(-1);
148
+ if (tail?.type !== 'text')
149
+ return [];
150
+ const peeled = tail.text.match(/^([\s\S]*?)\.\s+(\S[\s\S]*)$/);
151
+ if (!peeled)
152
+ return [];
153
+ if (peeled[1])
154
+ tail.text = peeled[1];
155
+ else
156
+ last.pop();
157
+ return [{ type: 'text', raw: peeled[2], text: peeled[2] }];
158
+ }
159
+ function clickPath(tokens, inline) {
160
+ if (!tokens.length || tokens.some((t) => !PATH_TOKEN.has(t.type)))
161
+ return null;
162
+ const { steps, arrows } = splitSteps(tokens);
163
+ if (arrows < 1 || steps.some((step) => step.length === 0) || plain(steps[0]) !== 'Controls')
164
+ return null;
165
+ const last = steps[steps.length - 1];
166
+ const follow = peelFollow(last);
167
+ if (!last.length)
168
+ return null;
169
+ const items = steps
170
+ .map((step, i) => `<li>${i === 0 ? CONTROLS_MARK : `<span>${inline(step)}</span>`}</li>`)
171
+ .join('');
172
+ const after = follow.length ? `<p class="doc-path-follow">${inline(follow)}</p>` : '';
173
+ return `<ol class="doc-path" aria-label="Click in order">${items}</ol>${after}\n`;
174
+ }
175
+ // The closed Controls button, from DialKit's ICON_PANEL (viewBox 0 0 16 16).
176
+ const CONTROLS_MARK = '<span class="doc-path-open"><svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden="true" focusable="false">' +
177
+ '<path opacity="0.5" fill="currentColor" d="M6.84766 11.75C6.78583 11.9899 6.75 12.2408 6.75 12.5C6.75 12.7592 6.78583 13.0101 6.84766 13.25H2C1.58579 13.25 1.25 12.9142 1.25 12.5C1.25 12.0858 1.58579 11.75 2 11.75H6.84766ZM14 11.75C14.4142 11.75 14.75 12.0858 14.75 12.5C14.75 12.9142 14.4142 13.25 14 13.25H12.6523C12.7142 13.0101 12.75 12.7592 12.75 12.5C12.75 12.2408 12.7142 11.9899 12.6523 11.75H14ZM3.09766 7.25C3.03583 7.48994 3 7.74075 3 8C3 8.25925 3.03583 8.51006 3.09766 8.75H2C1.58579 8.75 1.25 8.41421 1.25 8C1.25 7.58579 1.58579 7.25 2 7.25H3.09766ZM14 7.25C14.4142 7.25 14.75 7.58579 14.75 8C14.75 8.41421 14.4142 8.75 14 8.75H8.90234C8.96417 8.51006 9 8.25925 9 8C9 7.74075 8.96417 7.48994 8.90234 7.25H14ZM7.59766 2.75C7.53583 2.98994 7.5 3.24075 7.5 3.5C7.5 3.75925 7.53583 4.01006 7.59766 4.25H2C1.58579 4.25 1.25 3.91421 1.25 3.5C1.25 3.08579 1.58579 2.75 2 2.75H7.59766ZM14 2.75C14.4142 2.75 14.75 3.08579 14.75 3.5C14.75 3.91421 14.4142 4.25 14 4.25H13.4023C13.4642 4.01006 13.5 3.75925 13.5 3.5C13.5 3.24075 13.4642 2.98994 13.4023 2.75H14Z"/>' +
178
+ '<circle cx="6" cy="8" r="0.998596" fill="currentColor" stroke="currentColor" stroke-width="1.25"/>' +
179
+ '<circle cx="10.4999" cy="3.5" r="0.998657" fill="currentColor" stroke="currentColor" stroke-width="1.25"/>' +
180
+ '<circle cx="9.75015" cy="12.5" r="0.997986" fill="currentColor" stroke="currentColor" stroke-width="1.25"/>' +
181
+ '</svg><span class="doc-path-name">Controls</span></span>';
182
+ const plain = (tokens) => tokens
183
+ .map((t) => ('text' in t ? t.text : ''))
184
+ .join('')
185
+ .trim();
186
+ // `> [!NOTE]` on the first line of a quote makes it a callout of that kind.
187
+ function markAlert(quote) {
188
+ const first = quote.tokens[0];
189
+ if (first?.type !== 'paragraph')
190
+ return;
191
+ const para = first;
192
+ const lead = para.tokens[0];
193
+ if (lead?.type !== 'text' || !ALERT.test(lead.text))
194
+ return;
195
+ quote.alert = lead.text.match(ALERT)[1].toLowerCase();
196
+ lead.text = lead.text.replace(ALERT, '');
197
+ lead.raw = lead.raw.replace(ALERT, '');
198
+ para.text = para.text.replace(ALERT, '');
199
+ if (!lead.text)
200
+ para.tokens.shift();
201
+ if (!para.tokens.length)
202
+ quote.tokens.shift();
203
+ }
204
+ function imageSizeOf(path) {
205
+ try {
206
+ return imageSize(readFileSync(path));
207
+ }
208
+ catch {
209
+ // A missing image fails the build at its Vite import, which names the file; here it only goes unsized.
210
+ return null;
211
+ }
212
+ }
@@ -0,0 +1,15 @@
1
+ import type { InlineConfig, PluginOption } from 'vite';
2
+ export type HubOptions = {
3
+ /** The project: the folder with prototypes/, kit/ and docs/. */
4
+ root: string;
5
+ };
6
+ /** Every plugin the hub needs, for a project that writes its own vite.config.ts. */
7
+ export declare function designHub({ root }: HubOptions): PluginOption[];
8
+ /**
9
+ * The whole config `design-hub dev` and `design-hub build` run on. One port per
10
+ * project: Conductor's per-workspace port when it runs us, else the PORT
11
+ * `design-hub bootstrap` wrote to .env. strictPort refuses a taken port rather
12
+ * than drifting to the next one, because a fallback silently serves the wrong
13
+ * project. A build needs no port, so Vercel builds without a .env.
14
+ */
15
+ export declare function hubConfig({ root }: HubOptions, command: 'serve' | 'build'): InlineConfig;
@@ -0,0 +1,84 @@
1
+ /// <reference types="node" />
2
+ // The Vite side of the hub. `design-hub dev` and `design-hub build` run Vite
3
+ // on this config: the hub's own index.html as the root, the project's
4
+ // prototypes reached through the registry module, the project's kit/ in
5
+ // front of the package's, the review API in dev, and the gate's font emitted
6
+ // where the gate serves it. A project keeps no vite.config.ts; one that wants
7
+ // its own can import `designHub` from '@milesroxas/design-hub/vite'.
8
+ import { readFileSync } from 'node:fs';
9
+ import { createRequire } from 'node:module';
10
+ import { dirname, join } from 'node:path';
11
+ import react from '@vitejs/plugin-react';
12
+ import { devReview } from '../server/dev.js';
13
+ import { devPort, readEnv } from '../server/env.js';
14
+ import { PACKAGE, PACKAGE_KIT, SHELL } from '../server/paths.js';
15
+ import { GATE_FONT, gatePreview } from '../vercel/proxy.js';
16
+ import { kitResolver } from './kit.js';
17
+ import { markdownDocs } from './markdown.js';
18
+ import { registry } from './registry.js';
19
+ const require = createRequire(import.meta.url);
20
+ // The password page loads before sign-in, so it cannot reach the hashed
21
+ // bundle. Its font goes out at the one fixed path the gate lets through.
22
+ // `design-hub dev` has no gate, so there /_gate/ previews the page (?failed
23
+ // shows the wrong-password state).
24
+ const gateFontFile = () => join(dirname(require.resolve('@fontsource-variable/geist')), 'files', 'geist-latin-wght-normal.woff2');
25
+ function gateFont() {
26
+ return {
27
+ name: 'design-hub-gate-font',
28
+ generateBundle() {
29
+ this.emitFile({ type: 'asset', fileName: GATE_FONT.slice(1), source: readFileSync(gateFontFile()) });
30
+ },
31
+ configureServer(server) {
32
+ server.middlewares.use(async (req, res, nextMiddleware) => {
33
+ const url = new URL(req.url ?? '/', 'http://dev');
34
+ if (url.pathname === GATE_FONT) {
35
+ res.setHeader('content-type', 'font/woff2');
36
+ res.end(readFileSync(gateFontFile()));
37
+ return;
38
+ }
39
+ if (url.pathname !== '/_gate/')
40
+ return nextMiddleware();
41
+ const page = await gatePreview(url.searchParams.has('failed'));
42
+ res.setHeader('content-type', 'text/html; charset=utf-8');
43
+ res.setHeader('content-security-policy', page.csp);
44
+ res.end(page.html);
45
+ });
46
+ },
47
+ };
48
+ }
49
+ /** Every plugin the hub needs, for a project that writes its own vite.config.ts. */
50
+ export function designHub({ root }) {
51
+ return [
52
+ react(),
53
+ kitResolver(join(root, 'kit'), PACKAGE_KIT),
54
+ registry(root),
55
+ markdownDocs(),
56
+ devReview(),
57
+ gateFont(),
58
+ ];
59
+ }
60
+ /**
61
+ * The whole config `design-hub dev` and `design-hub build` run on. One port per
62
+ * project: Conductor's per-workspace port when it runs us, else the PORT
63
+ * `design-hub bootstrap` wrote to .env. strictPort refuses a taken port rather
64
+ * than drifting to the next one, because a fallback silently serves the wrong
65
+ * project. A build needs no port, so Vercel builds without a .env.
66
+ */
67
+ export function hubConfig({ root }, command) {
68
+ const port = devPort(readEnv(root));
69
+ if (command === 'serve' && port === undefined)
70
+ throw new Error('PORT is not set. Run `design-hub bootstrap` once to pick a free port and write it to .env.');
71
+ return {
72
+ configFile: false,
73
+ root: SHELL,
74
+ publicDir: join(SHELL, 'public'),
75
+ envDir: root,
76
+ cacheDir: join(root, 'node_modules', '.vite'),
77
+ plugins: designHub({ root }),
78
+ resolve: { dedupe: ['react', 'react-dom'] },
79
+ optimizeDeps: { entries: [join(SHELL, 'index.html'), join(root, 'prototypes', '*', 'index.tsx')] },
80
+ server: { port, strictPort: true, fs: { allow: [root, PACKAGE] } },
81
+ preview: port === undefined ? undefined : { port, strictPort: true },
82
+ build: { outDir: join(root, 'dist'), emptyOutDir: true },
83
+ };
84
+ }
@@ -0,0 +1,7 @@
1
+ import type { Plugin } from 'vite';
2
+ /** The prototype folders: kebab-case, with a README. `guide` is the walkthrough route, never a prototype. */
3
+ export declare const prototypeSlugs: (prototypes: string) => string[];
4
+ /** The features with a walkthrough at docs/sprints/<feature>/guide.md. */
5
+ export declare const guideFeatures: (sprints: string) => string[];
6
+ export declare function registrySource(root: string): string;
7
+ export declare function registry(root: string): Plugin;