pilothub 0.0.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 (272) hide show
  1. package/.env.local.example +19 -0
  2. package/.github/workflows/ci.yml +40 -0
  3. package/.oxlintrc.json +3 -0
  4. package/AGENTS.md +45 -0
  5. package/CHANGELOG.md +138 -0
  6. package/DEPRECATIONS.md +7 -0
  7. package/LICENSE +21 -0
  8. package/README.md +150 -0
  9. package/biome.json +41 -0
  10. package/convex/_generated/api.d.ts +153 -0
  11. package/convex/_generated/api.js +23 -0
  12. package/convex/_generated/dataModel.d.ts +60 -0
  13. package/convex/_generated/server.d.ts +143 -0
  14. package/convex/_generated/server.js +93 -0
  15. package/convex/auth.config.ts +8 -0
  16. package/convex/auth.ts +19 -0
  17. package/convex/comments.ts +88 -0
  18. package/convex/crons.ts +34 -0
  19. package/convex/devSeed.ts +459 -0
  20. package/convex/devSeedExtra.ts +541 -0
  21. package/convex/downloads.ts +78 -0
  22. package/convex/githubBackups.ts +170 -0
  23. package/convex/githubBackupsNode.ts +183 -0
  24. package/convex/githubImport.ts +317 -0
  25. package/convex/githubSoulBackups.ts +170 -0
  26. package/convex/githubSoulBackupsNode.ts +186 -0
  27. package/convex/http.ts +194 -0
  28. package/convex/httpApi.handlers.test.ts +488 -0
  29. package/convex/httpApi.test.ts +70 -0
  30. package/convex/httpApi.ts +305 -0
  31. package/convex/httpApiV1.handlers.test.ts +584 -0
  32. package/convex/httpApiV1.ts +1172 -0
  33. package/convex/leaderboards.ts +39 -0
  34. package/convex/lib/access.ts +36 -0
  35. package/convex/lib/apiTokenAuth.ts +36 -0
  36. package/convex/lib/badges.ts +50 -0
  37. package/convex/lib/changelog.test.ts +34 -0
  38. package/convex/lib/changelog.ts +278 -0
  39. package/convex/lib/embeddings.ts +38 -0
  40. package/convex/lib/githubBackup.ts +443 -0
  41. package/convex/lib/githubImport.test.ts +247 -0
  42. package/convex/lib/githubImport.ts +425 -0
  43. package/convex/lib/githubSoulBackup.ts +443 -0
  44. package/convex/lib/leaderboards.ts +103 -0
  45. package/convex/lib/moderation.ts +42 -0
  46. package/convex/lib/public.ts +89 -0
  47. package/convex/lib/searchText.test.ts +46 -0
  48. package/convex/lib/searchText.ts +27 -0
  49. package/convex/lib/skillBackfill.test.ts +34 -0
  50. package/convex/lib/skillBackfill.ts +67 -0
  51. package/convex/lib/skillPublish.test.ts +28 -0
  52. package/convex/lib/skillPublish.ts +284 -0
  53. package/convex/lib/skillStats.ts +80 -0
  54. package/convex/lib/skills.test.ts +197 -0
  55. package/convex/lib/skills.ts +273 -0
  56. package/convex/lib/soulChangelog.ts +273 -0
  57. package/convex/lib/soulPublish.ts +236 -0
  58. package/convex/lib/tokens.test.ts +33 -0
  59. package/convex/lib/tokens.ts +51 -0
  60. package/convex/lib/webhooks.test.ts +91 -0
  61. package/convex/lib/webhooks.ts +112 -0
  62. package/convex/maintenance.test.ts +270 -0
  63. package/convex/maintenance.ts +840 -0
  64. package/convex/rateLimits.ts +50 -0
  65. package/convex/schema.ts +472 -0
  66. package/convex/search.test.ts +12 -0
  67. package/convex/search.ts +254 -0
  68. package/convex/seed.test.ts +37 -0
  69. package/convex/seed.ts +254 -0
  70. package/convex/seedSouls.ts +111 -0
  71. package/convex/skillStatEvents.ts +568 -0
  72. package/convex/skills.ts +1606 -0
  73. package/convex/soulComments.ts +88 -0
  74. package/convex/soulDownloads.ts +14 -0
  75. package/convex/soulStars.ts +71 -0
  76. package/convex/souls.ts +570 -0
  77. package/convex/stars.ts +108 -0
  78. package/convex/statsMaintenance.ts +205 -0
  79. package/convex/telemetry.ts +434 -0
  80. package/convex/tokens.ts +88 -0
  81. package/convex/tsconfig.json +7 -0
  82. package/convex/uploads.ts +20 -0
  83. package/convex/users.ts +122 -0
  84. package/convex/webhooks.ts +50 -0
  85. package/convex.json +3 -0
  86. package/docs/README.md +32 -0
  87. package/docs/api.md +51 -0
  88. package/docs/architecture.md +61 -0
  89. package/docs/auth.md +54 -0
  90. package/docs/cli.md +117 -0
  91. package/docs/deploy.md +78 -0
  92. package/docs/diffing.md +84 -0
  93. package/docs/github-import.md +171 -0
  94. package/docs/http-api.md +187 -0
  95. package/docs/manual-testing.md +64 -0
  96. package/docs/mintlify.md +43 -0
  97. package/docs/quickstart.md +120 -0
  98. package/docs/skill-format.md +58 -0
  99. package/docs/soul-format.md +37 -0
  100. package/docs/spec.md +177 -0
  101. package/docs/telemetry.md +91 -0
  102. package/docs/troubleshooting.md +49 -0
  103. package/docs/webhook.md +51 -0
  104. package/e2e/menu-smoke.pw.test.ts +49 -0
  105. package/e2e/pilothub.e2e.test.ts +494 -0
  106. package/e2e/search-exact.pw.test.ts +97 -0
  107. package/package.json +84 -0
  108. package/packages/pilothub/LICENSE +22 -0
  109. package/packages/pilothub/README.md +57 -0
  110. package/packages/pilothub/bin/pilothub.js +2 -0
  111. package/packages/pilothub/package.json +41 -0
  112. package/packages/pilothub/src/browserAuth.test.ts +96 -0
  113. package/packages/pilothub/src/browserAuth.ts +174 -0
  114. package/packages/pilothub/src/cli/buildInfo.ts +94 -0
  115. package/packages/pilothub/src/cli/commands/auth.ts +97 -0
  116. package/packages/pilothub/src/cli/commands/delete.test.ts +73 -0
  117. package/packages/pilothub/src/cli/commands/delete.ts +83 -0
  118. package/packages/pilothub/src/cli/commands/publish.test.ts +122 -0
  119. package/packages/pilothub/src/cli/commands/publish.ts +108 -0
  120. package/packages/pilothub/src/cli/commands/skills.test.ts +191 -0
  121. package/packages/pilothub/src/cli/commands/skills.ts +380 -0
  122. package/packages/pilothub/src/cli/commands/star.ts +46 -0
  123. package/packages/pilothub/src/cli/commands/sync.test.ts +310 -0
  124. package/packages/pilothub/src/cli/commands/sync.ts +200 -0
  125. package/packages/pilothub/src/cli/commands/syncHelpers.test.ts +26 -0
  126. package/packages/pilothub/src/cli/commands/syncHelpers.ts +427 -0
  127. package/packages/pilothub/src/cli/commands/syncTypes.ts +27 -0
  128. package/packages/pilothub/src/cli/commands/unstar.ts +48 -0
  129. package/packages/pilothub/src/cli/helpStyle.ts +45 -0
  130. package/packages/pilothub/src/cli/pilotbotConfig.test.ts +159 -0
  131. package/packages/pilothub/src/cli/pilotbotConfig.ts +147 -0
  132. package/packages/pilothub/src/cli/registry.test.ts +63 -0
  133. package/packages/pilothub/src/cli/registry.ts +43 -0
  134. package/packages/pilothub/src/cli/scanSkills.test.ts +64 -0
  135. package/packages/pilothub/src/cli/scanSkills.ts +84 -0
  136. package/packages/pilothub/src/cli/slug.ts +16 -0
  137. package/packages/pilothub/src/cli/types.ts +12 -0
  138. package/packages/pilothub/src/cli/ui.ts +75 -0
  139. package/packages/pilothub/src/cli.ts +311 -0
  140. package/packages/pilothub/src/config.ts +36 -0
  141. package/packages/pilothub/src/discovery.test.ts +75 -0
  142. package/packages/pilothub/src/discovery.ts +19 -0
  143. package/packages/pilothub/src/http.test.ts +156 -0
  144. package/packages/pilothub/src/http.ts +301 -0
  145. package/packages/pilothub/src/schema/ark.ts +29 -0
  146. package/packages/pilothub/src/schema/index.ts +5 -0
  147. package/packages/pilothub/src/schema/routes.ts +22 -0
  148. package/packages/pilothub/src/schema/schemas.ts +260 -0
  149. package/packages/pilothub/src/schema/textFiles.test.ts +23 -0
  150. package/packages/pilothub/src/schema/textFiles.ts +66 -0
  151. package/packages/pilothub/src/skills.test.ts +191 -0
  152. package/packages/pilothub/src/skills.ts +172 -0
  153. package/packages/pilothub/src/types.ts +10 -0
  154. package/packages/pilothub/tsconfig.json +14 -0
  155. package/packages/schema/README.md +3 -0
  156. package/packages/schema/dist/ark.d.ts +4 -0
  157. package/packages/schema/dist/ark.js +26 -0
  158. package/packages/schema/dist/ark.js.map +1 -0
  159. package/packages/schema/dist/index.d.ts +5 -0
  160. package/packages/schema/dist/index.js +5 -0
  161. package/packages/schema/dist/index.js.map +1 -0
  162. package/packages/schema/dist/routes.d.ts +21 -0
  163. package/packages/schema/dist/routes.js +22 -0
  164. package/packages/schema/dist/routes.js.map +1 -0
  165. package/packages/schema/dist/schemas.d.ts +297 -0
  166. package/packages/schema/dist/schemas.js +243 -0
  167. package/packages/schema/dist/schemas.js.map +1 -0
  168. package/packages/schema/dist/textFiles.d.ts +5 -0
  169. package/packages/schema/dist/textFiles.js +66 -0
  170. package/packages/schema/dist/textFiles.js.map +1 -0
  171. package/packages/schema/package.json +26 -0
  172. package/packages/schema/src/ark.ts +29 -0
  173. package/packages/schema/src/index.ts +5 -0
  174. package/packages/schema/src/routes.ts +22 -0
  175. package/packages/schema/src/schemas.test.ts +123 -0
  176. package/packages/schema/src/schemas.ts +287 -0
  177. package/packages/schema/src/textFiles.test.ts +23 -0
  178. package/packages/schema/src/textFiles.ts +66 -0
  179. package/packages/schema/tsconfig.json +15 -0
  180. package/pilothub +46 -0
  181. package/playwright.config.ts +33 -0
  182. package/public/.well-known/pilothub.json +6 -0
  183. package/public/api/v1/openapi.json +379 -0
  184. package/public/favicon.ico +0 -0
  185. package/public/logo192.png +0 -0
  186. package/public/logo512.png +0 -0
  187. package/public/manifest.json +25 -0
  188. package/public/og.png +0 -0
  189. package/public/og.svg +98 -0
  190. package/public/pilot-logo.png +0 -0
  191. package/public/pilot-mark.png +0 -0
  192. package/public/robots.txt +3 -0
  193. package/public/tanstack-circle-logo.png +0 -0
  194. package/public/tanstack-word-logo-white.svg +1 -0
  195. package/scripts/check-peer-deps.ts +56 -0
  196. package/scripts/docs-list.ts +148 -0
  197. package/scripts/run-playwright-local.sh +14 -0
  198. package/server/og/fetchSkillOgMeta.ts +27 -0
  199. package/server/og/fetchSoulOgMeta.ts +27 -0
  200. package/server/og/ogAssets.ts +80 -0
  201. package/server/og/skillOgSvg.test.ts +59 -0
  202. package/server/og/skillOgSvg.ts +258 -0
  203. package/server/og/soulOgSvg.ts +209 -0
  204. package/server/routes/og/skill.png.ts +103 -0
  205. package/server/routes/og/soul.png.ts +111 -0
  206. package/src/__tests__/skill-detail-page.test.tsx +86 -0
  207. package/src/__tests__/skills-index.test.tsx +145 -0
  208. package/src/__tests__/upload.route.test.tsx +228 -0
  209. package/src/components/AppProviders.tsx +19 -0
  210. package/src/components/ClientOnly.tsx +18 -0
  211. package/src/components/Footer.tsx +29 -0
  212. package/src/components/Header.tsx +295 -0
  213. package/src/components/InstallSwitcher.tsx +53 -0
  214. package/src/components/SkillCard.tsx +36 -0
  215. package/src/components/SkillDetailPage.tsx +817 -0
  216. package/src/components/SkillDiffCard.tsx +485 -0
  217. package/src/components/SoulCard.tsx +19 -0
  218. package/src/components/SoulDetailPage.tsx +263 -0
  219. package/src/components/UserBootstrap.tsx +18 -0
  220. package/src/components/ui/dropdown-menu.tsx +67 -0
  221. package/src/components/ui/toggle-group.tsx +35 -0
  222. package/src/convex/client.ts +3 -0
  223. package/src/lib/badges.ts +29 -0
  224. package/src/lib/diffing.test.ts +163 -0
  225. package/src/lib/diffing.ts +106 -0
  226. package/src/lib/gravatar.test.ts +9 -0
  227. package/src/lib/gravatar.ts +158 -0
  228. package/src/lib/og.test.ts +142 -0
  229. package/src/lib/og.ts +156 -0
  230. package/src/lib/publicUser.ts +39 -0
  231. package/src/lib/roles.ts +19 -0
  232. package/src/lib/site.test.ts +130 -0
  233. package/src/lib/site.ts +84 -0
  234. package/src/lib/theme-transition.test.ts +134 -0
  235. package/src/lib/theme-transition.ts +134 -0
  236. package/src/lib/theme.test.tsx +88 -0
  237. package/src/lib/theme.ts +43 -0
  238. package/src/lib/uploadFiles.jsdom.test.ts +33 -0
  239. package/src/lib/uploadFiles.test.ts +123 -0
  240. package/src/lib/uploadFiles.ts +245 -0
  241. package/src/lib/uploadUtils.test.ts +78 -0
  242. package/src/lib/uploadUtils.ts +93 -0
  243. package/src/lib/useAuthStatus.ts +12 -0
  244. package/src/lib/utils.test.ts +9 -0
  245. package/src/lib/utils.ts +6 -0
  246. package/src/logo.svg +12 -0
  247. package/src/routeTree.gen.ts +345 -0
  248. package/src/router.tsx +17 -0
  249. package/src/routes/$owner/$slug.tsx +55 -0
  250. package/src/routes/__root.tsx +136 -0
  251. package/src/routes/admin.tsx +11 -0
  252. package/src/routes/cli/auth.tsx +168 -0
  253. package/src/routes/dashboard.tsx +97 -0
  254. package/src/routes/import.tsx +415 -0
  255. package/src/routes/index.tsx +252 -0
  256. package/src/routes/management.tsx +529 -0
  257. package/src/routes/settings.tsx +203 -0
  258. package/src/routes/skills/index.tsx +422 -0
  259. package/src/routes/souls/$slug.tsx +55 -0
  260. package/src/routes/souls/index.tsx +243 -0
  261. package/src/routes/stars.tsx +68 -0
  262. package/src/routes/u/$handle.tsx +307 -0
  263. package/src/routes/upload/utils.ts +81 -0
  264. package/src/routes/upload.tsx +499 -0
  265. package/src/styles.css +2718 -0
  266. package/tsconfig.json +24 -0
  267. package/tsconfig.oxlint.json +16 -0
  268. package/vercel.json +8 -0
  269. package/vite.config.ts +48 -0
  270. package/vitest.config.ts +47 -0
  271. package/vitest.e2e.config.ts +11 -0
  272. package/vitest.setup.ts +1 -0
@@ -0,0 +1,84 @@
1
+ export type SiteMode = 'skills' | 'souls'
2
+
3
+ const DEFAULT_PILOTHUB_SITE_URL = 'https://pilothub.com'
4
+ const DEFAULT_ONLYCRABS_SITE_URL = 'https://onlycrabs.ai'
5
+ const DEFAULT_ONLYCRABS_HOST = 'onlycrabs.ai'
6
+
7
+ export function getPilotHubSiteUrl() {
8
+ return import.meta.env.VITE_SITE_URL ?? DEFAULT_PILOTHUB_SITE_URL
9
+ }
10
+
11
+ export function getOnlyCrabsSiteUrl() {
12
+ const explicit = import.meta.env.VITE_SOULHUB_SITE_URL
13
+ if (explicit) return explicit
14
+
15
+ const siteUrl = import.meta.env.VITE_SITE_URL
16
+ if (siteUrl) {
17
+ try {
18
+ const url = new URL(siteUrl)
19
+ if (
20
+ url.hostname === 'localhost' ||
21
+ url.hostname === '127.0.0.1' ||
22
+ url.hostname === '0.0.0.0'
23
+ ) {
24
+ return url.origin
25
+ }
26
+ } catch {
27
+ // ignore invalid URLs, fall through to default
28
+ }
29
+ }
30
+
31
+ return DEFAULT_ONLYCRABS_SITE_URL
32
+ }
33
+
34
+ export function getOnlyCrabsHost() {
35
+ return import.meta.env.VITE_SOULHUB_HOST ?? DEFAULT_ONLYCRABS_HOST
36
+ }
37
+
38
+ export function detectSiteMode(host?: string | null): SiteMode {
39
+ if (!host) return 'skills'
40
+ const onlyCrabsHost = getOnlyCrabsHost().toLowerCase()
41
+ const lower = host.toLowerCase()
42
+ if (lower === onlyCrabsHost || lower.endsWith(`.${onlyCrabsHost}`)) return 'souls'
43
+ return 'skills'
44
+ }
45
+
46
+ export function detectSiteModeFromUrl(value?: string | null): SiteMode {
47
+ if (!value) return 'skills'
48
+ try {
49
+ const host = new URL(value).hostname
50
+ return detectSiteMode(host)
51
+ } catch {
52
+ return detectSiteMode(value)
53
+ }
54
+ }
55
+
56
+ export function getSiteMode(): SiteMode {
57
+ if (typeof window !== 'undefined') {
58
+ return detectSiteMode(window.location.hostname)
59
+ }
60
+ const forced = import.meta.env.VITE_SITE_MODE
61
+ if (forced === 'souls' || forced === 'skills') return forced
62
+
63
+ const onlyCrabsSite = import.meta.env.VITE_SOULHUB_SITE_URL
64
+ if (onlyCrabsSite) return detectSiteModeFromUrl(onlyCrabsSite)
65
+
66
+ const siteUrl = import.meta.env.VITE_SITE_URL ?? process.env.SITE_URL
67
+ if (siteUrl) return detectSiteModeFromUrl(siteUrl)
68
+
69
+ return 'skills'
70
+ }
71
+
72
+ export function getSiteName(mode: SiteMode = getSiteMode()) {
73
+ return mode === 'souls' ? 'SoulHub' : 'PilotHub'
74
+ }
75
+
76
+ export function getSiteDescription(mode: SiteMode = getSiteMode()) {
77
+ return mode === 'souls'
78
+ ? 'SoulHub — the home for SOUL.md bundles and personal system lore.'
79
+ : 'PilotHub — a fast skill registry for agents, with vector search.'
80
+ }
81
+
82
+ export function getSiteUrlForMode(mode: SiteMode = getSiteMode()) {
83
+ return mode === 'souls' ? getOnlyCrabsSiteUrl() : getPilotHubSiteUrl()
84
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it, vi } from 'vitest'
2
+ import { startThemeTransition } from './theme-transition'
3
+
4
+ describe('startThemeTransition', () => {
5
+ it('no-ops when theme does not change', () => {
6
+ const setTheme = vi.fn()
7
+ startThemeTransition({
8
+ currentTheme: 'dark',
9
+ nextTheme: 'dark',
10
+ setTheme,
11
+ })
12
+ expect(setTheme).not.toHaveBeenCalled()
13
+ })
14
+
15
+ it('applies theme without document (SSR)', () => {
16
+ const original = Object.getOwnPropertyDescriptor(globalThis, 'document')
17
+ Object.defineProperty(globalThis, 'document', { value: undefined, configurable: true })
18
+
19
+ try {
20
+ const calls: string[] = []
21
+ const setTheme = vi.fn()
22
+ startThemeTransition({
23
+ currentTheme: 'light',
24
+ nextTheme: 'dark',
25
+ setTheme,
26
+ onBeforeThemeChange: () => calls.push('before'),
27
+ onAfterThemeChange: () => calls.push('after'),
28
+ })
29
+ expect(calls).toEqual(['before', 'after'])
30
+ expect(setTheme).toHaveBeenCalledWith('dark')
31
+ } finally {
32
+ if (original) Object.defineProperty(globalThis, 'document', original)
33
+ else delete (globalThis as unknown as { document?: unknown }).document
34
+ }
35
+ })
36
+
37
+ it('skips view-transition when prefers reduced motion', () => {
38
+ const setTheme = vi.fn()
39
+ const root = document.documentElement
40
+
41
+ window.matchMedia = vi.fn(() => ({ matches: true }) as unknown as MediaQueryList)
42
+ ;(document as unknown as { startViewTransition?: unknown }).startViewTransition = vi.fn()
43
+
44
+ startThemeTransition({
45
+ currentTheme: 'light',
46
+ nextTheme: 'dark',
47
+ setTheme,
48
+ context: { pointerClientX: 10, pointerClientY: 10 },
49
+ })
50
+
51
+ expect(setTheme).toHaveBeenCalledWith('dark')
52
+ expect(root.classList.contains('theme-transition')).toBe(false)
53
+ expect(
54
+ (document as unknown as { startViewTransition?: unknown }).startViewTransition,
55
+ ).not.toHaveBeenCalled()
56
+ })
57
+
58
+ it('uses view-transition when available', async () => {
59
+ const setTheme = vi.fn()
60
+ const root = document.documentElement
61
+
62
+ window.matchMedia = vi.fn(() => ({ matches: false }) as unknown as MediaQueryList)
63
+
64
+ ;(
65
+ document as unknown as {
66
+ startViewTransition?: (callback: () => void) => { finished: Promise<void> }
67
+ }
68
+ ).startViewTransition = (callback) => {
69
+ callback()
70
+ return { finished: Promise.resolve() }
71
+ }
72
+
73
+ startThemeTransition({
74
+ currentTheme: 'light',
75
+ nextTheme: 'dark',
76
+ setTheme,
77
+ context: { pointerClientX: 10, pointerClientY: 20 },
78
+ })
79
+
80
+ expect(setTheme).toHaveBeenCalledWith('dark')
81
+ expect(root.classList.contains('theme-transition')).toBe(true)
82
+
83
+ await new Promise((r) => setTimeout(r, 0))
84
+ expect(root.classList.contains('theme-transition')).toBe(false)
85
+ expect(root.style.getPropertyValue('--theme-switch-x')).toBe('')
86
+ expect(root.style.getPropertyValue('--theme-switch-y')).toBe('')
87
+ })
88
+
89
+ it('cleans up when view-transition does not provide finished', () => {
90
+ const setTheme = vi.fn()
91
+ const root = document.documentElement
92
+
93
+ window.matchMedia = vi.fn(() => ({ matches: false }) as unknown as MediaQueryList)
94
+ ;(
95
+ document as unknown as { startViewTransition?: (callback: () => void) => unknown }
96
+ ).startViewTransition = (callback) => {
97
+ callback()
98
+ return {}
99
+ }
100
+
101
+ startThemeTransition({
102
+ currentTheme: 'light',
103
+ nextTheme: 'dark',
104
+ setTheme,
105
+ context: { element: document.body },
106
+ })
107
+
108
+ expect(setTheme).toHaveBeenCalledWith('dark')
109
+ expect(root.classList.contains('theme-transition')).toBe(false)
110
+ })
111
+
112
+ it('falls back when view-transition throws', () => {
113
+ const setTheme = vi.fn()
114
+ const root = document.documentElement
115
+
116
+ window.matchMedia = vi.fn(() => ({ matches: false }) as unknown as MediaQueryList)
117
+ ;(document as unknown as { startViewTransition?: () => never }).startViewTransition = () => {
118
+ throw new Error('nope')
119
+ }
120
+
121
+ const element = document.createElement('button')
122
+ element.getBoundingClientRect = () => ({ left: 10, top: 10, width: 10, height: 10 }) as DOMRect
123
+
124
+ startThemeTransition({
125
+ currentTheme: 'light',
126
+ nextTheme: 'dark',
127
+ setTheme,
128
+ context: { element },
129
+ })
130
+
131
+ expect(setTheme).toHaveBeenCalledWith('dark')
132
+ expect(root.classList.contains('theme-transition')).toBe(false)
133
+ })
134
+ })
@@ -0,0 +1,134 @@
1
+ import { flushSync } from 'react-dom'
2
+
3
+ export type ThemeValue = 'light' | 'dark' | 'system' | (string & {})
4
+
5
+ export type ThemeTransitionContext = {
6
+ element?: HTMLElement | null
7
+ pointerClientX?: number
8
+ pointerClientY?: number
9
+ }
10
+
11
+ export type ThemeTransitionOptions = {
12
+ nextTheme: ThemeValue
13
+ setTheme: (theme: ThemeValue) => void
14
+ context?: ThemeTransitionContext | undefined
15
+ onBeforeThemeChange?: () => void
16
+ onAfterThemeChange?: () => void
17
+ currentTheme?: ThemeValue | null
18
+ }
19
+
20
+ type DocumentWithViewTransition = Document & {
21
+ startViewTransition?: (callback: () => void) => {
22
+ finished: Promise<void>
23
+ }
24
+ }
25
+
26
+ type WindowWithMatchMedia = Window & {
27
+ matchMedia: (query: string) => MediaQueryList
28
+ }
29
+
30
+ const clamp01 = (value: number) => {
31
+ if (Number.isNaN(value)) return 0.5
32
+ if (value <= 0) return 0
33
+ if (value >= 1) return 1
34
+ return value
35
+ }
36
+
37
+ const resolveWindow = (): WindowWithMatchMedia | undefined =>
38
+ globalThis.window as WindowWithMatchMedia | undefined
39
+
40
+ const hasReducedMotionPreference = (): boolean => {
41
+ const currentWindow = resolveWindow()
42
+ if (!currentWindow || typeof currentWindow.matchMedia !== 'function') return false
43
+ return currentWindow.matchMedia('(prefers-reduced-motion: reduce)').matches ?? false
44
+ }
45
+
46
+ const cleanupThemeTransition = (root: HTMLElement) => {
47
+ root.classList.remove('theme-transition')
48
+ root.style.removeProperty('--theme-switch-x')
49
+ root.style.removeProperty('--theme-switch-y')
50
+ }
51
+
52
+ export const startThemeTransition = ({
53
+ nextTheme,
54
+ setTheme,
55
+ context,
56
+ onBeforeThemeChange,
57
+ onAfterThemeChange,
58
+ currentTheme,
59
+ }: ThemeTransitionOptions) => {
60
+ if (currentTheme === nextTheme) return
61
+
62
+ const documentReference = globalThis.document ?? null
63
+ if (!documentReference) {
64
+ onBeforeThemeChange?.()
65
+ setTheme(nextTheme)
66
+ onAfterThemeChange?.()
67
+ return
68
+ }
69
+
70
+ const root = documentReference.documentElement
71
+ const document_ = documentReference as DocumentWithViewTransition
72
+ const prefersReducedMotion = hasReducedMotionPreference()
73
+
74
+ const applyTheme = () => {
75
+ onBeforeThemeChange?.()
76
+ flushSync(() => {
77
+ setTheme(nextTheme)
78
+ })
79
+ onAfterThemeChange?.()
80
+ }
81
+
82
+ const canUseViewTransition = Boolean(document_.startViewTransition) && !prefersReducedMotion
83
+ if (canUseViewTransition) {
84
+ let xPercent = 0.5
85
+ let yPercent = 0.5
86
+
87
+ const currentWindow = resolveWindow()
88
+ if (
89
+ context?.pointerClientX !== undefined &&
90
+ context?.pointerClientY !== undefined &&
91
+ currentWindow
92
+ ) {
93
+ xPercent = clamp01(context.pointerClientX / currentWindow.innerWidth)
94
+ yPercent = clamp01(context.pointerClientY / currentWindow.innerHeight)
95
+ } else if (context?.element) {
96
+ const rect = context.element.getBoundingClientRect()
97
+ if (rect.width > 0 && rect.height > 0 && currentWindow) {
98
+ xPercent = clamp01((rect.left + rect.width / 2) / currentWindow.innerWidth)
99
+ yPercent = clamp01((rect.top + rect.height / 2) / currentWindow.innerHeight)
100
+ }
101
+ }
102
+
103
+ root.style.setProperty('--theme-switch-x', `${xPercent * 100}%`)
104
+ root.style.setProperty('--theme-switch-y', `${yPercent * 100}%`)
105
+ root.classList.add('theme-transition')
106
+
107
+ try {
108
+ const transition = document_.startViewTransition?.(() => {
109
+ applyTheme()
110
+ })
111
+ if (transition?.finished === undefined) {
112
+ cleanupThemeTransition(root)
113
+ } else {
114
+ const handleTransitionFinish = async () => {
115
+ try {
116
+ await transition.finished
117
+ } catch {
118
+ // swallow transition cancellation errors
119
+ } finally {
120
+ cleanupThemeTransition(root)
121
+ }
122
+ }
123
+ void handleTransitionFinish()
124
+ }
125
+ } catch {
126
+ cleanupThemeTransition(root)
127
+ applyTheme()
128
+ }
129
+ return
130
+ }
131
+
132
+ applyTheme()
133
+ cleanupThemeTransition(root)
134
+ }
@@ -0,0 +1,88 @@
1
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { applyTheme, getStoredTheme, useThemeMode } from './theme'
4
+
5
+ describe('theme', () => {
6
+ let store: Record<string, string>
7
+
8
+ function Harness() {
9
+ const { mode, setMode } = useThemeMode()
10
+ return (
11
+ <div>
12
+ <div data-testid="mode">{mode}</div>
13
+ <button type="button" onClick={() => setMode('dark')}>
14
+ dark
15
+ </button>
16
+ </div>
17
+ )
18
+ }
19
+
20
+ beforeEach(() => {
21
+ store = {}
22
+ Object.defineProperty(window, 'localStorage', {
23
+ value: {
24
+ getItem: (key: string) => (key in store ? store[key] : null),
25
+ setItem: (key: string, value: string) => {
26
+ store[key] = String(value)
27
+ },
28
+ removeItem: (key: string) => {
29
+ delete store[key]
30
+ },
31
+ clear: () => {
32
+ store = {}
33
+ },
34
+ },
35
+ configurable: true,
36
+ })
37
+ })
38
+
39
+ afterEach(() => {
40
+ document.documentElement.classList.remove('dark')
41
+ delete document.documentElement.dataset.theme
42
+ window.localStorage.clear()
43
+ vi.unstubAllGlobals()
44
+ })
45
+
46
+ it('reads stored theme with fallback', () => {
47
+ expect(getStoredTheme()).toBe('system')
48
+ window.localStorage.setItem('pilothub-theme', 'dark')
49
+ expect(getStoredTheme()).toBe('dark')
50
+ window.localStorage.setItem('pilothub-theme', 'nope')
51
+ expect(getStoredTheme()).toBe('system')
52
+ })
53
+
54
+ it('applies theme and toggles dark class', () => {
55
+ applyTheme('dark')
56
+ expect(document.documentElement.dataset.theme).toBe('dark')
57
+ expect(document.documentElement.classList.contains('dark')).toBe(true)
58
+
59
+ applyTheme('light')
60
+ expect(document.documentElement.dataset.theme).toBe('light')
61
+ expect(document.documentElement.classList.contains('dark')).toBe(false)
62
+ })
63
+
64
+ it('resolves system theme via matchMedia', () => {
65
+ vi.stubGlobal('matchMedia', () => ({
66
+ matches: true,
67
+ addEventListener: vi.fn(),
68
+ removeEventListener: vi.fn(),
69
+ }))
70
+ applyTheme('system')
71
+ expect(document.documentElement.dataset.theme).toBe('dark')
72
+ })
73
+
74
+ it('useThemeMode persists and applies mode', async () => {
75
+ vi.stubGlobal('matchMedia', () => ({
76
+ matches: false,
77
+ addEventListener: vi.fn(),
78
+ removeEventListener: vi.fn(),
79
+ }))
80
+ render(<Harness />)
81
+ expect(screen.getByTestId('mode').textContent).toBe('system')
82
+ fireEvent.click(screen.getByRole('button', { name: 'dark' }))
83
+ await waitFor(() => {
84
+ expect(document.documentElement.dataset.theme).toBe('dark')
85
+ })
86
+ expect(window.localStorage.getItem('pilothub-theme')).toBe('dark')
87
+ })
88
+ })
@@ -0,0 +1,43 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ export type ThemeMode = 'system' | 'light' | 'dark'
4
+
5
+ const THEME_KEY = 'pilothub-theme'
6
+
7
+ export function getStoredTheme(): ThemeMode {
8
+ if (typeof window === 'undefined') return 'system'
9
+ const stored = window.localStorage.getItem(THEME_KEY)
10
+ if (stored === 'light' || stored === 'dark' || stored === 'system') return stored
11
+ return 'system'
12
+ }
13
+
14
+ function resolveTheme(mode: ThemeMode) {
15
+ if (mode !== 'system') return mode
16
+ if (typeof window === 'undefined') return 'light'
17
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
18
+ }
19
+
20
+ export function applyTheme(mode: ThemeMode) {
21
+ if (typeof document === 'undefined') return
22
+ const resolved = resolveTheme(mode)
23
+ document.documentElement.dataset.theme = resolved
24
+ document.documentElement.classList.toggle('dark', resolved === 'dark')
25
+ }
26
+
27
+ export function useThemeMode() {
28
+ const [mode, setMode] = useState<ThemeMode>(() => getStoredTheme())
29
+
30
+ useEffect(() => {
31
+ applyTheme(mode)
32
+ if (typeof window !== 'undefined') {
33
+ window.localStorage.setItem(THEME_KEY, mode)
34
+ }
35
+ if (mode !== 'system' || typeof window === 'undefined') return
36
+ const media = window.matchMedia('(prefers-color-scheme: dark)')
37
+ const handler = () => applyTheme(mode)
38
+ media.addEventListener('change', handler)
39
+ return () => media.removeEventListener('change', handler)
40
+ }, [mode])
41
+
42
+ return { mode, setMode }
43
+ }
@@ -0,0 +1,33 @@
1
+ import { strToU8, unzipSync, zipSync } from 'fflate'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ import { expandFiles } from './uploadFiles'
5
+
6
+ function readWithFileReader(blob: Blob) {
7
+ return new Promise<ArrayBuffer>((resolve, reject) => {
8
+ const reader = new FileReader()
9
+ reader.onerror = () => reject(reader.error ?? new Error('Could not read blob.'))
10
+ reader.onload = () => resolve(reader.result as ArrayBuffer)
11
+ reader.readAsArrayBuffer(blob)
12
+ })
13
+ }
14
+
15
+ describe('expandFiles (jsdom)', () => {
16
+ it('expands zip archives using FileReader fallback', async () => {
17
+ const zip = zipSync({
18
+ 'hetzner-cloud-skill/SKILL.md': new Uint8Array(strToU8('hello')),
19
+ 'hetzner-cloud-skill/notes.txt': new Uint8Array(strToU8('notes')),
20
+ })
21
+ const zipBytes = Uint8Array.from(zip).buffer
22
+ const zipFile = new File([zipBytes], 'bundle.zip', { type: 'application/zip' })
23
+
24
+ const readerBuffer = await readWithFileReader(zipFile)
25
+ const entries = unzipSync(new Uint8Array(readerBuffer))
26
+ expect(Object.keys(entries)).toEqual(
27
+ expect.arrayContaining(['hetzner-cloud-skill/SKILL.md', 'hetzner-cloud-skill/notes.txt']),
28
+ )
29
+
30
+ const expanded = await expandFiles([zipFile])
31
+ expect(expanded.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
32
+ })
33
+ })
@@ -0,0 +1,123 @@
1
+ /* @vitest-environment node */
2
+ import { gzipSync, strToU8, zipSync } from 'fflate'
3
+ import { describe, expect, it } from 'vitest'
4
+ import { expandFiles } from './uploadFiles'
5
+
6
+ if (typeof File === 'undefined') {
7
+ class NodeFile extends Blob {
8
+ name: string
9
+ lastModified: number
10
+
11
+ constructor(parts: BlobPart[], name: string, options?: FilePropertyBag) {
12
+ super(parts, options)
13
+ this.name = name
14
+ this.lastModified = options?.lastModified ?? Date.now()
15
+ }
16
+ }
17
+ // @ts-expect-error Node test environment polyfill
18
+ globalThis.File = NodeFile
19
+ }
20
+
21
+ function buildTar(entries: Array<{ name: string; content: string }>) {
22
+ const blocks: Uint8Array[] = []
23
+ for (const entry of entries) {
24
+ const content = strToU8(entry.content)
25
+ const header = new Uint8Array(512)
26
+ writeString(header, entry.name, 0, 100)
27
+ writeString(header, '0000777', 100, 8)
28
+ writeString(header, '0000000', 108, 8)
29
+ writeString(header, '0000000', 116, 8)
30
+ writeString(header, content.length.toString(8).padStart(11, '0'), 124, 12)
31
+ writeString(header, '00000000000', 136, 12)
32
+ header[156] = '0'.charCodeAt(0)
33
+ writeString(header, 'ustar', 257, 6)
34
+ for (let i = 148; i < 156; i += 1) {
35
+ header[i] = 32
36
+ }
37
+ let sum = 0
38
+ for (const byte of header) sum += byte
39
+ writeString(header, sum.toString(8).padStart(6, '0'), 148, 6)
40
+ header[154] = 0
41
+ header[155] = 32
42
+ blocks.push(header)
43
+ blocks.push(content)
44
+ const pad = (512 - (content.length % 512)) % 512
45
+ if (pad) blocks.push(new Uint8Array(pad))
46
+ }
47
+ blocks.push(new Uint8Array(1024))
48
+ const total = blocks.reduce((sum, block) => sum + block.length, 0)
49
+ const buffer = new Uint8Array(total)
50
+ let offset = 0
51
+ for (const block of blocks) {
52
+ buffer.set(block, offset)
53
+ offset += block.length
54
+ }
55
+ return buffer
56
+ }
57
+
58
+ function writeString(target: Uint8Array, value: string, start: number, length: number) {
59
+ const bytes = strToU8(value)
60
+ target.set(bytes.subarray(0, length), start)
61
+ }
62
+
63
+ describe('expandFiles', () => {
64
+ it('expands zip archives into files', async () => {
65
+ const zip = zipSync({
66
+ 'SKILL.md': strToU8('hello'),
67
+ 'docs/readme.txt': strToU8('doc'),
68
+ })
69
+ const zipFile = new File([Uint8Array.from(zip).buffer], 'pack.zip', { type: 'application/zip' })
70
+ const result = await expandFiles([zipFile])
71
+ expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'docs/readme.txt'])
72
+ })
73
+
74
+ it('unwraps top-level folders in zip archives', async () => {
75
+ const zip = zipSync({
76
+ 'hetzner-cloud-skill/SKILL.md': strToU8('hello'),
77
+ 'hetzner-cloud-skill/docs/readme.txt': strToU8('doc'),
78
+ '__MACOSX/._SKILL.md': strToU8('junk'),
79
+ 'hetzner-cloud-skill/.DS_Store': strToU8('junk2'),
80
+ 'hetzner-cloud-skill/screenshot.png': strToU8('not-really-a-png'),
81
+ })
82
+ const zipFile = new File([Uint8Array.from(zip).buffer], 'pack.zip', { type: 'application/zip' })
83
+ const result = await expandFiles([zipFile])
84
+ expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'docs/readme.txt'])
85
+ const png = result.find((file) => file.name.endsWith('.png'))
86
+ expect(png).toBeUndefined()
87
+ })
88
+
89
+ it('expands gzipped tar archives into files', async () => {
90
+ const tar = buildTar([
91
+ { name: 'SKILL.md', content: 'hi' },
92
+ { name: 'notes.txt', content: 'yo' },
93
+ ])
94
+ const tgz = gzipSync(tar)
95
+ const tgzFile = new File([Uint8Array.from(tgz).buffer], 'bundle.tgz', {
96
+ type: 'application/gzip',
97
+ })
98
+ const result = await expandFiles([tgzFile])
99
+ expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
100
+ })
101
+
102
+ it('unwraps top-level folders in tar.gz archives', async () => {
103
+ const tar = buildTar([
104
+ { name: 'skill-folder/SKILL.md', content: 'hi' },
105
+ { name: 'skill-folder/notes.txt', content: 'yo' },
106
+ ])
107
+ const tgz = gzipSync(tar)
108
+ const tgzFile = new File([Uint8Array.from(tgz).buffer], 'bundle.tgz', {
109
+ type: 'application/gzip',
110
+ })
111
+ const result = await expandFiles([tgzFile])
112
+ expect(result.map((file) => file.name)).toEqual(['SKILL.md', 'notes.txt'])
113
+ })
114
+
115
+ it('expands .gz single files', async () => {
116
+ const gz = gzipSync(strToU8('content'))
117
+ const gzFile = new File([Uint8Array.from(gz).buffer], 'skill.md.gz', {
118
+ type: 'application/gzip',
119
+ })
120
+ const result = await expandFiles([gzFile])
121
+ expect(result.map((file) => file.name)).toEqual(['skill.md'])
122
+ })
123
+ })