@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.
- package/LICENSE +21 -0
- package/README.md +68 -2
- package/assets/auth.sqlite.schema.ts +479 -0
- package/assets/bun-sqlite.test.ts +46 -0
- package/assets/d1.test.ts +41 -0
- package/assets/drizzle.config.ts +2 -0
- package/assets/generate-auth-schema.mjs +25 -0
- package/assets/platform.sqlite.schema.ts +46 -0
- package/assets/polar-checkout.ts +10 -0
- package/assets/polar-route.ts +19 -0
- package/assets/polar.test.ts +30 -0
- package/assets/polar.ts +12 -0
- package/assets/purchase.bun-sqlite.ts +38 -0
- package/assets/purchase.sqlite.ts +39 -0
- package/assets/sqlite-migrations/0000_thin_phalanx.sql +295 -0
- package/assets/sqlite-migrations/meta/0000_snapshot.json +2058 -0
- package/assets/sqlite-migrations/meta/_journal.json +13 -0
- package/assets/turso.test.ts +40 -0
- package/package.json +44 -5
- package/src/adapters/database.mjs +448 -0
- package/src/adapters/deployment.mjs +101 -0
- package/src/adapters/email.mjs +75 -0
- package/src/adapters/payments.mjs +270 -0
- package/src/catalog.mjs +128 -0
- package/src/cli.mjs +294 -0
- package/src/generate.mjs +183 -0
- package/src/template.mjs +93 -0
- package/template/.env.docker +16 -0
- package/template/.env.example +67 -0
- package/template/DESIGN.md +58 -0
- package/template/LICENSE +21 -0
- package/template/biome.json +46 -0
- package/template/docker-compose.yml +47 -0
- package/template/docs/project-guide.md +61 -0
- package/template/drizzle/0000_create-app-role.sql +54 -0
- package/template/drizzle/0001_add-rls-context.sql +21 -0
- package/template/drizzle/0002_platform_tables.sql +320 -0
- package/template/drizzle/meta/0002_snapshot.json +2125 -0
- package/template/drizzle/meta/_journal.json +27 -0
- package/template/drizzle.config.ts +25 -0
- package/template/e2e/authenticated-ui.spec.ts +40 -0
- package/template/e2e/headless-design.spec.ts +66 -0
- package/template/e2e/navigation-loading.spec.ts +70 -0
- package/template/e2e/theme-responsive.spec.ts +260 -0
- package/template/gitignore +24 -0
- package/template/messages/de.json +212 -0
- package/template/messages/en.json +212 -0
- package/template/package.json +101 -0
- package/template/paraglide.config.ts +49 -0
- package/template/playwright.config.ts +58 -0
- package/template/project.inlang/settings.json +12 -0
- package/template/public/drizzle.svg +1 -0
- package/template/public/favicon.ico +0 -0
- package/template/public/logo192.png +0 -0
- package/template/public/logo512.png +0 -0
- package/template/public/manifest.json +25 -0
- package/template/public/robots.txt +3 -0
- package/template/scripts/check-client-bundle.ts +150 -0
- package/template/scripts/check-server-fns.ts +290 -0
- package/template/scripts/check-sql-raw.ts +50 -0
- package/template/scripts/compile-paraglide.ts +12 -0
- package/template/scripts/postgres/README.md +76 -0
- package/template/scripts/postgres/create-app-role.sh +60 -0
- package/template/scripts/seed-local.ts +331 -0
- package/template/scripts/validate-prod-env.ts +53 -0
- package/template/src/__tests__/README.md +9 -0
- package/template/src/__tests__/account-security.test.ts +664 -0
- package/template/src/__tests__/admin-http-surface.test.ts +311 -0
- package/template/src/__tests__/admin-peer-protection.test.ts +178 -0
- package/template/src/__tests__/auth-harness.ts +104 -0
- package/template/src/__tests__/auth-rate-limit.test.ts +43 -0
- package/template/src/__tests__/auth-redirect.test.ts +120 -0
- package/template/src/__tests__/body-limit.test.ts +47 -0
- package/template/src/__tests__/creem.test.ts +62 -0
- package/template/src/__tests__/demo-checkout.test.ts +141 -0
- package/template/src/__tests__/email-locale.test.ts +110 -0
- package/template/src/__tests__/global-setup.ts +80 -0
- package/template/src/__tests__/input-rules.test.ts +0 -0
- package/template/src/__tests__/message-parity.test.ts +38 -0
- package/template/src/__tests__/navigation-loading.test.tsx +252 -0
- package/template/src/__tests__/nowpayments.test.ts +106 -0
- package/template/src/__tests__/paraglide-url-contract.test.ts +87 -0
- package/template/src/__tests__/purchase-fulfillment.test.ts +204 -0
- package/template/src/__tests__/query-unauthorized-redirect.test.ts +95 -0
- package/template/src/__tests__/rls-policy.test.ts +120 -0
- package/template/src/__tests__/same-page-locale-href.test.ts +47 -0
- package/template/src/__tests__/search-params-oauth.test.ts +57 -0
- package/template/src/__tests__/security-headers.test.ts +12 -0
- package/template/src/__tests__/server-fn-error-contract.test.ts +173 -0
- package/template/src/__tests__/stripe.test.ts +84 -0
- package/template/src/__tests__/test-db.ts +32 -0
- package/template/src/__tests__/test-rls-db.ts +23 -0
- package/template/src/__tests__/user-update-guard.test.ts +109 -0
- package/template/src/components/LocaleSwitcher.tsx +79 -0
- package/template/src/components/ThemeToggle.tsx +47 -0
- package/template/src/components/auth/social.tsx +90 -0
- package/template/src/components/auth/turnstile.tsx +67 -0
- package/template/src/components/root/navigation-progress.tsx +32 -0
- package/template/src/components/root/not-found.tsx +20 -0
- package/template/src/components/root/pending.tsx +9 -0
- package/template/src/components/root/root-document.tsx +37 -0
- package/template/src/components/root/root-error.tsx +34 -0
- package/template/src/components/shells.tsx +201 -0
- package/template/src/components/ui/styles.ts +9 -0
- package/template/src/db/auth.schema.ts +455 -0
- package/template/src/db/helper.ts +38 -0
- package/template/src/db/index.ts +15 -0
- package/template/src/db/platform.schema.ts +122 -0
- package/template/src/db/schema.ts +2 -0
- package/template/src/env.ts +65 -0
- package/template/src/hooks/use-theme.ts +32 -0
- package/template/src/integrations/better-auth/header-user.tsx +36 -0
- package/template/src/integrations/better-auth/session-query.ts +11 -0
- package/template/src/integrations/react-email/PasswordOtpEmail.tsx +62 -0
- package/template/src/integrations/react-email/ResetPasswordEmail.tsx +64 -0
- package/template/src/integrations/react-email/VerifyEmail.tsx +72 -0
- package/template/src/integrations/react-email/theme.ts +75 -0
- package/template/src/integrations/tanstack-query/devtools.tsx +6 -0
- package/template/src/integrations/tanstack-query/root-provider.tsx +60 -0
- package/template/src/lib/auth-client.ts +27 -0
- package/template/src/lib/auth-paths.ts +94 -0
- package/template/src/lib/auth-plugins/existing-email-signup.ts +86 -0
- package/template/src/lib/auth-plugins/field-rules.ts +84 -0
- package/template/src/lib/auth-plugins/social-two-factor.ts +194 -0
- package/template/src/lib/auth-timing.ts +9 -0
- package/template/src/lib/auth.ts +279 -0
- package/template/src/lib/captcha.ts +11 -0
- package/template/src/lib/email.tsx +162 -0
- package/template/src/lib/github.ts +18 -0
- package/template/src/lib/locale-cookie.ts +12 -0
- package/template/src/lib/localized-pages.ts +25 -0
- package/template/src/lib/oauth-metadata.ts +19 -0
- package/template/src/lib/permissions.ts +29 -0
- package/template/src/lib/security-headers.ts +20 -0
- package/template/src/lib/site.ts +59 -0
- package/template/src/lib/theme.ts +42 -0
- package/template/src/lib/user-update-guard.ts +35 -0
- package/template/src/middlewares/admin.ts +46 -0
- package/template/src/middlewares/auth-rate-limit.ts +37 -0
- package/template/src/middlewares/better-auth.ts +15 -0
- package/template/src/middlewares/body-limit.ts +62 -0
- package/template/src/middlewares/database.ts +13 -0
- package/template/src/middlewares/protected.ts +45 -0
- package/template/src/routeTree.gen.ts +536 -0
- package/template/src/router.tsx +53 -0
- package/template/src/routes/[.]well-known/oauth-authorization-server/api/auth.ts +18 -0
- package/template/src/routes/[.]well-known/oauth-protected-resource/index.ts +32 -0
- package/template/src/routes/__root.tsx +175 -0
- package/template/src/routes/admin/audit.tsx +74 -0
- package/template/src/routes/admin/index.tsx +7 -0
- package/template/src/routes/admin/oauth-apps.tsx +164 -0
- package/template/src/routes/admin/route.tsx +27 -0
- package/template/src/routes/admin/users.tsx +215 -0
- package/template/src/routes/api/auth/$.ts +14 -0
- package/template/src/routes/api/auth/[.]well-known/openid-configuration.ts +15 -0
- package/template/src/routes/api/webhooks/creem.ts +69 -0
- package/template/src/routes/api/webhooks/nowpayments.ts +62 -0
- package/template/src/routes/api/webhooks/stripe.ts +74 -0
- package/template/src/routes/dashboard/account.tsx +430 -0
- package/template/src/routes/dashboard/index.tsx +128 -0
- package/template/src/routes/dashboard/route.tsx +17 -0
- package/template/src/routes/forgot-password.tsx +65 -0
- package/template/src/routes/index.tsx +85 -0
- package/template/src/routes/oauth/consent.tsx +77 -0
- package/template/src/routes/reset-password.tsx +78 -0
- package/template/src/routes/sign-in.tsx +174 -0
- package/template/src/routes/sign-up.tsx +135 -0
- package/template/src/routes/two-factor.tsx +74 -0
- package/template/src/server/README.md +3 -0
- package/template/src/server/account-security.ts +163 -0
- package/template/src/server/admin-audit.ts +37 -0
- package/template/src/server/admin-users.ts +112 -0
- package/template/src/server/auth.ts +44 -0
- package/template/src/server/demo-checkout.ts +67 -0
- package/template/src/server/oauth-clients.ts +83 -0
- package/template/src/server/payment-toggles.ts +39 -0
- package/template/src/server.ts +20 -0
- package/template/src/services/README.md +3 -0
- package/template/src/services/account-security.ts +893 -0
- package/template/src/services/admin-security.ts +164 -0
- package/template/src/services/admin-users.ts +181 -0
- package/template/src/services/auth-rate-limit.ts +32 -0
- package/template/src/services/creem.ts +83 -0
- package/template/src/services/demo-checkout.ts +100 -0
- package/template/src/services/nowpayments.ts +154 -0
- package/template/src/services/oauth-client.ts +85 -0
- package/template/src/services/oauth-clients.ts +15 -0
- package/template/src/services/payment-providers.ts +157 -0
- package/template/src/services/payment-toggles.ts +48 -0
- package/template/src/services/purchase.ts +233 -0
- package/template/src/services/stripe.ts +106 -0
- package/template/src/start.ts +35 -0
- package/template/src/styles/layout.css +93 -0
- package/template/src/styles/theme.css +84 -0
- package/template/src/styles/ui.css +233 -0
- package/template/src/styles.css +90 -0
- package/template/src/utils/api-error.ts +129 -0
- package/template/src/utils/auth-errors.ts +157 -0
- package/template/src/utils/auth-redirect.ts +61 -0
- package/template/src/utils/body-limit.ts +84 -0
- package/template/src/utils/cn.ts +6 -0
- package/template/src/utils/input-rules.ts +172 -0
- package/template/src/utils/locale-paths.ts +18 -0
- package/template/src/utils/same-page-locale-href.ts +20 -0
- package/template/src/utils/search-params.ts +71 -0
- package/template/tsconfig.json +30 -0
- package/template/tsr.config.json +6 -0
- package/template/vite.config.ts +24 -0
- package/template/vitest.config.ts +26 -0
- package/template/worker-configuration.d.ts +14660 -0
- 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/
|