@pithy-sh/ui-react 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/LICENSE +21 -0
  2. package/package.json +47 -0
  3. package/src/templates.ts +147 -0
  4. package/src/testing/virtualAuth.ts +12 -0
  5. package/src/testing/virtualI18n.ts +16 -0
  6. package/src/testing/virtualPayments.ts +5 -0
  7. package/src/testing/virtualTurnstile.ts +5 -0
  8. package/templates/client-env.d.ts +299 -0
  9. package/templates/index.html +14 -0
  10. package/templates/src/client.test.tsx +98 -0
  11. package/templates/src/client.tsx +51 -0
  12. package/templates/src/payments.tsx +147 -0
  13. package/templates/src/pithy-config.tsx +93 -0
  14. package/templates/src/pithy-locale.test.tsx +140 -0
  15. package/templates/src/pithy-locale.tsx +134 -0
  16. package/templates/src/pithy-screens.css +379 -0
  17. package/templates/src/router.test.tsx +63 -0
  18. package/templates/src/router.tsx +618 -0
  19. package/templates/src/routes/app/home.bare.tsx +96 -0
  20. package/templates/src/routes/app/home.tsx +41 -0
  21. package/templates/src/routes/pithy/callback.tsx +42 -0
  22. package/templates/src/routes/pithy/otp.tsx +127 -0
  23. package/templates/src/routes/pithy/paywall.tsx +160 -0
  24. package/templates/src/routes/pithy/pricing.tsx +312 -0
  25. package/templates/src/routes/pithy/sign-in.test.tsx +116 -0
  26. package/templates/src/routes/pithy/sign-in.tsx +470 -0
  27. package/templates/src/routes/pithy/subscription.tsx +183 -0
  28. package/templates/src/session.tsx +78 -0
  29. package/templates/src/styles.css +53 -0
  30. package/templates/src/turnstile.test.tsx +119 -0
  31. package/templates/src/turnstile.tsx +105 -0
  32. package/templates/tsconfig.client.json +28 -0
  33. package/templates/tsconfig.node.json +22 -0
  34. package/templates/vite.config.ts +45 -0
@@ -0,0 +1,98 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { act, type ReactNode } from "react";
4
+ import { expect, test, vi } from "vitest";
5
+
6
+ /**
7
+ * **The app mounts into a node it creates, so nothing in `index.html` can rename it out from under it.**
8
+ *
9
+ * This file used to find its mount point with `document.getElementById("root")` and render inside
10
+ * `if (container) { … }`, against a `<div id="root">` declared in `index.html`. Two strings, nothing
11
+ * comparing them. Renaming the div — an ordinary edit to your own HTML — produced an empty document
12
+ * with a 200: no throw, no log, a clean build and a green suite. The failure mode hardest to attribute,
13
+ * because the first three things anyone suspects are their own code, their build and their Worker, and
14
+ * none of them is wrong (#394).
15
+ *
16
+ * ## What this proves, and how it goes red
17
+ *
18
+ * The document is given a mount node with a **deliberately wrong id** before `client.tsx` is imported —
19
+ * exactly the document an adopter who renamed the div would have. Code that looks an id up finds
20
+ * nothing and renders nothing, and this goes red; only code that creates its own node can pass. The id
21
+ * is invented here and reachable from nowhere else, which is what a canary is for: asserting `"root"`
22
+ * would have passed against the very edit this catches.
23
+ *
24
+ * `./router` is stubbed for the same reason `src/turnstile.test.tsx` stubs `./pithy-config` — the real
25
+ * one resolves every screen, and those read `virtual:pithy/*` modules that only a Vite build serves.
26
+ * Mounting is what is under test here, not routing.
27
+ *
28
+ * ## And the app is mounted **under a translator**, which is the second thing this file proves
29
+ *
30
+ * `src/pithy-locale.tsx` has its own gate, and that gate mounts `<PithyLocale>` itself — so it proves
31
+ * the component works and never that anything renders it. Deleting the element and its import from
32
+ * this file left the whole repository green: an app with no translator over it, every screen in
33
+ * English under whatever `lang` was negotiated, and not one test anywhere going red. That is the very
34
+ * defect the seeded-gate convention exists to stop, one level up — the file that was broken is the
35
+ * file with no gate on it.
36
+ *
37
+ * So `./pithy-locale` is stubbed with a wrapper that marks the subtree it draws, and the app has to be
38
+ * found **inside** it. A stub rather than the real component on purpose: with nothing composed the real
39
+ * one renders its children untouched, which is indistinguishable from not being rendered at all — the
40
+ * assertion could not exist. What language it then mounts is `pithy-locale.test.tsx`'s subject, and
41
+ * this file does not restate it.
42
+ *
43
+ * **And `./pithy-config` is stubbed too, which is not a detail.** `src/pithy-locale.tsx` reads the i18n
44
+ * projection through it, so a `client.tsx` that reached the real module would reach a `virtual:pithy/*`
45
+ * one — and a seeded gate that needs one of those runs in this repository, where the kit\'s own Vitest
46
+ * config aliases them, and nowhere else. It would fail in every adopter\'s repository, on the first
47
+ * `vitest run` after scaffolding, over a file they had not touched. The stub above keeps that module
48
+ * out of the graph today; this one is what keeps this file green if it is ever narrowed. The projection
49
+ * is stubbed disabled, which is the shape a project that never composed `i18n` projects.
50
+ */
51
+
52
+ /** The id the document's mount node carries. Not the one anything renders into, on purpose. */
53
+ const CANARY_ID = "pithy-gate-canary-not-the-mount-id";
54
+
55
+ /** What the stubbed router draws. Finding it in the document is the whole assertion. */
56
+ const MOUNTED = "pithy-gate-canary-mounted";
57
+
58
+ /** The attribute the stubbed provider marks its subtree with. Invented here, like every canary above. */
59
+ const WRAPPED = "data-pithy-gate-locale";
60
+
61
+ vi.mock("./router", () => ({ Router: () => MOUNTED }));
62
+
63
+ vi.mock("./pithy-locale", () => ({
64
+ PithyLocale: ({ children }: { children: ReactNode }) => <div {...{ [WRAPPED]: "" }}>{children}</div>,
65
+ }));
66
+
67
+ vi.mock("./pithy-config", () => ({ i18nConfig: { enabled: false } }));
68
+
69
+ // React refuses to run `act` unless the environment says it is a test one.
70
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
71
+
72
+ test("the app mounts even though no node in the document carries the id it once looked for", async () => {
73
+ // The canary must not have drifted onto the real value: an id of "root" would pass against the bug.
74
+ expect(CANARY_ID).not.toBe("root");
75
+
76
+ document.body.innerHTML = `<div id="${CANARY_ID}"></div>`;
77
+
78
+ // Imported inside the case, because importing it is what mounts.
79
+ await act(async () => {
80
+ await import("./client");
81
+ });
82
+
83
+ expect(document.body.textContent, "nothing rendered — client.tsx did not mount into a node of its own").toContain(
84
+ MOUNTED,
85
+ );
86
+ // And it did not mount into the adopter's element. The node it renders into is its own.
87
+ expect(document.getElementById(CANARY_ID)?.textContent).toBe("");
88
+
89
+ // The app is under a translator, and this is the only place that is true of. Delete `<PithyLocale>`
90
+ // from `client.tsx` and every screen renders English under whatever language the document declares —
91
+ // with nothing else in the repository noticing, which is how it got shipped once already.
92
+ const wrapped = document.querySelector(`[${WRAPPED}]`);
93
+ expect(wrapped, "client.tsx renders no <PithyLocale> — the app mounts with no translator over it").not.toBeNull();
94
+ // Inside it, not beside it. A provider rendered as a sibling of the app translates nothing.
95
+ expect(wrapped?.textContent, "the app is not inside <PithyLocale> — nothing under it reads a catalog").toContain(
96
+ MOUNTED,
97
+ );
98
+ });
@@ -0,0 +1,51 @@
1
+ import { StrictMode } from "react";
2
+ import { createRoot } from "react-dom/client";
3
+ import { PithyLocale } from "./pithy-locale";
4
+ import { Router } from "./router";
5
+ // Yours, and Pithy's. `styles.css` is written once and then belongs to you; `pithy-screens.css` carries
6
+ // the classes Pithy's own screens render, in a cascade layer, so anything you write here wins over it.
7
+ // Pithy's screens import it themselves as well — that is what keeps them styled when they are added to
8
+ // a project whose `client.tsx` was written before they were.
9
+ import "./styles.css";
10
+ import "./pithy-screens.css";
11
+
12
+ /**
13
+ * The mount node, created here rather than found in `index.html`.
14
+ *
15
+ * **This is the whole of the mount contract, and it is one statement.** It used to be two: a
16
+ * `<div id="root">` in `index.html` and a `getElementById("root")` here, with an `if (container)`
17
+ * around the render. Renaming that div — an ordinary thing to do to your own HTML — made the app
18
+ * render nothing, throw nothing and log nothing: an empty document with a 200, a clean build and a
19
+ * green suite (#394). The guard was the defect; deleting it alone would have made the same rename an
20
+ * immediate error, and not having the second string at all means there is no rename to make.
21
+ *
22
+ * The id is still set, so `#root` works as a styling hook. Nothing reads it.
23
+ *
24
+ * Put whatever you like in `index.html`'s `<body>` — a splash, a `<noscript>` — and the app mounts
25
+ * after it. If you need it somewhere else in the document, append it there; this is ordinary DOM code,
26
+ * not a convention. The `<script type="module">` that loads this file is deferred, so `document.body`
27
+ * exists by the time this runs.
28
+ */
29
+ const container = document.body.appendChild(document.createElement("div"));
30
+ container.id = "root";
31
+
32
+ /**
33
+ * The app, in one language.
34
+ *
35
+ * `<PithyLocale>` is the whole of the i18n wiring, and it is one component rather than a call here
36
+ * because `lang` on the document and the words on the page have to come from a **single** resolved
37
+ * locale. They used to come from two places: this file negotiated a locale and set `lang` from it, and
38
+ * nothing mounted a translator at all — so a Spanish reader met `<html lang="es">` over a page rendering
39
+ * English, which is worse for a screen reader than never having negotiated. `src/pithy-locale.tsx` holds
40
+ * the argument in full.
41
+ *
42
+ * With no i18n capability composed it renders its children untouched, and every screen reads the English
43
+ * it was scaffolded with. Your own catalogs go on it: `<PithyLocale messages={{ fr: … }}>`.
44
+ */
45
+ createRoot(container).render(
46
+ <StrictMode>
47
+ <PithyLocale>
48
+ <Router />
49
+ </PithyLocale>
50
+ </StrictMode>,
51
+ );
@@ -0,0 +1,147 @@
1
+ import type { Translator } from "@pithy-sh/core/src/i18n/translator";
2
+ import { getEntitlements, type PaymentsFailure } from "@pithy-sh/payments/src/client/api";
3
+ import type { PriceVisitor } from "@pithy-sh/payments/src/pricing/location";
4
+ import { fetchPriceVisitor, type PriceVisitorOptions } from "@pithy-sh/payments/src/pricing/visitor";
5
+ import { useEffect, useState } from "react";
6
+ import { paymentsConfig } from "./pithy-config";
7
+
8
+ /**
9
+ * The thin bridge between the narrowed projection and the package's headless hooks.
10
+ *
11
+ * Two things live here and nothing else. {@link paymentsClient} binds every call to this project's own
12
+ * base path, once, so a screen never repeats it — and because it is a module constant it is a stable
13
+ * reference, which keeps the hooks' effects from re-running on every render.
14
+ *
15
+ * {@link holdsEntitlement} is the data half of the router's entitlement guard. The guard is a **UX
16
+ * affordance, never a security boundary**: the server's `requireEntitlement()` is the boundary, and this
17
+ * exists so a visitor without `pro` lands on the paywall instead of watching a screen 403. Anything this
18
+ * answers wrongly costs a redirect, never access.
19
+ *
20
+ * Everything the purchase flow actually does — the calls, the redirect-and-return dance, the error
21
+ * mapping — lives in `@pithy-sh/payments`, not here. That split is deliberate: this file is written once
22
+ * and is yours from then on, and store rules move. A paywall frozen in an adopter's repo is one Pithy
23
+ * cannot fix; a paywall that calls the package's hooks upgrades with a minor release.
24
+ */
25
+
26
+ /** Where the payments routes are, bound once. Pass it to every hook. */
27
+ export const paymentsClient = { basePath: paymentsConfig.basePath };
28
+
29
+ /**
30
+ * The class Paddle renders an inline checkout into.
31
+ *
32
+ * A class name, not an id — that is Paddle's `frameTarget` contract. The container is rendered only when
33
+ * the handoff asks for it, from `paddle.checkout` in your config: switch that to `inline` and the form
34
+ * appears in the page instead of over it, with no edit to any screen.
35
+ *
36
+ * **It is here because two screens sell.** The paywall and the pricing page each declared their own copy
37
+ * of this string, and the class is the one thing about it you are meant to act on — `.pithy-checkout` is
38
+ * an adopter hook, deliberately styled by no stylesheet Pithy ships, so that the frame lands where your
39
+ * layout wants it. A styling instruction against two names that agree today is one that styles half your
40
+ * checkouts the day somebody renames one (#391, item E).
41
+ *
42
+ * That is also why the unstyled report never mentions it: it has no rule anywhere on purpose, and a
43
+ * class read out of an identifier is not a literal that report can see. Both facts are intended.
44
+ */
45
+ export const CHECKOUT_FRAME = "pithy-checkout";
46
+
47
+ /**
48
+ * What Paddle.js starts with, or null when this project has no Paddle rail.
49
+ *
50
+ * Null rather than absent, because every hook that takes it takes null and reads it as "nothing to
51
+ * load". That is what keeps a pricing screen free of a conditional hook call, and it is why the rail
52
+ * being switched off is an empty state rather than an error about a provider nobody asked for.
53
+ *
54
+ * **The client token is the only credential in this bundle, and it belongs here.** Paddle publishes it
55
+ * for exactly this — a browser opens a checkout with it. The API key and the webhook signing secret are
56
+ * in the secrets store and are not expressible in a projection.
57
+ */
58
+ export const paddleSetup = paymentsConfig.paddle;
59
+
60
+ /**
61
+ * What a refusal from the store reads as, in the reader's language.
62
+ *
63
+ * **A `PaymentsFailure` carries a namespaced `code`, and the code is the catalog key.** Every screen
64
+ * here used to render `failure.message` — the sentence the server put on the wire, which is English and
65
+ * is only ever English, because `message` is written at the throw site in the one language a throw site
66
+ * has. So a reader on an otherwise-Spanish page met English the moment anything went wrong, which is
67
+ * the moment copy matters most and the moment nobody tests.
68
+ *
69
+ * This is the contract `docs/I18N.md` § *Errors* states, and the reason it is `maybe` rather than `t`:
70
+ * `t` is total, so a code no catalog covers would render as the code itself — `payments/product_not_found`
71
+ * on a buyer's screen, in place of the English sentence the server took care to send. `maybe` answers
72
+ * null on a miss, and the `??` is what makes the server's own words the floor.
73
+ *
74
+ * So a project that never composed `i18n` renders exactly what it always did, byte for byte: the baked
75
+ * translator holds only the screen's own English, no key matches a `payments/…` code, and the message
76
+ * comes straight through. The `client/…` sentinels this package mints for a browser that is offline or
77
+ * an answer it cannot read take the same path, and their English is likewise unchanged.
78
+ *
79
+ * It lives here, with `CHECKOUT_FRAME`, for the same reason that does: three screens render a failure,
80
+ * and three copies of one lookup is two screens that keep saying it in English the day somebody fixes
81
+ * the third.
82
+ */
83
+ export function failureText(t: Translator, failure: PaymentsFailure): string {
84
+ // `params` and not just the code: `interpolate` leaves an unsupplied placeholder exactly as written,
85
+ // so a translated sentence that names one would render `{board}` on the screen — worse than the
86
+ // English it replaced. `docs/I18N.md` states the contract as `t.maybe(code, params) ?? message`.
87
+ return t.maybe(failure.code, failure.params) ?? failure.message;
88
+ }
89
+
90
+ /**
91
+ * Whether the visitor holds `key` right now.
92
+ *
93
+ * "The visitor" is shorthand for whoever the server says this session holds entitlements for — the person
94
+ * under a project that bills users, the organization they are acting for under one that bills
95
+ * organizations. Nothing here names either, and nothing here should: the read carries the session and the
96
+ * server decides the holder, so no argument this file could pass would be one it was entitled to choose.
97
+ *
98
+ * `true` when payments is not composed, which is the same direction the session guard takes when there is
99
+ * no auth: a guard that arrives with a capability must not lock a screen when the capability is gone. The
100
+ * server is what protects the feature.
101
+ *
102
+ * A read that fails answers `false` — this is a route guard, so it is a lock, and a lock fails shut. The
103
+ * choice is written here rather than inherited from the reader: a caller that *named* the visitor's plan
104
+ * would need the opposite, and `getEntitlements` hands both callers the same honest answer.
105
+ */
106
+ export async function holdsEntitlement(key: string): Promise<boolean> {
107
+ if (!paymentsConfig.enabled) return true;
108
+ const held = await getEntitlements(paymentsClient);
109
+ return held.ok && held.value.some((entitlement) => entitlement.key === key && entitlement.granted);
110
+ }
111
+
112
+ /**
113
+ * Who Paddle prices this visitor as, once there is a session to ask about.
114
+ *
115
+ * The customer it answers is the store customer for whoever the session holds purchases for — a person, or
116
+ * the organization they buy on behalf of — read on the server from that session and never named here.
117
+ *
118
+ * **Two renders, and that is the design rather than a flicker to hide.** A pricing screen paints before
119
+ * the session resolves, so the first figure is the IP estimate — labelled `Estimated.`, because it is —
120
+ * and the second is the price the checkout will charge, from the billing address on file. Every checkout
121
+ * on the web recalculates when the address arrives; the only thing that makes it a broken promise is a
122
+ * first figure that did not admit what it was.
123
+ *
124
+ * `ask` is false for a stranger and while the session is still being read, so a marketing page makes no
125
+ * round trip for an answer known in advance to be "nobody".
126
+ *
127
+ * The reading and the guarding live in `@pithy-sh/payments`, not here — this file is yours from the day
128
+ * it is written, and where a customer id comes from is not a thing to freeze into your repository. A
129
+ * failed read answers null, which quotes from the IP and says so.
130
+ */
131
+ export function usePriceVisitor(ask: boolean, options?: PriceVisitorOptions): PriceVisitor | null {
132
+ const [visitor, setVisitor] = useState<PriceVisitor | null>(null);
133
+
134
+ useEffect(() => {
135
+ if (!ask || !paymentsConfig.enabled) return;
136
+ let live = true;
137
+ void fetchPriceVisitor(options).then((answer) => {
138
+ if (live) setVisitor(answer);
139
+ });
140
+ return () => {
141
+ live = false;
142
+ };
143
+ // `options` is `paymentsClient`, a module constant, so this asks once per session resolution.
144
+ }, [ask, options]);
145
+
146
+ return visitor;
147
+ }
@@ -0,0 +1,93 @@
1
+ import authModule from "virtual:pithy/auth";
2
+ import i18nModule from "virtual:pithy/i18n";
3
+ import paymentsModule from "virtual:pithy/payments";
4
+ import turnstileModule from "virtual:pithy/turnstile";
5
+
6
+ /**
7
+ * The composed capabilities' client-safe config, narrowed once for the whole client.
8
+ *
9
+ * `virtual:pithy/<capability>` is a union discriminated on `enabled`, because a capability that is
10
+ * not composed projects `{ enabled: false }` and nothing else — that is what makes importing an
11
+ * absent capability safe instead of a build error. Narrowing at every use would put a guard in front
12
+ * of every field read, so it happens here instead, once.
13
+ *
14
+ * The `enabled: false` branch carries the capability's own defaults rather than `undefined`, so a
15
+ * screen reads a field without a guard. Those defaults are not a fallback anyone should rely on:
16
+ * `pithy ui add --auth` refuses a Worker that does not compose `auth`, so on a scaffolded project the
17
+ * enabled branch is always the live one. They exist for the case where the capability is removed from
18
+ * `pithy.config.ts` *after* the scaffold — the screens keep compiling, and `enabled` is there so one
19
+ * can bail cleanly rather than render a form that posts nowhere.
20
+ */
21
+ export const authConfig = authModule.enabled
22
+ ? authModule
23
+ : {
24
+ enabled: false as const,
25
+ basePath: "/auth",
26
+ providers: { google: false, apple: false, facebook: false, github: false },
27
+ otpLength: 6,
28
+ signUpEnabled: true,
29
+ };
30
+
31
+ /** Turnstile's projection, narrowed the same way. `enabled` is false unless a login widget resolved. */
32
+ export const turnstileConfig = turnstileModule.enabled
33
+ ? turnstileModule
34
+ : {
35
+ enabled: false as const,
36
+ sitekey: "",
37
+ // Blank for the same reason the sitekey is: a widget that cannot render has no action to solve
38
+ // for, and a plausible-looking default here would be a second copy of a string the enabled branch
39
+ // is the only statement of (#377).
40
+ action: "",
41
+ mode: "visible" as const,
42
+ token: { field: "cf-turnstile-response", header: null },
43
+ };
44
+
45
+ /**
46
+ * Payments' projection, narrowed the same way. `enabled` is false when the capability is not composed, and
47
+ * also when it is composed with an empty catalog — a paywall with nothing on it is nothing to render, so
48
+ * the two read alike and a screen branches once.
49
+ *
50
+ * Only the browser-safe half of the catalog is here. Apple's issuer id, Google's service-account
51
+ * credentials and Stripe's secret and signing keys live in the secrets store and never enter a projection.
52
+ */
53
+ export const paymentsConfig = paymentsModule.enabled
54
+ ? paymentsModule
55
+ : {
56
+ enabled: false as const,
57
+ environment: "dev",
58
+ rails: { apple: false, google: false, stripe: false, lemonSqueezy: false, paddle: false },
59
+ basePath: "/payments",
60
+ paddle: null as { clientToken: string; environment: "sandbox" | "production"; checkout: string } | null,
61
+ products: [] as {
62
+ id: string;
63
+ type: "consumable" | "non_consumable" | "subscription";
64
+ entitlements: string[];
65
+ name: string;
66
+ skus: { stripe: string | null; lemonSqueezy: string | null; paddle: string | null };
67
+ }[],
68
+ };
69
+
70
+ /**
71
+ * The i18n capability's projection, narrowed the same way. `enabled` is false unless the capability is
72
+ * composed, and then every screen renders the English it was scaffolded with — which is the whole of
73
+ * what makes this capability optional.
74
+ *
75
+ * **Locale metadata only. Never catalogs.** The projection is inlined into the main chunk, so a catalog
76
+ * carried here would be downloaded by every reader in every language before the first paint. Catalogs
77
+ * arrive by dynamic import, one chunk per locale, from `@pithy-sh/i18n`.
78
+ *
79
+ * The disabled branch is the kit's own defaults, for the reason the three above carry theirs: a screen
80
+ * reads a field without a guard. `en` is not a claim that this project speaks English — it is what the
81
+ * templates are written in, and it is what `t()` falls back to when nothing negotiated.
82
+ */
83
+ export const i18nConfig = i18nModule.enabled
84
+ ? i18nModule
85
+ : {
86
+ enabled: false as const,
87
+ supportedLocales: ["en"],
88
+ defaultLocale: "en",
89
+ queryParam: "lang",
90
+ storageKey: "pithy.locale",
91
+ browserResolvers: ["query", "account", "storage", "navigator", "server", "default"],
92
+ exceptions: {} as Record<string, string>,
93
+ };
@@ -0,0 +1,140 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { KIT_CATALOGS } from "@pithy-sh/i18n/src/catalogs/kit";
4
+ import { useTranslator } from "@pithy-sh/i18n/src/react/translator";
5
+ import { act, type ReactNode } from "react";
6
+ import { createRoot } from "react-dom/client";
7
+ import { expect, test, vi } from "vitest";
8
+
9
+ /**
10
+ * **The document may not declare a language the page does not speak.**
11
+ *
12
+ * `client.tsx` negotiated a locale and put it on `<html lang>`, and nothing mounted a translator behind
13
+ * it. A project composing `i18n` therefore served `<html lang="es">` over a page rendering the English
14
+ * every screen bakes — and that is worse than leaving `lang="en"` alone, because assistive technology
15
+ * believes the attribute: a screen reader switches voice and pronounces English with Spanish phonetics.
16
+ *
17
+ * `src/pithy-locale.tsx` is what makes the two one statement, and this is the gate that keeps them one
18
+ * after the file is yours. Both halves are asserted from a single mount, which is the whole point — a
19
+ * test that checked them separately would pass on a page where each was independently reasonable.
20
+ *
21
+ * ## What this proves, and how it goes red
22
+ *
23
+ * The projection is mocked to negotiate a locale the kit has a real translation for, and a probe screen
24
+ * is rendered underneath with **invented** English baked into it. The probe's rendered text must be the
25
+ * kit's Spanish and not the English it carries, and `document.documentElement.lang` must be the same
26
+ * locale that produced those words. Delete the provider and the probe renders its baked English; delete
27
+ * the negotiation and `lang` never moves. Either way this goes red.
28
+ *
29
+ * The invented English is what makes the first assertion mean anything: a plausible sentence could
30
+ * coincide with a catalog entry, and then a page that mounted no provider at all would still pass.
31
+ *
32
+ * `./pithy-config` is mocked for the reason `src/turnstile.test.tsx` and `src/routes/pithy/sign-in.test.tsx`
33
+ * mock it — it imports the `virtual:pithy/*` modules, which only a Vite build serves. Mocked here it is
34
+ * also the input: this is the one gate whose subject is what the projection says.
35
+ */
36
+
37
+ /** The locale the mocked projection negotiates. Not `en`, on purpose — see below. */
38
+ const CANARY_LOCALE = "es";
39
+
40
+ /** The key the probe renders. Base-group copy: `router.tsx` says it while a route loads. */
41
+ const KEY = "app/loading";
42
+
43
+ /** What the probe bakes for {@link KEY}. Invented here, so no catalog can answer with it by accident. */
44
+ const CANARY_ENGLISH = "pithy-gate-canary-untranslated";
45
+
46
+ vi.mock("./pithy-config", () => ({
47
+ i18nConfig: {
48
+ enabled: true,
49
+ supportedLocales: [CANARY_LOCALE],
50
+ defaultLocale: CANARY_LOCALE,
51
+ queryParam: "lang",
52
+ storageKey: "pithy.locale",
53
+ // Only the link that cannot read a global, so this case is about the wiring and not about whatever
54
+ // a test environment happens to have in `localStorage` or on the URL.
55
+ browserResolvers: ["default"],
56
+ exceptions: {},
57
+ },
58
+ }));
59
+
60
+ // React refuses to run `act` unless the environment says it is a test one.
61
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
62
+
63
+ /**
64
+ * How long the catalog gets to land, and how long each poll waits.
65
+ *
66
+ * **A deadline in milliseconds, waited on — not a fixed number of turns, and not no wait at all.** The
67
+ * catalog arrives by dynamic `import()`, so how long it takes is the machine's answer and not this
68
+ * file's: a cold import plus its transform under a loaded CI runner is orders of magnitude slower than
69
+ * a warm one, and this file is copied into every adopter's repository, where the first `vitest run`
70
+ * after scaffolding is the coldest run that will ever happen there. A gate that reds on a file the
71
+ * adopter never wrote is a gate they delete.
72
+ *
73
+ * Ten seconds is hundreds of times the measured cold cost, and the case below states its own 30s
74
+ * timeout so the deadline is what reports the failure rather than Vitest's default five — which the
75
+ * kit raises for its own suites and an adopter's runner does not.
76
+ */
77
+ const SETTLE_DEADLINE_MS = 10_000;
78
+ const SETTLE_POLL_MS = 5;
79
+ const CASE_TIMEOUT_MS = 30_000;
80
+
81
+ /**
82
+ * Wait until the translator has mounted, rather than assuming it has by now.
83
+ *
84
+ * The transition **is** the signal: until the catalog lands the tree renders {@link CANARY_ENGLISH},
85
+ * and the whole assertion is that it stops. Polling on a macrotask rather than flushing microtasks,
86
+ * because `loadKitCatalog` is a real `import()` and Vitest resolves that off the module graph rather
87
+ * than out of the current tick.
88
+ */
89
+ async function untilTranslated(container: HTMLElement): Promise<void> {
90
+ const deadline = Date.now() + SETTLE_DEADLINE_MS;
91
+ while (container.textContent === CANARY_ENGLISH) {
92
+ if (Date.now() > deadline) {
93
+ throw new Error(
94
+ `The tree still renders its baked English after ${SETTLE_DEADLINE_MS}ms — no translator was mounted over it.`,
95
+ );
96
+ }
97
+ await act(async () => {
98
+ await new Promise((resume) => setTimeout(resume, SETTLE_POLL_MS));
99
+ });
100
+ }
101
+ }
102
+
103
+ /** A screen, as every seeded screen is written: one key, and the English it was scaffolded with. */
104
+ function Probe(): ReactNode {
105
+ const t = useTranslator({ [KEY]: CANARY_ENGLISH });
106
+ return t.t(KEY);
107
+ }
108
+
109
+ test(
110
+ "the page renders the language the document declares",
111
+ async () => {
112
+ // The canary, refused. Run this at `en` and it passes against the exact defect it exists to catch:
113
+ // a page with no provider at all renders English, and `lang="en"` agrees with it.
114
+ expect(CANARY_LOCALE).not.toBe("en");
115
+ // And the kit really translates the key, so "not the baked English" is a reachable state.
116
+ const translated = KIT_CATALOGS[CANARY_LOCALE]?.[KEY];
117
+ expect(translated, `the kit ships no ${CANARY_LOCALE} for ${KEY}`).toBeTypeOf("string");
118
+ expect(translated).not.toBe(CANARY_ENGLISH);
119
+
120
+ const { PithyLocale } = await import("./pithy-locale");
121
+ const container = document.body.appendChild(document.createElement("div"));
122
+ await act(async () => {
123
+ createRoot(container).render(
124
+ <PithyLocale>
125
+ <Probe />
126
+ </PithyLocale>,
127
+ );
128
+ });
129
+
130
+ // The catalog arrives by dynamic import, so the provider mounts some time after the first paint —
131
+ // how long is the machine's answer, not this file's. Waited on rather than counted out.
132
+ await untilTranslated(container);
133
+
134
+ expect(container.textContent, "the tree rendered its baked English — no translator was mounted over it").toBe(
135
+ translated,
136
+ );
137
+ expect(document.documentElement.lang, "the document declares a language nothing negotiated").toBe(CANARY_LOCALE);
138
+ },
139
+ CASE_TIMEOUT_MS,
140
+ );