@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,134 @@
1
+ import type { MessageCatalog } from "@pithy-sh/core/src/i18n/catalog";
2
+ import type { LocaleContext } from "@pithy-sh/core/src/i18n/locale";
3
+ import { applyProjectedLocale } from "@pithy-sh/i18n/src/browser/document";
4
+ import { loadKitCatalog } from "@pithy-sh/i18n/src/catalogs/browser";
5
+ import { TranslatorProvider } from "@pithy-sh/i18n/src/react/translator";
6
+ import { type ReactNode, useEffect, useState } from "react";
7
+ import { i18nConfig } from "./pithy-config";
8
+
9
+ /**
10
+ * The language this document is in — declared on `<html>` and rendered by every screen under it, from
11
+ * **one** value.
12
+ *
13
+ * ## Why this file exists at all
14
+ *
15
+ * `client.tsx` used to call `applyProjectedLocale` and stop there. That negotiates a locale and writes
16
+ * `lang` and `dir` onto the document, and nothing mounted a translator behind it — so a project that
17
+ * composed `i18n` and met a Spanish reader served `<html lang="es">` over a page rendering the English
18
+ * every screen bakes. That is **worse than never having negotiated at all**: a screen reader believes
19
+ * the attribute, switches voice, and pronounces English words with Spanish phonetics. The page is
20
+ * unreadable to the one reader the attribute exists for.
21
+ *
22
+ * The fix is the shape rather than an extra call. {@link locale} is resolved once, and the same value
23
+ * both goes on the document and selects the catalog this provider mounts. There is no second statement
24
+ * of what language the page is in, so the two cannot disagree.
25
+ *
26
+ * ## What it does when nothing is composed
27
+ *
28
+ * `applyProjectedLocale` answers `null` for a project with no `i18n` capability. Then this renders its
29
+ * children untouched — no provider, no catalog fetched, no chunk downloaded — and every screen reads
30
+ * the English it was scaffolded with, byte for byte. The document keeps the `lang` `index.html`
31
+ * declared. Removing the capability puts the app back exactly where it started.
32
+ */
33
+ export const locale: LocaleContext | null = applyProjectedLocale(i18nConfig);
34
+
35
+ /** What {@link PithyLocale} takes: the tree to render, and your own catalogs if you have any. */
36
+ export interface PithyLocaleProps {
37
+ /** The app. Rendered with a translator over it, or untouched when no locale was negotiated. */
38
+ readonly children: ReactNode;
39
+ /**
40
+ * Your own catalogs, keyed by locale — the **top** layer, above the kit's translation and above each
41
+ * screen's baked English.
42
+ *
43
+ * This is where a sentence you want said differently goes, and where a language the kit writes
44
+ * nothing in gets its words: `loadKitCatalog` answers an empty catalog for a locale the kit has no
45
+ * translation for, so `fr` without an entry here renders English under `lang="fr"`. One key is one
46
+ * entry; everything you do not mention keeps flowing from the package.
47
+ */
48
+ readonly messages?: Readonly<Record<string, MessageCatalog | undefined>>;
49
+ }
50
+
51
+ /**
52
+ * Mount the negotiated locale over the app.
53
+ *
54
+ * The catalog arrives by dynamic import, one chunk per locale, so a reader downloads only their own
55
+ * language. Until it lands the children render their baked English — which is the right thing to show
56
+ * for the handful of milliseconds involved, and is exactly what a reader in the default locale sees
57
+ * permanently.
58
+ *
59
+ * **This negotiates at module scope, not through `useNegotiatedLocale`, and the reason is timing.**
60
+ * The hook takes the same `virtual:pithy/i18n` projection this file passes to `applyProjectedLocale`,
61
+ * so there is no shape to convert — but a hook cannot run before the first paint. {@link locale} is
62
+ * resolved as this module loads, which is the difference between a screen that was always in its
63
+ * language and one that changed into it, and it is what keeps a right-to-left reader from watching the
64
+ * page reflow.
65
+ *
66
+ * Reach for the hook alongside this when your app has a session to offer it: a signed-in reader's
67
+ * stored locale outranks this device's memory, and only you know who is signed in. Give it the same
68
+ * `i18nConfig` and the same `messages`, pass `persist` so their choice follows them to their next
69
+ * device, and use its `choose` for a language control. `messages` goes to both because the hook's
70
+ * provider shadows this one rather than merging with it: an override passed only here vanishes the
71
+ * moment that provider mounts. This pre-paint pass stays where it is either way — the hook writes
72
+ * `lang` and `dir` too, one render later.
73
+ *
74
+ * **Running both is two providers and two catalog loads, so mount the hook's provider *inside* this
75
+ * one.** Nothing merges them: this component mounts a `TranslatorProvider` around its children, the
76
+ * hook hands you a `source` you mount in a second one, and every screen reads whichever provider is
77
+ * nearer to it. Inside, the hook's is nearer, so `choose` changes the words on screen. Outside —
78
+ * wrapping `<PithyLocale>` — this one shadows it, and a reader who picks a language gets a fresh `lang`
79
+ * on the document over the words of the locale that was negotiated at load: the same mismatch the top
80
+ * of this file exists to prevent.
81
+ *
82
+ * The second `loadKitCatalog` is cheap where the two agree — same locale, and its chunk is already in
83
+ * the module registry, so the call resolves off it rather than fetching again. It is a real second
84
+ * download when the hook lands somewhere this pass did not, and there is more than one way that
85
+ * happens. A signed-in reader's `account` locale outranks the device's memory, and only the hook is
86
+ * handed it. `choose` moves past whatever either negotiated. And the two read `navigator` differently:
87
+ * this pass offers the reader's first language alone, to stay ahead of the paint, while the hook walks
88
+ * the whole weighted list — so a first language you do not ship and a second you do can part them with
89
+ * no session involved. Landing somewhere else is the whole reason to run the hook at all.
90
+ */
91
+ export function PithyLocale({ children, messages }: PithyLocaleProps): ReactNode {
92
+ const [kit, setKit] = useState<MessageCatalog | null>(null);
93
+
94
+ useEffect(() => {
95
+ let live = true;
96
+ if (locale !== null) {
97
+ void loadKitCatalog(locale.catalogLocale)
98
+ .then((catalog) => {
99
+ if (live) setKit(catalog);
100
+ })
101
+ // A per-locale chunk that will not load — a 404 after a deploy, an offline tab — must not leave
102
+ // the provider unmounted forever with `lang` already changed on the document. That is the
103
+ // "declares a language it does not speak" failure by another road: a screen reader believes the
104
+ // attribute and mispronounces English. An empty catalog mounts the provider, so every screen
105
+ // falls through to the English it was scaffolded with, which is what is actually on screen.
106
+ .catch(() => {
107
+ if (live) setKit({});
108
+ });
109
+ }
110
+ return () => {
111
+ live = false;
112
+ };
113
+ }, []);
114
+
115
+ if (locale === null || kit === null) return children;
116
+ return (
117
+ <TranslatorProvider
118
+ value={{
119
+ // The tag `lang` carries, and the words the kit has.
120
+ catalogLocale: locale.catalogLocale,
121
+ // The tag the reader actually asked for, region and all — `es-AR` where `catalogLocale` is
122
+ // `es`. `Intl` supports it natively whether or not anyone wrote a string for it, so keeping the
123
+ // two apart is what gives Buenos Aires Spanish sentences and Argentine dates. Passing one value
124
+ // to both is the collapse this pair exists to prevent.
125
+ formattingLocale: locale.formattingLocale,
126
+ // Yours first, then the kit's. Each screen's own English is appended by `useTranslator`, last,
127
+ // which is what makes a key nobody translated still render a sentence.
128
+ layers: [messages?.[locale.catalogLocale], kit],
129
+ }}
130
+ >
131
+ {children}
132
+ </TranslatorProvider>
133
+ );
134
+ }
@@ -0,0 +1,379 @@
1
+ /*
2
+ * Pithy's screens, styled. Every class name a Pithy-authored screen renders is defined here, and
3
+ * nowhere else — `src/styles.css` beside it is yours, and this file never depends on it.
4
+ *
5
+ * That split is the whole reason the file exists. `src/styles.css` is written once and then belongs to
6
+ * you, so a project that gains the sign-in screens later — `pithy add auth`, then `pithy ui add react
7
+ * --auth` — gets the screens while its stylesheet is correctly left alone. When the two lived in one
8
+ * file, that backfill wrote a sign-in screen whose `stack`, `divider` and `secondary` nothing defined,
9
+ * and reported it as created. A screen and the rules it needs are one artifact. They ship together.
10
+ *
11
+ * ## Yours wins, whatever the import order
12
+ *
13
+ * Everything below sits in a cascade layer. Unlayered CSS beats layered CSS regardless of order or
14
+ * specificity, so a rule in your own stylesheet overrides one here with no `!important` and no regard
15
+ * for which file the bundler emitted first.
16
+ *
17
+ * ## Restyle by declaring the tokens
18
+ *
19
+ * The palette is seven custom properties, each read with a fallback. Declare `--fg`, `--bg`, `--surface`,
20
+ * `--fg-muted`, `--border`, `--accent` or `--danger` on `:root` in your own stylesheet and these screens
21
+ * adopt your colours; declare none and they stand up on their own, light or dark. **Declare them as a set.** A
22
+ * screen where the background is yours and the text on it is Pithy's is the one way this file can still
23
+ * produce something unreadable, and no fallback can detect it.
24
+ *
25
+ * The defaults follow `prefers-color-scheme`, so a project whose stylesheet predates this file is not
26
+ * handed a white input in a dark app.
27
+ */
28
+
29
+ @layer pithy {
30
+ :root {
31
+ --pithy-bg: var(--bg, #fafaf6);
32
+ --pithy-surface: var(--surface, #ffffff);
33
+ --pithy-fg: var(--fg, #111111);
34
+ --pithy-fg-muted: var(--fg-muted, #5f5d57);
35
+ --pithy-border: var(--border, #e5e3db);
36
+ /* Saffron. The one colour that is Pithy's rather than a neutral, so it carries the brand even in a
37
+ * project that has declared nothing. */
38
+ --pithy-accent: var(--accent, #d4a017);
39
+ /* A failure the visitor has to read and act on — never the accent. A refused provider is the one
40
+ * place these screens have to say something went wrong. */
41
+ --pithy-danger: var(--danger, #b3261e);
42
+
43
+ /*
44
+ * Where Turnstile's `flexible` size stops shrinking. Cloudflare's number, not ours.
45
+ *
46
+ * It is a custom property rather than a value typed into the two rules that need it, because the
47
+ * sign-in layout is built *from* it: the form's measure is checked against it and the credentials
48
+ * column's gutter is derived from it. One constant means no two values can be edited into
49
+ * disagreement, and `src/humanityCheckFit.test.ts` in @pithy-sh/ui-react fails if either derivation
50
+ * is replaced by a number.
51
+ */
52
+ --pithy-check-min: 300px;
53
+ /* The form's measure: one width for the field, the widget, the buttons and the provider row. Above
54
+ * --pithy-check-min, and it has to stay there — a narrower column and the widget hangs out of it. */
55
+ --pithy-auth-measure: 24.5rem;
56
+ /* The most the credentials column will pad itself by. Never at the widget's expense; see below. */
57
+ --pithy-auth-gutter: 1.5rem;
58
+ }
59
+
60
+ @media (prefers-color-scheme: dark) {
61
+ :root {
62
+ --pithy-bg: var(--bg, #111111);
63
+ --pithy-surface: var(--surface, #1a1a19);
64
+ --pithy-fg: var(--fg, #fafaf6);
65
+ --pithy-fg-muted: var(--fg-muted, #9a988f);
66
+ --pithy-border: var(--border, #2e2e2b);
67
+ --pithy-danger: var(--danger, #f2b8b5);
68
+ }
69
+ }
70
+
71
+ /*
72
+ * The two roots.
73
+ *
74
+ * `.screen` is a centred single column — the OTP screen, the callback, the paywall, the 404. `.auth`
75
+ * is the sign-in page, which is a full-bleed two-column layout rather than a column, so it cannot
76
+ * carry `.screen` as well. Everything below that styles a bare element is scoped to `:is(.screen,
77
+ * .auth)` for that reason: a field, a label or a button means the same thing on either root, and the
78
+ * alternative was one of the two quietly rendering a browser-default input.
79
+ */
80
+ /* The centred column. */
81
+ .screen {
82
+ margin: 0 auto;
83
+ max-width: 26rem;
84
+ padding: 4rem 1.5rem;
85
+ }
86
+
87
+ :is(.screen, .auth) h1 {
88
+ font-size: 1.75rem;
89
+ letter-spacing: -0.03em;
90
+ margin: 0 0 0.5rem;
91
+ }
92
+
93
+ /* Secondary text: the sentence under a heading, the hint under a field. */
94
+ .muted {
95
+ color: var(--pithy-fg-muted);
96
+ }
97
+
98
+ /* The vertical rhythm every form and provider list is laid out on. */
99
+ .stack {
100
+ display: flex;
101
+ flex-direction: column;
102
+ gap: 0.75rem;
103
+ }
104
+
105
+ :is(.screen, .auth) label {
106
+ display: block;
107
+ font-size: 0.875rem;
108
+ margin-bottom: 0.25rem;
109
+ }
110
+
111
+ /* Background and foreground come from the same pair, never one of each: `color: inherit` over a
112
+ * Pithy background is how a light-themed input ends up with light-themed text written on it. */
113
+ :is(.screen, .auth) input {
114
+ background: var(--pithy-surface);
115
+ border: 1px solid var(--pithy-border);
116
+ border-radius: 0.375rem;
117
+ color: var(--pithy-fg);
118
+ font: inherit;
119
+ padding: 0.625rem 0.75rem;
120
+ width: 100%;
121
+ }
122
+
123
+ :is(.screen, .auth) input:focus-visible {
124
+ border-color: var(--pithy-accent);
125
+ outline: 2px solid var(--pithy-accent);
126
+ outline-offset: 1px;
127
+ }
128
+
129
+ :is(.screen, .auth) button {
130
+ background: var(--pithy-fg);
131
+ border: 1px solid var(--pithy-fg);
132
+ border-radius: 0.375rem;
133
+ color: var(--pithy-bg);
134
+ cursor: pointer;
135
+ font: inherit;
136
+ padding: 0.625rem 0.75rem;
137
+ }
138
+
139
+ :is(.screen, .auth) button:disabled {
140
+ cursor: not-allowed;
141
+ opacity: 0.5;
142
+ }
143
+
144
+ /* The quieter button: a social provider, "use a code instead", "start over". */
145
+ :is(.screen, .auth) button.secondary {
146
+ background: var(--pithy-surface);
147
+ border-color: var(--pithy-border);
148
+ color: var(--pithy-fg);
149
+ }
150
+
151
+ /* The one-time-code row. */
152
+ .otp {
153
+ display: flex;
154
+ gap: 0.5rem;
155
+ }
156
+
157
+ .otp input {
158
+ padding: 0.625rem 0;
159
+ text-align: center;
160
+ }
161
+
162
+ /* The "or" between the email form and the social providers. */
163
+ .divider {
164
+ align-items: center;
165
+ color: var(--pithy-fg-muted);
166
+ display: flex;
167
+ font-size: 0.8125rem;
168
+ gap: 0.75rem;
169
+ margin: 1.5rem 0;
170
+ }
171
+
172
+ .divider::before,
173
+ .divider::after {
174
+ background: var(--pithy-border);
175
+ content: "";
176
+ flex: 1;
177
+ height: 1px;
178
+ }
179
+
180
+ /* ── the sign-in page ─────────────────────────────────────────────────────
181
+ *
182
+ * Two columns: your panel, and the form. Everything here is the form's half. The panel is a slot
183
+ * `routes/pithy/sign-in.tsx` ships empty, so these rules give it a box and a ground and say nothing
184
+ * about what goes in it.
185
+ */
186
+
187
+ /*
188
+ * The split, and the floor under the narrow track.
189
+ *
190
+ * `1fr / 1fr` alone would be wrong, because the credentials track is not free to be any width: the
191
+ * Turnstile widget's `flexible` size has a hard `--pithy-check-min` floor, and below that it stops
192
+ * matching the field and hangs out of the column. So that track's minimum is stated — as the measure
193
+ * plus its own gutters, derived rather than typed, so raising the measure moves the track with it.
194
+ *
195
+ * The two together mean the split is about a third at 1440px and holds until the floor takes over,
196
+ * which is well below where the panel stops rendering.
197
+ */
198
+ .auth {
199
+ display: grid;
200
+ grid-template-columns:
201
+ minmax(0, 1.08fr)
202
+ minmax(calc(var(--pithy-auth-measure) + 2 * var(--pithy-auth-gutter)), 0.92fr);
203
+ min-height: 100dvh;
204
+ }
205
+
206
+ /* An empty slot is one column, not a blank one. The form centres on the page instead of hugging an
207
+ * edge, and a project that never fills the panel still has a finished-looking screen. */
208
+ .auth[data-brand="none"] {
209
+ grid-template-columns: minmax(0, 1fr);
210
+ }
211
+
212
+ /* Your panel's box: a ground, an edge, and room. What is inside it is yours. */
213
+ .auth__brand {
214
+ background: var(--pithy-surface);
215
+ border-inline-end: 1px solid var(--pithy-border);
216
+ display: flex;
217
+ flex-direction: column;
218
+ gap: 1rem;
219
+ justify-content: center;
220
+ padding: 4rem 3rem;
221
+ }
222
+
223
+ /*
224
+ * The column the form is centred in.
225
+ *
226
+ * The inline padding is derived from the widget's floor rather than chosen: it takes
227
+ * `--pithy-auth-gutter` wherever the viewport can afford it, and gives the width back to the column
228
+ * when it cannot. Without that, a fixed gutter on a 320px phone leaves the host under 300px and the
229
+ * widget overflows the one control on the screen that has to be tapped.
230
+ */
231
+ .auth__credentials {
232
+ align-items: center;
233
+ display: flex;
234
+ justify-content: center;
235
+ padding-block: 4rem;
236
+ padding-inline: clamp(0.5rem, calc((100vw - var(--pithy-check-min)) / 2), var(--pithy-auth-gutter));
237
+ }
238
+
239
+ /* The measure: one width for the field, the widget, the buttons and the provider row. `min-width: 0`
240
+ * because this is a flex child — without it the widget's intrinsic width sets the floor for the whole
241
+ * column and pushes the layout wider than a phone. */
242
+ .auth__form {
243
+ display: flex;
244
+ flex-direction: column;
245
+ gap: 1rem;
246
+ max-width: var(--pithy-auth-measure);
247
+ min-width: 0;
248
+ width: 100%;
249
+ }
250
+
251
+ /* The flex gap carries the rhythm here, so the heading brings no margin of its own to argue with it. */
252
+ .auth h1 {
253
+ margin: 0;
254
+ }
255
+
256
+ /* Your mark, in the column you act in. Hidden while the panel is on screen, so exactly one of the two
257
+ * is ever visible; shown at every width when there is no panel at all. */
258
+ .auth__form-mark {
259
+ display: none;
260
+ }
261
+
262
+ .auth[data-brand="none"] .auth__form-mark {
263
+ display: block;
264
+ }
265
+
266
+ /* Two providers side by side rather than stacked: they are one choice taken once, and stacking them
267
+ * pushes the email form — the path that always works — below the fold on a laptop. */
268
+ .auth__providers {
269
+ display: grid;
270
+ gap: 0.75rem;
271
+ grid-template-columns: 1fr 1fr;
272
+ }
273
+
274
+ .auth__provider {
275
+ align-items: center;
276
+ display: inline-flex;
277
+ gap: 0.5rem;
278
+ justify-content: center;
279
+ }
280
+
281
+ /* A mark is a fixed 16px object, never squeezed by the label beside it. */
282
+ .auth__mark {
283
+ flex: none;
284
+ }
285
+
286
+ /* The divider belongs to the flex column's rhythm, not to the centred screen's. */
287
+ .auth .divider {
288
+ margin: 0.25rem 0;
289
+ }
290
+
291
+ /*
292
+ * The widget's host, and the reason the widths line up.
293
+ *
294
+ * Turnstile renders an iframe with an intrinsic size, so `width: 100%` here is only half the answer —
295
+ * the other half is asking the widget for its `flexible` size in `turnstile.tsx`, which is what makes
296
+ * it fill this box instead of sitting at its default 300px. Both are required; either alone leaves a
297
+ * ragged edge beside a full-width input.
298
+ *
299
+ * The widget draws its own edge inside the iframe, so this host draws none — a second border around
300
+ * the first reads as one heavy line beside a field with a light one. `overflow: hidden` is what makes
301
+ * the radius bite: an iframe is a replaced element with square corners.
302
+ */
303
+ .auth__check {
304
+ background: var(--pithy-surface);
305
+ border-radius: 0.375rem;
306
+ overflow: hidden;
307
+ width: 100%;
308
+ }
309
+
310
+ /* No turnstile composed, no empty box in the form. The host renders either way. */
311
+ .auth__check:empty {
312
+ display: none;
313
+ }
314
+
315
+ .auth__check iframe {
316
+ display: block;
317
+ width: 100%;
318
+ }
319
+
320
+ /* Why a provider button did not take you anywhere. Its own colour, never the accent. */
321
+ .auth__failed {
322
+ color: var(--pithy-danger);
323
+ font-size: 0.875rem;
324
+ margin: 0;
325
+ }
326
+
327
+ /*
328
+ * How an account gets made — reassurance, not navigation.
329
+ *
330
+ * Passwordless has no sign-up screen, so this is not a link and must never be styled as one. What it
331
+ * does is remove a worry: you are in the right place even if you have never been here. The answer
332
+ * carries the weight and takes the page's ink to carry it — bold at the muted colour is bold nobody
333
+ * notices.
334
+ */
335
+ .auth__signup {
336
+ color: var(--pithy-fg-muted);
337
+ font-size: 0.875rem;
338
+ margin: 0.5rem 0 0;
339
+ text-align: center;
340
+ }
341
+
342
+ .auth__signup strong {
343
+ color: var(--pithy-fg);
344
+ font-weight: 500;
345
+ }
346
+
347
+ /*
348
+ * Where the panel stops.
349
+ *
350
+ * **It is not stacked below the form — it is not rendered.** Copy under a sign-in form is copy nobody
351
+ * reads with a keyboard open, and it pushes the thing you came to do off the screen. Your mark moves
352
+ * into the form column instead, so the page still carries your identity.
353
+ */
354
+ @media (max-width: 64rem) {
355
+ .auth {
356
+ grid-template-columns: minmax(0, 1fr);
357
+ }
358
+
359
+ .auth__brand {
360
+ display: none;
361
+ }
362
+
363
+ .auth__form-mark {
364
+ display: block;
365
+ }
366
+
367
+ .auth__credentials {
368
+ padding-block: 3rem;
369
+ }
370
+ }
371
+
372
+ /* Two buttons in a phone's width leaves each narrower than its own label, and a truncated provider
373
+ * name is not a control anyone trusts with their account. */
374
+ @media (max-width: 30rem) {
375
+ .auth__providers {
376
+ grid-template-columns: minmax(0, 1fr);
377
+ }
378
+ }
379
+ }
@@ -0,0 +1,63 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { expect, test } from "vitest";
4
+ import { buildRoutes, type RouteModule, type ScreenRole, screenPath } from "./router";
5
+
6
+ /**
7
+ * **A guard's destination is the path the screen declares, never a copy of it.**
8
+ *
9
+ * The router used to hold its own `SIGN_IN_PATH = "/sign-in"` and `PAYWALL_PATH = "/paywall"`. The
10
+ * other end of each was an `export const path` in a different file, typed `string`, with nothing
11
+ * comparing them. Renaming a screen's path — `/sign-in` to `/login`, the most ordinary rebrand there
12
+ * is — typechecked, linted, built, and left the signed-out guard redirecting to the not-found screen.
13
+ *
14
+ * Nobody signed in would ever see it (#393).
15
+ *
16
+ * ## What this proves, and how it goes red
17
+ *
18
+ * The table is built from a screen this file invents, whose path is **deliberately not** any real
19
+ * one. A router holding a literal would still send visitors to `/sign-in`, and this goes red; only one
20
+ * that reads the screen's declared path can pass. The expected value is reachable from nowhere else —
21
+ * asserting the real path would pass against the very drift this catches.
22
+ *
23
+ * The second case is the other half: a role nothing claims must fail loudly rather than send somebody
24
+ * nowhere. A guard with no destination is a screen that never resolves, and silence is what this whole
25
+ * class of defect is made of.
26
+ */
27
+
28
+ /** The path the invented sign-in screen declares. Not the real one, on purpose. */
29
+ const CANARY_SIGN_IN = "/pithy-gate-canary-not-the-sign-in-path";
30
+
31
+ /** The same for the paywall, so one role passing cannot cover for the other. */
32
+ const CANARY_PAYWALL = "/pithy-gate-canary-not-the-paywall-path";
33
+
34
+ /** A screen in the shape `router.tsx` documents: a declared path, a role, and a component. */
35
+ function screen(path: string, role: ScreenRole): () => Promise<RouteModule> {
36
+ return async () => ({ path, role, default: () => null });
37
+ }
38
+
39
+ test("a guard is sent to the path the screen claiming the role declares", async () => {
40
+ // The canaries must not have drifted onto the real paths: either would pass against the bug.
41
+ expect(CANARY_SIGN_IN).not.toBe("/sign-in");
42
+ expect(CANARY_PAYWALL).not.toBe("/paywall");
43
+
44
+ const table = await buildRoutes([screen(CANARY_SIGN_IN, "sign-in"), screen(CANARY_PAYWALL, "paywall")]);
45
+
46
+ expect(screenPath(table, "sign-in")).toBe(CANARY_SIGN_IN);
47
+ expect(screenPath(table, "paywall")).toBe(CANARY_PAYWALL);
48
+ });
49
+
50
+ test("a screen in app/ takes the job over, the same way it takes a pattern over", async () => {
51
+ const mine = "/pithy-gate-canary-my-own-sign-in";
52
+ expect(mine).not.toBe(CANARY_SIGN_IN);
53
+
54
+ // Pithy's screens are registered first, yours second — so yours is the one a guard ends up at.
55
+ const table = await buildRoutes([screen(CANARY_SIGN_IN, "sign-in"), screen(mine, "sign-in")]);
56
+
57
+ expect(screenPath(table, "sign-in")).toBe(mine);
58
+ });
59
+
60
+ test("a role no screen claims is an error with a stack, not a redirect to nowhere", async () => {
61
+ const table = await buildRoutes([]);
62
+ expect(() => screenPath(table, "sign-in")).toThrow(/sign-in/);
63
+ });