@0x0501/mystack 0.0.0-stage → 0.1.0-bootstrap.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 (211) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/assets/auth.sqlite.schema.ts +479 -0
  4. package/assets/bun-sqlite.test.ts +46 -0
  5. package/assets/d1.test.ts +41 -0
  6. package/assets/drizzle.config.ts +2 -0
  7. package/assets/generate-auth-schema.mjs +25 -0
  8. package/assets/platform.sqlite.schema.ts +46 -0
  9. package/assets/polar-checkout.ts +10 -0
  10. package/assets/polar-route.ts +19 -0
  11. package/assets/polar.test.ts +30 -0
  12. package/assets/polar.ts +12 -0
  13. package/assets/purchase.bun-sqlite.ts +38 -0
  14. package/assets/purchase.sqlite.ts +39 -0
  15. package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
  16. package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
  17. package/assets/sqlite-migrations/meta/_journal.json +13 -0
  18. package/assets/turso.test.ts +40 -0
  19. package/package.json +44 -5
  20. package/src/adapters/database.mjs +448 -0
  21. package/src/adapters/deployment.mjs +101 -0
  22. package/src/adapters/email.mjs +75 -0
  23. package/src/adapters/payments.mjs +270 -0
  24. package/src/catalog.mjs +128 -0
  25. package/src/cli.mjs +294 -0
  26. package/src/generate.mjs +183 -0
  27. package/src/template.mjs +93 -0
  28. package/template/.env.docker +16 -0
  29. package/template/.env.example +67 -0
  30. package/template/DESIGN.md +58 -0
  31. package/template/LICENSE +21 -0
  32. package/template/biome.json +46 -0
  33. package/template/docker-compose.yml +47 -0
  34. package/template/docs/project-guide.md +61 -0
  35. package/template/drizzle/0000_create-app-role.sql +54 -0
  36. package/template/drizzle/0001_add-rls-context.sql +21 -0
  37. package/template/drizzle/0002_platform_tables.sql +320 -0
  38. package/template/drizzle/meta/0002_snapshot.json +2125 -0
  39. package/template/drizzle/meta/_journal.json +27 -0
  40. package/template/drizzle.config.ts +25 -0
  41. package/template/e2e/authenticated-ui.spec.ts +40 -0
  42. package/template/e2e/headless-design.spec.ts +66 -0
  43. package/template/e2e/navigation-loading.spec.ts +70 -0
  44. package/template/e2e/theme-responsive.spec.ts +260 -0
  45. package/template/gitignore +24 -0
  46. package/template/messages/de.json +212 -0
  47. package/template/messages/en.json +212 -0
  48. package/template/package.json +101 -0
  49. package/template/paraglide.config.ts +49 -0
  50. package/template/playwright.config.ts +58 -0
  51. package/template/project.inlang/settings.json +12 -0
  52. package/template/public/drizzle.svg +1 -0
  53. package/template/public/favicon.ico +0 -0
  54. package/template/public/logo192.png +0 -0
  55. package/template/public/logo512.png +0 -0
  56. package/template/public/manifest.json +25 -0
  57. package/template/public/robots.txt +3 -0
  58. package/template/scripts/check-client-bundle.ts +150 -0
  59. package/template/scripts/check-server-fns.ts +290 -0
  60. package/template/scripts/check-sql-raw.ts +50 -0
  61. package/template/scripts/compile-paraglide.ts +12 -0
  62. package/template/scripts/postgres/README.md +76 -0
  63. package/template/scripts/postgres/create-app-role.sh +60 -0
  64. package/template/scripts/seed-local.ts +331 -0
  65. package/template/scripts/validate-prod-env.ts +53 -0
  66. package/template/src/__tests__/README.md +9 -0
  67. package/template/src/__tests__/account-security.test.ts +664 -0
  68. package/template/src/__tests__/admin-http-surface.test.ts +311 -0
  69. package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
  70. package/template/src/__tests__/auth-harness.ts +104 -0
  71. package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
  72. package/template/src/__tests__/auth-redirect.test.ts +120 -0
  73. package/template/src/__tests__/body-limit.test.ts +47 -0
  74. package/template/src/__tests__/creem.test.ts +62 -0
  75. package/template/src/__tests__/demo-checkout.test.ts +141 -0
  76. package/template/src/__tests__/email-locale.test.ts +110 -0
  77. package/template/src/__tests__/global-setup.ts +80 -0
  78. package/template/src/__tests__/input-rules.test.ts +0 -0
  79. package/template/src/__tests__/message-parity.test.ts +38 -0
  80. package/template/src/__tests__/navigation-loading.test.tsx +252 -0
  81. package/template/src/__tests__/nowpayments.test.ts +106 -0
  82. package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
  83. package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
  84. package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
  85. package/template/src/__tests__/rls-policy.test.ts +120 -0
  86. package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
  87. package/template/src/__tests__/search-params-oauth.test.ts +57 -0
  88. package/template/src/__tests__/security-headers.test.ts +12 -0
  89. package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
  90. package/template/src/__tests__/stripe.test.ts +84 -0
  91. package/template/src/__tests__/test-db.ts +32 -0
  92. package/template/src/__tests__/test-rls-db.ts +23 -0
  93. package/template/src/__tests__/user-update-guard.test.ts +109 -0
  94. package/template/src/components/LocaleSwitcher.tsx +79 -0
  95. package/template/src/components/ThemeToggle.tsx +47 -0
  96. package/template/src/components/auth/social.tsx +90 -0
  97. package/template/src/components/auth/turnstile.tsx +67 -0
  98. package/template/src/components/root/navigation-progress.tsx +32 -0
  99. package/template/src/components/root/not-found.tsx +20 -0
  100. package/template/src/components/root/pending.tsx +9 -0
  101. package/template/src/components/root/root-document.tsx +37 -0
  102. package/template/src/components/root/root-error.tsx +34 -0
  103. package/template/src/components/shells.tsx +201 -0
  104. package/template/src/components/ui/styles.ts +9 -0
  105. package/template/src/db/auth.schema.ts +455 -0
  106. package/template/src/db/helper.ts +38 -0
  107. package/template/src/db/index.ts +15 -0
  108. package/template/src/db/platform.schema.ts +122 -0
  109. package/template/src/db/schema.ts +2 -0
  110. package/template/src/env.ts +65 -0
  111. package/template/src/hooks/use-theme.ts +32 -0
  112. package/template/src/integrations/better-auth/header-user.tsx +36 -0
  113. package/template/src/integrations/better-auth/session-query.ts +11 -0
  114. package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
  115. package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
  116. package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
  117. package/template/src/integrations/react-email/theme.ts +75 -0
  118. package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
  119. package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
  120. package/template/src/lib/auth-client.ts +27 -0
  121. package/template/src/lib/auth-paths.ts +94 -0
  122. package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
  123. package/template/src/lib/auth-plugins/field-rules.ts +84 -0
  124. package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
  125. package/template/src/lib/auth-timing.ts +9 -0
  126. package/template/src/lib/auth.ts +279 -0
  127. package/template/src/lib/captcha.ts +11 -0
  128. package/template/src/lib/email.tsx +162 -0
  129. package/template/src/lib/github.ts +18 -0
  130. package/template/src/lib/locale-cookie.ts +12 -0
  131. package/template/src/lib/localized-pages.ts +25 -0
  132. package/template/src/lib/oauth-metadata.ts +19 -0
  133. package/template/src/lib/permissions.ts +29 -0
  134. package/template/src/lib/security-headers.ts +20 -0
  135. package/template/src/lib/site.ts +59 -0
  136. package/template/src/lib/theme.ts +42 -0
  137. package/template/src/lib/user-update-guard.ts +35 -0
  138. package/template/src/middlewares/admin.ts +46 -0
  139. package/template/src/middlewares/auth-rate-limit.ts +37 -0
  140. package/template/src/middlewares/better-auth.ts +15 -0
  141. package/template/src/middlewares/body-limit.ts +62 -0
  142. package/template/src/middlewares/database.ts +13 -0
  143. package/template/src/middlewares/protected.ts +45 -0
  144. package/template/src/routeTree.gen.ts +536 -0
  145. package/template/src/router.tsx +53 -0
  146. package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
  147. package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
  148. package/template/src/routes/__root.tsx +175 -0
  149. package/template/src/routes/admin/audit.tsx +74 -0
  150. package/template/src/routes/admin/index.tsx +7 -0
  151. package/template/src/routes/admin/oauth-apps.tsx +164 -0
  152. package/template/src/routes/admin/route.tsx +27 -0
  153. package/template/src/routes/admin/users.tsx +215 -0
  154. package/template/src/routes/api/auth/$.ts +14 -0
  155. package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
  156. package/template/src/routes/api/webhooks/creem.ts +69 -0
  157. package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
  158. package/template/src/routes/api/webhooks/stripe.ts +74 -0
  159. package/template/src/routes/dashboard/account.tsx +430 -0
  160. package/template/src/routes/dashboard/index.tsx +128 -0
  161. package/template/src/routes/dashboard/route.tsx +17 -0
  162. package/template/src/routes/forgot-password.tsx +65 -0
  163. package/template/src/routes/index.tsx +85 -0
  164. package/template/src/routes/oauth/consent.tsx +77 -0
  165. package/template/src/routes/reset-password.tsx +78 -0
  166. package/template/src/routes/sign-in.tsx +174 -0
  167. package/template/src/routes/sign-up.tsx +135 -0
  168. package/template/src/routes/two-factor.tsx +74 -0
  169. package/template/src/server/README.md +3 -0
  170. package/template/src/server/account-security.ts +163 -0
  171. package/template/src/server/admin-audit.ts +37 -0
  172. package/template/src/server/admin-users.ts +112 -0
  173. package/template/src/server/auth.ts +44 -0
  174. package/template/src/server/demo-checkout.ts +67 -0
  175. package/template/src/server/oauth-clients.ts +83 -0
  176. package/template/src/server/payment-toggles.ts +39 -0
  177. package/template/src/server.ts +20 -0
  178. package/template/src/services/README.md +3 -0
  179. package/template/src/services/account-security.ts +893 -0
  180. package/template/src/services/admin-security.ts +164 -0
  181. package/template/src/services/admin-users.ts +181 -0
  182. package/template/src/services/auth-rate-limit.ts +32 -0
  183. package/template/src/services/creem.ts +83 -0
  184. package/template/src/services/demo-checkout.ts +100 -0
  185. package/template/src/services/nowpayments.ts +154 -0
  186. package/template/src/services/oauth-client.ts +85 -0
  187. package/template/src/services/oauth-clients.ts +15 -0
  188. package/template/src/services/payment-providers.ts +157 -0
  189. package/template/src/services/payment-toggles.ts +48 -0
  190. package/template/src/services/purchase.ts +233 -0
  191. package/template/src/services/stripe.ts +106 -0
  192. package/template/src/start.ts +35 -0
  193. package/template/src/styles/layout.css +93 -0
  194. package/template/src/styles/theme.css +84 -0
  195. package/template/src/styles/ui.css +233 -0
  196. package/template/src/styles.css +90 -0
  197. package/template/src/utils/api-error.ts +129 -0
  198. package/template/src/utils/auth-errors.ts +157 -0
  199. package/template/src/utils/auth-redirect.ts +61 -0
  200. package/template/src/utils/body-limit.ts +84 -0
  201. package/template/src/utils/cn.ts +6 -0
  202. package/template/src/utils/input-rules.ts +172 -0
  203. package/template/src/utils/locale-paths.ts +18 -0
  204. package/template/src/utils/same-page-locale-href.ts +20 -0
  205. package/template/src/utils/search-params.ts +71 -0
  206. package/template/tsconfig.json +30 -0
  207. package/template/tsr.config.json +6 -0
  208. package/template/vite.config.ts +24 -0
  209. package/template/vitest.config.ts +26 -0
  210. package/template/worker-configuration.d.ts +14660 -0
  211. package/template/wrangler.jsonc +39 -0
@@ -0,0 +1,27 @@
1
+ {
2
+ "version": "7",
3
+ "dialect": "postgresql",
4
+ "entries": [
5
+ {
6
+ "idx": 0,
7
+ "version": "7",
8
+ "when": 1754064000000,
9
+ "tag": "0000_create-app-role",
10
+ "breakpoints": true
11
+ },
12
+ {
13
+ "idx": 1,
14
+ "version": "7",
15
+ "when": 1754064000001,
16
+ "tag": "0001_add-rls-context",
17
+ "breakpoints": true
18
+ },
19
+ {
20
+ "idx": 2,
21
+ "version": "7",
22
+ "when": 1754064000002,
23
+ "tag": "0002_platform_tables",
24
+ "breakpoints": true
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,25 @@
1
+ import { config } from "dotenv";
2
+ import { defineConfig } from "drizzle-kit";
3
+
4
+ // Local env: `.env` then optional overrides in `.env.local` (both gitignored).
5
+ // Mirrors Docker defaults from `.env.docker` / `.env.example`.
6
+ config({ path: ".env" });
7
+ config({ path: ".env.local", override: true });
8
+
9
+ const migrationUrl = process.env.DATABASE_MIGRATION_URL;
10
+
11
+ if (!migrationUrl) {
12
+ console.warn(
13
+ "[drizzle.config] DATABASE_MIGRATION_URL is unset. " +
14
+ "Copy .env.example → .env and run `bun run db:up` for local Postgres.",
15
+ );
16
+ }
17
+
18
+ export default defineConfig({
19
+ out: "./drizzle",
20
+ schema: "./src/db/schema.ts",
21
+ dialect: "postgresql",
22
+ ...(migrationUrl ? { dbCredentials: { url: migrationUrl } } : {}),
23
+ verbose: true,
24
+ strict: true,
25
+ });
@@ -0,0 +1,40 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ test("authenticated pages hydrate consistently and retain Headless interactions", async ({
4
+ page,
5
+ }) => {
6
+ const email = process.env.E2E_USER_EMAIL;
7
+ const password = process.env.E2E_USER_PASSWORD;
8
+ if (!email || !password) {
9
+ test.skip(true, "Set E2E_USER_EMAIL/PASSWORD for a seeded local user.");
10
+ return;
11
+ }
12
+
13
+ const errors: string[] = [];
14
+ page.on("pageerror", (error) => errors.push(error.message));
15
+ await page.goto("/sign-in");
16
+ await page.getByLabel("Email", { exact: true }).fill(email);
17
+ await page.getByLabel("Password", { exact: true }).fill(password);
18
+ await page.getByRole("button", { name: "Sign in", exact: true }).click();
19
+ await expect(page).toHaveURL(/\/dashboard$/);
20
+ await page.getByTestId("theme-dark").click();
21
+ expect(errors).toEqual([]);
22
+
23
+ await page.goto("/dashboard/account");
24
+ await page.getByRole("tab", { name: "Profile", exact: true }).focus();
25
+ await page.keyboard.press("ArrowRight");
26
+ const security = page.getByRole("tab", { name: "Security", exact: true });
27
+ await expect(security).toBeFocused();
28
+ await page.keyboard.press("Enter");
29
+ await expect(security).toHaveAttribute("aria-selected", "true");
30
+ await page.getByRole("tab", { name: "Profile", exact: true }).click();
31
+ const select = page.getByRole("combobox");
32
+ await select.click();
33
+ await page.getByRole("option", { name: "Deutsch", exact: true }).click();
34
+ await expect(select).toContainText("Deutsch");
35
+ await expect(select).toBeFocused();
36
+ await expect(page.locator('nav a[aria-current="page"]')).toContainText(
37
+ "Account",
38
+ );
39
+ expect(errors).toEqual([]);
40
+ });
@@ -0,0 +1,66 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ test("a product skin and Tailwind utilities override default presentation", async ({
4
+ page,
5
+ }) => {
6
+ await page.goto("/");
7
+ await page.getByTestId("theme-light").click();
8
+ await page.addStyleTag({
9
+ content: `:root {
10
+ --primary: #7c3aed;
11
+ --primary-foreground: #ffffff;
12
+ --radius: 1.25rem;
13
+ }`,
14
+ });
15
+
16
+ const action = page.getByTestId("nav-sign-in");
17
+ await expect(action).toHaveCSS("background-color", "rgb(124, 58, 237)");
18
+ await expect(action).toHaveCSS("border-radius", "20px");
19
+
20
+ // Utilities in the consuming page take precedence over component CSS.
21
+ await action.evaluate((node) => node.classList.add("px-2", "text-xs"));
22
+ await expect(action).toHaveCSS("font-size", "12px");
23
+ await expect(action).toHaveCSS("padding-left", "8px");
24
+
25
+ await page.goto("/sign-in");
26
+ await page.addStyleTag({
27
+ content: ":root { --background: #fff7ed; --radius: 1.25rem; }",
28
+ });
29
+ await expect(page.getByTestId("auth-card")).toBeVisible();
30
+ await expect(page.locator('input[type="email"]')).toHaveCSS(
31
+ "background-color",
32
+ "rgb(255, 247, 237)",
33
+ );
34
+ await expect(page.locator('input[type="email"]')).toHaveCSS(
35
+ "border-radius",
36
+ "20px",
37
+ );
38
+ });
39
+
40
+ test("popup motion respects user preference and preserves locale and keyboard behavior", async ({
41
+ page,
42
+ }) => {
43
+ await page.emulateMedia({ reducedMotion: "no-preference" });
44
+ await page.goto("/sign-in?redirect=%2Fdashboard&ba_param=a&ba_param=b#flow");
45
+ const trigger = page.getByTestId("locale-switcher");
46
+ const menu = page.getByTestId("locale-switcher-menu");
47
+ await trigger.click();
48
+ await expect(menu).toBeVisible();
49
+ await page.keyboard.press("Escape");
50
+ await expect(menu).toBeHidden();
51
+ await expect(trigger).toBeFocused();
52
+
53
+ await page.emulateMedia({ reducedMotion: "reduce" });
54
+ await trigger.click();
55
+ await expect(menu).toBeVisible();
56
+ await expect(menu).toHaveCSS("transition-duration", "0s");
57
+ const href = await page.getByTestId("locale-link-de").getAttribute("href");
58
+ const localized = new URL(href!, page.url());
59
+ expect(localized.pathname).toBe("/de/sign-in");
60
+ expect(localized.searchParams.getAll("ba_param")).toEqual(["a", "b"]);
61
+ expect(localized.searchParams.get("redirect")).toBe("/dashboard");
62
+ expect(localized.hash).toBe("#flow");
63
+ await page.getByTestId("locale-link-de").click();
64
+ await expect(page).toHaveURL(localized.href);
65
+ await expect(page.locator("html")).toHaveAttribute("lang", "de");
66
+ });
@@ -0,0 +1,70 @@
1
+ import { expect, test } from "@playwright/test";
2
+
3
+ test("cached SSR session allows navigation while a slow page chunk shows loading feedback", async ({
4
+ page,
5
+ }) => {
6
+ const errors: string[] = [];
7
+ let sessionRequests = 0;
8
+ page.on("pageerror", (error) => errors.push(error.message));
9
+ page.on("request", (request) => {
10
+ if (request.url().includes("/_serverFn/")) {
11
+ sessionRequests++;
12
+ }
13
+ });
14
+ await page.goto("/sign-in");
15
+ await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
16
+
17
+ let release!: () => void;
18
+ const gate = new Promise<void>((resolve) => {
19
+ release = resolve;
20
+ });
21
+ await page.route(/\/assets\/sign-up-[^/]+\.js$/, async (route) => {
22
+ await gate;
23
+ await route.continue();
24
+ });
25
+ await page.getByRole("link", { name: "Sign up", exact: true }).click();
26
+ try {
27
+ await expect(page).toHaveURL(/\/sign-up$/);
28
+ await expect(page.getByRole("status")).toHaveText("Loading…");
29
+ expect(sessionRequests).toBe(0);
30
+ } finally {
31
+ release();
32
+ }
33
+ await expect(page.getByRole("heading", { name: "Create account" })).toBeVisible();
34
+ expect(sessionRequests).toBe(0);
35
+ expect(errors).toEqual([]);
36
+ });
37
+
38
+ test("an expired session query provides feedback while the root guard awaits the server", async ({
39
+ page,
40
+ }) => {
41
+ const errors: string[] = [];
42
+ page.on("pageerror", (error) => errors.push(error.message));
43
+ await page.goto("/sign-in");
44
+ await expect(page.getByRole("button", { name: "Sign in", exact: true })).toBeVisible();
45
+ await page.clock.install();
46
+ await page.clock.fastForward(31_000);
47
+
48
+ let release!: () => void;
49
+ let requests = 0;
50
+ const gate = new Promise<void>((resolve) => {
51
+ release = resolve;
52
+ });
53
+ await page.route("**/_serverFn/**", async (route) => {
54
+ requests++;
55
+ await gate;
56
+ await route.continue();
57
+ });
58
+ await page.getByRole("link", { name: "Sign up", exact: true }).click();
59
+ try {
60
+ await expect(page).toHaveURL(/\/sign-up$/);
61
+ await expect(page.getByRole("status")).toHaveText("Loading…");
62
+ await expect(page.getByRole("heading", { name: "Sign in", exact: true })).toBeVisible();
63
+ expect(requests).toBe(1);
64
+ } finally {
65
+ release();
66
+ }
67
+ await expect(page.getByRole("heading", { name: "Create account" })).toBeVisible();
68
+ expect(requests).toBe(1);
69
+ expect(errors).toEqual([]);
70
+ });
@@ -0,0 +1,260 @@
1
+ import { expect, type Page, test } from "@playwright/test";
2
+
3
+ /** Relative luminance (sRGB) for WCAG contrast math. */
4
+ function luminance(rgb: { r: number; g: number; b: number }): number {
5
+ const lin = (c: number) => {
6
+ const s = c / 255;
7
+ return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
8
+ };
9
+ const r = lin(rgb.r);
10
+ const g = lin(rgb.g);
11
+ const b = lin(rgb.b);
12
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b;
13
+ }
14
+
15
+ function contrastRatio(
16
+ a: { r: number; g: number; b: number },
17
+ b: { r: number; g: number; b: number },
18
+ ): number {
19
+ const l1 = luminance(a);
20
+ const l2 = luminance(b);
21
+ const lighter = Math.max(l1, l2);
22
+ const darker = Math.min(l1, l2);
23
+ return (lighter + 0.05) / (darker + 0.05);
24
+ }
25
+
26
+ function parseRgb(color: string): { r: number; g: number; b: number } | null {
27
+ const m = color.match(
28
+ /rgba?\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)/i,
29
+ );
30
+ if (!m) {
31
+ // modern browsers may return "rgb(16 21 28)" space-separated
32
+ const m2 = color.match(/rgba?\(\s*([\d.]+)\s+([\d.]+)\s+([\d.]+)/i);
33
+ if (!m2) return null;
34
+ return {
35
+ r: Number(m2[1]),
36
+ g: Number(m2[2]),
37
+ b: Number(m2[3]),
38
+ };
39
+ }
40
+ return { r: Number(m[1]), g: Number(m[2]), b: Number(m[3]) };
41
+ }
42
+
43
+ async function readColors(
44
+ page: Page,
45
+ selector: string,
46
+ ): Promise<{ color: string; backgroundColor: string }> {
47
+ return page.locator(selector).evaluate((el) => {
48
+ const styles = getComputedStyle(el);
49
+ return {
50
+ color: styles.color,
51
+ backgroundColor: styles.backgroundColor,
52
+ };
53
+ });
54
+ }
55
+
56
+ async function assertReadableText(
57
+ page: Page,
58
+ selector: string,
59
+ minRatio: number,
60
+ label: string,
61
+ ) {
62
+ const el = page.locator(selector).first();
63
+ await expect(el).toBeVisible();
64
+
65
+ // Walk up until we find a non-transparent background for contrast.
66
+ const pair = await el.evaluate((node) => {
67
+ const textColor = getComputedStyle(node).color;
68
+ let bg = "rgba(0, 0, 0, 0)";
69
+ let cur: Element | null = node;
70
+ while (cur) {
71
+ const c = getComputedStyle(cur).backgroundColor;
72
+ if (c && !c.includes("0, 0, 0, 0") && c !== "transparent") {
73
+ // also skip fully transparent alpha
74
+ const alpha = c.match(/[\d.]+\)$/);
75
+ if (c.startsWith("rgba") && alpha) {
76
+ const a = Number(c.slice(c.lastIndexOf(",") + 1, -1));
77
+ if (a < 0.05) {
78
+ cur = cur.parentElement;
79
+ continue;
80
+ }
81
+ }
82
+ bg = c;
83
+ break;
84
+ }
85
+ cur = cur.parentElement;
86
+ }
87
+ return { color: textColor, backgroundColor: bg };
88
+ });
89
+
90
+ const fg = parseRgb(pair.color);
91
+ const bg = parseRgb(pair.backgroundColor);
92
+ expect(fg, `${label}: parse foreground ${pair.color}`).not.toBeNull();
93
+ expect(bg, `${label}: parse background ${pair.backgroundColor}`).not.toBeNull();
94
+ const ratio = contrastRatio(fg!, bg!);
95
+ expect(
96
+ ratio,
97
+ `${label}: contrast ${ratio.toFixed(2)}:1 (fg=${pair.color}, bg=${pair.backgroundColor})`,
98
+ ).toBeGreaterThanOrEqual(minRatio);
99
+ }
100
+
101
+ async function assertNoHorizontalOverflow(page: Page) {
102
+ const overflow = await page.evaluate(() => {
103
+ const doc = document.documentElement;
104
+ return {
105
+ scrollWidth: doc.scrollWidth,
106
+ clientWidth: doc.clientWidth,
107
+ };
108
+ });
109
+ expect(
110
+ overflow.scrollWidth,
111
+ `horizontal overflow: scrollWidth ${overflow.scrollWidth} > clientWidth ${overflow.clientWidth}`,
112
+ ).toBeLessThanOrEqual(overflow.clientWidth + 1);
113
+ }
114
+
115
+ async function clearTheme(page: Page) {
116
+ await page.goto("/");
117
+ await page.evaluate(() => {
118
+ try {
119
+ localStorage.removeItem("theme");
120
+ } catch {
121
+ // ignore
122
+ }
123
+ });
124
+ // Re-apply system defaults without wiping on subsequent navigations.
125
+ await page.evaluate(() => {
126
+ document.documentElement.classList.remove("dark");
127
+ document.documentElement.dataset.theme = "system";
128
+ const dark = matchMedia("(prefers-color-scheme: dark)").matches;
129
+ document.documentElement.classList.toggle("dark", dark);
130
+ });
131
+ }
132
+
133
+ test.describe("theme, contrast, and responsive layout", () => {
134
+ test("home renders primary content and readable body text", async ({
135
+ page,
136
+ }) => {
137
+ await clearTheme(page);
138
+ await page.goto("/");
139
+ await expect(page.getByTestId("home-page")).toBeVisible();
140
+ await expect(page.getByTestId("home-title")).toBeVisible();
141
+ await expect(page.getByTestId("home-blurb")).toBeVisible();
142
+ await expect(page.getByTestId("theme-toggle")).toBeVisible();
143
+
144
+ // Body ink on paper — WCAG AA for normal text (4.5:1)
145
+ await assertReadableText(page, "[data-testid=home-title]", 4.5, "title");
146
+ await assertReadableText(page, "[data-testid=home-blurb]", 4.5, "blurb");
147
+ await assertNoHorizontalOverflow(page);
148
+ });
149
+
150
+ test("theme toggle switches light and dark with readable text", async ({
151
+ page,
152
+ }) => {
153
+ await clearTheme(page);
154
+ await page.goto("/");
155
+ const html = page.locator("html");
156
+
157
+ await page.getByTestId("theme-dark").click();
158
+ await expect(html).toHaveClass(/dark/);
159
+ await expect(html).toHaveAttribute("data-theme", "dark");
160
+ await assertReadableText(
161
+ page,
162
+ "[data-testid=home-title]",
163
+ 4.5,
164
+ "title dark",
165
+ );
166
+ await assertReadableText(
167
+ page,
168
+ "[data-testid=home-blurb]",
169
+ 4.5,
170
+ "blurb dark",
171
+ );
172
+
173
+ // Primary CTA: primary-fg on primary bg
174
+ const cta = page.getByTestId("nav-sign-in");
175
+ await expect(cta).toBeVisible();
176
+ const ctaColors = await readColors(page, "[data-testid=nav-sign-in]");
177
+ const ctaFg = parseRgb(ctaColors.color);
178
+ const ctaBg = parseRgb(ctaColors.backgroundColor);
179
+ expect(ctaFg).not.toBeNull();
180
+ expect(ctaBg).not.toBeNull();
181
+ expect(
182
+ contrastRatio(ctaFg!, ctaBg!),
183
+ `CTA dark contrast ${ctaColors.color} on ${ctaColors.backgroundColor}`,
184
+ ).toBeGreaterThanOrEqual(4.5);
185
+
186
+ await page.getByTestId("theme-light").click();
187
+ await expect(html).not.toHaveClass(/dark/);
188
+ await expect(html).toHaveAttribute("data-theme", "light");
189
+ await assertReadableText(
190
+ page,
191
+ "[data-testid=home-title]",
192
+ 4.5,
193
+ "title light",
194
+ );
195
+ await assertReadableText(
196
+ page,
197
+ "[data-testid=home-blurb]",
198
+ 4.5,
199
+ "blurb light",
200
+ );
201
+
202
+ const lightCta = await readColors(page, "[data-testid=nav-sign-in]");
203
+ const lFg = parseRgb(lightCta.color);
204
+ const lBg = parseRgb(lightCta.backgroundColor);
205
+ expect(lFg).not.toBeNull();
206
+ expect(lBg).not.toBeNull();
207
+ expect(
208
+ contrastRatio(lFg!, lBg!),
209
+ `CTA light contrast ${lightCta.color} on ${lightCta.backgroundColor}`,
210
+ ).toBeGreaterThanOrEqual(4.5);
211
+
212
+ // Preference persists across reload (do not clear localStorage here)
213
+ const stored = await page.evaluate(() => localStorage.getItem("theme"));
214
+ expect(stored).toBe("light");
215
+ await page.reload();
216
+ await expect(html).not.toHaveClass(/dark/);
217
+ await expect(html).toHaveAttribute("data-theme", "light");
218
+ });
219
+
220
+ test("sign-in form colors are correct in light and dark", async ({
221
+ page,
222
+ }) => {
223
+ await clearTheme(page);
224
+ await page.goto("/sign-in");
225
+ await expect(page.getByTestId("auth-card")).toBeVisible();
226
+ await expect(page.getByTestId("theme-toggle")).toBeVisible();
227
+
228
+ const email = page.locator('input[type="email"]');
229
+ await expect(email).toBeVisible();
230
+
231
+ // Light
232
+ await page.getByTestId("theme-light").click();
233
+ await assertReadableText(page, "h1", 4.5, "sign-in title light");
234
+ const lightInput = await readColors(page, 'input[type="email"]');
235
+ const liFg = parseRgb(lightInput.color);
236
+ const liBg = parseRgb(lightInput.backgroundColor);
237
+ expect(liFg).not.toBeNull();
238
+ expect(liBg).not.toBeNull();
239
+ expect(
240
+ contrastRatio(liFg!, liBg!),
241
+ `input light ${lightInput.color} on ${lightInput.backgroundColor}`,
242
+ ).toBeGreaterThanOrEqual(4.5);
243
+
244
+ // Dark
245
+ await page.getByTestId("theme-dark").click();
246
+ await expect(page.locator("html")).toHaveClass(/dark/);
247
+ await assertReadableText(page, "h1", 4.5, "sign-in title dark");
248
+ const darkInput = await readColors(page, 'input[type="email"]');
249
+ const diFg = parseRgb(darkInput.color);
250
+ const diBg = parseRgb(darkInput.backgroundColor);
251
+ expect(diFg).not.toBeNull();
252
+ expect(diBg).not.toBeNull();
253
+ expect(
254
+ contrastRatio(diFg!, diBg!),
255
+ `input dark ${darkInput.color} on ${darkInput.backgroundColor}`,
256
+ ).toBeGreaterThanOrEqual(4.5);
257
+
258
+ await assertNoHorizontalOverflow(page);
259
+ });
260
+ });
@@ -0,0 +1,24 @@
1
+ node_modules
2
+ .DS_Store
3
+ dist
4
+ dist-ssr
5
+ *.local
6
+ .env
7
+ .env.prod
8
+ .nitro
9
+ .tanstack
10
+ .wrangler
11
+ .output
12
+ .vinxi
13
+ __unconfig*
14
+ todos.json
15
+
16
+ # Playwright
17
+ test-results/
18
+ playwright-report/
19
+ blob-report/
20
+ .tokensave
21
+ .serena
22
+
23
+ # CLI release snapshot; rebuilt by prepack
24
+ /packages/mystack/template/