@kerne/react 1.1.0 → 1.3.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/README.md +62 -0
- package/dist/chunk-3R66S7BY.cjs +3312 -0
- package/dist/chunk-ZVL47U4B.js +3312 -0
- package/dist/{i18n-QilNgwqe.d.cts → fr-E5mmylSH.d.cts} +135 -3
- package/dist/{i18n-QilNgwqe.d.ts → fr-E5mmylSH.d.ts} +135 -3
- package/dist/index.cjs +51 -27
- package/dist/index.d.cts +61 -41
- package/dist/index.d.ts +61 -41
- package/dist/index.js +44 -20
- package/dist/ui/index.cjs +984 -1906
- package/dist/ui/index.d.cts +42 -96
- package/dist/ui/index.d.ts +42 -96
- package/dist/ui/index.js +1091 -2013
- package/package.json +11 -5
- package/dist/chunk-IZMRK3WK.js +0 -1285
- package/dist/chunk-TYNZ2WU7.cjs +0 -1285
|
@@ -0,0 +1,3312 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/ui/i18n.ts
|
|
4
|
+
import { createContext, useContext, useCallback, createElement, Fragment } from "react";
|
|
5
|
+
var defaultLocalization = {
|
|
6
|
+
common: {
|
|
7
|
+
email: "E-mail",
|
|
8
|
+
emailPlaceholder: "you@company.com",
|
|
9
|
+
password: "Password",
|
|
10
|
+
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
|
|
11
|
+
name: "Name",
|
|
12
|
+
namePlaceholder: "Ada Lovelace",
|
|
13
|
+
cancel: "Cancel",
|
|
14
|
+
continue: "Continue",
|
|
15
|
+
or: "or",
|
|
16
|
+
thisApp: "This app",
|
|
17
|
+
backToSignIn: "Back to sign in",
|
|
18
|
+
sending: "Sending...",
|
|
19
|
+
continueWith: "Continue with {provider}",
|
|
20
|
+
securedByKerne: "Secured by Kerne",
|
|
21
|
+
loading: "Loading",
|
|
22
|
+
showPassword: "Show password",
|
|
23
|
+
hidePassword: "Hide password",
|
|
24
|
+
capsLockOn: "Caps Lock is on",
|
|
25
|
+
passwordHint: "At least {count} characters",
|
|
26
|
+
passwordRequireUppercase: "One uppercase letter",
|
|
27
|
+
passwordRequireNumber: "One number",
|
|
28
|
+
passwordRequireSymbol: "One special character",
|
|
29
|
+
strengthLabel: "Password strength",
|
|
30
|
+
strengthWeak: "Weak",
|
|
31
|
+
strengthFair: "Fair",
|
|
32
|
+
strengthGood: "Good",
|
|
33
|
+
strengthStrong: "Strong"
|
|
34
|
+
},
|
|
35
|
+
signIn: {
|
|
36
|
+
title: "Welcome back",
|
|
37
|
+
signUpPrompt: "First time here?",
|
|
38
|
+
signUpLink: "Sign up for free",
|
|
39
|
+
signUpForApp: "Sign up for {app}",
|
|
40
|
+
submit: "Sign in",
|
|
41
|
+
submitting: "Signing in...",
|
|
42
|
+
magicSubmit: "Send me a sign-in link",
|
|
43
|
+
magicSubmitting: "Sending link...",
|
|
44
|
+
magicSubtitle: "We will email you a link to sign in.",
|
|
45
|
+
forgotPassword: "Forgot password?",
|
|
46
|
+
useMagicLink: "Send me a sign-in link instead",
|
|
47
|
+
usePassword: "Sign in with a password",
|
|
48
|
+
linkSent: "Check your inbox - if an account exists for {email}, a sign-in link is on its way.",
|
|
49
|
+
unavailable: "Sign-in is not available right now. Contact your administrator.",
|
|
50
|
+
loadFailed: "Could not load sign-in options. Please refresh the page."
|
|
51
|
+
},
|
|
52
|
+
signUp: {
|
|
53
|
+
title: "Create your account",
|
|
54
|
+
titleWithApp: "Create your {app} account",
|
|
55
|
+
signInPrompt: "Already registered?",
|
|
56
|
+
signInLink: "Sign in",
|
|
57
|
+
submit: "Create account",
|
|
58
|
+
submitting: "Creating account...",
|
|
59
|
+
magicSubmit: "Send me a sign-up link",
|
|
60
|
+
magicSubmitting: "Sending link...",
|
|
61
|
+
invitationCode: "Invitation code",
|
|
62
|
+
invitationNeeded: "You need an invitation to sign up.",
|
|
63
|
+
closedTitle: "Sign-ups are closed",
|
|
64
|
+
closedBody: "{app} is not accepting new accounts right now.",
|
|
65
|
+
linkSent: "Check your inbox - we sent a link to {email}. Opening it confirms your address and signs you in.",
|
|
66
|
+
useMagicLink: "Send me a sign-up link instead",
|
|
67
|
+
usePassword: "Sign up with a password",
|
|
68
|
+
unavailable: "Sign-up is not available right now. Contact your administrator.",
|
|
69
|
+
loadFailed: "Could not load sign-up options. Please refresh the page."
|
|
70
|
+
},
|
|
71
|
+
resetPassword: {
|
|
72
|
+
requestTitle: "Reset your password",
|
|
73
|
+
requestSubtitle: "We will email you a link to choose a new password.",
|
|
74
|
+
requestSubmit: "Send me a reset link",
|
|
75
|
+
requestSubmitting: "Sending link...",
|
|
76
|
+
requestSent: "Check your inbox - if an account exists for {email}, a reset link is on its way.",
|
|
77
|
+
rememberedPrompt: "Remembered it?",
|
|
78
|
+
title: "Choose a new password",
|
|
79
|
+
subtitle: "Pick something you have not used before.",
|
|
80
|
+
newPassword: "New password",
|
|
81
|
+
confirmPassword: "Confirm new password",
|
|
82
|
+
mismatch: "Both passwords must match.",
|
|
83
|
+
submit: "Update password",
|
|
84
|
+
submitting: "Updating...",
|
|
85
|
+
doneTitle: "Password updated",
|
|
86
|
+
doneBody: "You can now sign in with your new password.",
|
|
87
|
+
incompleteTitle: "This link is incomplete",
|
|
88
|
+
incompleteBody: "It is missing its reset token. Request a new reset email and try again."
|
|
89
|
+
},
|
|
90
|
+
magicLink: {
|
|
91
|
+
pendingTitle: "Signing you in",
|
|
92
|
+
pendingBody: "One moment while we verify your link.",
|
|
93
|
+
doneTitle: "You're signed in",
|
|
94
|
+
doneBody: "You can close this tab if it does not redirect on its own.",
|
|
95
|
+
failedTitle: "This link didn't work",
|
|
96
|
+
missingToken: "This link is missing its sign-in token."
|
|
97
|
+
},
|
|
98
|
+
verifyEmail: {
|
|
99
|
+
title: "Verify your email",
|
|
100
|
+
subtitle: "Enter the 6-digit code we emailed you.",
|
|
101
|
+
code: "Verification code",
|
|
102
|
+
submit: "Verify email",
|
|
103
|
+
submitting: "Verifying...",
|
|
104
|
+
resend: "Didn't get it? Send a new code",
|
|
105
|
+
resent: "A new code is on its way.",
|
|
106
|
+
verifyingTitle: "Verifying your email",
|
|
107
|
+
verifyingBody: "One moment while we confirm your link.",
|
|
108
|
+
doneTitle: "Email verified",
|
|
109
|
+
doneBody: "Your address is confirmed. You can continue.",
|
|
110
|
+
failedTitle: "This link didn't work",
|
|
111
|
+
newCode: "Send a new code"
|
|
112
|
+
},
|
|
113
|
+
activate: {
|
|
114
|
+
title: "Activate your account",
|
|
115
|
+
credentialsSubtitle: "Set a password for {email} to finish.",
|
|
116
|
+
magicSubtitle: "Confirm {email} to finish setting up your account.",
|
|
117
|
+
confirmPassword: "Confirm password",
|
|
118
|
+
mismatch: "Both passwords must match.",
|
|
119
|
+
submit: "Activate my account",
|
|
120
|
+
submitting: "Activating...",
|
|
121
|
+
failedTitle: "This link didn't work",
|
|
122
|
+
missingToken: "It is missing its activation token. Ask for a new invitation.",
|
|
123
|
+
goToSignIn: "Go to sign in"
|
|
124
|
+
},
|
|
125
|
+
waitlist: {
|
|
126
|
+
title: "Join the waitlist",
|
|
127
|
+
subtitle: "We will email you when a spot opens up.",
|
|
128
|
+
submit: "Join the waitlist",
|
|
129
|
+
submitting: "Adding you...",
|
|
130
|
+
joined: "You're on the list. We will be in touch."
|
|
131
|
+
},
|
|
132
|
+
profile: {
|
|
133
|
+
navLabel: "Account sections",
|
|
134
|
+
tabProfile: "Profile",
|
|
135
|
+
tabSecurity: "Security",
|
|
136
|
+
tabBilling: "Billing",
|
|
137
|
+
profileTitle: "Profile",
|
|
138
|
+
profileDesc: "Your name and the address you sign in with.",
|
|
139
|
+
securityTitle: "Security",
|
|
140
|
+
securityDesc: "Manage your password and your account.",
|
|
141
|
+
billingTitle: "Billing",
|
|
142
|
+
billingDesc: "Your plan, what you have used, and how to change it.",
|
|
143
|
+
firstName: "First name",
|
|
144
|
+
firstNamePlaceholder: "Ada",
|
|
145
|
+
lastName: "Last name",
|
|
146
|
+
lastNamePlaceholder: "Lovelace",
|
|
147
|
+
save: "Save",
|
|
148
|
+
saving: "Saving...",
|
|
149
|
+
saved: "Your profile has been saved.",
|
|
150
|
+
emailSectionTitle: "Email address",
|
|
151
|
+
emailSectionDesc: "The address you sign in with. Contact support to change it.",
|
|
152
|
+
verified: "Verified",
|
|
153
|
+
unverified: "Unverified",
|
|
154
|
+
verify: "Verify",
|
|
155
|
+
verificationSent: "Verification email sent.",
|
|
156
|
+
unnamed: "Unnamed"
|
|
157
|
+
},
|
|
158
|
+
security: {
|
|
159
|
+
passwordTitle: "Password",
|
|
160
|
+
passwordDesc: "Used together with your email to sign in.",
|
|
161
|
+
currentPassword: "Current password",
|
|
162
|
+
newPassword: "New password",
|
|
163
|
+
confirmPassword: "Confirm new password",
|
|
164
|
+
submit: "Change password",
|
|
165
|
+
submitting: "Changing...",
|
|
166
|
+
changed: "Your password has been changed.",
|
|
167
|
+
mismatch: "Both passwords must match.",
|
|
168
|
+
sameAsCurrent: "Your new password must differ from the current one.",
|
|
169
|
+
deleteTitle: "Delete account",
|
|
170
|
+
deleteDesc: "Deactivates your account and signs you out. Contact support if you need it back.",
|
|
171
|
+
deleteAction: "Delete my account",
|
|
172
|
+
deleteConfirm: "Yes, delete my account",
|
|
173
|
+
deleting: "Deleting..."
|
|
174
|
+
},
|
|
175
|
+
billing: {
|
|
176
|
+
currentPlan: "Current plan",
|
|
177
|
+
plan: "Plan",
|
|
178
|
+
price: "Price",
|
|
179
|
+
status: "Status",
|
|
180
|
+
renews: "Renews",
|
|
181
|
+
ends: "Ends",
|
|
182
|
+
trialEnds: "Trial ends",
|
|
183
|
+
managePortal: "Manage billing",
|
|
184
|
+
opening: "Opening...",
|
|
185
|
+
freePlan: "You are on the free plan.",
|
|
186
|
+
seePlans: "See plans",
|
|
187
|
+
cancelNotice: "Your plan is set to end - you keep access until the date above.",
|
|
188
|
+
cancelSubscription: "Cancel subscription",
|
|
189
|
+
canceling: "Canceling...",
|
|
190
|
+
cancelConfirmTitle: "Cancel your subscription?",
|
|
191
|
+
cancelConfirmBody: "You'll keep access until the end of the current billing period.",
|
|
192
|
+
cancelConfirmButton: "Yes, cancel",
|
|
193
|
+
cancelConfirmDismiss: "Never mind",
|
|
194
|
+
changePlan: "Change plan",
|
|
195
|
+
current: "Current",
|
|
196
|
+
switchTo: "Switch",
|
|
197
|
+
switching: "Switching...",
|
|
198
|
+
switchConfirmTitle: "Switch to {plan}?",
|
|
199
|
+
switchConfirmBody: "This applies right away, and your next invoice reflects the price difference for the rest of this period.",
|
|
200
|
+
switchConfirmButton: "Yes, switch",
|
|
201
|
+
switchConfirmDismiss: "Never mind",
|
|
202
|
+
noPlans: "No other plans are available right now.",
|
|
203
|
+
notOnInterval: "Not offered on this plan",
|
|
204
|
+
differentCurrency: "Different currency - contact support",
|
|
205
|
+
loadFailed: "Could not load your plan right now.",
|
|
206
|
+
loadingPlan: "Loading subscription",
|
|
207
|
+
loadingPlans: "Loading plans",
|
|
208
|
+
intervalGroupLabel: "Billing interval",
|
|
209
|
+
// Rendered after a price, as "12 € / month". A language that needs another
|
|
210
|
+
// shape overrides the whole label rather than these fragments.
|
|
211
|
+
intervalMonth: "month",
|
|
212
|
+
intervalYear: "year",
|
|
213
|
+
intervalWeek: "week",
|
|
214
|
+
intervalDay: "day",
|
|
215
|
+
statusActive: "Active",
|
|
216
|
+
statusTrialing: "Trial",
|
|
217
|
+
statusPastDue: "Payment due",
|
|
218
|
+
statusCanceled: "Canceled",
|
|
219
|
+
statusUnpaid: "Unpaid",
|
|
220
|
+
statusPaused: "Paused"
|
|
221
|
+
},
|
|
222
|
+
usage: {
|
|
223
|
+
title: "Usage",
|
|
224
|
+
subtitle: "Counted against your plan for the current period.",
|
|
225
|
+
used: "{count} used",
|
|
226
|
+
nothingMetered: "Nothing metered on your plan - you have no usage limits to watch.",
|
|
227
|
+
overage: "{count} over your included amount - at no extra charge.",
|
|
228
|
+
overageBilled: "{count} over your included amount - the extra is billed on top of your plan.",
|
|
229
|
+
limitReached: "You have reached your limit.",
|
|
230
|
+
capReached: "You've reached your billing cap for this period - everything past this is free.",
|
|
231
|
+
billedAmount: "{amount} billed this period.",
|
|
232
|
+
loading: "Loading usage",
|
|
233
|
+
loadFailed: "Could not load your usage right now."
|
|
234
|
+
},
|
|
235
|
+
pricing: {
|
|
236
|
+
getStarted: "Get started",
|
|
237
|
+
billedAt: "Billed {amount} / {interval}",
|
|
238
|
+
save: "Save {percent}%",
|
|
239
|
+
included: "Included",
|
|
240
|
+
notIncluded: "\u2014",
|
|
241
|
+
free: "Free",
|
|
242
|
+
forever: "forever",
|
|
243
|
+
startFree: "Start free",
|
|
244
|
+
trialNote: "{days}-day trial, no card",
|
|
245
|
+
thenMetered: "then metered",
|
|
246
|
+
meterTitle: "Metered overage",
|
|
247
|
+
meterRatePer: "Rate per {thing}",
|
|
248
|
+
meterShape: "Shape",
|
|
249
|
+
meterCounted: "Counted",
|
|
250
|
+
meterResets: "Resets",
|
|
251
|
+
meterCapped: "Capped",
|
|
252
|
+
meterTiers: "{count} tiers, cheapest {amount}",
|
|
253
|
+
meterFlat: "One rate",
|
|
254
|
+
meterVolume: "whole quantity at the reached rate",
|
|
255
|
+
meterGraduated: "each slice at its own rate",
|
|
256
|
+
meterPerSlice: "per {slice}",
|
|
257
|
+
meterPerCustomer: "per customer",
|
|
258
|
+
meterRoundedUp: "rounded up",
|
|
259
|
+
meterRoundedDown: "rounded down",
|
|
260
|
+
meterResetsEvery: "every {interval}",
|
|
261
|
+
perSlice: "{amount} per {slice}",
|
|
262
|
+
meterResetsCycle: "every billing period",
|
|
263
|
+
meterResetsNever: "never - it rolls over",
|
|
264
|
+
metered: "metered",
|
|
265
|
+
from: "from",
|
|
266
|
+
sizeUpFront: "Size it up front",
|
|
267
|
+
billedWithPlan: "billed with the plan",
|
|
268
|
+
includedAmount: "{amount} included",
|
|
269
|
+
prepaidAmount: "+{amount} prepaid",
|
|
270
|
+
nothingListed: "Nothing listed on this plan yet.",
|
|
271
|
+
unlimited: "Unlimited",
|
|
272
|
+
moreFeatures: "+{count} more",
|
|
273
|
+
loading: "Loading plans",
|
|
274
|
+
loadFailed: "Could not load plans right now."
|
|
275
|
+
},
|
|
276
|
+
checkout: {
|
|
277
|
+
confirmingTitle: "Confirming your payment",
|
|
278
|
+
confirmingBody: "This takes a few seconds - please keep this tab open.",
|
|
279
|
+
pendingTitle: "Payment received",
|
|
280
|
+
pendingBody: "Your plan is still being activated. It usually lands within a minute - refresh if it has not appeared by then.",
|
|
281
|
+
doneTitle: "You're all set",
|
|
282
|
+
doneBody: "Your subscription is active. Thank you.",
|
|
283
|
+
cancelTitle: "Checkout cancelled",
|
|
284
|
+
cancelBody: "Nothing was charged. You can pick a plan whenever you are ready.",
|
|
285
|
+
backToPlans: "Back to plans"
|
|
286
|
+
},
|
|
287
|
+
upgrade: {
|
|
288
|
+
quotaTitle: "You've used all your {feature}",
|
|
289
|
+
quotaBody: "You have used {used} of {limit} this period. Upgrade for a higher limit.",
|
|
290
|
+
featureTitle: "{feature} is not on your plan",
|
|
291
|
+
featureBody: "Upgrade your plan to unlock {feature}.",
|
|
292
|
+
action: "See plans",
|
|
293
|
+
checking: "Checking access"
|
|
294
|
+
},
|
|
295
|
+
account: {
|
|
296
|
+
menuLabel: "Account menu",
|
|
297
|
+
signOut: "Sign out"
|
|
298
|
+
},
|
|
299
|
+
errors: {
|
|
300
|
+
generic: "Something went wrong. Please try again.",
|
|
301
|
+
// Auth (K1xxx)
|
|
302
|
+
K1001: "Registration is not open right now.",
|
|
303
|
+
K1002: "This app has reached its user limit.",
|
|
304
|
+
K1003: "This invitation link is no longer valid.",
|
|
305
|
+
K1004: "That invitation code is not valid.",
|
|
306
|
+
K1005: "Invalid email or password.",
|
|
307
|
+
K1006: "This link has expired. Request a new one.",
|
|
308
|
+
K1007: "This link is missing its token.",
|
|
309
|
+
K1008: "An account already exists for this email.",
|
|
310
|
+
K1009: "That email address does not look valid.",
|
|
311
|
+
K1010: "Please choose a stronger password.",
|
|
312
|
+
K1011: "Please verify your email address first.",
|
|
313
|
+
K1012: "You need to sign in to continue.",
|
|
314
|
+
// Validation (K2xxx)
|
|
315
|
+
K2001: "Some of the details you entered are not valid.",
|
|
316
|
+
K2002: "Please fill in every required field.",
|
|
317
|
+
K2003: "Some of the details you entered are not valid.",
|
|
318
|
+
K2005: "That value is already taken.",
|
|
319
|
+
// Permissions (K3xxx)
|
|
320
|
+
K3002: "You do not have permission to do that.",
|
|
321
|
+
K3004: "That action is not available on your account.",
|
|
322
|
+
// Resources (K4xxx)
|
|
323
|
+
K4001: "We could not find what you were looking for.",
|
|
324
|
+
K4003: "We could not find that account.",
|
|
325
|
+
// Billing (K5xxx)
|
|
326
|
+
K5001: "You've reached the limit included in your plan.",
|
|
327
|
+
K5002: "Your plan does not include this. Upgrade to continue.",
|
|
328
|
+
K5003: "Your payment could not be processed.",
|
|
329
|
+
// Rate limiting (K6xxx)
|
|
330
|
+
K6001: "Too many attempts. Please wait a moment and try again."
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
var LocalizationContext = createContext(defaultLocalization);
|
|
334
|
+
function mergeLocalization(override, base = defaultLocalization) {
|
|
335
|
+
if (!override) return base;
|
|
336
|
+
const merged = { ...base };
|
|
337
|
+
for (const groupKey of Object.keys(base)) {
|
|
338
|
+
const group = override[groupKey];
|
|
339
|
+
if (group) {
|
|
340
|
+
merged[groupKey] = {
|
|
341
|
+
...base[groupKey],
|
|
342
|
+
...group
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return merged;
|
|
347
|
+
}
|
|
348
|
+
function useLocalization() {
|
|
349
|
+
return useContext(LocalizationContext);
|
|
350
|
+
}
|
|
351
|
+
function fill(template, values) {
|
|
352
|
+
return template.replace(
|
|
353
|
+
/\{(\w+)\}/g,
|
|
354
|
+
(match, key) => key in values ? String(values[key]) : match
|
|
355
|
+
);
|
|
356
|
+
}
|
|
357
|
+
function fillNode(template, values) {
|
|
358
|
+
return template.split(/(\{\w+\})/g).map((part, index) => {
|
|
359
|
+
const placeholder = /^\{(\w+)\}$/.exec(part);
|
|
360
|
+
if (placeholder && placeholder[1] in values) {
|
|
361
|
+
return createElement(Fragment, { key: index }, values[placeholder[1]]);
|
|
362
|
+
}
|
|
363
|
+
return part;
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
function resolveErrorMessage(error, l10n) {
|
|
367
|
+
const code = typeof error === "object" && error !== null && "code" in error ? String(error.code) : null;
|
|
368
|
+
if (code && l10n.errors[code]) return l10n.errors[code];
|
|
369
|
+
return l10n.errors.generic;
|
|
370
|
+
}
|
|
371
|
+
function useErrorResolver() {
|
|
372
|
+
const l10n = useLocalization();
|
|
373
|
+
return useCallback((error) => resolveErrorMessage(error, l10n), [l10n]);
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
// src/ui/styles.ts
|
|
377
|
+
var STYLE_ELEMENT_ID = "kerne-ui-styles";
|
|
378
|
+
var DARK_TOKENS = `
|
|
379
|
+
--kerne-fg: #f0f0f0;
|
|
380
|
+
--kerne-fg-muted: #a1a1a1;
|
|
381
|
+
--kerne-fg-subtle: #8a8a8a;
|
|
382
|
+
--kerne-border: #2e2e2e;
|
|
383
|
+
--kerne-border-strong: #3d3d3d;
|
|
384
|
+
--kerne-panel: #1f1f1f;
|
|
385
|
+
--kerne-surface: #171717;
|
|
386
|
+
--kerne-surface-raised: #2e2e2e;
|
|
387
|
+
--kerne-track: #373737;
|
|
388
|
+
--kerne-primary: #f0f0f0;
|
|
389
|
+
--kerne-primary-fg: #171717;
|
|
390
|
+
--kerne-primary-hover: #d9d9d9;
|
|
391
|
+
--kerne-accent: #e5e5e5;
|
|
392
|
+
--kerne-danger: #f0837a;
|
|
393
|
+
--kerne-success: #5cbd8d;
|
|
394
|
+
--kerne-warning: #d9ab5e;
|
|
395
|
+
--kerne-shadow: 0 4px 6px -2px rgba(0,0,0,.4), 0 12px 24px -8px rgba(0,0,0,.6);
|
|
396
|
+
`;
|
|
397
|
+
var CSS = `
|
|
398
|
+
.kerne-root {
|
|
399
|
+
--kerne-fg: #171717;
|
|
400
|
+
--kerne-fg-muted: #6e6e6e;
|
|
401
|
+
/* Meets 4.5:1 on white - this carries placeholders and the legal footnote,
|
|
402
|
+
which used to sit around 2.5:1. */
|
|
403
|
+
--kerne-fg-subtle: #717171;
|
|
404
|
+
--kerne-border: #e5e5e5;
|
|
405
|
+
--kerne-border-strong: #d9d9d9;
|
|
406
|
+
--kerne-field-bg: transparent;
|
|
407
|
+
--kerne-panel: #f5f5f5;
|
|
408
|
+
/* The ground the component sits on. Transparent so an embedded instance
|
|
409
|
+
inherits the host's page; set it (appearance.background) when the component
|
|
410
|
+
owns the viewport, so text and background can never resolve independently. */
|
|
411
|
+
--kerne-bg: transparent;
|
|
412
|
+
/* An opaque surface for the things that must not show through: the card
|
|
413
|
+
variant, the dropdown floating over host content, a pressed segment. */
|
|
414
|
+
--kerne-surface: #ffffff;
|
|
415
|
+
--kerne-surface-raised: #ffffff;
|
|
416
|
+
/* Unfilled part of a meter, and the ground of a disabled button. Distinct
|
|
417
|
+
from --kerne-panel, which is tuned to sit on the page: on a card the two
|
|
418
|
+
are nearly the same colour, so a meter drawn in panel had invisible empty
|
|
419
|
+
segments and read as having fewer steps than it has. */
|
|
420
|
+
--kerne-track: #e5e5e5;
|
|
421
|
+
--kerne-primary: #171717;
|
|
422
|
+
--kerne-primary-fg: #ffffff;
|
|
423
|
+
--kerne-primary-hover: #2e2e2e;
|
|
424
|
+
--kerne-accent: #171717;
|
|
425
|
+
--kerne-danger: #c0392f;
|
|
426
|
+
--kerne-success: #1f7a4d;
|
|
427
|
+
--kerne-warning: #9a6b12;
|
|
428
|
+
--kerne-radius: 8px;
|
|
429
|
+
--kerne-control-height: 44px;
|
|
430
|
+
--kerne-shadow: 0 4px 6px -2px rgba(0,0,0,.06), 0 12px 24px -8px rgba(0,0,0,.16);
|
|
431
|
+
--kerne-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
|
432
|
+
|
|
433
|
+
box-sizing: border-box;
|
|
434
|
+
font-family: var(--kerne-font);
|
|
435
|
+
color: var(--kerne-fg);
|
|
436
|
+
background: var(--kerne-bg);
|
|
437
|
+
-webkit-font-smoothing: antialiased;
|
|
438
|
+
display: flex;
|
|
439
|
+
justify-content: center;
|
|
440
|
+
width: 100%;
|
|
441
|
+
}
|
|
442
|
+
.kerne-root *, .kerne-root *::before, .kerne-root *::after { box-sizing: inherit; }
|
|
443
|
+
/* The cheapest tell that a surface was designed rather than assembled - a
|
|
444
|
+
themed selection color costs one rule and every host page skips it. */
|
|
445
|
+
.kerne-root ::selection { background: color-mix(in srgb, var(--kerne-accent) 30%, transparent); color: var(--kerne-fg); }
|
|
446
|
+
|
|
447
|
+
.kerne-root[data-kerne-theme="dark"] {${DARK_TOKENS}}
|
|
448
|
+
/* Only an explicit opt-in follows the visitor's OS. Defaulting to it meant a
|
|
449
|
+
light-only host got a dark form on any machine set to dark, without ever
|
|
450
|
+
asking for one - and with no way for the host to know it had happened. */
|
|
451
|
+
@media (prefers-color-scheme: dark) {
|
|
452
|
+
.kerne-root[data-kerne-theme="system"] {${DARK_TOKENS}}
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
.kerne-shell {
|
|
456
|
+
width: 100%;
|
|
457
|
+
max-width: 384px;
|
|
458
|
+
display: flex;
|
|
459
|
+
flex-direction: column;
|
|
460
|
+
}
|
|
461
|
+
/* Auth screens are a single column of fields; an account manager is a
|
|
462
|
+
navigation plus a content pane and suffocates at 384px. */
|
|
463
|
+
.kerne-shell[data-kerne-size="wide"] { max-width: 1800px; }
|
|
464
|
+
|
|
465
|
+
.kerne-shell[data-kerne-surface="card"] {
|
|
466
|
+
background: var(--kerne-surface);
|
|
467
|
+
border: 1px solid var(--kerne-border);
|
|
468
|
+
border-radius: calc(var(--kerne-radius) + 6px);
|
|
469
|
+
box-shadow: var(--kerne-shadow);
|
|
470
|
+
padding: 32px 28px;
|
|
471
|
+
/* The card is the ground from here down, so nested surfaces stop inheriting
|
|
472
|
+
the page behind it. */
|
|
473
|
+
--kerne-bg: var(--kerne-surface);
|
|
474
|
+
}
|
|
475
|
+
@media (max-width: 480px) {
|
|
476
|
+
/* Full-bleed on a phone: a card with 16px of page showing on each side wastes
|
|
477
|
+
the only axis a login form has. */
|
|
478
|
+
.kerne-shell[data-kerne-surface="card"] {
|
|
479
|
+
border: none;
|
|
480
|
+
box-shadow: none;
|
|
481
|
+
padding: 8px 0;
|
|
482
|
+
background: transparent;
|
|
483
|
+
/* The card no longer paints, so the ground is the page again. */
|
|
484
|
+
--kerne-bg: transparent;
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* ---------- Tabbed account manager ---------- */
|
|
489
|
+
.kerne-profile { display: grid; grid-template-columns: 188px minmax(0, 1fr); gap: 40px; align-items: start; }
|
|
490
|
+
@media (max-width: 640px) { .kerne-profile { grid-template-columns: 1fr; gap: 20px; } }
|
|
491
|
+
|
|
492
|
+
.kerne-nav { display: flex; flex-direction: column; gap: 2px; position: sticky; top: 24px; }
|
|
493
|
+
@media (max-width: 640px) {
|
|
494
|
+
.kerne-nav {
|
|
495
|
+
position: static;
|
|
496
|
+
flex-direction: row;
|
|
497
|
+
overflow-x: auto;
|
|
498
|
+
gap: 4px;
|
|
499
|
+
border-bottom: 1px solid var(--kerne-border);
|
|
500
|
+
padding-bottom: 8px;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
.kerne-nav-item {
|
|
504
|
+
font: inherit; font-size: 14px; text-align: left;
|
|
505
|
+
background: none; border: none; cursor: pointer;
|
|
506
|
+
padding: 8px 12px; border-radius: 6px;
|
|
507
|
+
color: var(--kerne-fg-muted); white-space: nowrap;
|
|
508
|
+
transition: background-color .12s ease, color .12s ease;
|
|
509
|
+
}
|
|
510
|
+
.kerne-nav-item:hover { color: var(--kerne-fg); }
|
|
511
|
+
.kerne-nav-item[aria-selected="true"] { background: var(--kerne-panel); color: var(--kerne-fg); font-weight: 500; }
|
|
512
|
+
.kerne-nav-item:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 2px; }
|
|
513
|
+
.kerne-nav-item:active { opacity: .7; }
|
|
514
|
+
|
|
515
|
+
.kerne-pane { display: flex; flex-direction: column; min-width: 0; }
|
|
516
|
+
.kerne-pane:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 4px; border-radius: 4px; }
|
|
517
|
+
.kerne-pane-title { font-size: 20px; font-weight: 600; letter-spacing: -.01em; margin: 0 0 4px; }
|
|
518
|
+
.kerne-pane-desc { font-size: 14px; color: var(--kerne-fg-muted); margin: 0 0 8px; }
|
|
519
|
+
|
|
520
|
+
.kerne-subsection { padding: 24px 0; display: flex; flex-direction: column; gap: 16px; }
|
|
521
|
+
.kerne-subsection:first-of-type { padding-top: 16px; }
|
|
522
|
+
.kerne-subsection + .kerne-subsection { border-top: 1px solid var(--kerne-border); }
|
|
523
|
+
.kerne-subsection-head { display: flex; flex-direction: column; gap: 3px; }
|
|
524
|
+
.kerne-subsection-title { font-size: 15px; font-weight: 600; letter-spacing: -0.012em; margin: 0; }
|
|
525
|
+
.kerne-subsection-desc { font-size: 12.5px; line-height: 1.5; color: var(--kerne-fg-muted); margin: 0; }
|
|
526
|
+
/* Buttons inside a settings pane are actions, not page-wide calls to action. */
|
|
527
|
+
.kerne-subsection .kerne-button { width: auto; align-self: flex-start; }
|
|
528
|
+
|
|
529
|
+
/* ---------- Plan options ---------- */
|
|
530
|
+
.kerne-plan-list { display: flex; flex-direction: column; gap: 10px; }
|
|
531
|
+
.kerne-plan-option {
|
|
532
|
+
display: flex; justify-content: space-between; align-items: center; gap: 16px;
|
|
533
|
+
padding: 14px 16px; border: 1px solid var(--kerne-border); border-radius: var(--kerne-radius);
|
|
534
|
+
}
|
|
535
|
+
.kerne-plan-option[data-current="true"] { border-color: var(--kerne-fg); }
|
|
536
|
+
.kerne-plan-option-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
|
537
|
+
.kerne-plan-option-name { font-size: 14px; font-weight: 500; display: flex; align-items: center; gap: 8px; }
|
|
538
|
+
.kerne-plan-option-price { font-size: 13px; color: var(--kerne-fg-muted); font-variant-numeric: tabular-nums; }
|
|
539
|
+
.kerne-plan-option .kerne-button { width: auto; flex-shrink: 0; height: 36px; font-size: 13px; }
|
|
540
|
+
|
|
541
|
+
/* Segmented control for billing interval. */
|
|
542
|
+
.kerne-segmented { display: inline-flex; padding: 3px; gap: 2px; background: var(--kerne-panel); border-radius: var(--kerne-radius); align-self: flex-start; }
|
|
543
|
+
.kerne-segment {
|
|
544
|
+
font: inherit; font-size: 13px; border: none; background: none; cursor: pointer;
|
|
545
|
+
padding: 5px 12px; border-radius: calc(var(--kerne-radius) - 3px); color: var(--kerne-fg-muted);
|
|
546
|
+
}
|
|
547
|
+
.kerne-segment[aria-pressed="true"] { background: var(--kerne-surface-raised); color: var(--kerne-fg); font-weight: 500; }
|
|
548
|
+
.kerne-segment:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 2px; }
|
|
549
|
+
.kerne-segment:active { opacity: .7; }
|
|
550
|
+
|
|
551
|
+
/* ---------- Masthead ---------- */
|
|
552
|
+
.kerne-masthead {
|
|
553
|
+
display: flex;
|
|
554
|
+
flex-direction: column;
|
|
555
|
+
align-items: center;
|
|
556
|
+
gap: 16px;
|
|
557
|
+
text-align: center;
|
|
558
|
+
margin-bottom: 32px;
|
|
559
|
+
}
|
|
560
|
+
.kerne-logo { display: flex; align-items: center; justify-content: center; }
|
|
561
|
+
.kerne-heading { display: flex; flex-direction: column; gap: 6px; }
|
|
562
|
+
.kerne-title {
|
|
563
|
+
margin: 0;
|
|
564
|
+
font-size: 24px;
|
|
565
|
+
font-weight: 600;
|
|
566
|
+
letter-spacing: -.02em;
|
|
567
|
+
line-height: 1.2;
|
|
568
|
+
text-wrap: balance;
|
|
569
|
+
}
|
|
570
|
+
.kerne-subtitle { margin: 0; font-size: 14px; color: var(--kerne-fg-muted); line-height: 1.5; }
|
|
571
|
+
|
|
572
|
+
/* Moving between steps (sign-in -> sign-up -> forgot password) used to be an
|
|
573
|
+
instant swap: the content and the height both jumped at once, which reads
|
|
574
|
+
as a page reload rather than one flow. */
|
|
575
|
+
@keyframes kerne-step-in {
|
|
576
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
577
|
+
to { opacity: 1; transform: none; }
|
|
578
|
+
}
|
|
579
|
+
.kerne-step { animation: kerne-step-in .18s ease-out; }
|
|
580
|
+
@media (prefers-reduced-motion: reduce) {
|
|
581
|
+
.kerne-step { animation: none; }
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
/* ---------- Stack ---------- */
|
|
585
|
+
.kerne-stack { display: flex; flex-direction: column; gap: 16px; }
|
|
586
|
+
.kerne-inline-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
587
|
+
.kerne-form { display: flex; flex-direction: column; gap: 12px; }
|
|
588
|
+
.kerne-field { display: flex; flex-direction: column; gap: 6px; }
|
|
589
|
+
.kerne-label { font-size: 14px; font-weight: 500; color: var(--kerne-fg); }
|
|
590
|
+
.kerne-label-row { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
|
591
|
+
|
|
592
|
+
/* Positioning context for the reveal toggle, which sits inside the input box. */
|
|
593
|
+
.kerne-control { position: relative; display: flex; }
|
|
594
|
+
|
|
595
|
+
.kerne-input {
|
|
596
|
+
font: inherit;
|
|
597
|
+
font-size: 14px;
|
|
598
|
+
height: var(--kerne-control-height);
|
|
599
|
+
color: var(--kerne-fg);
|
|
600
|
+
background: var(--kerne-field-bg);
|
|
601
|
+
border: 1px solid var(--kerne-border-strong);
|
|
602
|
+
border-radius: var(--kerne-radius);
|
|
603
|
+
padding: 0 13px;
|
|
604
|
+
width: 100%;
|
|
605
|
+
transition: border-color .12s ease, box-shadow .12s ease;
|
|
606
|
+
}
|
|
607
|
+
/* Room for the toggle, so a long password never runs underneath it. */
|
|
608
|
+
.kerne-input[data-kerne-reveal="true"] { padding-right: 42px; }
|
|
609
|
+
|
|
610
|
+
.kerne-reveal {
|
|
611
|
+
position: absolute;
|
|
612
|
+
top: 0; right: 0; bottom: 0;
|
|
613
|
+
width: 40px;
|
|
614
|
+
display: flex; align-items: center; justify-content: center;
|
|
615
|
+
background: none; border: none; padding: 0; cursor: pointer;
|
|
616
|
+
color: var(--kerne-fg-subtle);
|
|
617
|
+
border-radius: var(--kerne-radius);
|
|
618
|
+
transition: color .12s ease;
|
|
619
|
+
}
|
|
620
|
+
.kerne-reveal:hover:not(:disabled) { color: var(--kerne-fg); }
|
|
621
|
+
.kerne-reveal:disabled { cursor: not-allowed; opacity: .5; }
|
|
622
|
+
.kerne-reveal:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: -2px; }
|
|
623
|
+
|
|
624
|
+
.kerne-field-note { font-size: 12.5px; line-height: 1.4; color: var(--kerne-fg-muted); margin: 0; }
|
|
625
|
+
.kerne-field-error { font-size: 12.5px; line-height: 1.4; color: var(--kerne-danger); margin: 0; }
|
|
626
|
+
/* Caps Lock is a "you are about to get this wrong" signal, not background
|
|
627
|
+
guidance - in muted grey it sat at the same weight as a hint and got read
|
|
628
|
+
as one. */
|
|
629
|
+
.kerne-field-note[data-kerne-warn="true"] { color: var(--kerne-warning); }
|
|
630
|
+
|
|
631
|
+
/* ---------- Password rules ---------- */
|
|
632
|
+
/* Unmet stays at hint weight rather than turning red: nothing is wrong yet,
|
|
633
|
+
the user is still typing. Only the tick earns a color. */
|
|
634
|
+
.kerne-rules { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 3px; }
|
|
635
|
+
.kerne-rule {
|
|
636
|
+
display: flex; align-items: center; gap: 6px;
|
|
637
|
+
font-size: 12.5px; line-height: 1.4; color: var(--kerne-fg-muted);
|
|
638
|
+
}
|
|
639
|
+
.kerne-rule[data-met] { color: var(--kerne-success); }
|
|
640
|
+
|
|
641
|
+
/* ---------- Password strength ---------- */
|
|
642
|
+
.kerne-strength { display: flex; flex-direction: column; gap: 5px; }
|
|
643
|
+
.kerne-strength-track { display: flex; gap: 4px; }
|
|
644
|
+
.kerne-strength-step {
|
|
645
|
+
flex: 1; height: 3px; border-radius: 999px;
|
|
646
|
+
background: var(--kerne-track);
|
|
647
|
+
transition: background-color .18s ease;
|
|
648
|
+
}
|
|
649
|
+
.kerne-strength-label { font-size: 12px; color: var(--kerne-fg-muted); }
|
|
650
|
+
.kerne-strength[data-score="0"] .kerne-strength-step[data-on],
|
|
651
|
+
.kerne-strength[data-score="1"] .kerne-strength-step[data-on] { background: var(--kerne-danger); }
|
|
652
|
+
.kerne-strength[data-score="2"] .kerne-strength-step[data-on] { background: var(--kerne-warning); }
|
|
653
|
+
.kerne-strength[data-score="3"] .kerne-strength-step[data-on],
|
|
654
|
+
.kerne-strength[data-score="4"] .kerne-strength-step[data-on] { background: var(--kerne-success); }
|
|
655
|
+
@media (prefers-reduced-motion: reduce) {
|
|
656
|
+
.kerne-strength-step { transition: none; }
|
|
657
|
+
}
|
|
658
|
+
.kerne-input::placeholder { color: var(--kerne-fg-subtle); }
|
|
659
|
+
.kerne-input:hover:not(:disabled):not(:focus) { border-color: var(--kerne-fg-subtle); }
|
|
660
|
+
.kerne-input:focus {
|
|
661
|
+
outline: none;
|
|
662
|
+
border-color: var(--kerne-accent);
|
|
663
|
+
box-shadow: 0 0 0 3px color-mix(in srgb, var(--kerne-accent) 15%, transparent);
|
|
664
|
+
}
|
|
665
|
+
.kerne-input:disabled { opacity: .6; cursor: not-allowed; }
|
|
666
|
+
.kerne-input[aria-invalid="true"] { border-color: var(--kerne-danger); }
|
|
667
|
+
.kerne-input[data-kerne-code="true"] {
|
|
668
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
669
|
+
letter-spacing: .3em;
|
|
670
|
+
text-transform: uppercase;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/* ---------- Buttons ---------- */
|
|
674
|
+
.kerne-button {
|
|
675
|
+
font: inherit;
|
|
676
|
+
font-size: 14px;
|
|
677
|
+
font-weight: 500;
|
|
678
|
+
height: var(--kerne-control-height);
|
|
679
|
+
border-radius: var(--kerne-radius);
|
|
680
|
+
padding: 0 16px;
|
|
681
|
+
cursor: pointer;
|
|
682
|
+
border: 1px solid transparent;
|
|
683
|
+
display: inline-flex;
|
|
684
|
+
align-items: center;
|
|
685
|
+
justify-content: center;
|
|
686
|
+
gap: 10px;
|
|
687
|
+
width: 100%;
|
|
688
|
+
transition: background-color .12s ease, border-color .12s ease, opacity .12s ease, transform .1s ease;
|
|
689
|
+
}
|
|
690
|
+
.kerne-button:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 2px; }
|
|
691
|
+
.kerne-button:disabled { opacity: .5; cursor: not-allowed; }
|
|
692
|
+
/* Tactile press feedback - every control here had hover and focus, none had
|
|
693
|
+
the third state a real button reports back on. */
|
|
694
|
+
.kerne-button:active:not(:disabled) { transform: scale(.98); }
|
|
695
|
+
/* A disabled primary drops the brand entirely rather than wearing it at half
|
|
696
|
+
opacity: on a dark theme the light primary faded into an opaque grey slab
|
|
697
|
+
that read as an enabled button, and a brand colour has no business marking
|
|
698
|
+
an action you cannot take. Loading is excluded - it is still the real
|
|
699
|
+
button, just busy. */
|
|
700
|
+
.kerne-button-primary:disabled:not([data-kerne-loading="true"]) {
|
|
701
|
+
opacity: 1;
|
|
702
|
+
background: var(--kerne-track);
|
|
703
|
+
color: var(--kerne-fg-subtle);
|
|
704
|
+
}
|
|
705
|
+
/* A button mid-request is busy, not unavailable - dimming it to the disabled
|
|
706
|
+
opacity reads as "this stopped working". */
|
|
707
|
+
.kerne-button[data-kerne-loading="true"] { opacity: 1; cursor: progress; }
|
|
708
|
+
.kerne-button .kerne-spinner { width: 15px; height: 15px; border-width: 2px; }
|
|
709
|
+
/* Shadow value matches the existing .kerne-pricing-switch-thumb convention
|
|
710
|
+
rather than inventing a new one - same soft, barely-there elevation. */
|
|
711
|
+
.kerne-button-primary { background: var(--kerne-primary); color: var(--kerne-primary-fg); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08); }
|
|
712
|
+
.kerne-button-primary:hover:not(:disabled) { background: var(--kerne-primary-hover); }
|
|
713
|
+
.kerne-button-outline {
|
|
714
|
+
background: transparent;
|
|
715
|
+
color: var(--kerne-fg);
|
|
716
|
+
border-color: var(--kerne-border-strong);
|
|
717
|
+
}
|
|
718
|
+
.kerne-button-outline:hover:not(:disabled) { background: var(--kerne-panel); }
|
|
719
|
+
|
|
720
|
+
/* Method switch - a quiet text control, not a second call to action competing
|
|
721
|
+
with the primary button above it. */
|
|
722
|
+
.kerne-switch {
|
|
723
|
+
font: inherit;
|
|
724
|
+
font-size: 14px;
|
|
725
|
+
width: 100%;
|
|
726
|
+
text-align: center;
|
|
727
|
+
background: none;
|
|
728
|
+
border: none;
|
|
729
|
+
padding: 4px 0;
|
|
730
|
+
cursor: pointer;
|
|
731
|
+
color: var(--kerne-fg-muted);
|
|
732
|
+
text-underline-offset: 4px;
|
|
733
|
+
transition: color .12s ease;
|
|
734
|
+
}
|
|
735
|
+
.kerne-switch:hover { color: var(--kerne-fg); text-decoration: underline; }
|
|
736
|
+
.kerne-switch:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 2px; border-radius: 4px; }
|
|
737
|
+
.kerne-switch:active { opacity: .7; }
|
|
738
|
+
|
|
739
|
+
.kerne-link {
|
|
740
|
+
font-size: inherit;
|
|
741
|
+
color: var(--kerne-accent);
|
|
742
|
+
background: none;
|
|
743
|
+
border: none;
|
|
744
|
+
padding: 0;
|
|
745
|
+
cursor: pointer;
|
|
746
|
+
font-family: inherit;
|
|
747
|
+
text-decoration: none;
|
|
748
|
+
text-underline-offset: 3px;
|
|
749
|
+
}
|
|
750
|
+
.kerne-link:hover { text-decoration: underline; }
|
|
751
|
+
.kerne-link:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 2px; border-radius: 2px; }
|
|
752
|
+
|
|
753
|
+
/* ---------- Divider ---------- */
|
|
754
|
+
.kerne-divider {
|
|
755
|
+
display: flex;
|
|
756
|
+
align-items: center;
|
|
757
|
+
gap: 12px;
|
|
758
|
+
color: var(--kerne-fg-muted);
|
|
759
|
+
font-size: 11px;
|
|
760
|
+
text-transform: uppercase;
|
|
761
|
+
letter-spacing: .06em;
|
|
762
|
+
padding: 2px 0;
|
|
763
|
+
}
|
|
764
|
+
.kerne-divider::before, .kerne-divider::after {
|
|
765
|
+
content: "";
|
|
766
|
+
flex: 1;
|
|
767
|
+
height: 1px;
|
|
768
|
+
background: var(--kerne-border);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
/* ---------- Panels ---------- */
|
|
772
|
+
.kerne-panel {
|
|
773
|
+
display: flex;
|
|
774
|
+
align-items: flex-start;
|
|
775
|
+
gap: 10px;
|
|
776
|
+
font-size: 14px;
|
|
777
|
+
line-height: 1.5;
|
|
778
|
+
padding: 12px 14px;
|
|
779
|
+
border-radius: var(--kerne-radius);
|
|
780
|
+
background: var(--kerne-panel);
|
|
781
|
+
color: var(--kerne-fg-muted);
|
|
782
|
+
}
|
|
783
|
+
.kerne-panel-danger { color: var(--kerne-danger); }
|
|
784
|
+
.kerne-panel-success { color: var(--kerne-success); }
|
|
785
|
+
.kerne-panel svg { flex-shrink: 0; margin-top: 1px; }
|
|
786
|
+
.kerne-panel strong { color: var(--kerne-fg); font-weight: 500; }
|
|
787
|
+
|
|
788
|
+
.kerne-oauth-group { display: flex; flex-direction: column; gap: 10px; }
|
|
789
|
+
|
|
790
|
+
/* A closing line under a form, e.g. "Remembered it? Back to sign in". */
|
|
791
|
+
.kerne-footer { font-size: 14px; color: var(--kerne-fg-muted); text-align: center; margin: 4px 0 0; }
|
|
792
|
+
|
|
793
|
+
/* ---------- Footnotes ---------- */
|
|
794
|
+
.kerne-legal {
|
|
795
|
+
font-size: 12px;
|
|
796
|
+
line-height: 1.5;
|
|
797
|
+
color: var(--kerne-fg-subtle);
|
|
798
|
+
text-align: center;
|
|
799
|
+
margin: 32px 0 0;
|
|
800
|
+
}
|
|
801
|
+
.kerne-branding {
|
|
802
|
+
display: flex;
|
|
803
|
+
align-items: center;
|
|
804
|
+
justify-content: center;
|
|
805
|
+
gap: 5px;
|
|
806
|
+
margin-top: 24px;
|
|
807
|
+
font-size: 12px;
|
|
808
|
+
color: var(--kerne-fg-subtle);
|
|
809
|
+
text-decoration: none;
|
|
810
|
+
}
|
|
811
|
+
.kerne-branding:hover { color: var(--kerne-fg-muted); }
|
|
812
|
+
|
|
813
|
+
/* Centred outcome screen for the flows that resolve on their own (magic-link
|
|
814
|
+
landing, email verification by link) - there is no form to show, only what
|
|
815
|
+
happened. */
|
|
816
|
+
.kerne-status { display: flex; flex-direction: column; align-items: center; gap: 14px; text-align: center; padding: 8px 0; }
|
|
817
|
+
.kerne-status-icon { display: flex; align-items: center; justify-content: center; width: 44px; height: 44px; border-radius: 50%; background: var(--kerne-panel); }
|
|
818
|
+
.kerne-status-icon[data-tone="danger"] { color: var(--kerne-danger); }
|
|
819
|
+
.kerne-status-icon[data-tone="muted"] { color: var(--kerne-fg-muted); }
|
|
820
|
+
/* The one earned focal moment: checkout confirmed, magic link signed in,
|
|
821
|
+
password reset, email verified - a real milestone, shared by all five
|
|
822
|
+
flows through this one component. The circle settles, then the check
|
|
823
|
+
draws a beat later, so it reads as two beats instead of one flourish. */
|
|
824
|
+
.kerne-status-icon[data-tone="success"] {
|
|
825
|
+
color: var(--kerne-success);
|
|
826
|
+
animation: kerne-status-pop .3s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
827
|
+
}
|
|
828
|
+
.kerne-status-icon[data-tone="success"] svg path {
|
|
829
|
+
stroke-dasharray: 20;
|
|
830
|
+
stroke-dashoffset: 20;
|
|
831
|
+
animation: kerne-status-draw .35s ease-out .12s both;
|
|
832
|
+
}
|
|
833
|
+
@keyframes kerne-status-pop { from { opacity: 0; transform: scale(.75); } }
|
|
834
|
+
@keyframes kerne-status-draw { to { stroke-dashoffset: 0; } }
|
|
835
|
+
@media (prefers-reduced-motion: reduce) {
|
|
836
|
+
.kerne-status-icon[data-tone="success"] { animation: none; }
|
|
837
|
+
.kerne-status-icon[data-tone="success"] svg path { animation: none; stroke-dashoffset: 0; }
|
|
838
|
+
}
|
|
839
|
+
.kerne-status-text { font-size: 14px; color: var(--kerne-fg-muted); line-height: 1.5; margin: 0; max-width: 34ch; }
|
|
840
|
+
|
|
841
|
+
.kerne-spinner {
|
|
842
|
+
width: 20px; height: 20px;
|
|
843
|
+
border: 2px solid currentColor;
|
|
844
|
+
border-right-color: transparent;
|
|
845
|
+
border-radius: 50%;
|
|
846
|
+
animation: kerne-spin .6s linear infinite;
|
|
847
|
+
}
|
|
848
|
+
@keyframes kerne-spin { to { transform: rotate(360deg); } }
|
|
849
|
+
|
|
850
|
+
.kerne-button-danger {
|
|
851
|
+
background: transparent;
|
|
852
|
+
color: var(--kerne-danger);
|
|
853
|
+
border-color: color-mix(in srgb, var(--kerne-danger) 40%, transparent);
|
|
854
|
+
}
|
|
855
|
+
.kerne-button-danger:hover:not(:disabled) { background: color-mix(in srgb, var(--kerne-danger) 8%, transparent); }
|
|
856
|
+
|
|
857
|
+
/* ---------- Usage meters ---------- */
|
|
858
|
+
/*
|
|
859
|
+
* Same grammar as a pricing card's footer, read the other way round: the
|
|
860
|
+
* figure carries the weight and the feature name recedes under it. The old row
|
|
861
|
+
* gave both the same size, so "812 / 1,000" and "API calls" competed and the
|
|
862
|
+
* eye had to choose which one to read first.
|
|
863
|
+
*/
|
|
864
|
+
.kerne-meters { display: flex; flex-direction: column; gap: 22px; }
|
|
865
|
+
.kerne-meter { display: flex; flex-direction: column; gap: 9px; }
|
|
866
|
+
.kerne-meter-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
|
867
|
+
.kerne-meter-name { font-size: 12.5px; font-weight: 400; color: var(--kerne-fg-muted); }
|
|
868
|
+
.kerne-meter-value {
|
|
869
|
+
font-size: 15px;
|
|
870
|
+
font-weight: 600;
|
|
871
|
+
letter-spacing: -0.015em;
|
|
872
|
+
color: var(--kerne-fg);
|
|
873
|
+
/* Digits line up between rows instead of jittering as counts change. */
|
|
874
|
+
font-variant-numeric: tabular-nums;
|
|
875
|
+
}
|
|
876
|
+
.kerne-meter-track { height: 5px; border-radius: 999px; background: var(--kerne-track); overflow: hidden; }
|
|
877
|
+
/* Width still drives live updates (already correct, already tested) - the
|
|
878
|
+
mount reveal rides a transform instead, since animating width is a layout
|
|
879
|
+
thrash the fill's own final width never actually needs at first paint. */
|
|
880
|
+
.kerne-meter-fill {
|
|
881
|
+
height: 100%; border-radius: 999px; background: var(--kerne-fg);
|
|
882
|
+
transition: width .3s ease;
|
|
883
|
+
transform-origin: left;
|
|
884
|
+
animation: kerne-meter-grow-in .5s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
885
|
+
}
|
|
886
|
+
@keyframes kerne-meter-grow-in { from { transform: scaleX(0); } }
|
|
887
|
+
.kerne-meter[data-level="warning"] .kerne-meter-fill { background: var(--kerne-warning); }
|
|
888
|
+
.kerne-meter[data-level="critical"] .kerne-meter-fill { background: var(--kerne-danger); }
|
|
889
|
+
.kerne-meter[data-level="warning"] .kerne-meter-value { color: var(--kerne-warning); }
|
|
890
|
+
.kerne-meter[data-level="critical"] .kerne-meter-value { color: var(--kerne-danger); }
|
|
891
|
+
.kerne-meter-note { font-size: 11.5px; line-height: 1.5; color: var(--kerne-fg-subtle); margin: 0; }
|
|
892
|
+
/* What a soft overage costs is the one number on this screen that is money -
|
|
893
|
+
it reads at the body weight, not as another footnote. */
|
|
894
|
+
.kerne-meter-cost { color: var(--kerne-fg-muted); font-weight: 500; font-variant-numeric: tabular-nums; }
|
|
895
|
+
@media (prefers-reduced-motion: reduce) { .kerne-meter-fill { transition: none; animation: none; } }
|
|
896
|
+
|
|
897
|
+
/* ---------- Pricing table ---------- */
|
|
898
|
+
/*
|
|
899
|
+
* The catalogue grammar, shared with the dashboard's own preview: a card is a
|
|
900
|
+
* name, one big number, the call to action, and - under a hairline at the very
|
|
901
|
+
* bottom - what the plan grants as label/value rows.
|
|
902
|
+
*
|
|
903
|
+
* The rows are the load-bearing part. A checklist of ticks answers "is it in?"
|
|
904
|
+
* and hides the number that decides the purchase; a row puts the allowance
|
|
905
|
+
* where the eye already scans for it, and lets two cards be compared down a
|
|
906
|
+
* column instead of read one bullet at a time.
|
|
907
|
+
*/
|
|
908
|
+
.kerne-pricing-skeleton { width: 100%; }
|
|
909
|
+
/* A placeholder card has no subgrid to join - it lays its own rows out. */
|
|
910
|
+
.kerne-pricing-card-ghost { display: flex; gap: 18px; }
|
|
911
|
+
.kerne-pricing-ghost-rows {
|
|
912
|
+
display: flex;
|
|
913
|
+
flex-direction: column;
|
|
914
|
+
gap: 12px;
|
|
915
|
+
margin-top: auto;
|
|
916
|
+
padding-top: 14px;
|
|
917
|
+
border-top: 1px solid var(--kerne-border);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
.kerne-pricing-switch-wrap { display: flex; justify-content: center; margin: 24px 0; }
|
|
921
|
+
/*
|
|
922
|
+
* A filled track with a raised option on it - no outline. The border it used
|
|
923
|
+
* to carry drew a second edge a few pixels outside the thumb's own, which read
|
|
924
|
+
* as a control inside a control.
|
|
925
|
+
*/
|
|
926
|
+
.kerne-pricing-switch {
|
|
927
|
+
position: relative;
|
|
928
|
+
display: inline-flex;
|
|
929
|
+
gap: 2px;
|
|
930
|
+
padding: 4px;
|
|
931
|
+
background: var(--kerne-panel);
|
|
932
|
+
border-radius: 10px;
|
|
933
|
+
}
|
|
934
|
+
.kerne-pricing-switch-thumb {
|
|
935
|
+
position: absolute;
|
|
936
|
+
top: 4px;
|
|
937
|
+
bottom: 4px;
|
|
938
|
+
left: 0;
|
|
939
|
+
border-radius: 6px;
|
|
940
|
+
background: var(--kerne-surface);
|
|
941
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08), 0 1px 1px rgba(0, 0, 0, 0.04);
|
|
942
|
+
transition: transform 0.32s cubic-bezier(0.16, 1, 0.3, 1), width 0.32s cubic-bezier(0.16, 1, 0.3, 1);
|
|
943
|
+
}
|
|
944
|
+
.kerne-pricing-switch-option {
|
|
945
|
+
position: relative;
|
|
946
|
+
z-index: 1;
|
|
947
|
+
display: inline-flex;
|
|
948
|
+
align-items: center;
|
|
949
|
+
gap: 8px;
|
|
950
|
+
font: inherit;
|
|
951
|
+
font-size: 13.5px;
|
|
952
|
+
font-weight: 500;
|
|
953
|
+
line-height: 1;
|
|
954
|
+
white-space: nowrap;
|
|
955
|
+
color: var(--kerne-fg-muted);
|
|
956
|
+
background: none;
|
|
957
|
+
border: none;
|
|
958
|
+
border-radius: 6px;
|
|
959
|
+
padding: 5px 18px;
|
|
960
|
+
cursor: pointer;
|
|
961
|
+
transition: color 0.18s ease;
|
|
962
|
+
}
|
|
963
|
+
.kerne-pricing-switch-option[aria-pressed="true"] { color: var(--kerne-fg); font-weight: 600; }
|
|
964
|
+
.kerne-pricing-switch-option:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 2px; }
|
|
965
|
+
.kerne-pricing-switch-option:active { opacity: .7; }
|
|
966
|
+
/* Inline in the option, in the brand's own colour - a pill around it made a
|
|
967
|
+
second control inside the segment, and green claimed a semantic ("success")
|
|
968
|
+
that a discount does not carry. */
|
|
969
|
+
.kerne-pricing-switch-save {
|
|
970
|
+
font-size: 12.5px;
|
|
971
|
+
font-weight: 500;
|
|
972
|
+
color: var(--kerne-accent);
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/* An optional lead-in for a host building a full pricing page. Omitted, the
|
|
976
|
+
grid is exactly as before - the host's own headline stays the page's. */
|
|
977
|
+
.kerne-pricing-header {
|
|
978
|
+
display: flex;
|
|
979
|
+
flex-direction: column;
|
|
980
|
+
align-items: center;
|
|
981
|
+
gap: 11px;
|
|
982
|
+
text-align: center;
|
|
983
|
+
margin-bottom: 26px;
|
|
984
|
+
}
|
|
985
|
+
.kerne-pricing-eyebrow {
|
|
986
|
+
font-size: 11px;
|
|
987
|
+
font-weight: 500;
|
|
988
|
+
letter-spacing: 0.18em;
|
|
989
|
+
text-transform: uppercase;
|
|
990
|
+
color: var(--kerne-fg-subtle);
|
|
991
|
+
}
|
|
992
|
+
.kerne-pricing-headline {
|
|
993
|
+
margin: 0;
|
|
994
|
+
max-width: 22ch;
|
|
995
|
+
font-size: clamp(26px, 3.4vw, 40px);
|
|
996
|
+
line-height: 1.06;
|
|
997
|
+
font-weight: 600;
|
|
998
|
+
letter-spacing: -0.03em;
|
|
999
|
+
text-wrap: pretty;
|
|
1000
|
+
}
|
|
1001
|
+
.kerne-pricing-lede {
|
|
1002
|
+
margin: 0;
|
|
1003
|
+
max-width: 48ch;
|
|
1004
|
+
font-size: 13px;
|
|
1005
|
+
line-height: 1.6;
|
|
1006
|
+
color: var(--kerne-fg-muted);
|
|
1007
|
+
text-wrap: pretty;
|
|
1008
|
+
}
|
|
1009
|
+
|
|
1010
|
+
/* A plan card carries a name, a price, a button and a feature list, so it needs
|
|
1011
|
+
real width: at 252px the value column collided with its own label and a
|
|
1012
|
+
fourth plan wrapped alone onto a second row. */
|
|
1013
|
+
.kerne-pricing-grid {
|
|
1014
|
+
display: grid;
|
|
1015
|
+
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
|
1016
|
+
gap: 20px;
|
|
1017
|
+
align-items: stretch;
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
.kerne-pricing-card {
|
|
1021
|
+
position: relative;
|
|
1022
|
+
display: flex;
|
|
1023
|
+
flex-direction: column;
|
|
1024
|
+
gap: 22px;
|
|
1025
|
+
height: 100%;
|
|
1026
|
+
padding: 26px 24px 22px;
|
|
1027
|
+
background: var(--kerne-surface);
|
|
1028
|
+
border: 1px solid var(--kerne-border);
|
|
1029
|
+
border-radius: calc(var(--kerne-radius) + 6px);
|
|
1030
|
+
transition: border-color .18s ease;
|
|
1031
|
+
}
|
|
1032
|
+
/*
|
|
1033
|
+
* The featured card is its badge and its filled call to action, nothing more.
|
|
1034
|
+
* A raised background plus a heavier border plus a long shadow said "this one"
|
|
1035
|
+
* three times, and the stack of signals is what made the page read as a
|
|
1036
|
+
* template rather than as a product.
|
|
1037
|
+
*/
|
|
1038
|
+
.kerne-pricing-card:hover { border-color: var(--kerne-border-strong); }
|
|
1039
|
+
.kerne-pricing-card[data-featured="true"] { border-color: var(--kerne-border-strong); }
|
|
1040
|
+
.kerne-pricing-card[data-current="true"]:not([data-featured="true"]) { border-color: var(--kerne-border-strong); }
|
|
1041
|
+
|
|
1042
|
+
.kerne-pricing-card-head { display: flex; flex-direction: column; gap: 6px; }
|
|
1043
|
+
/* The badge shares a line with the NAME, never with the description: a short
|
|
1044
|
+
name leaves it room, and a two-line description keeps the card's full width
|
|
1045
|
+
instead of being squeezed into one word per line. */
|
|
1046
|
+
.kerne-pricing-card-titles { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
|
1047
|
+
.kerne-pricing-card-name { font-size: 18px; font-weight: 600; letter-spacing: -0.016em; line-height: 1.15; min-width: 0; }
|
|
1048
|
+
.kerne-pricing-card-desc { margin: 0; font-size: 12.5px; line-height: 1.5; color: var(--kerne-fg-muted); }
|
|
1049
|
+
|
|
1050
|
+
/* Inline in the head row, never escaping the card edge: it confirms the filled
|
|
1051
|
+
button below rather than shouting over the plan's own name. */
|
|
1052
|
+
.kerne-pricing-badge {
|
|
1053
|
+
flex: none;
|
|
1054
|
+
font-size: 10.5px;
|
|
1055
|
+
font-weight: 500;
|
|
1056
|
+
letter-spacing: 0.07em;
|
|
1057
|
+
text-transform: uppercase;
|
|
1058
|
+
white-space: nowrap;
|
|
1059
|
+
color: var(--kerne-accent);
|
|
1060
|
+
background: color-mix(in srgb, var(--kerne-accent) 12%, transparent);
|
|
1061
|
+
padding: 4px 9px;
|
|
1062
|
+
border-radius: 999px;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/* Price, button and trial travel together: the button belongs under the offer
|
|
1066
|
+
it accepts, not pinned to the bottom of a card whose footer is a table. */
|
|
1067
|
+
.kerne-pricing-body { display: flex; flex-direction: column; gap: 20px; }
|
|
1068
|
+
.kerne-pricing-price { display: flex; flex-direction: column; gap: 4px; }
|
|
1069
|
+
.kerne-pricing-price-row { display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap; }
|
|
1070
|
+
.kerne-pricing-amount {
|
|
1071
|
+
font-size: clamp(29px, 3.2vw, 38px);
|
|
1072
|
+
font-weight: 600;
|
|
1073
|
+
letter-spacing: -0.032em;
|
|
1074
|
+
line-height: 1;
|
|
1075
|
+
font-variant-numeric: tabular-nums;
|
|
1076
|
+
}
|
|
1077
|
+
.kerne-pricing-interval { font-size: 12.5px; color: var(--kerne-fg-muted); }
|
|
1078
|
+
.kerne-pricing-billed { margin: 0; font-size: 11.5px; color: var(--kerne-fg-subtle); }
|
|
1079
|
+
|
|
1080
|
+
.kerne-pricing-cta .kerne-button { width: 100%; }
|
|
1081
|
+
|
|
1082
|
+
/* Prepaid sizing: a sunken block inside the card, because what it holds is a
|
|
1083
|
+
control and not a statement. Same grammar as the footer rows above it. */
|
|
1084
|
+
.kerne-pricing-sizer {
|
|
1085
|
+
display: flex;
|
|
1086
|
+
flex-direction: column;
|
|
1087
|
+
gap: 14px;
|
|
1088
|
+
padding: 14px 14px 12px;
|
|
1089
|
+
background: var(--kerne-panel);
|
|
1090
|
+
border-radius: var(--kerne-radius);
|
|
1091
|
+
}
|
|
1092
|
+
.kerne-pricing-sizer-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
|
1093
|
+
.kerne-pricing-sizer-title {
|
|
1094
|
+
font-size: 11px;
|
|
1095
|
+
font-weight: 500;
|
|
1096
|
+
letter-spacing: 0.1em;
|
|
1097
|
+
text-transform: uppercase;
|
|
1098
|
+
color: var(--kerne-fg-subtle);
|
|
1099
|
+
}
|
|
1100
|
+
.kerne-pricing-sizer-note { font-size: 11.5px; color: var(--kerne-fg-subtle); }
|
|
1101
|
+
.kerne-pricing-sizer-row { display: flex; flex-direction: column; gap: 8px; }
|
|
1102
|
+
.kerne-pricing-sizer-line { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
|
|
1103
|
+
.kerne-pricing-sizer-name { font-size: 12.5px; color: var(--kerne-fg-muted); }
|
|
1104
|
+
.kerne-pricing-sizer-value {
|
|
1105
|
+
font-size: 13.5px;
|
|
1106
|
+
font-weight: 600;
|
|
1107
|
+
color: var(--kerne-fg);
|
|
1108
|
+
font-variant-numeric: tabular-nums;
|
|
1109
|
+
}
|
|
1110
|
+
.kerne-pricing-sizer-foot { font-size: 11.5px; color: var(--kerne-fg-subtle); }
|
|
1111
|
+
.kerne-pricing-sizer-foot[data-strong="true"] {
|
|
1112
|
+
color: var(--kerne-fg);
|
|
1113
|
+
font-weight: 500;
|
|
1114
|
+
font-variant-numeric: tabular-nums;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
/* The rail is drawn on the input itself: a wrapper would need the thumb's
|
|
1118
|
+
position mirrored in JS on every frame, and the fill is a gradient stop. */
|
|
1119
|
+
.kerne-pricing-range {
|
|
1120
|
+
-webkit-appearance: none;
|
|
1121
|
+
appearance: none;
|
|
1122
|
+
width: 100%;
|
|
1123
|
+
height: 16px;
|
|
1124
|
+
margin: 0;
|
|
1125
|
+
background: transparent;
|
|
1126
|
+
cursor: ew-resize;
|
|
1127
|
+
display: block;
|
|
1128
|
+
}
|
|
1129
|
+
.kerne-pricing-range::-webkit-slider-runnable-track {
|
|
1130
|
+
height: 5px;
|
|
1131
|
+
border-radius: 999px;
|
|
1132
|
+
background: linear-gradient(
|
|
1133
|
+
to right,
|
|
1134
|
+
var(--kerne-primary) 0 var(--kerne-fill, 0%),
|
|
1135
|
+
var(--kerne-track) var(--kerne-fill, 0%) 100%
|
|
1136
|
+
);
|
|
1137
|
+
}
|
|
1138
|
+
.kerne-pricing-range::-moz-range-track {
|
|
1139
|
+
height: 5px;
|
|
1140
|
+
border-radius: 999px;
|
|
1141
|
+
background: linear-gradient(
|
|
1142
|
+
to right,
|
|
1143
|
+
var(--kerne-primary) 0 var(--kerne-fill, 0%),
|
|
1144
|
+
var(--kerne-track) var(--kerne-fill, 0%) 100%
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
.kerne-pricing-range::-webkit-slider-thumb {
|
|
1148
|
+
-webkit-appearance: none;
|
|
1149
|
+
appearance: none;
|
|
1150
|
+
width: 15px;
|
|
1151
|
+
height: 15px;
|
|
1152
|
+
margin-top: -5px;
|
|
1153
|
+
border-radius: 50%;
|
|
1154
|
+
background: var(--kerne-surface);
|
|
1155
|
+
border: 2px solid var(--kerne-primary);
|
|
1156
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
|
1157
|
+
}
|
|
1158
|
+
.kerne-pricing-range::-moz-range-thumb {
|
|
1159
|
+
width: 15px;
|
|
1160
|
+
height: 15px;
|
|
1161
|
+
border-radius: 50%;
|
|
1162
|
+
background: var(--kerne-surface);
|
|
1163
|
+
border: 2px solid var(--kerne-primary);
|
|
1164
|
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12);
|
|
1165
|
+
}
|
|
1166
|
+
.kerne-pricing-range:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 3px; }
|
|
1167
|
+
|
|
1168
|
+
/* The value column and its note travel together on the right of a row. */
|
|
1169
|
+
.kerne-pricing-feature-side { display: flex; align-items: baseline; gap: 7px; flex: none; }
|
|
1170
|
+
.kerne-pricing-feature-note {
|
|
1171
|
+
font: inherit;
|
|
1172
|
+
font-size: 11px;
|
|
1173
|
+
padding: 0;
|
|
1174
|
+
background: none;
|
|
1175
|
+
border: 0;
|
|
1176
|
+
border-bottom: 1px dotted var(--kerne-border-strong);
|
|
1177
|
+
color: var(--kerne-fg-subtle);
|
|
1178
|
+
cursor: help;
|
|
1179
|
+
}
|
|
1180
|
+
.kerne-pricing-feature-note:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 3px; }
|
|
1181
|
+
|
|
1182
|
+
/* Opens upward: these notes live in the card's footer, where downward has
|
|
1183
|
+
nowhere to go. Hover AND focus, so a keyboard reaches the same explanation. */
|
|
1184
|
+
.kerne-pricing-pop-host { position: relative; display: inline-flex; }
|
|
1185
|
+
.kerne-pricing-pop {
|
|
1186
|
+
position: absolute;
|
|
1187
|
+
right: 0;
|
|
1188
|
+
bottom: calc(100% + 9px);
|
|
1189
|
+
z-index: 30;
|
|
1190
|
+
display: flex;
|
|
1191
|
+
flex-direction: column;
|
|
1192
|
+
width: 252px;
|
|
1193
|
+
padding: 12px 14px;
|
|
1194
|
+
text-align: left;
|
|
1195
|
+
background: var(--kerne-surface-raised);
|
|
1196
|
+
color: var(--kerne-fg);
|
|
1197
|
+
border: 1px solid var(--kerne-border);
|
|
1198
|
+
border-radius: var(--kerne-radius);
|
|
1199
|
+
box-shadow: var(--kerne-shadow);
|
|
1200
|
+
opacity: 0;
|
|
1201
|
+
transform: translateY(4px);
|
|
1202
|
+
pointer-events: none;
|
|
1203
|
+
transition: opacity 0.15s ease, transform 0.15s ease;
|
|
1204
|
+
}
|
|
1205
|
+
.kerne-pricing-pop-host:hover .kerne-pricing-pop,
|
|
1206
|
+
.kerne-pricing-pop-host:focus-within .kerne-pricing-pop {
|
|
1207
|
+
opacity: 1;
|
|
1208
|
+
transform: none;
|
|
1209
|
+
}
|
|
1210
|
+
.kerne-pricing-pop-title {
|
|
1211
|
+
font-size: 11px;
|
|
1212
|
+
font-weight: 500;
|
|
1213
|
+
letter-spacing: 0.1em;
|
|
1214
|
+
text-transform: uppercase;
|
|
1215
|
+
color: var(--kerne-fg-subtle);
|
|
1216
|
+
padding-bottom: 8px;
|
|
1217
|
+
}
|
|
1218
|
+
.kerne-pricing-pop-row {
|
|
1219
|
+
display: flex;
|
|
1220
|
+
align-items: baseline;
|
|
1221
|
+
justify-content: space-between;
|
|
1222
|
+
gap: 14px;
|
|
1223
|
+
padding: 4px 0;
|
|
1224
|
+
}
|
|
1225
|
+
.kerne-pricing-pop-key { font-size: 11.5px; color: var(--kerne-fg-muted); flex: none; }
|
|
1226
|
+
.kerne-pricing-pop-value { font-size: 11.5px; font-weight: 500; text-align: right; }
|
|
1227
|
+
@media (prefers-reduced-motion: reduce) { .kerne-pricing-pop { transition: none; } }
|
|
1228
|
+
/* When nothing is included the rate stands in for the value, so it reads at
|
|
1229
|
+
the value's weight rather than as a footnote. */
|
|
1230
|
+
.kerne-pricing-feature-note[data-lead="true"] {
|
|
1231
|
+
font-size: 12px;
|
|
1232
|
+
font-weight: 500;
|
|
1233
|
+
color: var(--kerne-fg);
|
|
1234
|
+
font-variant-numeric: tabular-nums;
|
|
1235
|
+
}
|
|
1236
|
+
.kerne-pricing-trial { margin: -8px 0 0; font-size: 11.5px; text-align: center; color: var(--kerne-fg-subtle); }
|
|
1237
|
+
|
|
1238
|
+
/* The footer of the card: one hairline, then what the plan grants. */
|
|
1239
|
+
.kerne-pricing-features {
|
|
1240
|
+
list-style: none;
|
|
1241
|
+
margin: auto 0 0;
|
|
1242
|
+
padding: 14px 0 0;
|
|
1243
|
+
border-top: 1px solid var(--kerne-border);
|
|
1244
|
+
display: flex;
|
|
1245
|
+
flex-direction: column;
|
|
1246
|
+
gap: 12px;
|
|
1247
|
+
}
|
|
1248
|
+
.kerne-pricing-feature {
|
|
1249
|
+
display: flex;
|
|
1250
|
+
align-items: baseline;
|
|
1251
|
+
justify-content: space-between;
|
|
1252
|
+
gap: 12px;
|
|
1253
|
+
font-size: 12px;
|
|
1254
|
+
line-height: 1.4;
|
|
1255
|
+
}
|
|
1256
|
+
.kerne-pricing-feature-name { color: var(--kerne-fg-muted); min-width: 0; }
|
|
1257
|
+
.kerne-pricing-feature-value {
|
|
1258
|
+
flex: none;
|
|
1259
|
+
font-weight: 500;
|
|
1260
|
+
color: var(--kerne-fg);
|
|
1261
|
+
text-align: right;
|
|
1262
|
+
font-variant-numeric: tabular-nums;
|
|
1263
|
+
}
|
|
1264
|
+
.kerne-pricing-feature-more { font-size: 11.5px; color: var(--kerne-fg-subtle); }
|
|
1265
|
+
/* A plan that sells at a price but lists nothing is the confusing case - say
|
|
1266
|
+
why instead of leaving a hairline over empty space. */
|
|
1267
|
+
.kerne-pricing-feature-empty { font-size: 11.5px; line-height: 1.5; color: var(--kerne-fg-subtle); }
|
|
1268
|
+
|
|
1269
|
+
/*
|
|
1270
|
+
* Every card's head and footer sit on the same lines across the row, whatever
|
|
1271
|
+
* each one packs in between. Without it a card with more to say pushes its own
|
|
1272
|
+
* feature table out of line with its neighbours' - the tell that a pricing
|
|
1273
|
+
* table was stacked rather than laid out. Behind @supports: browsers without
|
|
1274
|
+
* subgrid keep the flex column above, which is correct, just not aligned.
|
|
1275
|
+
*/
|
|
1276
|
+
@supports (grid-template-rows: subgrid) {
|
|
1277
|
+
.kerne-pricing-grid { grid-template-rows: auto auto auto; }
|
|
1278
|
+
.kerne-pricing-card:not(.kerne-pricing-card-ghost) {
|
|
1279
|
+
display: grid;
|
|
1280
|
+
grid-row: span 3;
|
|
1281
|
+
grid-template-rows: subgrid;
|
|
1282
|
+
}
|
|
1283
|
+
/* Four children, and nothing to align with - it keeps the flex column. */
|
|
1284
|
+
.kerne-pricing-card-ghost { grid-row: auto; }
|
|
1285
|
+
/* Heads and footers line up across the row; the body between them keeps its
|
|
1286
|
+
own height, so a plan with nothing to size doesn't get its button dragged
|
|
1287
|
+
down to meet one that has. */
|
|
1288
|
+
.kerne-pricing-body { align-self: start; }
|
|
1289
|
+
.kerne-pricing-features { align-self: end; }
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1293
|
+
.kerne-pricing-switch-thumb { transition: none; }
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
/* ---------- Sections (profile, settings) ---------- */
|
|
1297
|
+
.kerne-section-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
|
|
1298
|
+
.kerne-section-title { font-size: 17px; font-weight: 600; letter-spacing: -0.014em; margin: 0; }
|
|
1299
|
+
|
|
1300
|
+
/* One decision per row: the label recedes, the value carries the weight. */
|
|
1301
|
+
.kerne-rows { display: flex; flex-direction: column; gap: 11px; }
|
|
1302
|
+
.kerne-row { display: flex; justify-content: space-between; align-items: baseline; gap: 16px; font-size: 13px; }
|
|
1303
|
+
.kerne-row dt { color: var(--kerne-fg-muted); margin: 0; }
|
|
1304
|
+
.kerne-row dd { margin: 0; font-weight: 500; text-align: right; font-variant-numeric: tabular-nums; }
|
|
1305
|
+
|
|
1306
|
+
.kerne-badge {
|
|
1307
|
+
display: inline-flex;
|
|
1308
|
+
align-items: center;
|
|
1309
|
+
font-size: 12px;
|
|
1310
|
+
font-weight: 500;
|
|
1311
|
+
padding: 2px 8px;
|
|
1312
|
+
border-radius: 999px;
|
|
1313
|
+
background: var(--kerne-panel);
|
|
1314
|
+
color: var(--kerne-fg-muted);
|
|
1315
|
+
white-space: nowrap;
|
|
1316
|
+
}
|
|
1317
|
+
.kerne-badge[data-tone="success"] { color: var(--kerne-success); }
|
|
1318
|
+
.kerne-badge[data-tone="warning"] { color: var(--kerne-warning); }
|
|
1319
|
+
.kerne-badge[data-tone="danger"] { color: var(--kerne-danger); }
|
|
1320
|
+
|
|
1321
|
+
/* ---------- Identity ---------- */
|
|
1322
|
+
.kerne-avatar {
|
|
1323
|
+
width: 32px; height: 32px; border-radius: 50%;
|
|
1324
|
+
background: var(--kerne-panel); color: var(--kerne-fg-muted);
|
|
1325
|
+
display: flex; align-items: center; justify-content: center;
|
|
1326
|
+
font-size: 12px; font-weight: 600; letter-spacing: .02em; flex-shrink: 0;
|
|
1327
|
+
object-fit: cover;
|
|
1328
|
+
transition: box-shadow .15s ease;
|
|
1329
|
+
}
|
|
1330
|
+
/* Focus already gets an outline from .kerne-menu-trigger; hover gets its own
|
|
1331
|
+
ring so the trigger reads as interactive before the menu is even open. */
|
|
1332
|
+
.kerne-menu-trigger:hover .kerne-avatar { box-shadow: 0 0 0 3px color-mix(in srgb, var(--kerne-accent) 25%, transparent); }
|
|
1333
|
+
.kerne-identity { display: flex; align-items: center; gap: 12px; }
|
|
1334
|
+
.kerne-identity-text { display: flex; flex-direction: column; min-width: 0; }
|
|
1335
|
+
.kerne-identity-name { font-size: 14px; font-weight: 500; }
|
|
1336
|
+
.kerne-identity-mail { font-size: 13px; color: var(--kerne-fg-muted); overflow: hidden; text-overflow: ellipsis; }
|
|
1337
|
+
|
|
1338
|
+
.kerne-menu-wrap { position: relative; display: inline-flex; }
|
|
1339
|
+
.kerne-menu-trigger {
|
|
1340
|
+
background: none; border: none; padding: 0; cursor: pointer;
|
|
1341
|
+
border-radius: 50%; display: inline-flex; font: inherit;
|
|
1342
|
+
}
|
|
1343
|
+
.kerne-menu-trigger:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: 2px; }
|
|
1344
|
+
.kerne-menu {
|
|
1345
|
+
position: absolute; top: calc(100% + 8px); right: 0; z-index: 50;
|
|
1346
|
+
min-width: 240px; padding: 8px;
|
|
1347
|
+
display: flex; flex-direction: column; gap: 2px;
|
|
1348
|
+
/* Needs an opaque ground of its own: it floats over host content. */
|
|
1349
|
+
background: var(--kerne-surface);
|
|
1350
|
+
border: 1px solid var(--kerne-border);
|
|
1351
|
+
border-radius: var(--kerne-radius);
|
|
1352
|
+
box-shadow: var(--kerne-shadow);
|
|
1353
|
+
transform-origin: top right;
|
|
1354
|
+
animation: kerne-menu-in .15s cubic-bezier(0.16, 1, 0.3, 1) both;
|
|
1355
|
+
}
|
|
1356
|
+
@keyframes kerne-menu-in { from { opacity: 0; transform: scale(.96) translateY(-4px); } }
|
|
1357
|
+
@media (prefers-reduced-motion: reduce) { .kerne-menu { animation: none; } }
|
|
1358
|
+
.kerne-menu-header { padding: 8px 10px 10px; border-bottom: 1px solid var(--kerne-border); margin-bottom: 6px; }
|
|
1359
|
+
.kerne-menu-item {
|
|
1360
|
+
font: inherit; font-size: 14px; text-align: left;
|
|
1361
|
+
background: none; border: none; cursor: pointer;
|
|
1362
|
+
padding: 8px 10px; border-radius: 6px; color: var(--kerne-fg);
|
|
1363
|
+
display: block; width: 100%; text-decoration: none;
|
|
1364
|
+
}
|
|
1365
|
+
.kerne-menu-item:hover { background: var(--kerne-panel); }
|
|
1366
|
+
.kerne-menu-item:focus-visible { outline: 2px solid var(--kerne-accent); outline-offset: -2px; }
|
|
1367
|
+
.kerne-menu-item:active { opacity: .7; }
|
|
1368
|
+
.kerne-menu-item[data-tone="danger"] { color: var(--kerne-danger); }
|
|
1369
|
+
|
|
1370
|
+
/* ---------- Loading ---------- */
|
|
1371
|
+
.kerne-skeleton {
|
|
1372
|
+
background: var(--kerne-panel);
|
|
1373
|
+
border-radius: var(--kerne-radius);
|
|
1374
|
+
height: var(--kerne-control-height);
|
|
1375
|
+
animation: kerne-pulse 1.6s ease-in-out infinite;
|
|
1376
|
+
}
|
|
1377
|
+
@keyframes kerne-pulse { 50% { opacity: .55; } }
|
|
1378
|
+
@media (prefers-reduced-motion: reduce) {
|
|
1379
|
+
.kerne-skeleton { animation: none; }
|
|
1380
|
+
.kerne-spinner { animation-duration: 2s; }
|
|
1381
|
+
.kerne-input, .kerne-button, .kerne-switch, .kerne-avatar, .kerne-pricing-card { transition: none; }
|
|
1382
|
+
.kerne-button:active:not(:disabled) { transform: none; }
|
|
1383
|
+
.kerne-pricing-card:hover:not([data-featured="true"]) { transform: none; }
|
|
1384
|
+
}
|
|
1385
|
+
`;
|
|
1386
|
+
function extractTokens(css) {
|
|
1387
|
+
const tokens = {};
|
|
1388
|
+
const re = /--kerne-([a-z-]+):\s*([^;]+);/g;
|
|
1389
|
+
let match;
|
|
1390
|
+
while (match = re.exec(css)) {
|
|
1391
|
+
tokens[match[1]] = match[2].trim();
|
|
1392
|
+
}
|
|
1393
|
+
return tokens;
|
|
1394
|
+
}
|
|
1395
|
+
var ROOT_BLOCK = CSS.match(/\.kerne-root\s*\{([^}]+)\}/)?.[1] ?? "";
|
|
1396
|
+
var defaultTokens = {
|
|
1397
|
+
light: extractTokens(ROOT_BLOCK),
|
|
1398
|
+
dark: extractTokens(DARK_TOKENS)
|
|
1399
|
+
};
|
|
1400
|
+
function ensureStylesInjected() {
|
|
1401
|
+
if (typeof document === "undefined") return;
|
|
1402
|
+
if (document.getElementById(STYLE_ELEMENT_ID)) return;
|
|
1403
|
+
const style = document.createElement("style");
|
|
1404
|
+
style.id = STYLE_ELEMENT_ID;
|
|
1405
|
+
style.textContent = CSS;
|
|
1406
|
+
document.head.appendChild(style);
|
|
1407
|
+
}
|
|
1408
|
+
function parseColor(input) {
|
|
1409
|
+
const value = input.trim();
|
|
1410
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(value);
|
|
1411
|
+
if (hex) {
|
|
1412
|
+
const digits = hex[1].length === 3 ? hex[1].split("").map((c) => c + c).join("") : hex[1];
|
|
1413
|
+
return [
|
|
1414
|
+
parseInt(digits.slice(0, 2), 16),
|
|
1415
|
+
parseInt(digits.slice(2, 4), 16),
|
|
1416
|
+
parseInt(digits.slice(4, 6), 16)
|
|
1417
|
+
];
|
|
1418
|
+
}
|
|
1419
|
+
const rgb = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(value);
|
|
1420
|
+
if (rgb) return [Number(rgb[1]), Number(rgb[2]), Number(rgb[3])];
|
|
1421
|
+
return null;
|
|
1422
|
+
}
|
|
1423
|
+
function luminance([r, g, b]) {
|
|
1424
|
+
const channel = (v) => {
|
|
1425
|
+
const s = Math.min(Math.max(v, 0), 255) / 255;
|
|
1426
|
+
return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
|
|
1427
|
+
};
|
|
1428
|
+
return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b);
|
|
1429
|
+
}
|
|
1430
|
+
function toHex([r, g, b]) {
|
|
1431
|
+
const pair = (v) => Math.round(Math.min(Math.max(v, 0), 255)).toString(16).padStart(2, "0");
|
|
1432
|
+
return `#${pair(r)}${pair(g)}${pair(b)}`;
|
|
1433
|
+
}
|
|
1434
|
+
function mix(color, toward, amount) {
|
|
1435
|
+
return color.map((c) => c + (toward - c) * amount);
|
|
1436
|
+
}
|
|
1437
|
+
var LABEL_CROSSOVER = 0.2;
|
|
1438
|
+
function appearanceToStyle(appearance) {
|
|
1439
|
+
if (!appearance) return {};
|
|
1440
|
+
const vars = {};
|
|
1441
|
+
if (appearance.primaryColor) {
|
|
1442
|
+
vars["--kerne-primary"] = appearance.primaryColor;
|
|
1443
|
+
vars["--kerne-accent"] = appearance.accentColor ?? appearance.primaryColor;
|
|
1444
|
+
const rgb = parseColor(appearance.primaryColor);
|
|
1445
|
+
if (rgb) {
|
|
1446
|
+
const light = luminance(rgb) > LABEL_CROSSOVER;
|
|
1447
|
+
vars["--kerne-primary-fg"] = appearance.primaryTextColor ?? (light ? "#16171a" : "#ffffff");
|
|
1448
|
+
vars["--kerne-primary-hover"] = toHex(mix(rgb, light ? 0 : 255, 0.14));
|
|
1449
|
+
} else if (appearance.primaryTextColor) {
|
|
1450
|
+
vars["--kerne-primary-fg"] = appearance.primaryTextColor;
|
|
1451
|
+
}
|
|
1452
|
+
} else {
|
|
1453
|
+
if (appearance.accentColor) vars["--kerne-accent"] = appearance.accentColor;
|
|
1454
|
+
if (appearance.primaryTextColor) vars["--kerne-primary-fg"] = appearance.primaryTextColor;
|
|
1455
|
+
}
|
|
1456
|
+
if (appearance.textColor) vars["--kerne-fg"] = appearance.textColor;
|
|
1457
|
+
if (appearance.borderColor) vars["--kerne-border-strong"] = appearance.borderColor;
|
|
1458
|
+
if (appearance.panelColor) vars["--kerne-panel"] = appearance.panelColor;
|
|
1459
|
+
if (appearance.background) vars["--kerne-bg"] = appearance.background;
|
|
1460
|
+
if (appearance.surfaceColor) {
|
|
1461
|
+
vars["--kerne-surface"] = appearance.surfaceColor;
|
|
1462
|
+
vars["--kerne-surface-raised"] = appearance.surfaceColor;
|
|
1463
|
+
}
|
|
1464
|
+
if (appearance.fontFamily) vars["--kerne-font"] = appearance.fontFamily;
|
|
1465
|
+
if (appearance.radius) vars["--kerne-radius"] = appearance.radius;
|
|
1466
|
+
if (appearance.controlHeight) vars["--kerne-control-height"] = appearance.controlHeight;
|
|
1467
|
+
return vars;
|
|
1468
|
+
}
|
|
1469
|
+
function resolveTheme(appearance) {
|
|
1470
|
+
return appearance?.theme ?? "light";
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
// src/provider.tsx
|
|
1474
|
+
import { createContext as createContext3, useEffect, useMemo, useRef } from "react";
|
|
1475
|
+
import { Kerne } from "@kerne/server";
|
|
1476
|
+
|
|
1477
|
+
// src/ui/primitives.tsx
|
|
1478
|
+
import { useId, useState, useContext as useContext2, createContext as createContext2 } from "react";
|
|
1479
|
+
|
|
1480
|
+
// src/ui/password.ts
|
|
1481
|
+
var MIN_PASSWORD_LENGTH = 8;
|
|
1482
|
+
var BASELINE_POLICY = {
|
|
1483
|
+
min_length: MIN_PASSWORD_LENGTH,
|
|
1484
|
+
require_uppercase: false,
|
|
1485
|
+
require_number: false,
|
|
1486
|
+
require_symbol: false
|
|
1487
|
+
};
|
|
1488
|
+
function resolvePolicy(config) {
|
|
1489
|
+
return config?.password_policy ?? BASELINE_POLICY;
|
|
1490
|
+
}
|
|
1491
|
+
function passwordRules(password, policy) {
|
|
1492
|
+
const rules = [{ key: "min_length", met: password.length >= policy.min_length }];
|
|
1493
|
+
if (policy.require_uppercase) {
|
|
1494
|
+
rules.push({ key: "require_uppercase", met: /[A-Z]/.test(password) });
|
|
1495
|
+
}
|
|
1496
|
+
if (policy.require_number) {
|
|
1497
|
+
rules.push({ key: "require_number", met: /\d/.test(password) });
|
|
1498
|
+
}
|
|
1499
|
+
if (policy.require_symbol) {
|
|
1500
|
+
rules.push({ key: "require_symbol", met: /[^A-Za-z0-9]/.test(password) });
|
|
1501
|
+
}
|
|
1502
|
+
return rules;
|
|
1503
|
+
}
|
|
1504
|
+
function meetsPolicy(password, policy) {
|
|
1505
|
+
return passwordRules(password, policy).every((rule) => rule.met);
|
|
1506
|
+
}
|
|
1507
|
+
function passwordStrength(password) {
|
|
1508
|
+
if (password.length < MIN_PASSWORD_LENGTH) return 0;
|
|
1509
|
+
const variety = [/[a-z]/, /[A-Z]/, /\d/, /[^A-Za-z0-9]/].filter((re) => re.test(password)).length;
|
|
1510
|
+
let score = 1;
|
|
1511
|
+
if (password.length >= 12) score++;
|
|
1512
|
+
if (password.length >= 16) score++;
|
|
1513
|
+
if (variety >= 3) score++;
|
|
1514
|
+
return Math.min(score, 4);
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
// src/ui/primitives.tsx
|
|
1518
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
1519
|
+
var InsideShell = createContext2(false);
|
|
1520
|
+
var AppearanceContext = createContext2(void 0);
|
|
1521
|
+
function useResolvedAppearance(local) {
|
|
1522
|
+
const inherited = useContext2(AppearanceContext);
|
|
1523
|
+
if (!inherited) return local;
|
|
1524
|
+
if (!local) return inherited;
|
|
1525
|
+
return { ...inherited, ...local };
|
|
1526
|
+
}
|
|
1527
|
+
function Shell({
|
|
1528
|
+
appearance,
|
|
1529
|
+
className,
|
|
1530
|
+
size = "auth",
|
|
1531
|
+
children
|
|
1532
|
+
}) {
|
|
1533
|
+
ensureStylesInjected();
|
|
1534
|
+
const nested = useContext2(InsideShell);
|
|
1535
|
+
const resolved = useResolvedAppearance(appearance);
|
|
1536
|
+
if (nested) {
|
|
1537
|
+
return /* @__PURE__ */ jsx("div", { className, children });
|
|
1538
|
+
}
|
|
1539
|
+
return /* @__PURE__ */ jsx(InsideShell.Provider, { value: true, children: /* @__PURE__ */ jsx(
|
|
1540
|
+
"div",
|
|
1541
|
+
{
|
|
1542
|
+
className: `kerne-root${className ? ` ${className}` : ""}`,
|
|
1543
|
+
style: appearanceToStyle(resolved),
|
|
1544
|
+
"data-kerne-theme": resolveTheme(resolved),
|
|
1545
|
+
children: /* @__PURE__ */ jsx(
|
|
1546
|
+
"div",
|
|
1547
|
+
{
|
|
1548
|
+
className: "kerne-shell",
|
|
1549
|
+
"data-kerne-size": size === "wide" ? "wide" : void 0,
|
|
1550
|
+
"data-kerne-surface": resolved?.surface === "card" ? "card" : void 0,
|
|
1551
|
+
children
|
|
1552
|
+
}
|
|
1553
|
+
)
|
|
1554
|
+
}
|
|
1555
|
+
) });
|
|
1556
|
+
}
|
|
1557
|
+
function Subsection({
|
|
1558
|
+
title,
|
|
1559
|
+
description,
|
|
1560
|
+
children
|
|
1561
|
+
}) {
|
|
1562
|
+
return /* @__PURE__ */ jsxs("section", { className: "kerne-subsection", children: [
|
|
1563
|
+
/* @__PURE__ */ jsxs("div", { className: "kerne-subsection-head", children: [
|
|
1564
|
+
/* @__PURE__ */ jsx("h3", { className: "kerne-subsection-title", children: title }),
|
|
1565
|
+
description ? /* @__PURE__ */ jsx("p", { className: "kerne-subsection-desc", children: description }) : null
|
|
1566
|
+
] }),
|
|
1567
|
+
children
|
|
1568
|
+
] });
|
|
1569
|
+
}
|
|
1570
|
+
function Masthead({
|
|
1571
|
+
logo,
|
|
1572
|
+
title,
|
|
1573
|
+
subtitle
|
|
1574
|
+
}) {
|
|
1575
|
+
return /* @__PURE__ */ jsxs("div", { className: "kerne-masthead", children: [
|
|
1576
|
+
logo ? /* @__PURE__ */ jsx("div", { className: "kerne-logo", children: logo }) : null,
|
|
1577
|
+
/* @__PURE__ */ jsxs("div", { className: "kerne-heading", children: [
|
|
1578
|
+
/* @__PURE__ */ jsx("h1", { className: "kerne-title", children: title }),
|
|
1579
|
+
subtitle ? /* @__PURE__ */ jsx("p", { className: "kerne-subtitle", children: subtitle }) : null
|
|
1580
|
+
] })
|
|
1581
|
+
] });
|
|
1582
|
+
}
|
|
1583
|
+
function prefersAutoFocus() {
|
|
1584
|
+
if (typeof window === "undefined" || !window.matchMedia) return false;
|
|
1585
|
+
return window.matchMedia("(pointer: fine)").matches;
|
|
1586
|
+
}
|
|
1587
|
+
function Field({
|
|
1588
|
+
label,
|
|
1589
|
+
type,
|
|
1590
|
+
value,
|
|
1591
|
+
onChange,
|
|
1592
|
+
autoComplete,
|
|
1593
|
+
placeholder,
|
|
1594
|
+
disabled,
|
|
1595
|
+
required,
|
|
1596
|
+
invalid,
|
|
1597
|
+
error,
|
|
1598
|
+
action,
|
|
1599
|
+
code,
|
|
1600
|
+
autoFocus,
|
|
1601
|
+
children
|
|
1602
|
+
}) {
|
|
1603
|
+
const id = useId();
|
|
1604
|
+
const errorId = `${id}-error`;
|
|
1605
|
+
const noteId = `${id}-note`;
|
|
1606
|
+
const l10n = useLocalization();
|
|
1607
|
+
const isPassword = type === "password";
|
|
1608
|
+
const [revealed, setRevealed] = useState(false);
|
|
1609
|
+
const [capsLock, setCapsLock] = useState(false);
|
|
1610
|
+
const trackCapsLock = (e) => {
|
|
1611
|
+
if (!isPassword) return;
|
|
1612
|
+
setCapsLock(e.getModifierState?.("CapsLock") ?? false);
|
|
1613
|
+
};
|
|
1614
|
+
const describedBy = [error ? errorId : null, children ? noteId : null].filter(Boolean).join(" ");
|
|
1615
|
+
return /* @__PURE__ */ jsxs("div", { className: "kerne-field", children: [
|
|
1616
|
+
action ? /* @__PURE__ */ jsxs("div", { className: "kerne-label-row", children: [
|
|
1617
|
+
/* @__PURE__ */ jsx("label", { className: "kerne-label", htmlFor: id, children: label }),
|
|
1618
|
+
action
|
|
1619
|
+
] }) : /* @__PURE__ */ jsx("label", { className: "kerne-label", htmlFor: id, children: label }),
|
|
1620
|
+
/* @__PURE__ */ jsxs("div", { className: "kerne-control", children: [
|
|
1621
|
+
/* @__PURE__ */ jsx(
|
|
1622
|
+
"input",
|
|
1623
|
+
{
|
|
1624
|
+
id,
|
|
1625
|
+
className: "kerne-input",
|
|
1626
|
+
type: isPassword && revealed ? "text" : type,
|
|
1627
|
+
value,
|
|
1628
|
+
onChange: (e) => onChange(e.target.value),
|
|
1629
|
+
onKeyUp: trackCapsLock,
|
|
1630
|
+
onBlur: () => setCapsLock(false),
|
|
1631
|
+
autoComplete,
|
|
1632
|
+
placeholder,
|
|
1633
|
+
disabled,
|
|
1634
|
+
required,
|
|
1635
|
+
"aria-invalid": invalid || !!error || void 0,
|
|
1636
|
+
"aria-describedby": describedBy || void 0,
|
|
1637
|
+
"data-kerne-code": code ? "true" : void 0,
|
|
1638
|
+
"data-kerne-reveal": isPassword ? "true" : void 0,
|
|
1639
|
+
autoFocus: autoFocus && prefersAutoFocus()
|
|
1640
|
+
}
|
|
1641
|
+
),
|
|
1642
|
+
isPassword ? /* @__PURE__ */ jsx(
|
|
1643
|
+
"button",
|
|
1644
|
+
{
|
|
1645
|
+
type: "button",
|
|
1646
|
+
className: "kerne-reveal",
|
|
1647
|
+
onClick: () => setRevealed((v) => !v),
|
|
1648
|
+
disabled,
|
|
1649
|
+
"aria-label": revealed ? l10n.common.hidePassword : l10n.common.showPassword,
|
|
1650
|
+
"aria-pressed": revealed,
|
|
1651
|
+
tabIndex: -1,
|
|
1652
|
+
children: revealed ? /* @__PURE__ */ jsx(EyeOffIcon, {}) : /* @__PURE__ */ jsx(EyeIcon, {})
|
|
1653
|
+
}
|
|
1654
|
+
) : null
|
|
1655
|
+
] }),
|
|
1656
|
+
children ? /* @__PURE__ */ jsx("div", { id: noteId, children }) : null,
|
|
1657
|
+
capsLock ? /* @__PURE__ */ jsx("p", { className: "kerne-field-note", "data-kerne-warn": "true", role: "status", children: l10n.common.capsLockOn }) : null,
|
|
1658
|
+
error ? /* @__PURE__ */ jsx("p", { className: "kerne-field-error", id: errorId, role: "alert", children: error }) : null
|
|
1659
|
+
] });
|
|
1660
|
+
}
|
|
1661
|
+
function EyeIcon() {
|
|
1662
|
+
return /* @__PURE__ */ jsxs(
|
|
1663
|
+
"svg",
|
|
1664
|
+
{
|
|
1665
|
+
width: "16",
|
|
1666
|
+
height: "16",
|
|
1667
|
+
viewBox: "0 0 24 24",
|
|
1668
|
+
fill: "none",
|
|
1669
|
+
stroke: "currentColor",
|
|
1670
|
+
strokeWidth: "2",
|
|
1671
|
+
"aria-hidden": "true",
|
|
1672
|
+
children: [
|
|
1673
|
+
/* @__PURE__ */ jsx("path", { d: "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7z", strokeLinejoin: "round" }),
|
|
1674
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "3" })
|
|
1675
|
+
]
|
|
1676
|
+
}
|
|
1677
|
+
);
|
|
1678
|
+
}
|
|
1679
|
+
function EyeOffIcon() {
|
|
1680
|
+
return /* @__PURE__ */ jsxs(
|
|
1681
|
+
"svg",
|
|
1682
|
+
{
|
|
1683
|
+
width: "16",
|
|
1684
|
+
height: "16",
|
|
1685
|
+
viewBox: "0 0 24 24",
|
|
1686
|
+
fill: "none",
|
|
1687
|
+
stroke: "currentColor",
|
|
1688
|
+
strokeWidth: "2",
|
|
1689
|
+
"aria-hidden": "true",
|
|
1690
|
+
children: [
|
|
1691
|
+
/* @__PURE__ */ jsx(
|
|
1692
|
+
"path",
|
|
1693
|
+
{
|
|
1694
|
+
d: "M10.6 6.2A9.9 9.9 0 0 1 12 6c6.4 0 10 7 10 7a15.7 15.7 0 0 1-3 3.7M6.2 7.4A15.6 15.6 0 0 0 2 13s3.6 7 10 7a9.7 9.7 0 0 0 4.3-1",
|
|
1695
|
+
strokeLinecap: "round",
|
|
1696
|
+
strokeLinejoin: "round"
|
|
1697
|
+
}
|
|
1698
|
+
),
|
|
1699
|
+
/* @__PURE__ */ jsx("path", { d: "m3 3 18 18", strokeLinecap: "round" })
|
|
1700
|
+
]
|
|
1701
|
+
}
|
|
1702
|
+
);
|
|
1703
|
+
}
|
|
1704
|
+
function Button({
|
|
1705
|
+
children,
|
|
1706
|
+
variant = "primary",
|
|
1707
|
+
disabled,
|
|
1708
|
+
loading,
|
|
1709
|
+
type = "submit",
|
|
1710
|
+
onClick,
|
|
1711
|
+
ariaLabel
|
|
1712
|
+
}) {
|
|
1713
|
+
return /* @__PURE__ */ jsxs(
|
|
1714
|
+
"button",
|
|
1715
|
+
{
|
|
1716
|
+
type,
|
|
1717
|
+
className: `kerne-button kerne-button-${variant}`,
|
|
1718
|
+
disabled: disabled || loading,
|
|
1719
|
+
onClick,
|
|
1720
|
+
"aria-label": ariaLabel,
|
|
1721
|
+
"aria-busy": loading || void 0,
|
|
1722
|
+
"data-kerne-loading": loading ? "true" : void 0,
|
|
1723
|
+
children: [
|
|
1724
|
+
loading ? /* @__PURE__ */ jsx("span", { className: "kerne-spinner", "aria-hidden": "true" }) : null,
|
|
1725
|
+
children
|
|
1726
|
+
]
|
|
1727
|
+
}
|
|
1728
|
+
);
|
|
1729
|
+
}
|
|
1730
|
+
function PasswordStrength({
|
|
1731
|
+
password,
|
|
1732
|
+
policy
|
|
1733
|
+
}) {
|
|
1734
|
+
const l10n = useLocalization();
|
|
1735
|
+
const resolved = policy ?? resolvePolicy(null);
|
|
1736
|
+
if (!meetsPolicy(password, resolved)) {
|
|
1737
|
+
const labels2 = {
|
|
1738
|
+
min_length: fill(l10n.common.passwordHint, { count: resolved.min_length }),
|
|
1739
|
+
require_uppercase: l10n.common.passwordRequireUppercase,
|
|
1740
|
+
require_number: l10n.common.passwordRequireNumber,
|
|
1741
|
+
require_symbol: l10n.common.passwordRequireSymbol
|
|
1742
|
+
};
|
|
1743
|
+
const rules = passwordRules(password, resolved);
|
|
1744
|
+
if (rules.length === 1) {
|
|
1745
|
+
return /* @__PURE__ */ jsx("p", { className: "kerne-field-note", children: labels2[rules[0].key] });
|
|
1746
|
+
}
|
|
1747
|
+
return /* @__PURE__ */ jsx("ul", { className: "kerne-rules", children: rules.map((rule) => /* @__PURE__ */ jsxs("li", { className: "kerne-rule", "data-met": rule.met ? "true" : void 0, children: [
|
|
1748
|
+
/* @__PURE__ */ jsx("span", { "aria-hidden": "true", children: rule.met ? "\u2713" : "\xB7" }),
|
|
1749
|
+
labels2[rule.key]
|
|
1750
|
+
] }, rule.key)) });
|
|
1751
|
+
}
|
|
1752
|
+
const score = passwordStrength(password);
|
|
1753
|
+
const labels = [
|
|
1754
|
+
l10n.common.strengthWeak,
|
|
1755
|
+
l10n.common.strengthWeak,
|
|
1756
|
+
l10n.common.strengthFair,
|
|
1757
|
+
l10n.common.strengthGood,
|
|
1758
|
+
l10n.common.strengthStrong
|
|
1759
|
+
];
|
|
1760
|
+
return /* @__PURE__ */ jsxs("div", { className: "kerne-strength", "data-score": score, children: [
|
|
1761
|
+
/* @__PURE__ */ jsx("div", { className: "kerne-strength-track", "aria-hidden": "true", children: [1, 2, 3, 4].map((step) => /* @__PURE__ */ jsx(
|
|
1762
|
+
"span",
|
|
1763
|
+
{
|
|
1764
|
+
className: "kerne-strength-step",
|
|
1765
|
+
"data-on": score >= step ? "true" : void 0
|
|
1766
|
+
},
|
|
1767
|
+
step
|
|
1768
|
+
)) }),
|
|
1769
|
+
/* @__PURE__ */ jsxs("span", { className: "kerne-strength-label", children: [
|
|
1770
|
+
l10n.common.strengthLabel,
|
|
1771
|
+
": ",
|
|
1772
|
+
labels[score]
|
|
1773
|
+
] })
|
|
1774
|
+
] });
|
|
1775
|
+
}
|
|
1776
|
+
function MethodSwitch({
|
|
1777
|
+
onClick,
|
|
1778
|
+
children
|
|
1779
|
+
}) {
|
|
1780
|
+
return /* @__PURE__ */ jsx("button", { type: "button", className: "kerne-switch", onClick, children });
|
|
1781
|
+
}
|
|
1782
|
+
function AlertIcon() {
|
|
1783
|
+
return /* @__PURE__ */ jsxs(
|
|
1784
|
+
"svg",
|
|
1785
|
+
{
|
|
1786
|
+
width: "16",
|
|
1787
|
+
height: "16",
|
|
1788
|
+
viewBox: "0 0 24 24",
|
|
1789
|
+
fill: "none",
|
|
1790
|
+
stroke: "currentColor",
|
|
1791
|
+
strokeWidth: "2",
|
|
1792
|
+
"aria-hidden": "true",
|
|
1793
|
+
children: [
|
|
1794
|
+
/* @__PURE__ */ jsx("circle", { cx: "12", cy: "12", r: "10" }),
|
|
1795
|
+
/* @__PURE__ */ jsx("path", { d: "M12 8v4M12 16h.01", strokeLinecap: "round" })
|
|
1796
|
+
]
|
|
1797
|
+
}
|
|
1798
|
+
);
|
|
1799
|
+
}
|
|
1800
|
+
function MailIcon() {
|
|
1801
|
+
return /* @__PURE__ */ jsxs(
|
|
1802
|
+
"svg",
|
|
1803
|
+
{
|
|
1804
|
+
width: "16",
|
|
1805
|
+
height: "16",
|
|
1806
|
+
viewBox: "0 0 24 24",
|
|
1807
|
+
fill: "none",
|
|
1808
|
+
stroke: "currentColor",
|
|
1809
|
+
strokeWidth: "2",
|
|
1810
|
+
"aria-hidden": "true",
|
|
1811
|
+
children: [
|
|
1812
|
+
/* @__PURE__ */ jsx("rect", { x: "2", y: "4", width: "20", height: "16", rx: "2" }),
|
|
1813
|
+
/* @__PURE__ */ jsx("path", { d: "m2 7 10 6 10-6", strokeLinecap: "round", strokeLinejoin: "round" })
|
|
1814
|
+
]
|
|
1815
|
+
}
|
|
1816
|
+
);
|
|
1817
|
+
}
|
|
1818
|
+
function Panel({
|
|
1819
|
+
children,
|
|
1820
|
+
variant = "info"
|
|
1821
|
+
}) {
|
|
1822
|
+
const danger = variant === "danger";
|
|
1823
|
+
const icon = danger ? /* @__PURE__ */ jsx(AlertIcon, {}) : variant === "success" ? /* @__PURE__ */ jsx(CheckIcon, {}) : /* @__PURE__ */ jsx(MailIcon, {});
|
|
1824
|
+
return /* @__PURE__ */ jsxs(
|
|
1825
|
+
"div",
|
|
1826
|
+
{
|
|
1827
|
+
className: `kerne-panel${danger ? " kerne-panel-danger" : ""}${variant === "success" ? " kerne-panel-success" : ""}`,
|
|
1828
|
+
...danger ? { role: "alert" } : { role: "status", "aria-live": "polite" },
|
|
1829
|
+
children: [
|
|
1830
|
+
icon,
|
|
1831
|
+
/* @__PURE__ */ jsx("span", { children })
|
|
1832
|
+
]
|
|
1833
|
+
}
|
|
1834
|
+
);
|
|
1835
|
+
}
|
|
1836
|
+
function CheckIcon() {
|
|
1837
|
+
return /* @__PURE__ */ jsx(
|
|
1838
|
+
"svg",
|
|
1839
|
+
{
|
|
1840
|
+
width: "16",
|
|
1841
|
+
height: "16",
|
|
1842
|
+
viewBox: "0 0 24 24",
|
|
1843
|
+
fill: "none",
|
|
1844
|
+
stroke: "currentColor",
|
|
1845
|
+
strokeWidth: "2",
|
|
1846
|
+
"aria-hidden": "true",
|
|
1847
|
+
children: /* @__PURE__ */ jsx("path", { d: "m5 13 4 4L19 7", strokeLinecap: "round", strokeLinejoin: "round" })
|
|
1848
|
+
}
|
|
1849
|
+
);
|
|
1850
|
+
}
|
|
1851
|
+
function Status({
|
|
1852
|
+
tone,
|
|
1853
|
+
title,
|
|
1854
|
+
children,
|
|
1855
|
+
action
|
|
1856
|
+
}) {
|
|
1857
|
+
return /* @__PURE__ */ jsxs("div", { className: "kerne-status", ...tone === "danger" ? { role: "alert" } : {}, children: [
|
|
1858
|
+
/* @__PURE__ */ jsx("div", { className: "kerne-status-icon", "data-tone": tone === "loading" ? "muted" : tone, children: tone === "loading" ? /* @__PURE__ */ jsx("span", { className: "kerne-spinner", "aria-hidden": "true" }) : tone === "success" ? /* @__PURE__ */ jsx(CheckIcon, {}) : /* @__PURE__ */ jsx(AlertIcon, {}) }),
|
|
1859
|
+
/* @__PURE__ */ jsxs("div", { className: "kerne-heading", children: [
|
|
1860
|
+
/* @__PURE__ */ jsx("h1", { className: "kerne-title", children: title }),
|
|
1861
|
+
children ? /* @__PURE__ */ jsx("p", { className: "kerne-status-text", children }) : null
|
|
1862
|
+
] }),
|
|
1863
|
+
action
|
|
1864
|
+
] });
|
|
1865
|
+
}
|
|
1866
|
+
function Divider({ label }) {
|
|
1867
|
+
return /* @__PURE__ */ jsx("div", { className: "kerne-divider", children: label });
|
|
1868
|
+
}
|
|
1869
|
+
function TextLink({
|
|
1870
|
+
href,
|
|
1871
|
+
onClick,
|
|
1872
|
+
children
|
|
1873
|
+
}) {
|
|
1874
|
+
if (onClick) {
|
|
1875
|
+
return /* @__PURE__ */ jsx("button", { type: "button", className: "kerne-link", onClick, children });
|
|
1876
|
+
}
|
|
1877
|
+
return /* @__PURE__ */ jsx("a", { className: "kerne-link", href: href ?? "#", children });
|
|
1878
|
+
}
|
|
1879
|
+
var OAUTH_LABELS = {
|
|
1880
|
+
google: "Google",
|
|
1881
|
+
github: "GitHub",
|
|
1882
|
+
microsoft: "Microsoft",
|
|
1883
|
+
apple: "Apple",
|
|
1884
|
+
gitlab: "GitLab",
|
|
1885
|
+
discord: "Discord"
|
|
1886
|
+
};
|
|
1887
|
+
function OAuthIcon({ provider }) {
|
|
1888
|
+
const common = { width: 18, height: 18, viewBox: "0 0 24 24", "aria-hidden": true };
|
|
1889
|
+
switch (provider) {
|
|
1890
|
+
case "google":
|
|
1891
|
+
return /* @__PURE__ */ jsxs("svg", { ...common, children: [
|
|
1892
|
+
/* @__PURE__ */ jsx(
|
|
1893
|
+
"path",
|
|
1894
|
+
{
|
|
1895
|
+
fill: "#4285F4",
|
|
1896
|
+
d: "M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z"
|
|
1897
|
+
}
|
|
1898
|
+
),
|
|
1899
|
+
/* @__PURE__ */ jsx(
|
|
1900
|
+
"path",
|
|
1901
|
+
{
|
|
1902
|
+
fill: "#34A853",
|
|
1903
|
+
d: "M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
|
|
1904
|
+
}
|
|
1905
|
+
),
|
|
1906
|
+
/* @__PURE__ */ jsx(
|
|
1907
|
+
"path",
|
|
1908
|
+
{
|
|
1909
|
+
fill: "#FBBC05",
|
|
1910
|
+
d: "M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
|
|
1911
|
+
}
|
|
1912
|
+
),
|
|
1913
|
+
/* @__PURE__ */ jsx(
|
|
1914
|
+
"path",
|
|
1915
|
+
{
|
|
1916
|
+
fill: "#EA4335",
|
|
1917
|
+
d: "M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
|
|
1918
|
+
}
|
|
1919
|
+
)
|
|
1920
|
+
] });
|
|
1921
|
+
case "github":
|
|
1922
|
+
return /* @__PURE__ */ jsx("svg", { ...common, fill: "currentColor", children: /* @__PURE__ */ jsx("path", { d: "M12 .3a12 12 0 0 0-3.8 23.4c.6.1.8-.26.8-.57v-2c-3.34.72-4.04-1.61-4.04-1.61-.55-1.39-1.34-1.76-1.34-1.76-1.09-.75.08-.73.08-.73 1.2.09 1.84 1.24 1.84 1.24 1.07 1.83 2.81 1.3 3.5 1 .1-.78.42-1.31.76-1.61-2.67-.3-5.47-1.33-5.47-5.93 0-1.31.47-2.38 1.24-3.22-.14-.3-.54-1.52.1-3.18 0 0 1-.32 3.3 1.23a11.5 11.5 0 0 1 6 0c2.28-1.55 3.29-1.23 3.29-1.23.64 1.66.24 2.88.12 3.18.77.84 1.23 1.91 1.23 3.22 0 4.61-2.8 5.63-5.48 5.92.43.37.81 1.1.81 2.22v3.29c0 .31.2.68.81.57A12 12 0 0 0 12 .3z" }) });
|
|
1923
|
+
case "apple":
|
|
1924
|
+
return /* @__PURE__ */ jsx("svg", { ...common, fill: "currentColor", children: /* @__PURE__ */ jsx("path", { d: "M17.05 12.75c-.03-2.6 2.12-3.85 2.22-3.91-1.21-1.77-3.09-2.01-3.76-2.04-1.6-.16-3.12.94-3.93.94-.81 0-2.06-.92-3.39-.9-1.74.03-3.35 1.01-4.25 2.57-1.81 3.14-.46 7.79 1.3 10.34.86 1.25 1.89 2.65 3.24 2.6 1.3-.05 1.79-.84 3.36-.84 1.57 0 2.01.84 3.38.81 1.4-.02 2.28-1.27 3.13-2.53.99-1.45 1.4-2.86 1.42-2.93-.03-.01-2.72-1.04-2.75-4.13zM14.5 4.9c.71-.87 1.19-2.07 1.06-3.27-1.02.04-2.26.68-3 1.55-.66.76-1.24 1.98-1.09 3.15 1.14.09 2.31-.58 3.03-1.43z" }) });
|
|
1925
|
+
default:
|
|
1926
|
+
return null;
|
|
1927
|
+
}
|
|
1928
|
+
}
|
|
1929
|
+
function OAuthButtons({
|
|
1930
|
+
providers,
|
|
1931
|
+
disabled,
|
|
1932
|
+
onSelect
|
|
1933
|
+
}) {
|
|
1934
|
+
const l10n = useLocalization();
|
|
1935
|
+
if (providers.length === 0) return null;
|
|
1936
|
+
return /* @__PURE__ */ jsx("div", { className: "kerne-oauth-group", children: providers.map((provider) => {
|
|
1937
|
+
const label = fill(l10n.common.continueWith, {
|
|
1938
|
+
provider: OAUTH_LABELS[provider] ?? provider
|
|
1939
|
+
});
|
|
1940
|
+
return /* @__PURE__ */ jsxs(
|
|
1941
|
+
"button",
|
|
1942
|
+
{
|
|
1943
|
+
type: "button",
|
|
1944
|
+
className: "kerne-button kerne-button-outline",
|
|
1945
|
+
disabled,
|
|
1946
|
+
onClick: () => onSelect?.(provider),
|
|
1947
|
+
"aria-label": label,
|
|
1948
|
+
children: [
|
|
1949
|
+
/* @__PURE__ */ jsx(OAuthIcon, { provider }),
|
|
1950
|
+
label
|
|
1951
|
+
]
|
|
1952
|
+
},
|
|
1953
|
+
provider
|
|
1954
|
+
);
|
|
1955
|
+
}) });
|
|
1956
|
+
}
|
|
1957
|
+
function KerneBranding({ show }) {
|
|
1958
|
+
const l10n = useLocalization();
|
|
1959
|
+
if (!show) return null;
|
|
1960
|
+
return /* @__PURE__ */ jsx("a", { className: "kerne-branding", href: "https://kerne.io", target: "_blank", rel: "noopener noreferrer", children: l10n.common.securedByKerne });
|
|
1961
|
+
}
|
|
1962
|
+
function FormSkeleton() {
|
|
1963
|
+
const l10n = useLocalization();
|
|
1964
|
+
return /* @__PURE__ */ jsxs("div", { className: "kerne-form", "aria-busy": "true", "aria-label": l10n.common.loading, children: [
|
|
1965
|
+
/* @__PURE__ */ jsx("div", { className: "kerne-skeleton" }),
|
|
1966
|
+
/* @__PURE__ */ jsx("div", { className: "kerne-skeleton" })
|
|
1967
|
+
] });
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
// src/provider.tsx
|
|
1971
|
+
import { jsx as jsx2 } from "react/jsx-runtime";
|
|
1972
|
+
var DEFAULT_STORAGE_KEY = "kerne_auth";
|
|
1973
|
+
var DEFAULT_REFRESH_BUFFER = 60;
|
|
1974
|
+
var HANDOFF_QUERY_PARAM = "kerne_handoff";
|
|
1975
|
+
var KerneClient = class {
|
|
1976
|
+
kerne;
|
|
1977
|
+
storage;
|
|
1978
|
+
storageKey;
|
|
1979
|
+
onAuthChange;
|
|
1980
|
+
onHandoffError;
|
|
1981
|
+
autoRefresh;
|
|
1982
|
+
refreshBuffer;
|
|
1983
|
+
refreshTimer = null;
|
|
1984
|
+
listeners = /* @__PURE__ */ new Set();
|
|
1985
|
+
_user = null;
|
|
1986
|
+
_token = null;
|
|
1987
|
+
_refreshToken = null;
|
|
1988
|
+
_expiresAt = null;
|
|
1989
|
+
_isLoading = true;
|
|
1990
|
+
_authConfigPromise = null;
|
|
1991
|
+
baseUrl;
|
|
1992
|
+
appId;
|
|
1993
|
+
constructor(config) {
|
|
1994
|
+
this.baseUrl = config.baseUrl ?? "https://api.kerne.io";
|
|
1995
|
+
this.appId = config.appId;
|
|
1996
|
+
this.storage = config.storage ?? (typeof window !== "undefined" ? localStorage : null);
|
|
1997
|
+
this.storageKey = config.storageKey ?? DEFAULT_STORAGE_KEY;
|
|
1998
|
+
this.onAuthChange = config.onAuthChange;
|
|
1999
|
+
this.onHandoffError = config.onHandoffError;
|
|
2000
|
+
this.autoRefresh = config.autoRefresh ?? true;
|
|
2001
|
+
this.refreshBuffer = config.refreshBuffer ?? DEFAULT_REFRESH_BUFFER;
|
|
2002
|
+
this.kerne = new Kerne({
|
|
2003
|
+
baseUrl: this.baseUrl,
|
|
2004
|
+
appId: this.appId,
|
|
2005
|
+
timeout: config.timeout,
|
|
2006
|
+
// Forwarded so <KerneProvider headers={...}> is actually honored - the
|
|
2007
|
+
// type inherited it from KerneConfig, but the client was dropping it silently.
|
|
2008
|
+
headers: config.headers,
|
|
2009
|
+
onUnauthorized: () => {
|
|
2010
|
+
if (process.env.NODE_ENV === "development") {
|
|
2011
|
+
console.warn("[Kerne] 401 received. Auto-logout disabled in dev.");
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
this.clearSession();
|
|
2015
|
+
}
|
|
2016
|
+
});
|
|
2017
|
+
this.loadSession();
|
|
2018
|
+
}
|
|
2019
|
+
subscribe(listener) {
|
|
2020
|
+
this.listeners.add(listener);
|
|
2021
|
+
return () => this.listeners.delete(listener);
|
|
2022
|
+
}
|
|
2023
|
+
notify() {
|
|
2024
|
+
this.listeners.forEach((l) => l());
|
|
2025
|
+
}
|
|
2026
|
+
get user() {
|
|
2027
|
+
return this._user;
|
|
2028
|
+
}
|
|
2029
|
+
get token() {
|
|
2030
|
+
return this._token;
|
|
2031
|
+
}
|
|
2032
|
+
get isAuthenticated() {
|
|
2033
|
+
return this._token !== null;
|
|
2034
|
+
}
|
|
2035
|
+
get isLoading() {
|
|
2036
|
+
return this._isLoading;
|
|
2037
|
+
}
|
|
2038
|
+
loadSession() {
|
|
2039
|
+
const handoffCode = this.getHandoffCodeFromUrl();
|
|
2040
|
+
if (handoffCode) {
|
|
2041
|
+
void this.exchangeHandoffOnLoad(handoffCode);
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
if (!this.storage) {
|
|
2045
|
+
this._isLoading = false;
|
|
2046
|
+
this.notify();
|
|
2047
|
+
return;
|
|
2048
|
+
}
|
|
2049
|
+
try {
|
|
2050
|
+
const data = this.storage.getItem(this.storageKey);
|
|
2051
|
+
if (data) {
|
|
2052
|
+
const { user, token, refresh_token, expires_at } = JSON.parse(data);
|
|
2053
|
+
const expiresAt = expires_at ? new Date(expires_at) : null;
|
|
2054
|
+
if (!expiresAt || expiresAt > /* @__PURE__ */ new Date()) {
|
|
2055
|
+
this._user = user;
|
|
2056
|
+
this._token = token;
|
|
2057
|
+
this._refreshToken = refresh_token ?? null;
|
|
2058
|
+
this._expiresAt = expiresAt;
|
|
2059
|
+
this.kerne = this.kerne.withToken(token);
|
|
2060
|
+
this.scheduleRefresh();
|
|
2061
|
+
} else if (refresh_token && this.autoRefresh) {
|
|
2062
|
+
this._refreshToken = refresh_token;
|
|
2063
|
+
void this.refreshOnLoad();
|
|
2064
|
+
return;
|
|
2065
|
+
} else {
|
|
2066
|
+
this.storage.removeItem(this.storageKey);
|
|
2067
|
+
}
|
|
2068
|
+
}
|
|
2069
|
+
} catch (e) {
|
|
2070
|
+
console.error("[Kerne] Failed to load session:", e);
|
|
2071
|
+
this.storage?.removeItem(this.storageKey);
|
|
2072
|
+
}
|
|
2073
|
+
this._isLoading = false;
|
|
2074
|
+
this.notify();
|
|
2075
|
+
}
|
|
2076
|
+
getHandoffCodeFromUrl() {
|
|
2077
|
+
if (typeof window === "undefined") return null;
|
|
2078
|
+
return new URLSearchParams(window.location.search).get(HANDOFF_QUERY_PARAM);
|
|
2079
|
+
}
|
|
2080
|
+
/**
|
|
2081
|
+
* Strips only `kerne_handoff` from the URL, leaving the rest of the
|
|
2082
|
+
* path/query (e.g. a `return_to` the host app added) untouched - and
|
|
2083
|
+
* without adding a history entry, so back-navigation doesn't resurrect it.
|
|
2084
|
+
*/
|
|
2085
|
+
stripHandoffParamFromUrl() {
|
|
2086
|
+
if (typeof window === "undefined") return;
|
|
2087
|
+
const url = new URL(window.location.href);
|
|
2088
|
+
url.searchParams.delete(HANDOFF_QUERY_PARAM);
|
|
2089
|
+
window.history.replaceState(window.history.state, "", url.toString());
|
|
2090
|
+
}
|
|
2091
|
+
/**
|
|
2092
|
+
* Same shape as `refreshOnLoad`: a bootstrap-only path invoked once from
|
|
2093
|
+
* `loadSession()` when a `kerne_handoff` code is present in the URL,
|
|
2094
|
+
* entirely invisible to the host app - no code on their end required.
|
|
2095
|
+
*
|
|
2096
|
+
* Works no matter which page the browser lands on: `redirectUrl` is a hard
|
|
2097
|
+
* navigation (`window.location.href`), so the destination reloads the SDK
|
|
2098
|
+
* fresh and `loadSession()` reads the code straight off that page's own
|
|
2099
|
+
* URL - there is no dependency on a specific "callback route", as long as
|
|
2100
|
+
* `KerneProvider` wraps whatever the host renders there (normally true when
|
|
2101
|
+
* it is mounted once at the app root).
|
|
2102
|
+
*
|
|
2103
|
+
* One retry on a transient failure (network blip, 5xx) - worth it because
|
|
2104
|
+
* the code was never reached by the server in that case, so it is still
|
|
2105
|
+
* unspent. A 4xx (invalid/expired/already-consumed) is not retried: the
|
|
2106
|
+
* code is gone, retrying only delays the "logged out" outcome.
|
|
2107
|
+
*/
|
|
2108
|
+
async exchangeHandoffOnLoad(code) {
|
|
2109
|
+
try {
|
|
2110
|
+
for (let attempt = 0; ; attempt++) {
|
|
2111
|
+
try {
|
|
2112
|
+
const response = await this.kerne.auth.exchangeHandoff(code);
|
|
2113
|
+
await this.saveSession(response);
|
|
2114
|
+
return;
|
|
2115
|
+
} catch (e) {
|
|
2116
|
+
const statusCode = e.statusCode;
|
|
2117
|
+
const transient = statusCode === 0 || typeof statusCode === "number" && statusCode >= 500;
|
|
2118
|
+
if (transient && attempt === 0) {
|
|
2119
|
+
await new Promise((resolve) => setTimeout(resolve, 400));
|
|
2120
|
+
continue;
|
|
2121
|
+
}
|
|
2122
|
+
console.warn("[Kerne] Handoff exchange failed:", e);
|
|
2123
|
+
this.onHandoffError?.(e);
|
|
2124
|
+
return;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
} finally {
|
|
2128
|
+
this.stripHandoffParamFromUrl();
|
|
2129
|
+
this._isLoading = false;
|
|
2130
|
+
this.notify();
|
|
2131
|
+
}
|
|
2132
|
+
}
|
|
2133
|
+
async refreshOnLoad() {
|
|
2134
|
+
try {
|
|
2135
|
+
const response = await this.kerne.auth.refreshToken({
|
|
2136
|
+
refresh_token: this._refreshToken
|
|
2137
|
+
});
|
|
2138
|
+
await this.saveSession(response);
|
|
2139
|
+
} catch (e) {
|
|
2140
|
+
console.warn("[Kerne] Startup refresh failed, clearing session:", e);
|
|
2141
|
+
this._refreshToken = null;
|
|
2142
|
+
this.storage?.removeItem(this.storageKey);
|
|
2143
|
+
} finally {
|
|
2144
|
+
this._isLoading = false;
|
|
2145
|
+
this.notify();
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* `AuthResponse.user` is the minimal `{id, email, role}` claim set the
|
|
2150
|
+
* token endpoint returns, not the full profile (`User`) - so this always
|
|
2151
|
+
* follows up with `users.me()` rather than assigning it directly (that
|
|
2152
|
+
* used to leave `_user` looking like a `User` while actually missing
|
|
2153
|
+
* `email_verified`/`first_name`/`avatar`/etc).
|
|
2154
|
+
*/
|
|
2155
|
+
async saveSession(response) {
|
|
2156
|
+
this._token = response.token;
|
|
2157
|
+
this._refreshToken = response.refresh_token;
|
|
2158
|
+
this._expiresAt = response.expires_at ? new Date(response.expires_at) : null;
|
|
2159
|
+
this.kerne = this.kerne.withToken(response.token);
|
|
2160
|
+
try {
|
|
2161
|
+
this._user = await this.kerne.users.me();
|
|
2162
|
+
} catch (e) {
|
|
2163
|
+
const sameUser = this._user?.id === response.user?.id;
|
|
2164
|
+
console.error("[Kerne] Failed to load the user profile:", e);
|
|
2165
|
+
this._user = sameUser ? this._user : null;
|
|
2166
|
+
}
|
|
2167
|
+
if (this.storage) {
|
|
2168
|
+
this.storage.setItem(
|
|
2169
|
+
this.storageKey,
|
|
2170
|
+
JSON.stringify({
|
|
2171
|
+
user: this._user,
|
|
2172
|
+
token: response.token,
|
|
2173
|
+
refresh_token: response.refresh_token,
|
|
2174
|
+
expires_at: response.expires_at
|
|
2175
|
+
})
|
|
2176
|
+
);
|
|
2177
|
+
}
|
|
2178
|
+
this.scheduleRefresh();
|
|
2179
|
+
this.onAuthChange?.(this._user);
|
|
2180
|
+
this.notify();
|
|
2181
|
+
}
|
|
2182
|
+
clearSession() {
|
|
2183
|
+
this._user = null;
|
|
2184
|
+
this._token = null;
|
|
2185
|
+
this._refreshToken = null;
|
|
2186
|
+
this._expiresAt = null;
|
|
2187
|
+
this.cancelRefresh();
|
|
2188
|
+
if (this.storage) {
|
|
2189
|
+
this.storage.removeItem(this.storageKey);
|
|
2190
|
+
}
|
|
2191
|
+
this.onAuthChange?.(null);
|
|
2192
|
+
this.notify();
|
|
2193
|
+
}
|
|
2194
|
+
scheduleRefresh() {
|
|
2195
|
+
if (!this.autoRefresh || !this._expiresAt) return;
|
|
2196
|
+
this.cancelRefresh();
|
|
2197
|
+
const now = Date.now();
|
|
2198
|
+
const expiry = this._expiresAt.getTime();
|
|
2199
|
+
const refreshAt = expiry - this.refreshBuffer * 1e3;
|
|
2200
|
+
const delay = Math.max(0, refreshAt - now);
|
|
2201
|
+
if (delay > 0) {
|
|
2202
|
+
this.refreshTimer = setTimeout(() => this.silentRefresh(), delay);
|
|
2203
|
+
}
|
|
2204
|
+
}
|
|
2205
|
+
cancelRefresh() {
|
|
2206
|
+
if (this.refreshTimer) {
|
|
2207
|
+
clearTimeout(this.refreshTimer);
|
|
2208
|
+
this.refreshTimer = null;
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
async silentRefresh() {
|
|
2212
|
+
if (!this._refreshToken) return;
|
|
2213
|
+
try {
|
|
2214
|
+
const response = await this.kerne.auth.refreshToken({ refresh_token: this._refreshToken });
|
|
2215
|
+
await this.saveSession(response);
|
|
2216
|
+
console.debug("[Kerne] Session refreshed silently");
|
|
2217
|
+
} catch (e) {
|
|
2218
|
+
console.warn("[Kerne] Silent refresh failed:", e);
|
|
2219
|
+
}
|
|
2220
|
+
}
|
|
2221
|
+
async register(params) {
|
|
2222
|
+
const [firstName, ...lastNameParts] = (params.name || "").split(" ");
|
|
2223
|
+
const response = await this.kerne.auth.signup({
|
|
2224
|
+
email: params.email,
|
|
2225
|
+
password: params.password,
|
|
2226
|
+
first_name: firstName || void 0,
|
|
2227
|
+
last_name: lastNameParts.join(" ") || void 0,
|
|
2228
|
+
invitationToken: params.invitationToken,
|
|
2229
|
+
invitationCode: params.invitationCode
|
|
2230
|
+
});
|
|
2231
|
+
await this.saveSession(response);
|
|
2232
|
+
return response;
|
|
2233
|
+
}
|
|
2234
|
+
async login(params) {
|
|
2235
|
+
const response = await this.kerne.auth.login(params);
|
|
2236
|
+
await this.saveSession(response);
|
|
2237
|
+
return response;
|
|
2238
|
+
}
|
|
2239
|
+
/** Manually trigger a token refresh - normally handled automatically by `autoRefresh`. */
|
|
2240
|
+
async refreshToken() {
|
|
2241
|
+
if (!this._refreshToken) return null;
|
|
2242
|
+
const response = await this.kerne.auth.refreshToken({ refresh_token: this._refreshToken });
|
|
2243
|
+
await this.saveSession(response);
|
|
2244
|
+
return response;
|
|
2245
|
+
}
|
|
2246
|
+
logout() {
|
|
2247
|
+
this.clearSession();
|
|
2248
|
+
}
|
|
2249
|
+
async refreshUser() {
|
|
2250
|
+
if (!this._token) return null;
|
|
2251
|
+
try {
|
|
2252
|
+
const user = await this.kerne.users.me();
|
|
2253
|
+
this._user = user;
|
|
2254
|
+
this.onAuthChange?.(user);
|
|
2255
|
+
this.notify();
|
|
2256
|
+
return user;
|
|
2257
|
+
} catch {
|
|
2258
|
+
this.clearSession();
|
|
2259
|
+
return null;
|
|
2260
|
+
}
|
|
2261
|
+
}
|
|
2262
|
+
// No `avatar` here - UpdateUserDto doesn't accept it server-side
|
|
2263
|
+
// (`forbidNonWhitelisted` rejects the field with a 400).
|
|
2264
|
+
async updateProfile(params) {
|
|
2265
|
+
if (!this._user) throw new Error("User not authenticated");
|
|
2266
|
+
const updated = await this.kerne.users.update(this._user.id, params);
|
|
2267
|
+
this._user = updated;
|
|
2268
|
+
if (this.storage && this._token) {
|
|
2269
|
+
try {
|
|
2270
|
+
const data = this.storage.getItem(this.storageKey);
|
|
2271
|
+
if (data) {
|
|
2272
|
+
const session = JSON.parse(data);
|
|
2273
|
+
session.user = updated;
|
|
2274
|
+
this.storage.setItem(this.storageKey, JSON.stringify(session));
|
|
2275
|
+
}
|
|
2276
|
+
} catch (e) {
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
this.onAuthChange?.(updated);
|
|
2280
|
+
this.notify();
|
|
2281
|
+
return updated;
|
|
2282
|
+
}
|
|
2283
|
+
async updatePassword(params) {
|
|
2284
|
+
await this.kerne.auth.changePassword(params);
|
|
2285
|
+
}
|
|
2286
|
+
/** Soft-deletes (deactivates) the caller's own account, then clears the local session. */
|
|
2287
|
+
async requestAccountDeletion() {
|
|
2288
|
+
await this.kerne.auth.requestAccountDeletion();
|
|
2289
|
+
this.clearSession();
|
|
2290
|
+
}
|
|
2291
|
+
/** Forgot-password flow (logged out) - sends the reset email. */
|
|
2292
|
+
async requestPasswordReset(email) {
|
|
2293
|
+
await this.kerne.auth.requestPasswordReset(email);
|
|
2294
|
+
}
|
|
2295
|
+
/** Confirms a password reset with the token from the email. */
|
|
2296
|
+
async confirmPasswordReset(token, password) {
|
|
2297
|
+
await this.kerne.auth.confirmPasswordReset(token, password);
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Sends a magic-link email (logged out). Anti-enumeration: always resolves,
|
|
2301
|
+
* regardless of whether the email is registered.
|
|
2302
|
+
*/
|
|
2303
|
+
async startPasswordless(email, invitation) {
|
|
2304
|
+
await this.kerne.auth.startPasswordless(email, invitation);
|
|
2305
|
+
}
|
|
2306
|
+
/** Completes a magic-link login using the `token` query param from the emailed link. */
|
|
2307
|
+
async loginWithMagicLink(token) {
|
|
2308
|
+
const response = await this.kerne.auth.loginWithMagicLink(token);
|
|
2309
|
+
await this.saveSession(response);
|
|
2310
|
+
return response;
|
|
2311
|
+
}
|
|
2312
|
+
// Account Activation
|
|
2313
|
+
// Same token/endpoint for an admin-created user and a self-service
|
|
2314
|
+
// magic-link signup confirmation - see AuthResource.getActivationContext.
|
|
2315
|
+
/** Read-only: which UI to render for an activation token, without consuming it. */
|
|
2316
|
+
async getActivationContext(token) {
|
|
2317
|
+
return this.kerne.auth.getActivationContext(token);
|
|
2318
|
+
}
|
|
2319
|
+
/** Completes activation (sets a password or confirms magic-link mode), then logs the user in. */
|
|
2320
|
+
async completeActivation(token, password) {
|
|
2321
|
+
const response = await this.kerne.auth.completeActivation(token, password);
|
|
2322
|
+
await this.saveSession(response);
|
|
2323
|
+
return response;
|
|
2324
|
+
}
|
|
2325
|
+
// Cross-Origin Session Handoff
|
|
2326
|
+
// For a hosted auth origin (auth on its own domain, separate from the
|
|
2327
|
+
// tenant's app): call this at the end of a successful auth flow
|
|
2328
|
+
// (login/magic-link/register/activation) instead of
|
|
2329
|
+
// a plain `window.location.href = url` whenever `url` isn't same-origin -
|
|
2330
|
+
// the exchange on the other end is picked up automatically by
|
|
2331
|
+
// `exchangeHandoffOnLoad()`, no code required on the tenant app's side.
|
|
2332
|
+
/**
|
|
2333
|
+
* Mints a handoff code for the current session and redirects the browser
|
|
2334
|
+
* to `url` with it attached (`?kerne_handoff=...`) - never the real
|
|
2335
|
+
* token/refresh_token, which would otherwise leak into server logs,
|
|
2336
|
+
* browser history, and third-party `Referer` headers on the landing page.
|
|
2337
|
+
*/
|
|
2338
|
+
async redirectWithSession(url) {
|
|
2339
|
+
const { code } = await this.kerne.auth.handoff();
|
|
2340
|
+
const separator = url.includes("?") ? "&" : "?";
|
|
2341
|
+
const target = `${url}${separator}${HANDOFF_QUERY_PARAM}=${encodeURIComponent(code)}`;
|
|
2342
|
+
if (typeof window !== "undefined") window.location.href = target;
|
|
2343
|
+
}
|
|
2344
|
+
/**
|
|
2345
|
+
* The tenant's public auth config - which sign-in methods to render, whether
|
|
2346
|
+
* registration is open, whether to show Kerne branding.
|
|
2347
|
+
*
|
|
2348
|
+
* Deduped on the in-flight promise rather than the resolved value: the
|
|
2349
|
+
* prebuilt forms all read this on mount, and two of them mounted together
|
|
2350
|
+
* (a login screen with a register link prefetching) would otherwise fire two
|
|
2351
|
+
* identical requests before either resolved. Cached for the client's
|
|
2352
|
+
* lifetime - this config changes on the order of "the owner edited their
|
|
2353
|
+
* settings", not per render.
|
|
2354
|
+
*/
|
|
2355
|
+
async getAuthConfig() {
|
|
2356
|
+
if (!this._authConfigPromise) {
|
|
2357
|
+
this._authConfigPromise = this.kerne.auth.config().catch((e) => {
|
|
2358
|
+
this._authConfigPromise = null;
|
|
2359
|
+
throw e;
|
|
2360
|
+
});
|
|
2361
|
+
}
|
|
2362
|
+
return this._authConfigPromise;
|
|
2363
|
+
}
|
|
2364
|
+
/**
|
|
2365
|
+
* Errors are not swallowed here (unlike the old implementation, which
|
|
2366
|
+
* caught everything and returned `false`) - a 401/500 must not be
|
|
2367
|
+
* indistinguishable from a real denial, that's exactly what let the
|
|
2368
|
+
* `has_access`/`allowed` mismatch below ship unnoticed. Callers that want
|
|
2369
|
+
* a fail-closed boolean regardless of the reason (e.g. `<Access>`)
|
|
2370
|
+
* catch around this themselves.
|
|
2371
|
+
*/
|
|
2372
|
+
async allows(featureKey, requested) {
|
|
2373
|
+
const check = await this.kerne.check(featureKey, { requested });
|
|
2374
|
+
return check.allowed;
|
|
2375
|
+
}
|
|
2376
|
+
/** Full entitlement detail (limit/used/remaining/overage) - use `allows()` for a plain boolean. */
|
|
2377
|
+
async check(featureKey, requested) {
|
|
2378
|
+
return this.kerne.check(featureKey, { requested });
|
|
2379
|
+
}
|
|
2380
|
+
/** Atomically check a QUOTA limit and commit the consumption. Reports for the signed-in subject. */
|
|
2381
|
+
async consume(featureKey, params) {
|
|
2382
|
+
return this.kerne.consume(featureKey, params);
|
|
2383
|
+
}
|
|
2384
|
+
/** Report usage without a limit check, for the signed-in subject. See `reportUsageBatch()` for many at once. */
|
|
2385
|
+
async reportUsage(featureKey, params) {
|
|
2386
|
+
return this.kerne.reportUsage(featureKey, { subject: this.requireUserId(), ...params });
|
|
2387
|
+
}
|
|
2388
|
+
/** Several usage events in one request instead of one call each - kills the manual request staggering. */
|
|
2389
|
+
async reportUsageBatch(events) {
|
|
2390
|
+
const subject = this.requireUserId();
|
|
2391
|
+
return this.kerne.reportUsageBatch(events.map((e) => ({ subject, ...e })));
|
|
2392
|
+
}
|
|
2393
|
+
requireUserId() {
|
|
2394
|
+
const id = this.user?.id;
|
|
2395
|
+
if (!id) throw new Error("reportUsage requires a signed-in user; none is loaded yet.");
|
|
2396
|
+
return id;
|
|
2397
|
+
}
|
|
2398
|
+
async createCheckout(planPriceId, drivers) {
|
|
2399
|
+
const { url } = await this.kerne.billing.checkout(planPriceId, { drivers });
|
|
2400
|
+
return url;
|
|
2401
|
+
}
|
|
2402
|
+
async openCheckout(planPriceId, drivers) {
|
|
2403
|
+
const url = await this.createCheckout(planPriceId, drivers);
|
|
2404
|
+
if (typeof window !== "undefined") window.location.href = url;
|
|
2405
|
+
}
|
|
2406
|
+
async createPortal() {
|
|
2407
|
+
const { url } = await this.kerne.billing.portal();
|
|
2408
|
+
return url;
|
|
2409
|
+
}
|
|
2410
|
+
async openPortal() {
|
|
2411
|
+
const url = await this.createPortal();
|
|
2412
|
+
if (typeof window !== "undefined") window.location.href = url;
|
|
2413
|
+
}
|
|
2414
|
+
async getSubscription(productSlug) {
|
|
2415
|
+
const subs = await this.kerne.billing.subscriptions.list({
|
|
2416
|
+
productSlug,
|
|
2417
|
+
subjectType: "USER"
|
|
2418
|
+
});
|
|
2419
|
+
const active = subs.find(
|
|
2420
|
+
(s) => s.status === "ACTIVE" || s.status === "TRIALING" || s.status === "PAST_DUE"
|
|
2421
|
+
);
|
|
2422
|
+
return active ?? null;
|
|
2423
|
+
}
|
|
2424
|
+
/** `immediately: true` ends it now; otherwise cancels at period end (API default). */
|
|
2425
|
+
async cancelSubscription(subscriptionId, immediately) {
|
|
2426
|
+
return this.kerne.billing.subscriptions.cancel(subscriptionId, immediately);
|
|
2427
|
+
}
|
|
2428
|
+
/** Swaps the plan/price, effective immediately - deferring a downgrade isn't supported yet. */
|
|
2429
|
+
async changeSubscriptionPlan(subscriptionId, planPriceId, options) {
|
|
2430
|
+
return this.kerne.billing.subscriptions.changePlanPrice(subscriptionId, planPriceId, options);
|
|
2431
|
+
}
|
|
2432
|
+
/** Public pricing data - omit `productIdOrSlug` for the tenant's default product. */
|
|
2433
|
+
async getPlans(productIdOrSlug) {
|
|
2434
|
+
return this.kerne.billing.plans(productIdOrSlug);
|
|
2435
|
+
}
|
|
2436
|
+
/** Every entitlement for the current user in one call - for a "your plan" screen. */
|
|
2437
|
+
async getEntitlements() {
|
|
2438
|
+
return this.kerne.entitlements();
|
|
2439
|
+
}
|
|
2440
|
+
/** Usage for the current user - all QUOTA features, or one via `featureKey`. */
|
|
2441
|
+
async getUsage(featureKey) {
|
|
2442
|
+
return this.kerne.usage({ featureKey });
|
|
2443
|
+
}
|
|
2444
|
+
async joinWaitlist(params) {
|
|
2445
|
+
return this.kerne.waitlist.join(params);
|
|
2446
|
+
}
|
|
2447
|
+
/** Validate an invitation token before showing the registration form. */
|
|
2448
|
+
async validateInvitationToken(token) {
|
|
2449
|
+
return this.kerne.invitations.validateToken(token);
|
|
2450
|
+
}
|
|
2451
|
+
/** Validate an invitation code before showing the registration form. */
|
|
2452
|
+
async validateInvitationCode(code) {
|
|
2453
|
+
return this.kerne.invitations.validateCode(code);
|
|
2454
|
+
}
|
|
2455
|
+
async sendVerificationEmail(verificationType = "code") {
|
|
2456
|
+
await this.kerne.auth.sendVerificationEmail(verificationType);
|
|
2457
|
+
}
|
|
2458
|
+
async verifyEmailWithCode(code) {
|
|
2459
|
+
return await this.kerne.auth.verifyEmailWithCode(code);
|
|
2460
|
+
}
|
|
2461
|
+
async verifyEmailWithLink(token) {
|
|
2462
|
+
return await this.kerne.auth.verifyEmailWithLink(token);
|
|
2463
|
+
}
|
|
2464
|
+
async getVerificationStatus() {
|
|
2465
|
+
return await this.kerne.auth.getVerificationStatus();
|
|
2466
|
+
}
|
|
2467
|
+
};
|
|
2468
|
+
var KerneContext = createContext3(null);
|
|
2469
|
+
function KerneProvider({
|
|
2470
|
+
children,
|
|
2471
|
+
localization,
|
|
2472
|
+
appearance,
|
|
2473
|
+
...config
|
|
2474
|
+
}) {
|
|
2475
|
+
const clientRef = useRef(null);
|
|
2476
|
+
const client = useMemo(() => {
|
|
2477
|
+
if (!clientRef.current) {
|
|
2478
|
+
clientRef.current = new KerneClient(config);
|
|
2479
|
+
}
|
|
2480
|
+
return clientRef.current;
|
|
2481
|
+
}, [config.appId, config.baseUrl]);
|
|
2482
|
+
useEffect(() => {
|
|
2483
|
+
return () => {
|
|
2484
|
+
client.cancelRefresh?.();
|
|
2485
|
+
};
|
|
2486
|
+
}, [client]);
|
|
2487
|
+
const l10n = useMemo(() => mergeLocalization(localization), [localization]);
|
|
2488
|
+
return /* @__PURE__ */ jsx2(KerneContext.Provider, { value: client, children: /* @__PURE__ */ jsx2(LocalizationContext.Provider, { value: l10n, children: /* @__PURE__ */ jsx2(AppearanceContext.Provider, { value: appearance, children }) }) });
|
|
2489
|
+
}
|
|
2490
|
+
|
|
2491
|
+
// src/hooks.ts
|
|
2492
|
+
import { useContext as useContext3, useSyncExternalStore, useCallback as useCallback2, useEffect as useEffect2, useState as useState2 } from "react";
|
|
2493
|
+
function useClient() {
|
|
2494
|
+
const context = useContext3(KerneContext);
|
|
2495
|
+
if (!context) {
|
|
2496
|
+
throw new Error("useClient must be used within a KerneProvider");
|
|
2497
|
+
}
|
|
2498
|
+
return context;
|
|
2499
|
+
}
|
|
2500
|
+
function useAuth() {
|
|
2501
|
+
const client = useClient();
|
|
2502
|
+
const user = useSyncExternalStore(
|
|
2503
|
+
(callback) => client.subscribe(callback),
|
|
2504
|
+
() => client.user,
|
|
2505
|
+
() => client.user
|
|
2506
|
+
// Server snapshot
|
|
2507
|
+
);
|
|
2508
|
+
const token = useSyncExternalStore(
|
|
2509
|
+
(callback) => client.subscribe(callback),
|
|
2510
|
+
() => client.token,
|
|
2511
|
+
() => null
|
|
2512
|
+
// Server always null
|
|
2513
|
+
);
|
|
2514
|
+
const isAuthenticated = useSyncExternalStore(
|
|
2515
|
+
(callback) => client.subscribe(callback),
|
|
2516
|
+
() => client.isAuthenticated,
|
|
2517
|
+
() => false
|
|
2518
|
+
// Server always unauthenticated
|
|
2519
|
+
);
|
|
2520
|
+
const isLoading = useSyncExternalStore(
|
|
2521
|
+
(callback) => client.subscribe(callback),
|
|
2522
|
+
() => client.isLoading,
|
|
2523
|
+
() => true
|
|
2524
|
+
// Server always loading
|
|
2525
|
+
);
|
|
2526
|
+
return {
|
|
2527
|
+
user,
|
|
2528
|
+
token,
|
|
2529
|
+
isAuthenticated,
|
|
2530
|
+
isLoading,
|
|
2531
|
+
login: useCallback2(
|
|
2532
|
+
(params) => client.login(params),
|
|
2533
|
+
[client]
|
|
2534
|
+
),
|
|
2535
|
+
register: useCallback2(
|
|
2536
|
+
(params) => client.register(params),
|
|
2537
|
+
[client]
|
|
2538
|
+
),
|
|
2539
|
+
logout: useCallback2(() => client.logout(), [client]),
|
|
2540
|
+
refreshUser: useCallback2(() => client.refreshUser(), [client]),
|
|
2541
|
+
refreshToken: useCallback2(() => client.refreshToken(), [client]),
|
|
2542
|
+
updateProfile: useCallback2(
|
|
2543
|
+
(params) => client.updateProfile(params),
|
|
2544
|
+
[client]
|
|
2545
|
+
),
|
|
2546
|
+
updatePassword: useCallback2(
|
|
2547
|
+
(params) => client.updatePassword(params),
|
|
2548
|
+
[client]
|
|
2549
|
+
),
|
|
2550
|
+
requestAccountDeletion: useCallback2(() => client.requestAccountDeletion(), [client]),
|
|
2551
|
+
requestPasswordReset: useCallback2(
|
|
2552
|
+
(email) => client.requestPasswordReset(email),
|
|
2553
|
+
[client]
|
|
2554
|
+
),
|
|
2555
|
+
confirmPasswordReset: useCallback2(
|
|
2556
|
+
(token2, password) => client.confirmPasswordReset(token2, password),
|
|
2557
|
+
[client]
|
|
2558
|
+
),
|
|
2559
|
+
// Magic link
|
|
2560
|
+
startPasswordless: useCallback2(
|
|
2561
|
+
(email, invitation) => client.startPasswordless(email, invitation),
|
|
2562
|
+
[client]
|
|
2563
|
+
),
|
|
2564
|
+
loginWithMagicLink: useCallback2((token2) => client.loginWithMagicLink(token2), [client]),
|
|
2565
|
+
// Email verification
|
|
2566
|
+
sendVerificationEmail: useCallback2(
|
|
2567
|
+
(verificationType = "code") => client.sendVerificationEmail(verificationType),
|
|
2568
|
+
[client]
|
|
2569
|
+
),
|
|
2570
|
+
verifyEmailWithCode: useCallback2((code) => client.verifyEmailWithCode(code), [client]),
|
|
2571
|
+
verifyEmailWithLink: useCallback2(
|
|
2572
|
+
(token2) => client.verifyEmailWithLink(token2),
|
|
2573
|
+
[client]
|
|
2574
|
+
),
|
|
2575
|
+
getVerificationStatus: useCallback2(() => client.getVerificationStatus(), [client]),
|
|
2576
|
+
// Account activation (admin-created user, or self-service magic-link signup confirmation)
|
|
2577
|
+
getActivationContext: useCallback2(
|
|
2578
|
+
(token2) => client.getActivationContext(token2),
|
|
2579
|
+
[client]
|
|
2580
|
+
),
|
|
2581
|
+
completeActivation: useCallback2(
|
|
2582
|
+
(token2, password) => client.completeActivation(token2, password),
|
|
2583
|
+
[client]
|
|
2584
|
+
),
|
|
2585
|
+
// Cross-origin handoff (hosted auth origin -> tenant app_url) - see redirectWithSession on KerneClient.
|
|
2586
|
+
redirectWithSession: useCallback2((url) => client.redirectWithSession(url), [client])
|
|
2587
|
+
};
|
|
2588
|
+
}
|
|
2589
|
+
function useCheckout() {
|
|
2590
|
+
const client = useClient();
|
|
2591
|
+
return {
|
|
2592
|
+
createCheckout: useCallback2(
|
|
2593
|
+
(planPriceId, drivers) => client.createCheckout(planPriceId, drivers),
|
|
2594
|
+
[client]
|
|
2595
|
+
),
|
|
2596
|
+
openCheckout: useCallback2(
|
|
2597
|
+
(planPriceId, drivers) => client.openCheckout(planPriceId, drivers),
|
|
2598
|
+
[client]
|
|
2599
|
+
)
|
|
2600
|
+
};
|
|
2601
|
+
}
|
|
2602
|
+
function usePortal() {
|
|
2603
|
+
const client = useClient();
|
|
2604
|
+
return {
|
|
2605
|
+
createPortal: useCallback2(() => client.createPortal(), [client]),
|
|
2606
|
+
openPortal: useCallback2(() => client.openPortal(), [client])
|
|
2607
|
+
};
|
|
2608
|
+
}
|
|
2609
|
+
function useCancelSubscription() {
|
|
2610
|
+
const client = useClient();
|
|
2611
|
+
const [isCanceling, setIsCanceling] = useState2(false);
|
|
2612
|
+
const cancelSubscription = useCallback2(
|
|
2613
|
+
async (subscriptionId, immediately) => {
|
|
2614
|
+
setIsCanceling(true);
|
|
2615
|
+
try {
|
|
2616
|
+
return await client.cancelSubscription(subscriptionId, immediately);
|
|
2617
|
+
} finally {
|
|
2618
|
+
setIsCanceling(false);
|
|
2619
|
+
}
|
|
2620
|
+
},
|
|
2621
|
+
[client]
|
|
2622
|
+
);
|
|
2623
|
+
return { cancelSubscription, isCanceling };
|
|
2624
|
+
}
|
|
2625
|
+
function useChangePlan() {
|
|
2626
|
+
const client = useClient();
|
|
2627
|
+
const [isChanging, setIsChanging] = useState2(false);
|
|
2628
|
+
const changePlan = useCallback2(
|
|
2629
|
+
async (subscriptionId, planPriceId, options) => {
|
|
2630
|
+
setIsChanging(true);
|
|
2631
|
+
try {
|
|
2632
|
+
return await client.changeSubscriptionPlan(subscriptionId, planPriceId, options);
|
|
2633
|
+
} finally {
|
|
2634
|
+
setIsChanging(false);
|
|
2635
|
+
}
|
|
2636
|
+
},
|
|
2637
|
+
[client]
|
|
2638
|
+
);
|
|
2639
|
+
return { changePlan, isChanging };
|
|
2640
|
+
}
|
|
2641
|
+
function usePlanSwitch(subscription, refetchSubscription) {
|
|
2642
|
+
const { openCheckout } = useCheckout();
|
|
2643
|
+
const { changePlan } = useChangePlan();
|
|
2644
|
+
const [pending, setPending] = useState2(null);
|
|
2645
|
+
const [switching, setSwitching] = useState2(false);
|
|
2646
|
+
const [switchError, setSwitchError] = useState2(null);
|
|
2647
|
+
const [checkoutPriceId, setCheckoutPriceId] = useState2(null);
|
|
2648
|
+
const [checkoutError, setCheckoutError] = useState2(null);
|
|
2649
|
+
const selectPlan = useCallback2(
|
|
2650
|
+
async (plan, price, drivers) => {
|
|
2651
|
+
if (subscription) {
|
|
2652
|
+
setSwitchError(null);
|
|
2653
|
+
setPending({ plan, price });
|
|
2654
|
+
return;
|
|
2655
|
+
}
|
|
2656
|
+
setCheckoutError(null);
|
|
2657
|
+
setCheckoutPriceId(price.id);
|
|
2658
|
+
try {
|
|
2659
|
+
await openCheckout(price.id, drivers);
|
|
2660
|
+
} catch (e) {
|
|
2661
|
+
setCheckoutError(e);
|
|
2662
|
+
setCheckoutPriceId(null);
|
|
2663
|
+
}
|
|
2664
|
+
},
|
|
2665
|
+
[subscription, openCheckout]
|
|
2666
|
+
);
|
|
2667
|
+
const confirmSwitch = useCallback2(async () => {
|
|
2668
|
+
if (!pending || !subscription) return;
|
|
2669
|
+
setSwitching(true);
|
|
2670
|
+
setSwitchError(null);
|
|
2671
|
+
try {
|
|
2672
|
+
await changePlan(subscription.id, pending.price.id);
|
|
2673
|
+
setPending(null);
|
|
2674
|
+
refetchSubscription();
|
|
2675
|
+
} catch (e) {
|
|
2676
|
+
setSwitchError(e);
|
|
2677
|
+
} finally {
|
|
2678
|
+
setSwitching(false);
|
|
2679
|
+
}
|
|
2680
|
+
}, [pending, subscription, changePlan, refetchSubscription]);
|
|
2681
|
+
const dismissSwitch = useCallback2(() => {
|
|
2682
|
+
setPending(null);
|
|
2683
|
+
setSwitchError(null);
|
|
2684
|
+
}, []);
|
|
2685
|
+
return {
|
|
2686
|
+
pending,
|
|
2687
|
+
switching,
|
|
2688
|
+
switchError,
|
|
2689
|
+
checkoutPriceId,
|
|
2690
|
+
checkoutError,
|
|
2691
|
+
selectPlan,
|
|
2692
|
+
confirmSwitch,
|
|
2693
|
+
dismissSwitch
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2696
|
+
function useAccess(featureKey, requested) {
|
|
2697
|
+
const client = useClient();
|
|
2698
|
+
const [state, setState] = useState2({
|
|
2699
|
+
details: null,
|
|
2700
|
+
// Fails closed: an unresolved or failed check must never read as a yes,
|
|
2701
|
+
// since this is what gates paid capability.
|
|
2702
|
+
allowed: false,
|
|
2703
|
+
isLoading: true,
|
|
2704
|
+
error: null
|
|
2705
|
+
});
|
|
2706
|
+
useEffect2(() => {
|
|
2707
|
+
let mounted = true;
|
|
2708
|
+
client.check(featureKey, requested).then((details) => {
|
|
2709
|
+
if (mounted) {
|
|
2710
|
+
setState({ details, allowed: details.allowed, isLoading: false, error: null });
|
|
2711
|
+
}
|
|
2712
|
+
}).catch((error) => {
|
|
2713
|
+
if (mounted) {
|
|
2714
|
+
setState({ details: null, allowed: false, isLoading: false, error });
|
|
2715
|
+
}
|
|
2716
|
+
});
|
|
2717
|
+
return () => {
|
|
2718
|
+
mounted = false;
|
|
2719
|
+
};
|
|
2720
|
+
}, [client, featureKey, requested]);
|
|
2721
|
+
return state;
|
|
2722
|
+
}
|
|
2723
|
+
function useSubscription(productSlug) {
|
|
2724
|
+
const client = useClient();
|
|
2725
|
+
const [state, setState] = useState2({
|
|
2726
|
+
subscription: null,
|
|
2727
|
+
isLoading: true,
|
|
2728
|
+
error: null
|
|
2729
|
+
});
|
|
2730
|
+
const fetchOnce = useCallback2(
|
|
2731
|
+
(mountedRef) => {
|
|
2732
|
+
client.getSubscription(productSlug).then((subscription) => {
|
|
2733
|
+
if (mountedRef.current) {
|
|
2734
|
+
setState({ subscription, isLoading: false, error: null });
|
|
2735
|
+
}
|
|
2736
|
+
}).catch((error) => {
|
|
2737
|
+
if (mountedRef.current) {
|
|
2738
|
+
setState({ subscription: null, isLoading: false, error });
|
|
2739
|
+
}
|
|
2740
|
+
});
|
|
2741
|
+
},
|
|
2742
|
+
[client, productSlug]
|
|
2743
|
+
);
|
|
2744
|
+
useEffect2(() => {
|
|
2745
|
+
const mountedRef = { current: true };
|
|
2746
|
+
fetchOnce(mountedRef);
|
|
2747
|
+
return () => {
|
|
2748
|
+
mountedRef.current = false;
|
|
2749
|
+
};
|
|
2750
|
+
}, [fetchOnce]);
|
|
2751
|
+
const refetch = useCallback2(() => {
|
|
2752
|
+
setState((prev) => ({ ...prev, isLoading: true }));
|
|
2753
|
+
fetchOnce({ current: true });
|
|
2754
|
+
}, [fetchOnce]);
|
|
2755
|
+
const isActive = state.subscription?.status === "ACTIVE" || state.subscription?.status === "TRIALING";
|
|
2756
|
+
const planSlug = state.subscription?.plan?.slug;
|
|
2757
|
+
return {
|
|
2758
|
+
...state,
|
|
2759
|
+
isActive,
|
|
2760
|
+
planSlug,
|
|
2761
|
+
refetch
|
|
2762
|
+
};
|
|
2763
|
+
}
|
|
2764
|
+
function usePlans(productIdOrSlug) {
|
|
2765
|
+
const client = useClient();
|
|
2766
|
+
const [state, setState] = useState2({
|
|
2767
|
+
plans: [],
|
|
2768
|
+
isLoading: true,
|
|
2769
|
+
error: null
|
|
2770
|
+
});
|
|
2771
|
+
useEffect2(() => {
|
|
2772
|
+
let mounted = true;
|
|
2773
|
+
client.getPlans(productIdOrSlug).then((plans) => {
|
|
2774
|
+
if (mounted) {
|
|
2775
|
+
setState({ plans, isLoading: false, error: null });
|
|
2776
|
+
}
|
|
2777
|
+
}).catch((error) => {
|
|
2778
|
+
if (mounted) {
|
|
2779
|
+
setState({ plans: [], isLoading: false, error });
|
|
2780
|
+
}
|
|
2781
|
+
});
|
|
2782
|
+
return () => {
|
|
2783
|
+
mounted = false;
|
|
2784
|
+
};
|
|
2785
|
+
}, [client, productIdOrSlug]);
|
|
2786
|
+
return state;
|
|
2787
|
+
}
|
|
2788
|
+
function useAuthConfig() {
|
|
2789
|
+
const client = useClient();
|
|
2790
|
+
const [state, setState] = useState2({
|
|
2791
|
+
config: null,
|
|
2792
|
+
isLoading: true,
|
|
2793
|
+
error: null
|
|
2794
|
+
});
|
|
2795
|
+
useEffect2(() => {
|
|
2796
|
+
let mounted = true;
|
|
2797
|
+
client.getAuthConfig().then((config) => {
|
|
2798
|
+
if (mounted) {
|
|
2799
|
+
setState({ config, isLoading: false, error: null });
|
|
2800
|
+
}
|
|
2801
|
+
}).catch((error) => {
|
|
2802
|
+
if (mounted) {
|
|
2803
|
+
setState({ config: null, isLoading: false, error });
|
|
2804
|
+
}
|
|
2805
|
+
});
|
|
2806
|
+
return () => {
|
|
2807
|
+
mounted = false;
|
|
2808
|
+
};
|
|
2809
|
+
}, [client]);
|
|
2810
|
+
return state;
|
|
2811
|
+
}
|
|
2812
|
+
var hasWarnedEntitlementsIsNotAGate = false;
|
|
2813
|
+
function warnEntitlementsIsNotAGate() {
|
|
2814
|
+
if (hasWarnedEntitlementsIsNotAGate || process.env.NODE_ENV !== "development") return;
|
|
2815
|
+
hasWarnedEntitlementsIsNotAGate = true;
|
|
2816
|
+
console.warn(
|
|
2817
|
+
"[Kerne] useEntitlements() returns a cached snapshot for display - it has no `allowed` field and can be stale. Gate access with useAccess() (or kerne.allows()/enforce()), never by reading limit/remaining/enabled from this list yourself."
|
|
2818
|
+
);
|
|
2819
|
+
}
|
|
2820
|
+
function useEntitlements() {
|
|
2821
|
+
const client = useClient();
|
|
2822
|
+
const [state, setState] = useState2({
|
|
2823
|
+
entitlements: null,
|
|
2824
|
+
isLoading: true,
|
|
2825
|
+
error: null
|
|
2826
|
+
});
|
|
2827
|
+
useEffect2(() => {
|
|
2828
|
+
let mounted = true;
|
|
2829
|
+
warnEntitlementsIsNotAGate();
|
|
2830
|
+
client.getEntitlements().then((entitlements) => {
|
|
2831
|
+
if (mounted) {
|
|
2832
|
+
setState({ entitlements, isLoading: false, error: null });
|
|
2833
|
+
}
|
|
2834
|
+
}).catch((error) => {
|
|
2835
|
+
if (mounted) {
|
|
2836
|
+
setState({ entitlements: null, isLoading: false, error });
|
|
2837
|
+
}
|
|
2838
|
+
});
|
|
2839
|
+
return () => {
|
|
2840
|
+
mounted = false;
|
|
2841
|
+
};
|
|
2842
|
+
}, [client]);
|
|
2843
|
+
return state;
|
|
2844
|
+
}
|
|
2845
|
+
function useUsage(featureKey) {
|
|
2846
|
+
const client = useClient();
|
|
2847
|
+
const [state, setState] = useState2({
|
|
2848
|
+
usage: [],
|
|
2849
|
+
isLoading: true,
|
|
2850
|
+
error: null
|
|
2851
|
+
});
|
|
2852
|
+
useEffect2(() => {
|
|
2853
|
+
let mounted = true;
|
|
2854
|
+
client.getUsage(featureKey).then((usage) => {
|
|
2855
|
+
if (mounted) {
|
|
2856
|
+
setState({ usage, isLoading: false, error: null });
|
|
2857
|
+
}
|
|
2858
|
+
}).catch((error) => {
|
|
2859
|
+
if (mounted) {
|
|
2860
|
+
setState({ usage: [], isLoading: false, error });
|
|
2861
|
+
}
|
|
2862
|
+
});
|
|
2863
|
+
return () => {
|
|
2864
|
+
mounted = false;
|
|
2865
|
+
};
|
|
2866
|
+
}, [client, featureKey]);
|
|
2867
|
+
return state;
|
|
2868
|
+
}
|
|
2869
|
+
function useReportUsage() {
|
|
2870
|
+
const client = useClient();
|
|
2871
|
+
return {
|
|
2872
|
+
consume: useCallback2(
|
|
2873
|
+
(featureKey, params) => client.consume(featureKey, params),
|
|
2874
|
+
[client]
|
|
2875
|
+
),
|
|
2876
|
+
report: useCallback2(
|
|
2877
|
+
(featureKey, params) => client.reportUsage(featureKey, params),
|
|
2878
|
+
[client]
|
|
2879
|
+
),
|
|
2880
|
+
reportBatch: useCallback2(
|
|
2881
|
+
(events) => client.reportUsageBatch(events),
|
|
2882
|
+
[client]
|
|
2883
|
+
)
|
|
2884
|
+
};
|
|
2885
|
+
}
|
|
2886
|
+
function useWaitlist() {
|
|
2887
|
+
const client = useClient();
|
|
2888
|
+
return {
|
|
2889
|
+
join: useCallback2((params) => client.joinWaitlist(params), [client])
|
|
2890
|
+
};
|
|
2891
|
+
}
|
|
2892
|
+
function useInvitation(params) {
|
|
2893
|
+
const client = useClient();
|
|
2894
|
+
const { token, code } = params;
|
|
2895
|
+
const [state, setState] = useState2({
|
|
2896
|
+
result: null,
|
|
2897
|
+
isLoading: !!(token || code),
|
|
2898
|
+
error: null
|
|
2899
|
+
});
|
|
2900
|
+
useEffect2(() => {
|
|
2901
|
+
if (!token && !code) {
|
|
2902
|
+
setState({ result: null, isLoading: false, error: null });
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
let mounted = true;
|
|
2906
|
+
const validate = token ? client.validateInvitationToken(token) : client.validateInvitationCode(code);
|
|
2907
|
+
validate.then((result) => {
|
|
2908
|
+
if (mounted) {
|
|
2909
|
+
setState({ result, isLoading: false, error: null });
|
|
2910
|
+
}
|
|
2911
|
+
}).catch((error) => {
|
|
2912
|
+
if (mounted) {
|
|
2913
|
+
setState({ result: null, isLoading: false, error });
|
|
2914
|
+
}
|
|
2915
|
+
});
|
|
2916
|
+
return () => {
|
|
2917
|
+
mounted = false;
|
|
2918
|
+
};
|
|
2919
|
+
}, [client, token, code]);
|
|
2920
|
+
return state;
|
|
2921
|
+
}
|
|
2922
|
+
function useUser() {
|
|
2923
|
+
const { user, isLoading, updateProfile } = useAuth();
|
|
2924
|
+
return {
|
|
2925
|
+
user,
|
|
2926
|
+
isLoading,
|
|
2927
|
+
update: updateProfile,
|
|
2928
|
+
// Computed properties
|
|
2929
|
+
fullName: user ? `${user.first_name || ""} ${user.last_name || ""}`.trim() : null,
|
|
2930
|
+
initials: user ? `${user.first_name?.[0] || ""}${user.last_name?.[0] || ""}`.toUpperCase() : null,
|
|
2931
|
+
email: user?.email || null,
|
|
2932
|
+
emailVerified: user?.email_verified || false
|
|
2933
|
+
};
|
|
2934
|
+
}
|
|
2935
|
+
|
|
2936
|
+
// src/ui/locales/fr.ts
|
|
2937
|
+
var fr = {
|
|
2938
|
+
common: {
|
|
2939
|
+
email: "E-mail",
|
|
2940
|
+
emailPlaceholder: "vous@entreprise.com",
|
|
2941
|
+
password: "Mot de passe",
|
|
2942
|
+
passwordPlaceholder: "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022",
|
|
2943
|
+
name: "Nom",
|
|
2944
|
+
namePlaceholder: "Ada Lovelace",
|
|
2945
|
+
cancel: "Annuler",
|
|
2946
|
+
continue: "Continuer",
|
|
2947
|
+
or: "ou",
|
|
2948
|
+
thisApp: "Cette application",
|
|
2949
|
+
backToSignIn: "Retour \xE0 la connexion",
|
|
2950
|
+
sending: "Envoi en cours...",
|
|
2951
|
+
continueWith: "Continuer avec {provider}",
|
|
2952
|
+
securedByKerne: "S\xE9curis\xE9 par Kerne",
|
|
2953
|
+
loading: "Chargement",
|
|
2954
|
+
showPassword: "Afficher le mot de passe",
|
|
2955
|
+
hidePassword: "Masquer le mot de passe",
|
|
2956
|
+
capsLockOn: "Verr. Maj. activ\xE9",
|
|
2957
|
+
passwordHint: "Au moins {count} caract\xE8res",
|
|
2958
|
+
passwordRequireUppercase: "Une majuscule",
|
|
2959
|
+
passwordRequireNumber: "Un chiffre",
|
|
2960
|
+
passwordRequireSymbol: "Un caract\xE8re sp\xE9cial",
|
|
2961
|
+
strengthLabel: "Robustesse du mot de passe",
|
|
2962
|
+
strengthWeak: "Faible",
|
|
2963
|
+
strengthFair: "Moyen",
|
|
2964
|
+
strengthGood: "Bon",
|
|
2965
|
+
strengthStrong: "Fort"
|
|
2966
|
+
},
|
|
2967
|
+
signIn: {
|
|
2968
|
+
title: "Content de vous revoir",
|
|
2969
|
+
signUpPrompt: "Premi\xE8re visite ?",
|
|
2970
|
+
signUpLink: "S'inscrire gratuitement",
|
|
2971
|
+
signUpForApp: "S'inscrire sur {app}",
|
|
2972
|
+
submit: "Se connecter",
|
|
2973
|
+
submitting: "Connexion en cours...",
|
|
2974
|
+
magicSubmit: "M\u2019envoyer un lien de connexion",
|
|
2975
|
+
magicSubmitting: "Envoi du lien...",
|
|
2976
|
+
magicSubtitle: "Nous vous enverrons un lien de connexion par e-mail.",
|
|
2977
|
+
forgotPassword: "Mot de passe oubli\xE9 ?",
|
|
2978
|
+
useMagicLink: "Recevoir un lien de connexion \xE0 la place",
|
|
2979
|
+
usePassword: "Se connecter avec un mot de passe",
|
|
2980
|
+
linkSent: "Consultez votre bo\xEEte de r\xE9ception - si un compte existe pour {email}, un lien de connexion est en route.",
|
|
2981
|
+
unavailable: "La connexion n'est pas disponible pour le moment. Contactez votre administrateur.",
|
|
2982
|
+
loadFailed: "Impossible de charger les options de connexion. Actualisez la page."
|
|
2983
|
+
},
|
|
2984
|
+
signUp: {
|
|
2985
|
+
title: "Cr\xE9ez votre compte",
|
|
2986
|
+
titleWithApp: "Cr\xE9ez votre compte {app}",
|
|
2987
|
+
signInPrompt: "D\xE9j\xE0 inscrit ?",
|
|
2988
|
+
signInLink: "Se connecter",
|
|
2989
|
+
submit: "Cr\xE9er le compte",
|
|
2990
|
+
submitting: "Cr\xE9ation du compte...",
|
|
2991
|
+
magicSubmit: "M\u2019envoyer un lien d'inscription",
|
|
2992
|
+
magicSubmitting: "Envoi du lien...",
|
|
2993
|
+
invitationCode: "Code d'invitation",
|
|
2994
|
+
invitationNeeded: "Une invitation est n\xE9cessaire pour s'inscrire.",
|
|
2995
|
+
closedTitle: "Les inscriptions sont ferm\xE9es",
|
|
2996
|
+
closedBody: "{app} n'accepte pas de nouveaux comptes pour le moment.",
|
|
2997
|
+
linkSent: "Consultez votre bo\xEEte de r\xE9ception - nous avons envoy\xE9 un lien \xE0 {email}. L\u2019ouvrir confirme votre adresse et vous connecte.",
|
|
2998
|
+
useMagicLink: "Recevoir un lien d'inscription \xE0 la place",
|
|
2999
|
+
usePassword: "S'inscrire avec un mot de passe",
|
|
3000
|
+
unavailable: "L'inscription n'est pas disponible pour le moment. Contactez votre administrateur.",
|
|
3001
|
+
loadFailed: "Impossible de charger les options d'inscription. Actualisez la page."
|
|
3002
|
+
},
|
|
3003
|
+
resetPassword: {
|
|
3004
|
+
requestTitle: "R\xE9initialisez votre mot de passe",
|
|
3005
|
+
requestSubtitle: "Nous vous enverrons un lien par e-mail pour choisir un nouveau mot de passe.",
|
|
3006
|
+
requestSubmit: "M\u2019envoyer un lien de r\xE9initialisation",
|
|
3007
|
+
requestSubmitting: "Envoi du lien...",
|
|
3008
|
+
requestSent: "Consultez votre bo\xEEte de r\xE9ception - si un compte existe pour {email}, un lien de r\xE9initialisation est en route.",
|
|
3009
|
+
rememberedPrompt: "Vous vous en souvenez ?",
|
|
3010
|
+
title: "Choisissez un nouveau mot de passe",
|
|
3011
|
+
subtitle: "Choisissez un mot de passe que vous n'avez pas d\xE9j\xE0 utilis\xE9.",
|
|
3012
|
+
newPassword: "Nouveau mot de passe",
|
|
3013
|
+
confirmPassword: "Confirmez le nouveau mot de passe",
|
|
3014
|
+
mismatch: "Les deux mots de passe doivent \xEAtre identiques.",
|
|
3015
|
+
submit: "Mettre \xE0 jour le mot de passe",
|
|
3016
|
+
submitting: "Mise \xE0 jour...",
|
|
3017
|
+
doneTitle: "Mot de passe mis \xE0 jour",
|
|
3018
|
+
doneBody: "Vous pouvez maintenant vous connecter avec votre nouveau mot de passe.",
|
|
3019
|
+
incompleteTitle: "Ce lien est incomplet",
|
|
3020
|
+
incompleteBody: "Il lui manque son jeton de r\xE9initialisation. Demandez un nouvel e-mail et r\xE9essayez."
|
|
3021
|
+
},
|
|
3022
|
+
magicLink: {
|
|
3023
|
+
pendingTitle: "Connexion en cours",
|
|
3024
|
+
pendingBody: "Un instant, le temps de v\xE9rifier votre lien.",
|
|
3025
|
+
doneTitle: "Vous \xEAtes connect\xE9",
|
|
3026
|
+
doneBody: "Vous pouvez fermer cet onglet s'il ne se redirige pas automatiquement.",
|
|
3027
|
+
failedTitle: "Ce lien n'a pas fonctionn\xE9",
|
|
3028
|
+
missingToken: "Il manque le jeton de connexion \xE0 ce lien."
|
|
3029
|
+
},
|
|
3030
|
+
verifyEmail: {
|
|
3031
|
+
title: "V\xE9rifiez votre e-mail",
|
|
3032
|
+
subtitle: "Saisissez le code \xE0 6 chiffres que nous vous avons envoy\xE9 par e-mail.",
|
|
3033
|
+
code: "Code de v\xE9rification",
|
|
3034
|
+
submit: "V\xE9rifier l'e-mail",
|
|
3035
|
+
submitting: "V\xE9rification...",
|
|
3036
|
+
resend: "Vous ne l'avez pas re\xE7u ? Envoyer un nouveau code",
|
|
3037
|
+
resent: "Un nouveau code est en route.",
|
|
3038
|
+
verifyingTitle: "V\xE9rification de votre e-mail",
|
|
3039
|
+
verifyingBody: "Un instant, le temps de confirmer votre lien.",
|
|
3040
|
+
doneTitle: "E-mail v\xE9rifi\xE9",
|
|
3041
|
+
doneBody: "Votre adresse est confirm\xE9e. Vous pouvez continuer.",
|
|
3042
|
+
failedTitle: "Ce lien n'a pas fonctionn\xE9",
|
|
3043
|
+
newCode: "Envoyer un nouveau code"
|
|
3044
|
+
},
|
|
3045
|
+
activate: {
|
|
3046
|
+
title: "Activez votre compte",
|
|
3047
|
+
credentialsSubtitle: "D\xE9finissez un mot de passe pour {email} afin de terminer.",
|
|
3048
|
+
magicSubtitle: "Confirmez {email} pour terminer la configuration de votre compte.",
|
|
3049
|
+
confirmPassword: "Confirmez le mot de passe",
|
|
3050
|
+
mismatch: "Les deux mots de passe doivent \xEAtre identiques.",
|
|
3051
|
+
submit: "Activer mon compte",
|
|
3052
|
+
submitting: "Activation...",
|
|
3053
|
+
failedTitle: "Ce lien n'a pas fonctionn\xE9",
|
|
3054
|
+
missingToken: "Il lui manque son jeton d'activation. Demandez une nouvelle invitation.",
|
|
3055
|
+
goToSignIn: "Aller \xE0 la connexion"
|
|
3056
|
+
},
|
|
3057
|
+
waitlist: {
|
|
3058
|
+
title: "Rejoindre la liste d'attente",
|
|
3059
|
+
subtitle: "Nous vous enverrons un e-mail d\xE8s qu\u2019une place se lib\xE8re.",
|
|
3060
|
+
submit: "Rejoindre la liste d'attente",
|
|
3061
|
+
submitting: "Ajout en cours...",
|
|
3062
|
+
joined: "Vous \xEAtes sur la liste. Nous vous recontacterons."
|
|
3063
|
+
},
|
|
3064
|
+
profile: {
|
|
3065
|
+
navLabel: "Sections du compte",
|
|
3066
|
+
tabProfile: "Profil",
|
|
3067
|
+
tabSecurity: "S\xE9curit\xE9",
|
|
3068
|
+
tabBilling: "Facturation",
|
|
3069
|
+
profileTitle: "Profil",
|
|
3070
|
+
profileDesc: "Votre nom et l'adresse avec laquelle vous vous connectez.",
|
|
3071
|
+
securityTitle: "S\xE9curit\xE9",
|
|
3072
|
+
securityDesc: "G\xE9rez votre mot de passe et votre compte.",
|
|
3073
|
+
billingTitle: "Facturation",
|
|
3074
|
+
billingDesc: "Votre offre, votre consommation, et comment la modifier.",
|
|
3075
|
+
firstName: "Pr\xE9nom",
|
|
3076
|
+
firstNamePlaceholder: "Camille",
|
|
3077
|
+
lastName: "Nom",
|
|
3078
|
+
lastNamePlaceholder: "Dupont",
|
|
3079
|
+
save: "Enregistrer",
|
|
3080
|
+
saving: "Enregistrement...",
|
|
3081
|
+
saved: "Votre profil a \xE9t\xE9 enregistr\xE9.",
|
|
3082
|
+
emailSectionTitle: "Adresse e-mail",
|
|
3083
|
+
emailSectionDesc: "L'adresse avec laquelle vous vous connectez. Contactez le support pour la modifier.",
|
|
3084
|
+
verified: "V\xE9rifi\xE9e",
|
|
3085
|
+
unverified: "Non v\xE9rifi\xE9e",
|
|
3086
|
+
verify: "V\xE9rifier",
|
|
3087
|
+
verificationSent: "E-mail de v\xE9rification envoy\xE9.",
|
|
3088
|
+
unnamed: "Sans nom"
|
|
3089
|
+
},
|
|
3090
|
+
security: {
|
|
3091
|
+
passwordTitle: "Mot de passe",
|
|
3092
|
+
passwordDesc: "Utilis\xE9 avec votre e-mail pour vous connecter.",
|
|
3093
|
+
currentPassword: "Mot de passe actuel",
|
|
3094
|
+
newPassword: "Nouveau mot de passe",
|
|
3095
|
+
confirmPassword: "Confirmez le nouveau mot de passe",
|
|
3096
|
+
submit: "Changer le mot de passe",
|
|
3097
|
+
submitting: "Modification...",
|
|
3098
|
+
changed: "Votre mot de passe a \xE9t\xE9 modifi\xE9.",
|
|
3099
|
+
mismatch: "Les deux mots de passe doivent \xEAtre identiques.",
|
|
3100
|
+
sameAsCurrent: "Votre nouveau mot de passe doit \xEAtre diff\xE9rent de l\u2019actuel.",
|
|
3101
|
+
deleteTitle: "Supprimer le compte",
|
|
3102
|
+
deleteDesc: "D\xE9sactive votre compte et vous d\xE9connecte. Contactez le support pour le r\xE9cup\xE9rer.",
|
|
3103
|
+
deleteAction: "Supprimer mon compte",
|
|
3104
|
+
deleteConfirm: "Oui, supprimer mon compte",
|
|
3105
|
+
deleting: "Suppression..."
|
|
3106
|
+
},
|
|
3107
|
+
billing: {
|
|
3108
|
+
currentPlan: "Offre actuelle",
|
|
3109
|
+
plan: "Offre",
|
|
3110
|
+
price: "Prix",
|
|
3111
|
+
status: "Statut",
|
|
3112
|
+
renews: "Renouvellement",
|
|
3113
|
+
ends: "Fin",
|
|
3114
|
+
trialEnds: "Fin de l'essai",
|
|
3115
|
+
managePortal: "G\xE9rer la facturation",
|
|
3116
|
+
opening: "Ouverture...",
|
|
3117
|
+
freePlan: "Vous \xEAtes sur l'offre gratuite.",
|
|
3118
|
+
seePlans: "Voir les offres",
|
|
3119
|
+
cancelNotice: "Votre offre est en cours de r\xE9siliation - vous conservez l'acc\xE8s jusqu'\xE0 la date ci-dessus.",
|
|
3120
|
+
cancelSubscription: "R\xE9silier l'abonnement",
|
|
3121
|
+
canceling: "R\xE9siliation...",
|
|
3122
|
+
cancelConfirmTitle: "R\xE9silier votre abonnement ?",
|
|
3123
|
+
cancelConfirmBody: "Vous conserverez l'acc\xE8s jusqu'\xE0 la fin de la p\xE9riode de facturation en cours.",
|
|
3124
|
+
cancelConfirmButton: "Oui, r\xE9silier",
|
|
3125
|
+
cancelConfirmDismiss: "Annuler",
|
|
3126
|
+
changePlan: "Changer d'offre",
|
|
3127
|
+
current: "Actuelle",
|
|
3128
|
+
switchTo: "Changer",
|
|
3129
|
+
switching: "Changement...",
|
|
3130
|
+
switchConfirmTitle: "Passer \xE0 {plan} ?",
|
|
3131
|
+
switchConfirmBody: "Ce changement s'applique imm\xE9diatement, et votre prochaine facture refl\xE9tera la diff\xE9rence de prix pour le reste de la p\xE9riode.",
|
|
3132
|
+
switchConfirmButton: "Oui, changer",
|
|
3133
|
+
switchConfirmDismiss: "Annuler",
|
|
3134
|
+
noPlans: "Aucune autre offre n'est disponible pour le moment.",
|
|
3135
|
+
notOnInterval: "Non disponible pour cette p\xE9riode",
|
|
3136
|
+
differentCurrency: "Devise diff\xE9rente - contactez le support",
|
|
3137
|
+
loadFailed: "Impossible de charger votre offre pour le moment.",
|
|
3138
|
+
loadingPlan: "Chargement de l'abonnement",
|
|
3139
|
+
loadingPlans: "Chargement des offres",
|
|
3140
|
+
intervalGroupLabel: "P\xE9riode de facturation",
|
|
3141
|
+
intervalMonth: "mois",
|
|
3142
|
+
intervalYear: "an",
|
|
3143
|
+
intervalWeek: "semaine",
|
|
3144
|
+
intervalDay: "jour",
|
|
3145
|
+
statusActive: "Active",
|
|
3146
|
+
statusTrialing: "Essai",
|
|
3147
|
+
statusPastDue: "Paiement d\xFB",
|
|
3148
|
+
statusCanceled: "R\xE9sili\xE9e",
|
|
3149
|
+
statusUnpaid: "Impay\xE9e",
|
|
3150
|
+
statusPaused: "En pause"
|
|
3151
|
+
},
|
|
3152
|
+
usage: {
|
|
3153
|
+
title: "Utilisation",
|
|
3154
|
+
subtitle: "D\xE9compt\xE9 de votre offre pour la p\xE9riode en cours.",
|
|
3155
|
+
used: "{count} utilis\xE9(s)",
|
|
3156
|
+
nothingMetered: "Aucune mesure sur votre offre - vous n'avez aucune limite d'utilisation \xE0 surveiller.",
|
|
3157
|
+
overage: "{count} au-del\xE0 du montant inclus - sans frais suppl\xE9mentaires.",
|
|
3158
|
+
overageBilled: "{count} au-del\xE0 du montant inclus - le suppl\xE9ment est factur\xE9 en plus de votre offre.",
|
|
3159
|
+
limitReached: "Vous avez atteint votre limite.",
|
|
3160
|
+
capReached: "Vous avez atteint votre plafond de facturation pour cette p\xE9riode - tout ce qui suit est gratuit.",
|
|
3161
|
+
billedAmount: "{amount} factur\xE9(s) cette p\xE9riode.",
|
|
3162
|
+
loading: "Chargement de l'utilisation",
|
|
3163
|
+
loadFailed: "Impossible de charger votre utilisation pour le moment."
|
|
3164
|
+
},
|
|
3165
|
+
pricing: {
|
|
3166
|
+
getStarted: "Commencer",
|
|
3167
|
+
billedAt: "Factur\xE9 {amount} / {interval}",
|
|
3168
|
+
save: "\xC9conomisez {percent} %",
|
|
3169
|
+
unlimited: "Illimit\xE9",
|
|
3170
|
+
included: "Inclus",
|
|
3171
|
+
notIncluded: "\u2014",
|
|
3172
|
+
free: "Gratuit",
|
|
3173
|
+
forever: "pour toujours",
|
|
3174
|
+
startFree: "Commencer gratuitement",
|
|
3175
|
+
trialNote: "{days} jours d\u2019essai, sans carte",
|
|
3176
|
+
thenMetered: "puis \xE0 l\u2019usage",
|
|
3177
|
+
meterTitle: "D\xE9passement \xE0 l\u2019usage",
|
|
3178
|
+
meterRatePer: "Tarif par {thing}",
|
|
3179
|
+
meterShape: "Bar\xE8me",
|
|
3180
|
+
meterCounted: "Compt\xE9",
|
|
3181
|
+
meterResets: "Remise \xE0 z\xE9ro",
|
|
3182
|
+
meterCapped: "Plafonn\xE9",
|
|
3183
|
+
meterTiers: "{count} paliers, au plus bas {amount}",
|
|
3184
|
+
meterFlat: "Tarif unique",
|
|
3185
|
+
meterVolume: "toute la quantit\xE9 au palier atteint",
|
|
3186
|
+
meterGraduated: "chaque tranche \xE0 son propre tarif",
|
|
3187
|
+
meterPerSlice: "par {slice}",
|
|
3188
|
+
meterPerCustomer: "par client",
|
|
3189
|
+
meterRoundedUp: "arrondi au bloc sup\xE9rieur",
|
|
3190
|
+
meterRoundedDown: "arrondi au bloc inf\xE9rieur",
|
|
3191
|
+
meterResetsEvery: "chaque {interval}",
|
|
3192
|
+
perSlice: "{amount} par {slice}",
|
|
3193
|
+
meterResetsCycle: "\xE0 chaque p\xE9riode de facturation",
|
|
3194
|
+
meterResetsNever: "jamais - le solde se reporte",
|
|
3195
|
+
metered: "\xE0 l\u2019usage",
|
|
3196
|
+
from: "\xE0 partir de",
|
|
3197
|
+
sizeUpFront: "Dimensionnez \xE0 l\u2019avance",
|
|
3198
|
+
billedWithPlan: "factur\xE9 avec l\u2019offre",
|
|
3199
|
+
includedAmount: "{amount} inclus",
|
|
3200
|
+
prepaidAmount: "+{amount} pr\xE9pay\xE9",
|
|
3201
|
+
nothingListed: "Rien n\u2019est encore list\xE9 sur cette offre.",
|
|
3202
|
+
moreFeatures: "+{count} de plus",
|
|
3203
|
+
loading: "Chargement des offres",
|
|
3204
|
+
loadFailed: "Impossible de charger les offres pour le moment."
|
|
3205
|
+
},
|
|
3206
|
+
checkout: {
|
|
3207
|
+
confirmingTitle: "Confirmation de votre paiement",
|
|
3208
|
+
confirmingBody: "Cela prend quelques secondes - merci de garder cet onglet ouvert.",
|
|
3209
|
+
pendingTitle: "Paiement re\xE7u",
|
|
3210
|
+
pendingBody: "Votre offre est en cours d'activation. Cela prend g\xE9n\xE9ralement moins d'une minute - actualisez si elle n'est pas encore apparue.",
|
|
3211
|
+
doneTitle: "Tout est pr\xEAt",
|
|
3212
|
+
doneBody: "Votre abonnement est actif. Merci.",
|
|
3213
|
+
cancelTitle: "Paiement annul\xE9",
|
|
3214
|
+
cancelBody: "Aucun montant n'a \xE9t\xE9 d\xE9bit\xE9. Vous pouvez choisir une offre quand vous serez pr\xEAt.",
|
|
3215
|
+
backToPlans: "Retour aux offres"
|
|
3216
|
+
},
|
|
3217
|
+
upgrade: {
|
|
3218
|
+
quotaTitle: "Vous avez \xE9puis\xE9 votre quota de {feature}",
|
|
3219
|
+
quotaBody: "Vous avez utilis\xE9 {used} sur {limit} pour cette p\xE9riode. Passez \xE0 une offre sup\xE9rieure pour une limite plus \xE9lev\xE9e.",
|
|
3220
|
+
featureTitle: "{feature} n'est pas inclus dans votre offre",
|
|
3221
|
+
featureBody: "Passez \xE0 une offre sup\xE9rieure pour d\xE9bloquer {feature}.",
|
|
3222
|
+
action: "Voir les offres",
|
|
3223
|
+
checking: "V\xE9rification de l'acc\xE8s"
|
|
3224
|
+
},
|
|
3225
|
+
account: {
|
|
3226
|
+
menuLabel: "Menu du compte",
|
|
3227
|
+
signOut: "Se d\xE9connecter"
|
|
3228
|
+
},
|
|
3229
|
+
errors: {
|
|
3230
|
+
generic: "Une erreur est survenue. Veuillez r\xE9essayer.",
|
|
3231
|
+
// Auth (K1xxx)
|
|
3232
|
+
K1001: "Les inscriptions ne sont pas ouvertes pour le moment.",
|
|
3233
|
+
K1002: "Cette application a atteint sa limite d'utilisateurs.",
|
|
3234
|
+
K1003: "Ce lien d'invitation n'est plus valide.",
|
|
3235
|
+
K1004: "Ce code d'invitation n'est pas valide.",
|
|
3236
|
+
K1005: "E-mail ou mot de passe incorrect.",
|
|
3237
|
+
K1006: "Ce lien a expir\xE9. Demandez-en un nouveau.",
|
|
3238
|
+
K1007: "Il manque le jeton \xE0 ce lien.",
|
|
3239
|
+
K1008: "Un compte existe d\xE9j\xE0 pour cet e-mail.",
|
|
3240
|
+
K1009: "Cette adresse e-mail ne semble pas valide.",
|
|
3241
|
+
K1010: "Veuillez choisir un mot de passe plus robuste.",
|
|
3242
|
+
K1011: "Veuillez d'abord v\xE9rifier votre adresse e-mail.",
|
|
3243
|
+
K1012: "Vous devez vous connecter pour continuer.",
|
|
3244
|
+
// Validation (K2xxx)
|
|
3245
|
+
K2001: "Certaines informations saisies ne sont pas valides.",
|
|
3246
|
+
K2002: "Veuillez remplir tous les champs obligatoires.",
|
|
3247
|
+
K2003: "Certaines informations saisies ne sont pas valides.",
|
|
3248
|
+
K2005: "Cette valeur est d\xE9j\xE0 utilis\xE9e.",
|
|
3249
|
+
// Permissions (K3xxx)
|
|
3250
|
+
K3002: "Vous n'avez pas la permission de faire cela.",
|
|
3251
|
+
K3004: "Cette action n'est pas disponible sur votre compte.",
|
|
3252
|
+
// Resources (K4xxx)
|
|
3253
|
+
K4001: "Nous n'avons pas trouv\xE9 ce que vous cherchiez.",
|
|
3254
|
+
K4003: "Nous n'avons pas trouv\xE9 ce compte.",
|
|
3255
|
+
// Billing (K5xxx)
|
|
3256
|
+
K5001: "Vous avez atteint la limite incluse dans votre offre.",
|
|
3257
|
+
K5002: "Votre offre n'inclut pas cette fonctionnalit\xE9. Passez \xE0 une offre sup\xE9rieure pour continuer.",
|
|
3258
|
+
K5003: "Votre paiement n'a pas pu \xEAtre trait\xE9.",
|
|
3259
|
+
// Rate limiting (K6xxx)
|
|
3260
|
+
K6001: "Trop de tentatives. Veuillez patienter un instant puis r\xE9essayer."
|
|
3261
|
+
}
|
|
3262
|
+
};
|
|
3263
|
+
|
|
3264
|
+
export {
|
|
3265
|
+
defaultLocalization,
|
|
3266
|
+
mergeLocalization,
|
|
3267
|
+
useLocalization,
|
|
3268
|
+
fill,
|
|
3269
|
+
fillNode,
|
|
3270
|
+
useErrorResolver,
|
|
3271
|
+
defaultTokens,
|
|
3272
|
+
ensureStylesInjected,
|
|
3273
|
+
appearanceToStyle,
|
|
3274
|
+
resolveTheme,
|
|
3275
|
+
resolvePolicy,
|
|
3276
|
+
meetsPolicy,
|
|
3277
|
+
Shell,
|
|
3278
|
+
Subsection,
|
|
3279
|
+
Masthead,
|
|
3280
|
+
Field,
|
|
3281
|
+
Button,
|
|
3282
|
+
PasswordStrength,
|
|
3283
|
+
MethodSwitch,
|
|
3284
|
+
Panel,
|
|
3285
|
+
Status,
|
|
3286
|
+
Divider,
|
|
3287
|
+
TextLink,
|
|
3288
|
+
OAuthButtons,
|
|
3289
|
+
KerneBranding,
|
|
3290
|
+
FormSkeleton,
|
|
3291
|
+
KerneClient,
|
|
3292
|
+
KerneContext,
|
|
3293
|
+
KerneProvider,
|
|
3294
|
+
useClient,
|
|
3295
|
+
useAuth,
|
|
3296
|
+
useCheckout,
|
|
3297
|
+
usePortal,
|
|
3298
|
+
useCancelSubscription,
|
|
3299
|
+
useChangePlan,
|
|
3300
|
+
usePlanSwitch,
|
|
3301
|
+
useAccess,
|
|
3302
|
+
useSubscription,
|
|
3303
|
+
usePlans,
|
|
3304
|
+
useAuthConfig,
|
|
3305
|
+
useEntitlements,
|
|
3306
|
+
useUsage,
|
|
3307
|
+
useReportUsage,
|
|
3308
|
+
useWaitlist,
|
|
3309
|
+
useInvitation,
|
|
3310
|
+
useUser,
|
|
3311
|
+
fr
|
|
3312
|
+
};
|