@authhero/widget 0.38.8 → 0.38.9

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.
@@ -0,0 +1,523 @@
1
+ /**
2
+ * Page CSS and body layout for the login page.
3
+ *
4
+ * The single source of truth for the chrome around the widget card: chip
5
+ * tokens, floating corner chips, the phone footer bar, background tint, view
6
+ * transitions and the responsive breakpoints. Both the authhero login page
7
+ * and the widget demo server render from this, so a change to the phone
8
+ * layout can't show up in one and not the other.
9
+ */
10
+ import { darkModeCssVarRules } from "./colors";
11
+ import { buildThemePageBackground } from "./html";
12
+ /**
13
+ * Resolve the page-body layout (centering, background, font) from the theme
14
+ * and branding. Keeps the inline `<body>` style (fragment path) and the
15
+ * `auth0:head` stylesheet rule (full-document path) in sync so an Auth0-style
16
+ * template centers on the page background just like the default chrome does.
17
+ *
18
+ * The "left"/"right" offsets below are the wide-viewport values; the mobile
19
+ * block in `buildPageCss` collapses them back to centered under 768px.
20
+ */
21
+ export function buildBodyLayout(opts) {
22
+ const pageLayout = opts.themePageBackground?.page_layout || "center";
23
+ const justifyContent = pageLayout === "left"
24
+ ? "flex-start"
25
+ : pageLayout === "right"
26
+ ? "flex-end"
27
+ : "center";
28
+ const padding = pageLayout === "left"
29
+ ? "20px 20px 20px 80px"
30
+ : pageLayout === "right"
31
+ ? "20px 80px 20px 20px"
32
+ : "20px";
33
+ return {
34
+ background: buildThemePageBackground(opts.themePageBackground, opts.brandingPageBackground),
35
+ fontFamily: opts.fontUrl
36
+ ? "'Inter', system-ui, sans-serif"
37
+ : "system-ui, -apple-system, sans-serif",
38
+ justifyContent,
39
+ padding,
40
+ };
41
+ }
42
+ export function buildPageCss(opts) {
43
+ const { primaryColor, themePrimary, widgetBackground, bodyLayout, hasBgImage, } = opts;
44
+ const bodyRule = bodyLayout
45
+ ? `
46
+ body {
47
+ min-height: 100vh;
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: ${bodyLayout.justifyContent};
51
+ background: ${bodyLayout.background};
52
+ font-family: ${bodyLayout.fontFamily};
53
+ padding: ${bodyLayout.padding};
54
+ }`
55
+ : "";
56
+ return `
57
+ * { box-sizing: border-box; margin: 0; padding: 0; }
58
+ ${bodyRule}
59
+
60
+ /* ============= STEP TRANSITIONS =============
61
+ Cross-document view transitions morph the widget's box between
62
+ login steps (e.g. when the next step is taller) and cross-fade the
63
+ form content, à la Stripe's dashboard login. This is opt-in per
64
+ same-origin navigation and a no-op (instant nav) on browsers that
65
+ don't support it yet. The widget is lifted into its own named group
66
+ so only it animates its size; the rest of the page (background +
67
+ chips) cross-fades via the default \`root\` group, which is invisible
68
+ since those are unchanged between steps. */
69
+ @view-transition { navigation: auto; }
70
+
71
+ /* Resize-forward (Stripe-style): the widget box morphs its height from
72
+ the old step to the new one — that resize is the main motion. Content
73
+ keeps its natural height (height: auto) so the snapshot isn't stretched
74
+ to the morphing box, and does a quick, clean cross-fade underneath. */
75
+ ::view-transition-group(ah-widget) {
76
+ animation-duration: 420ms;
77
+ animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
78
+ }
79
+ ::view-transition-old(ah-widget),
80
+ ::view-transition-new(ah-widget) {
81
+ height: auto;
82
+ }
83
+ ::view-transition-old(ah-widget) {
84
+ animation: 140ms ease both ah-widget-out;
85
+ }
86
+ ::view-transition-new(ah-widget) {
87
+ animation: 240ms ease 110ms both ah-widget-in;
88
+ }
89
+ @keyframes ah-widget-out { to { opacity: 0; } }
90
+ @keyframes ah-widget-in { from { opacity: 0; } }
91
+
92
+ @media (prefers-reduced-motion: reduce) {
93
+ ::view-transition-group(*),
94
+ ::view-transition-old(*),
95
+ ::view-transition-new(*) { animation: none !important; }
96
+ }
97
+
98
+ /* ============= CHROME TOKENS =============
99
+ The chip surface tokens flip based on:
100
+ - data-mode (light/dark) — controls fg/bg pair
101
+ - data-bg (image/none) — toggles whether chips have a surface
102
+ This keeps a single chip ruleset for all four combinations. */
103
+ :root {
104
+ /* Pill surface values live in their own *-pill vars so they survive the
105
+ data-bg="none" reset below; the base tokens reference them, and the
106
+ .ah-chip--pill modifier re-points the base tokens back at them to
107
+ force a pill even on a solid background. */
108
+ --ah-chip-bg-pill: rgba(15,17,21,0.55);
109
+ --ah-chip-bg-hover-pill: rgba(15,17,21,0.75);
110
+ --ah-chip-border-pill: rgba(255,255,255,0.12);
111
+ --ah-chip-logo-bg-pill: rgba(15,17,21,0.4);
112
+ --ah-chip-bg: var(--ah-chip-bg-pill);
113
+ --ah-chip-bg-hover: var(--ah-chip-bg-hover-pill);
114
+ --ah-chip-border: var(--ah-chip-border-pill);
115
+ --ah-chip-fg: rgba(255,255,255,0.85);
116
+ --ah-chip-fg-dim: rgba(255,255,255,0.6);
117
+ --ah-chip-fg-mid: rgba(255,255,255,0.7);
118
+ --ah-chip-fg-strong: rgba(255,255,255,0.95);
119
+ --ah-chip-active-bg: rgba(255,255,255,0.14);
120
+ --ah-chip-logo-bg: var(--ah-chip-logo-bg-pill);
121
+ --ah-legal-fg: rgba(255,255,255,0.55);
122
+ --ah-legal-fg-hover: rgba(255,255,255,0.95);
123
+ --ah-legal-sep: rgba(255,255,255,0.25);
124
+ /* Phone footer bar. The surface follows data-bg (a translucent slab
125
+ over a background image, nothing over a solid one); the foreground
126
+ and hairline follow light/dark like every other chrome token. */
127
+ --ah-footer-bg: var(--ah-chip-bg-pill);
128
+ --ah-footer-border: rgba(255,255,255,0.12);
129
+ --ah-footer-fg: rgba(255,255,255,0.70);
130
+ --ah-footer-fg-strong: rgba(255,255,255,0.95);
131
+ --ah-bg-tint: radial-gradient(
132
+ ellipse at center,
133
+ rgba(15,23,48,0.30) 0%,
134
+ rgba(15,23,48,0.55) 70%,
135
+ rgba(10,15,30,0.78) 100%);
136
+ }
137
+
138
+ /* Light page mode — flip to dark text on translucent white chips.
139
+ The :root block above seeds dark tokens; data-mode="light" overrides
140
+ them when the toggle is explicit, and the prefers-color-scheme block
141
+ below mirrors them when the user is in auto mode (no data-mode set). */
142
+ html[data-mode="light"] {
143
+ --ah-chip-bg-pill: rgba(255,255,255,0.7);
144
+ --ah-chip-bg-hover-pill: rgba(255,255,255,0.92);
145
+ --ah-chip-border-pill: rgba(15,17,21,0.08);
146
+ --ah-chip-logo-bg-pill: rgba(255,255,255,0.75);
147
+ --ah-chip-fg: #0f1115;
148
+ --ah-chip-fg-dim: rgba(15,17,21,0.55);
149
+ --ah-chip-fg-mid: rgba(15,17,21,0.65);
150
+ --ah-chip-fg-strong: rgba(15,17,21,0.95);
151
+ --ah-chip-active-bg: rgba(15,17,21,0.08);
152
+ --ah-legal-fg: rgba(15,17,21,0.5);
153
+ --ah-legal-fg-hover: rgba(15,17,21,0.9);
154
+ --ah-legal-sep: rgba(15,17,21,0.2);
155
+ --ah-footer-border: rgba(15,17,21,0.10);
156
+ --ah-footer-fg: rgba(15,17,21,0.60);
157
+ --ah-footer-fg-strong: rgba(15,17,21,0.95);
158
+ --ah-bg-tint: transparent;
159
+ }
160
+
161
+ @media (prefers-color-scheme: light) {
162
+ html:not([data-mode]) {
163
+ --ah-chip-bg-pill: rgba(255,255,255,0.7);
164
+ --ah-chip-bg-hover-pill: rgba(255,255,255,0.92);
165
+ --ah-chip-border-pill: rgba(15,17,21,0.08);
166
+ --ah-chip-logo-bg-pill: rgba(255,255,255,0.75);
167
+ --ah-chip-fg: #0f1115;
168
+ --ah-chip-fg-dim: rgba(15,17,21,0.55);
169
+ --ah-chip-fg-mid: rgba(15,17,21,0.65);
170
+ --ah-chip-fg-strong: rgba(15,17,21,0.95);
171
+ --ah-chip-active-bg: rgba(15,17,21,0.08);
172
+ --ah-legal-fg: rgba(15,17,21,0.5);
173
+ --ah-legal-fg-hover: rgba(15,17,21,0.9);
174
+ --ah-legal-sep: rgba(15,17,21,0.2);
175
+ --ah-footer-border: rgba(15,17,21,0.10);
176
+ --ah-footer-fg: rgba(15,17,21,0.60);
177
+ --ah-footer-fg-strong: rgba(15,17,21,0.95);
178
+ --ah-bg-tint: transparent;
179
+ }
180
+ }
181
+
182
+ /* No background image — chips become text-only on a solid page color */
183
+ html[data-bg="none"] {
184
+ --ah-chip-bg: transparent;
185
+ --ah-chip-bg-hover: transparent;
186
+ --ah-chip-border: transparent;
187
+ --ah-chip-logo-bg: transparent;
188
+ /* On a solid page the phone footer sits directly on the widget's own
189
+ surface, so a translucent slab would read as a seam. Hairline only. */
190
+ --ah-footer-bg: transparent;
191
+ --ah-bg-tint: transparent;
192
+ }
193
+
194
+ /* ============= BACKGROUND TINT =============
195
+ Only renders when bg image is present. The token is transparent
196
+ in data-bg=none mode so the element stays in the DOM but invisible. */
197
+ .ah-bg-tint {
198
+ position: fixed; inset: 0; z-index: 0; pointer-events: none;
199
+ background: var(--ah-bg-tint);
200
+ transition: background 300ms ease;
201
+ }
202
+ .widget-container {
203
+ position: relative;
204
+ z-index: 1;
205
+ /* Names this box as its own view-transition group so its size morphs
206
+ smoothly across step navigations (see STEP TRANSITIONS above). */
207
+ view-transition-name: ah-widget;
208
+ }
209
+
210
+ /* ============= IN-FLOW WIDGET STACK =============
211
+ Optional wrapper (used by the default custom template) that places
212
+ in-flow content directly above/below the widget card, sharing its
213
+ width and centering. Unlike the fixed-position corner chips, these
214
+ regions are normal document flow — tenants author content into them
215
+ in their template. Empty regions collapse so they add no spacing. */
216
+ .ah-widget-stack {
217
+ position: relative;
218
+ z-index: 1;
219
+ display: flex;
220
+ flex-direction: column;
221
+ align-items: center;
222
+ gap: 16px;
223
+ width: clamp(320px, 100%, 400px);
224
+ }
225
+ .ah-widget-stack .widget-container { width: 100%; }
226
+ .ah-above-widget, .ah-below-widget {
227
+ width: 100%;
228
+ text-align: center;
229
+ color: var(--ah-chip-fg);
230
+ font-size: 13px;
231
+ line-height: 1.5;
232
+ }
233
+ .ah-above-widget:empty, .ah-below-widget:empty { display: none; }
234
+ .ah-above-widget a, .ah-below-widget a { color: var(--ah-color-link, #2563eb); }
235
+
236
+ /* The "widget" logo position is rendered by the widget's own shadow DOM
237
+ (see authhero-widget.tsx). The page only renders a logo when the
238
+ caller opts into the chip variant. */
239
+ html[data-logo-position="widget"] .ah-chip-logo,
240
+ html[data-logo-position="none"] .ah-chip-logo { display: none; }
241
+
242
+ /* ============= FLOATING CHIPS =============
243
+ Self-contained pills positioned at page corners. Surface comes from
244
+ the chrome tokens above, so they adapt to mode + bg automatically. */
245
+ .ah-chip {
246
+ position: fixed;
247
+ z-index: 10;
248
+ background: var(--ah-chip-bg);
249
+ backdrop-filter: blur(20px);
250
+ -webkit-backdrop-filter: blur(20px);
251
+ border: 1px solid var(--ah-chip-border);
252
+ color: var(--ah-chip-fg);
253
+ border-radius: 9999px;
254
+ font-size: 12px;
255
+ font-weight: 500;
256
+ display: inline-flex;
257
+ align-items: center;
258
+ gap: 6px;
259
+ transition: background 200ms ease, color 200ms ease, border-color 200ms ease;
260
+ }
261
+
262
+ .ah-chip-logo {
263
+ top: 24px; left: 24px;
264
+ padding: 6px 14px 6px 8px;
265
+ background: var(--ah-chip-logo-bg);
266
+ }
267
+ .ah-chip-logo img { display: block; max-height: 20px; width: auto; }
268
+ .ah-chip-logo .ah-logo-text {
269
+ font-family: 'Inter Tight', 'Inter', system-ui, sans-serif;
270
+ font-weight: 700;
271
+ letter-spacing: 0.04em;
272
+ text-transform: uppercase;
273
+ font-size: 12px;
274
+ }
275
+
276
+ .ah-chip-settings {
277
+ top: 24px; right: 24px;
278
+ padding: 4px;
279
+ gap: 0;
280
+ }
281
+ .ah-chip-settings button,
282
+ .ah-chip-settings .ah-lang {
283
+ background: 0; border: 0; padding: 6px 10px; cursor: pointer;
284
+ color: var(--ah-chip-fg-dim);
285
+ font-size: 12px; font-weight: 500;
286
+ border-radius: 9999px;
287
+ display: inline-flex; align-items: center; gap: 5px;
288
+ transition: 140ms;
289
+ }
290
+ .ah-chip-settings button:hover,
291
+ .ah-chip-settings .ah-lang:hover { color: var(--ah-chip-fg-strong); }
292
+ .ah-chip-settings .ah-lang select {
293
+ appearance: none; -webkit-appearance: none;
294
+ background: transparent; color: inherit; border: 0;
295
+ font: inherit; cursor: pointer; padding: 0;
296
+ outline: 0;
297
+ }
298
+
299
+ .ah-chip-trust {
300
+ bottom: 24px; left: 24px;
301
+ padding: 7px 14px 7px 10px;
302
+ color: var(--ah-chip-fg-mid);
303
+ }
304
+ .ah-chip-trust img { display: block; max-height: 18px; width: auto; opacity: 0.85; }
305
+ .ah-chip-trust:hover { color: var(--ah-chip-fg-strong); }
306
+ .ah-chip-trust a { color: inherit; text-decoration: none; display: inline-flex; align-items: center; gap: 6px; }
307
+
308
+ /* Legal — chip when there's a bg image, plain text on solid bg */
309
+ .ah-chip-legal {
310
+ position: fixed;
311
+ bottom: 24px; right: 24px;
312
+ z-index: 10;
313
+ background: var(--ah-chip-bg);
314
+ backdrop-filter: blur(20px);
315
+ -webkit-backdrop-filter: blur(20px);
316
+ border: 1px solid var(--ah-chip-border);
317
+ border-radius: 9999px;
318
+ padding: 7px 14px;
319
+ color: var(--ah-legal-fg);
320
+ font-size: 11px;
321
+ letter-spacing: 0.04em;
322
+ display: inline-flex; align-items: center; gap: 10px;
323
+ transition: background 200ms ease, color 200ms ease, border-color 200ms ease;
324
+ }
325
+ html[data-bg="none"] .ah-chip-legal { padding: 4px 0; bottom: 28px; right: 28px; }
326
+ .ah-chip-legal a {
327
+ color: inherit; text-decoration: none;
328
+ transition: color 140ms;
329
+ }
330
+ .ah-chip-legal a:hover { color: var(--ah-legal-fg-hover); }
331
+ .ah-chip-legal .ah-sep { color: var(--ah-legal-sep); }
332
+
333
+ /* ============= PHONE FOOTER BAR =============
334
+ On a phone the corner chips have nowhere to float: the widget fills the
335
+ screen, so a fixed pill either covers the form or gets dropped (which
336
+ is what used to happen below 480px — terms and the trust mark vanished
337
+ and the settings chip sat on top of the card). The footer collects the
338
+ same four pieces into one strip along the bottom edge.
339
+
340
+ Always in the DOM, revealed only under the mobile breakpoint, so the
341
+ same markup serves both layouts with no user-agent sniffing. */
342
+ .ah-footer {
343
+ display: none;
344
+ position: fixed;
345
+ left: 0; right: 0; bottom: 0;
346
+ z-index: 20;
347
+ align-items: center;
348
+ justify-content: space-between;
349
+ gap: 8px;
350
+ padding: 6px 12px;
351
+ /* Clear the home indicator on notched phones. */
352
+ padding-bottom: max(6px, env(safe-area-inset-bottom, 6px));
353
+ background: var(--ah-footer-bg);
354
+ border-top: 1px solid var(--ah-footer-border);
355
+ backdrop-filter: blur(20px);
356
+ -webkit-backdrop-filter: blur(20px);
357
+ color: var(--ah-footer-fg);
358
+ font-size: 12px;
359
+ transition: background 200ms ease, color 200ms ease, border-color 200ms ease;
360
+ }
361
+ .ah-footer-left, .ah-footer-right {
362
+ display: inline-flex;
363
+ align-items: center;
364
+ gap: 8px;
365
+ min-width: 0;
366
+ }
367
+ /* Space is tight on a phone (a 375px bar has ~330px of usable width).
368
+ Priority order when it runs out: the controls and the terms link hold
369
+ their size, and the trust mark — decorative, and the only item that
370
+ scales without becoming unreadable — shrinks to fit. */
371
+ .ah-footer-left { flex: 1 1 auto; overflow: hidden; }
372
+ .ah-footer-right { flex: 0 0 auto; }
373
+ .ah-footer-trust { flex: 0 1 auto; min-width: 0; display: inline-flex; }
374
+ .ah-footer-trust a { display: inline-flex; align-items: center; min-width: 0; }
375
+ .ah-footer-trust img {
376
+ display: block;
377
+ max-width: 100%;
378
+ height: auto;
379
+ max-height: 14px;
380
+ opacity: 0.8;
381
+ }
382
+ /* Below this the bar can't hold a logo and a legal link at a legible
383
+ size, and the legal link is the one that has to stay. */
384
+ @media (max-width: 359px) {
385
+ .ah-footer-trust { display: none; }
386
+ }
387
+ .ah-footer-terms {
388
+ flex: 0 0 auto;
389
+ color: inherit;
390
+ text-decoration: none;
391
+ white-space: nowrap;
392
+ transition: color 140ms;
393
+ }
394
+ .ah-footer-terms:hover { color: var(--ah-footer-fg-strong); }
395
+ .ah-footer button,
396
+ .ah-footer .ah-lang {
397
+ background: 0; border: 0; padding: 7px 8px; cursor: pointer;
398
+ color: inherit;
399
+ font-size: 12px; font-weight: 500;
400
+ min-height: 34px;
401
+ border-radius: 10px;
402
+ display: inline-flex; align-items: center; gap: 5px;
403
+ transition: 140ms;
404
+ }
405
+ .ah-footer button:hover,
406
+ .ah-footer .ah-lang:hover,
407
+ .ah-footer button:focus-visible,
408
+ .ah-footer .ah-lang:focus-within {
409
+ color: var(--ah-footer-fg-strong);
410
+ background: var(--ah-chip-active-bg);
411
+ }
412
+ /* The globe is redundant beside a language name and costs ~18px of a
413
+ budget that doesn't have it — the chip keeps it, the footer doesn't. */
414
+ .ah-footer .ah-lang > svg { display: none; }
415
+ .ah-footer .ah-lang select {
416
+ appearance: none; -webkit-appearance: none;
417
+ background: transparent; color: inherit; border: 0;
418
+ font: inherit; cursor: pointer; padding: 0;
419
+ outline: 0;
420
+ }
421
+
422
+ /* ============= PER-SLOT CHIP STYLE OVERRIDES =============
423
+ Templates can force a chip's surface via the slot tag's style arg
424
+ (e.g. {%- authhero:legal style="plain" -%}). Without it chips follow
425
+ the data-bg default (pill with a background image, plain on a solid
426
+ background). These modifiers re-point the surface tokens directly on
427
+ the element, so they win over the inherited data-bg values. */
428
+ .ah-chip--pill {
429
+ --ah-chip-bg: var(--ah-chip-bg-pill);
430
+ --ah-chip-bg-hover: var(--ah-chip-bg-hover-pill);
431
+ --ah-chip-border: var(--ah-chip-border-pill);
432
+ --ah-chip-logo-bg: var(--ah-chip-logo-bg-pill);
433
+ backdrop-filter: blur(20px);
434
+ -webkit-backdrop-filter: blur(20px);
435
+ }
436
+ .ah-chip-legal.ah-chip--pill { padding: 7px 14px; bottom: 24px; right: 24px; }
437
+
438
+ .ah-chip--plain {
439
+ --ah-chip-bg: transparent;
440
+ --ah-chip-bg-hover: transparent;
441
+ --ah-chip-border: transparent;
442
+ --ah-chip-logo-bg: transparent;
443
+ backdrop-filter: none;
444
+ -webkit-backdrop-filter: none;
445
+ }
446
+ .ah-chip-legal.ah-chip--plain { padding: 4px 0; bottom: 28px; right: 28px; }
447
+
448
+ /* ============= EXPLICIT DARK MODE FOR WIDGET =============
449
+ The page-level dark/light is controlled by data-mode (above).
450
+ The widget itself has its own --ah-color-* vars set via JS.
451
+ html.ah-dark-mode is the legacy class kept for the widget toggle. */
452
+ ${darkModeCssVarRules("html.ah-dark-mode authhero-widget", primaryColor || themePrimary)}
453
+ @media (prefers-color-scheme: dark) {
454
+ ${darkModeCssVarRules("html:not(.ah-light-mode) authhero-widget", primaryColor || themePrimary)}
455
+ }
456
+
457
+ /* ============= MOBILE =============
458
+ Chrome chips minimize, and the card either floats on the page
459
+ background image or fills the viewport when there isn't one — the
460
+ two variants are emitted conditionally at the end of this block.
461
+
462
+ The !important flags are load-bearing: the body-fragment path sets
463
+ the body layout inline (see buildBodyLayout) and the widget
464
+ container's width inline (see buildWidgetContainerStyle), and an
465
+ inline declaration outranks a normal rule from this stylesheet.
466
+
467
+ Below this width a page_layout of "left"/"right" collapses to
468
+ centered: the 80px offset only reads as a deliberate composition when
469
+ there's page background left over beside the widget. On a phone or a
470
+ narrow window the offset just pushes a 400px card off-centre (and, at
471
+ the low end, off-screen). */
472
+ @media (max-width: 767px) {
473
+ body { justify-content: center !important; padding: 20px !important; }
474
+ }
475
+ @media (max-width: 480px) {
476
+ /* Full width in both variants: overrides the inline
477
+ clamp(320px, 100%, 400px), which would otherwise hold the card at
478
+ 400px with stray gutters on a 400-480px phone. */
479
+ .widget-container { width: 100% !important; }
480
+ .ah-widget-stack { width: 100% !important; }
481
+ /* Every corner chip gives way to the footer bar, which carries the
482
+ same content in a strip that can't overlap the form. */
483
+ .ah-chip, .ah-chip-legal { display: none; }
484
+ .ah-footer { display: flex; }
485
+ }
486
+ ${hasBgImage
487
+ ? `
488
+ /* Phone, WITH a page background image — the card floats.
489
+
490
+ The image is the tenant's branding; going full-bleed here would hide
491
+ it entirely on the device most sign-ins come from. So the body keeps
492
+ its background (no override), the tint overlay stays for chip
493
+ legibility, and the card keeps a gutter so the image reads around it.
494
+ The widget's own stylesheet is told to match via the \`floating\`
495
+ attribute (see renderWidgetSSR). */
496
+ @media (max-width: 480px) {
497
+ /* Extra bottom padding keeps the floating card clear of the footer. */
498
+ body { padding: 20px 20px 76px !important; }
499
+ .ah-bg-tint { display: block; }
500
+ }`
501
+ : `
502
+ /* Phone, NO page background image — the card fills the screen.
503
+
504
+ With only a flat colour behind it there is nothing to show around the
505
+ card, so it goes edge-to-edge and the page background collapses into
506
+ the widget's own colour. Matches the widget stylesheet's default
507
+ (non-\`floating\`) mobile layout. */
508
+ @media (max-width: 480px) {
509
+ body { background: ${widgetBackground} !important; padding: 0 !important; }
510
+ html.ah-dark-mode body { background: #111827 !important; }
511
+ .ah-bg-tint { display: none; }
512
+ /* The full-bleed widget is exactly 100vh, so pad its bottom (a
513
+ light-DOM rule on the host beats the :host rule in its shadow tree)
514
+ to stop a tall form scrolling under the fixed footer. The footer's
515
+ own bottom padding grows with the safe-area inset, so the clearance
516
+ has to track it or the footer covers the last field on a notched
517
+ phone — mirror that \`max()\` rather than hard-coding 60px. */
518
+ authhero-widget {
519
+ padding-bottom: calc(54px + max(6px, env(safe-area-inset-bottom, 6px)));
520
+ }
521
+ }`}
522
+ `;
523
+ }
@@ -0,0 +1,121 @@
1
+ /**
2
+ * HTML/CSS escaping and sanitization for the page chrome.
3
+ *
4
+ * Self-contained on purpose: this module is consumed both by the authhero
5
+ * server (which has its own copy of these primitives in
6
+ * `sanitization-utils.ts` for its other routes) and by the widget demo
7
+ * server. Security primitives are deliberately NOT shared across the package
8
+ * boundary — they are small, stable, and a broken import here would silently
9
+ * weaken escaping on every login page.
10
+ */
11
+ /** Escape HTML special characters to prevent XSS. */
12
+ export function escapeHtml(str) {
13
+ return str
14
+ .replace(/&/g, "&amp;")
15
+ .replace(/</g, "&lt;")
16
+ .replace(/>/g, "&gt;")
17
+ .replace(/"/g, "&quot;")
18
+ .replace(/'/g, "&#x27;");
19
+ }
20
+ /**
21
+ * Escape a URL for safe use inside a CSS `url("...")` function, so a crafted
22
+ * image URL can't break out of the url() context and inject rules.
23
+ */
24
+ export function escapeCssUrl(url) {
25
+ return url
26
+ .replace(/\\/g, "\\\\")
27
+ .replace(/"/g, '\\"')
28
+ .replace(/'/g, "\\'")
29
+ .replace(/\(/g, "\\(")
30
+ .replace(/\)/g, "\\)")
31
+ .replace(/\n/g, "")
32
+ .replace(/\r/g, "")
33
+ .replace(/\t/g, "");
34
+ }
35
+ /** True for URLs we're willing to emit: http(s)/data, or root-relative. */
36
+ function isAllowedUrl(url) {
37
+ try {
38
+ return ["http:", "https:", "data:"].includes(new URL(url).protocol);
39
+ }
40
+ catch {
41
+ return url.startsWith("/");
42
+ }
43
+ }
44
+ /** Sanitize a URL for use in href/src attributes. Returns "" if unsafe. */
45
+ export function sanitizeUrl(url) {
46
+ if (!url)
47
+ return "";
48
+ return isAllowedUrl(url) ? escapeHtml(url) : "";
49
+ }
50
+ /**
51
+ * Sanitize a URL for use inside a CSS `url("...")`. Returns "" if unsafe.
52
+ *
53
+ * Deliberately not `sanitizeUrl` + `escapeCssUrl`: HTML entities are never
54
+ * decoded inside a `<style>` element, so HTML-escaping here would put the
55
+ * literal text `&amp;` into the stylesheet and corrupt every query string it
56
+ * touches — a signed `?sig=x&expires=y` would be requested as
57
+ * `?sig=x&amp;expires=y` and the image would fail to load.
58
+ *
59
+ * Angle brackets still have to go, or a `</style>` inside the URL would end
60
+ * the element and let the rest of it parse as markup. They are percent-encoded
61
+ * rather than entity-encoded so the URL stays a valid URL.
62
+ */
63
+ export function sanitizeCssUrl(url) {
64
+ if (!url)
65
+ return "";
66
+ if (!isAllowedUrl(url))
67
+ return "";
68
+ return escapeCssUrl(url.replace(/</g, "%3C").replace(/>/g, "%3E"));
69
+ }
70
+ /** Sanitize a CSS color value. Returns "" if it doesn't look like a color. */
71
+ export function sanitizeCssColor(color) {
72
+ if (!color)
73
+ return "";
74
+ const safeColorPattern = /^(#[0-9a-fA-F]{3,8}|rgba?\([^)]+\)|hsla?\([^)]+\)|[a-zA-Z]+)$/;
75
+ if (safeColorPattern.test(color.trim())) {
76
+ return color.trim();
77
+ }
78
+ return "";
79
+ }
80
+ /** Build a CSS `background` value from a branding page_background. */
81
+ export function buildPageBackground(pageBackground) {
82
+ if (!pageBackground)
83
+ return "#f5f5f5";
84
+ if (typeof pageBackground === "string") {
85
+ return sanitizeCssColor(pageBackground) || "#f5f5f5";
86
+ }
87
+ const { type, start, end, angle_deg } = pageBackground;
88
+ if (type === "linear-gradient" && start && end) {
89
+ const sanitizedStart = sanitizeCssColor(start);
90
+ const sanitizedEnd = sanitizeCssColor(end);
91
+ if (sanitizedStart && sanitizedEnd) {
92
+ const angle = typeof angle_deg === "number" ? angle_deg : 180;
93
+ return `linear-gradient(${angle}deg, ${sanitizedStart}, ${sanitizedEnd})`;
94
+ }
95
+ }
96
+ if (start) {
97
+ const sanitizedColor = sanitizeCssColor(start);
98
+ if (sanitizedColor)
99
+ return sanitizedColor;
100
+ }
101
+ return "#f5f5f5";
102
+ }
103
+ /**
104
+ * Build a CSS `background` value from a theme page_background, falling back
105
+ * to the branding one. Supports `background_image_url`.
106
+ */
107
+ export function buildThemePageBackground(themePageBackground, fallbackBrandingBackground) {
108
+ if (themePageBackground?.background_image_url) {
109
+ const imageUrl = sanitizeCssUrl(themePageBackground.background_image_url);
110
+ if (imageUrl) {
111
+ const bgColor = sanitizeCssColor(themePageBackground.background_color) || "#f5f5f5";
112
+ return `${bgColor} url("${imageUrl}") center / cover no-repeat`;
113
+ }
114
+ }
115
+ if (themePageBackground?.background_color) {
116
+ const bgColor = sanitizeCssColor(themePageBackground.background_color);
117
+ if (bgColor)
118
+ return bgColor;
119
+ }
120
+ return buildPageBackground(fallbackBrandingBackground);
121
+ }
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Login page chrome — the furniture around the widget card.
3
+ *
4
+ * Shared between the authhero login page (which renders it for real) and the
5
+ * widget demo server (which previews it), so the two can't drift. Everything
6
+ * here is framework-free: plain strings in, plain strings out, no JSX runtime
7
+ * and no i18n stack.
8
+ */
9
+ export * from "./types";
10
+ export * from "./html";
11
+ export * from "./colors";
12
+ export * from "./chrome";
13
+ export * from "./css";
@@ -0,0 +1 @@
1
+ export {};