create-stitchkit 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (174) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +35 -0
  4. package/dist/cli.js +230 -0
  5. package/package.json +50 -0
  6. package/template/README.md +69 -0
  7. package/template/_env +11 -0
  8. package/template/_env.example +12 -0
  9. package/template/_gitignore +10 -0
  10. package/template/biome.json +43 -0
  11. package/template/bun.lock +1372 -0
  12. package/template/e2e/starter.spec.ts +237 -0
  13. package/template/ecosystem.config.cjs +28 -0
  14. package/template/ecosystem.dev.config.cjs +29 -0
  15. package/template/package.json +55 -0
  16. package/template/packages/backend/package.json +31 -0
  17. package/template/packages/backend/src/cli.ts +12 -0
  18. package/template/packages/backend/src/domain/errors.ts +9 -0
  19. package/template/packages/backend/src/domain/repository/github-cache.test.ts +72 -0
  20. package/template/packages/backend/src/domain/repository/github-cache.ts +205 -0
  21. package/template/packages/backend/src/index.ts +56 -0
  22. package/template/packages/backend/src/lib/db.ts +7 -0
  23. package/template/packages/backend/src/surface.ts +13 -0
  24. package/template/packages/backend/src/tools.ts +21 -0
  25. package/template/packages/backend/src/transport/errors.ts +28 -0
  26. package/template/packages/backend/src/transport/repository-service.ts +18 -0
  27. package/template/packages/backend/tsconfig.json +8 -0
  28. package/template/packages/config/package.json +22 -0
  29. package/template/packages/config/src/server.ts +24 -0
  30. package/template/packages/config/tsconfig.json +5 -0
  31. package/template/packages/db/migrations/20260808000000_init/migration.sql +16 -0
  32. package/template/packages/db/migrations/20260808170000_repository_visibility/migration.sql +4 -0
  33. package/template/packages/db/migrations/migration_lock.toml +1 -0
  34. package/template/packages/db/package.json +29 -0
  35. package/template/packages/db/prisma.config.ts +8 -0
  36. package/template/packages/db/schema.prisma +31 -0
  37. package/template/packages/db/tsconfig.json +5 -0
  38. package/template/packages/frontend/messages/en.json +15 -0
  39. package/template/packages/frontend/messages/ru.json +15 -0
  40. package/template/packages/frontend/next.config.ts +18 -0
  41. package/template/packages/frontend/package.json +62 -0
  42. package/template/packages/frontend/postcss.config.mjs +1 -0
  43. package/template/packages/frontend/public/ASSETS.md +6 -0
  44. package/template/packages/frontend/public/favicon/mascot-stitch.png +0 -0
  45. package/template/packages/frontend/public/icons/biome.svg +1 -0
  46. package/template/packages/frontend/public/icons/bun.svg +19 -0
  47. package/template/packages/frontend/public/icons/next.svg +1 -0
  48. package/template/packages/frontend/public/icons/postgresql.svg +1 -0
  49. package/template/packages/frontend/public/icons/prisma.svg +6 -0
  50. package/template/packages/frontend/public/icons/react-query.svg +9 -0
  51. package/template/packages/frontend/public/icons/react.svg +1 -0
  52. package/template/packages/frontend/public/icons/tailwind.svg +12 -0
  53. package/template/packages/frontend/public/icons/tsconfig.svg +1 -0
  54. package/template/packages/frontend/public/icons/typescript.svg +7 -0
  55. package/template/packages/frontend/public/icons/zod.svg +1 -0
  56. package/template/packages/frontend/public/mascot-stitch.png +0 -0
  57. package/template/packages/frontend/public/theme-dark.svg +8 -0
  58. package/template/packages/frontend/public/theme-light.svg +8 -0
  59. package/template/packages/frontend/src/app/[locale]/error.tsx +19 -0
  60. package/template/packages/frontend/src/app/[locale]/layout.tsx +59 -0
  61. package/template/packages/frontend/src/app/[locale]/loading.tsx +14 -0
  62. package/template/packages/frontend/src/app/[locale]/page.tsx +44 -0
  63. package/template/packages/frontend/src/app/[locale]/starter-page.tsx +151 -0
  64. package/template/packages/frontend/src/app/[locale]/ui/[story]/page.tsx +40 -0
  65. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/account-showcase.tsx +112 -0
  66. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/admin-showcase.tsx +101 -0
  67. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/catalogue-navigation.tsx +56 -0
  68. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/catalogue-shell.tsx +51 -0
  69. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/code-foundation.tsx +35 -0
  70. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/components-story.tsx +54 -0
  71. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/data-story.tsx +126 -0
  72. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/image-preloader.tsx +8 -0
  73. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/index.ts +3 -0
  74. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-sections.tsx +269 -0
  75. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/landing-showcase.tsx +159 -0
  76. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/motion-story.tsx +78 -0
  77. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/overlays-story.tsx +134 -0
  78. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/payment-showcase.tsx +82 -0
  79. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/presentation-story.tsx +68 -0
  80. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/primitives-story.tsx +267 -0
  81. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/story-page.tsx +14 -0
  82. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/theme-story-client.tsx +208 -0
  83. package/template/packages/frontend/src/app/[locale]/ui/_catalogue/theme-story.tsx +13 -0
  84. package/template/packages/frontend/src/app/[locale]/ui/layout.tsx +6 -0
  85. package/template/packages/frontend/src/app/[locale]/ui/page.tsx +10 -0
  86. package/template/packages/frontend/src/app/api/og/[locale]/[page]/route.tsx +53 -0
  87. package/template/packages/frontend/src/app/globals.css +210 -0
  88. package/template/packages/frontend/src/app/robots.ts +9 -0
  89. package/template/packages/frontend/src/app/sitemap.ts +22 -0
  90. package/template/packages/frontend/src/components/brand-mark.tsx +22 -0
  91. package/template/packages/frontend/src/components/repository-summary.tsx +107 -0
  92. package/template/packages/frontend/src/components/system-controls.tsx +92 -0
  93. package/template/packages/frontend/src/components/ui/adaptive-modal.tsx +382 -0
  94. package/template/packages/frontend/src/components/ui/animated-counter.tsx +80 -0
  95. package/template/packages/frontend/src/components/ui/avatar.tsx +46 -0
  96. package/template/packages/frontend/src/components/ui/badge.tsx +94 -0
  97. package/template/packages/frontend/src/components/ui/button-variants.ts +24 -0
  98. package/template/packages/frontend/src/components/ui/button.tsx +81 -0
  99. package/template/packages/frontend/src/components/ui/card-deck.tsx +73 -0
  100. package/template/packages/frontend/src/components/ui/card.tsx +81 -0
  101. package/template/packages/frontend/src/components/ui/charts/area-chart.tsx +167 -0
  102. package/template/packages/frontend/src/components/ui/charts/index.ts +7 -0
  103. package/template/packages/frontend/src/components/ui/checkbox.tsx +133 -0
  104. package/template/packages/frontend/src/components/ui/confirmation-modal.tsx +129 -0
  105. package/template/packages/frontend/src/components/ui/copyable-text.tsx +85 -0
  106. package/template/packages/frontend/src/components/ui/drawer.tsx +136 -0
  107. package/template/packages/frontend/src/components/ui/dropdown-menu.tsx +185 -0
  108. package/template/packages/frontend/src/components/ui/field.tsx +28 -0
  109. package/template/packages/frontend/src/components/ui/hand-drawn-icons.tsx +41 -0
  110. package/template/packages/frontend/src/components/ui/index.ts +36 -0
  111. package/template/packages/frontend/src/components/ui/input.tsx +24 -0
  112. package/template/packages/frontend/src/components/ui/label.tsx +18 -0
  113. package/template/packages/frontend/src/components/ui/otp-input.tsx +140 -0
  114. package/template/packages/frontend/src/components/ui/select.tsx +240 -0
  115. package/template/packages/frontend/src/components/ui/sidebar.tsx +343 -0
  116. package/template/packages/frontend/src/components/ui/skeleton.tsx +8 -0
  117. package/template/packages/frontend/src/components/ui/slider.tsx +31 -0
  118. package/template/packages/frontend/src/components/ui/slot-drum.tsx +50 -0
  119. package/template/packages/frontend/src/components/ui/spinner.tsx +30 -0
  120. package/template/packages/frontend/src/components/ui/switch.tsx +31 -0
  121. package/template/packages/frontend/src/components/ui/table.tsx +106 -0
  122. package/template/packages/frontend/src/components/ui/tabs.tsx +196 -0
  123. package/template/packages/frontend/src/components/ui/textarea.tsx +21 -0
  124. package/template/packages/frontend/src/components/ui/toaster.tsx +72 -0
  125. package/template/packages/frontend/src/components/ui/tooltip.tsx +31 -0
  126. package/template/packages/frontend/src/env.ts +21 -0
  127. package/template/packages/frontend/src/features/data-table/components/column-helpers.tsx +136 -0
  128. package/template/packages/frontend/src/features/data-table/components/data-table.tsx +303 -0
  129. package/template/packages/frontend/src/features/data-table/components/inline-stat.tsx +113 -0
  130. package/template/packages/frontend/src/features/data-table/components/sortable-header.tsx +33 -0
  131. package/template/packages/frontend/src/features/data-table/components/table-error.tsx +24 -0
  132. package/template/packages/frontend/src/features/data-table/components/table-filters.tsx +82 -0
  133. package/template/packages/frontend/src/features/data-table/hooks/useTableState.ts +111 -0
  134. package/template/packages/frontend/src/features/data-table/index.ts +26 -0
  135. package/template/packages/frontend/src/features/data-table/table-features.ts +26 -0
  136. package/template/packages/frontend/src/hooks/index.ts +3 -0
  137. package/template/packages/frontend/src/hooks/useInfiniteScroll.ts +67 -0
  138. package/template/packages/frontend/src/hooks/useIsTouchDevice.ts +25 -0
  139. package/template/packages/frontend/src/hooks/useMediaQuery.ts +30 -0
  140. package/template/packages/frontend/src/i18n/locales.ts +6 -0
  141. package/template/packages/frontend/src/i18n/navigation.ts +4 -0
  142. package/template/packages/frontend/src/i18n/request.ts +9 -0
  143. package/template/packages/frontend/src/i18n/routing.ts +8 -0
  144. package/template/packages/frontend/src/lib/api/client.ts +20 -0
  145. package/template/packages/frontend/src/lib/api/queries.ts +11 -0
  146. package/template/packages/frontend/src/lib/query-client.ts +20 -0
  147. package/template/packages/frontend/src/lib/realtime/repository.ts +21 -0
  148. package/template/packages/frontend/src/lib/seo/metadata.ts +50 -0
  149. package/template/packages/frontend/src/lib/seo/pages.test.ts +25 -0
  150. package/template/packages/frontend/src/lib/seo/pages.ts +107 -0
  151. package/template/packages/frontend/src/lib/utils/cn.ts +6 -0
  152. package/template/packages/frontend/src/lib/utils/format.ts +25 -0
  153. package/template/packages/frontend/src/lib/utils/index.ts +2 -0
  154. package/template/packages/frontend/src/providers/index.tsx +18 -0
  155. package/template/packages/frontend/src/providers/react-query.tsx +9 -0
  156. package/template/packages/frontend/src/providers/realtime.tsx +16 -0
  157. package/template/packages/frontend/src/proxy.ts +6 -0
  158. package/template/packages/frontend/src/theme/config.ts +20 -0
  159. package/template/packages/frontend/src/theme/transition.ts +54 -0
  160. package/template/packages/frontend/tsconfig.json +13 -0
  161. package/template/packages/shared/package.json +23 -0
  162. package/template/packages/shared/src/contracts/repository.ts +28 -0
  163. package/template/packages/shared/src/events/repository.ts +9 -0
  164. package/template/packages/shared/src/index.ts +3 -0
  165. package/template/packages/shared/src/schemas/repository.test.ts +36 -0
  166. package/template/packages/shared/src/schemas/repository.ts +28 -0
  167. package/template/packages/shared/tsconfig.json +5 -0
  168. package/template/playwright.config.ts +15 -0
  169. package/template/scripts/check-authored.ts +81 -0
  170. package/template/scripts/dev.ts +23 -0
  171. package/template/scripts/local-env.ts +12 -0
  172. package/template/scripts/runtime-smoke.ts +67 -0
  173. package/template/scripts/tooling-env.ts +15 -0
  174. package/template/tsconfig.json +13 -0
@@ -0,0 +1,237 @@
1
+ import AxeBuilder from '@axe-core/playwright';
2
+ import { expect, test } from '@playwright/test';
3
+
4
+ test('renders the hydrated starter application and catalogue', async ({ page }) => {
5
+ await page.goto('/en');
6
+ await expect(page.getByRole('heading', { level: 1 })).toContainText(
7
+ 'Build the product, not the plumbing',
8
+ );
9
+ await expect(page.getByText('max-listov/stitchkit')).toBeVisible();
10
+ await page.getByRole('link', { name: /UI system/ }).click();
11
+ await expect(page).toHaveURL(/\/en\/ui\/components$/);
12
+ await expect(page.getByRole('heading', { level: 1 })).toContainText('UI components');
13
+ });
14
+
15
+ test('publishes complete page metadata and a reachable Open Graph card', async ({
16
+ page,
17
+ request,
18
+ }) => {
19
+ await page.goto('/en/ui/themes');
20
+ await expect(page).toHaveTitle('Theme system · Stitchkit Starter');
21
+ await expect(page.locator('link[rel="canonical"]')).toHaveAttribute(
22
+ 'href',
23
+ /\/en\/ui\/themes$/,
24
+ );
25
+ await expect(page.locator('meta[property="og:description"]')).toHaveAttribute(
26
+ 'content',
27
+ /View Transition/,
28
+ );
29
+
30
+ const imageUrl = await page.locator('meta[property="og:image"]').getAttribute('content');
31
+ expect(imageUrl).not.toBeNull();
32
+ if (!imageUrl) throw new Error('Open Graph image URL is missing');
33
+ const imageResponse = await request.get(imageUrl);
34
+ expect(imageResponse.status()).toBe(200);
35
+ expect(imageResponse.headers()['content-type']).toContain('image/png');
36
+
37
+ const sitemapResponse = await request.get('/sitemap.xml');
38
+ expect(sitemapResponse.status()).toBe(200);
39
+ expect(await sitemapResponse.text()).toContain('/ru/ui/themes');
40
+ });
41
+
42
+ test('spins the refresh icon in place while its action is pending', async ({ page }) => {
43
+ await page.route('**/api/repository/refresh', async (route) => {
44
+ await new Promise((resolve) => setTimeout(resolve, 750));
45
+ await route.continue();
46
+ });
47
+ await page.goto('/en');
48
+
49
+ const refresh = page.getByRole('button', { name: 'Refresh repository data' });
50
+ await expect(refresh).toHaveCSS('height', '32px');
51
+ await expect(refresh).toHaveCSS('width', '32px');
52
+ await expect(refresh.locator('.tabler-icon-refresh')).toHaveCount(1);
53
+ await refresh.click();
54
+ await expect(refresh).toHaveAttribute('aria-busy', 'true');
55
+ await expect(refresh.locator('svg')).toHaveCount(1);
56
+ await expect(refresh.locator('.tabler-icon-refresh')).toHaveClass(/animate-spin/);
57
+ });
58
+
59
+ test('switches catalogue sections and component tabs', async ({ page }) => {
60
+ await page.goto('/en/ui');
61
+ await expect(page).toHaveURL(/\/en\/ui\/components$/);
62
+
63
+ const navigationTrigger = page.getByRole('button', { name: 'Open navigation' });
64
+ const mobileNavigation = await navigationTrigger.isVisible();
65
+ if (mobileNavigation) await navigationTrigger.click();
66
+
67
+ await expect(page.getByRole('link', { name: 'UI components', exact: true })).toHaveAttribute(
68
+ 'aria-current',
69
+ 'page',
70
+ );
71
+ if (mobileNavigation) await page.getByRole('button', { name: 'Close navigation' }).click();
72
+ await page.getByRole('tab', { name: 'Feedback' }).click();
73
+ await expect(page.getByRole('tab', { name: 'Feedback' })).toHaveAttribute(
74
+ 'aria-selected',
75
+ 'true',
76
+ );
77
+ await page.getByRole('button', { name: 'Open project' }).click();
78
+ await expect(page.getByText('Project opened')).toBeVisible();
79
+ await page.getByRole('button', { name: 'Success toast' }).click();
80
+ await expect(page.getByText('Changes saved')).toBeVisible();
81
+
82
+ if (mobileNavigation) await navigationTrigger.click();
83
+ await page.getByRole('link', { name: 'Theme system', exact: true }).click();
84
+ await expect(page).toHaveURL(/\/en\/ui\/themes$/);
85
+ });
86
+
87
+ test('uses one matching focus border for inputs and textareas', async ({ page }) => {
88
+ await page.emulateMedia({ reducedMotion: 'reduce' });
89
+ await page.goto('/en/ui/components');
90
+ await page.getByRole('tab', { name: 'Forms' }).click();
91
+
92
+ const input = page.getByLabel('Project name');
93
+ const textarea = page.getByLabel('Description');
94
+ const inputFocus = await input.evaluate((element) => {
95
+ element.focus();
96
+ const style = getComputedStyle(element);
97
+ return {
98
+ borderColor: style.borderColor,
99
+ boxShadow: style.boxShadow,
100
+ outlineStyle: style.outlineStyle,
101
+ };
102
+ });
103
+ const textareaFocus = await textarea.evaluate((element) => {
104
+ element.focus();
105
+ const style = getComputedStyle(element);
106
+ return {
107
+ borderColor: style.borderColor,
108
+ boxShadow: style.boxShadow,
109
+ outlineStyle: style.outlineStyle,
110
+ };
111
+ });
112
+
113
+ expect(textareaFocus).toEqual(inputFocus);
114
+ expect(textareaFocus.boxShadow).toBe('none');
115
+ expect(textareaFocus.outlineStyle).toBe('none');
116
+ });
117
+
118
+ test('has no serious accessibility violations on the main surfaces', async ({ page }) => {
119
+ test.setTimeout(90_000);
120
+ await page.emulateMedia({ reducedMotion: 'reduce' });
121
+
122
+ for (const path of ['/en', '/en/ui/components', '/en/ui/themes', '/en/ui/blocks']) {
123
+ await page.goto(path);
124
+ await expect(page.locator('html')).not.toHaveClass(/theme-transitioning/);
125
+ const results = await new AxeBuilder({ page }).analyze();
126
+ const blocking = results.violations.filter(
127
+ (violation) => violation.impact === 'critical' || violation.impact === 'serious',
128
+ );
129
+ expect(blocking, `${path}: ${blocking.map((item) => item.id).join(', ')}`).toEqual([]);
130
+ }
131
+ });
132
+
133
+ test('keeps the mobile surface inside the viewport', async ({ page }) => {
134
+ await page.goto('/en/ui/blocks');
135
+ const viewport = await page.evaluate(() => ({
136
+ horizontalOverflow: document.documentElement.scrollWidth - window.innerWidth,
137
+ verticalOverflow: document.documentElement.scrollHeight - window.innerHeight,
138
+ }));
139
+ expect(viewport.horizontalOverflow).toBeLessThanOrEqual(1);
140
+ expect(viewport.verticalOverflow).toBeLessThanOrEqual(1);
141
+
142
+ const content = page.getByTestId('catalogue-content');
143
+ const scrollSurface = await content.evaluate((element) => ({
144
+ clientHeight: element.clientHeight,
145
+ scrollHeight: element.scrollHeight,
146
+ }));
147
+ expect(scrollSurface.scrollHeight).toBeGreaterThan(scrollSurface.clientHeight);
148
+ });
149
+
150
+ test('keeps the desktop starter inside one viewport', async ({ page }) => {
151
+ await page.setViewportSize({ width: 1440, height: 900 });
152
+ await page.goto('/en');
153
+ const overflow = await page.evaluate(
154
+ () => document.documentElement.scrollHeight - window.innerHeight,
155
+ );
156
+ expect(overflow).toBeLessThanOrEqual(1);
157
+ });
158
+
159
+ test('keeps long localized navigation labels inside the mobile drawer', async ({ page }) => {
160
+ await page.setViewportSize({ width: 390, height: 844 });
161
+ await page.goto('/ru/ui/components');
162
+ await page.getByRole('button', { name: 'Open navigation' }).click();
163
+
164
+ const drawer = page.getByRole('dialog');
165
+ await expect(drawer).toBeVisible();
166
+ const overflow = await drawer.evaluate((element) => ({
167
+ drawer: element.scrollWidth - element.clientWidth,
168
+ labels: [...element.querySelectorAll('nav a span')].map(
169
+ (label) => label.scrollWidth - label.clientWidth,
170
+ ),
171
+ page: document.documentElement.scrollWidth - window.innerWidth,
172
+ }));
173
+
174
+ expect(overflow.drawer).toBeLessThanOrEqual(1);
175
+ expect(overflow.labels.every((value) => value <= 1)).toBe(true);
176
+ expect(overflow.page).toBeLessThanOrEqual(1);
177
+ });
178
+
179
+ test('provides a server-first synchronized theme system', async ({ context, page }) => {
180
+ const consoleProblems: string[] = [];
181
+ page.on('console', (message) => {
182
+ if (message.type() === 'error' || message.type() === 'warning') {
183
+ consoleProblems.push(message.text());
184
+ }
185
+ });
186
+
187
+ await page.goto('/en/ui/themes');
188
+ await expect(page.getByTestId('theme-state-selected')).not.toContainText('hydrating');
189
+ await expect(page.locator('#theme-scope-light')).toHaveClass(/light/);
190
+ await expect(page.locator('#theme-scope-dark')).toHaveClass(/dark/);
191
+
192
+ const supportsViewTransitions = await page.evaluate(
193
+ () => typeof document.startViewTransition === 'function',
194
+ );
195
+ if (supportsViewTransitions) {
196
+ await page.getByRole('button', { name: 'Radial reveal', exact: true }).click();
197
+ await page.getByRole('button', { name: '400 ms', exact: true }).click();
198
+ await page.getByRole('button', { name: 'Dark', exact: true }).click();
199
+ await expect(page.locator('html')).toHaveAttribute('data-theme-transition', 'radial');
200
+ await expect(page.locator('html')).not.toHaveAttribute('data-theme-transition', 'radial');
201
+ }
202
+
203
+ await page.getByRole('button', { name: 'Dark', exact: true }).click();
204
+ await expect(page.locator('html')).toHaveClass(/dark/);
205
+ await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#242428');
206
+ await expect(
207
+ page.getByAltText('Application shell selected by the resolved theme'),
208
+ ).toHaveAttribute('src', '/theme-dark.svg');
209
+
210
+ const secondPage = await context.newPage();
211
+ await secondPage.goto('/en/ui/themes');
212
+ await expect(secondPage.locator('html')).toHaveClass(/dark/);
213
+ await page.getByRole('button', { name: 'Light', exact: true }).click();
214
+ await expect(secondPage.locator('html')).toHaveClass(/light/);
215
+
216
+ await page.getByRole('button', { name: 'System', exact: true }).click();
217
+ await expect(page.getByTestId('theme-state-selected')).toContainText('system');
218
+ const themeCookie = (await context.cookies()).find(
219
+ (cookie) => cookie.name === 'stitchkit-starter-theme',
220
+ );
221
+ expect(themeCookie?.value).toBe('system');
222
+ await page.reload();
223
+ await expect(page.getByTestId('theme-state-server-cookie')).toContainText('system');
224
+ expect(
225
+ consoleProblems.filter((message) =>
226
+ /hydration|useServerInsertedHTML|inline script/i.test(message),
227
+ ),
228
+ ).toEqual([]);
229
+ });
230
+
231
+ test('falls back to an immediate theme update for reduced motion', async ({ page }) => {
232
+ await page.emulateMedia({ reducedMotion: 'reduce' });
233
+ await page.goto('/en/ui/themes');
234
+ await page.getByRole('button', { name: 'Dark', exact: true }).click();
235
+ await expect(page.locator('html')).toHaveClass(/dark/);
236
+ await expect(page.locator('html')).not.toHaveAttribute('data-theme-transition');
237
+ });
@@ -0,0 +1,28 @@
1
+ const path = require('node:path');
2
+ const { config } = require('dotenv');
3
+
4
+ config({ path: path.join(__dirname, '.env'), quiet: true, override: true });
5
+
6
+ module.exports = {
7
+ apps: [
8
+ {
9
+ name: 'stitchkit-starter-backend',
10
+ cwd: path.join(__dirname, 'packages/backend'),
11
+ script: 'dist/index.js',
12
+ interpreter: 'bun',
13
+ autorestart: true,
14
+ kill_timeout: 15000,
15
+ env: { NODE_ENV: 'production' },
16
+ },
17
+ {
18
+ name: 'stitchkit-starter-frontend',
19
+ cwd: path.join(__dirname, 'packages/frontend'),
20
+ script: 'node_modules/.bin/next',
21
+ args: ['start', '--port', process.env.WEB_PORT, '--hostname', '0.0.0.0'],
22
+ interpreter: 'bun',
23
+ autorestart: true,
24
+ kill_timeout: 15000,
25
+ env: { NODE_ENV: 'production' },
26
+ },
27
+ ],
28
+ };
@@ -0,0 +1,29 @@
1
+ const path = require('node:path');
2
+ const { config } = require('dotenv');
3
+
4
+ config({ path: path.join(__dirname, '.env'), quiet: true, override: true });
5
+
6
+ module.exports = {
7
+ apps: [
8
+ {
9
+ name: 'stitchkit-starter-backend-dev',
10
+ cwd: path.join(__dirname, 'packages/backend'),
11
+ script: 'src/index.ts',
12
+ interpreter: 'bun',
13
+ interpreter_args: '--watch',
14
+ autorestart: true,
15
+ kill_timeout: 10000,
16
+ env: { NODE_ENV: 'development' },
17
+ },
18
+ {
19
+ name: 'stitchkit-starter-frontend-dev',
20
+ cwd: path.join(__dirname, 'packages/frontend'),
21
+ script: 'node_modules/.bin/next',
22
+ args: ['dev', '--port', process.env.WEB_PORT, '--hostname', '0.0.0.0'],
23
+ interpreter: 'bun',
24
+ autorestart: true,
25
+ kill_timeout: 10000,
26
+ env: { NODE_ENV: 'development' },
27
+ },
28
+ ],
29
+ };
@@ -0,0 +1,55 @@
1
+ {
2
+ "name": "stitchkit-starter",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "workspaces": [
7
+ "packages/*"
8
+ ],
9
+ "catalog": {
10
+ "stitchkit": "^0.42.0"
11
+ },
12
+ "scripts": {
13
+ "dev": "bun scripts/dev.ts",
14
+ "check": "bun run db:generate && bun run check:authored && bun run --filter '*' check",
15
+ "check:authored": "bun scripts/check-authored.ts",
16
+ "test": "bun run --filter '*' test",
17
+ "build": "bun run db:generate && bun --filter @app/backend build && bun --filter @app/frontend build",
18
+ "start:api": "bun --filter @app/backend start",
19
+ "start:web": "bun --filter @app/frontend start",
20
+ "env:ensure": "bun scripts/local-env.ts",
21
+ "db:setup": "bun run db:generate && bun run db:deploy",
22
+ "db:generate": "bun run env:ensure && bun --filter @app/db db:generate",
23
+ "db:migrate": "bun --filter @app/db db:migrate",
24
+ "db:deploy": "bun --filter @app/db db:deploy",
25
+ "db:studio": "bun --filter @app/db db:studio",
26
+ "cli": "bun packages/backend/src/cli.ts",
27
+ "tools": "bun packages/backend/src/tools.ts",
28
+ "runtime:smoke": "bun scripts/runtime-smoke.ts",
29
+ "e2e": "playwright test",
30
+ "lint": "biome check --error-on-warnings .",
31
+ "lint:fix": "biome check --write .",
32
+ "pm2:dev": "bun scripts/dev.ts",
33
+ "pm2:prod": "bun run db:deploy && pm2 startOrReload ecosystem.config.cjs --update-env"
34
+ },
35
+ "dependencies": {
36
+ "dotenv": "^17.4.2"
37
+ },
38
+ "devDependencies": {
39
+ "@app/shared": "workspace:*",
40
+ "@axe-core/playwright": "^4.11.0",
41
+ "@biomejs/biome": "^2.5.7",
42
+ "@modelcontextprotocol/sdk": "^1.30.0",
43
+ "@playwright/test": "^1.55.0",
44
+ "@types/bun": "^1.3.14",
45
+ "oxc-parser": "^0.143.0",
46
+ "socket.io-client": "^4.8.3",
47
+ "typescript": "^7.0.2",
48
+ "zod": "^4.4.3"
49
+ },
50
+ "engines": {
51
+ "bun": ">=1.2.0",
52
+ "node": ">=22"
53
+ },
54
+ "packageManager": "bun@1.3.14"
55
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@app/backend",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "scripts": {
7
+ "dev": "bun --watch src/index.ts",
8
+ "build": "rm -rf dist && bun build src/index.ts src/cli.ts src/tools.ts --outdir dist --target bun --packages external",
9
+ "start": "bun dist/index.js",
10
+ "check": "bun x tsc --noEmit",
11
+ "test": "bun test --pass-with-no-tests",
12
+ "cli": "bun src/cli.ts",
13
+ "tools": "bun src/tools.ts"
14
+ },
15
+ "dependencies": {
16
+ "@app/config": "workspace:*",
17
+ "@app/db": "workspace:*",
18
+ "@app/shared": "workspace:*",
19
+ "@modelcontextprotocol/sdk": "^1.30.0",
20
+ "@prisma/adapter-pg": "^7.9.1",
21
+ "@socket.io/bun-engine": "^0.1.1",
22
+ "ai": "^7.0.58",
23
+ "socket.io": "^4.8.3",
24
+ "stitchkit": "catalog:",
25
+ "zod": "^4.4.3"
26
+ },
27
+ "devDependencies": {
28
+ "@types/bun": "^1.3.14",
29
+ "typescript": "^7.0.2"
30
+ }
31
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import { createCli } from 'stitchkit/cli';
4
+ import { createSurface } from './surface';
5
+
6
+ const { services, socket } = await createSurface();
7
+
8
+ try {
9
+ await createCli({ name: 'stitchkit-starter', version: '0.1.0', services });
10
+ } finally {
11
+ await socket.io.close();
12
+ }
@@ -0,0 +1,9 @@
1
+ import { defineErrors } from 'stitchkit';
2
+ import { z } from 'zod';
3
+
4
+ export const { errors: domainErrors, codes: domainErrorCodes } = defineErrors({
5
+ GITHUB_UNAVAILABLE: {
6
+ status: 503,
7
+ details: z.object({ cause: z.string() }),
8
+ },
9
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, expect, test } from 'bun:test';
2
+ import {
3
+ GitHubRepositoryCache,
4
+ type RepositorySnapshotStore,
5
+ type SnapshotRecord,
6
+ } from './github-cache';
7
+
8
+ function repositoryResponse(): Response {
9
+ return Response.json({
10
+ full_name: 'max-listov/stitchkit',
11
+ description: 'Contract-first framework',
12
+ html_url: 'https://github.com/max-listov/stitchkit',
13
+ language: 'TypeScript',
14
+ visibility: 'public',
15
+ stargazers_count: 42,
16
+ forks_count: 7,
17
+ open_issues_count: 1,
18
+ });
19
+ }
20
+
21
+ function commitsResponse(): Response {
22
+ return Response.json(
23
+ [
24
+ {
25
+ sha: 'abcdef123456',
26
+ commit: {
27
+ message: 'Ship the starter\n\nDetails',
28
+ author: { date: '2026-08-08T00:00:00Z' },
29
+ committer: { date: '2026-08-08T00:00:00Z' },
30
+ },
31
+ },
32
+ ],
33
+ {
34
+ headers: {
35
+ link: '<https://api.github.com/repositories/1/commits?per_page=1&page=314>; rel="last"',
36
+ },
37
+ },
38
+ );
39
+ }
40
+
41
+ describe('GitHubRepositoryCache', () => {
42
+ test('deduplicates refreshes and persists an exact repository snapshot', async () => {
43
+ const requests: URL[] = [];
44
+ const fetcher = async (url: URL) => {
45
+ requests.push(url);
46
+ return url.pathname.endsWith('/commits') ? commitsResponse() : repositoryResponse();
47
+ };
48
+ let stored: SnapshotRecord | null = null;
49
+ const store: RepositorySnapshotStore = {
50
+ read: async () => stored,
51
+ write: async (snapshot) => {
52
+ stored = snapshot;
53
+ return snapshot;
54
+ },
55
+ };
56
+
57
+ const cache = new GitHubRepositoryCache(fetcher, store);
58
+ const [first, concurrent] = await Promise.all([cache.read(), cache.read()]);
59
+
60
+ expect(first).toEqual(concurrent);
61
+ expect(first.fullName).toBe('max-listov/stitchkit');
62
+ expect(first.visibility).toBe('PUBLIC');
63
+ expect(first.commitCount).toBe(314);
64
+ expect(first.latestCommit?.message).toBe('Ship the starter');
65
+ expect(first.cache.state).toBe('revalidated');
66
+ expect(requests).toHaveLength(2);
67
+
68
+ const cached = await cache.read();
69
+ expect(cached.cache.state).toBe('fresh');
70
+ expect(requests).toHaveLength(2);
71
+ });
72
+ });
@@ -0,0 +1,205 @@
1
+ import { env } from '@app/config';
2
+ import { RepositoryVisibility } from '@app/db';
3
+ import type { RepositorySnapshot } from '@app/shared';
4
+ import { z } from 'zod';
5
+ import { prisma } from '../../lib/db';
6
+ import { domainErrors } from '../errors';
7
+
8
+ const GitHubRepositoryResponseSchema = z.object({
9
+ full_name: z.string(),
10
+ description: z.string().nullable(),
11
+ html_url: z.url(),
12
+ language: z.string().nullable(),
13
+ visibility: z.enum(['public', 'private', 'internal']),
14
+ stargazers_count: z.number().int().nonnegative(),
15
+ forks_count: z.number().int().nonnegative(),
16
+ open_issues_count: z.number().int().nonnegative(),
17
+ });
18
+
19
+ const GitHubCommitResponseSchema = z.array(
20
+ z.object({
21
+ sha: z.string().min(1),
22
+ commit: z.object({
23
+ message: z.string().min(1),
24
+ author: z.object({ date: z.iso.datetime().nullable() }).nullable(),
25
+ committer: z.object({ date: z.iso.datetime().nullable() }).nullable(),
26
+ }),
27
+ }),
28
+ );
29
+
30
+ const RepositoryNameSchema = z
31
+ .string()
32
+ .regex(/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/, 'Expected owner/repository');
33
+
34
+ type RepositoryFetcher = (url: URL, init: RequestInit) => Promise<Response>;
35
+ export interface SnapshotRecord {
36
+ fullName: string;
37
+ description: string | null;
38
+ htmlUrl: string;
39
+ language: string | null;
40
+ visibility: RepositoryVisibility;
41
+ stars: number;
42
+ forks: number;
43
+ openIssues: number;
44
+ commitCount: number;
45
+ latestCommitSha: string | null;
46
+ latestCommitMessage: string | null;
47
+ latestCommittedAt: Date | null;
48
+ fetchedAt: Date;
49
+ expiresAt: Date;
50
+ }
51
+
52
+ function repositoryVisibility(
53
+ visibility: z.infer<typeof GitHubRepositoryResponseSchema>['visibility'],
54
+ ): RepositoryVisibility {
55
+ switch (visibility) {
56
+ case 'public':
57
+ return RepositoryVisibility.PUBLIC;
58
+ case 'private':
59
+ return RepositoryVisibility.PRIVATE;
60
+ case 'internal':
61
+ return RepositoryVisibility.INTERNAL;
62
+ }
63
+ }
64
+
65
+ export interface RepositorySnapshotStore {
66
+ read(fullName: string): Promise<SnapshotRecord | null>;
67
+ write(snapshot: SnapshotRecord): Promise<SnapshotRecord>;
68
+ }
69
+
70
+ const snapshotStore: RepositorySnapshotStore = {
71
+ read: (fullName) => prisma.repositorySnapshot.findUnique({ where: { fullName } }),
72
+ write: (snapshot) =>
73
+ prisma.repositorySnapshot.upsert({
74
+ where: { fullName: snapshot.fullName },
75
+ create: snapshot,
76
+ update: snapshot,
77
+ }),
78
+ };
79
+
80
+ function githubHeaders(): Headers {
81
+ const headers = new Headers({
82
+ Accept: 'application/vnd.github+json',
83
+ 'User-Agent': 'stitchkit-starter',
84
+ 'X-GitHub-Api-Version': '2026-03-10',
85
+ });
86
+ if (env.GITHUB_TOKEN) headers.set('Authorization', `Bearer ${env.GITHUB_TOKEN}`);
87
+ return headers;
88
+ }
89
+
90
+ function commitCount(response: Response, visibleCommits: number): number {
91
+ const link = response.headers.get('link');
92
+ const lastPage = link?.match(/<[^>]*[?&]page=(\d+)[^>]*>; rel="last"/)?.[1];
93
+ return lastPage ? z.coerce.number().int().nonnegative().parse(lastPage) : visibleCommits;
94
+ }
95
+
96
+ function serialize(
97
+ record: SnapshotRecord,
98
+ state: RepositorySnapshot['cache']['state'],
99
+ ): RepositorySnapshot {
100
+ const latestCommit =
101
+ record.latestCommitSha && record.latestCommitMessage && record.latestCommittedAt
102
+ ? {
103
+ sha: record.latestCommitSha,
104
+ message: record.latestCommitMessage,
105
+ committedAt: record.latestCommittedAt.toISOString(),
106
+ }
107
+ : null;
108
+
109
+ return {
110
+ fullName: record.fullName,
111
+ description: record.description,
112
+ htmlUrl: record.htmlUrl,
113
+ language: record.language,
114
+ visibility: record.visibility,
115
+ stars: record.stars,
116
+ forks: record.forks,
117
+ openIssues: record.openIssues,
118
+ commitCount: record.commitCount,
119
+ latestCommit,
120
+ cache: {
121
+ state,
122
+ fetchedAt: record.fetchedAt.toISOString(),
123
+ expiresAt: record.expiresAt.toISOString(),
124
+ },
125
+ };
126
+ }
127
+
128
+ export class GitHubRepositoryCache {
129
+ private refreshPromise?: Promise<RepositorySnapshot>;
130
+
131
+ constructor(
132
+ private readonly fetcher: RepositoryFetcher = (url, init) => fetch(url, init),
133
+ private readonly store: RepositorySnapshotStore = snapshotStore,
134
+ ) {}
135
+
136
+ async read(): Promise<RepositorySnapshot> {
137
+ const repository = RepositoryNameSchema.parse(env.GITHUB_REPOSITORY);
138
+ const snapshot = await this.store.read(repository);
139
+ if (snapshot && snapshot.expiresAt > new Date()) return serialize(snapshot, 'fresh');
140
+ return this.refresh();
141
+ }
142
+
143
+ refresh(): Promise<RepositorySnapshot> {
144
+ if (this.refreshPromise) return this.refreshPromise;
145
+ this.refreshPromise = this.revalidate().finally(() => {
146
+ this.refreshPromise = undefined;
147
+ });
148
+ return this.refreshPromise;
149
+ }
150
+
151
+ private async revalidate(): Promise<RepositorySnapshot> {
152
+ const repositoryName = RepositoryNameSchema.parse(env.GITHUB_REPOSITORY);
153
+ const previous = await this.store.read(repositoryName);
154
+
155
+ try {
156
+ const [repositoryResponse, commitsResponse] = await Promise.all([
157
+ this.fetcher(new URL(`/repos/${repositoryName}`, 'https://api.github.com'), {
158
+ headers: githubHeaders(),
159
+ signal: AbortSignal.timeout(8_000),
160
+ }),
161
+ this.fetcher(
162
+ new URL(`/repos/${repositoryName}/commits?per_page=1`, 'https://api.github.com'),
163
+ { headers: githubHeaders(), signal: AbortSignal.timeout(8_000) },
164
+ ),
165
+ ]);
166
+ if (!repositoryResponse.ok || !commitsResponse.ok) {
167
+ throw new Error(
168
+ `GitHub returned repository=${repositoryResponse.status}, commits=${commitsResponse.status}`,
169
+ );
170
+ }
171
+
172
+ const repository = GitHubRepositoryResponseSchema.parse(await repositoryResponse.json());
173
+ const commits = GitHubCommitResponseSchema.parse(await commitsResponse.json());
174
+ const latest = commits[0];
175
+ const committedAt = latest?.commit.author?.date ?? latest?.commit.committer?.date;
176
+ if (latest && !committedAt) throw new Error('The latest GitHub commit has no timestamp');
177
+
178
+ const fetchedAt = new Date();
179
+ const snapshot = await this.store.write({
180
+ fullName: repository.full_name,
181
+ description: repository.description,
182
+ htmlUrl: repository.html_url,
183
+ language: repository.language,
184
+ visibility: repositoryVisibility(repository.visibility),
185
+ stars: repository.stargazers_count,
186
+ forks: repository.forks_count,
187
+ openIssues: repository.open_issues_count,
188
+ commitCount: commitCount(commitsResponse, commits.length),
189
+ latestCommitSha: latest?.sha ?? null,
190
+ latestCommitMessage: latest?.commit.message.split('\n')[0] ?? null,
191
+ latestCommittedAt: committedAt ? new Date(committedAt) : null,
192
+ fetchedAt,
193
+ expiresAt: new Date(fetchedAt.getTime() + env.GITHUB_CACHE_TTL_SECONDS * 1_000),
194
+ });
195
+ return serialize(snapshot, 'revalidated');
196
+ } catch (error) {
197
+ if (previous) return serialize(previous, 'stale');
198
+ throw domainErrors.GITHUB_UNAVAILABLE({
199
+ message: 'GitHub repository data is temporarily unavailable',
200
+ details: { cause: error instanceof Error ? error.message : 'Unknown upstream error' },
201
+ hint: 'Retry after the upstream service recovers',
202
+ });
203
+ }
204
+ }
205
+ }