@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.
- package/LICENSE +21 -0
- package/package.json +47 -0
- package/src/templates.ts +147 -0
- package/src/testing/virtualAuth.ts +12 -0
- package/src/testing/virtualI18n.ts +16 -0
- package/src/testing/virtualPayments.ts +5 -0
- package/src/testing/virtualTurnstile.ts +5 -0
- package/templates/client-env.d.ts +299 -0
- package/templates/index.html +14 -0
- package/templates/src/client.test.tsx +98 -0
- package/templates/src/client.tsx +51 -0
- package/templates/src/payments.tsx +147 -0
- package/templates/src/pithy-config.tsx +93 -0
- package/templates/src/pithy-locale.test.tsx +140 -0
- package/templates/src/pithy-locale.tsx +134 -0
- package/templates/src/pithy-screens.css +379 -0
- package/templates/src/router.test.tsx +63 -0
- package/templates/src/router.tsx +618 -0
- package/templates/src/routes/app/home.bare.tsx +96 -0
- package/templates/src/routes/app/home.tsx +41 -0
- package/templates/src/routes/pithy/callback.tsx +42 -0
- package/templates/src/routes/pithy/otp.tsx +127 -0
- package/templates/src/routes/pithy/paywall.tsx +160 -0
- package/templates/src/routes/pithy/pricing.tsx +312 -0
- package/templates/src/routes/pithy/sign-in.test.tsx +116 -0
- package/templates/src/routes/pithy/sign-in.tsx +470 -0
- package/templates/src/routes/pithy/subscription.tsx +183 -0
- package/templates/src/session.tsx +78 -0
- package/templates/src/styles.css +53 -0
- package/templates/src/turnstile.test.tsx +119 -0
- package/templates/src/turnstile.tsx +105 -0
- package/templates/tsconfig.client.json +28 -0
- package/templates/tsconfig.node.json +22 -0
- 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
|
+
});
|