@authhero/widget 0.38.8 → 0.38.10

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