@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,168 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+
5
+ const CHARS = " .:-=+*#%@";
6
+ const W = 110;
7
+ const H = 55;
8
+
9
+ const CONF = {
10
+ zoom: 1.16,
11
+ speed: 1.7,
12
+ dashLength: 16,
13
+ dashThickness: 2.8,
14
+ dashDistFactor: 0.36,
15
+ ringGapMultiplier: 0.3,
16
+ angleLimit: 0.3,
17
+ yBounce: 1,
18
+ ringRadius: 13,
19
+ ringThickness: 2.6,
20
+ yScaleFactor: 1.05,
21
+ dashAngle: 0.8,
22
+ };
23
+
24
+ // Fixed brand palette - all three colors are part of the Quark logo identity.
25
+ const CLASS_COLORS = { s: "#2d3436", b: "#377dff", c: "#ff4757" };
26
+
27
+ export default function QuarkAnimation() {
28
+ const ref = useRef(null);
29
+
30
+ useEffect(() => {
31
+ const el = ref.current;
32
+ if (!el) return;
33
+
34
+ let t = 0;
35
+ let raf;
36
+
37
+ function render() {
38
+ t += 0.01 * CONF.speed;
39
+
40
+ const rY = Math.sin(t) * CONF.angleLimit;
41
+ const yO = Math.sin(t * 1.5) * CONF.yBounce;
42
+ const cB = new Array(W * H).fill(" ");
43
+ const clB = new Array(W * H).fill("");
44
+ const zB = new Float32Array(W * H).fill(-1000);
45
+
46
+ const rR = CONF.ringRadius * CONF.zoom;
47
+ const rT = CONF.ringThickness * CONF.zoom;
48
+ const dL = CONF.dashLength * CONF.zoom;
49
+ const dT = CONF.dashThickness * CONF.zoom;
50
+ const dA = CONF.dashAngle;
51
+ const gap = (dT / rR) * 0.8 + CONF.ringGapMultiplier;
52
+
53
+ // Ring (torus)
54
+ for (let p = 0; p < Math.PI * 2; p += 0.12) {
55
+ for (let th = 0; th < Math.PI * 2; th += 0.04) {
56
+ let d = Math.abs(th - dA);
57
+ if (d > Math.PI) d = 2 * Math.PI - d;
58
+ if (d < gap) continue;
59
+
60
+ const rXx = Math.cos(th);
61
+ const rYy = Math.sin(th);
62
+ const x = (rR + rT * Math.cos(p)) * rXx;
63
+ const y = (rR + rT * Math.cos(p)) * rYy * CONF.yScaleFactor;
64
+ const z = rT * Math.sin(p);
65
+
66
+ const rx = x * Math.cos(rY) + z * Math.sin(rY);
67
+ const rz = -x * Math.sin(rY) + z * Math.cos(rY);
68
+ const ry = y + yO;
69
+
70
+ const xp = Math.floor(W / 2 + rx * 1.5);
71
+ const yp = Math.floor(H / 2 + ry);
72
+
73
+ if (xp >= 0 && xp < W && yp >= 0 && yp < H) {
74
+ const i = yp * W + xp;
75
+ if (rz > zB[i]) {
76
+ zB[i] = rz;
77
+ const nT = ((th % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
78
+ const nD = ((dA % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2);
79
+ clB[i] = rYy < 0 ? "s" : nT > nD ? "b" : "c";
80
+ cB[i] =
81
+ CHARS[
82
+ Math.floor(
83
+ Math.max(0, Math.min(1, (rz + rT) / (rT * 2) + 0.3)) *
84
+ (CHARS.length - 1),
85
+ )
86
+ ];
87
+ }
88
+ }
89
+ }
90
+ }
91
+
92
+ // Dash (quark)
93
+ for (let l = 0; l < dL; l += 0.6) {
94
+ for (let p = 0; p < Math.PI * 2; p += 0.2) {
95
+ for (let rad = 0; rad < dT; rad += 0.6) {
96
+ const sR = rR * CONF.dashDistFactor;
97
+ const lx = rad * Math.cos(p);
98
+ const ly = rad * Math.sin(p);
99
+ const lz = l;
100
+
101
+ const tx =
102
+ sR * Math.cos(dA) + lz * Math.cos(dA) - lx * Math.sin(dA);
103
+ const ty =
104
+ (sR * Math.sin(dA) + lz * Math.sin(dA) + lx * Math.cos(dA)) *
105
+ CONF.yScaleFactor;
106
+ const tz = ly;
107
+
108
+ const rtx = tx * Math.cos(rY) + tz * Math.sin(rY);
109
+ const rtz = -tx * Math.sin(rY) + tz * Math.cos(rY);
110
+ const rty = ty + yO;
111
+
112
+ const xp = Math.floor(W / 2 + rtx * 1.5);
113
+ const yp = Math.floor(H / 2 + rty);
114
+
115
+ if (xp >= 0 && xp < W && yp >= 0 && yp < H) {
116
+ const i = yp * W + xp;
117
+ if (rtz > zB[i]) {
118
+ zB[i] = rtz;
119
+ clB[i] = "c";
120
+ cB[i] =
121
+ CHARS[
122
+ Math.floor(
123
+ Math.max(0, Math.min(1, (rtz + dT) / (dT * 2) + 0.3)) *
124
+ (CHARS.length - 1),
125
+ )
126
+ ];
127
+ }
128
+ }
129
+ }
130
+ }
131
+ }
132
+
133
+ // Build HTML string - inline colors avoid global CSS pollution
134
+ let o = "";
135
+ for (let i = 0; i < cB.length; i++) {
136
+ if (i > 0 && i % W === 0) o += "\n";
137
+ const cls = clB[i];
138
+ if (cls) {
139
+ o += `<span style="color:${CLASS_COLORS[cls]}">${cB[i]}</span>`;
140
+ } else {
141
+ o += cB[i];
142
+ }
143
+ }
144
+
145
+ el.innerHTML = o;
146
+ raf = requestAnimationFrame(render);
147
+ }
148
+
149
+ render();
150
+ return () => cancelAnimationFrame(raf);
151
+ }, []);
152
+
153
+ return (
154
+ <div
155
+ ref={ref}
156
+ style={{
157
+ lineHeight: "0.82",
158
+ letterSpacing: "0",
159
+ whiteSpace: "pre",
160
+ textAlign: "center",
161
+ fontSize: "11px",
162
+ fontWeight: "bold",
163
+ fontFamily: "monospace",
164
+ color: "#e0e0e0",
165
+ }}
166
+ />
167
+ );
168
+ }
@@ -0,0 +1,212 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useState } from "react";
4
+ import AsciiPanel from "./AsciiPanel.js";
5
+
6
+ const INNER_W = 34;
7
+ const TYPING_MS = 2000;
8
+ const HOLD_MS = 5000;
9
+ const UNTYPING_MS = 1500;
10
+ const NUM_PANELS = 3;
11
+
12
+ const PHASE_INITIAL_TYPING = 0;
13
+ const PHASE_HOLD = 1;
14
+ const PHASE_UNTYPE_0 = 2;
15
+ const PHASE_TYPE_0 = 3;
16
+ const PHASE_HOLD_AFTER_0 = 4;
17
+ const PHASE_UNTYPE_1 = 5;
18
+ const PHASE_TYPE_1 = 6;
19
+ const PHASE_HOLD_AFTER_1 = 7;
20
+ const PHASE_UNTYPE_2 = 8;
21
+ const PHASE_TYPE_2 = 9;
22
+ const PHASE_HOLD_AFTER_2 = 10;
23
+
24
+ function wrapText(text, maxLen) {
25
+ const words = text.split(" ");
26
+ const lines = [];
27
+ let current = "";
28
+ for (const word of words) {
29
+ if (current.length + (current ? 1 : 0) + word.length > maxLen) {
30
+ if (current) lines.push(current);
31
+ current = word.length > maxLen ? word.slice(0, maxLen) : word;
32
+ } else {
33
+ current = current ? `${current} ${word}` : word;
34
+ }
35
+ }
36
+ if (current) lines.push(current);
37
+ return lines.length ? lines : [""];
38
+ }
39
+
40
+ function getContentLines(panel) {
41
+ const t = wrapText(panel.title, INNER_W);
42
+ const d = wrapText(panel.description, INNER_W);
43
+ return [...t, "", ...d];
44
+ }
45
+
46
+ function getMaxDisplayLines(panels) {
47
+ return Math.max(...panels.map((panel) => getContentLines(panel).length));
48
+ }
49
+
50
+ function getTotalChars(panel) {
51
+ const t = wrapText(panel.title, INNER_W);
52
+ const d = wrapText(panel.description, INNER_W);
53
+ return t.join("").length + d.join("").length;
54
+ }
55
+
56
+ const PHASE_DURATION = {
57
+ [PHASE_INITIAL_TYPING]: TYPING_MS,
58
+ [PHASE_HOLD]: HOLD_MS,
59
+ [PHASE_UNTYPE_0]: UNTYPING_MS,
60
+ [PHASE_TYPE_0]: TYPING_MS,
61
+ [PHASE_HOLD_AFTER_0]: HOLD_MS,
62
+ [PHASE_UNTYPE_1]: UNTYPING_MS,
63
+ [PHASE_TYPE_1]: TYPING_MS,
64
+ [PHASE_HOLD_AFTER_1]: HOLD_MS,
65
+ [PHASE_UNTYPE_2]: UNTYPING_MS,
66
+ [PHASE_TYPE_2]: TYPING_MS,
67
+ [PHASE_HOLD_AFTER_2]: HOLD_MS,
68
+ };
69
+
70
+ const NEXT_PHASE = {
71
+ [PHASE_INITIAL_TYPING]: PHASE_HOLD,
72
+ [PHASE_HOLD]: PHASE_UNTYPE_0,
73
+ [PHASE_UNTYPE_0]: PHASE_TYPE_0,
74
+ [PHASE_TYPE_0]: PHASE_HOLD_AFTER_0,
75
+ [PHASE_HOLD_AFTER_0]: PHASE_UNTYPE_1,
76
+ [PHASE_UNTYPE_1]: PHASE_TYPE_1,
77
+ [PHASE_TYPE_1]: PHASE_HOLD_AFTER_1,
78
+ [PHASE_HOLD_AFTER_1]: PHASE_UNTYPE_2,
79
+ [PHASE_UNTYPE_2]: PHASE_TYPE_2,
80
+ [PHASE_TYPE_2]: PHASE_HOLD_AFTER_2,
81
+ [PHASE_HOLD_AFTER_2]: PHASE_UNTYPE_0,
82
+ };
83
+
84
+ export default function SyncedPanels({ panels }) {
85
+ const [renderTick, setRenderTick] = useState(0);
86
+ const [copiedIdx, setCopiedIdx] = useState(-1);
87
+ const rafRef = useRef(null);
88
+
89
+ const stateRef = useRef({
90
+ phase: PHASE_INITIAL_TYPING,
91
+ phaseStart: 0,
92
+ started: false,
93
+ promptIdxs: [0, 1, 2],
94
+ charCounts: [0, 0, 0],
95
+ queue: Array.from({ length: panels.length }, (_, i) => i).slice(NUM_PANELS),
96
+ });
97
+
98
+ const maxLines = getMaxDisplayLines(panels.slice(0, NUM_PANELS));
99
+ const maxCharsRef = useRef(panels.map(getTotalChars));
100
+
101
+ useEffect(() => {
102
+ maxCharsRef.current = panels.map(getTotalChars);
103
+ }, [panels]);
104
+
105
+ useEffect(() => {
106
+ let active = true;
107
+
108
+ function tick(ts) {
109
+ if (!active) return;
110
+
111
+ const st = stateRef.current;
112
+ if (!st.started) {
113
+ st.started = true;
114
+ st.phaseStart = ts;
115
+ }
116
+
117
+ const elapsed = ts - st.phaseStart;
118
+ const duration = PHASE_DURATION[st.phase];
119
+ const t = Math.min(elapsed / duration, 1);
120
+ const p = panels;
121
+
122
+ const isTyping =
123
+ st.phase === PHASE_INITIAL_TYPING ||
124
+ st.phase === PHASE_TYPE_0 ||
125
+ st.phase === PHASE_TYPE_1 ||
126
+ st.phase === PHASE_TYPE_2;
127
+ const isUntyping =
128
+ st.phase === PHASE_UNTYPE_0 ||
129
+ st.phase === PHASE_UNTYPE_1 ||
130
+ st.phase === PHASE_UNTYPE_2;
131
+
132
+ const changingPanel =
133
+ st.phase === PHASE_UNTYPE_0 || st.phase === PHASE_TYPE_0
134
+ ? 0
135
+ : st.phase === PHASE_UNTYPE_1 || st.phase === PHASE_TYPE_1
136
+ ? 1
137
+ : st.phase === PHASE_UNTYPE_2 || st.phase === PHASE_TYPE_2
138
+ ? 2
139
+ : -1;
140
+
141
+ for (let i = 0; i < NUM_PANELS; i++) {
142
+ if (isTyping && changingPanel === i) {
143
+ const maxChars =
144
+ maxCharsRef.current[st.promptIdxs[i] % p.length] || 1;
145
+ st.charCounts[i] = Math.floor(t * maxChars);
146
+ } else if (isUntyping && changingPanel === i) {
147
+ const maxChars =
148
+ maxCharsRef.current[st.promptIdxs[i] % p.length] || 1;
149
+ st.charCounts[i] = Math.floor((1 - t) * maxChars);
150
+ } else if (isTyping && changingPanel === -1) {
151
+ const maxChars =
152
+ maxCharsRef.current[st.promptIdxs[i] % p.length] || 1;
153
+ st.charCounts[i] = Math.floor(t * maxChars);
154
+ }
155
+ }
156
+
157
+ if (t >= 1) {
158
+ if (isUntyping && changingPanel >= 0) {
159
+ if (st.queue.length === 0) {
160
+ st.queue = Array.from({ length: p.length }, (_, i) => i).filter(
161
+ (idx) => !st.promptIdxs.includes(idx),
162
+ );
163
+ }
164
+ st.promptIdxs[changingPanel] = st.queue.shift();
165
+ st.charCounts[changingPanel] = 0;
166
+ }
167
+ st.phase = NEXT_PHASE[st.phase];
168
+ st.phaseStart = ts;
169
+ }
170
+
171
+ setRenderTick((n) => n + 1);
172
+ rafRef.current = requestAnimationFrame(tick);
173
+ }
174
+
175
+ rafRef.current = requestAnimationFrame(tick);
176
+ return () => {
177
+ active = false;
178
+ if (rafRef.current) cancelAnimationFrame(rafRef.current);
179
+ };
180
+ }, [panels]);
181
+
182
+ void renderTick;
183
+
184
+ function handleCopy(text, idx) {
185
+ navigator.clipboard.writeText(text);
186
+ setCopiedIdx(idx);
187
+ setTimeout(() => setCopiedIdx(-1), 1500);
188
+ }
189
+
190
+ const st = stateRef.current;
191
+ const p = panels;
192
+
193
+ return (
194
+ <div className="quark-home-panels">
195
+ {[0, 1, 2].map((i) => {
196
+ const panel = p[st.promptIdxs[i] % p.length];
197
+ return (
198
+ <AsciiPanel
199
+ key={`panel-${i}`}
200
+ title={panel.title}
201
+ description={panel.description}
202
+ prompt={panel.prompt}
203
+ charCount={st.charCounts[i]}
204
+ copied={copiedIdx === i}
205
+ onCopy={() => handleCopy(panel.prompt, i)}
206
+ maxLines={maxLines}
207
+ />
208
+ );
209
+ })}
210
+ </div>
211
+ );
212
+ }
@@ -0,0 +1,198 @@
1
+ "use client";
2
+
3
+ import { usePathname } from "next/navigation";
4
+ import { useEffect, useEffectEvent, useRef } from "react";
5
+ import { isUmamiStaff } from "../../lib/analytics/umami.js";
6
+ import { getUmamiConfig } from "../../lib/analytics/umami-config.js";
7
+ import {
8
+ buildReplayEndpoint,
9
+ createReplayBuffer,
10
+ getReplayMaskConfig,
11
+ postReplayBatch,
12
+ shouldSampleReplay,
13
+ UMAMI_REPLAY_DEFAULTS,
14
+ waitForUmamiSessionCache,
15
+ } from "../../lib/analytics/umami-replay.js";
16
+
17
+ const RRWEB_OPTIONS = {
18
+ inlineStylesheet: true,
19
+ slimDOMOptions: {
20
+ script: true,
21
+ comment: true,
22
+ headMetaDescKeywords: true,
23
+ headMetaSocial: true,
24
+ headMetaRobots: true,
25
+ headMetaHttpEquiv: true,
26
+ headMetaAuthorship: true,
27
+ headMetaVerification: true,
28
+ },
29
+ recordCanvas: false,
30
+ recordCrossOriginIframes: false,
31
+ checkoutEveryNms: 30000,
32
+ };
33
+
34
+ export default function UmamiReplayRecorder() {
35
+ const pathname = usePathname();
36
+ const previousPathnameRef = useRef(pathname);
37
+ const bufferRef = useRef(createReplayBuffer());
38
+ const cacheRef = useRef("");
39
+ const endpointRef = useRef("");
40
+ const websiteIdRef = useRef("");
41
+ const startedAtRef = useRef(0);
42
+ const isActiveRef = useRef(false);
43
+ const recordRef = useRef(null);
44
+ const stopRecordingRef = useRef(null);
45
+
46
+ const flushPendingEvents = useEffectEvent(
47
+ async ({ useKeepalive = false } = {}) => {
48
+ const events = bufferRef.current.drain();
49
+ if (!events.length) return false;
50
+
51
+ const sent = await postReplayBatch({
52
+ endpoint: endpointRef.current,
53
+ cache: cacheRef.current,
54
+ websiteId: websiteIdRef.current,
55
+ events,
56
+ useKeepalive,
57
+ });
58
+
59
+ if (!sent) {
60
+ bufferRef.current.restore(events);
61
+ }
62
+
63
+ return sent;
64
+ },
65
+ );
66
+
67
+ useEffect(() => {
68
+ const umamiConfig = getUmamiConfig();
69
+ if (!umamiConfig.replayEnabled || !shouldSampleReplay()) {
70
+ return undefined;
71
+ }
72
+
73
+ // Skip replay on admin pages and for any staff user (umami_user_role
74
+ // cookie set by admin layout via inline client script).
75
+ if (window.location.pathname.startsWith("/admin")) return undefined;
76
+ if (isUmamiStaff()) return undefined;
77
+
78
+ let cancelled = false;
79
+ const abortController = new AbortController();
80
+ let flushIntervalId = null;
81
+ let stopRecording = () => {};
82
+ let removeVisibilityListener = () => {};
83
+ let removePageHideListener = () => {};
84
+
85
+ async function setupReplay() {
86
+ const cache = await waitForUmamiSessionCache({
87
+ signal: abortController.signal,
88
+ });
89
+ if (cancelled || !cache) return;
90
+
91
+ const { record } = await import("rrweb");
92
+ if (cancelled) return;
93
+
94
+ cacheRef.current = cache;
95
+ endpointRef.current = buildReplayEndpoint(umamiConfig.url);
96
+ websiteIdRef.current = umamiConfig.websiteId;
97
+ startedAtRef.current = Date.now();
98
+
99
+ const stopRrweb = record({
100
+ emit(event) {
101
+ if (
102
+ Date.now() - startedAtRef.current >
103
+ UMAMI_REPLAY_DEFAULTS.maxDurationMs
104
+ ) {
105
+ stopRecording();
106
+ return;
107
+ }
108
+
109
+ const eventCount = bufferRef.current.push(event);
110
+ if (eventCount >= UMAMI_REPLAY_DEFAULTS.flushEventCount) {
111
+ void flushPendingEvents();
112
+ }
113
+ },
114
+ ...getReplayMaskConfig(),
115
+ ...RRWEB_OPTIONS,
116
+ ...(UMAMI_REPLAY_DEFAULTS.blockSelector
117
+ ? { blockSelector: UMAMI_REPLAY_DEFAULTS.blockSelector }
118
+ : {}),
119
+ });
120
+
121
+ recordRef.current = record;
122
+ isActiveRef.current = true;
123
+ flushIntervalId = window.setInterval(() => {
124
+ void flushPendingEvents();
125
+ }, UMAMI_REPLAY_DEFAULTS.flushIntervalMs);
126
+
127
+ const handleVisibilityChange = () => {
128
+ if (document.visibilityState === "hidden") {
129
+ void flushPendingEvents({ useKeepalive: true });
130
+ }
131
+ };
132
+ const handlePageHide = () => {
133
+ void flushPendingEvents({ useKeepalive: true });
134
+ };
135
+
136
+ document.addEventListener("visibilitychange", handleVisibilityChange);
137
+ window.addEventListener("pagehide", handlePageHide);
138
+
139
+ removeVisibilityListener = () => {
140
+ document.removeEventListener(
141
+ "visibilitychange",
142
+ handleVisibilityChange,
143
+ );
144
+ };
145
+ removePageHideListener = () => {
146
+ window.removeEventListener("pagehide", handlePageHide);
147
+ };
148
+
149
+ const stopFn = ({ useKeepalive = false } = {}) => {
150
+ if (!isActiveRef.current) return;
151
+
152
+ isActiveRef.current = false;
153
+ if (flushIntervalId) {
154
+ clearInterval(flushIntervalId);
155
+ flushIntervalId = null;
156
+ }
157
+
158
+ removeVisibilityListener();
159
+ removePageHideListener();
160
+ stopRrweb();
161
+ recordRef.current = null;
162
+ void flushPendingEvents({ useKeepalive });
163
+ };
164
+ stopRecordingRef.current = stopFn;
165
+ stopRecording = stopFn;
166
+ }
167
+
168
+ void setupReplay();
169
+
170
+ return () => {
171
+ cancelled = true;
172
+ abortController.abort();
173
+ stopRecording({ useKeepalive: true });
174
+ };
175
+ }, []);
176
+
177
+ useEffect(() => {
178
+ if (previousPathnameRef.current === pathname) return;
179
+
180
+ previousPathnameRef.current = pathname;
181
+
182
+ // Stop recording if navigating into an admin page or if a staff
183
+ // cookie appears mid-session (e.g. after visiting /admin).
184
+ if (pathname.startsWith("/admin") || isUmamiStaff()) {
185
+ if (isActiveRef.current) {
186
+ stopRecordingRef.current?.({ useKeepalive: true });
187
+ }
188
+ return;
189
+ }
190
+
191
+ if (!isActiveRef.current || !recordRef.current) return;
192
+
193
+ void flushPendingEvents();
194
+ recordRef.current.takeFullSnapshot?.(true);
195
+ }, [pathname]);
196
+
197
+ return null;
198
+ }
@@ -0,0 +1,39 @@
1
+ "use client";
2
+
3
+ import { useEffect } from "react";
4
+ import { isUmamiStaff } from "../../lib/analytics/umami.js";
5
+ import { getUmamiConfig } from "../../lib/analytics/umami-config.js";
6
+ import { observeWebVitals } from "../../lib/analytics/umami-web-vitals.js";
7
+
8
+ /**
9
+ * Client component that mounts the anonymous Core Web Vitals observer.
10
+ *
11
+ * Must be rendered inside the root layout's `<body>` so that
12
+ * `window` and `performance` APIs are available.
13
+ *
14
+ * Design:
15
+ * - Noop when Umami analytics is not configured or when
16
+ * the browser does not support PerformanceObserver.
17
+ * - Observes LCP, FID, CLS, INP, and TTFB and dispatches
18
+ * them as anonymous Umami custom events (event_type = 2).
19
+ * - Zero PII — only metric name, value, rating, and sanitised
20
+ * URL path are sent.
21
+ * - Cleans up all observers on unmount.
22
+ */
23
+ export default function UmamiWebVitals() {
24
+ useEffect(() => {
25
+ const config = getUmamiConfig();
26
+ if (!config.enabled) return;
27
+
28
+ // Skip web vitals dispatch for any staff user (identified by the
29
+ // umami_user_role cookie set by the admin layout).
30
+ if (isUmamiStaff()) return;
31
+
32
+ const stopObserving = observeWebVitals();
33
+ return () => {
34
+ stopObserving();
35
+ };
36
+ }, []);
37
+
38
+ return null;
39
+ }
@@ -0,0 +1,7 @@
1
+ import { handlers } from "@/lib/auth";
2
+ import { normalizeAuthRequest } from "@/lib/normalize-auth-request";
3
+
4
+ export const GET = (req, context) =>
5
+ handlers.GET(normalizeAuthRequest(req), context);
6
+ export const POST = (req, context) =>
7
+ handlers.POST(normalizeAuthRequest(req), context);
@@ -0,0 +1,82 @@
1
+ import { validateBody } from "@techstream/quark-core";
2
+ import { user } from "@techstream/quark-db";
3
+ import { importJWK, jwtVerify } from "jose";
4
+ import { NextResponse } from "next/server";
5
+ import { z } from "zod";
6
+ import { issueTokenPair } from "../../../../lib/jwt";
7
+ import { handleError } from "../../error-handler";
8
+
9
+ const appleAuthSchema = z.object({
10
+ identityToken: z.string().min(1),
11
+ nonce: z.string().optional(),
12
+ });
13
+
14
+ const APPLE_KEYS_URL = "https://appleid.apple.com/auth/keys";
15
+
16
+ /**
17
+ * POST /api/auth/apple
18
+ * Verify Apple identity token and issue a custom JWT.
19
+ */
20
+ export async function POST(request) {
21
+ try {
22
+ const { identityToken } = await validateBody(request, appleAuthSchema);
23
+
24
+ // Decode the JWT header to get the kid
25
+ const [headerB64] = identityToken.split(".");
26
+ const header = JSON.parse(Buffer.from(headerB64, "base64url").toString());
27
+
28
+ // Fetch Apple's public keys
29
+ const keysResponse = await fetch(APPLE_KEYS_URL);
30
+ const { keys } = await keysResponse.json();
31
+
32
+ // Find the matching key
33
+ const matchingKey = keys.find((k) => k.kid === header.kid);
34
+ if (!matchingKey) {
35
+ return NextResponse.json(
36
+ { message: "Invalid Apple token: no matching key" },
37
+ { status: 401 },
38
+ );
39
+ }
40
+
41
+ // Verify the token using Apple's public key
42
+ const publicKey = await importJWK(matchingKey, "RS256");
43
+
44
+ let payload;
45
+ try {
46
+ const result = await jwtVerify(identityToken, publicKey, {
47
+ issuer: "https://appleid.apple.com",
48
+ });
49
+ payload = result.payload;
50
+ } catch {
51
+ return NextResponse.json(
52
+ { message: "Invalid Apple token" },
53
+ { status: 401 },
54
+ );
55
+ }
56
+
57
+ const appleEmail = payload.email;
58
+ if (!appleEmail) {
59
+ return NextResponse.json(
60
+ { message: "Apple token does not contain an email" },
61
+ { status: 400 },
62
+ );
63
+ }
64
+
65
+ // Find or create user
66
+ let existingUser = await user.findByEmail(appleEmail);
67
+ if (!existingUser) {
68
+ existingUser = await user.create({
69
+ email: appleEmail,
70
+ name: payload.name
71
+ ? `${payload.name.firstName || ""} ${payload.name.lastName || ""}`.trim()
72
+ : null,
73
+ });
74
+ }
75
+
76
+ const { token, refreshToken, expiresAt } =
77
+ await issueTokenPair(existingUser);
78
+ return NextResponse.json({ token, refreshToken, expiresAt });
79
+ } catch (error) {
80
+ return handleError(error);
81
+ }
82
+ }