@usequark/quark-create-app 1.23.11

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 (237) hide show
  1. package/README.md +210 -0
  2. package/package.json +56 -0
  3. package/src/dependabot-config.test.js +128 -0
  4. package/src/deploy/adapters/iac.js +203 -0
  5. package/src/deploy/adapters/index.js +27 -0
  6. package/src/deploy/adapters/railway.js +508 -0
  7. package/src/deploy/adapters/railway.test.js +318 -0
  8. package/src/deploy/contract.js +59 -0
  9. package/src/deploy/deploy.integration.test.js +183 -0
  10. package/src/deploy/deploy.js +430 -0
  11. package/src/deploy/deploy.test.js +105 -0
  12. package/src/deploy/discovery.js +115 -0
  13. package/src/deploy/discovery.test.js +141 -0
  14. package/src/deploy/index.js +38 -0
  15. package/src/deploy/inspect.js +130 -0
  16. package/src/index.js +2920 -0
  17. package/src/mobile-template.test.js +232 -0
  18. package/src/prepare-hooks.test.js +535 -0
  19. package/src/scaffold-guards.test.js +140 -0
  20. package/src/scaffold-output.test.js +228 -0
  21. package/src/template-scripts.test.js +160 -0
  22. package/src/utils.js +77 -0
  23. package/src/utils.test.js +95 -0
  24. package/src/worker-dockerfile.test.js +85 -0
  25. package/templates/base-project/.cursor/rules/quark.mdc +172 -0
  26. package/templates/base-project/.dockerignore +14 -0
  27. package/templates/base-project/.env.railway.example +87 -0
  28. package/templates/base-project/.github/copilot-instructions.md +65 -0
  29. package/templates/base-project/.github/dependabot.yml +31 -0
  30. package/templates/base-project/.github/skills/project-context/SKILL.md +109 -0
  31. package/templates/base-project/.github/workflows/ci.yml +116 -0
  32. package/templates/base-project/.github/workflows/dependabot-auto-merge.yml +22 -0
  33. package/templates/base-project/.github/workflows/install-windows.yml +27 -0
  34. package/templates/base-project/.github/workflows/release.yml +67 -0
  35. package/templates/base-project/.railway/railway.ts +40 -0
  36. package/templates/base-project/CLAUDE.md +163 -0
  37. package/templates/base-project/MAIN.md +21 -0
  38. package/templates/base-project/README.md +164 -0
  39. package/templates/base-project/apps/web/.env.example +99 -0
  40. package/templates/base-project/apps/web/Dockerfile +38 -0
  41. package/templates/base-project/apps/web/biome.json +8 -0
  42. package/templates/base-project/apps/web/jsconfig.json +9 -0
  43. package/templates/base-project/apps/web/next.config.js +154 -0
  44. package/templates/base-project/apps/web/package.json +42 -0
  45. package/templates/base-project/apps/web/postcss.config.mjs +7 -0
  46. package/templates/base-project/apps/web/public/.well-known/security.txt +3 -0
  47. package/templates/base-project/apps/web/public/quark.svg +47 -0
  48. package/templates/base-project/apps/web/scripts/prepare-standalone.mjs +71 -0
  49. package/templates/base-project/apps/web/src/app/_components/AsciiPanel.js +103 -0
  50. package/templates/base-project/apps/web/src/app/_components/HealthIndicator.js +85 -0
  51. package/templates/base-project/apps/web/src/app/_components/HomeThemeToggle.js +63 -0
  52. package/templates/base-project/apps/web/src/app/_components/QuarkAnimation.js +168 -0
  53. package/templates/base-project/apps/web/src/app/_components/SyncedPanels.js +212 -0
  54. package/templates/base-project/apps/web/src/app/_components/UmamiReplayRecorder.js +198 -0
  55. package/templates/base-project/apps/web/src/app/_components/UmamiWebVitals.js +39 -0
  56. package/templates/base-project/apps/web/src/app/api/auth/[...nextauth]/route.js +7 -0
  57. package/templates/base-project/apps/web/src/app/api/auth/apple/route.js +82 -0
  58. package/templates/base-project/apps/web/src/app/api/auth/google/route.js +59 -0
  59. package/templates/base-project/apps/web/src/app/api/auth/refresh/route.js +48 -0
  60. package/templates/base-project/apps/web/src/app/api/auth/register/route.js +63 -0
  61. package/templates/base-project/apps/web/src/app/api/auth/token/route.js +44 -0
  62. package/templates/base-project/apps/web/src/app/api/csrf/route.js +40 -0
  63. package/templates/base-project/apps/web/src/app/api/device/register/route.js +57 -0
  64. package/templates/base-project/apps/web/src/app/api/error-handler.js +23 -0
  65. package/templates/base-project/apps/web/src/app/api/files/[id]/route.js +89 -0
  66. package/templates/base-project/apps/web/src/app/api/files/route.js +120 -0
  67. package/templates/base-project/apps/web/src/app/api/health/route.js +160 -0
  68. package/templates/base-project/apps/web/src/app/api/users/[id]/route.js +54 -0
  69. package/templates/base-project/apps/web/src/app/api/users/me/route.js +67 -0
  70. package/templates/base-project/apps/web/src/app/api/users/route.js +45 -0
  71. package/templates/base-project/apps/web/src/app/auth/error/page.js +83 -0
  72. package/templates/base-project/apps/web/src/app/auth/forgot-password/page.js +58 -0
  73. package/templates/base-project/apps/web/src/app/auth/provider-loading.js +25 -0
  74. package/templates/base-project/apps/web/src/app/auth/register/RegisterPageClient.js +340 -0
  75. package/templates/base-project/apps/web/src/app/auth/register/page.js +17 -0
  76. package/templates/base-project/apps/web/src/app/auth/signin/SignInPageClient.js +230 -0
  77. package/templates/base-project/apps/web/src/app/auth/signin/page.js +10 -0
  78. package/templates/base-project/apps/web/src/app/favicon.ico +0 -0
  79. package/templates/base-project/apps/web/src/app/global-error.js +58 -0
  80. package/templates/base-project/apps/web/src/app/globals.css +1166 -0
  81. package/templates/base-project/apps/web/src/app/icon.svg +47 -0
  82. package/templates/base-project/apps/web/src/app/layout.js +57 -0
  83. package/templates/base-project/apps/web/src/app/manifest.js +12 -0
  84. package/templates/base-project/apps/web/src/app/not-found.js +39 -0
  85. package/templates/base-project/apps/web/src/app/page.js +262 -0
  86. package/templates/base-project/apps/web/src/app/robots.js +23 -0
  87. package/templates/base-project/apps/web/src/app/sitemap.js +14 -0
  88. package/templates/base-project/apps/web/src/lib/analytics/umami-before-send.js +29 -0
  89. package/templates/base-project/apps/web/src/lib/analytics/umami-config.js +95 -0
  90. package/templates/base-project/apps/web/src/lib/analytics/umami-csp.js +24 -0
  91. package/templates/base-project/apps/web/src/lib/analytics/umami-marketing.js +131 -0
  92. package/templates/base-project/apps/web/src/lib/analytics/umami-replay.js +204 -0
  93. package/templates/base-project/apps/web/src/lib/analytics/umami-web-vitals.js +237 -0
  94. package/templates/base-project/apps/web/src/lib/analytics/umami.js +57 -0
  95. package/templates/base-project/apps/web/src/lib/auth-middleware.js +82 -0
  96. package/templates/base-project/apps/web/src/lib/auth-signup.js +5 -0
  97. package/templates/base-project/apps/web/src/lib/auth.js +191 -0
  98. package/templates/base-project/apps/web/src/lib/csrf-cookie.js +11 -0
  99. package/templates/base-project/apps/web/src/lib/jwt.js +84 -0
  100. package/templates/base-project/apps/web/src/lib/load-crm-config.js +35 -0
  101. package/templates/base-project/apps/web/src/lib/normalize-auth-request.js +33 -0
  102. package/templates/base-project/apps/web/src/lib/proxy-auth.js +80 -0
  103. package/templates/base-project/apps/web/src/lib/seo/indexing.js +25 -0
  104. package/templates/base-project/apps/web/src/lib/seo/site-metadata.js +74 -0
  105. package/templates/base-project/apps/web/src/lib/sitemap-entries.js +22 -0
  106. package/templates/base-project/apps/web/src/preflight.js +55 -0
  107. package/templates/base-project/apps/web/src/proxy.js +328 -0
  108. package/templates/base-project/biome.json +52 -0
  109. package/templates/base-project/docker-compose.override.yml +17 -0
  110. package/templates/base-project/docker-compose.yml +37 -0
  111. package/templates/base-project/docs/FIRST_FEATURE.md +74 -0
  112. package/templates/base-project/docs/START_HERE.md +70 -0
  113. package/templates/base-project/docs/adr/README.md +42 -0
  114. package/templates/base-project/package.json +58 -0
  115. package/templates/base-project/packages/db/package.json +38 -0
  116. package/templates/base-project/packages/db/prisma/migrations/20260202061128_initial/migration.sql +202 -0
  117. package/templates/base-project/packages/db/prisma/migrations/migration_lock.toml +3 -0
  118. package/templates/base-project/packages/db/prisma/schema.prisma +161 -0
  119. package/templates/base-project/packages/db/prisma/seed.js +152 -0
  120. package/templates/base-project/packages/db/prisma.config.js +27 -0
  121. package/templates/base-project/packages/db/src/client.js +75 -0
  122. package/templates/base-project/packages/db/src/connection.js +44 -0
  123. package/templates/base-project/packages/db/src/context.js +275 -0
  124. package/templates/base-project/packages/db/src/index.js +5 -0
  125. package/templates/base-project/packages/db/src/ping.js +66 -0
  126. package/templates/base-project/packages/db/src/queries.js +292 -0
  127. package/templates/base-project/packages/db/src/schemas.js +30 -0
  128. package/templates/base-project/packages/db/src/umami.js +299 -0
  129. package/templates/base-project/pnpm-workspace.yaml +20 -0
  130. package/templates/base-project/scripts/check-loading.mjs +49 -0
  131. package/templates/base-project/scripts/check-standards.mjs +186 -0
  132. package/templates/base-project/scripts/clean-workspace.mjs +479 -0
  133. package/templates/base-project/scripts/dev-preflight.mjs +48 -0
  134. package/templates/base-project/scripts/doctor.js +408 -0
  135. package/templates/base-project/scripts/prepare.js +490 -0
  136. package/templates/base-project/scripts/run-tests.mjs +71 -0
  137. package/templates/base-project/skills/add-dashboard/SKILL.md +28 -0
  138. package/templates/base-project/skills/add-endpoint/SKILL.md +29 -0
  139. package/templates/base-project/skills/add-model/SKILL.md +28 -0
  140. package/templates/base-project/skills/ecommerce/SKILL.md +224 -0
  141. package/templates/base-project/skills/ecommerce-cart/SKILL.md +193 -0
  142. package/templates/base-project/skills/ecommerce-catalog/SKILL.md +179 -0
  143. package/templates/base-project/skills/ecommerce-checkout/SKILL.md +195 -0
  144. package/templates/base-project/skills/i18n/SKILL.md +211 -0
  145. package/templates/base-project/skills/payment/SKILL.md +280 -0
  146. package/templates/base-project/skills/quark-skills/SKILL.md +37 -0
  147. package/templates/base-project/turbo.json +143 -0
  148. package/templates/config/package.json +20 -0
  149. package/templates/config/src/app-url.js +134 -0
  150. package/templates/config/src/environment.js +284 -0
  151. package/templates/config/src/index.js +16 -0
  152. package/templates/config/src/load-config.js +157 -0
  153. package/templates/config/src/rate-limit.js +136 -0
  154. package/templates/config/src/redis.js +43 -0
  155. package/templates/config/src/validate-env.js +532 -0
  156. package/templates/jobs/README.md +52 -0
  157. package/templates/jobs/package.json +9 -0
  158. package/templates/jobs/src/definitions.js +13 -0
  159. package/templates/jobs/src/index.js +1 -0
  160. package/templates/mobile/.env.example +16 -0
  161. package/templates/mobile/CHECKLIST.md +40 -0
  162. package/templates/mobile/README.md +130 -0
  163. package/templates/mobile/app/(app)/_layout.tsx +83 -0
  164. package/templates/mobile/app/(app)/index.tsx +147 -0
  165. package/templates/mobile/app/(app)/profile.tsx +204 -0
  166. package/templates/mobile/app/(app)/settings.tsx +80 -0
  167. package/templates/mobile/app/(auth)/_layout.tsx +10 -0
  168. package/templates/mobile/app/(auth)/sign-in.tsx +140 -0
  169. package/templates/mobile/app/(auth)/sign-up.tsx +178 -0
  170. package/templates/mobile/app/+not-found.tsx +32 -0
  171. package/templates/mobile/app/_layout.tsx +18 -0
  172. package/templates/mobile/app.json +47 -0
  173. package/templates/mobile/assets/adaptive-icon.png +0 -0
  174. package/templates/mobile/assets/icon.png +0 -0
  175. package/templates/mobile/assets/notification-icon.png +0 -0
  176. package/templates/mobile/assets/splash.png +0 -0
  177. package/templates/mobile/biome.json +27 -0
  178. package/templates/mobile/components/AuthGate.tsx +19 -0
  179. package/templates/mobile/components/ThemeProvider.tsx +23 -0
  180. package/templates/mobile/eas.json +29 -0
  181. package/templates/mobile/hooks/use-auth.ts +127 -0
  182. package/templates/mobile/lib/api-client.ts +138 -0
  183. package/templates/mobile/lib/api.ts +83 -0
  184. package/templates/mobile/lib/auth-providers-google.ts +74 -0
  185. package/templates/mobile/lib/auth-providers.ts +74 -0
  186. package/templates/mobile/lib/auth.ts +100 -0
  187. package/templates/mobile/lib/config.ts +14 -0
  188. package/templates/mobile/lib/errors.ts +46 -0
  189. package/templates/mobile/lib/notifications.ts +77 -0
  190. package/templates/mobile/lib/storage.ts +25 -0
  191. package/templates/mobile/lib/theme-tokens.ts +16 -0
  192. package/templates/mobile/package.json +37 -0
  193. package/templates/mobile/tsconfig.json +10 -0
  194. package/templates/pwa/app/_components/PWARegister.js +15 -0
  195. package/templates/pwa/app/manifest.js +32 -0
  196. package/templates/pwa/public/sw.js +72 -0
  197. package/templates/ui/README.md +44 -0
  198. package/templates/ui/package.json +21 -0
  199. package/templates/ui/src/badge.js +23 -0
  200. package/templates/ui/src/button.js +111 -0
  201. package/templates/ui/src/card.js +128 -0
  202. package/templates/ui/src/checkbox.js +20 -0
  203. package/templates/ui/src/container.js +13 -0
  204. package/templates/ui/src/dialog.js +137 -0
  205. package/templates/ui/src/error-banner.js +15 -0
  206. package/templates/ui/src/footer.js +239 -0
  207. package/templates/ui/src/form-field.integration-test.js +142 -0
  208. package/templates/ui/src/form-field.js +50 -0
  209. package/templates/ui/src/index.js +24 -0
  210. package/templates/ui/src/input.js +11 -0
  211. package/templates/ui/src/label.js +14 -0
  212. package/templates/ui/src/lightbox.integration-test.js +167 -0
  213. package/templates/ui/src/lightbox.js +243 -0
  214. package/templates/ui/src/logo.js +52 -0
  215. package/templates/ui/src/navbar.js +602 -0
  216. package/templates/ui/src/password-input.js +31 -0
  217. package/templates/ui/src/rich-text.js +514 -0
  218. package/templates/ui/src/select.js +317 -0
  219. package/templates/ui/src/skeleton.js +10 -0
  220. package/templates/ui/src/spinner.js +31 -0
  221. package/templates/ui/src/table.js +169 -0
  222. package/templates/ui/src/textarea.js +11 -0
  223. package/templates/ui/src/theme-constants.js +24 -0
  224. package/templates/ui/src/theme.js +196 -0
  225. package/templates/ui/src/toast.js +206 -0
  226. package/templates/worker/Dockerfile +64 -0
  227. package/templates/worker/README.md +56 -0
  228. package/templates/worker/package.json +36 -0
  229. package/templates/worker/src/config/pricing.js +46 -0
  230. package/templates/worker/src/handlers/email-job-validation.js +36 -0
  231. package/templates/worker/src/handlers/email.js +98 -0
  232. package/templates/worker/src/handlers/files.js +59 -0
  233. package/templates/worker/src/handlers/index.js +20 -0
  234. package/templates/worker/src/handlers/push-notification.js +234 -0
  235. package/templates/worker/src/index.js +505 -0
  236. package/templates/worker/src/lib/railway.js +438 -0
  237. package/templates/worker/src/lib/tokens.js +81 -0
@@ -0,0 +1,196 @@
1
+ "use client";
2
+ import React, {
3
+ createContext,
4
+ useCallback,
5
+ useContext,
6
+ useEffect,
7
+ useLayoutEffect,
8
+ useState,
9
+ } from "react";
10
+ import {
11
+ THEME_ATTR,
12
+ THEME_CHANGE_EVENT,
13
+ THEME_STORAGE_KEY,
14
+ } from "./theme-constants.js";
15
+
16
+ const ThemeCtx = createContext({ theme: "dark", setTheme: () => {} });
17
+
18
+ /**
19
+ * Wraps a subtree with a shared theme value.
20
+ *
21
+ * Behaviour:
22
+ * - If `defaultTheme` is provided it is used as the fallback when no stored
23
+ * preference exists (good for pages with a deliberate starting theme).
24
+ * - If `defaultTheme` is omitted the fallback is the OS `prefers-color-scheme`
25
+ * media query, evaluated after hydration in a layout effect.
26
+ * - The user's explicit toggle choice is persisted to
27
+ * `localStorage` under the key `quark-theme` and restored on subsequent
28
+ * visits so their preference is remembered across sessions.
29
+ *
30
+ * @param {{ defaultTheme?: 'light' | 'dark', children: React.ReactNode }} props
31
+ */
32
+ export function ThemeProvider({ defaultTheme, children }) {
33
+ // Always initialize from the server-safe default. The lazy initialiser cannot
34
+ // run during React hydration (React reuses the server state), so we sync to
35
+ // the real user preference in a layout effect instead.
36
+ const [theme, setTheme] = useState(defaultTheme ?? "dark");
37
+
38
+ // useLayoutEffect fires synchronously after DOM mutations but BEFORE the
39
+ // browser paints, so the toggle snaps to the correct position with no
40
+ // visible flash. On the server it is a no-op (same as useEffect).
41
+ useLayoutEffect(() => {
42
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
43
+ const resolved =
44
+ stored === "light" || stored === "dark"
45
+ ? stored
46
+ : (defaultTheme ??
47
+ (window.matchMedia("(prefers-color-scheme: dark)").matches
48
+ ? "dark"
49
+ : "light"));
50
+ document.documentElement.setAttribute(THEME_ATTR, resolved);
51
+ setTheme(resolved);
52
+ }, [defaultTheme]);
53
+
54
+ const persistSetTheme = useCallback((t) => {
55
+ localStorage.setItem(THEME_STORAGE_KEY, t);
56
+ document.documentElement.setAttribute(THEME_ATTR, t);
57
+ setTheme(t);
58
+ }, []);
59
+
60
+ // Keep state and DOM in sync when the OS colour scheme changes (only
61
+ // relevant when no defaultTheme was provided and the user has no stored
62
+ // preference). Must call persistSetTheme so data-theme and CSS vars update.
63
+ useEffect(() => {
64
+ const stored = localStorage.getItem(THEME_STORAGE_KEY);
65
+ if (stored || defaultTheme != null) return;
66
+ const mq = window.matchMedia("(prefers-color-scheme: dark)");
67
+ const onChange = (e) => persistSetTheme(e.matches ? "dark" : "light");
68
+ mq.addEventListener("change", onChange);
69
+ return () => mq.removeEventListener("change", onChange);
70
+ }, [defaultTheme, persistSetTheme]);
71
+
72
+ // Listen for toggles fired by HomeThemeToggle (or any other out-of-tree
73
+ // component) so React context stays in sync without any import coupling.
74
+ useEffect(() => {
75
+ function onExternalChange(e) {
76
+ const t = e.detail?.theme;
77
+ if (t === "light" || t === "dark") setTheme(t);
78
+ }
79
+ document.addEventListener(THEME_CHANGE_EVENT, onExternalChange);
80
+ return () =>
81
+ document.removeEventListener(THEME_CHANGE_EVENT, onExternalChange);
82
+ }, []);
83
+
84
+ return React.createElement(
85
+ ThemeCtx.Provider,
86
+ { value: { theme, setTheme: persistSetTheme } },
87
+ children,
88
+ );
89
+ }
90
+
91
+ /**
92
+ * Returns the current theme and a setter from the nearest ThemeProvider.
93
+ * Falls back to `{ theme: 'dark' }` when used outside a provider.
94
+ *
95
+ * @returns {{ theme: 'light' | 'dark', setTheme: (t: string) => void }}
96
+ */
97
+ export function useTheme() {
98
+ return useContext(ThemeCtx);
99
+ }
100
+
101
+ /**
102
+ * Labeled pill button that shows the current theme and switches on click.
103
+ * Displays "🌙 Dark Mode" when dark, "☀ Light Mode" when light.
104
+ * Must be rendered inside a ThemeProvider.
105
+ */
106
+ export function ThemeToggle({ className = "" }) {
107
+ const { theme, setTheme } = useTheme();
108
+ const isDark = theme === "dark";
109
+
110
+ const icon = isDark
111
+ ? React.createElement(
112
+ "svg",
113
+ {
114
+ "aria-hidden": "true",
115
+ width: "13",
116
+ height: "13",
117
+ viewBox: "0 0 24 24",
118
+ fill: "currentColor",
119
+ className: "shrink-0",
120
+ },
121
+ React.createElement("path", {
122
+ d: "M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z",
123
+ }),
124
+ )
125
+ : React.createElement(
126
+ "svg",
127
+ {
128
+ "aria-hidden": "true",
129
+ width: "13",
130
+ height: "13",
131
+ viewBox: "0 0 24 24",
132
+ fill: "none",
133
+ stroke: "currentColor",
134
+ strokeWidth: "2",
135
+ strokeLinecap: "round",
136
+ strokeLinejoin: "round",
137
+ className: "shrink-0",
138
+ },
139
+ React.createElement("circle", { cx: "12", cy: "12", r: "5" }),
140
+ React.createElement("line", { x1: "12", y1: "1", x2: "12", y2: "3" }),
141
+ React.createElement("line", { x1: "12", y1: "21", x2: "12", y2: "23" }),
142
+ React.createElement("line", {
143
+ x1: "4.22",
144
+ y1: "4.22",
145
+ x2: "5.64",
146
+ y2: "5.64",
147
+ }),
148
+ React.createElement("line", {
149
+ x1: "18.36",
150
+ y1: "18.36",
151
+ x2: "19.78",
152
+ y2: "19.78",
153
+ }),
154
+ React.createElement("line", { x1: "1", y1: "12", x2: "3", y2: "12" }),
155
+ React.createElement("line", { x1: "21", y1: "12", x2: "23", y2: "12" }),
156
+ React.createElement("line", {
157
+ x1: "4.22",
158
+ y1: "19.78",
159
+ x2: "5.64",
160
+ y2: "18.36",
161
+ }),
162
+ React.createElement("line", {
163
+ x1: "18.36",
164
+ y1: "5.64",
165
+ x2: "19.78",
166
+ y2: "4.22",
167
+ }),
168
+ );
169
+
170
+ return React.createElement(
171
+ "button",
172
+ {
173
+ type: "button",
174
+ onClick: () => setTheme(isDark ? "light" : "dark"),
175
+ "aria-label": `Switch to ${isDark ? "light" : "dark"} theme`,
176
+ "aria-pressed": isDark,
177
+ className:
178
+ `inline-flex items-center cursor-pointer whitespace-nowrap shrink-0 outline-none transition-[background,border-color,color] duration-150 ease-in ${className || ""}`.trim(),
179
+ style: {
180
+ gap: "var(--toggle-gap, 6px)",
181
+ height: "var(--toggle-height, 28px)",
182
+ paddingLeft: "var(--toggle-padding-x, 10px)",
183
+ paddingRight: "var(--toggle-padding-x-right, 12px)",
184
+ borderRadius: "var(--toggle-radius, 14px)",
185
+ border: "1px solid var(--toggle-track-border)",
186
+ background: "var(--toggle-track-bg)",
187
+ color: "var(--toggle-text, var(--color-text-muted))",
188
+ fontSize: "var(--toggle-font-size, 12px)",
189
+ fontWeight: "500",
190
+ letterSpacing: "0.01em",
191
+ },
192
+ },
193
+ icon,
194
+ isDark ? "Dark Mode" : "Light Mode",
195
+ );
196
+ }
@@ -0,0 +1,206 @@
1
+ "use client";
2
+ import React, { useCallback, useEffect, useRef, useState } from "react";
3
+
4
+ const RADIUS = 9;
5
+ const CIRC = 2 * Math.PI * RADIUS;
6
+
7
+ // Visual properties driven entirely by CSS variables — see globals.css
8
+ // [data-toast-variant] selectors override --toast-bg, --toast-border, --toast-text, --toast-shadow
9
+
10
+ export function Toast({
11
+ message,
12
+ variant = "default",
13
+ onClose,
14
+ visible = true,
15
+ duration = 4000,
16
+ _toastId,
17
+ }) {
18
+ const [progress, setProgress] = useState(100);
19
+ const [hovered, setHovered] = useState(false);
20
+ const [isAnimatingOut, setIsAnimatingOut] = useState(false);
21
+ const [shouldRender, setShouldRender] = useState(visible);
22
+
23
+ const variantCls =
24
+ "bg-[--toast-bg] border-[--toast-border] text-[--toast-text] shadow-[var(--toast-shadow)]";
25
+
26
+ const remainingRef = useRef(duration);
27
+ const startRef = useRef(null);
28
+ const intervalRef = useRef(null);
29
+ const isClosingRef = useRef(false);
30
+
31
+ const stop = useCallback(() => clearInterval(intervalRef.current), []);
32
+
33
+ const start = useCallback(() => {
34
+ stop();
35
+ startRef.current = Date.now();
36
+ intervalRef.current = setInterval(() => {
37
+ const elapsed = Date.now() - startRef.current;
38
+ const pct = Math.max(0, 100 - (elapsed / remainingRef.current) * 100);
39
+ setProgress(pct);
40
+ if (pct <= 0) {
41
+ stop();
42
+ onClose?.();
43
+ }
44
+ }, 16);
45
+ }, [stop, onClose]);
46
+
47
+ useEffect(() => {
48
+ if (visible) {
49
+ setShouldRender(true);
50
+ setIsAnimatingOut(false);
51
+ isClosingRef.current = false;
52
+ remainingRef.current = duration;
53
+ setProgress(100);
54
+ setHovered(false);
55
+ start();
56
+ } else {
57
+ if (isClosingRef.current) return;
58
+ isClosingRef.current = true;
59
+ setIsAnimatingOut(true);
60
+ stop();
61
+ // Wait for animation to finish before removing from DOM
62
+ const t = setTimeout(() => {
63
+ setShouldRender(false);
64
+ setIsAnimatingOut(false);
65
+ isClosingRef.current = false;
66
+ }, 200);
67
+ return () => clearTimeout(t);
68
+ }
69
+ return stop;
70
+ }, [visible, duration, start, stop]);
71
+
72
+ useEffect(() => {
73
+ if (!visible || isAnimatingOut) return;
74
+ if (hovered) {
75
+ stop();
76
+ } else {
77
+ remainingRef.current = duration;
78
+ start();
79
+ }
80
+ }, [hovered, duration, start, stop, visible, isAnimatingOut]);
81
+
82
+ if (!shouldRender) return null;
83
+
84
+ const dashOffset = CIRC * (1 - progress / 100);
85
+
86
+ const animStyle = isAnimatingOut
87
+ ? { animation: "quark-toast-out 0.2s ease-in forwards" }
88
+ : {
89
+ animation:
90
+ "quark-toast-in 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards",
91
+ };
92
+
93
+ return React.createElement(
94
+ "div",
95
+ {
96
+ role: "alert",
97
+ "aria-live": "assertive",
98
+ "data-toast-variant": variant,
99
+ onMouseEnter: () => setHovered(true),
100
+ onMouseLeave: () => setHovered(false),
101
+ className: `fixed bottom-4 right-4 z-50 flex items-center gap-3 rounded-[--radius-default] px-4 py-3 text-sm transition-colors duration-200 ${variantCls}`,
102
+ style: animStyle,
103
+ },
104
+ React.createElement("span", null, message),
105
+ React.createElement(
106
+ "div",
107
+ {
108
+ className:
109
+ "relative ml-2 flex h-6 w-6 shrink-0 items-center justify-center",
110
+ },
111
+ hovered
112
+ ? React.createElement(
113
+ "button",
114
+ {
115
+ type: "button",
116
+ "aria-label": "Dismiss notification",
117
+ onClick: onClose,
118
+ className:
119
+ "flex h-6 w-6 cursor-pointer items-center justify-center rounded-[--radius-default] text-base leading-none opacity-80 transition-all hover:bg-black/10 dark:hover:bg-white/10 hover:opacity-100 active:bg-black/20 dark:active:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60",
120
+ },
121
+ React.createElement(
122
+ "svg",
123
+ {
124
+ width: 10,
125
+ height: 10,
126
+ viewBox: "0 0 10 10",
127
+ "aria-hidden": "true",
128
+ fill: "none",
129
+ className: "shrink-0",
130
+ },
131
+ React.createElement("path", {
132
+ d: "M1 1L9 9",
133
+ stroke: "currentColor",
134
+ strokeWidth: 1.5,
135
+ strokeLinecap: "round",
136
+ }),
137
+ React.createElement("path", {
138
+ d: "M9 1L1 9",
139
+ stroke: "currentColor",
140
+ strokeWidth: 1.5,
141
+ strokeLinecap: "round",
142
+ }),
143
+ ),
144
+ )
145
+ : React.createElement(
146
+ "svg",
147
+ {
148
+ width: 24,
149
+ height: 24,
150
+ viewBox: "0 0 24 24",
151
+ "aria-hidden": "true",
152
+ },
153
+ React.createElement("circle", {
154
+ cx: 12,
155
+ cy: 12,
156
+ r: RADIUS,
157
+ fill: "none",
158
+ stroke: "currentColor",
159
+ strokeOpacity: 0.2,
160
+ strokeWidth: 2,
161
+ }),
162
+ React.createElement("circle", {
163
+ cx: 12,
164
+ cy: 12,
165
+ r: RADIUS,
166
+ fill: "none",
167
+ stroke: "currentColor",
168
+ strokeWidth: 2,
169
+ strokeLinecap: "round",
170
+ strokeDasharray: CIRC,
171
+ strokeDashoffset: dashOffset,
172
+ style: { transform: "rotate(-90deg)", transformOrigin: "center" },
173
+ }),
174
+ ),
175
+ ),
176
+ );
177
+ }
178
+
179
+ export function useToast() {
180
+ const [state, setState] = useState({
181
+ visible: false,
182
+ message: "",
183
+ variant: "default",
184
+ duration: 4000,
185
+ toastId: 0,
186
+ });
187
+
188
+ const show = useCallback(
189
+ (message, variant = "default", duration = 4000) =>
190
+ setState((s) => ({
191
+ visible: true,
192
+ message,
193
+ variant,
194
+ duration,
195
+ toastId: s.toastId + 1,
196
+ })),
197
+ [],
198
+ );
199
+
200
+ const hide = useCallback(
201
+ () => setState((s) => ({ ...s, visible: false })),
202
+ [],
203
+ );
204
+
205
+ return { show, hide, toastProps: { ...state, onClose: hide } };
206
+ }
@@ -0,0 +1,64 @@
1
+ FROM node:22-alpine@sha256:9385cd9f3001dfc3431e8ead12c43e9e1f87cc1b9b5c6cfd0f73865d405b27c4 AS builder
2
+
3
+ WORKDIR /app
4
+
5
+ RUN corepack enable
6
+
7
+ COPY . .
8
+
9
+ RUN pnpm install --frozen-lockfile --filter ./apps/worker... --filter ./packages/db \
10
+ && pnpm --filter ./packages/db db:generate \
11
+ && pnpm --filter ./apps/worker --prod deploy --legacy /app/deploy
12
+
13
+ # `pnpm deploy --prod` correctly drops `prisma` (a devDependency of
14
+ # packages/db), but it keeps the peer subtrees pnpm auto-installed *for* that
15
+ # devDependency. `prisma` declares `typescript` as an optional peer, so the
16
+ # hoisted store ends up with typescript@7 - the native Go build, which ships
17
+ # @typescript/typescript-<platform> (a ~100 MB Go `tsc` with 10 HIGH CVEs).
18
+ #
19
+ # Nothing at runtime needs it: the worker runs compiled JS and talks to
20
+ # Postgres through @prisma/client. Remove the auto-installed peer subtrees from
21
+ # the deploy output so the runtime image stays free of dev-only binaries.
22
+ RUN rm -rf \
23
+ /app/deploy/node_modules/.pnpm/typescript@* \
24
+ /app/deploy/node_modules/.pnpm/@typescript+typescript-* \
25
+ /app/deploy/node_modules/.pnpm/node_modules/typescript \
26
+ /app/deploy/node_modules/.pnpm/node_modules/@typescript \
27
+ /app/deploy/node_modules/.pnpm/node_modules/.bin/tsc \
28
+ /app/deploy/node_modules/typescript \
29
+ /app/deploy/node_modules/@typescript
30
+
31
+ # `pnpm deploy` rewrites peer-suffixed directory names (e.g.
32
+ # valibot@1.4.2_typescript@7.0.2) and nested `.bin/tsc` shims that link back
33
+ # into the removed store, so drop those too.
34
+ RUN find /app/deploy/node_modules -type d -name '*_typescript@*' -prune \
35
+ -exec rm -rf {} + \
36
+ && find /app/deploy/node_modules -path '*/node_modules/.bin/tsc*' -delete
37
+
38
+ # Fail the build rather than ship silently if a future dependency reintroduces
39
+ # a dev-only native binary through the same path.
40
+ RUN if find /app/deploy -name 'tsc' -type f 2>/dev/null | grep -q .; then \
41
+ echo 'error: dev-only TypeScript binary leaked into the deploy output' >&2; \
42
+ find /app/deploy -name 'tsc' -type f >&2; \
43
+ exit 1; \
44
+ fi
45
+
46
+ FROM node:22-alpine@sha256:9385cd9f3001dfc3431e8ead12c43e9e1f87cc1b9b5c6cfd0f73865d405b27c4 AS runtime
47
+
48
+ RUN apk upgrade --no-cache
49
+
50
+ ENV NODE_ENV=production
51
+
52
+ WORKDIR /app
53
+
54
+ RUN rm -rf \
55
+ /usr/local/lib/node_modules/npm \
56
+ /usr/local/bin/npm \
57
+ /usr/local/bin/npx \
58
+ /opt/yarn-v1.22.22 \
59
+ /usr/local/bin/yarn \
60
+ /usr/local/bin/yarnpkg
61
+
62
+ COPY --from=builder /app/deploy/ ./
63
+
64
+ CMD ["node", "src/index.js"]
@@ -0,0 +1,56 @@
1
+ # Quark Worker Service
2
+
3
+ Background job processor using **BullMQ** and **Redis**.
4
+
5
+ ## Quick Start
6
+
7
+ ```bash
8
+ docker compose up -d # Redis required
9
+ pnpm install
10
+ pnpm dev # Starts worker with file watching
11
+ ```
12
+
13
+ ## Adding a Job Handler
14
+
15
+ 1. Define the job name in `packages/jobs/src/definitions.js`
16
+ 2. Create a handler in `apps/worker/src/handlers/<job-name>.js`:
17
+
18
+ ```javascript
19
+ import { createLogger } from "@techstream/quark-core";
20
+
21
+ const log = createLogger("job:my-task");
22
+
23
+ export async function handleMyTask({ data }) {
24
+ log.info("processing", data);
25
+ // your logic here
26
+ }
27
+ ```
28
+
29
+ 3. Register it in `apps/worker/src/handlers/index.js`:
30
+
31
+ ```javascript
32
+ import { handleMyTask } from "./my-task.js";
33
+
34
+ const jobHandlers = {
35
+ [JOB_NAMES.MY_TASK]: handleMyTask,
36
+ };
37
+ ```
38
+
39
+ ## Dispatching Jobs
40
+
41
+ ```javascript
42
+ import { createQueue, addJob } from "@techstream/quark-core";
43
+ import { JOB_NAMES } from "@yourscope/jobs";
44
+
45
+ const queue = createQueue("default");
46
+ await addJob(queue, JOB_NAMES.MY_TASK, { userId: "123" });
47
+ ```
48
+
49
+ ## Key Files
50
+
51
+ | File | Purpose |
52
+ |---|---|
53
+ | `src/index.js` | Worker entry point, queue registration |
54
+ | `src/handlers/` | Job handler functions |
55
+ | `src/lib/` | Shared utilities (tokens, Railway helpers) |
56
+ | `railway.json` | Railway deployment config *(deprecated - use `.railway/railway.ts`)* |
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "@myquark/worker",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "description": "",
7
+ "main": "index.js",
8
+ "exports": {
9
+ ".": "./src/index.js",
10
+ "./tools": "./src/lib/tools/index.js"
11
+ },
12
+ "scripts": {
13
+ "clean": "rm -rf dist",
14
+ "test": "node ../../scripts/run-tests.mjs src --exclude=integration.test.js",
15
+ "dev": "tsx watch src/index.js",
16
+ "start:deploy": "node src/index.js",
17
+ "preflight": "node src/index.js --preflight",
18
+ "lint": "biome format --write && biome check --write"
19
+ },
20
+ "keywords": [],
21
+ "author": "",
22
+ "license": "MIT",
23
+ "packageManager": "pnpm@10.12.1",
24
+ "dependencies": {
25
+ "@techstream/quark-config": "workspace:*",
26
+ "@techstream/quark-core": "^2.0.0",
27
+ "@techstream/quark-db": "workspace:*",
28
+ "@techstream/quark-jobs": "workspace:*",
29
+ "bullmq": "^6.3.8",
30
+ "jose": "^6.2.12"
31
+ },
32
+ "devDependencies": {
33
+ "@types/node": "^26.6.2",
34
+ "tsx": "^4.23.15"
35
+ }
36
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Default model pricing (USD per 1M tokens).
3
+ *
4
+ * Override or extend at runtime via OPENROUTER_PRICING_OVERRIDE (JSON string).
5
+ * Example:
6
+ * OPENROUTER_PRICING_OVERRIDE='{"openai/gpt-4o":{"input":2.0,"output":8.0}}'
7
+ *
8
+ * Override entries are shallow-merged on top of these defaults.
9
+ */
10
+
11
+ /** @type {Record<string, { input: number, output: number }>} */
12
+ export const DEFAULT_PRICING = {
13
+ "deepseek/deepseek-v4-flash": { input: 0.09, output: 0.18 },
14
+ "anthropic/claude-3.5-sonnet": { input: 3, output: 15 },
15
+ "anthropic/claude-3-opus": { input: 15, output: 75 },
16
+ "anthropic/claude-4-sonnet": { input: 15, output: 75 },
17
+ "openai/gpt-4o": { input: 2.5, output: 10 },
18
+ "openai/gpt-4o-mini": { input: 0.15, output: 0.6 },
19
+ "google/gemini-2.0-flash": { input: 0.1, output: 0.4 },
20
+ };
21
+
22
+ /**
23
+ * Resolve pricing table: defaults merged with optional OPENROUTER_PRICING_OVERRIDE.
24
+ * @returns {Record<string, { input: number, output: number }>}
25
+ */
26
+ export function getPricing() {
27
+ const pricing = { ...DEFAULT_PRICING };
28
+ const override = process.env.OPENROUTER_PRICING_OVERRIDE;
29
+
30
+ if (!override) {
31
+ return pricing;
32
+ }
33
+
34
+ try {
35
+ const parsed = JSON.parse(override);
36
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
37
+ return { ...pricing, ...parsed };
38
+ }
39
+ } catch {
40
+ // Invalid JSON — fall back to defaults
41
+ }
42
+
43
+ return pricing;
44
+ }
45
+
46
+ export default getPricing;
@@ -0,0 +1,36 @@
1
+ import { AppError, ValidationError } from "@techstream/quark-core/errors";
2
+
3
+ export function requireWelcomeEmailUserId(data) {
4
+ const userId = data?.userId;
5
+
6
+ if (!userId) {
7
+ throw new ValidationError("userId is required for SEND_WELCOME_EMAIL job");
8
+ }
9
+
10
+ return userId;
11
+ }
12
+
13
+ export function requireResetPasswordEmailData(data) {
14
+ const userId = data?.userId;
15
+ const resetUrl = data?.resetUrl;
16
+
17
+ if (!userId || !resetUrl) {
18
+ throw new ValidationError(
19
+ "userId and resetUrl are required for SEND_RESET_PASSWORD_EMAIL job",
20
+ );
21
+ }
22
+
23
+ return { userId, resetUrl };
24
+ }
25
+
26
+ export function requireUserEmailRecord(userId, userRecord) {
27
+ if (!userRecord?.email) {
28
+ throw new AppError(
29
+ `User ${userId} not found or has no email`,
30
+ 404,
31
+ "USER_EMAIL_NOT_FOUND",
32
+ );
33
+ }
34
+
35
+ return userRecord;
36
+ }